diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 33cc911..5ad8ed5 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -2459,6 +2459,10 @@ background: headers: { "Content-Type": "application/json", ...o?.headers }, ...o, }; + // FormData 时不能手动设 Content-Type(浏览器需自动生成 multipart boundary) + if (o?.body instanceof FormData) { + delete opts.headers["Content-Type"]; + } var r = await fetch("/api/v1" + p, opts); if (r.status === 401) { location.href = "/login"; @@ -2500,6 +2504,7 @@ background: if (msgsEl) { msgsEl.scrollTop = msgsEl.scrollHeight; } + initChatDragDrop(); } renderAll(); } @@ -2780,6 +2785,10 @@ background: html += "" + '
' + + '' + + '' + '' + @@ -3707,6 +3716,73 @@ background: }, 120000); } + // 上传文件并注入 agent:multipart POST /api/v1/chat/file。 + // 服务端落盘 uploads/ 后注入「[用户发送了文件: 名字 (大小)] 已保存到 <路径>」; + // 回复与普通消息一样走 SSE 流式渲染。可选附言从输入框读取。 + // 拖拽文件到聊天区即发送(可选:先在输入框写附言) + function initChatDragDrop() { + var panel = document.getElementById("chat-panel-chat"); + if (!panel || panel.__dnd) return; + panel.__dnd = true; + panel.addEventListener("dragover", function (e) { + e.preventDefault(); + panel.style.outline = "2px dashed var(--accent, #4a90d9)"; + }); + panel.addEventListener("dragleave", function () { + panel.style.outline = ""; + }); + panel.addEventListener("drop", function (e) { + e.preventDefault(); + panel.style.outline = ""; + if (e.dataTransfer.files && e.dataTransfer.files.length) { + sendChatFile(e.dataTransfer.files[0]); + } + }); + // 粘贴截图/复制的文件直接发送 + document.addEventListener("paste", function (e) { + var chatVisible = + document.getElementById("chat-panel-chat") && + document.getElementById("chat-input"); + if (!chatVisible) return; + var items = e.clipboardData && e.clipboardData.files; + if (items && items.length && document.activeElement !== null) { + sendChatFile(items[0]); + } + }); + } + + async function sendChatFile(fileObj, extraText) { + if (!fileObj || state.chatLoading) return; + var inp = document.getElementById("chat-input"); + var message = (extraText || inp.value || "").trim(); + if (inp) inp.value = ""; + var fd = new FormData(); + fd.append("file", fileObj); + if (message) fd.append("message", message); + state.messages.push({ + role: "user", + content: message, + attachment: { + type: /^image\//.test(fileObj.type) ? "image" : "file", + url: URL.createObjectURL(fileObj), + name: fileObj.name, + size: fileObj.size, + }, + }); + rerenderChat(true); + state.chatLoading = true; + state.chatStage = __("等待AI回复...", "Waiting for AI..."); + rerenderChat(true); + try { + await api("/chat/file", { method: "POST", body: fd, rawBody: true }); + // 回复经 SSE 流式到达,这里无需处理响应体 + } catch (e) { + toast(__("文件发送失败:" + e.message, "File send failed: " + e.message), true); + state.chatLoading = false; + endChatTurn(); + } + } + async function sendChat() { var inp = document.getElementById("chat-input"); var btn = document.getElementById("chat-send-btn"); diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 86d3051..0ffa7fa 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -154,6 +154,17 @@ type ChatMsg struct { 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 { @@ -349,6 +360,20 @@ func (h *Handler) subscribeChatEvents() { 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 } @@ -356,10 +381,11 @@ func (h *Handler) subscribeChatEvents() { h.pendingIdx = -1 h.chatMu.Unlock() h.addChatMsg(ChatMsg{ - Role: "user", - Content: content, - Source: source, - Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), + 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) { @@ -423,16 +449,38 @@ func (h *Handler) subscribeChatEvents() { // 输出通道主动输出(output_send__{通道})作为独立气泡,不并入最终回复 if kind == "channel_output" { h.pendingIdx = -1 - h.chatMu.Unlock() - if content == "" { + // 附件输出(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 } - h.addChatMsg(ChatMsg{ - Role: "assistant", - Content: content, - Source: channel, - Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), - }) + m := ChatMsg{ + Role: "assistant", + Content: content, + Source: channel, + Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), + Attachment: att, + } + // 附件消息不把本地路径当正文展示(如 "/tmp/homeagent.png"),置空 + if att != nil { + m.Content = "" + } + h.addChatMsg(m) + h.chatMu.Unlock() return } if msg := h.pendingAssistantLocked(); msg != nil && content != "" { @@ -695,6 +743,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/tracker", h.requireAPI(h.handleTracker)) mux.HandleFunc("/api/v1/tracker/", h.requireAPI(h.handleTracker)) mux.HandleFunc("/api/v1/chat", h.requireAPI(h.handleChat)) + // 用户上传文件并附带消息注入 agent(multipart:file + message) + mux.HandleFunc("/api/v1/chat/file", h.requireAPI(h.handleChatFile)) mux.HandleFunc("/api/v1/chat/history", h.requireAPI(h.handleChatHistory)) mux.HandleFunc("/api/v1/chat/interrupt", h.requireAPI(h.handleChatInterrupt)) mux.HandleFunc("/api/v1/chat/events", h.requireAPI(h.handleChatEvents)) @@ -707,6 +757,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/device/", h.requireAPI(h.handleDeviceGatewayProxy)) // agent 发送的文件下载(webui_files 中转目录;requireWeb 与 dashboard 同源同鉴权) mux.HandleFunc("/files/", h.requireWeb(h.handleFiles)) + // 用户上传文件的下载(uploads 目录,同一安全模型) + mux.HandleFunc("/uploads/", h.requireWeb(h.handleUploads)) mux.HandleFunc("/v1/chat/completions", h.requireAPI(h.handleOpenAICompletions)) mux.HandleFunc("/", h.requireWeb(h.handleStatic)) } @@ -1226,6 +1278,184 @@ func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"messages": result}) } +// 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 strings.HasPrefix(ct, "image/") { + attType = "image" + } + + // 注入 agent 的文本(qq 插件模式:[xx发送了文件] + 路径) + humanSize := formatBytesGo(sz) + text := fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n文件已保存到: %s\n可用 files_read 等工具读取此路径处理。", + map[string]string{"image": "图片", "file": "文件"}[attType], base, humanSize, savePath) + if message != "" { + text = message + "\n" + text + } + + source := "webui" + if deviceID != "" { + source = "webui/" + deviceID + } + payload := map[string]interface{}{ + "content": text, + "upload_url": dlURL, + "upload_type": attType, + "upload_size": sz, + "upload_name": base, + } + 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() 以 diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index fc49831..33ea14e 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -48,6 +48,10 @@ func randomSecret(n int) string { // 由插件 Start 时从 daemon.data_dir 推导注入。 var webFilesDir string +// uploadsDir 是用户经 webui 上传文件的存储目录(/uploads)。 +// handleChatFile 落盘、handleUploads 下载共用;参考 qq 插件 files_dir 收文件设计。 +var uploadsDir string + // stageWebFile 把 agent 要发送的本地文件拷贝到 webui_files 中转目录, // 返回可下载 URL 路径与字节数。image/file 的 payload 支持本地路径或 http(s) URL // (URL 直接透传给前端,不落盘)。文件名用随机 UUID 防路径猜测,扩展名保留自源文件。 @@ -132,6 +136,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil { if s2, ok := dd.(string); ok && s2 != "" { webFilesDir = filepath.Join(s2, "webui_files") + uploadsDir = filepath.Join(s2, "uploads") } }