Files

304 lines
9.0 KiB
Go

// Dashboard AI agent endpoints. Sessions and their message runs are per-user;
// message and approval runs stream over SSE (text deltas, tool step events,
// approval_required, done with credits_remaining). The run executes in the
// request context, so a client that aborts the fetch cancels the run (the stop
// mechanism). Tools execute AS the invoking member with their org permission
// bits enforced by the registry.
package handler
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/aiagent"
"github.com/warmbly/warmbly/internal/app/aitools"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/utils/paging"
)
// jwtInvocation builds a tool invocation for a JWT (dashboard) caller: it runs
// as the member with their org permission bits, never an API key.
func (h *Handler) jwtInvocation(c *gin.Context) (aitools.Invocation, *errx.Error) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
return aitools.Invocation{}, errx.New(errx.BadRequest, "no organization selected")
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
return aitools.Invocation{}, errx.New(errx.Unauthorized, "invalid user")
}
member, xerr := h.OrganizationService.GetMembership(c.Request.Context(), *orgID, userID)
if xerr != nil || member == nil {
return aitools.Invocation{}, errx.New(errx.Forbidden, "not a member of this organization")
}
return aitools.Invocation{
OrgID: *orgID,
UserID: userID,
OrgPerms: member.Permissions,
IsAPIKey: false,
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}, nil
}
// CreateAgentSession — POST /ai/sessions
func (h *Handler) CreateAgentSession(c *gin.Context) {
if h.AIAgentService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
return
}
inv, xerr := h.jwtInvocation(c)
if xerr != nil {
errx.JSON(c, xerr)
return
}
var req struct {
Page string `json:"page"`
Resource string `json:"resource"`
}
_ = c.ShouldBindJSON(&req)
sess, serr := h.AIAgentService.CreateSession(c.Request.Context(), inv.OrgID, inv.UserID, req.Page, req.Resource)
if serr != nil {
errx.JSON(c, serr)
return
}
c.JSON(http.StatusOK, sess)
}
// ListAgentSessions — GET /ai/sessions (cursor paginated, newest first)
func (h *Handler) ListAgentSessions(c *gin.Context) {
if h.AIAgentService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
return
}
inv, xerr := h.jwtInvocation(c)
if xerr != nil {
errx.JSON(c, xerr)
return
}
limit, xerr := parseCreditLimit(c.Query("limit"), 25, 100)
if xerr != nil {
errx.JSON(c, xerr)
return
}
beforeAt, beforeID, xerr := paging.DecodeTimeCursor(c.Query("cursor"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
sessions, err := h.AIAgentService.ListSessions(c.Request.Context(), inv.OrgID, inv.UserID, limit+1, beforeAt, beforeID)
if err != nil {
errx.JSON(c, errx.New(errx.Internal, "failed to list sessions"))
return
}
var nextCursor *string
if len(sessions) > limit {
last := sessions[limit-1]
nextCursor = paging.EncodeTime(last.CreatedAt, last.ID)
sessions = sessions[:limit]
}
c.JSON(http.StatusOK, gin.H{
"data": sessions,
"pagination": gin.H{"next_cursor": nextCursor, "has_more": nextCursor != nil},
})
}
// AgentSessionMessages — GET /ai/sessions/:id/messages returns a session's
// hydrated transcript (+ any pending approval) so a reopened tab rehydrates.
func (h *Handler) AgentSessionMessages(c *gin.Context) {
if h.AIAgentService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
return
}
inv, xerr := h.jwtInvocation(c)
if xerr != nil {
errx.JSON(c, xerr)
return
}
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid session id"))
return
}
sess, gerr := h.AIAgentService.GetSession(c.Request.Context(), inv.OrgID, inv.UserID, sessionID)
if gerr != nil || sess == nil {
errx.JSON(c, errx.New(errx.NotFound, "session not found"))
return
}
turns, terr := h.AIAgentService.Transcript(c.Request.Context(), inv.OrgID, inv.UserID, sessionID)
if terr != nil {
errx.JSON(c, terr)
return
}
c.JSON(http.StatusOK, gin.H{
"title": sess.Title,
"turns": turns,
"pending": sess.Context.Pending,
"free_model": sess.Context.FreeModel,
})
}
// DeleteAgentSession — DELETE /ai/sessions/:id removes a conversation and its
// transcript. Sessions are private to the member, so no extra permission gate.
func (h *Handler) DeleteAgentSession(c *gin.Context) {
if h.AIAgentService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
return
}
inv, xerr := h.jwtInvocation(c)
if xerr != nil {
errx.JSON(c, xerr)
return
}
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid session id"))
return
}
if derr := h.AIAgentService.DeleteSession(c.Request.Context(), inv.OrgID, inv.UserID, sessionID); derr != nil {
errx.JSON(c, derr)
return
}
c.JSON(http.StatusOK, gin.H{"message": "session deleted"})
}
// ClearAgentSessions — DELETE /ai/sessions removes the member's entire
// conversation history in this workspace.
func (h *Handler) ClearAgentSessions(c *gin.Context) {
if h.AIAgentService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
return
}
inv, xerr := h.jwtInvocation(c)
if xerr != nil {
errx.JSON(c, xerr)
return
}
n, derr := h.AIAgentService.ClearSessions(c.Request.Context(), inv.OrgID, inv.UserID)
if derr != nil {
errx.JSON(c, derr)
return
}
c.JSON(http.StatusOK, gin.H{"message": "history cleared", "deleted": n})
}
// AgentMessage — POST /ai/sessions/:id/messages (SSE)
func (h *Handler) AgentMessage(c *gin.Context) {
if h.AIAgentService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
return
}
inv, xerr := h.jwtInvocation(c)
if xerr != nil {
errx.JSON(c, xerr)
return
}
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid session id"))
return
}
var req struct {
MessageID string `json:"message_id"`
Text string `json:"text"`
Page string `json:"page"`
Resource string `json:"resource"`
}
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
if req.MessageID == "" {
req.MessageID = uuid.NewString()
}
emit := sseEmitter(c)
if serr := h.AIAgentService.RunMessage(c.Request.Context(), inv, sessionID, req.MessageID, req.Text, req.Page, req.Resource, emit); serr != nil {
emit(aiagent.StreamEvent{Type: "error", Code: string(codeIdentifier(serr)), Message: serr.Message})
}
}
// AgentApprove — POST /ai/sessions/:id/approve (SSE) resumes a paused run.
func (h *Handler) AgentApprove(c *gin.Context) {
if h.AIAgentService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
return
}
inv, xerr := h.jwtInvocation(c)
if xerr != nil {
errx.JSON(c, xerr)
return
}
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid session id"))
return
}
var req struct {
Decision string `json:"decision"` // approve | deny | always_allow
}
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
switch req.Decision {
case "approve", "deny", "always_allow":
default:
errx.JSON(c, errx.New(errx.BadRequest, "decision must be approve, deny, or always_allow"))
return
}
emit := sseEmitter(c)
if serr := h.AIAgentService.Resume(c.Request.Context(), inv, sessionID, req.Decision, emit); serr != nil {
emit(aiagent.StreamEvent{Type: "error", Code: string(codeIdentifier(serr)), Message: serr.Message})
}
}
// sseEmitter prepares the response for Server-Sent Events and returns a
// flush-per-event emitter. Safe to call once per request.
func sseEmitter(c *gin.Context) func(aiagent.StreamEvent) {
h := c.Writer.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no") // disable proxy buffering so deltas flush
c.Writer.WriteHeader(http.StatusOK)
flusher, _ := c.Writer.(http.Flusher)
if flusher != nil {
flusher.Flush()
}
return func(ev aiagent.StreamEvent) {
b, err := json.Marshal(ev)
if err != nil {
return
}
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", b)
if flusher != nil {
flusher.Flush()
}
}
}
// codeIdentifier gives a short machine code for an errx to surface in the SSE
// error event.
func codeIdentifier(e *errx.Error) string {
switch e.Code {
case errx.NotFound:
return "not_found"
case errx.BadRequest:
return "bad_request"
case errx.Forbidden:
return "forbidden"
case errx.ServiceUnavailable:
return "service_unavailable"
default:
return "error"
}
}