ci / vet-and-test (pull_request) Successful in 46s
Why: Preserve the newest protected main for the combined review candidate. Evidence: Exact incoming object fetched; affected checks will replay. Impact: Retain both histories without relaxing source or task authority. Refs: Dev main aa6364f
2040 lines
75 KiB
Go
2040 lines
75 KiB
Go
// Package server 提供 HTTP/SSE API 与内嵌前端(go:embed)。
|
||
// API-first:前端只是第一个客户端;全部能力走公开 API。
|
||
package server
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"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)
|
||
// ProjectAuthzV2 EXT-8b 资源级授权适配器(U9;组合根注入;nil=未注册,
|
||
// 读面回落 ProjectAuthz 兼容)。查询六要素由本包受信构造。
|
||
ProjectAuthzV2 func(q extension.ResourceAuthzQuery) (bool, error)
|
||
// ProjectAuthzActions 动作级授权适配器(协作存储 U3,KTD13):注册后对
|
||
// 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律
|
||
// 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展
|
||
// 计划见 docs/guides/team-storage.md(KTD13 一节)。
|
||
ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error)
|
||
// CompiledPlugins 编译进二进制的进程内插件摘要(组合根在 applyCompiled
|
||
// 期间记录注册面生成;nil/空 = 无编译插件,清单行为不变)。
|
||
CompiledPlugins []CompiledPlugin
|
||
}
|
||
|
||
// ProjectAction 项目资源的动作授权矩阵(KTD13)。共享可读不等于可写或可
|
||
// 批准:动作语义独立判定,未配置的动作默认拒绝。
|
||
type ProjectAction string
|
||
|
||
const (
|
||
ProjectRead ProjectAction = "read" // 读取项目资源(会话/消息/检索/工件下载)
|
||
ProjectEdit ProjectAction = "edit" // 写入与修改(会话内发消息、卡片裁决、设置变更)
|
||
ProjectApprove ProjectAction = "approve" // 审批(HITL 确认、命令确认)
|
||
ProjectExecute ProjectAction = "execute" // 触发执行(run、工具、工作流)
|
||
ProjectAdmin ProjectAction = "admin" // 管理(成员、共享范围、销毁性操作)
|
||
)
|
||
|
||
// SetProjectAuthorizer 组合根在装载扩展后注入 EXT-8 适配器。
|
||
func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (bool, error)) {
|
||
s.ProjectAuthz = fn
|
||
}
|
||
|
||
// SetResourceAuthorizerV2 组合根在装载扩展后注入 EXT-8b 资源级适配器。
|
||
func (s *Server) SetResourceAuthorizerV2(fn func(q extension.ResourceAuthzQuery) (bool, error)) {
|
||
s.ProjectAuthzV2 = fn
|
||
}
|
||
|
||
// SetProjectActionAuthorizer 组合根注入 KTD13 动作级适配器(版本化公开契约
|
||
// 扩展落地前,宿主侧先以本槽位提供矩阵语义)。
|
||
func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core.ID, action ProjectAction) (bool, error)) {
|
||
s.ProjectAuthzActions = fn
|
||
}
|
||
|
||
// authorizeProjectAction 动作矩阵唯一判定(KTD13):
|
||
// - action-aware 适配器已注册 → 权威(出错/拒绝一律 false,fail-closed);
|
||
// - 未注册 → read 回落旧只读 ProjectAuthz(兼容 EXT-8 现网),其余动作
|
||
// 默认拒绝(旧适配器不得自动获得写入能力)。
|
||
func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action ProjectAction) bool {
|
||
if s.ProjectAuthzActions != nil {
|
||
ok, err := s.ProjectAuthzActions(owner, projectID, action)
|
||
return err == nil && ok
|
||
}
|
||
if action == ProjectRead {
|
||
return s.authorizeSharedResource(owner, "read", "project", projectID, "", projectID)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// authorizeSharedResource EXT-8b 资源级读判定(U9):V2 注册时按六要素咨询
|
||
// (Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、
|
||
// Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed
|
||
// 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。
|
||
func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool {
|
||
if s.ProjectAuthzV2 == nil {
|
||
return s.authorizeSharedProject(actor, projectID)
|
||
}
|
||
if actor == "" || action == "" || resourceType == "" || resourceID == "" {
|
||
return false
|
||
}
|
||
ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{
|
||
Actor: actor, Action: action, ResourceType: resourceType,
|
||
ResourceID: resourceID, Owner: owner, ProjectID: projectID,
|
||
})
|
||
return err == nil && ok
|
||
}
|
||
|
||
// authorizeSharedProject 读取路径的共享判定(V1):适配器未注册或拒绝/出错一律
|
||
// false(fail-closed;错误不区分,避免泄露存在性)。EXT-8b 落地后仅作 V2 缺席
|
||
// 时的兼容回落——新读面一律走 authorizeSharedResource。
|
||
func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
|
||
if s.ProjectAuthz == nil {
|
||
return false
|
||
}
|
||
ok, err := s.ProjectAuthz(owner, projectID)
|
||
return err == nil && ok
|
||
}
|
||
|
||
func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server {
|
||
sub, err := web.Dist()
|
||
if err != nil {
|
||
panic(err) // embed 布局错误属构建期问题
|
||
}
|
||
return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub}
|
||
}
|
||
|
||
// NLIntentGate 意图闸门端口(intentgate.Gate 实现;避免包依赖方向反转)。
|
||
type NLIntentGate interface {
|
||
Match(ctx context.Context, input string) (string, string)
|
||
}
|
||
|
||
type intentRecognizer interface {
|
||
Recognize(ctx context.Context, input string) intentgate.MatchResult
|
||
}
|
||
|
||
func (s *Server) recognize(ctx context.Context, text string) intentgate.MatchResult {
|
||
if rg, ok := s.Gate.(intentRecognizer); ok {
|
||
return rg.Recognize(ctx, text)
|
||
}
|
||
branch, id := s.Gate.Match(ctx, text)
|
||
return intentgate.MatchResult{Branch: branch, ID: id}
|
||
}
|
||
|
||
// WireBuiltinCommands 注册内置命令(`/` 菜单全清单的数据源;处理器为宿主用例适配)。
|
||
// 响应保持既有 {command, ...} 契约(frontend store.sendMessage 按此消费)。
|
||
func (s *Server) WireBuiltinCommands() {
|
||
b := s.Bus
|
||
if b == nil {
|
||
return
|
||
}
|
||
must := func(err error) {
|
||
if err != nil {
|
||
panic("server: builtin command: " + err.Error()) // 装配期错误属编程错误
|
||
}
|
||
}
|
||
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "builtin.help", Title: "帮助", Description: "查看可用命令与入口说明"},
|
||
"/help", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||
return map[string]any{"reply": fmt.Sprintf("可用命令(注册表 %d 条)——/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile <id> 切换画像 · /help 帮助", len(b.List()))}, nil
|
||
}))
|
||
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "system.intent", Title: "意图路由",
|
||
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单;button 入口只读匹配、不嵌套分发",
|
||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
|
||
"/intent", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||
return handleSystemIntent(ctx, b, req)
|
||
}))
|
||
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话",
|
||
ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)},
|
||
"/new", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||
conv, err := s.st.GetConversation(req.Owner, req.ConversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
c, err := s.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
|
||
}))
|
||
}
|
||
|
||
// handleSystemIntent 实现 system.intent:命中后默认嵌套分发目标命令。
|
||
// B 类硬化:Entrance=button(页面动作)只返回匹配结果,禁止嵌套业务写路径。
|
||
func handleSystemIntent(ctx context.Context, b *cmdbus.Bus, req cmdbus.Request) (any, error) {
|
||
text, _ := req.Params["text"].(string)
|
||
in, ok := b.Intents().Match(text)
|
||
if !ok {
|
||
list := b.Intents().List()
|
||
cands := make([]map[string]any, 0, len(list))
|
||
for _, x := range list {
|
||
cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command})
|
||
}
|
||
return map[string]any{"matched": false, "candidates": cands}, nil
|
||
}
|
||
if agentcommand.CanonicalEntrance(req.Entrance) == "button" {
|
||
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
|
||
"dispatched": false, "note": "button entrance: match only, no nested dispatch"}, nil
|
||
}
|
||
params := map[string]any{}
|
||
for k, v := range in.Params {
|
||
params[k] = v
|
||
}
|
||
for k, v := range req.Params {
|
||
if k != "text" {
|
||
params[k] = v
|
||
}
|
||
}
|
||
resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat",
|
||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID})
|
||
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
|
||
"dispatched": true, "receipt": resp.Receipt, "payload": resp.Payload}, nil
|
||
}
|
||
|
||
func (s *Server) Handler() http.Handler {
|
||
mux := http.NewServeMux()
|
||
|
||
// ---- 认证与会话(管理员发放制,R2)----
|
||
mux.HandleFunc("POST /api/auth/login", s.login)
|
||
mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey)
|
||
mux.HandleFunc("POST /api/auth/logout", s.logout)
|
||
mux.HandleFunc("POST /api/auth/password", s.changePassword)
|
||
mux.HandleFunc("GET /api/admin/users", s.listAdminUsers)
|
||
mux.HandleFunc("POST /api/admin/users", s.createAdminUser)
|
||
mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword)
|
||
mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey)
|
||
mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys)
|
||
mux.HandleFunc("DELETE /api/admin/users/{owner}/login-keys/{id}", s.revokeAdminLoginKey)
|
||
|
||
// ---- 项目 / 会话 ----
|
||
mux.HandleFunc("GET /api/projects", s.listProjects)
|
||
mux.HandleFunc("POST /api/projects", s.createProject)
|
||
mux.HandleFunc("PATCH /api/projects/{id}", s.patchProject)
|
||
mux.HandleFunc("DELETE /api/projects/{id}", s.deleteProject)
|
||
mux.HandleFunc("GET /api/projects/{id}/conversations", s.listConversations)
|
||
mux.HandleFunc("POST /api/projects/{id}/conversations", s.createConversation)
|
||
mux.HandleFunc("GET /api/conversations", s.listTasks)
|
||
mux.HandleFunc("GET /api/conversations/{id}", s.conversationDetail)
|
||
mux.HandleFunc("GET /api/conversations/{id}/export", s.exportConversation)
|
||
mux.HandleFunc("PATCH /api/conversations/{id}", s.patchConversation)
|
||
mux.HandleFunc("DELETE /api/conversations/{id}", s.deleteConversation)
|
||
mux.HandleFunc("POST /api/conversations/{id}/messages", s.postMessage)
|
||
mux.HandleFunc("GET /api/conversations/{id}/checkpoints", s.listCheckpoints)
|
||
mux.HandleFunc("POST /api/conversations/{id}/rewind", s.rewindFiles)
|
||
mux.HandleFunc("POST /api/conversations/{id}/regenerate", s.regenerateMessage)
|
||
mux.HandleFunc("POST /api/messages/{id}/exclude", s.excludeMessage)
|
||
mux.HandleFunc("POST /api/messages/{id}/variant", s.selectVariant)
|
||
mux.HandleFunc("GET /api/secrets", s.listSecrets)
|
||
mux.HandleFunc("POST /api/secrets", s.createSecret)
|
||
mux.HandleFunc("POST /api/secrets/{id}/activate", s.activateSecret)
|
||
mux.HandleFunc("PATCH /api/secrets/{id}", s.renameSecret)
|
||
mux.HandleFunc("DELETE /api/secrets/{id}", s.deleteSecret)
|
||
mux.HandleFunc("GET /api/context-entries", s.listContextEntries)
|
||
mux.HandleFunc("POST /api/context-entries", s.saveContextEntry)
|
||
mux.HandleFunc("DELETE /api/context-entries/{id}", s.deleteContextEntry)
|
||
mux.HandleFunc("GET /api/prompt-commands", s.listUserPromptCommands)
|
||
mux.HandleFunc("POST /api/prompt-commands", s.saveUserPromptCommand)
|
||
mux.HandleFunc("DELETE /api/prompt-commands/{id}", s.deleteUserPromptCommand)
|
||
mux.HandleFunc("POST /api/runs/{id}/cancel", s.cancelRun)
|
||
mux.HandleFunc("GET /api/runs/{id}", s.getRun)
|
||
|
||
// ---- HITL ----
|
||
mux.HandleFunc("POST /api/hitl/{id}/resolve", s.resolveHITL)
|
||
|
||
// ---- 收件箱任务卡(提案 T2;项目收件箱 D48-D51)----
|
||
mux.HandleFunc("GET /api/conversations/{id}/cards", s.listCards)
|
||
mux.HandleFunc("GET /api/cards/{id}/sources/{sourceId}", s.taskSource)
|
||
mux.HandleFunc("GET /api/projects/{id}/cards", s.listProjectCards)
|
||
mux.HandleFunc("POST /api/cards/{id}/actions/{actionId}", s.cardAction)
|
||
mux.HandleFunc("POST /api/cards/{id}/pin", s.cardPin)
|
||
mux.HandleFunc("POST /api/cards/{id}/dismiss", s.cardDismiss)
|
||
mux.HandleFunc("POST /api/cards/{id}/to_project", s.cardToProject)
|
||
|
||
// ---- 事件流(SSE)----
|
||
mux.HandleFunc("GET /api/events", s.events)
|
||
|
||
// ---- 上传与工件 ----
|
||
mux.HandleFunc("POST /api/projects/{id}/uploads", s.upload)
|
||
mux.HandleFunc("GET /api/projects/{id}/artifacts", s.listArtifacts)
|
||
mux.HandleFunc("GET /api/artifacts/{id}", s.getArtifact)
|
||
mux.HandleFunc("GET /api/artifacts/{id}/content", s.artifactContent)
|
||
mux.HandleFunc("PUT /api/artifacts/{id}/content", s.putArtifactContent)
|
||
mux.HandleFunc("DELETE /api/artifacts/{id}", s.deleteArtifact)
|
||
|
||
// ---- 画像 / 设置 / 工具 / 插件 / 注册表 ----
|
||
mux.HandleFunc("GET /api/profiles", s.listProfiles)
|
||
mux.HandleFunc("POST /api/profiles", s.saveProfile)
|
||
mux.HandleFunc("DELETE /api/profiles/{id}", s.deleteProfile)
|
||
mux.HandleFunc("GET /api/prompts", s.listPromptModules)
|
||
mux.HandleFunc("POST /api/prompts", s.savePromptModule)
|
||
mux.HandleFunc("DELETE /api/prompts/{id}", s.deletePromptModule)
|
||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||
mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser)
|
||
mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject)
|
||
mux.HandleFunc("GET /api/settings/system", s.getSettingsSystem)
|
||
mux.HandleFunc("PUT /api/settings/system", s.putSettingsSystem)
|
||
mux.HandleFunc("GET /api/tools", s.listTools)
|
||
mux.HandleFunc("GET /api/url-preview", s.urlPreview)
|
||
mux.HandleFunc("POST /api/tools", s.createUserTool)
|
||
mux.HandleFunc("DELETE /api/tools/{name}", s.deleteUserTool)
|
||
mux.HandleFunc("GET /api/plugins", s.listPlugins)
|
||
mux.HandleFunc("POST /api/plugins/{id}/enabled", s.setPluginEnabled)
|
||
mux.HandleFunc("POST /api/plugins/{id}/activation", s.setSurfaceActivation)
|
||
mux.HandleFunc("POST /api/surfaces", s.submitSurface)
|
||
mux.HandleFunc("GET /api/surfaces/{id}", s.getSurface)
|
||
mux.HandleFunc("POST /api/surfaces/{id}/actions", s.submitSurfaceAction)
|
||
mux.HandleFunc("GET /api/conversations/{id}/surfaces", s.listSurfaces)
|
||
mux.HandleFunc("GET /api/projects/{id}/surfaces", s.listProjectSurfaces)
|
||
mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins)
|
||
mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema)
|
||
mux.HandleFunc("GET /api/registry", s.registry)
|
||
// WP3-I3/KTD4:管理区声明分区清单(编译插件经 Registry.AddAdminZone 声明;
|
||
// 展示元数据对所有认证主体开放,分区内数据能力由组件命令面 fail-closed)。
|
||
mux.HandleFunc("GET /api/admin-zone/sections", s.listAdminZoneSections)
|
||
|
||
// ---- 计划 / 待办 / 命令 / 解析器 / 检索 ----
|
||
mux.HandleFunc("GET /api/conversations/{id}/plan", s.getPlan)
|
||
mux.HandleFunc("GET /api/conversations/{id}/todos", s.getTodos)
|
||
mux.HandleFunc("GET /api/commands", s.listCommands)
|
||
mux.HandleFunc("GET /api/workspaces", s.listWorkspaces)
|
||
mux.HandleFunc("POST /api/commands/{id}", s.execCommand)
|
||
mux.HandleFunc("GET /api/commands/audit", s.listAudits)
|
||
mux.HandleFunc("GET /api/parsers", s.listParsers)
|
||
mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge)
|
||
mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge)
|
||
|
||
mux.HandleFunc("POST /api/tasks", s.createBackgroundTask)
|
||
mux.HandleFunc("GET /api/tasks", s.listBackgroundTasks)
|
||
mux.HandleFunc("GET /api/tasks/{id}", s.getBackgroundTask)
|
||
mux.HandleFunc("POST /api/tasks/{id}", s.updateBackgroundTask)
|
||
mux.HandleFunc("POST /api/memory", s.putMemory)
|
||
mux.HandleFunc("GET /api/memory", s.listMemory)
|
||
mux.HandleFunc("GET /api/audit", s.listAudit)
|
||
mux.HandleFunc("POST /api/audit", s.postAudit)
|
||
mux.HandleFunc("GET /api/llm/budget", s.llmBudget)
|
||
mux.HandleFunc("GET /api/me", s.me)
|
||
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ts": time.Now().UTC()})
|
||
})
|
||
|
||
// ---- 管理(嵌入式档在线备份)----
|
||
mux.HandleFunc("POST /api/admin/backup", s.backup)
|
||
mux.HandleFunc("GET /api/admin/recovery-holds", s.listRecoveryHolds)
|
||
mux.HandleFunc("POST /api/admin/recovery-holds/{runId}/release", s.releaseRecoveryHold)
|
||
|
||
// API 未匹配路径一律 404(不回落 SPA,避免 /api/* 返回 HTML 200)。
|
||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
|
||
|
||
// 静态前端(embed);API 全链路:认证(未启用 = 默认租户)→ 租户限流。
|
||
inner := s.withRateLimit(mux)
|
||
var h http.Handler = inner
|
||
if s.Auth != nil {
|
||
h = s.Auth.Middleware(inner)
|
||
}
|
||
mux.HandleFunc("/", s.serveStatic)
|
||
return h
|
||
}
|
||
|
||
// ---- SSE:游标重放 + 在线订阅 + 终态对账由 Query 承担 ----
|
||
|
||
const sseRetryMillis = 1000
|
||
|
||
// sseAuthRecheck:会话凭证的 SSE 长连接复验周期。密码写入(恢复/改密/重置)
|
||
// 会删除全部会话并 bump auth_version;已建立的流最多在此窗口内继续收事件,
|
||
// 心跳点复验失效即断流(EventSource 重连时由中间件拒绝)。测试可缩短。
|
||
var sseAuthRecheck = 15 * time.Second
|
||
|
||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||
conversationID := r.URL.Query().Get("conversationId")
|
||
isProject := false
|
||
// 项目流(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-8b:资源级咨询(stream×project);V2 缺席回落 V1。
|
||
if !s.authorizeSharedResource(s.owner(r), "stream", "project", core.ID(pid), "", core.ID(pid)) {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
}
|
||
conversationID = string(core.ProjectChannelKey(core.ID(pid)))
|
||
isProject = true
|
||
} 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)
|
||
}
|
||
|
||
// 长连接凭证复验(login 恢复合同 §6.7):会话凭证查会话行(密码写入即删除);
|
||
// API key 凭证查撤销位(RevokeAllAPIKeys/失陷撤销后断流)。两种凭证都在
|
||
// 复验周期内核验属主未变。
|
||
sessionTok, keyCred := "", ""
|
||
switch AuthVia(r) {
|
||
case AuthViaSession:
|
||
sessionTok = sessionToken(r)
|
||
case AuthViaKey:
|
||
keyCred = bearerKey(r)
|
||
if keyCred == "" {
|
||
keyCred = r.URL.Query().Get("key")
|
||
}
|
||
}
|
||
wantOwner := s.owner(r)
|
||
stillAuthed := func() bool {
|
||
if sessionTok != "" {
|
||
o, _, ok := s.st.SessionInfo(sessionTok)
|
||
return ok && o == wantOwner
|
||
}
|
||
if keyCred != "" {
|
||
o, ok := s.st.AuthOwner(keyCred)
|
||
return ok && o == wantOwner
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。
|
||
live, stop := s.Subscribe(conversationID)
|
||
defer stop()
|
||
// Flush readiness only after subscribing; clients can publish immediately
|
||
// after receiving this frame without falling into an intake gap.
|
||
fmt.Fprintf(w, "retry: %d\n\n", sseRetryMillis)
|
||
flusher.Flush()
|
||
|
||
send := func(e core.StreamEvent) bool {
|
||
if (e.Type == core.EvCardCreated || e.Type == core.EvCardUpdated) && s.Cards != nil {
|
||
var p core.CardPayload
|
||
if json.Unmarshal(e.Payload, &p) == nil && p.Card.Task != nil {
|
||
p.Card = s.Cards.VisibleTaskCard(s.owner(r), p.Card)
|
||
e.Payload, _ = json.Marshal(p)
|
||
}
|
||
}
|
||
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()
|
||
recheck := time.NewTicker(sseAuthRecheck)
|
||
defer recheck.Stop()
|
||
for {
|
||
select {
|
||
case <-r.Context().Done():
|
||
stop()
|
||
return
|
||
case <-recheck.C:
|
||
if !stillAuthed() {
|
||
fmt.Fprint(w, ": session-expired\n\n")
|
||
flusher.Flush()
|
||
stop()
|
||
return
|
||
}
|
||
case <-heartbeat.C:
|
||
fmt.Fprint(w, ": ping\n\n")
|
||
flusher.Flush()
|
||
case e, ok := <-live:
|
||
if !ok {
|
||
stop()
|
||
return
|
||
}
|
||
if e.ConversationID != conversationID || e.Seq <= 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 sc := SessionScope(r); sc != "" {
|
||
out["sessionScope"] = sc
|
||
}
|
||
if st, err := s.st.GetUserAuthState(owner); err == nil {
|
||
out["isAdmin"] = st.IsAdmin
|
||
out["mustChangePassword"] = st.MustChangePassword
|
||
out["passwordSet"] = st.HasPassword
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Title string `json:"title"`
|
||
ProfileID string `json:"profileId"`
|
||
}
|
||
json.NewDecoder(r.Body).Decode(&req)
|
||
if req.Title == "" {
|
||
req.Title = "新会话"
|
||
}
|
||
c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||
// EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者,
|
||
// 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。
|
||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(s.owner(r), "read", "project", core.ID(r.PathValue("id")), "", 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 || art.ProjectID != conv.ProjectID {
|
||
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)
|
||
hash := sha256.Sum256(content)
|
||
doc.Meta.Origin = &core.DocumentOrigin{ArtifactID: art.ID, ContentHash: hex.EncodeToString(hash[:]), ParserRevision: "parsed-ir.v1/" + doc.Meta.Parser}
|
||
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")
|
||
// 跨实例取消(协作存储 U5/KTD5)**前置**:run 不在本实例执行(无句柄)
|
||
// 且 PG 档——写持久命令,由执行实例消费;API 立即受理(202)。
|
||
// 不得先调本地 CancelRun:非执行实例无句柄时它返回 nil 的「假成功」
|
||
// 会吞掉命令路径,run 永远停不下(U7 双进程验收实测)。
|
||
// conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远
|
||
// 无法送达(同批实测)。
|
||
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
|
||
convID := ""
|
||
if run, gerr := s.st.GetRun(s.owner(r), runID); gerr == nil {
|
||
convID = string(run.ConversationID)
|
||
}
|
||
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "cancel", runID, convID, 0,
|
||
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}); cerr != nil {
|
||
writeStoreErr(w, cerr)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"})
|
||
return
|
||
}
|
||
writeOKOr(w, s.mgr.CancelRun(runID))
|
||
}
|
||
|
||
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
|
||
run, err := s.st.GetRun(s.owner(r), r.PathValue("id"))
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
children, _ := s.st.ChildRuns(run.ID)
|
||
writeJSON(w, http.StatusOK, map[string]any{"run": run, "children": children})
|
||
}
|
||
|
||
// ---- HITL ----
|
||
|
||
func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
|
||
var resp core.HITLResponse
|
||
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
resp.CardID = r.PathValue("id")
|
||
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
|
||
}
|
||
snapshot, err := s.st.CardsAtCursor(owner, r.PathValue("id"))
|
||
if err == nil && s.Cards != nil {
|
||
for i := range snapshot.Cards {
|
||
snapshot.Cards[i] = s.Cards.VisibleTaskCard(owner, snapshot.Cards[i])
|
||
}
|
||
}
|
||
writeJSONOr(w, snapshot, 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"))
|
||
if err == nil && s.Cards != nil {
|
||
for i := range cards {
|
||
cards[i] = s.Cards.VisibleTaskCard(owner, cards[i])
|
||
}
|
||
}
|
||
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-8b:资源级咨询(list×project);放行则返回项目全部工件(只读共享)。
|
||
if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", 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
|
||
}
|
||
// EXT-8b:工件级六要素(Owner=工件属主,受信元数据解析)。
|
||
if !s.authorizeSharedResource(owner, "download", "artifact", a.ID, a.Owner, a.ProjectID) {
|
||
return core.Artifact{}, false
|
||
}
|
||
return a, true
|
||
}
|
||
|
||
func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
|
||
owner := s.owner(r)
|
||
a, err := s.st.GetArtifact(owner, r.PathValue("id"))
|
||
if err != nil {
|
||
shared, ok := s.sharedArtifactGet(owner, r.PathValue("id"))
|
||
if !ok {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, shared)
|
||
return
|
||
}
|
||
writeJSONOr(w, a, err)
|
||
}
|
||
|
||
func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||
owner := s.owner(r)
|
||
a, err := s.st.GetArtifact(owner, r.PathValue("id"))
|
||
if err != nil {
|
||
shared, ok := s.sharedArtifactGet(owner, r.PathValue("id"))
|
||
if !ok {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
a = shared
|
||
}
|
||
content, err := s.st.ArtifactContentByID(a.ID)
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
if expected := r.URL.Query().Get("expectedHash"); expected != "" {
|
||
sum := sha256.Sum256(content)
|
||
if hex.EncodeToString(sum[:]) != expected {
|
||
http.Error(w, "来源已更新,请从文件列表查看当前版本;任务中的引用片段仍可用于历史核验", http.StatusConflict)
|
||
return
|
||
}
|
||
}
|
||
ct := contentTypeOf(a)
|
||
w.Header().Set("Content-Type", ct)
|
||
setArtifactHeaders(w, ct)
|
||
w.Header().Set("Content-Disposition", contentDisposition(a.Name, r.URL.Query().Get("download") == "1"))
|
||
w.Write(content)
|
||
}
|
||
|
||
func (s *Server) putArtifactContent(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(io.LimitReader(r.Body, 20<<20))
|
||
writeOKOr(w, s.st.UpdateArtifactContent(s.owner(r), r.PathValue("id"), body))
|
||
}
|
||
|
||
func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) {
|
||
writeOKOr(w, s.st.DeleteArtifact(s.owner(r), r.PathValue("id")))
|
||
}
|
||
|
||
// ---- 画像 ----
|
||
|
||
func (s *Server) listProfiles(w http.ResponseWriter, r *http.Request) {
|
||
ps, err := s.prof.List(s.owner(r))
|
||
writeJSONOr(w, ps, err)
|
||
}
|
||
|
||
func (s *Server) saveProfile(w http.ResponseWriter, r *http.Request) {
|
||
var p core.AgentProfile
|
||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if p.ID == "" {
|
||
p.ID = "prof_" + strconv.FormatInt(time.Now().UnixNano()%100000, 10)
|
||
}
|
||
if err := s.prof.Save(s.owner(r), p); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, p)
|
||
}
|
||
|
||
func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
|
||
writeOKOr(w, s.prof.Delete(s.owner(r), r.PathValue("id")))
|
||
}
|
||
|
||
// ---- 设置 ----
|
||
|
||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||
owner := s.owner(r)
|
||
projectID := r.URL.Query().Get("project")
|
||
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"schema": schema,
|
||
"defaults": settings.Masked(settings.SystemDefaults(), schema),
|
||
"user": settings.Masked(mustUser(s.set, owner), schema),
|
||
"project": settings.Masked(mustScope(s.set, owner, projectID), schema),
|
||
})
|
||
}
|
||
|
||
func mustUser(s *settings.Service, owner string) map[string]any {
|
||
m, _ := s.UserScope(owner)
|
||
if m == nil {
|
||
return map[string]any{}
|
||
}
|
||
return m
|
||
}
|
||
|
||
func mustScope(s *settings.Service, owner, projectID string) map[string]any {
|
||
if projectID == "" {
|
||
return map[string]any{}
|
||
}
|
||
m, _ := s.ProjectScope(owner, projectID)
|
||
return m
|
||
}
|
||
|
||
func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
|
||
var values map[string]any
|
||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) })
|
||
}
|
||
|
||
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||
var values map[string]any
|
||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
s.writeSettingsMerge(w, func() error {
|
||
return s.set.MergeProject(s.owner(r), r.PathValue("id"), values)
|
||
})
|
||
}
|
||
|
||
// writeSettingsMerge 统一处理设置写入结果:触碰系统受管项返回 400 并列出路径,
|
||
// 其余错误按 writeOKOr 既有语义处理。
|
||
func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) {
|
||
err := merge()
|
||
if err == nil {
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
return
|
||
}
|
||
var mp *settings.ErrManagedPath
|
||
if errors.As(err, &mp) {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
writeStoreErr(w, err)
|
||
}
|
||
|
||
// ---- 系统设置(管理员;P2 受管项的唯一写入面)----
|
||
|
||
// requireSystemAdmin 系统设置面的准入:认证档要求基座管理员;无认证单机档
|
||
// (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏
|
||
// 嵌入式部署的配置播种,不增加安全边界。
|
||
func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||
if s.Auth == nil {
|
||
return s.owner(r), true
|
||
}
|
||
return s.requireAdmin(w, r)
|
||
}
|
||
|
||
func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
|
||
sys, err := s.set.SystemScope()
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"schema": schema,
|
||
"defaults": settings.Masked(settings.SystemDefaults(), schema),
|
||
"system": settings.Masked(sys, schema),
|
||
})
|
||
}
|
||
|
||
func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
var values map[string]any
|
||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
writeOKOr(w, s.set.MergeSystem(values))
|
||
}
|
||
|
||
// ---- 工具(用户声明式 HTTP 工具注册)----
|
||
|
||
// visibleTools Hidden(预留能力)工具按租户生效设置过滤:runtime.fsTools 未开不可见。
|
||
func (s *Server) visibleTools(owner string) []core.ToolDesc {
|
||
list := tools.FilterExecDisabled(s.reg.List())
|
||
if s.set.Effective(owner, "").GetBool("runtime.fsTools", false) {
|
||
return list
|
||
}
|
||
out := make([]core.ToolDesc, 0, len(list))
|
||
for _, t := range list {
|
||
if !t.Hidden {
|
||
out = append(out, t)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *Server) listTools(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, s.visibleTools(s.owner(r)))
|
||
}
|
||
|
||
func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
|
||
var desc core.ToolDesc
|
||
if err := json.NewDecoder(r.Body).Decode(&desc); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
desc.Source = core.SourceUser
|
||
if desc.HTTP == nil {
|
||
http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest)
|
||
return
|
||
}
|
||
lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") }
|
||
if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, desc)
|
||
}
|
||
|
||
func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) {
|
||
name := r.PathValue("name")
|
||
if desc, _, ok := s.reg.Get(name); ok && desc.Source != core.SourceUser {
|
||
http.Error(w, "仅可删除 user 来源工具", http.StatusForbidden)
|
||
return
|
||
}
|
||
s.reg.UnregisterBySource(core.SourceUser, name)
|
||
writeJSON(w, http.StatusOK, map[string]any{"deleted": name})
|
||
}
|
||
|
||
// ---- 插件 ----
|
||
|
||
// CompiledPlugin 是一个编译进二进制的进程内插件的注册面摘要(WP3-I4):
|
||
// ID 即插件身份;Commands/Intents/Skills 为装载期成功注册的声明资产名。
|
||
// 命令规格与处理器在命令总线,摘要只服务 /api/plugins 管理清单展示。
|
||
type CompiledPlugin struct {
|
||
ID string
|
||
Commands []string
|
||
Intents []string
|
||
Skills []string
|
||
AdminZones []AdminZoneSection
|
||
}
|
||
|
||
// AdminZoneSection 是编译插件声明的管理区展示分区(WP3-I3/KTD4):
|
||
// 字段与 extension.AdminZoneSpec 对齐(组合根装载期记录);入口不做基座级
|
||
// 权限判定,分区内数据能力由组件自身命令面 fail-closed。
|
||
type AdminZoneSection struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
Icon string `json:"icon,omitempty"`
|
||
Desc string `json:"desc,omitempty"`
|
||
Order int `json:"order,omitempty"`
|
||
}
|
||
|
||
type pluginView struct {
|
||
Manifest core.PluginManifest `json:"manifest"`
|
||
Panels []core.PluginPanel `json:"panels"`
|
||
Cards map[string][]core.UINode `json:"cards"`
|
||
Workflows []core.WorkflowDef `json:"workflows"`
|
||
Intents []json.RawMessage `json:"intents"`
|
||
Prompts map[string]string `json:"prompts"`
|
||
Settings []core.SettingDesc `json:"settings"`
|
||
Pages []core.PluginPageDecl `json:"pages,omitempty"`
|
||
Channels []core.PluginChannelDecl `json:"channels,omitempty"`
|
||
Skills map[string]string `json:"skills,omitempty"`
|
||
}
|
||
|
||
func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
|
||
var out []pluginView
|
||
dirIDs := map[string]bool{}
|
||
for _, ld := range s.plugins.ListFor(s.owner(r), r.URL.Query().Get("projectId")) {
|
||
dirIDs[ld.Manifest.ID] = true
|
||
out = append(out, pluginView{
|
||
Manifest: ld.Manifest, Panels: ld.Panels, Cards: ld.Cards,
|
||
Workflows: ld.Workflows, Intents: ld.Intents, Prompts: ld.Prompts, Settings: ld.PluginSettings,
|
||
Pages: ld.Pages, Channels: ld.Channels, Skills: ld.Skills,
|
||
})
|
||
}
|
||
// 编译插件(WP3-I4):条目由注册面合成——backend.mode=inproc 标记编译
|
||
// 来源,apiVersion 取扩展面版本;与目录插件同名时目录 manifest 权威
|
||
// (目录版本可装卸、信息更全),编译条目不重复列出。
|
||
for _, cp := range s.CompiledPlugins {
|
||
if dirIDs[cp.ID] {
|
||
continue
|
||
}
|
||
out = append(out, pluginView{Manifest: core.PluginManifest{
|
||
ID: cp.ID, Name: cp.ID, APIVersion: extension.SurfaceVersion,
|
||
Provides: core.PluginProvides{Commands: cp.Commands, Intents: cp.Intents, Skills: cp.Skills},
|
||
Backend: core.PluginBackend{Mode: "inproc"},
|
||
}})
|
||
}
|
||
writeJSON(w, http.StatusOK, orEmptySlice(out))
|
||
}
|
||
|
||
func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) {
|
||
results := s.plugins.LoadDir()
|
||
writeJSON(w, http.StatusOK, map[string]any{"reloaded": len(results), "errors": results})
|
||
}
|
||
|
||
func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, s.plugins.SettingsSchema())
|
||
}
|
||
|
||
// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的
|
||
// 声明并按 Order→ID 稳定排序。声明是展示元数据,对所有认证主体一致;
|
||
// 分区内可见数据由分区组件自身的命令面按 owner fail-closed 判定。
|
||
func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) {
|
||
var out []AdminZoneSection
|
||
for _, cp := range s.CompiledPlugins {
|
||
out = append(out, cp.AdminZones...)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Order != out[j].Order {
|
||
return out[i].Order < out[j].Order
|
||
}
|
||
return out[i].ID < out[j].ID
|
||
})
|
||
writeJSON(w, http.StatusOK, orEmptySlice(out))
|
||
}
|
||
|
||
// registry 统一注册表视图(来源标记 + 按来源启停入口)。
|
||
func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
|
||
reg := map[string]any{
|
||
"tools": s.visibleTools(s.owner(r)),
|
||
"parsers": ingest.ParsersDescription(),
|
||
"panels": flattenPanels(s.plugins.List()),
|
||
}
|
||
writeJSON(w, http.StatusOK, reg)
|
||
}
|
||
|
||
func flattenPanels(lds []*plugins.Loaded) []core.PluginPanel {
|
||
var out []core.PluginPanel
|
||
for _, ld := range lds {
|
||
out = append(out, ld.Panels...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ---- 计划 / 待办 / 命令 / 检索 ----
|
||
|
||
func (s *Server) getPlan(w http.ResponseWriter, r *http.Request) {
|
||
p, err := s.st.GetPlan(r.PathValue("id"))
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeJSON(w, http.StatusOK, map[string]any{})
|
||
return
|
||
}
|
||
writeJSONOr(w, p, err)
|
||
}
|
||
|
||
func (s *Server) getTodos(w http.ResponseWriter, r *http.Request) {
|
||
l, err := s.st.GetTodos(r.PathValue("id"))
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeJSON(w, http.StatusOK, map[string]any{})
|
||
return
|
||
}
|
||
writeJSONOr(w, l, err)
|
||
}
|
||
|
||
func (s *Server) listCommands(w http.ResponseWriter, r *http.Request) {
|
||
out := []map[string]any{}
|
||
if s.Bus != nil {
|
||
slashOf := map[string]string{}
|
||
for slash, id := range s.Bus.SlashTable() {
|
||
slashOf[id] = slash
|
||
}
|
||
for _, c := range s.Bus.List() {
|
||
item := map[string]any{"id": c.Spec.ID, "title": c.Spec.Title, "description": c.Spec.Description, "danger": string(c.Spec.Danger), "source": string(c.Source), "enabled": c.Enabled}
|
||
if slash, ok := slashOf[c.Spec.ID]; ok {
|
||
item["name"] = slash // legacy 兼容:`/` 菜单按 name 消费
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
}
|
||
// 提示词库技能命令(命令触发型 task 模块):进 `/` 菜单同源展示(发送时由
|
||
// postMessage 改写为技能任务,不走命令总线)。
|
||
if s.Prompts != nil {
|
||
mods, err := s.Prompts.Effective(s.owner(r))
|
||
if err == nil {
|
||
for _, m := range mods {
|
||
if m.Disabled || m.Layer != core.LayerTask {
|
||
continue
|
||
}
|
||
for _, t := range m.Triggers {
|
||
if t.Kind == core.TriggerCommand && t.Command != "" {
|
||
out = append(out, map[string]any{
|
||
"id": m.ID, "name": t.Command, "title": m.Title,
|
||
"description": m.Description, "source": "prompt-skill", "enabled": true,
|
||
})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out = s.appendUserPromptCommands(s.owner(r), out)
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// ---- 提示词库 API(覆盖链:内置 embed → 用户 DB 行)----
|
||
|
||
func (s *Server) listPromptModules(w http.ResponseWriter, r *http.Request) {
|
||
if s.Prompts == nil {
|
||
http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
mods, err := s.Prompts.Effective(s.owner(r))
|
||
writeJSONOr(w, mods, err)
|
||
}
|
||
|
||
func (s *Server) savePromptModule(w http.ResponseWriter, r *http.Request) {
|
||
if s.Prompts == nil {
|
||
http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
var m core.PromptModule
|
||
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if m.ID == "" || m.Layer == "" || strings.TrimSpace(m.Body) == "" {
|
||
http.Error(w, "id/layer/body required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := s.Prompts.Save(s.owner(r), m); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, m)
|
||
}
|
||
|
||
func (s *Server) deletePromptModule(w http.ResponseWriter, r *http.Request) {
|
||
if s.Prompts == nil {
|
||
http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.Delete(s.owner(r), r.PathValue("id")))
|
||
}
|
||
func (s *Server) listParsers(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, ingest.ParsersDescription())
|
||
}
|
||
|
||
func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) {
|
||
var docs []core.KnowledgeDoc
|
||
if err := json.NewDecoder(r.Body).Decode(&docs); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
owner := s.owner(r)
|
||
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
for i := range docs {
|
||
docs[i].Owner = owner
|
||
docs[i].ProjectID = r.PathValue("id")
|
||
if docs[i].Source == "" {
|
||
docs[i].Source = "manual"
|
||
}
|
||
}
|
||
writeOKOr(w, s.st.AddKnowledge(docs...))
|
||
}
|
||
|
||
func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query().Get("q")
|
||
if q == "" {
|
||
http.Error(w, "q required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
|
||
query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK}
|
||
owner := s.owner(r)
|
||
// EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版;
|
||
// 否则维持切片 2 行为(本人检索 + 全文共享追加)。
|
||
allow := func(docOwner, projectID core.ID) bool {
|
||
return s.authorizeSharedResource(owner, "search", "project", projectID, docOwner, projectID)
|
||
}
|
||
if rs, ok := s.Retriever.(interface {
|
||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||
}); ok && s.Retriever != nil {
|
||
hits, err := rs.RetrieveShared(r.Context(), core.ID(owner), query, allow)
|
||
writeJSONOr(w, hits, err)
|
||
return
|
||
}
|
||
var hits []core.RetrievalHit
|
||
var err error
|
||
if s.Retriever != nil {
|
||
hits, err = s.Retriever.Retrieve(r.Context(), owner, query)
|
||
} else {
|
||
hits, err = s.st.SearchKnowledge(owner, query)
|
||
}
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
shared, serr := s.st.SearchKnowledgeShared(owner, query, allow)
|
||
if serr != nil {
|
||
writeStoreErr(w, serr)
|
||
return
|
||
}
|
||
hits = append(hits, shared...)
|
||
if limit := query.TopK; limit > 0 && len(hits) > limit {
|
||
hits = hits[:limit]
|
||
}
|
||
writeJSONOr(w, hits, err)
|
||
}
|
||
|
||
// ---- 帮助 ----
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
func writeJSONOr(w http.ResponseWriter, v any, err error) {
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, v)
|
||
}
|
||
|
||
func writeOKOr(w http.ResponseWriter, err error) {
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
http.Error(w, err.Error(), http.StatusNotFound)
|
||
return
|
||
}
|
||
if errors.Is(err, store.ErrConflict) {
|
||
http.Error(w, err.Error(), http.StatusConflict)
|
||
return
|
||
}
|
||
if errors.Is(err, store.ErrBadCredentials) {
|
||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if errors.Is(err, runmgr.ErrConversationBusy) {
|
||
http.Error(w, err.Error(), http.StatusConflict)
|
||
return
|
||
}
|
||
if errors.Is(err, runmgr.ErrAdmissionQueueFull) {
|
||
http.Error(w, err.Error(), http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
}
|
||
|
||
// backup 触发一致性在线备份(嵌入式档:VACUUM INTO;PG 档见运维指南 pg_dump)。
|
||
func (s *Server) backup(w http.ResponseWriter, r *http.Request) {
|
||
dir := filepath.Join(s.st.DataDir(), "backups")
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
dest := filepath.Join(dir, "agent-system-"+time.Now().UTC().Format("20060102T150405Z")+".db")
|
||
if err := s.st.Backup(dest); err != nil {
|
||
http.Error(w, err.Error(), http.StatusNotImplemented)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": dest})
|
||
}
|
||
|
||
// extMIME:常见文档扩展的显式类型表——系统 mime.types 不保证收录 docx/xlsx 等
|
||
// Office 格式,显式兜底让上传件的下载与浏览器预览拿到正确 Content-Type。
|
||
var extMIME = map[string]string{
|
||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||
".doc": "application/msword",
|
||
".xls": "application/vnd.ms-excel",
|
||
".ppt": "application/vnd.ms-powerpoint",
|
||
".pdf": "application/pdf",
|
||
".zip": "application/zip",
|
||
".csv": "text/csv; charset=utf-8",
|
||
".md": "text/markdown; charset=utf-8",
|
||
}
|
||
|
||
func contentTypeOf(a core.Artifact) string {
|
||
if a.MIME != "" {
|
||
return a.MIME
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(a.Name))
|
||
if t, ok := extMIME[ext]; ok {
|
||
return t
|
||
}
|
||
if t := mime.TypeByExtension(ext); t != "" {
|
||
return t
|
||
}
|
||
if a.Kind == "image" {
|
||
return "application/octet-stream"
|
||
}
|
||
return "text/plain; charset=utf-8"
|
||
}
|
||
|
||
// contentDisposition:工件文件名带中文(如 会议纪要.docx)时用 RFC 5987 的
|
||
// filename* 权威编码,另给纯 ASCII 兜底;?download=1 切换 attachment(下载),
|
||
// 默认 inline(浏览器内预览)。
|
||
func contentDisposition(name string, download bool) string {
|
||
disposition := "inline"
|
||
if download {
|
||
disposition = "attachment"
|
||
}
|
||
return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFallbackName(name), url.PathEscape(name))
|
||
}
|
||
|
||
// asciiFallbackName:非 ASCII 与引号/反斜杠替换为下划线,仅作 filename 兜底。
|
||
func asciiFallbackName(name string) string {
|
||
var b strings.Builder
|
||
for _, r := range name {
|
||
if r >= 0x20 && r < 0x7f && r != '"' && r != '\\' {
|
||
b.WriteRune(r)
|
||
} else {
|
||
b.WriteRune('_')
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func orEmptySlice[T any](s []T) []T {
|
||
if s == nil {
|
||
return []T{}
|
||
}
|
||
return s
|
||
}
|