// Package studio has the HTTP handlers of the chats API: conversations the // server keeps, with steering, sub-agents and live events. package studio import ( "context" "errors" "net/http" "strconv" "strings" "time" "github.com/labstack/echo/v4" "github.com/mudler/xlog" "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/schema" studiosvc "github.com/mudler/LocalAI/core/services/studio" "github.com/mudler/LocalAI/core/services/studio/agents" "github.com/mudler/LocalAI/core/services/studio/files" "github.com/mudler/LocalAI/core/services/studio/pages" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/store" "github.com/mudler/LocalAI/core/services/studio/workspace" ) // Errors a ModelChecker returns. var ( // ErrUnknownModel means no model has this name. ErrUnknownModel = errors.New("unknown model") // ErrModelNotAllowed means the user may not use the model. ErrModelNotAllowed = errors.New("model not allowed") ) // ModelChecker says whether a user may start a conversation with a model. type ModelChecker interface { Check(ctx context.Context, user *auth.User, model string) error } // ModelCheckerFunc adapts a function to ModelChecker. type ModelCheckerFunc func(ctx context.Context, user *auth.User, model string) error // Check implements ModelChecker. func (f ModelCheckerFunc) Check(ctx context.Context, user *auth.User, model string) error { return f(ctx, user, model) } // Handlers serves the conversation API. type Handlers struct { svc *studiosvc.Service models ModelChecker pages *pages.Service trust *pages.Trust // previews keeps the recent file previews of this frontend. previews *previewCache } // New returns the handlers. A nil models skips the model check. func New(svc *studiosvc.Service, models ModelChecker) *Handlers { h := &Handlers{svc: svc, models: models, previews: newPreviewCache()} if svc != nil { h.pages = pages.New(svc.Files()) store := svc.PageConsents() if store == nil { store, _ = pages.NewJSONConsents("") } h.trust = pages.NewTrust(h.pages, store) } return h } // maxAttachBytes caps one message with its attachments. It applies when the // operator set no --upload-limit; with one, the general body limit is lower // and wins (see bodyLimitSkipper in core/http). Each file is also held to the // workspace file size. const maxAttachBytes = 1 << 30 const ( defaultListLimit = 50 maxListLimit = 200 defaultMessages = 200 maxMessages = 1000 heartbeat = 15 * time.Second ) func userID(c echo.Context) string { if u := auth.GetUser(c); u != nil { return u.ID } return "" } // feature is the guard of the endpoints of a feature that can be off: it // returns host when the feature is on. When host is nil the feature is off: it // writes the answer of off and returns false, and the handler returns nil, as // after a failed bind. func feature[T any](c echo.Context, host *T, off func(echo.Context) error) (*T, bool) { if host == nil { _ = off(c) return nil, false } return host, true } func fail(c echo.Context, status int, typ, msg string) error { return c.JSON(status, schema.ErrorResponse{Error: &schema.APIError{Message: msg, Code: status, Type: typ}}) } // chatRules maps the errors of the chat service. Another user's conversation // is the same answer as a missing one. var chatRules = []errRule{ fixed(http.StatusNotFound, typeNotFound, "conversation not found", session.ErrNotFound), fixed(http.StatusConflict, typeConflict, "the message was already delivered to the model", store.ErrDelivered), fixed(http.StatusNotFound, typeNotFound, "agent template not found", studiosvc.ErrNoTemplate), fixed(http.StatusConflict, typeConflict, "a turn is running; stop it first", studiosvc.ErrChatBusy), fixed(http.StatusConflict, "agent_folder_limit", "This agent cannot start because its file access exceeds the folder limit. Ask an administrator to reduce the shared folders available to you, then try again.", studiosvc.ErrAgentFolderLimit), fixed(http.StatusConflict, "agent_folder_missing", "The folder of this agent is missing. Choose a folder in the agent's settings.", studiosvc.ErrAgentFolderMissing), verbatim(http.StatusBadRequest, typeInvalid, "studio: ", studiosvc.ErrNothingToRegenerate, studiosvc.ErrNotUserMessage, studiosvc.ErrInvalidSettings, studiosvc.ErrBadImport, session.ErrInvalid), verbatim(http.StatusBadRequest, typeInvalid, "", studiosvc.ErrBadSkillName, studiosvc.ErrBadAddonID, studiosvc.ErrBadMCPSwitch, studiosvc.ErrBadBuiltinGroup), verbatim(http.StatusConflict, typeFilesOff, "", files.ErrUnavailable), fixed(http.StatusRequestEntityTooLarge, typeInvalid, "an attachment is too large", workspace.ErrTooLarge), fixed(http.StatusConflict, typeConflict, "a sub-agent conversation takes no messages from you", studiosvc.ErrSubAgentReadOnly), verbatim(http.StatusBadRequest, typeInvalid, "", studiosvc.ErrNoModel), sentinelText(http.StatusBadRequest, typeInvalid, studiosvc.ErrBadCommand), verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrCommandBusy), verbatim(http.StatusBadRequest, typeInvalid, "studio: ", studiosvc.ErrNothingToCompact, studiosvc.ErrNothingToClear, studiosvc.ErrCannotCompact), fixed(http.StatusNotFound, typeNotFound, "agent not found", agents.ErrNotFound), fixed(http.StatusConflict, typeConflict, "you already have an agent with this name", agents.ErrNameTaken), verbatim(http.StatusBadRequest, typeInvalid, agents.ErrInvalid.Error()+": ", agents.ErrInvalid), sentinelText(http.StatusBadRequest, typeInvalid, studiosvc.ErrAgentFolder), verbatim(http.StatusConflict, typeNotifyOff, "", studiosvc.ErrNotificationsOff), fixed(http.StatusForbidden, typeAuthz, "the LocalAI assistant needs an administrator", studiosvc.ErrAssistantForbidden), fixed(http.StatusServiceUnavailable, "assistant_off", "the LocalAI assistant is switched off on this server", studiosvc.ErrAssistantOff), fixed(http.StatusBadRequest, typeInvalid, "an assistant chat cannot name a saved agent", studiosvc.ErrAssistantAgent), verbatim(http.StatusConflict, typeAgentsOff, "", studiosvc.ErrAgentsOff), fixed(http.StatusForbidden, typeAuthz, "only an administrator can allow commands on this machine; anyone can refuse them", studiosvc.ErrHostExecDecision), fixed(http.StatusBadRequest, typeInvalid, "the body must be {id, decision} with decision once or deny", studiosvc.ErrHostExecBadDecision), fixed(http.StatusGone, "consent_expired", "the card expired: the turn that asked has ended, so the command did not run", studiosvc.ErrHostExecExpired), fixed(http.StatusConflict, "already_answered", "the card was answered already with the other answer", studiosvc.ErrHostExecAnswered), fixed(http.StatusBadRequest, "egress_above_ceiling", "the preset is above the limit an administrator set", studiosvc.ErrEgressAboveCeiling), fixed(http.StatusBadRequest, typeInvalid, "the preset must be off, registries or all", studiosvc.ErrEgressPreset), fixed(http.StatusNotFound, typeNotFound, "no such network rule", studiosvc.ErrEgressNoRule), fixed(http.StatusNotFound, typeNotFound, "no such network card", studiosvc.ErrEgressNoCard), fixed(http.StatusBadRequest, typeInvalid, "the answer must be once, chat or deny", studiosvc.ErrEgressBadDecision), fixed(http.StatusConflict, "already_answered", "the network card was answered already with another answer", studiosvc.ErrEgressAnswered), fixed(http.StatusBadRequest, "egress_above_ceiling", "the limit an administrator set for the Sandbox network does not admit this host; an administrator can raise it on the Settings tab of the add-on that runs the commands", studiosvc.ErrEgressHostAboveCeiling), fixed(http.StatusForbidden, typeAuthz, "only an administrator can allow a host inside your network; anyone can deny it", studiosvc.ErrEgressDecision), verbatim(http.StatusConflict, typeConflict, "", studiosvc.ErrGoalNotAllowed, studiosvc.ErrGoalReached), verbatim(http.StatusNotFound, typeNotFound, "", studiosvc.ErrNoGoal), verbatim(http.StatusBadRequest, typeInvalid, "", studiosvc.ErrGoalText), } // failFor maps a service error to a response. func failFor(c echo.Context, err error) error { return mapErr(c, err, chatRules, func(c echo.Context, err error) error { xlog.Error("studio: request failed", "path", c.Request().URL.Path, "error", err) return fail(c, http.StatusInternalServerError, typeServerError, "the request failed") }) } func conversation(rec session.ChatRecord, info store.TurnInfo) schema.ChatsConversation { return schema.ChatsConversation{ ID: rec.ID, Title: rec.Title, Model: rec.Model, Kind: rec.Kind, ParentID: rec.ParentID, AgentID: rec.AgentID, Origin: rec.Origin, OriginID: rec.OriginID, Assistant: rec.Assistant, Created: rec.Created, Updated: rec.Updated, Running: info.Running, LastTurn: string(info.Last), Settings: settingsJSON(rec.Settings), } } func (h *Handlers) describe(c echo.Context, user string, rec session.ChatRecord) (schema.ChatsConversation, error) { info, err := h.svc.Turn(c.Request().Context(), user, rec.ID) if err != nil { return schema.ChatsConversation{}, err } conv := conversation(rec, info) conv.Goal = h.goalJSON(rec.Goal) return conv, nil } // Create starts a conversation. // @Summary Start a chat. // @Tags chats // @Description With switches, the chat starts with those tools, add-ons and skills switched off (and those opt-in tools on), as GET /api/chats/draft/tools and GET /api/chats/skills list them for a chat that does not exist yet. A bad switch refuses the request and no chat is created. // @Param request body schema.ChatsCreateRequest true "model and optional title, agent and switches" // @Success 201 {object} schema.ChatsConversation "The new conversation" // @Failure 400 {object} schema.ErrorResponse "The request is not valid" // @Failure 403 {object} schema.ErrorResponse "Refused" // @Failure 409 {object} schema.ErrorResponse "The saved agent's folder is missing or not chosen (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)" // @Router /api/chats [post] func (h *Handlers) Create(c echo.Context) error { var req schema.ChatsCreateRequest if err := c.Bind(&req); err != nil { return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") } if req.Assistant { // The service checks the role again from its user lookup; this answer // comes first so a refusal never depends on that lookup being set. if u := auth.GetUser(c); u != nil && u.Role != auth.RoleAdmin { return failFor(c, studiosvc.ErrAssistantForbidden) } } if req.AgentID != "" && !req.Assistant && !auth.FeatureAllowed(c, auth.FeatureAgents) { return fail(c, http.StatusForbidden, "authorization_error", "feature not enabled for your account: "+auth.FeatureAgents) } model := req.Model if req.AgentID != "" && !req.Assistant { ag, err := h.svc.Agent(c.Request().Context(), userID(c), req.AgentID) if err != nil { return failFor(c, err) } if ag.Model != "" { model = ag.Model } } if model == "" { return fail(c, http.StatusBadRequest, "invalid_request_error", "model is required") } if h.models != nil { switch err := h.models.Check(c.Request().Context(), auth.GetUser(c), model); { case errors.Is(err, ErrUnknownModel): return fail(c, http.StatusBadRequest, "invalid_request_error", "unknown model") case errors.Is(err, ErrModelNotAllowed): return fail(c, http.StatusForbidden, "authorization_error", "you may not use this model") case err != nil: return failFor(c, err) } } user := userID(c) in := studiosvc.CreateInput{Title: req.Title, Model: req.Model, AgentID: req.AgentID, Assistant: req.Assistant} if sw := req.Switches; sw != nil { in.Switches = studiosvc.Switches{AddonsOff: sw.AddonsOff, McpOff: sw.McpOff, BuiltinsOff: sw.BuiltinsOff, BuiltinsOn: sw.BuiltinsOn, SkillsOff: sw.SkillsOff} } rec, err := h.svc.Create(c.Request().Context(), user, in) if err != nil { return failFor(c, err) } out, err := h.describe(c, user, rec) if err != nil { return failFor(c, err) } return c.JSON(http.StatusCreated, out) } // List returns the user's conversations, newest first. // @Summary List chats. // @Tags chats // @Param limit query int false "most conversations to return (default 50, at most 200)" // @Param kind query string false "with chat, sub-agent runs are left out and the limit counts chats" // @Success 200 {object} schema.ChatsConversationList "Conversations, including sub-agent runs unless kind=chat" // @Router /api/chats [get] func (h *Handlers) List(c echo.Context) error { limit := intParam(c, "limit", defaultListLimit, maxListLimit) user := userID(c) list := h.svc.List if c.QueryParam("kind") == "chat" { list = h.svc.ListChats } recs, err := list(c.Request().Context(), user, limit) if err != nil { return failFor(c, err) } out := schema.ChatsConversationList{Conversations: make([]schema.ChatsConversation, 0, len(recs)), Persistent: h.svc.Persistent(), Files: h.svc.Files().Available(), FilesReason: h.svc.Files().Unavailable(), ImageModel: h.svc.ImageModel()} for _, rec := range recs { conv, err := h.describe(c, user, rec) if err != nil { return failFor(c, err) } out.Conversations = append(out.Conversations, conv) } return c.JSON(http.StatusOK, out) } // Get returns one conversation with its waiting messages. // @Summary Get a chat. // @Tags chats // @Param id path string true "conversation id" // @Success 200 {object} schema.ChatsConversationDetail "The conversation and its pending messages" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Router /api/chats/{id} [get] func (h *Handlers) Get(c echo.Context) error { user, id := userID(c), c.Param("id") rec, err := h.svc.Get(c.Request().Context(), user, id) if err != nil { return failFor(c, err) } info, err := h.svc.Turn(c.Request().Context(), user, rec.ID) if err != nil { return failFor(c, err) } conv := conversation(rec, info) conv.Goal = h.goalJSON(rec.Goal) pend, err := h.svc.Pending(c.Request().Context(), user, id) if err != nil { return failFor(c, err) } // Opening a chat is how a run's notifications are read. h.svc.MarkChatNotificationsRead(c.Request().Context(), user, id) out := schema.ChatsConversationDetail{ChatsConversation: conv, Pending: make([]schema.ChatsPending, 0, len(pend))} for _, p := range pend { out.Pending = append(out.Pending, pendingOf(p)) } // The card waits only while the turn that asked holds the chat; a card a // dead frontend left, also under a later turn, is not shown. if hc := h.svc.HostConsent(c.Request().Context(), user, rec, info); hc != nil { out.HostConsent = &schema.ChatsHostConsent{ID: hc.ID, Command: hc.Command} } out.Loops = []schema.ChatsLoop{} if host := h.svc.Loops(); host != nil { list, err := host.List(c.Request().Context(), user, id) if err != nil { return failFor(c, err) } out.Loops = loopsJSON(list) } return c.JSON(http.StatusOK, out) } // Delete removes a conversation. // @Summary Delete a chat. // @Tags chats // @Param id path string true "conversation id" // @Success 200 {object} map[string]bool "deleted" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Router /api/chats/{id} [delete] func (h *Handlers) Delete(c echo.Context) error { if err := h.svc.Delete(c.Request().Context(), userID(c), c.Param("id")); err != nil { return failFor(c, err) } return c.JSON(http.StatusOK, map[string]bool{"deleted": true}) } // Messages returns the transcript. // @Summary Read a chat's transcript. // @Tags chats // @Param id path string true "conversation id" // @Param after query int false "only lines after this sequence number" // @Param limit query int false "most lines to return (default 200, at most 1000)" // @Success 200 {object} schema.ChatsMessageList "Transcript lines in order" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Router /api/chats/{id}/messages [get] func (h *Handlers) Messages(c echo.Context) error { after, _ := strconv.ParseInt(c.QueryParam("after"), 10, 64) entries, err := h.svc.Messages(c.Request().Context(), userID(c), c.Param("id"), after, intParam(c, "limit", defaultMessages, maxMessages)) if err != nil { return failFor(c, err) } entries = h.svc.ResolveAttachments(c.Request().Context(), userID(c), c.Param("id"), entries) return c.JSON(http.StatusOK, messageList(h.svc.Principal(userID(c)), entries)) } // attachmentsOf maps the files of a message to the API's shape. func attachmentsOf(atts []session.Attachment) []schema.ChatsAttachment { if len(atts) == 0 { return nil } out := make([]schema.ChatsAttachment, len(atts)) for i, a := range atts { out[i] = schema.ChatsAttachment{Path: a.Path, Name: a.Name, Size: a.Size, Mime: a.Mime, Version: a.Version, Missing: a.Missing} } return out } // pendingOf is a waiting message as the person reads it. It holds the text // the model will get, so the files are split out of it. func pendingOf(p store.Pending) schema.ChatsPending { text, _, atts := session.SplitMessage(p.Text) return schema.ChatsPending{ID: p.ID, Text: text, Attachments: attachmentsOf(atts), Seq: p.Seq} } // homePaths turns the workspace paths of a checkpoint line into paths inside // the user's home, one a line, and drops the others. func homePaths(principal, text string) string { home := session.HomePath(principal) var out []string for p := range strings.SplitSeq(text, "\n") { if rel, ok := strings.CutPrefix(p, home+"/"); ok && rel != "" { out = append(out, "/"+rel) } } return strings.Join(out, "\n") } func messageList(principal string, entries []session.Entry) schema.ChatsMessageList { out := schema.ChatsMessageList{Messages: make([]schema.ChatsMessage, 0, len(entries))} for _, e := range entries { // A user line shows the words; the files go apart, also for a line // stored with the list of files in its text. e = session.DisplayEntry(e) m := schema.ChatsMessage{ Seq: e.Seq, Turn: e.Turn, Role: e.Role, Text: e.Text, Attachments: attachmentsOf(e.Attachments), DurMs: e.Dur.Milliseconds(), At: e.At, } if e.Loop != nil { m.Loop = &schema.ChatsLoopMark{ID: e.Loop.ID, Run: e.Loop.Run} } if e.Card != nil { m.App = appJSON(e.Card.App, true) m.Card = toolCardJSON(e.Card) } if e.Role == session.RoleCheckpoint { m.Rev, m.Text = e.Rev, homePaths(principal, e.Text) } if e.Invoked != nil { m.Invoked = &schema.ChatsInvocation{Kind: e.Invoked.Kind, Name: e.Invoked.Name, ID: e.Invoked.ID} } if e.Role == session.RoleCompacted { m.Before, m.After, _ = session.ParseCompacted(e.Text) } if e.Role == session.RoleGoal { m.GoalEvent, m.GoalTurn = e.Note, e.Rev } out.Messages = append(out.Messages, m) } return out } // Send gives the conversation a message. // @Summary Send a message to a chat. // @Description With no turn running the message starts one. With a turn running it waits and reaches the model, with any others, at its next request. // @Tags chats // @Param id path string true "conversation id" // @Description Send JSON {"text"}, or multipart/form-data with a text field and files to attach. Files are saved in the chat's folder of your workspace. With a turn running the files are saved at once and the message waits like any other, with their paths in its text. With skill (a skill of the chat) or agent (a saved agent, when the chat has saved agents on) the message runs with that command; such a message does not wait for a running turn (409). An unknown skill or agent is a 400 that lists what the chat has. // @Accept json,mpfd // @Param request body schema.ChatsSendRequest true "the message" // @Param files formData file false "files to attach (multipart)" // @Success 200 {object} schema.ChatsSendResponse "Whether a turn started, or the id of the waiting message" // @Failure 400 {object} schema.ErrorResponse "The request is not valid" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Failure 409 {object} schema.ErrorResponse "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)" // @Router /api/chats/{id}/messages [post] func (h *Handlers) Send(c echo.Context) error { var ( text string up []session.Upload refs []schema.ChatsMCPRef cmd studiosvc.Command ) if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") { c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, maxAttachBytes) form, err := c.MultipartForm() if err != nil { if tooLarge := (*http.MaxBytesError)(nil); errors.As(err, &tooLarge) { return fail(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "a message with attachments is at most 1 GiB") } return fail(c, http.StatusBadRequest, "invalid_request_error", "the attachments could not be read") } defer func() { _ = form.RemoveAll() }() if v := form.Value["text"]; len(v) > 0 { text = v[0] } if v := form.Value["skill"]; len(v) > 0 { cmd.Skill = v[0] } if v := form.Value["agent"]; len(v) > 0 { cmd.Agent = v[0] } if v := form.Value["context"]; len(v) > 0 { var err error if refs, err = parseContext(v[0]); err != nil { return fail(c, http.StatusBadRequest, "invalid_request_error", "context must be a JSON list of prompts and resources") } } for _, fh := range form.File["files"] { f, err := fh.Open() if err != nil { return fail(c, http.StatusBadRequest, "invalid_request_error", "an attachment could not be read") } defer func() { _ = f.Close() }() up = append(up, session.Upload{Name: fh.Filename, Body: f}) } } else { var req schema.ChatsSendRequest if err := c.Bind(&req); err != nil { return fail(c, http.StatusBadRequest, "invalid_request_error", "text is required") } text, refs = req.Text, req.Context cmd = studiosvc.Command{Skill: req.Skill, Agent: req.Agent} } cmd.Skill, cmd.Agent = strings.TrimSpace(cmd.Skill), strings.TrimSpace(cmd.Agent) if text == "" && len(up) == 0 && len(refs) == 0 && cmd.Skill == "" { return fail(c, http.StatusBadRequest, "invalid_request_error", "text is required") } var err error if text, err = h.svc.WithMCPContext(c.Request().Context(), userID(c), c.Param("id"), text, mcpRefs(refs)); err != nil { return failMCPContent(c, err) } res, err := h.svc.SendCommand(c.Request().Context(), userID(c), c.Param("id"), text, up, cmd) if err != nil { return failFor(c, err) } return c.JSON(http.StatusOK, schema.ChatsSendResponse{Started: res.Started, PendingID: res.PendingID}) } // EditPending changes a waiting message. // @Summary Edit a message that waits for the model. // @Tags chats // @Param id path string true "conversation id" // @Param pid path string true "pending message id" // @Param request body schema.ChatsPendingEdit true "the new text" // @Success 200 {object} map[string]bool "updated" // @Failure 400 {object} schema.ErrorResponse "The request is not valid" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Router /api/chats/{id}/pending/{pid} [patch] func (h *Handlers) EditPending(c echo.Context) error { var req schema.ChatsPendingEdit if err := c.Bind(&req); err != nil || req.Text == "" { return fail(c, http.StatusBadRequest, "invalid_request_error", "text is required") } if err := h.svc.EditPending(c.Request().Context(), userID(c), c.Param("id"), c.Param("pid"), req.Text); err != nil { return failFor(c, err) } return c.JSON(http.StatusOK, map[string]bool{"updated": true}) } // CancelPending removes a waiting message. // @Summary Cancel a message that waits for the model. // @Tags chats // @Param id path string true "conversation id" // @Param pid path string true "pending message id" // @Success 200 {object} map[string]bool "cancelled" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Router /api/chats/{id}/pending/{pid} [delete] func (h *Handlers) CancelPending(c echo.Context) error { if err := h.svc.CancelPending(c.Request().Context(), userID(c), c.Param("id"), c.Param("pid")); err != nil { return failFor(c, err) } return c.JSON(http.StatusOK, map[string]bool{"cancelled": true}) } // SendNow starts a turn from the waiting messages. // @Summary Send the waiting messages now. // @Description After a stop or a failed turn the waiting messages stay put. This starts a turn from them. // @Tags chats // @Param id path string true "conversation id" // @Success 200 {object} map[string]bool "sent" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Router /api/chats/{id}/send-now [post] func (h *Handlers) SendNow(c echo.Context) error { if err := h.svc.SendNow(c.Request().Context(), userID(c), c.Param("id")); err != nil { return failFor(c, err) } return c.JSON(http.StatusOK, map[string]bool{"sent": true}) } // Stop ends the running turn. // @Summary Stop the running turn. // @Tags chats // @Param id path string true "conversation id" // @Success 200 {object} map[string]bool "stopped" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Router /api/chats/{id}/stop [post] func (h *Handlers) Stop(c echo.Context) error { if err := h.svc.Stop(c.Request().Context(), userID(c), c.Param("id")); err != nil { return failFor(c, err) } return c.JSON(http.StatusOK, map[string]bool{"stopped": true}) } // Events streams the conversation's live events. // @Summary Stream a chat's events. // @Description Server-sent events: text, reasoning, tool_start, tool_end, checkpoint, turn_start, turn_end, error, pending_delivered, sub_agent_done, compacted (the context was compacted, by the chat or by POST compact; before and after are token counts), cleared (the context was cleared; read the transcript again), paused (the tool loop reached its step limit, chats_max_iterations; steps is the limit), goal (the chat's goal changed; text is its state, empty when cleared: read the chat again), files and loops (the loops of the chat changed; read GET /api/chats/{id}/loops). Events are live only; read the transcript for what came before. // @Tags chats // @Param id path string true "conversation id" // @Produce text/event-stream // @Success 200 {object} schema.ChatsEvent "An event stream" // @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" // @Router /api/chats/{id}/events [get] func (h *Handlers) Events(c echo.Context) error { events, cancel, err := h.svc.Events(c.Request().Context(), userID(c), c.Param("id")) if err != nil { return failFor(c, err) } defer cancel() return streamSSE(c, events, func(ev session.Event) (string, any) { return string(ev.Kind), eventJSON(ev) }) } func eventJSON(ev session.Event) schema.ChatsEvent { out := schema.ChatsEvent{Kind: string(ev.Kind), Turn: ev.Turn, Text: ev.Text, IDs: ev.IDs, Before: ev.Before, After: ev.After, Steps: ev.Steps, Code: ev.Code, Model: ev.Model, Retry: ev.Retry, Detail: ev.Detail} if u := ev.Usage; u != nil { out.Usage = &schema.ChatsUsage{ Used: u.Used, Limit: u.Limit, Completion: u.Completion, TokensPerSecond: u.TokensPerSecond, Model: u.Model, Node: u.Node, PromptMs: u.PromptMillis, } } if ev.Tool != nil { out.Tool = &schema.ChatsToolEvent{Name: ev.Tool.Name, Summary: ev.Tool.Summary, OK: ev.Tool.OK, Output: ev.Tool.Output, Seq: ev.Tool.Seq} if ev.Tool.Card != nil { out.Tool.App = appJSON(ev.Tool.Card.App, false) out.Tool.Card = toolCardJSON(ev.Tool.Card) } } return out } func intParam(c echo.Context, name string, def, max int) int { n, err := strconv.Atoi(c.QueryParam(name)) if err != nil || n <= 0 { return def } if n > max { return max } return n } // toolCardJSON is the card of a tool line as the API gives it. func toolCardJSON(c *session.ToolCard) *schema.ChatsToolCard { out := &schema.ChatsToolCard{Kind: c.Kind, ID: c.ID, FilesMore: c.FilesMore, FileKind: c.FileKind} for _, f := range c.Files { out.Files = append(out.Files, schema.ChatsToolFile{Path: f.Path, Rev: f.Rev, Op: f.Op, Kind: f.Kind}) } return out }