mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
304 lines
9.0 KiB
Go
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"
|
|
}
|
|
}
|