// Package server 提供 HTTP/SSE API 与内嵌前端(go:embed)。 // API-first:前端只是第一个客户端;全部能力走公开 API。 package server import ( "context" "encoding/json" "errors" "fmt" "io" "io/fs" "mime" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" agentcommand "agent-command" "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/ingest" "agent-system-backend/internal/intentgate" "agent-system-backend/internal/plugins" "agent-system-backend/internal/profiles" "agent-system-backend/internal/prompts" "agent-system-backend/internal/ratelimit" "agent-system-backend/internal/runmgr" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" "agent-system-backend/internal/workflow" "agent-system-backend/web" core "agent-system-core" ) type Server struct { st *store.Store Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) mgr *runmgr.Manager Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) Workflows *workflow.Registry // 已装载工作流(nil = 不接) Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) reg *tools.Registry prof *profiles.Service set *settings.Service plugins *plugins.Host static fs.FS // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。 Subscribe func(conversationID string) (<-chan core.StreamEvent, func()) } func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server { sub, err := web.Dist() if err != nil { panic(err) // embed 布局错误属构建期问题 } return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub} } // NLIntentGate 意图闸门端口(intentgate.Gate 实现;避免包依赖方向反转)。 type NLIntentGate interface { Match(ctx context.Context, input string) (string, string) } type intentRecognizer interface { Recognize(ctx context.Context, input string) intentgate.MatchResult } func (s *Server) recognize(ctx context.Context, text string) intentgate.MatchResult { if rg, ok := s.Gate.(intentRecognizer); ok { return rg.Recognize(ctx, text) } branch, id := s.Gate.Match(ctx, text) return intentgate.MatchResult{Branch: branch, ID: id} } // WireBuiltinCommands 注册内置命令(`/` 菜单全清单的数据源;处理器为宿主用例适配)。 // 响应保持既有 {command, ...} 契约(frontend store.sendMessage 按此消费)。 func (s *Server) WireBuiltinCommands() { b := s.Bus if b == nil { return } must := func(err error) { if err != nil { panic("server: builtin command: " + err.Error()) // 装配期错误属编程错误 } } must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "builtin.help", Title: "帮助", Description: "查看可用命令与入口说明"}, "/help", func(ctx context.Context, req cmdbus.Request) (any, error) { return map[string]any{"reply": fmt.Sprintf("可用命令(注册表 %d 条)——/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile 切换画像 · /help 帮助", len(b.List()))}, nil })) must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话", ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)}, "/new", func(ctx context.Context, req cmdbus.Request) (any, error) { conv, err := s.st.GetConversation(req.Owner, req.ConversationID) if err != nil { return nil, err } c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID) if err != nil { return nil, err } return map[string]any{"conversation": c}, nil })) must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "runtime.mock", Title: "演示模式", Description: "切换 mock 演示模式(on|off)", ParamsSchema: json.RawMessage(`{"type":"object","required":["on"],"properties":{"on":{"type":"boolean"}}}`)}, "/mock", func(ctx context.Context, req cmdbus.Request) (any, error) { on, _ := req.Params["on"].(bool) if err := s.set.MergeUser(req.Owner, map[string]any{"runtime": map[string]any{"mock": on}}); err != nil { return nil, err } return map[string]any{"runtime.mock": on}, nil })) must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "models.set", Title: "设置主模型", Description: "设置主模型名称", ParamsSchema: json.RawMessage(`{"type":"object","required":["model"],"properties":{"model":{"type":"string"}}}`)}, "/model", func(ctx context.Context, req cmdbus.Request) (any, error) { m, _ := req.Params["model"].(string) if err := s.set.MergeUser(req.Owner, map[string]any{"models": map[string]any{"chat": map[string]any{"model": m}}}); err != nil { return nil, err } return map[string]any{"model": m}, nil })) must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.profile", Title: "切换画像", Description: "切换会话画像", ParamsSchema: json.RawMessage(`{"type":"object","required":["profileId"],"properties":{"profileId":{"type":"string"}}}`)}, "/profile", func(ctx context.Context, req cmdbus.Request) (any, error) { pid, _ := req.Params["profileId"].(string) if err := s.st.UpdateConversation(req.Owner, req.ConversationID, "", pid); err != nil { return nil, err } return map[string]any{"profile": pid}, nil })) } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // ---- 认证与会话(管理员发放制,R2)---- mux.HandleFunc("POST /api/auth/login", s.login) mux.HandleFunc("POST /api/auth/logout", s.logout) mux.HandleFunc("POST /api/auth/password", s.changePassword) mux.HandleFunc("GET /api/admin/users", s.listAdminUsers) mux.HandleFunc("POST /api/admin/users", s.createAdminUser) mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword) // ---- 项目 / 会话 ---- mux.HandleFunc("GET /api/projects", s.listProjects) mux.HandleFunc("POST /api/projects", s.createProject) mux.HandleFunc("PATCH /api/projects/{id}", s.patchProject) mux.HandleFunc("DELETE /api/projects/{id}", s.deleteProject) mux.HandleFunc("GET /api/projects/{id}/conversations", s.listConversations) mux.HandleFunc("POST /api/projects/{id}/conversations", s.createConversation) mux.HandleFunc("GET /api/conversations", s.listTasks) mux.HandleFunc("GET /api/conversations/{id}", s.conversationDetail) mux.HandleFunc("GET /api/conversations/{id}/export", s.exportConversation) mux.HandleFunc("PATCH /api/conversations/{id}", s.patchConversation) mux.HandleFunc("DELETE /api/conversations/{id}", s.deleteConversation) mux.HandleFunc("POST /api/conversations/{id}/messages", s.postMessage) mux.HandleFunc("POST /api/runs/{id}/cancel", s.cancelRun) mux.HandleFunc("GET /api/runs/{id}", s.getRun) // ---- HITL ---- mux.HandleFunc("POST /api/hitl/{id}/resolve", s.resolveHITL) // ---- 收件箱任务卡(提案 T2;项目收件箱 D48-D51)---- mux.HandleFunc("GET /api/conversations/{id}/cards", s.listCards) mux.HandleFunc("GET /api/projects/{id}/cards", s.listProjectCards) mux.HandleFunc("POST /api/cards/{id}/actions/{actionId}", s.cardAction) mux.HandleFunc("POST /api/cards/{id}/pin", s.cardPin) mux.HandleFunc("POST /api/cards/{id}/dismiss", s.cardDismiss) mux.HandleFunc("POST /api/cards/{id}/to_project", s.cardToProject) // ---- 事件流(SSE)---- mux.HandleFunc("GET /api/events", s.events) // ---- 上传与工件 ---- mux.HandleFunc("POST /api/projects/{id}/uploads", s.upload) mux.HandleFunc("GET /api/projects/{id}/artifacts", s.listArtifacts) mux.HandleFunc("GET /api/artifacts/{id}", s.getArtifact) mux.HandleFunc("GET /api/artifacts/{id}/content", s.artifactContent) mux.HandleFunc("PUT /api/artifacts/{id}/content", s.putArtifactContent) mux.HandleFunc("DELETE /api/artifacts/{id}", s.deleteArtifact) // ---- 画像 / 设置 / 工具 / 插件 / 注册表 ---- mux.HandleFunc("GET /api/profiles", s.listProfiles) mux.HandleFunc("POST /api/profiles", s.saveProfile) mux.HandleFunc("DELETE /api/profiles/{id}", s.deleteProfile) mux.HandleFunc("GET /api/prompts", s.listPromptModules) mux.HandleFunc("POST /api/prompts", s.savePromptModule) mux.HandleFunc("DELETE /api/prompts/{id}", s.deletePromptModule) mux.HandleFunc("GET /api/settings", s.getSettings) mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser) mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject) mux.HandleFunc("GET /api/tools", s.listTools) mux.HandleFunc("GET /api/url-preview", s.urlPreview) mux.HandleFunc("POST /api/tools", s.createUserTool) mux.HandleFunc("DELETE /api/tools/{name}", s.deleteUserTool) mux.HandleFunc("GET /api/plugins", s.listPlugins) mux.HandleFunc("POST /api/plugins/{id}/enabled", s.setPluginEnabled) mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins) mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema) mux.HandleFunc("GET /api/registry", s.registry) // ---- 计划 / 待办 / 命令 / 解析器 / 检索 ---- mux.HandleFunc("GET /api/conversations/{id}/plan", s.getPlan) mux.HandleFunc("GET /api/conversations/{id}/todos", s.getTodos) mux.HandleFunc("GET /api/commands", s.listCommands) mux.HandleFunc("GET /api/workspaces", s.listWorkspaces) mux.HandleFunc("POST /api/commands/{id}", s.execCommand) mux.HandleFunc("GET /api/commands/audit", s.listAudits) mux.HandleFunc("GET /api/parsers", s.listParsers) mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge) mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge) mux.HandleFunc("POST /api/tasks", s.createBackgroundTask) mux.HandleFunc("GET /api/tasks", s.listBackgroundTasks) mux.HandleFunc("GET /api/tasks/{id}", s.getBackgroundTask) mux.HandleFunc("POST /api/tasks/{id}", s.updateBackgroundTask) mux.HandleFunc("POST /api/memory", s.putMemory) mux.HandleFunc("GET /api/memory", s.listMemory) mux.HandleFunc("GET /api/audit", s.listAudit) mux.HandleFunc("POST /api/audit", s.postAudit) mux.HandleFunc("GET /api/llm/budget", s.llmBudget) mux.HandleFunc("GET /api/me", s.me) mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ts": time.Now().UTC()}) }) // ---- 管理(嵌入式档在线备份)---- mux.HandleFunc("POST /api/admin/backup", s.backup) // API 未匹配路径一律 404(不回落 SPA,避免 /api/* 返回 HTML 200)。 mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }) // 静态前端(embed);API 全链路:认证(未启用 = 默认租户)→ 租户限流。 inner := s.withRateLimit(mux) var h http.Handler = inner if s.Auth != nil { h = s.Auth.Middleware(inner) } mux.HandleFunc("/", s.serveStatic) return h } // ---- SSE:游标重放 + 在线订阅 + 终态对账由 Query 承担 ---- func (s *Server) events(w http.ResponseWriter, r *http.Request) { conversationID := r.URL.Query().Get("conversationId") isProject := false // 项目流(D50):scope=project&projectId= 订阅项目频道——伪会话键 // "project:" 复用会话流的持久化/seq/重放机制;租户边界按项目校验。 if r.URL.Query().Get("scope") == "project" { pid := r.URL.Query().Get("projectId") if pid == "" { http.Error(w, "projectId required for project scope", http.StatusBadRequest) return } if _, err := s.st.GetProject(s.owner(r), pid); err != nil { writeStoreErr(w, err) return } conversationID = string(core.ProjectChannelKey(core.ID(pid))) isProject = true } else if conversationID == "" { http.Error(w, "conversationId required", http.StatusBadRequest) return } flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") // 租户边界:跨租户会话不可订阅(NotFound,不泄露存在性);项目流已按项目校验。 if !isProject { if _, err := s.st.GetConversation(s.owner(r), conversationID); err != nil { writeStoreErr(w, err) return } } since := int64(0) if ids := r.Header.Get("Last-Event-ID"); ids != "" { since, _ = strconv.ParseInt(ids, 10, 64) } else if q := r.URL.Query().Get("since"); q != "" { since, _ = strconv.ParseInt(q, 10, 64) } // 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。 live, stop := s.Subscribe(conversationID) defer stop() send := func(e core.StreamEvent) bool { buf, _ := json.Marshal(e) fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.Seq, e.Type, buf) flusher.Flush() return r.Context().Err() == nil } lastSent := since replay, err := s.st.EventsAfter(conversationID, since, 1000) if err == nil { for _, e := range replay { if e.Seq <= lastSent { continue } if !send(e) { stop() return } lastSent = e.Seq } } // 心跳 + 在线转发。 heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() for { select { case <-r.Context().Done(): stop() return case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") flusher.Flush() case e := <-live: if e.ConversationID != conversationID || e.Seq <= lastSent { continue } if !send(e) { stop() return } lastSent = e.Seq } } } func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") if path == "" { path = "index.html" } data, err := fs.ReadFile(s.static, path) if err != nil { // SPA 回落 index.html。 data, err = fs.ReadFile(s.static, "index.html") if err != nil { http.NotFound(w, r) return } path = "index.html" } switch { case strings.HasSuffix(path, ".html"): w.Header().Set("Content-Type", "text/html; charset=utf-8") case strings.HasSuffix(path, ".js"): w.Header().Set("Content-Type", "text/javascript; charset=utf-8") case strings.HasSuffix(path, ".css"): w.Header().Set("Content-Type", "text/css; charset=utf-8") case strings.HasSuffix(path, ".svg"): w.Header().Set("Content-Type", "image/svg+xml") } w.Write(data) } // ---- handlers:项目与会话 ---- func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { ps, err := s.st.ListProjects(s.owner(r)) writeJSONOr(w, ps, err) } func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" { http.Error(w, "name required", http.StatusBadRequest) return } p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) writeJSONOr(w, p, err) } func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req struct { Name *string `json:"name"` Settings map[string]any `json:"settings"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.Name != nil { if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil { writeStoreErr(w, err) return } } if req.Settings != nil { if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil { writeStoreErr(w, err) return } } p, err := s.st.GetProject(s.owner(r), id) writeJSONOr(w, p, err) } func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id"))) } func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { writeStoreErr(w, err) return } cs, err := s.st.ListConversations(owner, r.PathValue("id")) writeJSONOr(w, cs, err) } // listTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档,?archived=1 含归档。 func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { v := r.URL.Query().Get("archived") cs, err := s.st.ListTasks(s.owner(r), v == "1" || v == "true") writeJSONOr(w, cs, err) } // me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。 func (s *Server) me(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) name := owner if u, err := s.st.GetUser(owner); err == nil && u.Name != "" { name = u.Name } out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled} if st, err := s.st.GetUserAuthState(owner); err == nil { out["isAdmin"] = st.IsAdmin out["mustChangePassword"] = st.MustChangePassword } writeJSON(w, http.StatusOK, out) } func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { var req struct { Title string `json:"title"` ProfileID string `json:"profileId"` } json.NewDecoder(r.Body).Decode(&req) if req.Title == "" { req.Title = "新会话" } c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) writeJSONOr(w, c, err) } // conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。 func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") owner := s.owner(r) conv, err := s.st.GetConversation(owner, id) if err != nil { writeStoreErr(w, err) return } msgs, _ := s.st.ListMessages(id) runs, _ := s.st.ListRunsByConversation(id) cards, _ := s.st.ActiveHITLCards(owner, id) lastSeq, _ := s.st.LastEventSeq(id) writeJSON(w, http.StatusOK, map[string]any{ "conversation": conv, "messages": msgs, "runs": runs, "activeHitl": cards, "lastSeq": lastSeq, }) } func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req struct { Title string `json:"title"` ProfileID string `json:"profileId"` Pinned *bool `json:"pinned"` Archived *bool `json:"archived"` AllowCrossRead *bool `json:"allowCrossRead"` CrossPrivate *bool `json:"crossPrivate"` } json.NewDecoder(r.Body).Decode(&req) if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil { writeStoreErr(w, err) return } writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate)) } func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) { writeOKOr(w, s.st.DeleteConversation(s.owner(r), r.PathValue("id"))) } // ---- 消息发送(含命令路由与附件) ---- func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { convID := r.PathValue("id") owner := s.owner(r) conv, err := s.st.GetConversation(owner, convID) if err != nil { writeStoreErr(w, err) return } var req struct { Text string `json:"text"` AttachmentIDs []string `json:"attachmentIds"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } text := strings.TrimSpace(req.Text) if text == "" && len(req.AttachmentIDs) == 0 { http.Error(w, "text or attachments required", http.StatusBadRequest) return } // 技能命令:提示词库命令触发命中时保持原文、跳过命令路由,走正常 run—— // system 组装时命令触发照常命中(命令是显式意图,不受附件门槛约束); // 技能命令是提示词层能力,不进命令总线。 skillCommand := false if s.Prompts != nil && strings.HasPrefix(text, "/") { if _, _, ok, err := s.Prompts.MatchCommand(owner, text); err == nil && ok { skillCommand = true } } // 命令路由:/ 开头走内置命令,不启动 run(4.11 输入增强)。 if strings.HasPrefix(text, "/") && !skillCommand { s.handleCommand(w, r, conv, text) return } parts := []core.Part{} if text != "" { parts = append(parts, core.Part{Type: core.PartText, Text: text}) } var parsedDocs []core.ParsedDocument var inputs []runmgr.WorkspaceFile for _, artID := range req.AttachmentIDs { art, err := s.st.GetArtifact(owner, artID) if err != nil { http.Error(w, "attachment "+artID+" not found", http.StatusBadRequest) return } content, _ := s.st.ArtifactContent(owner, artID) if len(content) > 0 { inputs = append(inputs, runmgr.WorkspaceFile{Name: art.Name, Content: content}) } doc := ingest.Parse(art.Name, art.MIME, content) parsedDocs = append(parsedDocs, doc) parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}) } msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: parts} if err := s.st.AppendMessage(&msg); err != nil { writeStoreErr(w, err) return } // 先命令总线,再绑定工作流,最后才是普通回合。 if s.Gate != nil && !strings.HasPrefix(text, "/") { recognized := s.recognize(r.Context(), text) if recognized.Branch == "matched" && s.Bus != nil && s.Bus.HasCommand(recognized.ID) { resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner, Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID, }) out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": recognized.ID} if resp.Card != nil { out["card"] = resp.Card } if m, ok := resp.Payload.(map[string]any); ok { for k, v := range m { out[k] = v } } writeJSON(w, http.StatusOK, out) return } if recognized.Branch == "matched" && s.Workflows != nil { if def, ok := s.Workflows.ByIntent(recognized.ID); ok { missing := make([]workflow.SlotQuestion, len(recognized.Missing)) for i, q := range recognized.Missing { missing[i] = workflow.SlotQuestion{Key: q.Key, Question: q.Question} } run, err := s.mgr.StartWorkflowRun(conv, msg, def, recognized.Slots, missing) writeJSONOr(w, map[string]any{"message": msg, "run": run}, err) return } } } run, err := s.mgr.StartConversationRun(conv, msg, parsedDocs, inputs) writeJSONOr(w, map[string]any{"message": msg, "run": run}, err) } // handleCommand `/` 入口 → 命令总线(D27 四入口同源;slash 语义解析后统一分发+审计)。 func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core.Conversation, text string) { fields := strings.Fields(text) slash := strings.ToLower(fields[0]) if s.Bus == nil { http.Error(w, "commands disabled", http.StatusServiceUnavailable) return } id, ok := s.Bus.ResolveSlash(slash) if !ok { http.Error(w, "未知命令 "+slash+"(/help 查看)", http.StatusBadRequest) return } params, err := parseSlashParams(id, fields[1:]) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ ID: id, Params: params, Entrance: "slash", Actor: s.owner(r), Owner: s.owner(r), ConversationID: conv.ID, }) out := map[string]any{"command": slash, "receipt": resp.Receipt} if m, ok := resp.Payload.(map[string]any); ok { for k, v := range m { out[k] = v } } if resp.Receipt.State == agentcommand.StateRejected { out["error"] = resp.Receipt.Reason } writeJSON(w, http.StatusOK, out) } // parseSlashParams slash 参数 → 命令参数(按命令语义;未知参数原样透传由 schema 校验拒绝)。 func parseSlashParams(id string, args []string) (map[string]any, error) { switch id { case "runtime.mock": if len(args) != 1 || (args[0] != "on" && args[0] != "off") { return nil, errors.New("usage: /mock on|off") } return map[string]any{"on": args[0] == "on"}, nil case "models.set": if len(args) < 1 { return nil, errors.New("usage: /model ") } return map[string]any{"model": strings.Join(args, " ")}, nil case "conversation.profile": if len(args) < 1 { return nil, errors.New("usage: /profile ") } return map[string]any{"profileId": args[0]}, nil case "conversation.create": if len(args) > 0 { return map[string]any{"title": strings.Join(args, " ")}, nil } return map[string]any{}, nil default: return map[string]any{}, nil } } // execCommand 通用命令入口(button/card/chat 等 API 客户端统一走此路由;D27)。 func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { if s.Bus == nil { http.Error(w, "commands disabled", http.StatusServiceUnavailable) return } var body struct { Params map[string]any `json:"params"` Entrance string `json:"entrance"` ConversationID string `json:"conversationId"` AnchorMessageID string `json:"anchorMessageId"` } _ = json.NewDecoder(r.Body).Decode(&body) if body.Params == nil { body.Params = map[string]any{} } resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID, }) code := http.StatusOK if resp.Receipt.State == agentcommand.StateRejected { code = http.StatusBadRequest } out := map[string]any{"receipt": resp.Receipt} if resp.Payload != nil { out["payload"] = resp.Payload } if resp.Card != nil { out["card"] = resp.Card } writeJSON(w, code, out) } // listWorkspaces 插件工作区声明(T4;前端合并其内置工作区后渲染 OverlayHost)。 func (s *Server) listWorkspaces(w http.ResponseWriter, r *http.Request) { ws := []core.PluginWorkspace{} if s.plugins != nil { ws = s.plugins.ListWorkspaces() } writeJSON(w, http.StatusOK, ws) } // listAudits 审计查询(会话维度或全局;入口对称的可核查凭证)。 func (s *Server) listAudits(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) audits, err := s.st.ListAudits(r.URL.Query().Get("conversationId"), limit) writeJSONOr(w, map[string]any{"audits": audits}, err) } func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) { writeOKOr(w, s.mgr.CancelRun(r.PathValue("id"))) } func (s *Server) getRun(w http.ResponseWriter, r *http.Request) { run, err := s.st.GetRun(s.owner(r), r.PathValue("id")) if err != nil { writeStoreErr(w, err) return } children, _ := s.st.ChildRuns(run.ID) writeJSON(w, http.StatusOK, map[string]any{"run": run, "children": children}) } // ---- HITL ---- func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) { var resp core.HITLResponse if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp.CardID = r.PathValue("id") pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp) if err != nil { writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"resolved": true, "pivoted": pivoted, "pivotText": pivotText}) } // ---- 收件箱任务卡 ---- func (s *Server) listCards(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) if _, err := s.st.GetConversation(owner, r.PathValue("id")); err != nil { writeStoreErr(w, err) return } cards, err := s.Cards.List(owner, r.PathValue("id")) writeJSONOr(w, map[string]any{"cards": cards}, err) } // listProjectCards 项目收件箱聚合(D49 拍板口径):项目级卡 ∪ 该项目全部会话 // 的未决卡;done 卡不聚合。跨租户 NotFound(不泄露存在性)。 func (s *Server) listProjectCards(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { writeStoreErr(w, err) return } cards, err := s.st.ListProjectCards(owner, r.PathValue("id")) writeJSONOr(w, map[string]any{"cards": cards}, err) } // cardToProject 会话卡留档到项目收件箱(D48):复制为项目级卡(幂等),原卡 // dismissed 留痕;回执给前端 upsert 两张卡的终态。 func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) { created, err := s.Cards.ToProject(s.owner(r), r.PathValue("id")) if err != nil { writeStoreErr(w, err) return } origin, gerr := s.Cards.Get(s.owner(r), r.PathValue("id")) if gerr != nil { writeJSONOr(w, map[string]any{"card": created}, gerr) return } writeJSON(w, http.StatusOK, map[string]any{"card": created, "origin": origin}) } // cardAction 处理卡上按钮:resolved + 回执消息进对话时间线(确认即留痕)。 func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) var body struct { FreeText string `json:"freeText,omitempty"` } _ = json.NewDecoder(r.Body).Decode(&body) // 命令确认卡(ref=cmd:)走总线回道:confirm=执行挂起命令,其余=取消。 if ref, err := s.st.CardRef(owner, r.PathValue("id")); err == nil && strings.HasPrefix(ref, "cmd:") { if card, err := s.Cards.Get(owner, r.PathValue("id")); err == nil && card.Status == core.CardActionRequired { resp := s.Bus.Confirm(owner, card.ID, r.PathValue("actionId")) writeJSON(w, http.StatusOK, map[string]any{"card": card, "receipt": resp.Receipt, "payload": resp.Payload}) return } } card, label, err := s.Cards.Action(owner, r.PathValue("id"), r.PathValue("actionId"), body.FreeText) if err != nil { writeStoreErr(w, err) return } // 回执消息:确认结论写进对话时间线(含所点按钮语义),审计锚点为 card.resolved 事件。 // 返回持久化后的回执消息(服务端 id),前端本地追加后与权威对账不重复。 var receipt *core.Message if card.ConversationID != "" { receipt = &core.Message{ID: store.NewID("msg"), ConversationID: card.ConversationID, Role: core.RoleAssistant, CreatedAt: time.Now().UTC()} receipt.Parts = []core.Part{{Type: core.PartText, Text: fmt.Sprintf("✓ 已按「%s」处理「%s」——结论已记录并留痕(card:%s)", label, card.Title, card.ID)}} _ = s.st.AppendMessage(receipt) } writeJSON(w, http.StatusOK, map[string]any{"card": card, "receipt": receipt}) } func (s *Server) cardPin(w http.ResponseWriter, r *http.Request) { var body struct { Pinned bool `json:"pinned"` } _ = json.NewDecoder(r.Body).Decode(&body) card, err := s.Cards.SetPinned(s.owner(r), r.PathValue("id"), body.Pinned) writeJSONOr(w, map[string]any{"card": card}, err) } func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) { card, err := s.Cards.Dismiss(s.owner(r), r.PathValue("id")) writeJSONOr(w, map[string]any{"card": card}, err) } // ---- 上传与工件 ---- func (s *Server) upload(w http.ResponseWriter, r *http.Request) { projectID := r.PathValue("id") r.ParseMultipartForm(64 << 20) file, header, err := r.FormFile("file") if err != nil { http.Error(w, "file field required", http.StatusBadRequest) return } defer file.Close() content, err := io.ReadAll(io.LimitReader(file, 100<<20)) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } owner := s.owner(r) if _, err := s.st.GetProject(owner, projectID); err != nil { writeStoreErr(w, err) return } mime := header.Header.Get("Content-Type") art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime} if err := s.st.CreateArtifact(&art, content); err != nil { writeStoreErr(w, err) return } // 摄取卡(SR-A5 界面化):解析过程显式可见,失败不静默——原始文件已存工件可取回。 // v1 边界:上传为项目级事件,卡片按项目持久化(conversationId 空);会话内演示见 mock #upload。 ingestCard := core.ActivityCard{ ProjectID: projectID, Kind: core.CardKindProgress, Title: header.Filename, Status: core.CardActive, Tone: "info", Body: []core.UINode{{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "解析中", Value: 0.3}}}, } if s.Cards != nil { ingestCard, _ = s.Cards.Create(owner, ingestCard, "") } doc := ingest.Parse(art.Name, mime, content) if s.Cards != nil { ingestCard.Status = core.CardDone ingestCard.Tone = "ok" body := []core.UINode{ {Type: core.UIKV, KV: [][2]string{{"解析器", doc.Meta.Parser}, {"内容块", fmt.Sprint(len(doc.Blocks))}}}, {Type: core.UIActions, Actions: []core.UIAction{{ID: "open", Label: "📄 打开工件", Command: ""}}}, } if len(doc.Warnings) > 0 { ingestCard.Status = core.CardActionRequired ingestCard.Tone = "warn" for _, wn := range doc.Warnings { body = append(body, core.UINode{Type: core.UIMarkdown, Markdown: "⚠ " + wn}) } body = append(body, core.UINode{Type: core.UIMarkdown, Markdown: "_解析不完整不静默:原始文件已永久保留,可取回或重试。_"}) } ingestCard.Body = body _ = s.Cards.Update(owner, ingestCard) } // 文本可解析的进检索索引(管线分块+全文+可选向量)。 if plain := doc.PlainText(); plain != "" && len(plain) < 200000 { if s.Indexer != nil { _ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain) } else { _ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain}) } } writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc}) } func kindOf(filename string) string { switch { case strings.HasSuffix(strings.ToLower(filename), ".md"), strings.HasSuffix(strings.ToLower(filename), ".txt"), strings.HasSuffix(strings.ToLower(filename), ".docx"), strings.HasSuffix(strings.ToLower(filename), ".pdf"): return "document" case strings.Contains(strings.ToLower(filename), ".png"), strings.Contains(strings.ToLower(filename), ".jpg"), strings.Contains(strings.ToLower(filename), ".jpeg"), strings.Contains(strings.ToLower(filename), ".webp"), strings.Contains(strings.ToLower(filename), ".gif"): return "image" default: return "data" } } func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { writeStoreErr(w, err) return } as, err := s.st.ListArtifacts(owner, r.PathValue("id")) writeJSONOr(w, as, err) } func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) { a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id")) writeJSONOr(w, a, err) } func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) { a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id")) if err != nil { writeStoreErr(w, err) return } content, err := s.st.ArtifactContent(s.owner(r), a.ID) if err != nil { writeStoreErr(w, err) return } w.Header().Set("Content-Type", contentTypeOf(a)) w.Header().Set("Content-Disposition", contentDisposition(a.Name, r.URL.Query().Get("download") == "1")) w.Write(content) } func (s *Server) putArtifactContent(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(io.LimitReader(r.Body, 20<<20)) writeOKOr(w, s.st.UpdateArtifactContent(s.owner(r), r.PathValue("id"), body)) } func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) { writeOKOr(w, s.st.DeleteArtifact(s.owner(r), r.PathValue("id"))) } // ---- 画像 ---- func (s *Server) listProfiles(w http.ResponseWriter, r *http.Request) { ps, err := s.prof.List(s.owner(r)) writeJSONOr(w, ps, err) } func (s *Server) saveProfile(w http.ResponseWriter, r *http.Request) { var p core.AgentProfile if err := json.NewDecoder(r.Body).Decode(&p); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if p.ID == "" { p.ID = "prof_" + strconv.FormatInt(time.Now().UnixNano()%100000, 10) } if err := s.prof.Save(s.owner(r), p); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, p) } func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) { writeOKOr(w, s.prof.Delete(s.owner(r), r.PathValue("id"))) } // ---- 设置 ---- func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) projectID := r.URL.Query().Get("project") schema := append(settings.Schema(), s.plugins.SettingsSchema()...) writeJSON(w, http.StatusOK, map[string]any{ "schema": schema, "defaults": settings.Masked(core.EffectiveSettings(settings.SystemDefaults())), "user": maskMap(mustUser(s.set, owner)), "project": maskMap(mustScope(s.set, owner, projectID)), }) } func mustUser(s *settings.Service, owner string) map[string]any { m, _ := s.UserScope(owner) if m == nil { return map[string]any{} } return m } func mustScope(s *settings.Service, owner, projectID string) map[string]any { if projectID == "" { return map[string]any{} } m, _ := s.ProjectScope(owner, projectID) return m } func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) { var values map[string]any if err := json.NewDecoder(r.Body).Decode(&values); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeOKOr(w, s.set.MergeUser(s.owner(r), values)) } func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) { var values map[string]any if err := json.NewDecoder(r.Body).Decode(&values); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeOKOr(w, s.set.MergeProject(s.owner(r), r.PathValue("id"), values)) } // ---- 工具(用户声明式 HTTP 工具注册)---- // visibleTools Hidden(预留能力)工具按租户生效设置过滤:runtime.fsTools 未开不可见。 func (s *Server) visibleTools(owner string) []core.ToolDesc { list := s.reg.List() if s.set.Effective(owner, "").GetBool("runtime.fsTools", false) { return list } out := make([]core.ToolDesc, 0, len(list)) for _, t := range list { if !t.Hidden { out = append(out, t) } } return out } func (s *Server) listTools(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.visibleTools(s.owner(r))) } func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) { var desc core.ToolDesc if err := json.NewDecoder(r.Body).Decode(&desc); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } desc.Source = core.SourceUser if desc.HTTP == nil { http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest) return } lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") } if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, desc) } func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if desc, _, ok := s.reg.Get(name); ok && desc.Source != core.SourceUser { http.Error(w, "仅可删除 user 来源工具", http.StatusForbidden) return } s.reg.UnregisterBySource(core.SourceUser, name) writeJSON(w, http.StatusOK, map[string]any{"deleted": name}) } // ---- 插件 ---- type pluginView struct { Manifest core.PluginManifest `json:"manifest"` Panels []core.PluginPanel `json:"panels"` Cards map[string][]core.UINode `json:"cards"` Workflows []core.WorkflowDef `json:"workflows"` Intents []json.RawMessage `json:"intents"` Prompts map[string]string `json:"prompts"` Settings []core.SettingDesc `json:"settings"` Pages []core.PluginPageDecl `json:"pages,omitempty"` Channels []core.PluginChannelDecl `json:"channels,omitempty"` Skills map[string]string `json:"skills,omitempty"` } func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) { var out []pluginView for _, ld := range s.plugins.ListFor(s.owner(r), r.URL.Query().Get("projectId")) { out = append(out, pluginView{ Manifest: ld.Manifest, Panels: ld.Panels, Cards: ld.Cards, Workflows: ld.Workflows, Intents: ld.Intents, Prompts: ld.Prompts, Settings: ld.PluginSettings, Pages: ld.Pages, Channels: ld.Channels, Skills: ld.Skills, }) } writeJSON(w, http.StatusOK, orEmptySlice(out)) } func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) { results := s.plugins.LoadDir() writeJSON(w, http.StatusOK, map[string]any{"reloaded": len(results), "errors": results}) } func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.plugins.SettingsSchema()) } // registry 统一注册表视图(来源标记 + 按来源启停入口)。 func (s *Server) registry(w http.ResponseWriter, r *http.Request) { reg := map[string]any{ "tools": s.visibleTools(s.owner(r)), "parsers": ingest.ParsersDescription(), "panels": flattenPanels(s.plugins.List()), } writeJSON(w, http.StatusOK, reg) } func flattenPanels(lds []*plugins.Loaded) []core.PluginPanel { var out []core.PluginPanel for _, ld := range lds { out = append(out, ld.Panels...) } return out } // ---- 计划 / 待办 / 命令 / 检索 ---- func (s *Server) getPlan(w http.ResponseWriter, r *http.Request) { p, err := s.st.GetPlan(r.PathValue("id")) if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusOK, map[string]any{}) return } writeJSONOr(w, p, err) } func (s *Server) getTodos(w http.ResponseWriter, r *http.Request) { l, err := s.st.GetTodos(r.PathValue("id")) if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusOK, map[string]any{}) return } writeJSONOr(w, l, err) } func (s *Server) listCommands(w http.ResponseWriter, r *http.Request) { out := []map[string]any{} if s.Bus != nil { slashOf := map[string]string{} for slash, id := range s.Bus.SlashTable() { slashOf[id] = slash } for _, c := range s.Bus.List() { item := map[string]any{"id": c.Spec.ID, "title": c.Spec.Title, "description": c.Spec.Description, "danger": string(c.Spec.Danger), "source": string(c.Source), "enabled": c.Enabled} if slash, ok := slashOf[c.Spec.ID]; ok { item["name"] = slash // legacy 兼容:`/` 菜单按 name 消费 } out = append(out, item) } } // 提示词库技能命令(命令触发型 task 模块):进 `/` 菜单同源展示(发送时由 // postMessage 改写为技能任务,不走命令总线)。 if s.Prompts != nil { mods, err := s.Prompts.Effective(s.owner(r)) if err == nil { for _, m := range mods { if m.Disabled || m.Layer != core.LayerTask { continue } for _, t := range m.Triggers { if t.Kind == core.TriggerCommand && t.Command != "" { out = append(out, map[string]any{ "id": m.ID, "name": t.Command, "title": m.Title, "description": m.Description, "source": "prompt-skill", "enabled": true, }) break } } } } } writeJSON(w, http.StatusOK, out) } // ---- 提示词库 API(覆盖链:内置 embed → 用户 DB 行)---- func (s *Server) listPromptModules(w http.ResponseWriter, r *http.Request) { if s.Prompts == nil { http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable) return } mods, err := s.Prompts.Effective(s.owner(r)) writeJSONOr(w, mods, err) } func (s *Server) savePromptModule(w http.ResponseWriter, r *http.Request) { if s.Prompts == nil { http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable) return } var m core.PromptModule if err := json.NewDecoder(r.Body).Decode(&m); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if m.ID == "" || m.Layer == "" || strings.TrimSpace(m.Body) == "" { http.Error(w, "id/layer/body required", http.StatusBadRequest) return } if err := s.Prompts.Save(s.owner(r), m); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, m) } func (s *Server) deletePromptModule(w http.ResponseWriter, r *http.Request) { if s.Prompts == nil { http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable) return } writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.Delete(s.owner(r), r.PathValue("id"))) } func (s *Server) listParsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, ingest.ParsersDescription()) } func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) { var docs []core.KnowledgeDoc if err := json.NewDecoder(r.Body).Decode(&docs); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } owner := s.owner(r) if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { writeStoreErr(w, err) return } for i := range docs { docs[i].Owner = owner docs[i].ProjectID = r.PathValue("id") if docs[i].Source == "" { docs[i].Source = "manual" } } writeOKOr(w, s.st.AddKnowledge(docs...)) } func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") if q == "" { http.Error(w, "q required", http.StatusBadRequest) return } topK, _ := strconv.Atoi(r.URL.Query().Get("topK")) query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK} if s.Retriever != nil { hits, err := s.Retriever.Retrieve(r.Context(), s.owner(r), query) writeJSONOr(w, hits, err) return } hits, err := s.st.SearchKnowledge(s.owner(r), query) writeJSONOr(w, hits, err) } // ---- 帮助 ---- func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeJSONOr(w http.ResponseWriter, v any, err error) { if err != nil { writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, v) } func writeOKOr(w http.ResponseWriter, err error) { if err != nil { writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func writeStoreErr(w http.ResponseWriter, err error) { if errors.Is(err, store.ErrNotFound) { http.Error(w, err.Error(), http.StatusNotFound) return } if errors.Is(err, store.ErrConflict) { http.Error(w, err.Error(), http.StatusConflict) return } if errors.Is(err, runmgr.ErrConversationBusy) { http.Error(w, err.Error(), http.StatusConflict) return } http.Error(w, err.Error(), http.StatusInternalServerError) } // backup 触发一致性在线备份(嵌入式档:VACUUM INTO;PG 档见运维指南 pg_dump)。 func (s *Server) backup(w http.ResponseWriter, r *http.Request) { dir := filepath.Join(s.st.DataDir(), "backups") if err := os.MkdirAll(dir, 0o755); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } dest := filepath.Join(dir, "agent-system-"+time.Now().UTC().Format("20060102T150405Z")+".db") if err := s.st.Backup(dest); err != nil { http.Error(w, err.Error(), http.StatusNotImplemented) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": dest}) } // extMIME:常见文档扩展的显式类型表——系统 mime.types 不保证收录 docx/xlsx 等 // Office 格式,显式兜底让上传件的下载与浏览器预览拿到正确 Content-Type。 var extMIME = map[string]string{ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".doc": "application/msword", ".xls": "application/vnd.ms-excel", ".ppt": "application/vnd.ms-powerpoint", ".pdf": "application/pdf", ".zip": "application/zip", ".csv": "text/csv; charset=utf-8", ".md": "text/markdown; charset=utf-8", } func contentTypeOf(a core.Artifact) string { if a.MIME != "" { return a.MIME } ext := strings.ToLower(filepath.Ext(a.Name)) if t, ok := extMIME[ext]; ok { return t } if t := mime.TypeByExtension(ext); t != "" { return t } if a.Kind == "image" { return "application/octet-stream" } return "text/plain; charset=utf-8" } // contentDisposition:工件文件名带中文(如 会议纪要.docx)时用 RFC 5987 的 // filename* 权威编码,另给纯 ASCII 兜底;?download=1 切换 attachment(下载), // 默认 inline(浏览器内预览)。 func contentDisposition(name string, download bool) string { disposition := "inline" if download { disposition = "attachment" } return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFallbackName(name), url.PathEscape(name)) } // asciiFallbackName:非 ASCII 与引号/反斜杠替换为下划线,仅作 filename 兜底。 func asciiFallbackName(name string) string { var b strings.Builder for _, r := range name { if r >= 0x20 && r < 0x7f && r != '"' && r != '\\' { b.WriteRune(r) } else { b.WriteRune('_') } } return b.String() } func maskMap(m map[string]any) map[string]any { if m == nil { return map[string]any{} } return settings.Masked(m) } func orEmptySlice[T any](s []T) []T { if s == nil { return []T{} } return s }