Files
Agent-System-Backend/internal/server/server.go
T
weichen 0d74ec94dd Implement the backend: store/bus/runmgr core
Details (original message):
Implement the backend: store/bus/runmgr core, mock and LLM runners (agent-native client), HITL three-branch routing, subagent runs, builtin and declarative HTTP tools, ingest parsers (md/txt/docx/pdf-text), knowledge fulltext, layered settings, profiles, plugin host, REST+SSE API, single-binary entrypoint; e2e and unit tests.
2026-09-29 17:53:24 +00:00

780 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package server 提供 HTTP/SSE API 与内嵌前端(go:embed)。
// API-first:前端只是第一个客户端;全部能力走公开 API。
package server
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"strconv"
"strings"
"time"
core "agent-system-core"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
"agent-system-backend/web"
)
type Server struct {
st *store.Store
mgr *runmgr.Manager
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}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// ---- 项目 / 会话 ----
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/{id}", s.conversationDetail)
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)
// ---- 事件流(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/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("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/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/parsers", s.listParsers)
mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge)
mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge)
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()})
})
// 静态前端(embed)。
mux.HandleFunc("/", s.serveStatic)
return mux
}
// ---- SSE:游标重放 + 在线订阅 + 终态对账由 Query 承担 ----
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
conversationID := r.URL.Query().Get("conversationId")
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")
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()
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(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(id, *req.Name); err != nil {
writeStoreErr(w, err)
return
}
}
if req.Settings != nil {
if err := s.st.UpdateProjectSettings(id, req.Settings); err != nil {
writeStoreErr(w, err)
return
}
}
p, err := s.st.GetProject(id)
writeJSONOr(w, p, err)
}
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteProject(r.PathValue("id")))
}
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
cs, err := s.st.ListConversations(r.PathValue("id"))
writeJSONOr(w, cs, err)
}
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(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")
conv, err := s.st.GetConversation(id)
if err != nil {
writeStoreErr(w, err)
return
}
msgs, _ := s.st.ListMessages(id)
runs, _ := s.st.ListRunsByConversation(id)
cards, _ := s.st.ActiveHITLCards(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"`
}
json.NewDecoder(r.Body).Decode(&req)
writeOKOr(w, s.st.UpdateConversation(id, req.Title, req.ProfileID))
}
func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteConversation(r.PathValue("id")))
}
// ---- 消息发送(含命令路由与附件) ----
func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
convID := r.PathValue("id")
conv, err := s.st.GetConversation(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(4.11 输入增强)。
if strings.HasPrefix(text, "/") {
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
for _, artID := range req.AttachmentIDs {
art, err := s.st.GetArtifact(artID)
if err != nil {
http.Error(w, "attachment "+artID+" not found", http.StatusBadRequest)
return
}
content, _ := s.st.ArtifactContent(artID)
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
}
run, err := s.mgr.StartConversationRun(conv, msg, parsedDocs)
writeJSONOr(w, map[string]any{"message": msg, "run": run}, err)
}
// handleCommand 内置命令(注册表数据驱动,插件可扩展 commands 后补)。
func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core.Conversation, text string) {
fields := strings.Fields(text)
cmd := strings.ToLower(fields[0])
switch cmd {
case "/help":
writeJSON(w, http.StatusOK, map[string]any{"command": cmd, "reply": "可用命令:/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile <id> 切换画像 · /help 帮助"})
case "/new":
c, err := s.st.CreateConversation(conv.ProjectID, "新会话", conv.ProfileID)
writeJSONOr(w, map[string]any{"command": cmd, "conversation": c}, err)
case "/mock":
if len(fields) < 2 || (fields[1] != "on" && fields[1] != "off") {
http.Error(w, "usage: /mock on|off", http.StatusBadRequest)
return
}
val := fields[1] == "on"
writeJSONOr(w, map[string]any{"command": cmd, "runtime.mock": val}, s.set.MergeUser(map[string]any{"runtime": map[string]any{"mock": val}}))
case "/model":
if len(fields) < 2 {
http.Error(w, "usage: /model <model-name>", http.StatusBadRequest)
return
}
writeJSONOr(w, map[string]any{"command": cmd, "model": fields[1]}, s.set.MergeUser(map[string]any{"models": map[string]any{"chat": map[string]any{"model": fields[1]}}}))
case "/profile":
if len(fields) < 2 {
http.Error(w, "usage: /profile <id>", http.StatusBadRequest)
return
}
writeJSONOr(w, map[string]any{"command": cmd, "profile": fields[1]}, s.st.UpdateConversation(conv.ID, "", fields[1]))
default:
http.Error(w, "未知命令 "+cmd+"(/help 查看)", http.StatusBadRequest)
}
}
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(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(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) 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
}
mime := header.Header.Get("Content-Type")
art := core.Artifact{ProjectID: projectID, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
if err := s.st.CreateArtifact(&art, content); err != nil {
writeStoreErr(w, err)
return
}
doc := ingest.Parse(art.Name, mime, content)
// 文本可解析的进知识索引(全文检索)。
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
_ = s.st.AddKnowledge(core.KnowledgeDoc{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) {
as, err := s.st.ListArtifacts(r.PathValue("id"))
writeJSONOr(w, as, err)
}
func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
a, err := s.st.GetArtifact(r.PathValue("id"))
writeJSONOr(w, a, err)
}
func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
a, err := s.st.GetArtifact(r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
content, err := s.st.ArtifactContent(a.ID)
if err != nil {
writeStoreErr(w, err)
return
}
w.Header().Set("Content-Type", contentTypeOf(a))
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(r.PathValue("id"), body))
}
func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteArtifact(r.PathValue("id")))
}
// ---- 画像 ----
func (s *Server) listProfiles(w http.ResponseWriter, r *http.Request) {
ps, err := s.prof.List()
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(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(r.PathValue("id")))
}
// ---- 设置 ----
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
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)),
"project": maskMap(mustScope(s.set, projectID)),
})
}
func mustUser(s *settings.Service) map[string]any {
m, _ := s.UserScope()
if m == nil {
return map[string]any{}
}
return m
}
func mustScope(s *settings.Service, projectID string) map[string]any {
if projectID == "" {
return map[string]any{}
}
m, _ := s.ProjectScope(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(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(r.PathValue("id"), values))
}
// ---- 工具(用户声明式 HTTP 工具注册)----
func (s *Server) listTools(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.reg.List())
}
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("").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"`
}
func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
var out []pluginView
for _, ld := range s.plugins.List() {
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,
})
}
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.reg.List(),
"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)
}
var builtinCommands = []map[string]string{
{"name": "/help", "description": "查看可用命令"},
{"name": "/new", "description": "新建会话"},
{"name": "/mock", "description": "演示模式 on|off(不调用真实模型)"},
{"name": "/model", "description": "设置主模型名称"},
{"name": "/profile", "description": "切换会话画像"},
}
func (s *Server) listCommands(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, builtinCommands)
}
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
}
for i := range docs {
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"))
hits, err := s.st.SearchKnowledge(core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK})
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
}
http.Error(w, err.Error(), http.StatusInternalServerError)
}
func contentTypeOf(a core.Artifact) string {
if a.MIME != "" {
return a.MIME
}
if a.Kind == "image" {
return "application/octet-stream"
}
return "text/plain; charset=utf-8"
}
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
}