// 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" "sort" "strconv" "strings" "time" agentcommand "git-ssh.u1680832.nyat.app/weichen/Agent-Command.git" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/cardsvc" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/cmdbus" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/ingest" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/intentgate" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/plugins" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/profiles" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/projectscope" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/prompts" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/ratelimit" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/route" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/runmgr" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/settings" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/surfacesvc" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/tools" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/workflow" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/pkg/extension" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/web" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) type Server struct { projectLifecycle *extension.ProjectLifecycle projectHost func(string) extension.Host 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 = 不启用;组合根注入) Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) 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()) // DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与 // 摄取卡行为不变);组合根自 extension.Options 注入,零值=false=现状。 DisableUploadIndexing bool // ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口 // owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。 ProjectAuthz func(owner string, projectID core.ID) (bool, error) // ProjectAuthzV2 EXT-8b 资源级授权适配器(U9;组合根注入;nil=未注册, // 读面回落 ProjectAuthz 兼容)。查询六要素由本包受信构造。 ProjectAuthzV2 func(q extension.ResourceAuthzQuery) (bool, error) // ProjectAuthzActions 动作级授权适配器(协作存储 U3,KTD13):注册后对 // 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律 // 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展 // 计划见 docs/guides/team-storage.md(KTD13 一节)。 ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error) // CompiledPlugins 编译进二进制的进程内插件摘要(组合根在 applyCompiled // 期间记录注册面生成;nil/空 = 无编译插件,清单行为不变)。 CompiledPlugins []CompiledPlugin } // ProjectAction 项目资源的动作授权矩阵(KTD13)。共享可读不等于可写或可 // 批准:动作语义独立判定,未配置的动作默认拒绝。 type ProjectAction string const ( ProjectRead ProjectAction = "read" // 读取项目资源(会话/消息/检索/工件下载) ProjectEdit ProjectAction = "edit" // 写入与修改(会话内发消息、卡片裁决、设置变更) ProjectApprove ProjectAction = "approve" // 审批(HITL 确认、命令确认) ProjectExecute ProjectAction = "execute" // 触发执行(run、工具、工作流) ProjectAdmin ProjectAction = "admin" // 管理(成员、共享范围、销毁性操作) ) // SetProjectAuthorizer 组合根在装载扩展后注入 EXT-8 适配器。 func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (bool, error)) { s.ProjectAuthz = fn } // SetResourceAuthorizerV2 组合根在装载扩展后注入 EXT-8b 资源级适配器。 func (s *Server) SetResourceAuthorizerV2(fn func(q extension.ResourceAuthzQuery) (bool, error)) { s.ProjectAuthzV2 = fn } // SetProjectActionAuthorizer 组合根注入 KTD13 动作级适配器(版本化公开契约 // 扩展落地前,宿主侧先以本槽位提供矩阵语义)。 func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core.ID, action ProjectAction) (bool, error)) { s.ProjectAuthzActions = fn } // authorizeProjectAction 动作矩阵唯一判定(KTD13): // - action-aware 适配器已注册 → 权威(出错/拒绝一律 false,fail-closed); // - 未注册 → read 回落旧只读 ProjectAuthz(兼容 EXT-8 现网),其余动作 // 默认拒绝(旧适配器不得自动获得写入能力)。 func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action ProjectAction) bool { if s.ProjectAuthzActions != nil { ok, err := s.ProjectAuthzActions(owner, projectID, action) return err == nil && ok } if action == ProjectRead { return s.authorizeSharedResource(owner, "read", "project", projectID, "", projectID) } return false } // authorizeSharedResource EXT-8b 资源级读判定(U9):V2 注册时按六要素咨询 // (Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、 // Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed // 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。 func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool { if s.ProjectAuthzV2 == nil { return s.authorizeSharedProject(actor, projectID) } if actor == "" || action == "" || resourceType == "" || resourceID == "" { return false } ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{ Actor: actor, Action: action, ResourceType: resourceType, ResourceID: resourceID, Owner: owner, ProjectID: projectID, }) return err == nil && ok } // authorizeSharedProject 读取路径的共享判定(V1):适配器未注册或拒绝/出错一律 // false(fail-closed;错误不区分,避免泄露存在性)。EXT-8b 落地后仅作 V2 缺席 // 时的兼容回落——新读面一律走 authorizeSharedResource。 func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool { if s.ProjectAuthz == nil { return false } ok, err := s.ProjectAuthz(owner, projectID) return err == nil && ok } 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: "system.intent", Title: "意图路由", Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单;button 入口只读匹配、不嵌套分发", ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)}, "/intent", func(ctx context.Context, req cmdbus.Request) (any, error) { return handleSystemIntent(ctx, b, req) })) 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.createPrivateConversation(ctx, 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 })) } // handleSystemIntent 实现 system.intent:命中后默认嵌套分发目标命令。 // B 类硬化:Entrance=button(页面动作)只返回匹配结果,禁止嵌套业务写路径。 func handleSystemIntent(ctx context.Context, b *cmdbus.Bus, req cmdbus.Request) (any, error) { text, _ := req.Params["text"].(string) in, ok := b.Intents().Match(text) if !ok { list := b.Intents().List() cands := make([]map[string]any, 0, len(list)) for _, x := range list { cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command}) } return map[string]any{"matched": false, "candidates": cands}, nil } if agentcommand.CanonicalEntrance(req.Entrance) == "button" { return map[string]any{"matched": true, "intent": in.ID, "command": in.Command, "dispatched": false, "note": "button entrance: match only, no nested dispatch"}, nil } params := map[string]any{} for k, v := range in.Params { params[k] = v } for k, v := range req.Params { if k != "text" { params[k] = v } } resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat", Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID}) return map[string]any{"matched": true, "intent": in.ID, "command": in.Command, "dispatched": true, "receipt": resp.Receipt, "payload": resp.Payload}, nil } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // ---- 认证与会话(管理员发放制,R2)---- mux.HandleFunc("POST /api/auth/login", s.login) mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey) 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("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey) mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys) mux.HandleFunc("DELETE /api/admin/users/{owner}/login-keys/{id}", s.revokeAdminLoginKey) // ---- 项目 / 会话 ---- 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("GET /api/conversations/{id}/checkpoints", s.listCheckpoints) mux.HandleFunc("POST /api/conversations/{id}/rewind", s.rewindFiles) mux.HandleFunc("POST /api/conversations/{id}/regenerate", s.regenerateMessage) mux.HandleFunc("POST /api/messages/{id}/exclude", s.excludeMessage) mux.HandleFunc("POST /api/messages/{id}/variant", s.selectVariant) mux.HandleFunc("GET /api/secrets", s.listSecrets) mux.HandleFunc("POST /api/secrets", s.createSecret) mux.HandleFunc("POST /api/secrets/{id}/activate", s.activateSecret) mux.HandleFunc("PATCH /api/secrets/{id}", s.renameSecret) mux.HandleFunc("DELETE /api/secrets/{id}", s.deleteSecret) mux.HandleFunc("GET /api/context-entries", s.listContextEntries) mux.HandleFunc("POST /api/context-entries", s.saveContextEntry) mux.HandleFunc("DELETE /api/context-entries/{id}", s.deleteContextEntry) mux.HandleFunc("GET /api/prompt-commands", s.listUserPromptCommands) mux.HandleFunc("POST /api/prompt-commands", s.saveUserPromptCommand) mux.HandleFunc("DELETE /api/prompt-commands/{id}", s.deleteUserPromptCommand) 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/settings/system", s.getSettingsSystem) mux.HandleFunc("PUT /api/settings/system", s.putSettingsSystem) 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/{id}/activation", s.setSurfaceActivation) mux.HandleFunc("POST /api/surfaces", s.submitSurface) mux.HandleFunc("GET /api/surfaces/{id}", s.getSurface) mux.HandleFunc("POST /api/surfaces/{id}/actions", s.submitSurfaceAction) mux.HandleFunc("GET /api/conversations/{id}/surfaces", s.listSurfaces) mux.HandleFunc("GET /api/projects/{id}/surfaces", s.listProjectSurfaces) mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins) mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema) mux.HandleFunc("GET /api/registry", s.registry) // WP3-I3/KTD4:管理区声明分区清单(编译插件经 Registry.AddAdminZone 声明; // 展示元数据对所有认证主体开放,分区内数据能力由组件命令面 fail-closed)。 mux.HandleFunc("GET /api/admin-zone/sections", s.listAdminZoneSections) // ---- 计划 / 待办 / 命令 / 解析器 / 检索 ---- 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) mux.HandleFunc("GET /api/admin/recovery-holds", s.listRecoveryHolds) mux.HandleFunc("POST /api/admin/recovery-holds/{runId}/release", s.releaseRecoveryHold) // 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 承担 ---- const sseRetryMillis = 1000 // sseAuthRecheck:会话凭证的 SSE 长连接复验周期。密码写入(恢复/改密/重置) // 会删除全部会话并 bump auth_version;已建立的流最多在此窗口内继续收事件, // 心跳点复验失效即断流(EventSource 重连时由中间件拒绝)。测试可缩短。 var sseAuthRecheck = 15 * time.Second func (s *Server) events(w http.ResponseWriter, r *http.Request) { conversationID := r.URL.Query().Get("conversationId") isProject := false streamProject := "" // 项目流(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 { // EXT-8b:资源级咨询(stream×project);V2 缺席回落 V1。 if !s.authorizeSharedResource(s.owner(r), "stream", "project", core.ID(pid), "", core.ID(pid)) { writeStoreErr(w, err) return } } conversationID = string(core.ProjectChannelKey(core.ID(pid))) isProject = true streamProject = pid } 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 } } // 浏览器 EventSource 断线后按这个间隔重连,并带上 Last-Event-ID。 fmt.Fprintf(w, "retry: %d\n\n", sseRetryMillis) flusher.Flush() 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) } // 长连接凭证复验(login 恢复合同 §6.7):会话凭证查会话行(密码写入即删除); // API key 凭证查撤销位(RevokeAllAPIKeys/失陷撤销后断流)。两种凭证都在 // 复验周期内核验属主未变。 sessionTok, keyCred := "", "" switch AuthVia(r) { case AuthViaSession: sessionTok = sessionToken(r) case AuthViaKey: keyCred = bearerKey(r) if keyCred == "" { keyCred = r.URL.Query().Get("key") } } wantOwner := s.owner(r) stillAuthed := func() bool { if sessionTok != "" { o, _, ok := s.st.SessionInfo(sessionTok) return ok && o == wantOwner } if keyCred != "" { o, ok := s.st.AuthOwner(keyCred) return ok && o == wantOwner } return true } // 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。 live, stop := s.Subscribe(conversationID) defer stop() lastVisible := since send := func(e core.StreamEvent) bool { if e.Seq <= lastVisible { return true } if isProject && !s.projectEventVisible(wantOwner, e) { return r.Context().Err() == nil } buf, _ := json.Marshal(e) fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.Seq, e.Type, buf) flusher.Flush() lastVisible = e.Seq return r.Context().Err() == nil } // Seen progresses even when a private frame is filtered. Visible only tracks // frames delivered to this actor, preserving authoritative seq and resumption. lastSeen := since for { replay, err := s.st.EventsAfter(conversationID, lastSeen, 1000) if err != nil || len(replay) == 0 { break } for _, e := range replay { if e.Seq <= lastSeen { continue } if !send(e) { stop() return } lastSeen = e.Seq } if len(replay) < 1000 { break } } // 心跳 + 在线转发。通道关闭表示缓冲溢出,结束响应让 EventSource 带 Last-Event-ID 重连。 // 会话凭证在心跳点复验:失效(改密/恢复/重置已删会话)即终止响应。 heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() recheck := time.NewTicker(sseAuthRecheck) defer recheck.Stop() for { select { case <-r.Context().Done(): stop() return case <-recheck.C: if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) { fmt.Fprint(w, ": project-access-revoked\n\n") flusher.Flush() return } if !stillAuthed() { fmt.Fprint(w, ": session-expired\n\n") flusher.Flush() stop() return } case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") flusher.Flush() case e, ok := <-live: if !ok { stop() return } if e.ConversationID != conversationID || e.Seq <= lastSeen { continue } if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) { fmt.Fprint(w, ": project-access-revoked\n\n") flusher.Flush() return } if !send(e) { stop() return } lastSeen = 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" } w.Header().Set("X-Content-Type-Options", "nosniff") switch { case strings.HasSuffix(path, ".html"): w.Header().Set("Content-Type", "text/html; charset=utf-8") setPageHeaders(w) 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:项目与会话 ---- // projectView 项目 API 视图:core.Project 字段原样 + EXT-15 共享标注(本人自有 // 项缺省 false/省略;适配器放行的他人项目为 true,供前端区分展示)。 type projectView struct { core.Project Shared bool `json:"shared,omitempty"` } func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) if s.projectLifecycle != nil { if s.projectLifecycle.List == nil { writeProjectErr(w, store.ErrNotFound) return } ps, err := s.projectLifecycle.List(r.Context(), s.lifecycleHost(owner)) if err != nil { writeProjectErr(w, err) return } out := make([]projectView, 0, len(ps)) for _, p := range ps { out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner}) } writeJSON(w, http.StatusOK, out) return } ps, err := s.st.ListProjects(owner) if err != nil { writeStoreErr(w, err) return } out := make([]projectView, 0, len(ps)) own := make(map[string]bool, len(ps)) for _, p := range ps { own[string(p.ID)] = true out = append(out, projectView{Project: p}) } // EXT-15:适配器已注册时,追加经放行的共享项目(他人 owner、去重)。 if s.ProjectAuthz != nil { all, err := s.st.ListAllProjects() if err == nil { for _, p := range all { if own[string(p.ID)] || string(p.Owner) == owner { continue } if ok, aerr := s.ProjectAuthz(owner, p.ID); aerr == nil && ok { out = append(out, projectView{Project: p, Shared: true}) } } } } writeJSON(w, http.StatusOK, out) } 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 } if s.projectLifecycle != nil { if s.projectLifecycle.Create == nil { writeProjectErr(w, store.ErrNotFound) return } p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name)) writeProjectJSON(w, s.publicProjectMetadata(p), err) 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.Settings != nil { values, err := s.set.PrepareProjectMetadataWrite(req.Settings) if err != nil { s.writeSettingsMerge(w, func() error { return err }) return } req.Settings = values } if s.projectLifecycle != nil { var p core.Project var err error if req.Name != nil { if strings.TrimSpace(*req.Name) == "" { http.Error(w, "name required", http.StatusBadRequest) return } if s.projectLifecycle.Rename == nil { writeProjectErr(w, store.ErrNotFound) return } p, err = s.projectLifecycle.Rename(r.Context(), s.lifecycleHost(s.owner(r)), id, strings.TrimSpace(*req.Name)) if err != nil { writeProjectErr(w, err) return } } if req.Settings != nil { if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil { writeStoreErr(w, err) return } } if req.Name == nil || req.Settings != nil { if s.projectLifecycle.List == nil { writeProjectErr(w, store.ErrNotFound) return } var projects []core.Project projects, err = s.projectLifecycle.List(r.Context(), s.lifecycleHost(s.owner(r))) if err == nil { err = store.ErrNotFound for _, candidate := range projects { if candidate.ID == id { p, err = candidate, nil break } } } } writeProjectJSON(w, s.publicProjectMetadata(p), err) 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) { if s.projectLifecycle != nil { if s.projectLifecycle.Archive == nil { writeProjectErr(w, store.ErrNotFound) return } if err := s.projectLifecycle.Archive(r.Context(), s.lifecycleHost(s.owner(r)), r.PathValue("id")); err != nil { writeProjectErr(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } 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 s.projectLifecycle != nil { if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil { writeProjectErr(w, err) return } } else 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 为默认租户)。 // authVia 区分凭证通道:改密门只对会话登录者放行,key 持有者不能改密(无密码属主)。 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, "authVia": AuthVia(r)} if sc := SessionScope(r); sc != "" { out["sessionScope"] = sc } if st, err := s.st.GetUserAuthState(owner); err == nil { out["isAdmin"] = st.IsAdmin out["mustChangePassword"] = st.MustChangePassword out["passwordSet"] = st.HasPassword } 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.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) writeProjectJSON(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) // resumeSeq:进行中的 run 从它的第一条事件之前重放,刷新或切回时补回已产生的过程。 resumeSeq := lastSeq if open, err := s.st.LatestOpenRun(id); err == nil { if seq, ok, ferr := s.st.FirstRunEventSeq(id, open.ID); ferr == nil && ok && seq > 0 { resumeSeq = seq - 1 } } writeJSON(w, http.StatusOK, map[string]any{ "conversation": conv, "messages": msgs, "runs": runs, "activeHitl": cards, "lastSeq": lastSeq, "resumeSeq": resumeSeq, }) } 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"` PermissionMode *string `json:"permissionMode"` SessionNote *string `json:"sessionNote"` } json.NewDecoder(r.Body).Decode(&req) onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.SessionNote == nil onlyNote := req.SessionNote != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.PermissionMode == nil if !onlyMode && !onlyNote { if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil { writeStoreErr(w, err) return } } if req.PermissionMode != nil { if err := s.st.SetPermissionMode(s.owner(r), id, *req.PermissionMode); err != nil { writeStoreErr(w, err) return } } if req.SessionNote != nil { if err := s.st.SetSessionNote(s.owner(r), id, *req.SessionNote); 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 } if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil { writeProjectErr(w, err) return } var req struct { Text string `json:"text"` AttachmentIDs []string `json:"attachmentIds"` RequestID string `json:"requestId"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } text := strings.TrimSpace(req.Text) if expanded, ok := s.expandUserCommand(owner, text); ok { text = expanded } if text == "" && len(req.AttachmentIDs) == 0 { http.Error(w, "text or attachments required", http.StatusBadRequest) return } if req.RequestID != "" { existing, err := s.st.MessageByRequest(convID, req.RequestID) if err == nil { s.writeExistingMessage(w, convID, existing) return } if !errors.Is(err, store.ErrNotFound) { writeStoreErr(w, err) 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}}) } // 先路由再落库。回合和工作流要先抢槽位,忙则 409 且不写任何数据。 // 意图命中的命令不占槽位,会话忙时也能用。 // 路由裁决统一走唯一 Router(internal/route;I1 WP3)。 routeKind := route.KindTurn var recognized intentgate.MatchResult var workflowDef core.WorkflowDef if s.Gate != nil && !strings.HasPrefix(text, "/") { recognized = s.recognize(r.Context(), text) dec := route.Decide(recognized, route.Env{ HasCommand: func(id string) bool { return s.Bus != nil && s.Bus.HasCommand(id) }, WorkflowFor: func(intentID string) (core.WorkflowDef, bool) { if s.Workflows == nil { return core.WorkflowDef{}, false } return s.Workflows.ByIntent(intentID) }, // SquadReady 保持 false:per-run entry 覆盖未就位(见 route 包注释的已知边界)。 }) switch dec.Kind { case route.KindCommand: routeKind = route.KindCommand recognized.ID = dec.CommandID recognized.Slots = dec.Slots case route.KindWorkflow: routeKind = route.KindWorkflow workflowDef = dec.Workflow recognized.Slots = dec.Slots } } claimed := false if routeKind == route.KindTurn || routeKind == route.KindWorkflow { // 执行许可预检(U3,R8):待执行队列已满时 429 且不写任何数据 //(与忙 409 同位;排队中的接纳不受影响)。 if s.mgr.QueueFull() { http.Error(w, "execution queue full(runtime.execQueueMax)", http.StatusTooManyRequests) return } if err := s.mgr.ClaimConversation(convID); err != nil { writeStoreErr(w, err) return } claimed = true } msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: parts} if err := s.st.AppendMessageWithRequest(&msg, req.RequestID); err != nil { var dup *store.DuplicateRequestError if errors.As(err, &dup) { if claimed { s.mgr.ReleaseConversation(convID) } s.writeExistingMessage(w, convID, dup.Existing) return } if claimed { s.mgr.ReleaseConversation(convID) } writeStoreErr(w, err) return } if routeKind == route.KindCommand { 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 } var run core.Run var startErr error if routeKind == route.KindWorkflow { missing := make([]workflow.SlotQuestion, len(recognized.Missing)) for i, q := range recognized.Missing { missing[i] = workflow.SlotQuestion{Key: q.Key, Question: q.Question} } run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, workflowDef, recognized.Slots, missing) } else { run, startErr = s.mgr.StartClaimedConversationRun(conv, msg, parsedDocs, inputs) } if startErr != nil { s.mgr.ReleaseConversation(convID) writeProjectErr(w, startErr) return } _ = s.st.SetMessageRunID(msg.ID, run.ID) msg.RunID = run.ID writeJSON(w, http.StatusOK, map[string]any{"message": msg, "run": run}) } // writeExistingMessage 把同一 requestId 的已有消息和仍在进行的 run 原样返回。 func (s *Server) writeExistingMessage(w http.ResponseWriter, convID string, msg core.Message) { out := map[string]any{"message": msg} if msg.RunID != "" { if run, err := s.st.GetRunByID(msg.RunID); err == nil { out["run"] = run writeJSON(w, http.StatusOK, out) return } } if run, err := s.st.LatestOpenRun(convID); err == nil { out["run"] = run } writeJSON(w, http.StatusOK, out) } // 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, HostProjectID: string(conv.ProjectID), }) 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{} } var hostProject string if body.ConversationID != "" { conv, err := s.st.GetConversation(s.owner(r), body.ConversationID) if err != nil { if s.projectLifecycle != nil { writeStoreErr(w, err) return } } else { hostProject = string(conv.ProjectID) if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil { writeProjectErr(w, err) return } } } 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, HostProjectID: hostProject, }) 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(s.owner(r), r.URL.Query().Get("conversationId"), limit) writeJSONOr(w, map[string]any{"audits": audits}, err) } func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) { runID := r.PathValue("id") // 跨实例取消(协作存储 U5/KTD5)**前置**:run 不在本实例执行(无句柄) // 且 PG 档——写持久命令,由执行实例消费;API 立即受理(202)。 // 不得先调本地 CancelRun:非执行实例无句柄时它返回 nil 的「假成功」 // 会吞掉命令路径,run 永远停不下(U7 双进程验收实测)。 // conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远 // 无法送达(同批实测)。 if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) { convID := "" if run, gerr := s.st.GetRun(s.owner(r), runID); gerr == nil { convID = string(run.ConversationID) } if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "cancel", runID, convID, 0, runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}); cerr != nil { writeStoreErr(w, cerr) return } writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"}) return } writeOKOr(w, s.mgr.CancelRun(runID)) } 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") if s.projectLifecycle != nil { card, err := s.st.GetHITLCard(s.owner(r), resp.CardID) if err != nil { writeStoreErr(w, err) return } conv, err := s.st.GetConversation(s.owner(r), card.ConversationID) if err != nil { writeStoreErr(w, err) return } if err := s.projectWork(r.Context(), s.owner(r), conv.ProjectID); err != nil { writeProjectErr(w, err) return } } pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp) if err != nil && s.st.Dialect() == "postgres" { // 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例 // 消费时重验 gate(cardID+gateVersion),过期审批明确拒绝。 if card, gerr := s.st.GetHITLCard(s.owner(r), string(resp.CardID)); gerr == nil && !card.Resolved { kind := resp.ChoiceID if kind == "" { kind = "free" } if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "hitl", string(card.RunID), string(card.ConversationID), 0, runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}); cerr == nil { writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"}) return } } } 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) if s.projectLifecycle != nil { card, err := s.st.GetCard(owner, r.PathValue("id")) if err != nil { writeStoreErr(w, err) return } pid := card.ProjectID if card.ConversationID != "" { conv, err := s.st.GetConversation(owner, card.ConversationID) if err != nil { writeStoreErr(w, err) return } pid = conv.ProjectID } if pid != "" { if err := s.projectWork(r.Context(), owner, pid); err != nil { writeProjectErr(w, err) return } } } 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") owner := s.owner(r) if s.projectLifecycle != nil { if err := s.projectWork(r.Context(), owner, projectID); err != nil { writeProjectErr(w, err) return } if _, err := s.st.ProjectMetadata(projectID); err != nil { writeStoreErr(w, err) return } } 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 } if s.projectLifecycle == nil { if _, err := s.st.GetProject(owner, projectID); err != nil { writeStoreErr(w, err) return } } if s.projectLifecycle != nil { // Request-body reading can outlast the membership/ready decision. if err := s.projectWork(r.Context(), owner, projectID); err != nil { writeProjectErr(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}}}, } // This endpoint has no private conversation stream. Registered uploads keep // original filename/parser metadata out of shared project cards and events. showIngestCard := s.Cards != nil && s.projectLifecycle == nil if showIngestCard { ingestCard, _ = s.Cards.Create(owner, ingestCard, "") } doc := ingest.Parse(art.Name, mime, content) if showIngestCard { 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) } // 文本可解析的进检索索引(管线分块+全文+可选向量)。 // EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。 if !s.DisableUploadIndexing { 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, pid := s.owner(r), r.PathValue("id") var as []core.Artifact var err error if _, ownedErr := s.st.GetProject(owner, pid); ownedErr != nil { if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", core.ID(pid)) { writeStoreErr(w, ownedErr) return } as, err = s.st.ListProjectArtifactsAll(pid) } else { as, err = s.st.ListArtifacts(owner, pid) } if err != nil { writeStoreErr(w, err) return } if s.ProjectAuthzV2 != nil { filtered := make([]core.Artifact, 0, len(as)) for _, a := range as { if s.authorizeSharedResource(owner, "list", "artifact", a.ID, a.Owner, a.ProjectID) { filtered = append(filtered, a) } } as = filtered } writeJSON(w, http.StatusOK, as) } // sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器, // 放行才返回;否则保持 NotFound(不泄露存在性)。 func (s *Server) sharedArtifactGet(owner, id string) (core.Artifact, bool) { a, err := s.st.GetArtifactByID(id) if err != nil { return core.Artifact{}, false } // EXT-8b:工件级六要素(Owner=工件属主,受信元数据解析)。 if !s.authorizeSharedResource(owner, "download", "artifact", a.ID, a.Owner, a.ProjectID) { return core.Artifact{}, false } return a, true } func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) a, err := s.st.GetArtifact(owner, r.PathValue("id")) if err != nil { shared, ok := s.sharedArtifactGet(owner, r.PathValue("id")) if !ok { writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, shared) return } writeJSONOr(w, a, err) } func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) a, err := s.st.GetArtifact(owner, r.PathValue("id")) if err != nil { shared, ok := s.sharedArtifactGet(owner, r.PathValue("id")) if !ok { writeStoreErr(w, err) return } a = shared } content, err := s.st.ArtifactContentByID(a.ID) if err != nil { writeStoreErr(w, err) return } ct := contentTypeOf(a) w.Header().Set("Content-Type", ct) setArtifactHeaders(w, ct) 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(settings.SystemDefaults(), schema), "user": settings.Masked(mustUser(s.set, owner), schema), "project": settings.Masked(mustScope(s.set, owner, projectID), schema), }) } 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 } s.writeSettingsMerge(w, func() error { return 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 } s.writeSettingsMerge(w, func() error { return s.set.MergeProject(s.owner(r), r.PathValue("id"), values) }) } // writeSettingsMerge 统一处理设置写入结果:触碰系统受管项返回 400 并列出路径, // 其余错误按 writeOKOr 既有语义处理。 func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) { err := merge() if err == nil { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } var mp *settings.ErrManagedPath if errors.As(err, &mp) { http.Error(w, err.Error(), http.StatusBadRequest) return } writeStoreErr(w, err) } // ---- 系统设置(管理员;P2 受管项的唯一写入面)---- // requireSystemAdmin 系统设置面的准入:认证档要求基座管理员;无认证单机档 // (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏 // 嵌入式部署的配置播种,不增加安全边界。 func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { if s.Auth == nil { return s.owner(r), true } return s.requireAdmin(w, r) } func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemAdmin(w, r); !ok { return } schema := append(settings.Schema(), s.plugins.SettingsSchema()...) sys, err := s.set.SystemScope() if err != nil { writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, map[string]any{ "schema": schema, "defaults": settings.Masked(settings.SystemDefaults(), schema), "system": settings.Masked(sys, schema), }) } func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemAdmin(w, r); !ok { return } 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.MergeSystem(values)) } // ---- 工具(用户声明式 HTTP 工具注册)---- // visibleTools Hidden(预留能力)工具按租户生效设置过滤:runtime.fsTools 未开不可见。 func (s *Server) visibleTools(owner string) []core.ToolDesc { list := tools.FilterExecDisabled(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}) } // ---- 插件 ---- // CompiledPlugin 是一个编译进二进制的进程内插件的注册面摘要(WP3-I4): // ID 即插件身份;Commands/Intents/Skills 为装载期成功注册的声明资产名。 // 命令规格与处理器在命令总线,摘要只服务 /api/plugins 管理清单展示。 type CompiledPlugin struct { ID string Commands []string Intents []string Skills []string AdminZones []AdminZoneSection } // AdminZoneSection 是编译插件声明的管理区展示分区(WP3-I3/KTD4): // 字段与 extension.AdminZoneSpec 对齐(组合根装载期记录);入口不做基座级 // 权限判定,分区内数据能力由组件自身命令面 fail-closed。 type AdminZoneSection struct { ID string `json:"id"` Title string `json:"title"` Icon string `json:"icon,omitempty"` Desc string `json:"desc,omitempty"` Order int `json:"order,omitempty"` } 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 dirIDs := map[string]bool{} for _, ld := range s.plugins.ListFor(s.owner(r), r.URL.Query().Get("projectId")) { dirIDs[ld.Manifest.ID] = true 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, }) } // 编译插件(WP3-I4):条目由注册面合成——backend.mode=inproc 标记编译 // 来源,apiVersion 取扩展面版本;与目录插件同名时目录 manifest 权威 // (目录版本可装卸、信息更全),编译条目不重复列出。 for _, cp := range s.CompiledPlugins { if dirIDs[cp.ID] { continue } out = append(out, pluginView{Manifest: core.PluginManifest{ ID: cp.ID, Name: cp.ID, APIVersion: extension.SurfaceVersion, Provides: core.PluginProvides{Commands: cp.Commands, Intents: cp.Intents, Skills: cp.Skills}, Backend: core.PluginBackend{Mode: "inproc"}, }}) } 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()) } // listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的 // 声明并按 Order→ID 稳定排序。声明是展示元数据,对所有认证主体一致; // 分区内可见数据由分区组件自身的命令面按 owner fail-closed 判定。 func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) { var out []AdminZoneSection for _, cp := range s.CompiledPlugins { out = append(out, cp.AdminZones...) } sort.Slice(out, func(i, j int) bool { if out[i].Order != out[j].Order { return out[i].Order < out[j].Order } return out[i].ID < out[j].ID }) writeJSON(w, http.StatusOK, orEmptySlice(out)) } // 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 } } } } } out = s.appendUserPromptCommands(s.owner(r), out) 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} owner := s.owner(r) if s.projectLifecycle != nil && query.ProjectID != "" { id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID) if err != nil { writeProjectErr(w, err) return } query.ProjectID = id } if s.ProjectAuthzV2 != nil { allow := func(d core.KnowledgeDoc) bool { ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) return err == nil && ok } if rs, ok := s.Retriever.(interface { RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) }); ok { hits, err := rs.RetrieveAuthorized(r.Context(), owner, query, allow) writeJSONOr(w, hits, err) return } hits, err := s.st.SearchKnowledge(owner, query) if err != nil { writeStoreErr(w, err) return } filtered := make([]core.RetrievalHit, 0, len(hits)) for _, h := range hits { if allow(h.Doc) { filtered = append(filtered, h) } } shared, err := s.st.SearchKnowledgeSharedAuthorized(owner, query, allow) if err != nil { writeStoreErr(w, err) return } filtered = append(filtered, shared...) if query.TopK > 0 && len(filtered) > query.TopK { filtered = filtered[:query.TopK] } writeJSON(w, http.StatusOK, filtered) return } // EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版; // 否则维持切片 2 行为(本人检索 + 全文共享追加)。 allow := func(docOwner, projectID core.ID) bool { return s.authorizeSharedResource(owner, "search", "project", projectID, docOwner, projectID) } if rs, ok := s.Retriever.(interface { RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) }); ok && s.Retriever != nil { hits, err := rs.RetrieveShared(r.Context(), core.ID(owner), query, allow) writeJSONOr(w, hits, err) return } var hits []core.RetrievalHit var err error if s.Retriever != nil { hits, err = s.Retriever.Retrieve(r.Context(), owner, query) } else { hits, err = s.st.SearchKnowledge(owner, query) } if err != nil { writeStoreErr(w, err) return } shared, serr := s.st.SearchKnowledgeShared(owner, query, allow) if serr != nil { writeStoreErr(w, serr) return } hits = append(hits, shared...) if limit := query.TopK; limit > 0 && len(hits) > limit { hits = hits[:limit] } 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, store.ErrBadCredentials) { http.Error(w, "current password incorrect", http.StatusUnauthorized) return } if errors.Is(err, runmgr.ErrConversationBusy) { http.Error(w, err.Error(), http.StatusConflict) return } if errors.Is(err, runmgr.ErrAdmissionQueueFull) { http.Error(w, err.Error(), http.StatusTooManyRequests) 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 orEmptySlice[T any](s []T) []T { if s == nil { return []T{} } return s }