// 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 切换画像 · /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 ", 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 ", 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 }