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.
780 lines
24 KiB
Go
780 lines
24 KiB
Go
// 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
|
||
}
|