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")
}
}