From 642e1c39b1fadbf6ed7fafeef4179fe28ef61d1f Mon Sep 17 00:00:00 2001 From: HomeAgent Agent Date: Mon, 14 Sep 2026 07:03:47 +0800 Subject: [PATCH] =?UTF-8?q?refactor(webui):=20handler.go=202993=20?= =?UTF-8?q?=E8=A1=8C=E6=8C=89=E8=B5=84=E6=BA=90=E6=8B=86=E6=88=90=2011=20?= =?UTF-8?q?=E4=B8=AA=E5=90=8C=E5=8C=85=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆法:按「资源面」搬家,每个顶层声明(func/type/var/const)整体搬到目标文件, 声明体一字未改,各文件按实际用到的包重新生成 import。文件头加一行说明本文件负责哪一面。 handler.go 骨架:嵌入前端资源、Handler/构造、路由表、鉴权会话日志中间件、静态页 handler_chat.go 对话面:消息模型与内存历史、SSE 事件订阅、对话/历史接口 handler_upload.go 上传面:handleChatFile / handleUploads / 中断对话 handler_memory.go 记忆面:图/文档/文本记忆、知识库、LLM 源、变更追踪 handler_agents.go 内核与代理面:状态、kernel、人格、代理/快照/回滚 handler_settings.go 设置与插件面:配置读写、插件列表详情(含 pluginmgr 反代) handler_terminal.go 终端面:终端会话、终端接口、命令历史 handler_sse.go SSE 环形缓冲(断线重连补发) handler_openai.go OpenAI 兼容面:/v1/chat/completions handler_device.go 设备网关反代(HTTP + WS 升级透传) handler_files.go /files/ 与 /uploads/ 下载 零漂移校验:拿重构前的 handler.go 与新 11 个文件逐行比对(忽略空行、package/import 头), **丢失行 0**;新增行恰好是 11 个文件头注释(14 行)。 顺带修掉 import 里两处假使用:handler_openai 的 sdk 只作为 Handler 字段名出现(h.sdk.), handler_settings 的 fmt 只出现在注释里 —— 都从 import 里去掉。 验证:go build ./... / go vet / webui+config+sdk 测试全绿; 起真实实例(沿用已有 data 目录)后 /status /settings /chat/history /plugins /terminals /kernel /memory /config /login 全部 200,设置在注入 5MB 历史的情况下仍是 33,921 字节。 最大文件从 2993 → 706 行(handler_chat.go)。 --- internal/plugins/webui/handler.go | 2491 +---------------- internal/plugins/webui/handler_agents.go | 240 ++ internal/plugins/webui/handler_chat.go | 706 +++++ internal/plugins/webui/handler_device.go | 191 ++ internal/plugins/webui/handler_files.go | 84 + internal/plugins/webui/handler_memory.go | 336 +++ internal/plugins/webui/handler_openai.go | 194 ++ internal/plugins/webui/handler_plugin_test.go | 2 +- internal/plugins/webui/handler_settings.go | 365 +++ internal/plugins/webui/handler_sse.go | 76 + internal/plugins/webui/handler_terminal.go | 88 + internal/plugins/webui/handler_upload.go | 335 +++ 12 files changed, 2623 insertions(+), 2485 deletions(-) create mode 100644 internal/plugins/webui/handler_agents.go create mode 100644 internal/plugins/webui/handler_chat.go create mode 100644 internal/plugins/webui/handler_device.go create mode 100644 internal/plugins/webui/handler_files.go create mode 100644 internal/plugins/webui/handler_memory.go create mode 100644 internal/plugins/webui/handler_openai.go create mode 100644 internal/plugins/webui/handler_settings.go create mode 100644 internal/plugins/webui/handler_sse.go create mode 100644 internal/plugins/webui/handler_terminal.go create mode 100644 internal/plugins/webui/handler_upload.go diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 8a951f6..b91ce70 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -2,33 +2,25 @@ package webui import ( "bufio" - "context" - "crypto/rand" "embed" - "encoding/base64" - "encoding/hex" - "encoding/json" "fmt" - "io" "log" - "math" "net" - "net/http" - "os" - "path/filepath" - "sort" "strconv" "strings" "sync" "time" - agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" - internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" - "gitcode.com/JianFeeeee/HomeAgent/internal/meta" + "crypto/rand" + "encoding/hex" + "encoding/json" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" - "gitcode.com/JianFeeeee/HomeAgent/pkg/types" + "net/http" ) +// 本文件是 WebUI 的骨架:嵌入式前端资源、Handler 结构、构造、路由表、 +// 鉴权/会话/日志中间件与静态页。各资源的具体 handler 见同包 handler_*.go。 + //go:embed dashboard.html mascot.webp logo.svg var dashboardFS embed.FS @@ -71,51 +63,6 @@ func init() { } } -// sseEventRecord 保存一条 SSE 事件元数据,供断线重连时按 Last-Event-ID 重放遗漏事件。 -type sseEventRecord struct { - id string // SSE 事件 id 值(如 "1234567890-5") - eventType string // 事件类型(agent_output, reasoning 等) - data json.RawMessage // 序列化后的 payload JSON -} - -// sseEventRing 是一个固定大小的环状缓冲区,保持最近 cap 条 SSE 事件。 -type sseEventRing struct { - mu sync.Mutex - buf []sseEventRecord - cap int -} - -func newSSEEventRing(cap int) *sseEventRing { - return &sseEventRing{cap: cap} -} - -// Append 追加一条事件,超过容量时丢弃最旧条目。 -func (r *sseEventRing) Append(id, eventType string, data json.RawMessage) { - r.mu.Lock() - defer r.mu.Unlock() - r.buf = append(r.buf, sseEventRecord{id: id, eventType: eventType, data: data}) - if len(r.buf) > r.cap { - r.buf = r.buf[len(r.buf)-r.cap:] - } -} - -// After 返回所有在指定 id 之后的事件(按写入顺序),若 id 不在缓冲区中则返回全部。 -func (r *sseEventRing) After(id string) []sseEventRecord { - r.mu.Lock() - defer r.mu.Unlock() - for i := len(r.buf) - 1; i >= 0; i-- { - if r.buf[i].id == id { - result := make([]sseEventRecord, len(r.buf)-i-1) - copy(result, r.buf[i+1:]) - return result - } - } - // ID 不在缓冲区(可能是太旧或从未收到),返回全部 - result := make([]sseEventRecord, len(r.buf)) - copy(result, r.buf) - return result -} - type Handler struct { sdk *sdk.PluginSDK supervisor sdk.SupervisorAPI @@ -154,92 +101,6 @@ type Handler struct { termStates map[string]*termState } -type ChatMsg struct { - Role string `json:"role"` - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` - ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` - Source string `json:"source,omitempty"` - Time string `json:"time"` - // Attachment 附件输出(output_send__webui type=image/file): - // image 前端内联展示,file 渲染下载卡片。nil 表示纯文本消息。 - Attachment *Attachment `json:"attachment,omitempty"` -} - -// Attachment 描述一条附件消息(与 SSE agent_output 事件的 output_type/url/size 对应)。 -type Attachment struct { - Type string `json:"type"` // "image" | "file" - URL string `json:"url"` // /files/ 或远程 http(s) URL - Size int64 `json:"size,omitempty"` // 字节数(远程 URL 为 0) - Name string `json:"name,omitempty"` // 展示用文件名 -} - -type ChatToolCall struct { - Tool string `json:"tool"` - Name string `json:"name,omitempty"` - Args interface{} `json:"args,omitempty"` - Result interface{} `json:"result,omitempty"` - Status string `json:"status,omitempty"` - Plugin string `json:"plugin,omitempty"` -} - -type CmdExec struct { - Command string `json:"command"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - ExitCode int `json:"exit_code"` - Status string `json:"status"` - Time string `json:"time"` -} - -type termState struct { - ID string `json:"id"` - Command string `json:"command"` - Running bool `json:"running"` - Output string `json:"output"` - CreatedAt string `json:"created_at"` - Uptime string `json:"uptime"` - created time.Time -} - -const maxChatHistory = 200 -const maxCmdHistory = 100 -const maxTerminals = 50 - -// ===== client_msg_id 去重(防 GUI 断线重连/超时重试导致的消息重放)===== -// GUI 端每条发送消息带唯一 client_msg_id;服务端按 ID 单飞(singleflight): -// 首次请求正常注入 agent,同 ID 重放等待首次结果并直接复用,不再重复处理。 - -const maxChatMsgCache = 256 - -type chatMsgEntry struct { - done chan struct{} - resp *agentIO.OutputEvent -} - -func (h *Handler) claimChatMsg(id string) (*chatMsgEntry, bool) { - h.chatMsgMu.Lock() - defer h.chatMsgMu.Unlock() - if e, ok := h.chatMsgCache[id]; ok { - return e, true - } - e := &chatMsgEntry{done: make(chan struct{})} - h.chatMsgCache[id] = e - h.chatMsgOrder = append(h.chatMsgOrder, id) - if len(h.chatMsgOrder) > maxChatMsgCache { - old := h.chatMsgOrder[0] - h.chatMsgOrder = h.chatMsgOrder[1:] - delete(h.chatMsgCache, old) - } - return e, false -} - -// completeChatMsg 记录首次处理结果并唤醒所有等待的同 ID 重放请求。 -func (h *Handler) completeChatMsg(e *chatMsgEntry, resp *agentIO.OutputEvent) { - e.resp = resp - close(e.done) -} - func NewHandler(s *sdk.PluginSDK) *Handler { var ( sup sdk.SupervisorAPI @@ -295,331 +156,6 @@ func NewHandler(s *sdk.PluginSDK) *Handler { return h } -// subscribeTerminalStream 常驻订阅终端实时画面推流(terminal_output 事件), -// 维护 termStates 的 Running 状态与全量输出缓冲,供 /api/v1/terminals 与前端轮询使用。 -func (h *Handler) subscribeTerminalStream() { - if h.sdk == nil { - return - } - h.sdk.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) { - id, _ := ev.Payload["terminal_id"].(string) - if id == "" { - return - } - output, _ := ev.Payload["output"].(string) - running, _ := ev.Payload["running"].(bool) - h.termMu.Lock() - ts, ok := h.termStates[id] - if !ok { - ts = &termState{ID: id, created: time.Now()} - h.termStates[id] = ts - } - ts.Running = running - if output != "" { - const maxTermOutput = 64 * 1024 - if len(ts.Output)+len(output) > maxTermOutput { - excess := len(ts.Output) + len(output) - maxTermOutput - if len(ts.Output) > excess { - ts.Output = ts.Output[excess:] - } else { - ts.Output = "" - } - } - ts.Output += output - } - h.termMu.Unlock() - }) -} - -func (h *Handler) loadChatHistory() { - if h.history == nil { - return - } - msgs := h.history.LoadWithMigration(h.settings) - if len(msgs) == 0 { - return - } - h.chatMu.Lock() - h.chatHistory = msgs - h.chatMu.Unlock() -} - -func (h *Handler) trackToolEvents() { - if h.sdk == nil { - return - } - h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) { - h.handleToolEvent(ev) - }) -} - -// subscribeChatEvents 捕获所有通道(cli/qq/webui 等)的对话轮次, -// 与 handleChat 的注入一起构成完整的全通道对话历史。 -func (h *Handler) subscribeChatEvents() { - if h.sdk == nil { - return - } - h.sdk.Subscribe(sdk.EventRawInput, func(ev *sdk.Event) { - content, _ := ev.Payload["content"].(string) - source, _ := ev.Payload["source"].(string) - // 用户上传的附件(handleChatFile 注入的 payload 携带 upload_* 字段) - var att *Attachment - if url, _ := ev.Payload["upload_url"].(string); url != "" { - ut, _ := ev.Payload["upload_type"].(string) - var size int64 - switch v := ev.Payload["upload_size"].(type) { - case int64: - size = v - case float64: - size = int64(v) - } - name, _ := ev.Payload["upload_name"].(string) - att = &Attachment{Type: ut, URL: url, Size: size, Name: name} - } - if content == "" { - return - } - h.chatMu.Lock() - h.pendingIdx = -1 - h.chatMu.Unlock() - h.addChatMsg(ChatMsg{ - Role: "user", - Content: content, - Source: source, - Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), - Attachment: att, - }) - }) - h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) { - tool, _ := ev.Payload["tool"].(string) - if tool == "" { - return - } - channel, _ := ev.Payload["channel"].(string) - if channel == "_consolidation_" { - return - } - plugin, _ := ev.Payload["plugin"].(string) - status, _ := ev.Payload["status"].(string) - if status == "" { - status = "ok" - } - tc := ChatToolCall{ - Tool: tool, - Name: tool, - Args: ev.Payload["args"], - Result: ev.Payload["result"], - Status: status, - Plugin: plugin, - } - h.chatMu.Lock() - msg := h.pendingAssistantLocked() - if msg == nil { - h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)}) - h.pendingIdx = len(h.chatHistory) - 1 - msg = &h.chatHistory[h.pendingIdx] - } - msg.ToolCalls = append(msg.ToolCalls, tc) - h.persistChatLocked() - h.chatMu.Unlock() - }) - h.sdk.Subscribe(sdk.EventReasoning, func(ev *sdk.Event) { - content, _ := ev.Payload["content"].(string) - if content == "" { - return - } - channel, _ := ev.Payload["channel"].(string) - if channel == "_consolidation_" { - return - } - h.chatMu.Lock() - msg := h.pendingAssistantLocked() - if msg == nil { - h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)}) - h.pendingIdx = len(h.chatHistory) - 1 - msg = &h.chatHistory[h.pendingIdx] - } - msg.ReasoningContent += content - h.persistChatLocked() - h.chatMu.Unlock() - }) - h.sdk.Subscribe(sdk.EventAgentOutput, func(ev *sdk.Event) { - content, _ := ev.Payload["content"].(string) - channel, _ := ev.Payload["channel"].(string) - kind, _ := ev.Payload["kind"].(string) - h.chatMu.Lock() - // 输出通道主动输出(output_send__{通道})作为独立气泡,不并入最终回复 - if kind == "channel_output" { - h.pendingIdx = -1 - // 附件输出(output_type=image/file):存 attachment 字段供前端渲染, - // content 保留原始 payload 作为备选文案(历史兼容旧数据)。 - var att *Attachment - if ot, _ := ev.Payload["output_type"].(string); ot == "image" || ot == "file" { - url, _ := ev.Payload["url"].(string) - size, _ := ev.Payload["size"].(int64) - if f, ok := ev.Payload["size"].(float64); ok && size == 0 { - size = int64(f) - } - name := url - if i := strings.LastIndexByte(url, '/'); i >= 0 { - name = url[i+1:] - } - att = &Attachment{Type: ot, URL: url, Size: size, Name: name} - } - if content == "" && att == nil { - h.chatMu.Unlock() - return - } - m := ChatMsg{ - Role: "assistant", - Source: channel, - Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), - Attachment: att, - } - // 附件消息不把本地路径当正文展示(如 "/tmp/homeagent.png"),置空 - if att != nil { - m.Content = "" - } else { - m.Content = content - } - // 已持 chatMu:直接操作 chatHistory + persist,不可调 addChatMsg - //(内部会重入加锁导致死锁——output_send__webui 发图 60s 超时的根因) - h.chatHistory = append(h.chatHistory, m) - if len(h.chatHistory) > maxChatHistory { - drop := len(h.chatHistory) - maxChatHistory - h.chatHistory = h.chatHistory[drop:] - if h.pendingIdx >= 0 { - h.pendingIdx -= drop - if h.pendingIdx < 0 { - h.pendingIdx = -1 - } - } - } - h.persistChatLocked() - h.chatMu.Unlock() - return - } - if msg := h.pendingAssistantLocked(); msg != nil && content != "" { - msg.Content = content - if channel != "" { - msg.Source = channel - } - h.pendingIdx = -1 - h.persistChatLocked() - h.chatMu.Unlock() - return - } - h.pendingIdx = -1 - h.chatMu.Unlock() - if content == "" { - return - } - h.addChatMsg(ChatMsg{ - Role: "assistant", - Content: content, - Source: channel, - Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), - }) - }) -} - -// pendingAssistantLocked 返回 chatHistory 中当前进行中的 assistant 消息(已持有 chatMu)。 -// 仅当最后一条是 assistant 且尚未产出最终内容时视为进行中,避免跨轮次误合并。 -func (h *Handler) pendingAssistantLocked() *ChatMsg { - if h.pendingIdx < 0 || h.pendingIdx >= len(h.chatHistory) { - return nil - } - msg := &h.chatHistory[h.pendingIdx] - if msg.Role != "assistant" || msg.Content != "" { - return nil - } - return msg -} - -func (h *Handler) persistChatLocked() { - if h.history == nil { - return - } - // 写独立文件(原子替换)。失败只告警:聊天记录不该影响对话主流程。 - if err := h.history.Save(h.chatHistory); err != nil { - log.Printf("[webui] 写聊天记录 %s 失败: %v", h.history.Path(), err) - } -} - -func (h *Handler) handleToolEvent(ev *sdk.Event) { - payload := ev.Payload - tool, _ := payload["tool"].(string) - args, _ := payload["args"].(map[string]interface{}) - status, _ := payload["status"].(string) - ts := time.Now() - - switch tool { - case "cmd_run": - exec := CmdExec{ - Command: getStr(args, "command"), - Status: status, - Time: ts.Format(time.RFC3339), - } - h.cmdMu.Lock() - h.cmdHistory = append(h.cmdHistory, exec) - if len(h.cmdHistory) > maxCmdHistory { - h.cmdHistory = h.cmdHistory[len(h.cmdHistory)-maxCmdHistory:] - } - h.cmdMu.Unlock() - - case "terminal_create": - id := getStr(args, "id") - if id == "" { - // agent 调用时不知道生成的 id,从工具结果中回填 - if res, ok := payload["result"].(map[string]interface{}); ok { - id = getStr(res, "id") - } - } - if id == "" { - break - } - cmd := getStr(args, "command") - if cmd == "" { - if res, ok := payload["result"].(map[string]interface{}); ok { - cmd = getStr(res, "command") - } - } - now := time.Now() - term := &termState{ - ID: id, - Command: cmd, - Running: true, - CreatedAt: now.Format(time.RFC3339), - created: now, - } - h.termMu.Lock() - if old, ok := h.termStates[id]; ok { - old.Command = cmd - old.Running = true - old.created = now - } else { - h.termStates[id] = term - } - if len(h.termStates) > maxTerminals { - for k := range h.termStates { - delete(h.termStates, k) - break - } - } - h.termMu.Unlock() - - case "terminal_close": - id := getStr(args, "id") - if id != "" { - h.termMu.Lock() - if t, ok := h.termStates[id]; ok { - t.Running = false - } - h.termMu.Unlock() - } - } -} - func getStr(m map[string]interface{}, key string) string { if m == nil { return "" @@ -938,2014 +474,6 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } -func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - agentCount := 0 - if h.supervisor != nil { - agentCount = len(h.supervisor.ListAgents()) - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "status": "running", - "uptime": time.Since(h.startTime).Round(time.Second).String(), - "agents": agentCount, - // 内核版本取 internal/meta(-ldflags 注入点)。 - // - // 此前这里给的是 `sdk.SDKVersion`,那条链最终指向 **SDK 仓 - // meta.Version 的硬编码值**,与构建时注入的内核版本无关—— - // 两仲版本号碰巧相等时看不出问题,一旦不等就报错。 - // SDK 版本另用 sdk_version 字段并行暴露。 - "version": meta.Version, - "commit": meta.Commit, - "build_time": meta.BuildTime, - "sdk_version": sdk.SDKVersion, - "startedAt": h.startTime, - }) -} - -func (h *Handler) handleKernel(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if h.status == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "kernel status provider not available"}) - return - } - writeJSON(w, http.StatusOK, h.status.GetKernelStatus()) -} - -// 人格设定:配置项键,以及「首启向导已经问过」的一次性标记。 -// -// 为什么需要向导:人格曾经只有 /personal/personal.md 一个来源且无人维护, -// 里面写死的旧版本号反过来让实例自述旧版本(v1.2.0 压测发现)。 -// 现在人格是配置项(默认模板不含任何版本号),首启问一次,之后不再打扰。 -// handlePersona 是首启人格向导的后端(与内核 persona_set 工具共用 internal/config 的实现)。 -// -// GET → {initialized, current_prompt, file_override} -// POST → {"mode":"default"|"custom"|"later","content":"..."} -// 写入 core.agent.personal_prompt 并打一次性标记,返回 restart_required -// -// 生效时机:人格在 homed 启动时载入(以【人格设定】块拼进系统提示词), -// 所以**自定义内容需重启生效**;选「默认」或「稍后」(保持当前默认)无需重启。 -// 不回答就是「稍后」:保留默认并打标记,不阻塞任何流程。 -// -// 跨通道:这里只是 WebUI 侧的入口;任何通道的消息到来时,内核都会检查同一枚标记, -// 未确认则在提示词里要求模型主动询问(见 internal/agent/core 的首启门禁)。 -func (h *Handler) handlePersona(w http.ResponseWriter, r *http.Request) { - if h.settings == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "settings not available"}) - return - } - switch r.Method { - case http.MethodGet: - writeJSON(w, http.StatusOK, map[string]interface{}{ - "initialized": internalConfig.PersonaInitializedKV(h.settings), - "current_prompt": internalConfig.CurrentPersonaKV(h.settings), - "file_override": h.personaFileExists(), - }) - case http.MethodPost: - var req struct { - Mode string `json:"mode"` - Content string `json:"content"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - restart, err := internalConfig.SetPersonaKV(h.settings, req.Mode, req.Content) - if err != nil { - // 非法 mode / 空内容 → 400;落库失败 → 500。两者都不打标记。 - code := http.StatusInternalServerError - if strings.Contains(err.Error(), "unknown mode") || strings.Contains(err.Error(), "content required") { - code = http.StatusBadRequest - } - writeJSON(w, code, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "status": "ok", "mode": req.Mode, "restart_required": restart, - }) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -// personaFileExists 报告是否存在会覆盖配置项的人格文件(存在时它优先)。 -// 数据目录取自 core.daemon.data_dir(由播种写入)。 -func (h *Handler) personaFileExists() bool { - v, err := h.settings.GetCore("core.daemon.data_dir") - if err != nil { - return false - } - dir, _ := v.(string) - if dir == "" { - return false - } - _, err = os.Stat(filepath.Join(dir, "personal", "personal.md")) - return err == nil -} - -func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - if h.supervisor == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"}) - return - } - agents := h.supervisor.ListAgents() - writeJSON(w, http.StatusOK, map[string]interface{}{"agents": agents}) - case http.MethodPost: - var cfg types.AgentConfig - if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - if cfg.ID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent id is required"}) - return - } - if h.config != nil { - kcfg := h.config.Get() - kcfg.Agents = append(kcfg.Agents, cfg) - h.config.Put(kcfg) - } - writeJSON(w, http.StatusCreated, map[string]string{"id": string(cfg.ID)}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/") - parts := strings.Split(path, "/") - agentID := types.AgentID(parts[0]) - if len(parts) == 1 { - switch r.Method { - case http.MethodGet: - if h.supervisor == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"}) - return - } - status, err := h.supervisor.GetAgentStatus(string(agentID)) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, status) - case http.MethodDelete: - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": string(agentID)}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - return - } - action := parts[1] - switch action { - case "snapshots": - h.handleSnapshots(w, r, agentID, parts) - case "rollback": - h.handleRollback(w, r, agentID, parts) - case "start", "stop", "restart": - h.handleAgentAction(w, r, agentID, action) - default: - http.Error(w, "not found", http.StatusNotFound) - } -} - -func (h *Handler) handleSnapshots(w http.ResponseWriter, r *http.Request, agentID types.AgentID, parts []string) { - switch r.Method { - case http.MethodGet: - writeJSON(w, http.StatusOK, map[string]interface{}{"agent_id": agentID, "snapshots": []map[string]interface{}{}}) - case http.MethodPost: - if h.supervisor == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"}) - return - } - snap, err := h.supervisor.PreActionSnapshot(string(agentID)) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusCreated, snap) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleRollback(w http.ResponseWriter, r *http.Request, agentID types.AgentID, parts []string) { - if r.Method != http.MethodPost || len(parts) < 3 { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if h.supervisor == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"}) - return - } - snapID := types.SnapshotID(parts[2]) - if err := h.supervisor.RollbackAgent(string(agentID), string(snapID)); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_initiated", "agent": string(agentID), "snap": string(snapID)}) -} - -func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agentID types.AgentID, action string) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - writeJSON(w, http.StatusNotImplemented, map[string]string{"error": fmt.Sprintf("agent %s action not implemented by supervisor", action)}) -} - -func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) { - if h.memory == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"}) - return - } - switch r.Method { - case http.MethodGet: - userInput := r.URL.Query().Get("q") - keywords := strings.Split(userInput, ",") - depth, _ := strconv.Atoi(r.URL.Query().Get("depth")) - if depth <= 0 { - depth = 2 - } - entities, relations, err := h.memory.Recall(keywords, depth) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{"entities": entities, "relations": relations}) - case http.MethodPost: - var req struct { - Triples []sdk.Triple `json:"triples"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if err := h.memory.Commit(req.Triples); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "committed", "committed": len(req.Triples)}) - case http.MethodDelete: - var req struct { - Criteria map[string]string `json:"criteria"` - Mode string `json:"mode"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - deleted, err := h.memory.Purge(req.Criteria, req.Mode) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleMemoryContext(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if h.indexer == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"}) - return - } - userInput := r.URL.Query().Get("q") - injected, err := h.indexer.BuildContext(userInput) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "context": h.indexer.FormatContext(injected), - "summary": injected.Summary, - "entities": injected.Entities, - "token_estimate": injected.TokenEstimate, - "tool_prompt": h.indexer.BuildToolPrompt(), - }) -} - -func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if h.indexer == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "tools": h.indexer.GetToolDefinitions(), - "tool_prompt": h.indexer.BuildToolPrompt(), - }) -} - -func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if h.memory == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"}) - return - } - data, err := h.memory.GraphData() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data}) -} - -func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - if h.knowledge == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"}) - return - } - query := r.URL.Query().Get("q") - if query != "" { - results, err := h.knowledge.Search(query, 10) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{"results": results}) - return - } - categories, err := h.knowledge.List() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "categories": categories, - "stats": h.knowledge.Stats(), - }) - - case http.MethodPost: - if h.knowledge == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"}) - return - } - ct := r.Header.Get("Content-Type") - if strings.HasPrefix(ct, "multipart/form-data") { - if err := r.ParseMultipartForm(10 << 20); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - name := r.FormValue("name") - file, _, err := r.FormFile("file") - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"}) - return - } - defer file.Close() - buf := make([]byte, 10<<20) - n, _ := file.Read(buf) - content := string(buf[:n]) - if err := h.knowledge.Add(name, content); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": name}) - return - } - - var req struct { - Name string `json:"name"` - Content string `json:"content"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.Name == "" || req.Content == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and content required"}) - return - } - if err := h.knowledge.Add(req.Name, req.Content); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": req.Name}) - - case http.MethodDelete: - name := r.URL.Query().Get("name") - if name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name query param required"}) - return - } - if err := h.knowledge.Remove(name); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) - - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) { - if h.textMem == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "text memory not available"}) - return - } - switch r.Method { - case http.MethodGet: - recent, _ := h.textMem.RecentEvents(50) - stats := h.textMem.Stats() - writeJSON(w, http.StatusOK, map[string]interface{}{ - "stats": stats, - "recent": recent, - }) - case http.MethodDelete: - writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleAdapters(w http.ResponseWriter, r *http.Request) { - if h.adapter == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"}) - return - } - switch r.Method { - case http.MethodGet: - writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": h.adapter.List()}) - case http.MethodPost: - var req struct { - Name string `json:"name"` - Code string `json:"code"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if err := h.adapter.Load(req.Name, req.Code); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusCreated, map[string]string{"status": "loaded", "name": req.Name}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleAdapterByID(w http.ResponseWriter, r *http.Request) { - if h.adapter == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"}) - return - } - name := strings.TrimPrefix(r.URL.Path, "/api/v1/adapters/") - if name == "" { - http.NotFound(w, r) - return - } - switch r.Method { - case http.MethodGet: - for _, a := range h.adapter.List() { - if a.Name == name { - writeJSON(w, http.StatusOK, a) - return - } - } - http.NotFound(w, r) - case http.MethodDelete: - if err := h.adapter.Remove(name); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "adapter not found"}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "network_status": "monitoring", - "endpoints": h.config.Get().Defaults.LLMEndpoints, - }) -} - -// chatSaveThrottle 控制写盘频率:最多每 3 秒写一次 -const chatSaveThrottle = 3 * time.Second - -func (h *Handler) addChatMsg(msg ChatMsg) { - h.chatMu.Lock() - h.chatHistory = append(h.chatHistory, msg) - if len(h.chatHistory) > maxChatHistory { - drop := len(h.chatHistory) - maxChatHistory - h.chatHistory = h.chatHistory[drop:] - if h.pendingIdx >= 0 { - h.pendingIdx -= drop - if h.pendingIdx < 0 { - h.pendingIdx = -1 - } - } - } - h.persistChatLocked() - h.chatMu.Unlock() -} - -// handleChatHistory 返回对话历史,支持分段懒加载。 -// -// 查询参数(全部可选,省略时保持旧行为=返回全量,向后兼容旧客户端): -// - limit: 返回条数上限(1..maxChatHistory)。带 limit 时默认取「最新的 limit 条」。 -// - before: 游标,只返回下标 < before 的消息(配合 limit 向上翻页取更早历史)。 -// -// 响应额外返回 total / offset / has_more,供前端判断是否继续向上加载。 -// 注意:不对 tool_calls / reasoning_content 做任何裁剪——工具调用详情是排查与 -// 上下文还原的关键信息,必须完整下发;瘦身只通过分页控制条数。 -func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - limit := parseIntDefault(q.Get("limit"), 0) - if limit < 0 { - limit = 0 - } - if limit > maxChatHistory { - limit = maxChatHistory - } - - h.chatMu.Lock() - total := len(h.chatHistory) - // before 游标:默认取到末尾(最新) - end := parseIntDefault(q.Get("before"), total) - if end < 0 || end > total { - end = total - } - start := 0 - if limit > 0 && end-limit > 0 { - start = end - limit - } - result := make([]ChatMsg, end-start) - copy(result, h.chatHistory[start:end]) - h.chatMu.Unlock() - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "messages": result, - "total": total, - "offset": start, - "has_more": start > 0, - }) -} - -// parseIntDefault 解析十进制整数,失败/空串返回 def。 -func parseIntDefault(s string, def int) int { - if s == "" { - return def - } - n, err := strconv.Atoi(s) - if err != nil { - return def - } - return n -} - -// maxInlineMediaBytes 是上传媒体内联进 LLM 请求的字节上限。 -// -// base64 会胀大 4/3,8MB 原图变成 ~11MB 文本;再加上网关的请求体上限与 -// 模型的图像 token 预算,超过这个量级多半会被上游 413 拒掉。 -// 超限时退回按路径处理(模型可用 describe_image 主动看)而不是报错。 -const maxInlineMediaBytes = 8 << 20 - -// handleChatFile 处理用户经 webui 上传文件并附带消息注入 agent。 -// 设计对齐 qq 插件收文件模式:文件落盘到固定目录(/uploads), -// 注入文本带「文件名 + 保存路径」,agent 用 files_read 等工具按路径消费。 -// 表单字段:file(必填,multipart 文件)、message(可选附言)、device_id/device_name。 -func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if uploadsDir == "" { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "uploads dir not initialized"}) - return - } - if err := r.ParseMultipartForm(64 << 20); err != nil { // 单文件上限 64MB - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart: " + err.Error()}) - return - } - file, hdr, err := r.FormFile("file") - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"}) - return - } - defer file.Close() - message := r.FormValue("message") - deviceID := r.FormValue("device_id") - deviceName := r.FormValue("device_name") - clientMsgID := r.FormValue("client_msg_id") - - if h.sdk == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) - return - } - - // 落盘:保留原文件名;重名加毫秒后缀防覆盖。文件名消毒防路径穿越。 - base := filepath.Base(hdr.Filename) - if base == "" || base == "." || strings.Contains(base, "..") { - base = "upload.bin" - } - if err := os.MkdirAll(uploadsDir, 0755); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "create uploads dir"}) - return - } - savePath := filepath.Join(uploadsDir, base) - if _, err := os.Stat(savePath); err == nil { - ext := filepath.Ext(base) - stem := strings.TrimSuffix(base, ext) - savePath = filepath.Join(uploadsDir, fmt.Sprintf("%s_%d%s", stem, time.Now().UnixMilli(), ext)) - } - out, err := os.Create(savePath) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "save file"}) - return - } - sz, err := io.Copy(out, file) - out.Close() - if err != nil { - os.Remove(savePath) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write file"}) - return - } - - // 下载 URL(前端附件卡片用):/uploads/ 与 /files/ 同一鉴权模型,路由在 RegisterRoutes 挂载 - dlURL := "/uploads/" + filepath.Base(savePath) - attType := "file" - ct := hdr.Header.Get("Content-Type") - if ct == "" { - // 部分客户端(curl -F、某些移动端)不带 Content-Type,退回按扩展名判定。 - // 判错的后果不只是卡片样式:图片被当普通文件就走不进视觉链路,模型看不到图。 - ct = contentTypeByExt(strings.ToLower(filepath.Ext(savePath))) - } - switch { - case strings.HasPrefix(ct, "image/"): - attType = "image" - case strings.HasPrefix(ct, "audio/"): - attType = "audio" - } - - // 图片/音频直接进多模态链路:读回字节拼 data URL,随本轮 message 发给模型。 - // - // 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 files_read—— - // 但 files_read 返回的是文本,图片的字节对模型永远不可见,除非它想到再调 - // describe_image。走 InjectInputMedia 后与用户在 qq 发图走同一条统一输入主干: - // 自动落进 CAS、挂上媒体记忆引用,且模型「本轮」就看得到图。 - var mediaBlocks []sdk.ContentBlock - if attType == "image" || attType == "audio" { - if sz > maxInlineMediaBytes { - log.Printf("[webui] %s %s 有 %s,超过 %s 内联上限,退回按路径处理", - attType, base, formatBytesGo(sz), formatBytesGo(maxInlineMediaBytes)) - } else if raw, err := os.ReadFile(savePath); err != nil { - log.Printf("[webui] 读回上传的%s失败,退回按路径处理: %v", attType, err) - } else { - dataURL := "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(raw) - if attType == "image" { - mediaBlocks = []sdk.ContentBlock{{ - Type: "image_url", - ImageURL: &sdk.ImageURL{URL: dataURL, Detail: "auto"}, - }} - } else { - mediaBlocks = []sdk.ContentBlock{{ - Type: "audio_url", - AudioURL: &sdk.AudioURL{URL: dataURL}, - }} - } - } - } - - // 注入 agent:文件元信息走 interrupt 通道(内核以 system 角色注入 LLM, - // 不写入用户对话履历、不产生独立用户气泡——对齐 terminal_watch/timer 的 - // 工具提醒模式)。用户的附言若有则作为正常消息先行注入。 - // qq 插件同款文本格式:[xx发送了文件] + 路径,agent 用 files_read 消费。 - humanSize := formatBytesGo(sz) - source := "webui" - if deviceID != "" { - source = "webui/" + deviceID - } - typeLabel := map[string]string{"image": "图片", "audio": "音频", "file": "文件"}[attType] - if typeLabel == "" { - typeLabel = "文件" - } - fileNote := fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n文件已保存到: %s\n可用 files_read 等工具读取此路径处理。", - typeLabel, base, humanSize, savePath) - // 媒体已随本轮发给模型时不再叫它去读文件:那只会读到一堆二进制字节。 - if len(mediaBlocks) > 0 { - fileNote = fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n原文件保存在: %s", - typeLabel, base, humanSize, savePath) - } - if message != "" { - text := message - go func() { - // 附言作为用户消息(带附件卡片)注入;文件说明紧随其后以 interrupt 补充 - payload2 := map[string]interface{}{"content": text} - if deviceID != "" { - payload2["device_id"] = deviceID - payload2["device_name"] = deviceName - } - if clientMsgID != "" { - payload2["client_msg_id"] = clientMsgID + "-note" - } - payload2["upload_url"] = dlURL - payload2["upload_type"] = attType - payload2["upload_size"] = sz - payload2["upload_name"] = base - // 媒体跟附言同一条注入:拆开会让模型先看到「帮我看看这张图」而图在下一轮才到。 - if len(mediaBlocks) > 0 { - payload2["media_blocks"] = mediaBlocks - } - h.sdk.InjectInput(source, "webui", "text", payload2) - }() - time.Sleep(100 * time.Millisecond) // 保证附言先入队 - h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{"content": fileNote, "no_memory": true}) - writeJSON(w, http.StatusOK, map[string]interface{}{ - "status": "accepted", - "file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType}, - }) - return - } - // 无附言:仅文件说明,直接同步注入并等待回复(与普通聊天体验一致) - payload := map[string]interface{}{ - "content": fileNote, - "upload_url": dlURL, - "upload_type": attType, - "upload_size": sz, - "upload_name": base, - } - if len(mediaBlocks) > 0 { - payload["media_blocks"] = mediaBlocks - } - if deviceID != "" { - payload["device_id"] = deviceID - payload["device_name"] = deviceName - } - if clientMsgID != "" { - payload["client_msg_id"] = clientMsgID - } - - ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) - defer cancel() - respCh := make(chan *agentIO.OutputEvent, 1) - go func() { - respCh <- h.sdk.InjectInputSync(source, "webui", "text", payload) - }() - var resp *agentIO.OutputEvent - select { - case resp = <-respCh: - case <-ctx.Done(): - writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout"}) - return - } - if resp == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) - return - } - content, _ := resp.Payload["content"].(string) - reasoning, _ := resp.Payload["reasoning_content"].(string) - result := map[string]interface{}{ - "response": content, - "file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType}, - } - if reasoning != "" { - result["reasoning_content"] = reasoning - } - writeJSON(w, http.StatusOK, result) -} - -// formatBytesGo 服务端字节人性化显示。 -func formatBytesGo(n int64) string { - if n <= 0 { - return "0 B" - } - units := []string{"B", "KB", "MB", "GB"} - i := 0 - f := float64(n) - for f >= 1024 && i < len(units)-1 { - f /= 1024 - i++ - } - if i == 0 { - return fmt.Sprintf("%d %s", n, units[i]) - } - return fmt.Sprintf("%.1f %s", f, units[i]) -} - -// handleUploads 服务 /uploads/:用户上传文件的下载(与 /files/ 同一安全模型)。 -func (h *Handler) handleUploads(w http.ResponseWriter, r *http.Request) { - if uploadsDir == "" { - http.NotFound(w, r) - return - } - name := strings.TrimPrefix(r.URL.Path, "/uploads/") - if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") { - http.NotFound(w, r) - return - } - fp := filepath.Join(uploadsDir, name) - f, err := os.Open(fp) - if err != nil { - http.NotFound(w, r) - return - } - defer f.Close() - st, err := f.Stat() - if err != nil || st.IsDir() { - http.NotFound(w, r) - return - } - ct := contentTypeByExt(strings.ToLower(filepath.Ext(name))) - w.Header().Set("Content-Type", ct) - if strings.HasPrefix(ct, "image/") || strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") { - w.Header().Set("Content-Disposition", "inline; filename="+name) - } else { - w.Header().Set("Content-Disposition", "attachment; filename="+name) - } - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Cache-Control", "private, max-age=3600") - http.ServeContent(w, r, name, st.ModTime(), f) -} - -// handleChatInterrupt 注入用户中断:取消正在进行的 LLM 生成并/或发送打断消息。 -// 核心拦截语义(interceptLoop): -// - 有 LLM 在跑:cancelLLM 取消当前请求 + 中断入队,process() 以 -// [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入; -// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。 -// -// message 可选:空则纯取消(仍会注入空内容中断触发取消)。 -func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if h.sdk == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) - return - } - var body struct { - Message string `json:"message"` - DeviceID string `json:"device_id"` - } - if r.Body != nil { - _ = json.NewDecoder(r.Body).Decode(&body) // body 可选 - } - - source := "webui" - if body.DeviceID != "" { - source = "webui/" + body.DeviceID - } - // PriorityL4:终止按钮必须能立即打断当前任务(内核级插件才有的能力)。 - // agent 正卡在工具执行里时按不下手——那是临界区,由内核在安全点生效; - // 但 LLM 流式段会被立刻取消。 - h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{ - "content": body.Message, - "priority": sdk.PriorityL4, - }) - writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"}) -} - -func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) { - h.termMu.Lock() - terms := make([]*termState, 0, len(h.termStates)) - for _, ts := range h.termStates { - ts.Uptime = time.Since(ts.created).Round(time.Second).String() - terms = append(terms, ts) - } - h.termMu.Unlock() - writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms}) -} - -func (h *Handler) handleCmdHistory(w http.ResponseWriter, r *http.Request) { - h.cmdMu.Lock() - result := make([]CmdExec, len(h.cmdHistory)) - copy(result, h.cmdHistory) - h.cmdMu.Unlock() - writeJSON(w, http.StatusOK, map[string]interface{}{"history": result}) -} - -func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var body struct { - Message string `json:"message"` - DeviceID string `json:"device_id"` // 消息来源设备(GUI/受控设备),可选 - DeviceName string `json:"device_name"` // 设备显示名,可选 - ClientMsgID string `json:"client_msg_id"` // 客户端唯一消息 ID(防断线重放/超时重试) - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if body.Message == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"}) - return - } - - if h.sdk == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) - return - } - - // client_msg_id 去重:同 ID 重放等待首次结果直接复用,不重复注入 agent。 - // 无 ID 的旧客户端走原路径(agent 核心层另有内容级短窗口去重兑底)。 - var entry *chatMsgEntry - if body.ClientMsgID != "" { - var replay bool - entry, replay = h.claimChatMsg(body.ClientMsgID) - if replay { - log.Printf("[webui] duplicate chat msg %s: waiting for first request result", body.ClientMsgID) - select { - case <-entry.done: - resp := entry.resp - if resp == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) - return - } - content, _ := resp.Payload["content"].(string) - reasoning, _ := resp.Payload["reasoning_content"].(string) - result := map[string]interface{}{"response": content, "deduplicated": true} - if reasoning != "" { - result["reasoning_content"] = reasoning - } - writeJSON(w, http.StatusOK, result) - case <-r.Context().Done(): - return - } - return - } - } - - // 来源编码:带设备身份时用 webui/{device_id}(agent 经 injectSourceContext 可见来源); - // 无设备时保持 webui(兼容旧调用)。device_name 一并注入便于 agent 识别。 - source := "webui" - if body.DeviceID != "" { - source = "webui/" + body.DeviceID - } - payload := map[string]interface{}{"content": body.Message} - if body.DeviceID != "" { - payload["device_id"] = body.DeviceID - payload["device_name"] = body.DeviceName - } - if body.ClientMsgID != "" { - payload["client_msg_id"] = body.ClientMsgID - } - // 带超时的上下文,防止 InjectInputSync 长时间阻塞 HTTP 请求。 - // 注意:ctx 派生自 r.Context(),客户端提前断开(前端 15s ackTimer abort)时 - // 立即取消,不会真等满 300s;300s 只约束"连接保持 + agent 排队/长生成"场景 - // (agent 串行处理,后发消息的排队时间也计入,60s 曾导致连发第 3 条必超时)。 - ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) - defer cancel() - - respCh := make(chan *agentIO.OutputEvent, 1) - go func() { - respCh <- h.sdk.InjectInputSync(source, "webui", "text", payload) - }() - - var resp *agentIO.OutputEvent - select { - case resp = <-respCh: - case <-ctx.Done(): - if entry != nil { - h.completeChatMsg(entry, nil) - } - writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (60s)"}) - return - } - - if entry != nil { - h.completeChatMsg(entry, resp) - } - - if resp == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) - return - } - content, _ := resp.Payload["content"].(string) - reasoning, _ := resp.Payload["reasoning_content"].(string) - result := map[string]interface{}{ - "response": content, - } - if reasoning != "" { - result["reasoning_content"] = reasoning - } - writeJSON(w, http.StatusOK, result) -} - -// sendSSE 向 writeCh 发送一条 SSE 事件;队列满时等 100ms 再试, -// 比立即 drop 更友好,避免密集 tool_call/delta 期间前端丢帧。 -func sendSSE(writeCh chan string, id, eventType, data string) { - line := fmt.Sprintf("id: %s\nevent: %s\ndata: %s\n", id, eventType, data) - select { - case writeCh <- line: - return - default: - } - // 队列满:等 100ms 让 writer flush,再试一次 - timer := time.NewTimer(100 * time.Millisecond) - defer timer.Stop() - select { - case writeCh <- line: - case <-timer.C: - log.Printf("[SSE] DROPPED %s id=%s (writeCh full 100ms, len=%d)", eventType, id, len(writeCh)) - } -} - -func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming not supported", 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.WriteHeader(http.StatusOK) - flusher.Flush() - - done := r.Context().Done() - if h.sdk == nil { - fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"event bus unavailable\"}\n\n") - flusher.Flush() - return - } - - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - - // writeCh 不 close:Subscribe 回调闭包持有它,handler 退出后回调仍可能被 - // 总线异步触发,close 后再发送会 panic(send on closed channel,生产日志中 - // 单日数千次)。writer goroutine 通过 done 退出;发送侧 select on done 防泄漏。 - // 缓冲 2048:reasoning/content 增量是高频小包(LLM token 级), - // 512 时连续 tool_call + reasoning + delta 密集期会溢出导致前端丢帧。 - // 写入侧用短超时(50ms)兜底,比立即丢弃更友好。 - writeCh := make(chan string, 2048) - writerDone := make(chan struct{}) - go func() { - defer func() { - if r := recover(); r != nil { - log.Printf("[SSE] writer panic: %v", r) - } - close(writerDone) - }() - // 批量合并窗口:16ms 内收集的增量一次性 flush,降 flush 次数、 - // 避免高频小包拖慢 socket 写导致 writeCh 积压丢 delta。 - pending := make([]string, 0, 64) - flushPending := func() { - if len(pending) == 0 { - return - } - for _, line := range pending { - fmt.Fprintf(w, "%s\n", line) - } - flusher.Flush() - pending = pending[:0] - } - flushTicker := time.NewTicker(16 * time.Millisecond) - defer flushTicker.Stop() - for { - select { - case line := <-writeCh: - pending = append(pending, line) - // 大批量一次性 flush:阈值从 64 提高,利用批量减少 syscall 开销 - if len(pending) >= 128 { - flushPending() - } - case <-flushTicker.C: - flushPending() - case <-done: - flushPending() - return - } - } - }() - - log.Printf("[SSE] handler started, subscribing to events") - - // 解析 Last-Event-ID(断线重连时客户端携带),重放期间内遗漏的事件。 - // 注意:本 handler 的 Last-Event-ID 重放仅为 GUI (cmd/gui/renderer/app.js) 服务。 - // 浏览器原生 EventSource (webui/dashboard.html 使用) 由浏览器自动处理 Last-Event-ID 重连。 - if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" { - log.Printf("[SSE] client reported Last-Event-ID: %s", lastEventID) - if h.sseEvents != nil { - replayed := h.sseEvents.After(lastEventID) - if len(replayed) == 0 { - log.Printf("[SSE] replay: nothing after id %s (id not in ring or already at tip)", lastEventID) - // ID 不在 ring:说明最后一帧是 delta(delta 不进 ring)或已到最新。 - // 显式通知前端补拉历史,避免其空等后续聚合事件(表现为消息同步不及时)。 - fmt.Fprintf(w, "event: sync_required\ndata: {}\n\n") - flusher.Flush() - } else { - log.Printf("[SSE] replay: sending %d events after id %s", len(replayed), lastEventID) - for _, rec := range replayed { - fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n", rec.id, rec.eventType, string(rec.data)) - flusher.Flush() - } - log.Printf("[SSE] replay complete, wrote %d events", len(replayed)) - } - } - } - - subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output"} - // token 级流式增量事件:实时转发给浏览器做逐 token 渲染。 - // 不进 sseEventRing —— 断线重连只重放聚合事件(最终真相), - // 避免重放 delta 与聚合内容重复追加。 - var unsubs []func() - var seq int64 - appendDeltaSub := func(evtType sdk.EventType) { - unsub := h.sdk.Subscribe(evtType, func(evt *sdk.Event) { - data, _ := json.Marshal(evt) - seq++ - id := fmt.Sprintf("%d-%d", evt.Timestamp, seq) - sendSSE(writeCh, id, string(evt.Type), string(data)) - }) - unsubs = append(unsubs, unsub) - } - appendDeltaSub(sdk.EventReasoningDelta) - appendDeltaSub(sdk.EventContentDelta) - - for _, t := range subTypes { - t2 := t - unsub := h.sdk.Subscribe(sdk.EventType(t2), func(evt *sdk.Event) { - if evt.Type == sdk.EventToolCall { - toolName, _ := evt.Payload["tool"].(string) - log.Printf("[SSE] received tool_call event: tool=%s", toolName) - } - data, _ := json.Marshal(evt) - seq++ - id := fmt.Sprintf("%d-%d", evt.Timestamp, seq) - // 写入环状缓冲区,供断线重连重放 - if h.sseEvents != nil { - h.sseEvents.Append(id, string(evt.Type), data) - } - sendSSE(writeCh, id, string(evt.Type), string(data)) - if evt.Type == sdk.EventToolCall { - toolName, _ := evt.Payload["tool"].(string) - log.Printf("[SSE] wrote tool_call to writeCh: tool=%s", toolName) - } - }) - unsubs = append(unsubs, unsub) - } - defer func() { - for _, unsub := range unsubs { - unsub() - } - <-writerDone // 等 writer 退出,保证 handler 返回后无残余写入 - }() - for { - select { - case <-done: - return - case <-ticker.C: - // 心跳直接写 w 并 flush(绕过 writeCh,事件密集/队列满时也能保活长连接, - // 避免远程 nginx 网关因长时间无字节而 504/半开)。 - if _, err := fmt.Fprintf(w, ": heartbeat\n\n"); err != nil { - return - } - flusher.Flush() - } - } -} - -func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { - if h.config == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "config not available"}) - return - } - switch r.Method { - case http.MethodGet: - writeJSON(w, http.StatusOK, h.config.Get()) - case http.MethodPut: - var cfg types.Config - if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid config"}) - return - } - h.config.Put(&cfg) - writeJSON(w, http.StatusOK, map[string]string{"status": "config_updated"}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -// isInternalSetting 判断某个插件的配置键是不是**内部数据**(不是用户设置项)。 -// -// chathistory 是 webui 自己持久化的整段聊天记录(生产实例实测 5.2MB)。它躺在 -// 插件配置表里,于是会被设置接口当成普通配置项整块吐出:GET /api/v1/settings -// 因此返回 8MB+,并且前端把它渲染成一个巨大的文本框。它只应由 chat 相关 -// 接口的 handleChatHistory 读,不进设置面。 -func isInternalSetting(plugin, key string) bool { - return plugin == "webui" && key == "chathistory" -} - -func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) { - if h.settings == nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "config registry not available"}) - return - } - switch r.Method { - case http.MethodGet: - prefix := r.URL.Query().Get("prefix") - values := make(map[string]interface{}) - meta := make(map[string]*sdk.ConfigDef) - - if strings.HasPrefix(prefix, "plugin.") { - // 插件配置:从插件自身 config_ 表读取 - pluginName := prefix[7:] - keys, _ := h.settings.ListPlugin(pluginName, "") - for _, k := range keys { - if isInternalSetting(pluginName, k) { - continue - } - v, _ := h.settings.GetPlugin(pluginName, k) - fullKey := prefix + "." + k - values[fullKey] = v - } - for _, def := range h.settings.DefsPlugin(pluginName, "") { - fullKey := prefix + "." + def.Key - meta[fullKey] = def - } - } else { - // 核心配置:从 core config 表读取(键可为任意前缀,如 core.llm.*、webui.*) - all := h.settings.Dump() - var keys []string - for k := range all { - if strings.HasPrefix(k, prefix) { - keys = append(keys, k) - } - } - sort.Strings(keys) - for _, k := range keys { - values[k] = all[k] - } - for _, d := range h.settings.DefsCore(prefix) { - meta[d.Key] = d - // 有 def 但 DB 中尚无值的 key,用 default 填充以便在 WebUI 中显示和编辑 - if _, exists := values[d.Key]; !exists { - values[d.Key] = d.Default - } - } - // 无前缀时同时加载所有插件配置 - if prefix == "" { - for _, p := range h.settings.Plugins() { - if p == "core" { - continue - } - pkeys, _ := h.settings.ListPlugin(p, "") - for _, k := range pkeys { - if isInternalSetting(p, k) { - continue - } - v, _ := h.settings.GetPlugin(p, k) - fullKey := "plugin." + p + "." + k - values[fullKey] = v - } - for _, def := range h.settings.DefsPlugin(p, "") { - fullKey := "plugin." + p + "." + def.Key - meta[fullKey] = def - } - } - } - } - - plugins := []string{"core"} - for _, p := range h.settings.Plugins() { - if p != "core" { - plugins = append(plugins, "plugin."+p) - } - } - var pm map[string]sdk.PluginMeta - if h.pluginMgr != nil { - pm = h.pluginMgr.PluginMetas() - } - var disabledPlugins []sdk.DisabledPluginInfo - if h.pluginMgr != nil { - disabledPlugins = h.pluginMgr.ListDisabledPlugins() - } - sort.Strings(plugins) - writeJSON(w, http.StatusOK, map[string]interface{}{ - "settings": values, - "meta": meta, - "plugins": plugins, - "plugin_meta": pm, - "disabled_plugins": disabledPlugins, - }) - case http.MethodPut: - var body struct { - Key string `json:"key"` - Value interface{} `json:"value"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - // 整数型 float64(如 QQ 号 2.198972886e+09)规范化为 int64, - // 避免被 fmt.Sprint 以科学计数法存库导致后续读取/解析失败。 - if f, ok := body.Value.(float64); ok && f == math.Trunc(f) && math.Abs(f) < 1e15 { - body.Value = int64(f) - } - deleting := body.Value == nil - if strings.HasPrefix(body.Key, "plugin.") { - parts := strings.SplitN(body.Key, ".", 3) - if len(parts) >= 3 { - if isInternalSetting(parts[1], parts[2]) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "该键属于插件内部数据,不经设置接口读写"}) - return - } - var err error - if deleting { - err = h.settings.RemovePlugin(parts[1], parts[2]) - } else { - err = h.settings.SetPlugin(parts[1], parts[2], body.Value) - } - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - } - } else { - var err error - if deleting { - err = h.settings.RemoveCore(body.Key) - } else { - err = h.settings.SetCore(body.Key, body.Value) - } - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - } - if strings.HasPrefix(body.Key, "core.llm.") && h.llm != nil { - if err := h.llm.ReloadFromConfig(); err != nil { - log.Printf("[webui] failed to reload LLM providers: %v", err) - } - } - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - if h.sdk == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "IO manager not available"}) - return - } - - var req struct { - Model string `json:"model"` - Messages []openAIMessage `json:"messages"` - Stream bool `json:"stream"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()}) - return - } - if len(req.Messages) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "messages is required"}) - return - } - - lastMsg := req.Messages[len(req.Messages)-1] - if lastMsg.Role != "user" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "last message must be from user"}) - return - } - - // 带超时的上下文(同 handleChat:客户端断开立即取消;300s 约束长生成) - ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) - defer cancel() - - respCh := make(chan *agentIO.OutputEvent, 1) - go func() { - // NoMemory:本端点(OpenAI 兼容 /v1/chat/completions)的调用方是 - // IDE、工具与脚本,送来的是**固定的提示词模板**("请分析这段代码"之类), - // 不是人类在对话。记进记忆会把真实对话挤掉,而且同一模板会反复刷屏。 - // 原文仍进上下文,模型照旧看得到;只是不参与向量化/关键词提取/蒸馏。 - respCh <- h.sdk.InjectTextSyncNoMemory("http", "http", lastMsg.Content) - }() - - var response *agentIO.OutputEvent - select { - case response = <-respCh: - case <-ctx.Done(): - writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (300s)"}) - return - } - - if response == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no response from agent"}) - return - } - - content, _ := response.Payload["content"].(string) - reasoningContent, _ := response.Payload["reasoning_content"].(string) - usage, _ := response.Payload["usage"].(map[string]interface{}) - - if req.Stream { - h.writeOpenAIStream(w, req.Model, content, reasoningContent, usage) - return - } - - resp := map[string]interface{}{ - "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), - "object": "chat.completion", - "created": time.Now().Unix(), - "model": req.Model, - "choices": []map[string]interface{}{ - { - "index": 0, - "message": map[string]interface{}{ - "role": "assistant", - "content": content, - }, - "finish_reason": "stop", - }, - }, - } - if reasoningContent != "" { - resp["choices"].([]map[string]interface{})[0]["message"].(map[string]interface{})["reasoning_content"] = reasoningContent - } - if usage != nil { - resp["usage"] = usage - } - - w.Header().Set("Content-Type", "application/json; charset=utf-8") - json.NewEncoder(w).Encode(resp) -} - -func (h *Handler) writeOpenAIStream(w http.ResponseWriter, model, content, reasoningContent string, usage map[string]interface{}) { - flusher, ok := w.(http.Flusher) - if !ok { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"}) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.WriteHeader(http.StatusOK) - flusher.Flush() - - // 如果有 reasoning_content,先发送一个 reasoning chunk - if reasoningContent != "" { - reasoningChunk := map[string]interface{}{ - "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), - "object": "chat.completion.chunk", - "created": time.Now().Unix(), - "model": model, - "choices": []map[string]interface{}{ - { - "index": 0, - "delta": map[string]interface{}{ - "content": "", - "reasoning_content": reasoningContent, - }, - "finish_reason": nil, - }, - }, - } - data, _ := json.Marshal(reasoningChunk) - fmt.Fprintf(w, "data: %s\n\n", data) - flusher.Flush() - } - - // content chunk - contentChunk := map[string]interface{}{ - "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), - "object": "chat.completion.chunk", - "created": time.Now().Unix(), - "model": model, - "choices": []map[string]interface{}{ - { - "index": 0, - "delta": map[string]interface{}{ - "content": content, - }, - "finish_reason": nil, - }, - }, - } - data, _ := json.Marshal(contentChunk) - fmt.Fprintf(w, "data: %s\n\n", data) - flusher.Flush() - - // finish chunk - finishChunk := map[string]interface{}{ - "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), - "object": "chat.completion.chunk", - "created": time.Now().Unix(), - "model": model, - "choices": []map[string]interface{}{ - { - "index": 0, - "delta": map[string]interface{}{}, - "finish_reason": "stop", - }, - }, - } - if usage != nil { - finishChunk["usage"] = usage - } - data, _ = json.Marshal(finishChunk) - fmt.Fprintf(w, "data: %s\n\n", data) - flusher.Flush() - - fmt.Fprintf(w, "data: [DONE]\n\n") - flusher.Flush() -} - -func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) { - if h.tracker == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "tracker not available"}) - return - } - path := strings.TrimPrefix(r.URL.Path, "/api/v1/tracker") - path = strings.TrimPrefix(path, "/") - - switch { - case path == "changesets" && r.Method == http.MethodGet: - writeJSON(w, http.StatusOK, map[string]interface{}{ - "changesets": h.tracker.ChangeSets(), - "count": len(h.tracker.ChangeSets()), - }) - case path == "rollback" && r.Method == http.MethodPost: - if err := h.tracker.Rollback(); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_complete"}) - case path == "" && r.Method == http.MethodGet: - writeJSON(w, http.StatusOK, map[string]interface{}{ - "stats": h.tracker.Stats(), - "has_changes": h.tracker.HasChanges(), - "changesets": len(h.tracker.ChangeSets()), - }) - case path == "" && r.Method == http.MethodDelete: - if err := h.tracker.Rollback(); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) - default: - http.Error(w, "not found", http.StatusNotFound) - } -} - -// ======== Remote Device Gateway (proxied to remotedevice, opt-in) ======== - -// deviceGatewayEnabled / deviceGatewayAddr 由 webui 插件启动时从设置读取并注入。 -// 默认禁用:用户显式配置 device_gateway_enabled=true 后,/api/v1/device/* 才会反代到 -// remotedevice 插件(self-contained),避免与 remotedevice 耦合。 -var ( - deviceGatewayEnabled bool - deviceGatewayAddr string - deviceGatewayToken string -) - -// handleDeviceGatewayProxy 将 /api/v1/device/* 反代到 remotedevice 内部 HTTP 服务。 -// 鉴权:本端走 requireAPI(webui API key),转发时带 remotedevice 的 token(X-API-Key)。 -// WS 升级请求(Upgrade: websocket)走 hijack 双向字节透传(标准库 http.Client 不支持 101 升级)。 -func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Request) { - if !deviceGatewayEnabled { - http.NotFound(w, r) - return - } - // 客户端鉴权模式:服务端不再提供授权接口(授权由设备端本地控制)。 - // 拒绝旧的 /device/auth 调用,避免误导。 - if strings.HasSuffix(r.URL.Path, "/device/auth") { - writeJSON(w, http.StatusGone, map[string]string{ - "error": "device authorization moved to client-side; the server no longer stores authorization state", - }) - return - } - addr := deviceGatewayAddr - if addr == "" { - addr = "127.0.0.1:9890" - } - path := r.URL.Path // 保留 /api/v1/device/... 全路径 - url := "http://" + addr + path - if r.URL.RawQuery != "" { - url += "?" + r.URL.RawQuery - } - - // WebSocket 升级:hijack 双向透传(支持 WS over 远程 homed) - if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { - h.proxyWebSocket(w, r, addr, path) - return - } - - req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - req.Header = r.Header.Clone() - if deviceGatewayToken != "" { - req.Header.Set("X-API-Key", deviceGatewayToken) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "device gateway unreachable: " + err.Error()}) - return - } - defer resp.Body.Close() - for k, v := range resp.Header { - w.Header()[k] = v - } - w.WriteHeader(resp.StatusCode) - io.Copy(w, resp.Body) -} - -// proxyWebSocket 用 TCP 直连 + hijack 将客户端 WS 连接双向透传到设备网关。 -func (h *Handler) proxyWebSocket(w http.ResponseWriter, r *http.Request, addr, path string) { - // 设备网关默认仅监听 127.0.0.1(remotedevice),反代目标即内网 homed 本机或指定 addr。 - // 用 net.Dial 直连网关并手动发起 WS 升级握手(net/http 客户端不支持 ws:// 升级)。 - host, port := addr, "9890" - if h2, p2, ok := splitHostPort(addr); ok { - host, port = h2, p2 - } - target := net.JoinHostPort(host, port) - upConn, err := net.DialTimeout("tcp", target, 15*time.Second) - if err != nil { - http.Error(w, "ws upstream dial: "+err.Error(), http.StatusBadGateway) - return - } - defer upConn.Close() - - // 手动构造 WS 升级请求(保留客户端头 + 注入网关 token) - key := r.Header.Get("Sec-WebSocket-Key") - if key == "" { - key = "homeagent-proxy-random-key" - } - reqPath := path - if r.URL.RawQuery != "" { - reqPath += "?" + r.URL.RawQuery - } - var b strings.Builder - b.WriteString("GET " + reqPath + " HTTP/1.1\r\n") - b.WriteString("Host: " + addr + "\r\n") - b.WriteString("Upgrade: websocket\r\n") - b.WriteString("Connection: Upgrade\r\n") - b.WriteString("Sec-WebSocket-Key: " + key + "\r\n") - b.WriteString("Sec-WebSocket-Version: 13\r\n") - if deviceGatewayToken != "" { - b.WriteString("X-API-Key: " + deviceGatewayToken + "\r\n") - } - for k, vv := range r.Header { - kl := strings.ToLower(k) - if kl == "upgrade" || kl == "connection" || kl == "sec-websocket-key" || kl == "sec-websocket-version" || kl == "host" || kl == "x-api-key" || kl == "authorization" { - continue - } - for _, v := range vv { - b.WriteString(k + ": " + v + "\r\n") - } - } - b.WriteString("\r\n") - if _, err := upConn.Write([]byte(b.String())); err != nil { - http.Error(w, "ws upstream write: "+err.Error(), http.StatusBadGateway) - return - } - - // 读上游 101 响应 - br := bufio.NewReader(upConn) - resp, err := http.ReadResponse(br, nil) - if err != nil { - http.Error(w, "ws upstream response: "+err.Error(), http.StatusBadGateway) - return - } - if resp.StatusCode != http.StatusSwitchingProtocols { - http.Error(w, "ws upstream status: "+resp.Status, http.StatusBadGateway) - return - } - - // 客户端 hijack:把 101 响应头写给客户端并接管双向连接 - hj, ok := w.(http.Hijacker) - if !ok { - http.Error(w, "hijack not supported", http.StatusInternalServerError) - return - } - clientConn, brw, err := hj.Hijack() - if err != nil { - return - } - defer clientConn.Close() - - // 向上游 101 响应头转发给客户端 - if err := resp.Write(brw); err != nil { - return - } - if err := brw.Flush(); err != nil { - return - } - - // 双向透传(WS 帧字节不动): - // 客户端 -> 上游 - errCh := make(chan struct{}, 2) - go func() { - io.Copy(upConn, brw) - if tc, ok := upConn.(interface{ CloseWrite() error }); ok { - tc.CloseWrite() - } - errCh <- struct{}{} - }() - // 上游 -> 客户端 - go func() { - wb := bufio.NewWriter(clientConn) - io.Copy(wb, br) - wb.Flush() - errCh <- struct{}{} - }() - <-errCh -} - -// splitHostPort 拆分 addr 为 host/port;无端口时返回 ok=false。 -func splitHostPort(addr string) (string, string, bool) { - if strings.Contains(addr, ":") { - h, p, err := net.SplitHostPort(addr) - if err == nil { - return h, p, true - } - } - return addr, "", false -} - -// ======== Plugin Management (proxied to pluginmgr HTTP API) ======== - -func (h *Handler) pluginmgrAddr() string { - addr := "127.0.0.1:9876" - if h.settings == nil { - return addr - } - if v, err := h.settings.GetPlugin("pluginmgr", "http_addr"); err == nil { - if s, ok := v.(string); ok && s != "" { - addr = s - } - } - return addr -} - -func (h *Handler) proxyToPluginmgr(w http.ResponseWriter, r *http.Request, path string) { - addr := h.pluginmgrAddr() - url := "http://" + addr + path - req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - req.Header = r.Header.Clone() - - resp, err := http.DefaultClient.Do(req) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - defer resp.Body.Close() - - for k, v := range resp.Header { - w.Header()[k] = v - } - w.WriteHeader(resp.StatusCode) - io.Copy(w, resp.Body) -} - -func (h *Handler) handlePlugins(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - h.proxyToPluginmgr(w, r, "/plugins") - case http.MethodPost: - h.proxyToPluginmgr(w, r, "/plugins") - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/") - path = strings.TrimSuffix(path, "/") - - // 插件名白名单:仅允许单段安全名称,阻断路径穿越/空名/嵌套路径 - validPluginName := func(s string) bool { - if s == "" || len(s) > 128 { - return false - } - // 禁止路径分隔符、连续点(父目录穿越)、冒号、空格等危险字符 - if strings.Contains(s, "..") || strings.ContainsAny(s, "/\\: \t\r\n\x00") { - return false - } - for _, c := range s { - if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') { - return false - } - } - return true - } - - if path == "disabled" && r.Method == http.MethodGet { - if h.pluginMgr == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{"disabled": h.pluginMgr.ListDisabledPlugins()}) - return - } - - if path == "reload" { - if r.Method == http.MethodPost { - if h.pluginMgr == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin registry not available"}) - return - } - if _, err := h.pluginMgr.ReloadPlugins(); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"}) - return - } - // reload/disabled 是保留字,不允许 DELETE/GET 等其它操作误把其当作插件名 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - if idx := strings.LastIndex(path, "/"); idx > 0 { - name := path[:idx] - action := path[idx+1:] - if r.Method == http.MethodPost { - switch action { - case "disable": - if h.pluginMgr == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) - return - } - if !validPluginName(name) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) - return - } - if err := h.pluginMgr.DisablePlugin(name, "webui"); err != nil { - // 未安装 → 404;已禁用 → 409;其余为真实内部错误 - status := http.StatusInternalServerError - msg := err.Error() - switch { - case strings.HasSuffix(msg, "not installed"): - status = http.StatusNotFound - case strings.HasSuffix(msg, "already disabled"): - status = http.StatusConflict - } - writeJSON(w, status, map[string]string{"error": msg}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"}) - return - - case "enable": - if h.pluginMgr == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) - return - } - if !validPluginName(name) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) - return - } - if err := h.pluginMgr.EnablePlugin(name); err != nil { - // 启用失败多数是“插件不存在/加载失败” → 404 更贴切 - status := http.StatusInternalServerError - if strings.Contains(err.Error(), "failed") { - status = http.StatusNotFound - } - writeJSON(w, status, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"}) - return - } - } - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // 单段插件名路径(GET 详情 / DELETE 卸载) - if !validPluginName(path) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) - return - } - - switch r.Method { - case http.MethodGet: - h.proxyToPluginmgr(w, r, "/plugins/"+path) - case http.MethodDelete: - h.proxyToPluginmgr(w, r, "/plugins/"+path) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleFiles 服务 /files/:仅限 webui_files 中转目录内的文件, -// 防路径穿越(name 必须是纯文件名),Content-Type 按扩展名白名单映射。 -func (h *Handler) handleFiles(w http.ResponseWriter, r *http.Request) { - if webFilesDir == "" { - http.NotFound(w, r) - return - } - name := strings.TrimPrefix(r.URL.Path, "/files/") - if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") { - http.NotFound(w, r) - return - } - fp := filepath.Join(webFilesDir, name) - f, err := os.Open(fp) - if err != nil { - http.NotFound(w, r) - return - } - defer f.Close() - st, err := f.Stat() - if err != nil || st.IsDir() { - http.NotFound(w, r) - return - } - ct := contentTypeByExt(strings.ToLower(filepath.Ext(name))) - w.Header().Set("Content-Type", ct) - // 图片内联展示;其他类型 attachment 下载。X-Content-Type-Options 防 MIME sniff。 - if strings.HasPrefix(ct, "image/") || strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") { - w.Header().Set("Content-Disposition", "inline; filename="+name) - } else { - w.Header().Set("Content-Disposition", "attachment; filename="+name) - } - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Cache-Control", "private, max-age=3600") - http.ServeContent(w, r, name, st.ModTime(), f) -} - -func contentTypeByExt(ext string) string { - switch ext { - case ".png": - return "image/png" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".gif": - return "image/gif" - case ".webp": - return "image/webp" - case ".bmp": - return "image/bmp" - case ".mp4": - return "video/mp4" - case ".webm": - return "video/webm" - case ".mp3": - return "audio/mpeg" - case ".wav": - return "audio/wav" - case ".ogg": - return "audio/ogg" - case ".pdf": - return "application/pdf" - case ".zip": - return "application/zip" - case ".json": - return "application/json" - case ".txt", ".log", ".md": - return "text/plain; charset=utf-8" - default: - // 未知类型强制二进制流 + nosniff,绝不内联执行 - return "application/octet-stream" - } -} - func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -2980,11 +508,6 @@ func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } -type openAIMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) diff --git a/internal/plugins/webui/handler_agents.go b/internal/plugins/webui/handler_agents.go new file mode 100644 index 0000000..85e129d --- /dev/null +++ b/internal/plugins/webui/handler_agents.go @@ -0,0 +1,240 @@ +package webui + +import ( + "fmt" + "os" + "strings" + "time" + + "encoding/json" + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + "gitcode.com/JianFeeeee/HomeAgent/internal/meta" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" + "net/http" + "path/filepath" +) + +// 内核与代理面:状态、kernel 快照、人格、代理列表/快照/回滚/动作。 + +func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + agentCount := 0 + if h.supervisor != nil { + agentCount = len(h.supervisor.ListAgents()) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "running", + "uptime": time.Since(h.startTime).Round(time.Second).String(), + "agents": agentCount, + // 内核版本取 internal/meta(-ldflags 注入点)。 + // + // 此前这里给的是 `sdk.SDKVersion`,那条链最终指向 **SDK 仓 + // meta.Version 的硬编码值**,与构建时注入的内核版本无关—— + // 两仲版本号碰巧相等时看不出问题,一旦不等就报错。 + // SDK 版本另用 sdk_version 字段并行暴露。 + "version": meta.Version, + "commit": meta.Commit, + "build_time": meta.BuildTime, + "sdk_version": sdk.SDKVersion, + "startedAt": h.startTime, + }) +} + +func (h *Handler) handleKernel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.status == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "kernel status provider not available"}) + return + } + writeJSON(w, http.StatusOK, h.status.GetKernelStatus()) +} + +// 人格设定:配置项键,以及「首启向导已经问过」的一次性标记。 +// +// 为什么需要向导:人格曾经只有 /personal/personal.md 一个来源且无人维护, +// 里面写死的旧版本号反过来让实例自述旧版本(v1.2.0 压测发现)。 +// 现在人格是配置项(默认模板不含任何版本号),首启问一次,之后不再打扰。 +// handlePersona 是首启人格向导的后端(与内核 persona_set 工具共用 internal/config 的实现)。 +// +// GET → {initialized, current_prompt, file_override} +// POST → {"mode":"default"|"custom"|"later","content":"..."} +// 写入 core.agent.personal_prompt 并打一次性标记,返回 restart_required +// +// 生效时机:人格在 homed 启动时载入(以【人格设定】块拼进系统提示词), +// 所以**自定义内容需重启生效**;选「默认」或「稍后」(保持当前默认)无需重启。 +// 不回答就是「稍后」:保留默认并打标记,不阻塞任何流程。 +// +// 跨通道:这里只是 WebUI 侧的入口;任何通道的消息到来时,内核都会检查同一枚标记, +// 未确认则在提示词里要求模型主动询问(见 internal/agent/core 的首启门禁)。 +func (h *Handler) handlePersona(w http.ResponseWriter, r *http.Request) { + if h.settings == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "settings not available"}) + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "initialized": internalConfig.PersonaInitializedKV(h.settings), + "current_prompt": internalConfig.CurrentPersonaKV(h.settings), + "file_override": h.personaFileExists(), + }) + case http.MethodPost: + var req struct { + Mode string `json:"mode"` + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + restart, err := internalConfig.SetPersonaKV(h.settings, req.Mode, req.Content) + if err != nil { + // 非法 mode / 空内容 → 400;落库失败 → 500。两者都不打标记。 + code := http.StatusInternalServerError + if strings.Contains(err.Error(), "unknown mode") || strings.Contains(err.Error(), "content required") { + code = http.StatusBadRequest + } + writeJSON(w, code, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", "mode": req.Mode, "restart_required": restart, + }) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// personaFileExists 报告是否存在会覆盖配置项的人格文件(存在时它优先)。 +// 数据目录取自 core.daemon.data_dir(由播种写入)。 +func (h *Handler) personaFileExists() bool { + v, err := h.settings.GetCore("core.daemon.data_dir") + if err != nil { + return false + } + dir, _ := v.(string) + if dir == "" { + return false + } + _, err = os.Stat(filepath.Join(dir, "personal", "personal.md")) + return err == nil +} + +func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if h.supervisor == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"}) + return + } + agents := h.supervisor.ListAgents() + writeJSON(w, http.StatusOK, map[string]interface{}{"agents": agents}) + case http.MethodPost: + var cfg types.AgentConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if cfg.ID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent id is required"}) + return + } + if h.config != nil { + kcfg := h.config.Get() + kcfg.Agents = append(kcfg.Agents, cfg) + h.config.Put(kcfg) + } + writeJSON(w, http.StatusCreated, map[string]string{"id": string(cfg.ID)}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/") + parts := strings.Split(path, "/") + agentID := types.AgentID(parts[0]) + if len(parts) == 1 { + switch r.Method { + case http.MethodGet: + if h.supervisor == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"}) + return + } + status, err := h.supervisor.GetAgentStatus(string(agentID)) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, status) + case http.MethodDelete: + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": string(agentID)}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + return + } + action := parts[1] + switch action { + case "snapshots": + h.handleSnapshots(w, r, agentID, parts) + case "rollback": + h.handleRollback(w, r, agentID, parts) + case "start", "stop", "restart": + h.handleAgentAction(w, r, agentID, action) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func (h *Handler) handleSnapshots(w http.ResponseWriter, r *http.Request, agentID types.AgentID, parts []string) { + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{"agent_id": agentID, "snapshots": []map[string]interface{}{}}) + case http.MethodPost: + if h.supervisor == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"}) + return + } + snap, err := h.supervisor.PreActionSnapshot(string(agentID)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, snap) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleRollback(w http.ResponseWriter, r *http.Request, agentID types.AgentID, parts []string) { + if r.Method != http.MethodPost || len(parts) < 3 { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.supervisor == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"}) + return + } + snapID := types.SnapshotID(parts[2]) + if err := h.supervisor.RollbackAgent(string(agentID), string(snapID)); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_initiated", "agent": string(agentID), "snap": string(snapID)}) +} + +func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agentID types.AgentID, action string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": fmt.Sprintf("agent %s action not implemented by supervisor", action)}) +} diff --git a/internal/plugins/webui/handler_chat.go b/internal/plugins/webui/handler_chat.go new file mode 100644 index 0000000..2c0bed8 --- /dev/null +++ b/internal/plugins/webui/handler_chat.go @@ -0,0 +1,706 @@ +package webui + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "encoding/json" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "net/http" +) + +// 对话面:聊天消息模型与内存历史、SSE 事件订阅、对话与历史接口。 +// +// 持久化在 history.go(独立文件,位置由插件设置 history_file 决定)。 + +type ChatMsg struct { + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` + Source string `json:"source,omitempty"` + Time string `json:"time"` + // Attachment 附件输出(output_send__webui type=image/file): + // image 前端内联展示,file 渲染下载卡片。nil 表示纯文本消息。 + Attachment *Attachment `json:"attachment,omitempty"` +} + +// Attachment 描述一条附件消息(与 SSE agent_output 事件的 output_type/url/size 对应)。 +type Attachment struct { + Type string `json:"type"` // "image" | "file" + URL string `json:"url"` // /files/ 或远程 http(s) URL + Size int64 `json:"size,omitempty"` // 字节数(远程 URL 为 0) + Name string `json:"name,omitempty"` // 展示用文件名 +} + +type ChatToolCall struct { + Tool string `json:"tool"` + Name string `json:"name,omitempty"` + Args interface{} `json:"args,omitempty"` + Result interface{} `json:"result,omitempty"` + Status string `json:"status,omitempty"` + Plugin string `json:"plugin,omitempty"` +} + +const maxChatHistory = 200 + +// ===== client_msg_id 去重(防 GUI 断线重连/超时重试导致的消息重放)===== +// GUI 端每条发送消息带唯一 client_msg_id;服务端按 ID 单飞(singleflight): +// 首次请求正常注入 agent,同 ID 重放等待首次结果并直接复用,不再重复处理。 + +const maxChatMsgCache = 256 + +type chatMsgEntry struct { + done chan struct{} + resp *agentIO.OutputEvent +} + +func (h *Handler) claimChatMsg(id string) (*chatMsgEntry, bool) { + h.chatMsgMu.Lock() + defer h.chatMsgMu.Unlock() + if e, ok := h.chatMsgCache[id]; ok { + return e, true + } + e := &chatMsgEntry{done: make(chan struct{})} + h.chatMsgCache[id] = e + h.chatMsgOrder = append(h.chatMsgOrder, id) + if len(h.chatMsgOrder) > maxChatMsgCache { + old := h.chatMsgOrder[0] + h.chatMsgOrder = h.chatMsgOrder[1:] + delete(h.chatMsgCache, old) + } + return e, false +} + +// completeChatMsg 记录首次处理结果并唤醒所有等待的同 ID 重放请求。 +func (h *Handler) completeChatMsg(e *chatMsgEntry, resp *agentIO.OutputEvent) { + e.resp = resp + close(e.done) +} + +func (h *Handler) loadChatHistory() { + if h.history == nil { + return + } + msgs := h.history.LoadWithMigration(h.settings) + if len(msgs) == 0 { + return + } + h.chatMu.Lock() + h.chatHistory = msgs + h.chatMu.Unlock() +} + +func (h *Handler) trackToolEvents() { + if h.sdk == nil { + return + } + h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) { + h.handleToolEvent(ev) + }) +} + +// subscribeChatEvents 捕获所有通道(cli/qq/webui 等)的对话轮次, +// 与 handleChat 的注入一起构成完整的全通道对话历史。 +func (h *Handler) subscribeChatEvents() { + if h.sdk == nil { + return + } + h.sdk.Subscribe(sdk.EventRawInput, func(ev *sdk.Event) { + content, _ := ev.Payload["content"].(string) + source, _ := ev.Payload["source"].(string) + // 用户上传的附件(handleChatFile 注入的 payload 携带 upload_* 字段) + var att *Attachment + if url, _ := ev.Payload["upload_url"].(string); url != "" { + ut, _ := ev.Payload["upload_type"].(string) + var size int64 + switch v := ev.Payload["upload_size"].(type) { + case int64: + size = v + case float64: + size = int64(v) + } + name, _ := ev.Payload["upload_name"].(string) + att = &Attachment{Type: ut, URL: url, Size: size, Name: name} + } + if content == "" { + return + } + h.chatMu.Lock() + h.pendingIdx = -1 + h.chatMu.Unlock() + h.addChatMsg(ChatMsg{ + Role: "user", + Content: content, + Source: source, + Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), + Attachment: att, + }) + }) + h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) { + tool, _ := ev.Payload["tool"].(string) + if tool == "" { + return + } + channel, _ := ev.Payload["channel"].(string) + if channel == "_consolidation_" { + return + } + plugin, _ := ev.Payload["plugin"].(string) + status, _ := ev.Payload["status"].(string) + if status == "" { + status = "ok" + } + tc := ChatToolCall{ + Tool: tool, + Name: tool, + Args: ev.Payload["args"], + Result: ev.Payload["result"], + Status: status, + Plugin: plugin, + } + h.chatMu.Lock() + msg := h.pendingAssistantLocked() + if msg == nil { + h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)}) + h.pendingIdx = len(h.chatHistory) - 1 + msg = &h.chatHistory[h.pendingIdx] + } + msg.ToolCalls = append(msg.ToolCalls, tc) + h.persistChatLocked() + h.chatMu.Unlock() + }) + h.sdk.Subscribe(sdk.EventReasoning, func(ev *sdk.Event) { + content, _ := ev.Payload["content"].(string) + if content == "" { + return + } + channel, _ := ev.Payload["channel"].(string) + if channel == "_consolidation_" { + return + } + h.chatMu.Lock() + msg := h.pendingAssistantLocked() + if msg == nil { + h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)}) + h.pendingIdx = len(h.chatHistory) - 1 + msg = &h.chatHistory[h.pendingIdx] + } + msg.ReasoningContent += content + h.persistChatLocked() + h.chatMu.Unlock() + }) + h.sdk.Subscribe(sdk.EventAgentOutput, func(ev *sdk.Event) { + content, _ := ev.Payload["content"].(string) + channel, _ := ev.Payload["channel"].(string) + kind, _ := ev.Payload["kind"].(string) + h.chatMu.Lock() + // 输出通道主动输出(output_send__{通道})作为独立气泡,不并入最终回复 + if kind == "channel_output" { + h.pendingIdx = -1 + // 附件输出(output_type=image/file):存 attachment 字段供前端渲染, + // content 保留原始 payload 作为备选文案(历史兼容旧数据)。 + var att *Attachment + if ot, _ := ev.Payload["output_type"].(string); ot == "image" || ot == "file" { + url, _ := ev.Payload["url"].(string) + size, _ := ev.Payload["size"].(int64) + if f, ok := ev.Payload["size"].(float64); ok && size == 0 { + size = int64(f) + } + name := url + if i := strings.LastIndexByte(url, '/'); i >= 0 { + name = url[i+1:] + } + att = &Attachment{Type: ot, URL: url, Size: size, Name: name} + } + if content == "" && att == nil { + h.chatMu.Unlock() + return + } + m := ChatMsg{ + Role: "assistant", + Source: channel, + Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), + Attachment: att, + } + // 附件消息不把本地路径当正文展示(如 "/tmp/homeagent.png"),置空 + if att != nil { + m.Content = "" + } else { + m.Content = content + } + // 已持 chatMu:直接操作 chatHistory + persist,不可调 addChatMsg + //(内部会重入加锁导致死锁——output_send__webui 发图 60s 超时的根因) + h.chatHistory = append(h.chatHistory, m) + if len(h.chatHistory) > maxChatHistory { + drop := len(h.chatHistory) - maxChatHistory + h.chatHistory = h.chatHistory[drop:] + if h.pendingIdx >= 0 { + h.pendingIdx -= drop + if h.pendingIdx < 0 { + h.pendingIdx = -1 + } + } + } + h.persistChatLocked() + h.chatMu.Unlock() + return + } + if msg := h.pendingAssistantLocked(); msg != nil && content != "" { + msg.Content = content + if channel != "" { + msg.Source = channel + } + h.pendingIdx = -1 + h.persistChatLocked() + h.chatMu.Unlock() + return + } + h.pendingIdx = -1 + h.chatMu.Unlock() + if content == "" { + return + } + h.addChatMsg(ChatMsg{ + Role: "assistant", + Content: content, + Source: channel, + Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), + }) + }) +} + +// pendingAssistantLocked 返回 chatHistory 中当前进行中的 assistant 消息(已持有 chatMu)。 +// 仅当最后一条是 assistant 且尚未产出最终内容时视为进行中,避免跨轮次误合并。 +func (h *Handler) pendingAssistantLocked() *ChatMsg { + if h.pendingIdx < 0 || h.pendingIdx >= len(h.chatHistory) { + return nil + } + msg := &h.chatHistory[h.pendingIdx] + if msg.Role != "assistant" || msg.Content != "" { + return nil + } + return msg +} + +func (h *Handler) persistChatLocked() { + if h.history == nil { + return + } + // 写独立文件(原子替换)。失败只告警:聊天记录不该影响对话主流程。 + if err := h.history.Save(h.chatHistory); err != nil { + log.Printf("[webui] 写聊天记录 %s 失败: %v", h.history.Path(), err) + } +} + +func (h *Handler) handleToolEvent(ev *sdk.Event) { + payload := ev.Payload + tool, _ := payload["tool"].(string) + args, _ := payload["args"].(map[string]interface{}) + status, _ := payload["status"].(string) + ts := time.Now() + + switch tool { + case "cmd_run": + exec := CmdExec{ + Command: getStr(args, "command"), + Status: status, + Time: ts.Format(time.RFC3339), + } + h.cmdMu.Lock() + h.cmdHistory = append(h.cmdHistory, exec) + if len(h.cmdHistory) > maxCmdHistory { + h.cmdHistory = h.cmdHistory[len(h.cmdHistory)-maxCmdHistory:] + } + h.cmdMu.Unlock() + + case "terminal_create": + id := getStr(args, "id") + if id == "" { + // agent 调用时不知道生成的 id,从工具结果中回填 + if res, ok := payload["result"].(map[string]interface{}); ok { + id = getStr(res, "id") + } + } + if id == "" { + break + } + cmd := getStr(args, "command") + if cmd == "" { + if res, ok := payload["result"].(map[string]interface{}); ok { + cmd = getStr(res, "command") + } + } + now := time.Now() + term := &termState{ + ID: id, + Command: cmd, + Running: true, + CreatedAt: now.Format(time.RFC3339), + created: now, + } + h.termMu.Lock() + if old, ok := h.termStates[id]; ok { + old.Command = cmd + old.Running = true + old.created = now + } else { + h.termStates[id] = term + } + if len(h.termStates) > maxTerminals { + for k := range h.termStates { + delete(h.termStates, k) + break + } + } + h.termMu.Unlock() + + case "terminal_close": + id := getStr(args, "id") + if id != "" { + h.termMu.Lock() + if t, ok := h.termStates[id]; ok { + t.Running = false + } + h.termMu.Unlock() + } + } +} + +// chatSaveThrottle 控制写盘频率:最多每 3 秒写一次 +const chatSaveThrottle = 3 * time.Second + +func (h *Handler) addChatMsg(msg ChatMsg) { + h.chatMu.Lock() + h.chatHistory = append(h.chatHistory, msg) + if len(h.chatHistory) > maxChatHistory { + drop := len(h.chatHistory) - maxChatHistory + h.chatHistory = h.chatHistory[drop:] + if h.pendingIdx >= 0 { + h.pendingIdx -= drop + if h.pendingIdx < 0 { + h.pendingIdx = -1 + } + } + } + h.persistChatLocked() + h.chatMu.Unlock() +} + +// handleChatHistory 返回对话历史,支持分段懒加载。 +// +// 查询参数(全部可选,省略时保持旧行为=返回全量,向后兼容旧客户端): +// - limit: 返回条数上限(1..maxChatHistory)。带 limit 时默认取「最新的 limit 条」。 +// - before: 游标,只返回下标 < before 的消息(配合 limit 向上翻页取更早历史)。 +// +// 响应额外返回 total / offset / has_more,供前端判断是否继续向上加载。 +// 注意:不对 tool_calls / reasoning_content 做任何裁剪——工具调用详情是排查与 +// 上下文还原的关键信息,必须完整下发;瘦身只通过分页控制条数。 +func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + limit := parseIntDefault(q.Get("limit"), 0) + if limit < 0 { + limit = 0 + } + if limit > maxChatHistory { + limit = maxChatHistory + } + + h.chatMu.Lock() + total := len(h.chatHistory) + // before 游标:默认取到末尾(最新) + end := parseIntDefault(q.Get("before"), total) + if end < 0 || end > total { + end = total + } + start := 0 + if limit > 0 && end-limit > 0 { + start = end - limit + } + result := make([]ChatMsg, end-start) + copy(result, h.chatHistory[start:end]) + h.chatMu.Unlock() + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "messages": result, + "total": total, + "offset": start, + "has_more": start > 0, + }) +} + +func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Message string `json:"message"` + DeviceID string `json:"device_id"` // 消息来源设备(GUI/受控设备),可选 + DeviceName string `json:"device_name"` // 设备显示名,可选 + ClientMsgID string `json:"client_msg_id"` // 客户端唯一消息 ID(防断线重放/超时重试) + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if body.Message == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"}) + return + } + + if h.sdk == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) + return + } + + // client_msg_id 去重:同 ID 重放等待首次结果直接复用,不重复注入 agent。 + // 无 ID 的旧客户端走原路径(agent 核心层另有内容级短窗口去重兑底)。 + var entry *chatMsgEntry + if body.ClientMsgID != "" { + var replay bool + entry, replay = h.claimChatMsg(body.ClientMsgID) + if replay { + log.Printf("[webui] duplicate chat msg %s: waiting for first request result", body.ClientMsgID) + select { + case <-entry.done: + resp := entry.resp + if resp == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) + return + } + content, _ := resp.Payload["content"].(string) + reasoning, _ := resp.Payload["reasoning_content"].(string) + result := map[string]interface{}{"response": content, "deduplicated": true} + if reasoning != "" { + result["reasoning_content"] = reasoning + } + writeJSON(w, http.StatusOK, result) + case <-r.Context().Done(): + return + } + return + } + } + + // 来源编码:带设备身份时用 webui/{device_id}(agent 经 injectSourceContext 可见来源); + // 无设备时保持 webui(兼容旧调用)。device_name 一并注入便于 agent 识别。 + source := "webui" + if body.DeviceID != "" { + source = "webui/" + body.DeviceID + } + payload := map[string]interface{}{"content": body.Message} + if body.DeviceID != "" { + payload["device_id"] = body.DeviceID + payload["device_name"] = body.DeviceName + } + if body.ClientMsgID != "" { + payload["client_msg_id"] = body.ClientMsgID + } + // 带超时的上下文,防止 InjectInputSync 长时间阻塞 HTTP 请求。 + // 注意:ctx 派生自 r.Context(),客户端提前断开(前端 15s ackTimer abort)时 + // 立即取消,不会真等满 300s;300s 只约束"连接保持 + agent 排队/长生成"场景 + // (agent 串行处理,后发消息的排队时间也计入,60s 曾导致连发第 3 条必超时)。 + ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) + defer cancel() + + respCh := make(chan *agentIO.OutputEvent, 1) + go func() { + respCh <- h.sdk.InjectInputSync(source, "webui", "text", payload) + }() + + var resp *agentIO.OutputEvent + select { + case resp = <-respCh: + case <-ctx.Done(): + if entry != nil { + h.completeChatMsg(entry, nil) + } + writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (60s)"}) + return + } + + if entry != nil { + h.completeChatMsg(entry, resp) + } + + if resp == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) + return + } + content, _ := resp.Payload["content"].(string) + reasoning, _ := resp.Payload["reasoning_content"].(string) + result := map[string]interface{}{ + "response": content, + } + if reasoning != "" { + result["reasoning_content"] = reasoning + } + writeJSON(w, http.StatusOK, result) +} + +func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", 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.WriteHeader(http.StatusOK) + flusher.Flush() + + done := r.Context().Done() + if h.sdk == nil { + fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"event bus unavailable\"}\n\n") + flusher.Flush() + return + } + + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + // writeCh 不 close:Subscribe 回调闭包持有它,handler 退出后回调仍可能被 + // 总线异步触发,close 后再发送会 panic(send on closed channel,生产日志中 + // 单日数千次)。writer goroutine 通过 done 退出;发送侧 select on done 防泄漏。 + // 缓冲 2048:reasoning/content 增量是高频小包(LLM token 级), + // 512 时连续 tool_call + reasoning + delta 密集期会溢出导致前端丢帧。 + // 写入侧用短超时(50ms)兜底,比立即丢弃更友好。 + writeCh := make(chan string, 2048) + writerDone := make(chan struct{}) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[SSE] writer panic: %v", r) + } + close(writerDone) + }() + // 批量合并窗口:16ms 内收集的增量一次性 flush,降 flush 次数、 + // 避免高频小包拖慢 socket 写导致 writeCh 积压丢 delta。 + pending := make([]string, 0, 64) + flushPending := func() { + if len(pending) == 0 { + return + } + for _, line := range pending { + fmt.Fprintf(w, "%s\n", line) + } + flusher.Flush() + pending = pending[:0] + } + flushTicker := time.NewTicker(16 * time.Millisecond) + defer flushTicker.Stop() + for { + select { + case line := <-writeCh: + pending = append(pending, line) + // 大批量一次性 flush:阈值从 64 提高,利用批量减少 syscall 开销 + if len(pending) >= 128 { + flushPending() + } + case <-flushTicker.C: + flushPending() + case <-done: + flushPending() + return + } + } + }() + + log.Printf("[SSE] handler started, subscribing to events") + + // 解析 Last-Event-ID(断线重连时客户端携带),重放期间内遗漏的事件。 + // 注意:本 handler 的 Last-Event-ID 重放仅为 GUI (cmd/gui/renderer/app.js) 服务。 + // 浏览器原生 EventSource (webui/dashboard.html 使用) 由浏览器自动处理 Last-Event-ID 重连。 + if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" { + log.Printf("[SSE] client reported Last-Event-ID: %s", lastEventID) + if h.sseEvents != nil { + replayed := h.sseEvents.After(lastEventID) + if len(replayed) == 0 { + log.Printf("[SSE] replay: nothing after id %s (id not in ring or already at tip)", lastEventID) + // ID 不在 ring:说明最后一帧是 delta(delta 不进 ring)或已到最新。 + // 显式通知前端补拉历史,避免其空等后续聚合事件(表现为消息同步不及时)。 + fmt.Fprintf(w, "event: sync_required\ndata: {}\n\n") + flusher.Flush() + } else { + log.Printf("[SSE] replay: sending %d events after id %s", len(replayed), lastEventID) + for _, rec := range replayed { + fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n", rec.id, rec.eventType, string(rec.data)) + flusher.Flush() + } + log.Printf("[SSE] replay complete, wrote %d events", len(replayed)) + } + } + } + + subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output"} + // token 级流式增量事件:实时转发给浏览器做逐 token 渲染。 + // 不进 sseEventRing —— 断线重连只重放聚合事件(最终真相), + // 避免重放 delta 与聚合内容重复追加。 + var unsubs []func() + var seq int64 + appendDeltaSub := func(evtType sdk.EventType) { + unsub := h.sdk.Subscribe(evtType, func(evt *sdk.Event) { + data, _ := json.Marshal(evt) + seq++ + id := fmt.Sprintf("%d-%d", evt.Timestamp, seq) + sendSSE(writeCh, id, string(evt.Type), string(data)) + }) + unsubs = append(unsubs, unsub) + } + appendDeltaSub(sdk.EventReasoningDelta) + appendDeltaSub(sdk.EventContentDelta) + + for _, t := range subTypes { + t2 := t + unsub := h.sdk.Subscribe(sdk.EventType(t2), func(evt *sdk.Event) { + if evt.Type == sdk.EventToolCall { + toolName, _ := evt.Payload["tool"].(string) + log.Printf("[SSE] received tool_call event: tool=%s", toolName) + } + data, _ := json.Marshal(evt) + seq++ + id := fmt.Sprintf("%d-%d", evt.Timestamp, seq) + // 写入环状缓冲区,供断线重连重放 + if h.sseEvents != nil { + h.sseEvents.Append(id, string(evt.Type), data) + } + sendSSE(writeCh, id, string(evt.Type), string(data)) + if evt.Type == sdk.EventToolCall { + toolName, _ := evt.Payload["tool"].(string) + log.Printf("[SSE] wrote tool_call to writeCh: tool=%s", toolName) + } + }) + unsubs = append(unsubs, unsub) + } + defer func() { + for _, unsub := range unsubs { + unsub() + } + <-writerDone // 等 writer 退出,保证 handler 返回后无残余写入 + }() + for { + select { + case <-done: + return + case <-ticker.C: + // 心跳直接写 w 并 flush(绕过 writeCh,事件密集/队列满时也能保活长连接, + // 避免远程 nginx 网关因长时间无字节而 504/半开)。 + if _, err := fmt.Fprintf(w, ": heartbeat\n\n"); err != nil { + return + } + flusher.Flush() + } + } +} diff --git a/internal/plugins/webui/handler_device.go b/internal/plugins/webui/handler_device.go new file mode 100644 index 0000000..d0264c9 --- /dev/null +++ b/internal/plugins/webui/handler_device.go @@ -0,0 +1,191 @@ +package webui + +import ( + "bufio" + "io" + "net" + "strings" + "time" + + "net/http" +) + +// 设备网关反代:/api/v1/device/* → remotedevice(HTTP + WS 升级透传)。 + +// ======== Remote Device Gateway (proxied to remotedevice, opt-in) ======== + +// deviceGatewayEnabled / deviceGatewayAddr 由 webui 插件启动时从设置读取并注入。 +// 默认禁用:用户显式配置 device_gateway_enabled=true 后,/api/v1/device/* 才会反代到 +// remotedevice 插件(self-contained),避免与 remotedevice 耦合。 +var ( + deviceGatewayEnabled bool + deviceGatewayAddr string + deviceGatewayToken string +) + +// handleDeviceGatewayProxy 将 /api/v1/device/* 反代到 remotedevice 内部 HTTP 服务。 +// 鉴权:本端走 requireAPI(webui API key),转发时带 remotedevice 的 token(X-API-Key)。 +// WS 升级请求(Upgrade: websocket)走 hijack 双向字节透传(标准库 http.Client 不支持 101 升级)。 +func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Request) { + if !deviceGatewayEnabled { + http.NotFound(w, r) + return + } + // 客户端鉴权模式:服务端不再提供授权接口(授权由设备端本地控制)。 + // 拒绝旧的 /device/auth 调用,避免误导。 + if strings.HasSuffix(r.URL.Path, "/device/auth") { + writeJSON(w, http.StatusGone, map[string]string{ + "error": "device authorization moved to client-side; the server no longer stores authorization state", + }) + return + } + addr := deviceGatewayAddr + if addr == "" { + addr = "127.0.0.1:9890" + } + path := r.URL.Path // 保留 /api/v1/device/... 全路径 + url := "http://" + addr + path + if r.URL.RawQuery != "" { + url += "?" + r.URL.RawQuery + } + + // WebSocket 升级:hijack 双向透传(支持 WS over 远程 homed) + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + h.proxyWebSocket(w, r, addr, path) + return + } + + req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + req.Header = r.Header.Clone() + if deviceGatewayToken != "" { + req.Header.Set("X-API-Key", deviceGatewayToken) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "device gateway unreachable: " + err.Error()}) + return + } + defer resp.Body.Close() + for k, v := range resp.Header { + w.Header()[k] = v + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +// proxyWebSocket 用 TCP 直连 + hijack 将客户端 WS 连接双向透传到设备网关。 +func (h *Handler) proxyWebSocket(w http.ResponseWriter, r *http.Request, addr, path string) { + // 设备网关默认仅监听 127.0.0.1(remotedevice),反代目标即内网 homed 本机或指定 addr。 + // 用 net.Dial 直连网关并手动发起 WS 升级握手(net/http 客户端不支持 ws:// 升级)。 + host, port := addr, "9890" + if h2, p2, ok := splitHostPort(addr); ok { + host, port = h2, p2 + } + target := net.JoinHostPort(host, port) + upConn, err := net.DialTimeout("tcp", target, 15*time.Second) + if err != nil { + http.Error(w, "ws upstream dial: "+err.Error(), http.StatusBadGateway) + return + } + defer upConn.Close() + + // 手动构造 WS 升级请求(保留客户端头 + 注入网关 token) + key := r.Header.Get("Sec-WebSocket-Key") + if key == "" { + key = "homeagent-proxy-random-key" + } + reqPath := path + if r.URL.RawQuery != "" { + reqPath += "?" + r.URL.RawQuery + } + var b strings.Builder + b.WriteString("GET " + reqPath + " HTTP/1.1\r\n") + b.WriteString("Host: " + addr + "\r\n") + b.WriteString("Upgrade: websocket\r\n") + b.WriteString("Connection: Upgrade\r\n") + b.WriteString("Sec-WebSocket-Key: " + key + "\r\n") + b.WriteString("Sec-WebSocket-Version: 13\r\n") + if deviceGatewayToken != "" { + b.WriteString("X-API-Key: " + deviceGatewayToken + "\r\n") + } + for k, vv := range r.Header { + kl := strings.ToLower(k) + if kl == "upgrade" || kl == "connection" || kl == "sec-websocket-key" || kl == "sec-websocket-version" || kl == "host" || kl == "x-api-key" || kl == "authorization" { + continue + } + for _, v := range vv { + b.WriteString(k + ": " + v + "\r\n") + } + } + b.WriteString("\r\n") + if _, err := upConn.Write([]byte(b.String())); err != nil { + http.Error(w, "ws upstream write: "+err.Error(), http.StatusBadGateway) + return + } + + // 读上游 101 响应 + br := bufio.NewReader(upConn) + resp, err := http.ReadResponse(br, nil) + if err != nil { + http.Error(w, "ws upstream response: "+err.Error(), http.StatusBadGateway) + return + } + if resp.StatusCode != http.StatusSwitchingProtocols { + http.Error(w, "ws upstream status: "+resp.Status, http.StatusBadGateway) + return + } + + // 客户端 hijack:把 101 响应头写给客户端并接管双向连接 + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijack not supported", http.StatusInternalServerError) + return + } + clientConn, brw, err := hj.Hijack() + if err != nil { + return + } + defer clientConn.Close() + + // 向上游 101 响应头转发给客户端 + if err := resp.Write(brw); err != nil { + return + } + if err := brw.Flush(); err != nil { + return + } + + // 双向透传(WS 帧字节不动): + // 客户端 -> 上游 + errCh := make(chan struct{}, 2) + go func() { + io.Copy(upConn, brw) + if tc, ok := upConn.(interface{ CloseWrite() error }); ok { + tc.CloseWrite() + } + errCh <- struct{}{} + }() + // 上游 -> 客户端 + go func() { + wb := bufio.NewWriter(clientConn) + io.Copy(wb, br) + wb.Flush() + errCh <- struct{}{} + }() + <-errCh +} + +// splitHostPort 拆分 addr 为 host/port;无端口时返回 ok=false。 +func splitHostPort(addr string) (string, string, bool) { + if strings.Contains(addr, ":") { + h, p, err := net.SplitHostPort(addr) + if err == nil { + return h, p, true + } + } + return addr, "", false +} diff --git a/internal/plugins/webui/handler_files.go b/internal/plugins/webui/handler_files.go new file mode 100644 index 0000000..a84fcc9 --- /dev/null +++ b/internal/plugins/webui/handler_files.go @@ -0,0 +1,84 @@ +package webui + +import ( + "os" + "strings" + + "net/http" + "path/filepath" +) + +// 附件面:/files/ 中转移目录与 /uploads/ 用户上传目录的下载。 + +// handleFiles 服务 /files/:仅限 webui_files 中转目录内的文件, +// 防路径穿越(name 必须是纯文件名),Content-Type 按扩展名白名单映射。 +func (h *Handler) handleFiles(w http.ResponseWriter, r *http.Request) { + if webFilesDir == "" { + http.NotFound(w, r) + return + } + name := strings.TrimPrefix(r.URL.Path, "/files/") + if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") { + http.NotFound(w, r) + return + } + fp := filepath.Join(webFilesDir, name) + f, err := os.Open(fp) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + st, err := f.Stat() + if err != nil || st.IsDir() { + http.NotFound(w, r) + return + } + ct := contentTypeByExt(strings.ToLower(filepath.Ext(name))) + w.Header().Set("Content-Type", ct) + // 图片内联展示;其他类型 attachment 下载。X-Content-Type-Options 防 MIME sniff。 + if strings.HasPrefix(ct, "image/") || strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") { + w.Header().Set("Content-Disposition", "inline; filename="+name) + } else { + w.Header().Set("Content-Disposition", "attachment; filename="+name) + } + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, max-age=3600") + http.ServeContent(w, r, name, st.ModTime(), f) +} + +func contentTypeByExt(ext string) string { + switch ext { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".bmp": + return "image/bmp" + case ".mp4": + return "video/mp4" + case ".webm": + return "video/webm" + case ".mp3": + return "audio/mpeg" + case ".wav": + return "audio/wav" + case ".ogg": + return "audio/ogg" + case ".pdf": + return "application/pdf" + case ".zip": + return "application/zip" + case ".json": + return "application/json" + case ".txt", ".log", ".md": + return "text/plain; charset=utf-8" + default: + // 未知类型强制二进制流 + nosniff,绝不内联执行 + return "application/octet-stream" + } +} diff --git a/internal/plugins/webui/handler_memory.go b/internal/plugins/webui/handler_memory.go new file mode 100644 index 0000000..1b2efd2 --- /dev/null +++ b/internal/plugins/webui/handler_memory.go @@ -0,0 +1,336 @@ +package webui + +import ( + "strconv" + "strings" + + "encoding/json" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "net/http" +) + +// 记忆面:图记忆 / 文档记忆 / 文本记忆 / 知识库 / LLM 源 / 变更追踪。 + +func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) { + if h.memory == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"}) + return + } + switch r.Method { + case http.MethodGet: + userInput := r.URL.Query().Get("q") + keywords := strings.Split(userInput, ",") + depth, _ := strconv.Atoi(r.URL.Query().Get("depth")) + if depth <= 0 { + depth = 2 + } + entities, relations, err := h.memory.Recall(keywords, depth) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"entities": entities, "relations": relations}) + case http.MethodPost: + var req struct { + Triples []sdk.Triple `json:"triples"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if err := h.memory.Commit(req.Triples); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "committed", "committed": len(req.Triples)}) + case http.MethodDelete: + var req struct { + Criteria map[string]string `json:"criteria"` + Mode string `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + deleted, err := h.memory.Purge(req.Criteria, req.Mode) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleMemoryContext(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.indexer == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"}) + return + } + userInput := r.URL.Query().Get("q") + injected, err := h.indexer.BuildContext(userInput) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "context": h.indexer.FormatContext(injected), + "summary": injected.Summary, + "entities": injected.Entities, + "token_estimate": injected.TokenEstimate, + "tool_prompt": h.indexer.BuildToolPrompt(), + }) +} + +func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.indexer == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "tools": h.indexer.GetToolDefinitions(), + "tool_prompt": h.indexer.BuildToolPrompt(), + }) +} + +func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.memory == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"}) + return + } + data, err := h.memory.GraphData() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data}) +} + +func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if h.knowledge == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"}) + return + } + query := r.URL.Query().Get("q") + if query != "" { + results, err := h.knowledge.Search(query, 10) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"results": results}) + return + } + categories, err := h.knowledge.List() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "categories": categories, + "stats": h.knowledge.Stats(), + }) + + case http.MethodPost: + if h.knowledge == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"}) + return + } + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + if err := r.ParseMultipartForm(10 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + name := r.FormValue("name") + file, _, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"}) + return + } + defer file.Close() + buf := make([]byte, 10<<20) + n, _ := file.Read(buf) + content := string(buf[:n]) + if err := h.knowledge.Add(name, content); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": name}) + return + } + + var req struct { + Name string `json:"name"` + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Name == "" || req.Content == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and content required"}) + return + } + if err := h.knowledge.Add(req.Name, req.Content); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": req.Name}) + + case http.MethodDelete: + name := r.URL.Query().Get("name") + if name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name query param required"}) + return + } + if err := h.knowledge.Remove(name); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) { + if h.textMem == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "text memory not available"}) + return + } + switch r.Method { + case http.MethodGet: + recent, _ := h.textMem.RecentEvents(50) + stats := h.textMem.Stats() + writeJSON(w, http.StatusOK, map[string]interface{}{ + "stats": stats, + "recent": recent, + }) + case http.MethodDelete: + writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleAdapters(w http.ResponseWriter, r *http.Request) { + if h.adapter == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"}) + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": h.adapter.List()}) + case http.MethodPost: + var req struct { + Name string `json:"name"` + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if err := h.adapter.Load(req.Name, req.Code); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "loaded", "name": req.Name}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleAdapterByID(w http.ResponseWriter, r *http.Request) { + if h.adapter == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"}) + return + } + name := strings.TrimPrefix(r.URL.Path, "/api/v1/adapters/") + if name == "" { + http.NotFound(w, r) + return + } + switch r.Method { + case http.MethodGet: + for _, a := range h.adapter.List() { + if a.Name == name { + writeJSON(w, http.StatusOK, a) + return + } + } + http.NotFound(w, r) + case http.MethodDelete: + if err := h.adapter.Remove(name); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "adapter not found"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "network_status": "monitoring", + "endpoints": h.config.Get().Defaults.LLMEndpoints, + }) +} + +func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) { + if h.tracker == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "tracker not available"}) + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/v1/tracker") + path = strings.TrimPrefix(path, "/") + + switch { + case path == "changesets" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "changesets": h.tracker.ChangeSets(), + "count": len(h.tracker.ChangeSets()), + }) + case path == "rollback" && r.Method == http.MethodPost: + if err := h.tracker.Rollback(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_complete"}) + case path == "" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "stats": h.tracker.Stats(), + "has_changes": h.tracker.HasChanges(), + "changesets": len(h.tracker.ChangeSets()), + }) + case path == "" && r.Method == http.MethodDelete: + if err := h.tracker.Rollback(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} diff --git a/internal/plugins/webui/handler_openai.go b/internal/plugins/webui/handler_openai.go new file mode 100644 index 0000000..f5e3f43 --- /dev/null +++ b/internal/plugins/webui/handler_openai.go @@ -0,0 +1,194 @@ +package webui + +import ( + "context" + "fmt" + "time" + + "encoding/json" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "net/http" +) + +// OpenAI 兼容面:/v1/chat/completions(含流式)。 + +func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if h.sdk == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "IO manager not available"}) + return + } + + var req struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` + Stream bool `json:"stream"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()}) + return + } + if len(req.Messages) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "messages is required"}) + return + } + + lastMsg := req.Messages[len(req.Messages)-1] + if lastMsg.Role != "user" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "last message must be from user"}) + return + } + + // 带超时的上下文(同 handleChat:客户端断开立即取消;300s 约束长生成) + ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) + defer cancel() + + respCh := make(chan *agentIO.OutputEvent, 1) + go func() { + // NoMemory:本端点(OpenAI 兼容 /v1/chat/completions)的调用方是 + // IDE、工具与脚本,送来的是**固定的提示词模板**("请分析这段代码"之类), + // 不是人类在对话。记进记忆会把真实对话挤掉,而且同一模板会反复刷屏。 + // 原文仍进上下文,模型照旧看得到;只是不参与向量化/关键词提取/蒸馏。 + respCh <- h.sdk.InjectTextSyncNoMemory("http", "http", lastMsg.Content) + }() + + var response *agentIO.OutputEvent + select { + case response = <-respCh: + case <-ctx.Done(): + writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (300s)"}) + return + } + + if response == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no response from agent"}) + return + } + + content, _ := response.Payload["content"].(string) + reasoningContent, _ := response.Payload["reasoning_content"].(string) + usage, _ := response.Payload["usage"].(map[string]interface{}) + + if req.Stream { + h.writeOpenAIStream(w, req.Model, content, reasoningContent, usage) + return + } + + resp := map[string]interface{}{ + "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), + "object": "chat.completion", + "created": time.Now().Unix(), + "model": req.Model, + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]interface{}{ + "role": "assistant", + "content": content, + }, + "finish_reason": "stop", + }, + }, + } + if reasoningContent != "" { + resp["choices"].([]map[string]interface{})[0]["message"].(map[string]interface{})["reasoning_content"] = reasoningContent + } + if usage != nil { + resp["usage"] = usage + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(resp) +} + +func (h *Handler) writeOpenAIStream(w http.ResponseWriter, model, content, reasoningContent string, usage map[string]interface{}) { + flusher, ok := w.(http.Flusher) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"}) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // 如果有 reasoning_content,先发送一个 reasoning chunk + if reasoningContent != "" { + reasoningChunk := map[string]interface{}{ + "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), + "object": "chat.completion.chunk", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{ + { + "index": 0, + "delta": map[string]interface{}{ + "content": "", + "reasoning_content": reasoningContent, + }, + "finish_reason": nil, + }, + }, + } + data, _ := json.Marshal(reasoningChunk) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + + // content chunk + contentChunk := map[string]interface{}{ + "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), + "object": "chat.completion.chunk", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{ + { + "index": 0, + "delta": map[string]interface{}{ + "content": content, + }, + "finish_reason": nil, + }, + }, + } + data, _ := json.Marshal(contentChunk) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + // finish chunk + finishChunk := map[string]interface{}{ + "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), + "object": "chat.completion.chunk", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{ + { + "index": 0, + "delta": map[string]interface{}{}, + "finish_reason": "stop", + }, + }, + } + if usage != nil { + finishChunk["usage"] = usage + } + data, _ = json.Marshal(finishChunk) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() +} + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} diff --git a/internal/plugins/webui/handler_plugin_test.go b/internal/plugins/webui/handler_plugin_test.go index d911e4f..4b55df3 100644 --- a/internal/plugins/webui/handler_plugin_test.go +++ b/internal/plugins/webui/handler_plugin_test.go @@ -52,7 +52,7 @@ func (m *mockPluginMgr) ReloadPlugins() (string, error) { m.reloadN++ return "reloaded", nil } -func (m *mockPluginMgr) ReloadOne(name string) error { return nil } +func (m *mockPluginMgr) ReloadOne(name string) error { return nil } func (m *mockPluginMgr) StopAndUnload(name string) error { return nil } func (m *mockPluginMgr) PluginMetas() map[string]sdk.PluginMeta { return nil diff --git a/internal/plugins/webui/handler_settings.go b/internal/plugins/webui/handler_settings.go new file mode 100644 index 0000000..861fcc5 --- /dev/null +++ b/internal/plugins/webui/handler_settings.go @@ -0,0 +1,365 @@ +package webui + +import ( + "io" + "log" + "math" + "sort" + "strings" + + "encoding/json" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" + "net/http" +) + +// 设置与插件面:配置读写、插件列表/详情(含 pluginmgr 反代)。 + +func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { + if h.config == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "config not available"}) + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, h.config.Get()) + case http.MethodPut: + var cfg types.Config + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid config"}) + return + } + h.config.Put(&cfg) + writeJSON(w, http.StatusOK, map[string]string{"status": "config_updated"}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// isInternalSetting 判断某个插件的配置键是不是**内部数据**(不是用户设置项)。 +// +// chathistory 是 webui 自己持久化的整段聊天记录(生产实例实测 5.2MB)。它躺在 +// 插件配置表里,于是会被设置接口当成普通配置项整块吐出:GET /api/v1/settings +// 因此返回 8MB+,并且前端把它渲染成一个巨大的文本框。它只应由 chat 相关 +// 接口的 handleChatHistory 读,不进设置面。 +func isInternalSetting(plugin, key string) bool { + return plugin == "webui" && key == "chathistory" +} + +func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) { + if h.settings == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "config registry not available"}) + return + } + switch r.Method { + case http.MethodGet: + prefix := r.URL.Query().Get("prefix") + values := make(map[string]interface{}) + meta := make(map[string]*sdk.ConfigDef) + + if strings.HasPrefix(prefix, "plugin.") { + // 插件配置:从插件自身 config_ 表读取 + pluginName := prefix[7:] + keys, _ := h.settings.ListPlugin(pluginName, "") + for _, k := range keys { + if isInternalSetting(pluginName, k) { + continue + } + v, _ := h.settings.GetPlugin(pluginName, k) + fullKey := prefix + "." + k + values[fullKey] = v + } + for _, def := range h.settings.DefsPlugin(pluginName, "") { + fullKey := prefix + "." + def.Key + meta[fullKey] = def + } + } else { + // 核心配置:从 core config 表读取(键可为任意前缀,如 core.llm.*、webui.*) + all := h.settings.Dump() + var keys []string + for k := range all { + if strings.HasPrefix(k, prefix) { + keys = append(keys, k) + } + } + sort.Strings(keys) + for _, k := range keys { + values[k] = all[k] + } + for _, d := range h.settings.DefsCore(prefix) { + meta[d.Key] = d + // 有 def 但 DB 中尚无值的 key,用 default 填充以便在 WebUI 中显示和编辑 + if _, exists := values[d.Key]; !exists { + values[d.Key] = d.Default + } + } + // 无前缀时同时加载所有插件配置 + if prefix == "" { + for _, p := range h.settings.Plugins() { + if p == "core" { + continue + } + pkeys, _ := h.settings.ListPlugin(p, "") + for _, k := range pkeys { + if isInternalSetting(p, k) { + continue + } + v, _ := h.settings.GetPlugin(p, k) + fullKey := "plugin." + p + "." + k + values[fullKey] = v + } + for _, def := range h.settings.DefsPlugin(p, "") { + fullKey := "plugin." + p + "." + def.Key + meta[fullKey] = def + } + } + } + } + + plugins := []string{"core"} + for _, p := range h.settings.Plugins() { + if p != "core" { + plugins = append(plugins, "plugin."+p) + } + } + var pm map[string]sdk.PluginMeta + if h.pluginMgr != nil { + pm = h.pluginMgr.PluginMetas() + } + var disabledPlugins []sdk.DisabledPluginInfo + if h.pluginMgr != nil { + disabledPlugins = h.pluginMgr.ListDisabledPlugins() + } + sort.Strings(plugins) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "settings": values, + "meta": meta, + "plugins": plugins, + "plugin_meta": pm, + "disabled_plugins": disabledPlugins, + }) + case http.MethodPut: + var body struct { + Key string `json:"key"` + Value interface{} `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + // 整数型 float64(如 QQ 号 2.198972886e+09)规范化为 int64, + // 避免被 fmt.Sprint 以科学计数法存库导致后续读取/解析失败。 + if f, ok := body.Value.(float64); ok && f == math.Trunc(f) && math.Abs(f) < 1e15 { + body.Value = int64(f) + } + deleting := body.Value == nil + if strings.HasPrefix(body.Key, "plugin.") { + parts := strings.SplitN(body.Key, ".", 3) + if len(parts) >= 3 { + if isInternalSetting(parts[1], parts[2]) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "该键属于插件内部数据,不经设置接口读写"}) + return + } + var err error + if deleting { + err = h.settings.RemovePlugin(parts[1], parts[2]) + } else { + err = h.settings.SetPlugin(parts[1], parts[2], body.Value) + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + } + } else { + var err error + if deleting { + err = h.settings.RemoveCore(body.Key) + } else { + err = h.settings.SetCore(body.Key, body.Value) + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + } + if strings.HasPrefix(body.Key, "core.llm.") && h.llm != nil { + if err := h.llm.ReloadFromConfig(); err != nil { + log.Printf("[webui] failed to reload LLM providers: %v", err) + } + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// ======== Plugin Management (proxied to pluginmgr HTTP API) ======== + +func (h *Handler) pluginmgrAddr() string { + addr := "127.0.0.1:9876" + if h.settings == nil { + return addr + } + if v, err := h.settings.GetPlugin("pluginmgr", "http_addr"); err == nil { + if s, ok := v.(string); ok && s != "" { + addr = s + } + } + return addr +} + +func (h *Handler) proxyToPluginmgr(w http.ResponseWriter, r *http.Request, path string) { + addr := h.pluginmgrAddr() + url := "http://" + addr + path + req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + req.Header = r.Header.Clone() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + defer resp.Body.Close() + + for k, v := range resp.Header { + w.Header()[k] = v + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +func (h *Handler) handlePlugins(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.proxyToPluginmgr(w, r, "/plugins") + case http.MethodPost: + h.proxyToPluginmgr(w, r, "/plugins") + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/") + path = strings.TrimSuffix(path, "/") + + // 插件名白名单:仅允许单段安全名称,阻断路径穿越/空名/嵌套路径 + validPluginName := func(s string) bool { + if s == "" || len(s) > 128 { + return false + } + // 禁止路径分隔符、连续点(父目录穿越)、冒号、空格等危险字符 + if strings.Contains(s, "..") || strings.ContainsAny(s, "/\\: \t\r\n\x00") { + return false + } + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') { + return false + } + } + return true + } + + if path == "disabled" && r.Method == http.MethodGet { + if h.pluginMgr == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"disabled": h.pluginMgr.ListDisabledPlugins()}) + return + } + + if path == "reload" { + if r.Method == http.MethodPost { + if h.pluginMgr == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin registry not available"}) + return + } + if _, err := h.pluginMgr.ReloadPlugins(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"}) + return + } + // reload/disabled 是保留字,不允许 DELETE/GET 等其它操作误把其当作插件名 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if idx := strings.LastIndex(path, "/"); idx > 0 { + name := path[:idx] + action := path[idx+1:] + if r.Method == http.MethodPost { + switch action { + case "disable": + if h.pluginMgr == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) + return + } + if !validPluginName(name) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) + return + } + if err := h.pluginMgr.DisablePlugin(name, "webui"); err != nil { + // 未安装 → 404;已禁用 → 409;其余为真实内部错误 + status := http.StatusInternalServerError + msg := err.Error() + switch { + case strings.HasSuffix(msg, "not installed"): + status = http.StatusNotFound + case strings.HasSuffix(msg, "already disabled"): + status = http.StatusConflict + } + writeJSON(w, status, map[string]string{"error": msg}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"}) + return + + case "enable": + if h.pluginMgr == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) + return + } + if !validPluginName(name) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) + return + } + if err := h.pluginMgr.EnablePlugin(name); err != nil { + // 启用失败多数是“插件不存在/加载失败” → 404 更贴切 + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "failed") { + status = http.StatusNotFound + } + writeJSON(w, status, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"}) + return + } + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // 单段插件名路径(GET 详情 / DELETE 卸载) + if !validPluginName(path) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) + return + } + + switch r.Method { + case http.MethodGet: + h.proxyToPluginmgr(w, r, "/plugins/"+path) + case http.MethodDelete: + h.proxyToPluginmgr(w, r, "/plugins/"+path) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/plugins/webui/handler_sse.go b/internal/plugins/webui/handler_sse.go new file mode 100644 index 0000000..7279894 --- /dev/null +++ b/internal/plugins/webui/handler_sse.go @@ -0,0 +1,76 @@ +package webui + +import ( + "fmt" + "log" + "sync" + "time" + + "encoding/json" +) + +// SSE 环形缓冲:/api/v1/chat/events 断线重连时按 Last-Event-ID 补发。 + +// sseEventRecord 保存一条 SSE 事件元数据,供断线重连时按 Last-Event-ID 重放遗漏事件。 +type sseEventRecord struct { + id string // SSE 事件 id 值(如 "1234567890-5") + eventType string // 事件类型(agent_output, reasoning 等) + data json.RawMessage // 序列化后的 payload JSON +} + +// sseEventRing 是一个固定大小的环状缓冲区,保持最近 cap 条 SSE 事件。 +type sseEventRing struct { + mu sync.Mutex + buf []sseEventRecord + cap int +} + +func newSSEEventRing(cap int) *sseEventRing { + return &sseEventRing{cap: cap} +} + +// Append 追加一条事件,超过容量时丢弃最旧条目。 +func (r *sseEventRing) Append(id, eventType string, data json.RawMessage) { + r.mu.Lock() + defer r.mu.Unlock() + r.buf = append(r.buf, sseEventRecord{id: id, eventType: eventType, data: data}) + if len(r.buf) > r.cap { + r.buf = r.buf[len(r.buf)-r.cap:] + } +} + +// After 返回所有在指定 id 之后的事件(按写入顺序),若 id 不在缓冲区中则返回全部。 +func (r *sseEventRing) After(id string) []sseEventRecord { + r.mu.Lock() + defer r.mu.Unlock() + for i := len(r.buf) - 1; i >= 0; i-- { + if r.buf[i].id == id { + result := make([]sseEventRecord, len(r.buf)-i-1) + copy(result, r.buf[i+1:]) + return result + } + } + // ID 不在缓冲区(可能是太旧或从未收到),返回全部 + result := make([]sseEventRecord, len(r.buf)) + copy(result, r.buf) + return result +} + +// sendSSE 向 writeCh 发送一条 SSE 事件;队列满时等 100ms 再试, +// 比立即 drop 更友好,避免密集 tool_call/delta 期间前端丢帧。 +func sendSSE(writeCh chan string, id, eventType, data string) { + line := fmt.Sprintf("id: %s\nevent: %s\ndata: %s\n", id, eventType, data) + select { + case writeCh <- line: + return + default: + } + // 队列满:等 100ms 让 writer flush,再试一次 + timer := time.NewTimer(100 * time.Millisecond) + defer timer.Stop() + select { + case writeCh <- line: + case <-timer.C: + log.Printf("[SSE] DROPPED %s id=%s (writeCh full 100ms, len=%d)", eventType, id, len(writeCh)) + } +} diff --git a/internal/plugins/webui/handler_terminal.go b/internal/plugins/webui/handler_terminal.go new file mode 100644 index 0000000..db82b0c --- /dev/null +++ b/internal/plugins/webui/handler_terminal.go @@ -0,0 +1,88 @@ +package webui + +import ( + "time" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "net/http" +) + +// 终端面:持久终端会话状态、终端接口、命令历史。 + +type CmdExec struct { + Command string `json:"command"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Status string `json:"status"` + Time string `json:"time"` +} + +type termState struct { + ID string `json:"id"` + Command string `json:"command"` + Running bool `json:"running"` + Output string `json:"output"` + CreatedAt string `json:"created_at"` + Uptime string `json:"uptime"` + created time.Time +} + +const maxCmdHistory = 100 + +const maxTerminals = 50 + +// subscribeTerminalStream 常驻订阅终端实时画面推流(terminal_output 事件), +// 维护 termStates 的 Running 状态与全量输出缓冲,供 /api/v1/terminals 与前端轮询使用。 +func (h *Handler) subscribeTerminalStream() { + if h.sdk == nil { + return + } + h.sdk.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) { + id, _ := ev.Payload["terminal_id"].(string) + if id == "" { + return + } + output, _ := ev.Payload["output"].(string) + running, _ := ev.Payload["running"].(bool) + h.termMu.Lock() + ts, ok := h.termStates[id] + if !ok { + ts = &termState{ID: id, created: time.Now()} + h.termStates[id] = ts + } + ts.Running = running + if output != "" { + const maxTermOutput = 64 * 1024 + if len(ts.Output)+len(output) > maxTermOutput { + excess := len(ts.Output) + len(output) - maxTermOutput + if len(ts.Output) > excess { + ts.Output = ts.Output[excess:] + } else { + ts.Output = "" + } + } + ts.Output += output + } + h.termMu.Unlock() + }) +} + +func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) { + h.termMu.Lock() + terms := make([]*termState, 0, len(h.termStates)) + for _, ts := range h.termStates { + ts.Uptime = time.Since(ts.created).Round(time.Second).String() + terms = append(terms, ts) + } + h.termMu.Unlock() + writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms}) +} + +func (h *Handler) handleCmdHistory(w http.ResponseWriter, r *http.Request) { + h.cmdMu.Lock() + result := make([]CmdExec, len(h.cmdHistory)) + copy(result, h.cmdHistory) + h.cmdMu.Unlock() + writeJSON(w, http.StatusOK, map[string]interface{}{"history": result}) +} diff --git a/internal/plugins/webui/handler_upload.go b/internal/plugins/webui/handler_upload.go new file mode 100644 index 0000000..d8a00e8 --- /dev/null +++ b/internal/plugins/webui/handler_upload.go @@ -0,0 +1,335 @@ +package webui + +import ( + "context" + "fmt" + "io" + "log" + "os" + "strconv" + "strings" + "time" + + "encoding/base64" + "encoding/json" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "net/http" + "path/filepath" +) + +// 上传面:用户上传文件(handleChatFile)与下载(handleUploads)、中断对话。 + +// parseIntDefault 解析十进制整数,失败/空串返回 def。 +func parseIntDefault(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +// maxInlineMediaBytes 是上传媒体内联进 LLM 请求的字节上限。 +// +// base64 会胀大 4/3,8MB 原图变成 ~11MB 文本;再加上网关的请求体上限与 +// 模型的图像 token 预算,超过这个量级多半会被上游 413 拒掉。 +// 超限时退回按路径处理(模型可用 describe_image 主动看)而不是报错。 +const maxInlineMediaBytes = 8 << 20 + +// handleChatFile 处理用户经 webui 上传文件并附带消息注入 agent。 +// 设计对齐 qq 插件收文件模式:文件落盘到固定目录(/uploads), +// 注入文本带「文件名 + 保存路径」,agent 用 files_read 等工具按路径消费。 +// 表单字段:file(必填,multipart 文件)、message(可选附言)、device_id/device_name。 +func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if uploadsDir == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "uploads dir not initialized"}) + return + } + if err := r.ParseMultipartForm(64 << 20); err != nil { // 单文件上限 64MB + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart: " + err.Error()}) + return + } + file, hdr, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"}) + return + } + defer file.Close() + message := r.FormValue("message") + deviceID := r.FormValue("device_id") + deviceName := r.FormValue("device_name") + clientMsgID := r.FormValue("client_msg_id") + + if h.sdk == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) + return + } + + // 落盘:保留原文件名;重名加毫秒后缀防覆盖。文件名消毒防路径穿越。 + base := filepath.Base(hdr.Filename) + if base == "" || base == "." || strings.Contains(base, "..") { + base = "upload.bin" + } + if err := os.MkdirAll(uploadsDir, 0755); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "create uploads dir"}) + return + } + savePath := filepath.Join(uploadsDir, base) + if _, err := os.Stat(savePath); err == nil { + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + savePath = filepath.Join(uploadsDir, fmt.Sprintf("%s_%d%s", stem, time.Now().UnixMilli(), ext)) + } + out, err := os.Create(savePath) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "save file"}) + return + } + sz, err := io.Copy(out, file) + out.Close() + if err != nil { + os.Remove(savePath) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write file"}) + return + } + + // 下载 URL(前端附件卡片用):/uploads/ 与 /files/ 同一鉴权模型,路由在 RegisterRoutes 挂载 + dlURL := "/uploads/" + filepath.Base(savePath) + attType := "file" + ct := hdr.Header.Get("Content-Type") + if ct == "" { + // 部分客户端(curl -F、某些移动端)不带 Content-Type,退回按扩展名判定。 + // 判错的后果不只是卡片样式:图片被当普通文件就走不进视觉链路,模型看不到图。 + ct = contentTypeByExt(strings.ToLower(filepath.Ext(savePath))) + } + switch { + case strings.HasPrefix(ct, "image/"): + attType = "image" + case strings.HasPrefix(ct, "audio/"): + attType = "audio" + } + + // 图片/音频直接进多模态链路:读回字节拼 data URL,随本轮 message 发给模型。 + // + // 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 files_read—— + // 但 files_read 返回的是文本,图片的字节对模型永远不可见,除非它想到再调 + // describe_image。走 InjectInputMedia 后与用户在 qq 发图走同一条统一输入主干: + // 自动落进 CAS、挂上媒体记忆引用,且模型「本轮」就看得到图。 + var mediaBlocks []sdk.ContentBlock + if attType == "image" || attType == "audio" { + if sz > maxInlineMediaBytes { + log.Printf("[webui] %s %s 有 %s,超过 %s 内联上限,退回按路径处理", + attType, base, formatBytesGo(sz), formatBytesGo(maxInlineMediaBytes)) + } else if raw, err := os.ReadFile(savePath); err != nil { + log.Printf("[webui] 读回上传的%s失败,退回按路径处理: %v", attType, err) + } else { + dataURL := "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(raw) + if attType == "image" { + mediaBlocks = []sdk.ContentBlock{{ + Type: "image_url", + ImageURL: &sdk.ImageURL{URL: dataURL, Detail: "auto"}, + }} + } else { + mediaBlocks = []sdk.ContentBlock{{ + Type: "audio_url", + AudioURL: &sdk.AudioURL{URL: dataURL}, + }} + } + } + } + + // 注入 agent:文件元信息走 interrupt 通道(内核以 system 角色注入 LLM, + // 不写入用户对话履历、不产生独立用户气泡——对齐 terminal_watch/timer 的 + // 工具提醒模式)。用户的附言若有则作为正常消息先行注入。 + // qq 插件同款文本格式:[xx发送了文件] + 路径,agent 用 files_read 消费。 + humanSize := formatBytesGo(sz) + source := "webui" + if deviceID != "" { + source = "webui/" + deviceID + } + typeLabel := map[string]string{"image": "图片", "audio": "音频", "file": "文件"}[attType] + if typeLabel == "" { + typeLabel = "文件" + } + fileNote := fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n文件已保存到: %s\n可用 files_read 等工具读取此路径处理。", + typeLabel, base, humanSize, savePath) + // 媒体已随本轮发给模型时不再叫它去读文件:那只会读到一堆二进制字节。 + if len(mediaBlocks) > 0 { + fileNote = fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n原文件保存在: %s", + typeLabel, base, humanSize, savePath) + } + if message != "" { + text := message + go func() { + // 附言作为用户消息(带附件卡片)注入;文件说明紧随其后以 interrupt 补充 + payload2 := map[string]interface{}{"content": text} + if deviceID != "" { + payload2["device_id"] = deviceID + payload2["device_name"] = deviceName + } + if clientMsgID != "" { + payload2["client_msg_id"] = clientMsgID + "-note" + } + payload2["upload_url"] = dlURL + payload2["upload_type"] = attType + payload2["upload_size"] = sz + payload2["upload_name"] = base + // 媒体跟附言同一条注入:拆开会让模型先看到「帮我看看这张图」而图在下一轮才到。 + if len(mediaBlocks) > 0 { + payload2["media_blocks"] = mediaBlocks + } + h.sdk.InjectInput(source, "webui", "text", payload2) + }() + time.Sleep(100 * time.Millisecond) // 保证附言先入队 + h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{"content": fileNote, "no_memory": true}) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "accepted", + "file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType}, + }) + return + } + // 无附言:仅文件说明,直接同步注入并等待回复(与普通聊天体验一致) + payload := map[string]interface{}{ + "content": fileNote, + "upload_url": dlURL, + "upload_type": attType, + "upload_size": sz, + "upload_name": base, + } + if len(mediaBlocks) > 0 { + payload["media_blocks"] = mediaBlocks + } + if deviceID != "" { + payload["device_id"] = deviceID + payload["device_name"] = deviceName + } + if clientMsgID != "" { + payload["client_msg_id"] = clientMsgID + } + + ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) + defer cancel() + respCh := make(chan *agentIO.OutputEvent, 1) + go func() { + respCh <- h.sdk.InjectInputSync(source, "webui", "text", payload) + }() + var resp *agentIO.OutputEvent + select { + case resp = <-respCh: + case <-ctx.Done(): + writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout"}) + return + } + if resp == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) + return + } + content, _ := resp.Payload["content"].(string) + reasoning, _ := resp.Payload["reasoning_content"].(string) + result := map[string]interface{}{ + "response": content, + "file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType}, + } + if reasoning != "" { + result["reasoning_content"] = reasoning + } + writeJSON(w, http.StatusOK, result) +} + +// formatBytesGo 服务端字节人性化显示。 +func formatBytesGo(n int64) string { + if n <= 0 { + return "0 B" + } + units := []string{"B", "KB", "MB", "GB"} + i := 0 + f := float64(n) + for f >= 1024 && i < len(units)-1 { + f /= 1024 + i++ + } + if i == 0 { + return fmt.Sprintf("%d %s", n, units[i]) + } + return fmt.Sprintf("%.1f %s", f, units[i]) +} + +// handleUploads 服务 /uploads/:用户上传文件的下载(与 /files/ 同一安全模型)。 +func (h *Handler) handleUploads(w http.ResponseWriter, r *http.Request) { + if uploadsDir == "" { + http.NotFound(w, r) + return + } + name := strings.TrimPrefix(r.URL.Path, "/uploads/") + if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") { + http.NotFound(w, r) + return + } + fp := filepath.Join(uploadsDir, name) + f, err := os.Open(fp) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + st, err := f.Stat() + if err != nil || st.IsDir() { + http.NotFound(w, r) + return + } + ct := contentTypeByExt(strings.ToLower(filepath.Ext(name))) + w.Header().Set("Content-Type", ct) + if strings.HasPrefix(ct, "image/") || strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") { + w.Header().Set("Content-Disposition", "inline; filename="+name) + } else { + w.Header().Set("Content-Disposition", "attachment; filename="+name) + } + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, max-age=3600") + http.ServeContent(w, r, name, st.ModTime(), f) +} + +// handleChatInterrupt 注入用户中断:取消正在进行的 LLM 生成并/或发送打断消息。 +// 核心拦截语义(interceptLoop): +// - 有 LLM 在跑:cancelLLM 取消当前请求 + 中断入队,process() 以 +// [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入; +// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。 +// +// message 可选:空则纯取消(仍会注入空内容中断触发取消)。 +func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.sdk == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) + return + } + var body struct { + Message string `json:"message"` + DeviceID string `json:"device_id"` + } + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) // body 可选 + } + + source := "webui" + if body.DeviceID != "" { + source = "webui/" + body.DeviceID + } + // PriorityL4:终止按钮必须能立即打断当前任务(内核级插件才有的能力)。 + // agent 正卡在工具执行里时按不下手——那是临界区,由内核在安全点生效; + // 但 LLM 流式段会被立刻取消。 + h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{ + "content": body.Message, + "priority": sdk.PriorityL4, + }) + writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"}) +}