mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
fix(webui): 附件消息历史持久化 + 用户文件上传(对齐 qq 插件收文件设计)
1. 附件展示链路补全
- ChatMsg 新增 Attachment 字段(type/url/size/name),channel_output
订阅提取 output_type/url/size 存档——修复刷新后附件变纯文本路径
(如 '/tmp/homeagent.png')的问题
- EventRawInput 订阅支持 upload_* 字段:用户上传的消息也带附件卡片
2. 用户→agent 文件上传(POST /api/v1/chat/file)
设计对齐 qq 插件收文件模式:
- multipart(file + message) 落盘 <data>/uploads/<原文件名>(重名加
毫秒后缀,路径穿越消毒),单文件上限 64MB
- 注入文本「[用户通过 webui 发送了图片: 名字 (大小)] + 保存路径」,
agent 用 files_read 等工具按路径消费
- 回复走与普通消息相同的 SSE 流式管道
- GET /uploads/<name> 下载,与 /files/ 同一鉴权与安全模型
3. 前端
- 输入区 📎 按钮;拖拽到聊天区直接发送;粘贴截图即发送
- 本地预览用 URL.createObjectURL 即时显示
This commit is contained in:
@ -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 +=
|
||||
"</div>" +
|
||||
'<div class="chat-input-row">' +
|
||||
'<input type="file" id="chat-file" style="display:none" onchange="sendChatFile(this.files[0])">' +
|
||||
'<button class="btn" onclick=\'document.getElementById("chat-file").click()\' id="chat-file-btn" title="' +
|
||||
__("发送文件", "Send file") +
|
||||
'" style="padding:0 12px">📎</button>' +
|
||||
'<input id="chat-input" placeholder="' +
|
||||
__("输入消息...", "Type a message...") +
|
||||
'" onkeydown="if(event.key==\'Enter\')sendChat()">' +
|
||||
@ -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");
|
||||
|
||||
@ -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/<name> 或远程 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 插件收文件模式:文件落盘到固定目录(<data>/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/<name>:用户上传文件的下载(与 /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() 以
|
||||
|
||||
@ -48,6 +48,10 @@ func randomSecret(n int) string {
|
||||
// 由插件 Start 时从 daemon.data_dir 推导注入。
|
||||
var webFilesDir string
|
||||
|
||||
// uploadsDir 是用户经 webui 上传文件的存储目录(<data>/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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user