Files
Agent-System-Backend/internal/server/server.go
T
weichen 1719b5f668
ci / vet-and-test (pull_request) Successful in 49s
Merge authoritative main into the admin-zone branch
2026-10-12 02:37:57 +00:00

1855 lines
68 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 "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/route"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
"agent-system-backend/pkg/extension"
"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 = 不启用;组合根注入)
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)
// 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
}
// 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.authorizeSharedProject(owner, projectID)
}
return false
}
// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false
// (fail-closed;错误不区分,避免泄露存在性)。
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 <id> 切换画像 · /help 帮助", len(b.List()))}, nil
}))
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "system.intent", Title: "意图路由",
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
"/intent", func(ctx context.Context, 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
}
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,
"receipt": resp.Receipt, "payload": resp.Payload}, 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("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/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
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
conversationID := r.URL.Query().Get("conversationId")
isProject := false
// 项目流(D50):scope=project&projectId=<id> 订阅项目频道——伪会话键
// "project:<id>" 复用会话流的持久化/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-8 切片 2:owner 自有未命中时咨询适配器(放行才可订阅项目流)。
if !s.authorizeSharedProject(s.owner(r), core.ID(pid)) {
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
}
}
// 浏览器 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)
}
// 先订阅再重放,避免重放与订阅间隙丢事件;按 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
for {
replay, err := s.st.EventsAfter(conversationID, lastSent, 1000)
if err != nil || len(replay) == 0 {
break
}
for _, e := range replay {
if e.Seq <= lastSent {
continue
}
if !send(e) {
stop()
return
}
lastSent = e.Seq
}
if len(replay) < 1000 {
break
}
}
// 心跳 + 在线转发。通道关闭表示缓冲溢出,结束响应让 EventSource 带 Last-Event-ID 重连。
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, ok := <-live:
if !ok {
stop()
return
}
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"
}
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)
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
}
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 为默认租户)。
// 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 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)
// EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者,
// 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedProject(s.owner(r), core.ID(r.PathValue("id"))) {
c, err = s.st.CreateConversationInProject(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)
// 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
}
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)
writeStoreErr(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 <model-name>")
}
return map[string]any{"model": strings.Join(args, " ")}, nil
case "conversation.profile":
if len(args) < 1 {
return nil, errors.New("usage: /profile <id>")
}
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 != "" {
if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil {
hostProject = string(conv.ProjectID)
}
}
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")
err := s.mgr.CancelRun(runID)
if err == nil {
writeOKOr(w, nil)
return
}
// 跨实例取消(协作存储 U5/KTD5):run 不在本实例执行(无句柄)且 PG 档
// ——写持久命令,由执行实例消费;API 立即受理(命令可见、唯一消费)。
if s.st.Dialect() == "postgres" && s.mgr.RunActiveLocally(runID) == false {
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "cancel", runID, "", 0,
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}); cerr != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"})
return
}
writeStoreErr(w, err)
}
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 && 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)
var body struct {
FreeText string `json:"freeText,omitempty"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
// 命令确认卡(ref=cmd:<id>)走总线回道: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)
}
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
// 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 := s.owner(r)
pid := r.PathValue("id")
if _, err := s.st.GetProject(owner, pid); err != nil {
// EXT-8:owner 自有未命中时问适配器;放行则返回项目全部工件(只读共享)。
if !s.authorizeSharedProject(owner, core.ID(pid)) {
writeStoreErr(w, err)
return
}
as, err := s.st.ListProjectArtifactsAll(pid)
writeJSONOr(w, as, err)
return
}
as, err := s.st.ListArtifacts(owner, pid)
writeJSONOr(w, as, err)
}
// 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
}
if !s.authorizeSharedProject(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
}
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 := 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)
// EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版;
// 否则维持切片 2 行为(本人检索 + 全文共享追加)。
allow := func(docOwner, projectID core.ID) bool {
return s.authorizeSharedProject(owner, 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, 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
}