package server import ( "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "agent-system-backend/internal/store" "agent-system-backend/internal/surfacesvc" core "agent-system-core" ) func (s *Server) createBackgroundTask(w http.ResponseWriter, r *http.Request) { var body struct { Kind string `json:"kind"` Title string `json:"title"` ConversationID string `json:"conversationId"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Kind == "" { http.Error(w, "kind required", http.StatusBadRequest) return } task, err := s.st.CreateBackgroundTaskAccountChecked(s.owner(r), body.Kind, body.Title, body.ConversationID, s.Auth == nil || !s.Auth.Enabled) if err != nil { writeStoreErr(w, err) return } writeJSON(w, http.StatusCreated, task) } func (s *Server) listBackgroundTasks(w http.ResponseWriter, r *http.Request) { tasks, err := s.st.ListBackgroundTasks(s.owner(r)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, orEmptySlice(tasks)) } func (s *Server) getBackgroundTask(w http.ResponseWriter, r *http.Request) { task, err := s.st.GetBackgroundTask(s.owner(r), r.PathValue("id")) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, task) } func (s *Server) updateBackgroundTask(w http.ResponseWriter, r *http.Request) { var body struct { Status string `json:"status"` Progress int `json:"progress"` Error string `json:"error"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Status == "" { http.Error(w, "status required", http.StatusBadRequest) return } if err := s.st.UpdateBackgroundTaskAccountChecked(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } s.getBackgroundTask(w, r) } func (s *Server) putMemory(w http.ResponseWriter, r *http.Request) { var e core.MemoryEntry if err := json.NewDecoder(r.Body).Decode(&e); err != nil || strings.TrimSpace(e.Content) == "" { http.Error(w, "content required", http.StatusBadRequest) return } if e.Scope == "" { e.Scope = "user" } if e.Type == "" { e.Type = "fact" } if e.Source == "" { e.Source = "manual" } saved, err := s.st.PutMemoryAccountChecked(s.owner(r), e, s.Auth == nil || !s.Auth.Enabled) if err != nil { writeStoreErr(w, err) return } writeJSON(w, http.StatusCreated, saved) } func (s *Server) listMemory(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() list, err := s.st.ListMemory(s.owner(r), q.Get("scope"), q.Get("projectId")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, orEmptySlice(list)) } func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) evs, err := s.st.ListAuditEvents(s.owner(r), limit) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, orEmptySlice(evs)) } func (s *Server) postAudit(w http.ResponseWriter, r *http.Request) { var body struct { Action string `json:"action"` Target string `json:"target"` Evidence string `json:"evidence"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Action == "" { http.Error(w, "action required", http.StatusBadRequest) return } if err := s.st.AppendAuditEventAccountChecked(s.owner(r), store.AuditEvent{ Owner: s.owner(r), Actor: s.owner(r), Action: body.Action, Target: body.Target, Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"), }, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) setPluginEnabled(w http.ResponseWriter, r *http.Request) { var body struct { Enabled bool `json:"enabled"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if err := s.st.SetPluginEnabledAccountChecked(s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) llmBudget(w http.ResponseWriter, r *http.Request) { budget := int(s.set.Effective(s.owner(r), "").GetNumber("llm.dailyTokenBudget", 0)) status, err := s.st.AccountTokens(s.owner(r), 0, budget) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"total": status.Total, "budget": budget, "over": status.Over}) } func (s *Server) exportConversation(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) id := r.PathValue("id") conv, err := s.st.GetConversation(owner, id) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } msgs, err := s.st.ListMessages(id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if r.URL.Query().Get("format") == "md" { w.Header().Set("Content-Type", "text/markdown; charset=utf-8") fmt.Fprintf(w, "# %s\n\n", conv.Title) for _, m := range msgs { fmt.Fprintf(w, "## %s\n\n%s\n\n", m.Role, messageText(m)) } return } writeJSON(w, http.StatusOK, map[string]any{"conversation": conv, "messages": msgs}) } func messageText(m core.Message) string { var b strings.Builder for _, p := range m.Parts { if p.Type == core.PartText { b.WriteString(p.Text) } } return b.String() } // ---- Generative UI Surface(提案 U2;EXT-17)。直接 API 与插件命令共用 // surfacesvc.Submit 单一咽喉:禁用/epoch/校验/CAS 守卫不因入口不同而绕过。---- func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) { if s.Surfaces == nil { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } var body struct { PluginID string `json:"pluginId"` ConversationID string `json:"conversationId"` SurfaceID string `json:"surfaceId"` BaseRevision int `json:"baseRevision"` ObservedEpoch int64 `json:"observedEpoch"` ContextVersion string `json:"contextVersion"` SemanticText string `json:"semanticText"` Spec json.RawMessage `json:"spec"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PluginID == "" || len(body.Spec) == 0 { http.Error(w, "pluginId and spec required", http.StatusBadRequest) return } // 新建页面必须落在调用者自己的会话上:会话表反查项目归属是唯一来源, // 空或越权会话会让页面绕过项目生命周期检查。缺失与他人会话同回 404。 if body.SurfaceID == "" { if body.ConversationID == "" { http.Error(w, "conversationId required", http.StatusBadRequest) return } if _, err := s.st.GetConversation(s.owner(r), body.ConversationID); err != nil { http.Error(w, "conversation not found", http.StatusNotFound) return } } snap, err := s.Surfaces.SubmitAccountChecked(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{ SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID), BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch, ContextVersion: body.ContextVersion, SemanticText: body.SemanticText, Spec: body.Spec, }, s.Auth == nil || !s.Auth.Enabled) if err != nil { s.writeSurfaceError(w, err) return } writeJSON(w, http.StatusCreated, snap) } func (s *Server) getSurface(w http.ResponseWriter, r *http.Request) { if s.Surfaces == nil { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id"))) if err != nil { s.writeSurfaceError(w, err) return } writeJSON(w, http.StatusOK, snap) } func (s *Server) listSurfaces(w http.ResponseWriter, r *http.Request) { if s.Surfaces == nil { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } metas, err := s.Surfaces.List(r.Context(), s.owner(r), core.ID(r.PathValue("id"))) if err != nil { s.writeSurfaceError(w, err) return } writeJSON(w, http.StatusOK, orEmptySlice(metas)) } func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) { if s.Surfaces == nil { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } var body struct { Enabled bool `json:"enabled"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "enabled required", http.StatusBadRequest) return } row, err := s.Surfaces.SetActivationAccountChecked(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled) if err != nil { s.writeSurfaceError(w, err) return } writeJSON(w, http.StatusOK, row) } func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) { if s.Surfaces == nil { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } if s.projectLifecycle != nil { snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id"))) if err != nil { s.writeSurfaceError(w, err) return } if snap.Identity.ProjectID != "" { if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil { writeProjectErr(w, err) return } } } // 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道 // (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。 var body struct { ActionID string `json:"actionId"` Revision int `json:"revision"` RequestID string `json:"requestId"` Data map[string]any `json:"data"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ActionID == "" || body.RequestID == "" { http.Error(w, "actionId and requestId required", http.StatusBadRequest) return } res, err := s.Surfaces.SubmitActionAccountChecked(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{ SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID, Revision: body.Revision, RequestID: body.RequestID, Data: body.Data, }, s.Auth == nil || !s.Auth.Enabled) if err != nil { s.writeSurfaceError(w, err) return } writeJSON(w, http.StatusOK, res) } // writeSurfaceError 把服务层确定错误映射到状态码:禁用/epoch=409 语义(准入 // 截止),规格违规=400 带字段清单,冲突=409 带当前版本,NotFound=404。 func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) { var invalid *surfacesvc.ErrInvalidSpec var conflict *store.ErrRevisionConflict switch { case errors.Is(err, surfacesvc.ErrDisabled), errors.Is(err, surfacesvc.ErrStaleEpoch), errors.Is(err, surfacesvc.ErrUnavailable): http.Error(w, err.Error(), http.StatusForbidden) case errors.Is(err, surfacesvc.ErrUnknownAction): http.Error(w, err.Error(), http.StatusBadRequest) case errors.Is(err, store.ErrIdempotencyConflict): http.Error(w, err.Error(), http.StatusConflict) case errors.As(err, &invalid): writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid spec", "details": invalid.Errs}) case errors.As(err, &conflict): writeJSON(w, http.StatusConflict, map[string]any{"error": "revision conflict", "current": conflict.Current}) case errors.Is(err, store.ErrNotFound): http.Error(w, err.Error(), http.StatusNotFound) default: http.Error(w, err.Error(), http.StatusInternalServerError) } } // listProjectSurfaces 项目级跨会话页面聚合(U6/R8):owner 授权作用域。 func (s *Server) listProjectSurfaces(w http.ResponseWriter, r *http.Request) { if s.Surfaces == nil { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } metas, err := s.Surfaces.ListByProject(r.Context(), s.owner(r), core.ID(r.PathValue("id"))) if err != nil { s.writeSurfaceError(w, err) return } writeJSON(w, http.StatusOK, orEmptySlice(metas)) }