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