diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 17208fd..33cc911 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -2374,6 +2374,15 @@ background: } })(); + // 字节数人性化显示(附件卡片用) + function formatBytes(n) { + if (!n || n <= 0) return ""; + var units = ["B", "KB", "MB", "GB"]; + var i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return (i === 0 ? n : n.toFixed(1)) + " " + units[i]; + } + // ===== Utility ===== function renderMd(text) { if (typeof text !== "string") text = String(text || ""); @@ -3005,6 +3014,31 @@ background: msgs.forEach(function (m, i) { var role = m.role || "user"; var c = m.content || ""; + // 附件消息(agent 经 output_send__webui 发送的 image/file) + if (m.attachment) { + var att = m.attachment; + var attHtml = ""; + if (att.type === "image") { + attHtml = + '' + + '图片加载失败\'"/>'; + } else { + var sizeStr = att.size ? formatBytes(att.size) : ""; + attHtml = + '' + + '' + + '' + escHtml(att.name || "附件") + (sizeStr ? ' (' + sizeStr + ')' : '') + ''; + } + html += + '
' + + attHtml + + (c ? '
' + renderMd(c) + "
" : "") + + "
"; + return; + } if (role === "assistant") { c = renderMd(c); } else if (role === "system") { @@ -4153,12 +4187,23 @@ background: if (!p.content) return; state.chatStage = __("AI 回复中...", "AI replying..."); if (p.kind === "channel_output") { + // 附件输出(output_type=image/file):渲染为图片预览/下载卡片 + var att = null; + if (p.output_type === "image" || p.output_type === "file") { + att = { + type: p.output_type, + url: p.url || p.content, + size: p.size || 0, + name: (p.url || "").split("/").pop() || "附件", + }; + } var cm = { role: "assistant", - content: p.content, + content: att ? "" : p.content, source: p.channel || "", _final: true, _grow: true, + attachment: att, }; if ( state.chatFinalIdx >= 0 && diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index f2bbcb3..86d3051 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -13,6 +13,8 @@ import ( "math" "net" "net/http" + "os" + "path/filepath" "sort" "strconv" "strings" @@ -703,6 +705,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID)) // 设备网关(可配置反代到 remotedevice;默认禁用,未启用时返回 404) mux.HandleFunc("/api/v1/device/", h.requireAPI(h.handleDeviceGatewayProxy)) + // agent 发送的文件下载(webui_files 中转目录;requireWeb 与 dashboard 同源同鉴权) + mux.HandleFunc("/files/", h.requireWeb(h.handleFiles)) mux.HandleFunc("/v1/chat/completions", h.requireAPI(h.handleOpenAICompletions)) mux.HandleFunc("/", h.requireWeb(h.handleStatic)) } @@ -2228,6 +2232,79 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { } } +// 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") diff --git a/internal/plugins/webui/handler_files_test.go b/internal/plugins/webui/handler_files_test.go new file mode 100644 index 0000000..299036c --- /dev/null +++ b/internal/plugins/webui/handler_files_test.go @@ -0,0 +1,83 @@ +package webui + +// webui 文件发送能力测试:stageWebFile 中转 + /files/ 带鉴权下载。 +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStageWebFileAndDownload(t *testing.T) { + dir := t.TempDir() + webFilesDir = dir + defer func() { webFilesDir = "" }() + + // 源文件(模拟 agent 要发的图片) + src := filepath.Join(dir, "src.jpg") + if err := os.WriteFile(src, []byte("fake-jpeg-bytes"), 0644); err != nil { + t.Fatal(err) + } + + url, size, err := stageWebFile(src, true) + if err != nil { + t.Fatalf("stageWebFile: %v", err) + } + if !strings.HasPrefix(url, "/files/") || !strings.HasSuffix(url, ".jpg") { + t.Fatalf("unexpected url: %s", url) + } + if size != int64(len("fake-jpeg-bytes")) { + t.Fatalf("size = %d", size) + } + // 中转文件存在且内容一致 + data, err := os.ReadFile(filepath.Join(dir, strings.TrimPrefix(url, "/files/"))) + if err != nil || string(data) != "fake-jpeg-bytes" { + t.Fatalf("staged file mismatch: %v", err) + } + + // 远程 URL 透传不落盘 + u2, _, err := stageWebFile("https://example.com/a.png", true) + if err != nil || u2 != "https://example.com/a.png" { + t.Fatalf("remote url passthrough failed: %v %s", err, u2) + } + + // 危险扩展名被替换为 .bin + srcBad := filepath.Join(dir, "evil.html") + os.WriteFile(srcBad, []byte("x"), 0644) + u3, _, err := stageWebFile(srcBad, false) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(u3, ".bin") { + t.Fatalf("html ext should be forced to .bin, got %s", u3) + } +} + +func TestHandleFilesAuth(t *testing.T) { + h, _ := newTestHandler(t) + // 未登录访问 → 重定向登录页(requireWeb) + req := httptest.NewRequest(http.MethodGet, "/files/whatever.jpg", nil) + w := httptest.NewRecorder() + h.handleFiles(w, req) // 直接调 handler 本体验证文件逻辑;鉴权由 mux 层 requireWeb 覆盖 + + // 不存在的文件 → 404 + req2 := httptest.NewRequest(http.MethodGet, "/files/nonexistent.jpg", nil) + w2 := httptest.NewRecorder() + h.handleFiles(w2, req2) + if w2.Code != http.StatusNotFound { + t.Fatalf("want 404 for missing file, got %d", w2.Code) + } + + // 路径穿越拒绝 + for _, bad := range []string{"/files/../etc/passwd", "/files/a/b.jpg", `/files\a.jpg`} { + req3 := httptest.NewRequest(http.MethodGet, "/files/x", nil) + req3.URL.Path = bad + w3 := httptest.NewRecorder() + h.handleFiles(w3, req3) + if w3.Code != http.StatusNotFound { + t.Errorf("path traversal %q: want 404, got %d", bad, w3.Code) + } + } +} diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 3955fd5..fc49831 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -4,8 +4,12 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "io" "log" "net/http" + "os" + "path/filepath" + "strings" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" @@ -40,6 +44,62 @@ func randomSecret(n int) string { return hex.EncodeToString(buf) } +// webFilesDir 是 agent 向 webui 发送文件时的中转目录(/webui_files)。 +// 由插件 Start 时从 daemon.data_dir 推导注入。 +var webFilesDir string + +// stageWebFile 把 agent 要发送的本地文件拷贝到 webui_files 中转目录, +// 返回可下载 URL 路径与字节数。image/file 的 payload 支持本地路径或 http(s) URL +// (URL 直接透传给前端,不落盘)。文件名用随机 UUID 防路径猜测,扩展名保留自源文件。 +func stageWebFile(payload string, isImage bool) (url string, size int64, err error) { + if strings.HasPrefix(payload, "http://") || strings.HasPrefix(payload, "https://") { + return payload, 0, nil // 远程 URL 直接透传 + } + if webFilesDir == "" { + return "", 0, fmt.Errorf("webui files dir not initialized") + } + src := payload + if _, err := os.Stat(src); err != nil { + return "", 0, fmt.Errorf("文件不存在: %s", src) + } + if err := os.MkdirAll(webFilesDir, 0755); err != nil { + return "", 0, fmt.Errorf("create webui_files: %w", err) + } + buf := make([]byte, 8) + rand.Read(buf) + ext := strings.ToLower(filepath.Ext(src)) + if extBad(ext) { + ext = ".bin" + } + name := hex.EncodeToString(buf) + ext + dst := filepath.Join(webFilesDir, name) + in, err := os.Open(src) + if err != nil { + return "", 0, fmt.Errorf("open source: %w", err) + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return "", 0, fmt.Errorf("create dest: %w", err) + } + defer out.Close() + n, err := io.Copy(out, in) + if err != nil { + os.Remove(dst) + return "", 0, fmt.Errorf("copy: %w", err) + } + return "/files/" + name, n, nil +} + +// extBad 过滤危险/无意义扩展名(防双扩展名绕过 Content-Type)。 +func extBad(ext string) bool { + switch ext { + case "", ".html", ".htm", ".svg", ".js", ".exe", ".sh", ".bat", ".cmd", ".ps1": + return true + } + return false +} + func (p *Plugin) ensureAuthBootstrap(s *sdk.PluginSDK) { sett := s.Settings() if sett == nil { @@ -68,6 +128,13 @@ func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) + // 中转目录:/webui_files,agent 发送 image/file 时拷贝至此 + if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil { + if s2, ok := dd.(string); ok && s2 != "" { + webFilesDir = filepath.Join(s2, "webui_files") + } + } + addr := ":8080" if v, _ := s.Settings().Get("addr"); v != nil { if s2, ok := v.(string); ok && s2 != "" { @@ -75,18 +142,42 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } } - s.RegisterOutputChannel("webui", 1, "Web 控制台", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) { + // 能力位 7 = CapText|CapFile|CapImage;旧值 1 仅文本,agent 无法向 webui 发文件/图片 + s.RegisterOutputChannel("webui", 7, "Web 控制台(支持文字/图片/文件,图片内联展示、文件可下载)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) { payload, _ := args["payload"].(string) - if payload != "" { + rawType, _ := args["type"].(string) + if payload == "" { + return nil, fmt.Errorf("payload 不能为空") + } + // 能力位:CapText|CapFile|CapImage = 1|2|4 = 7(旧值 1 仅文本)。 + // image/file 时 payload 为本地路径(或 http URL),拷贝到 webui_files + // 并经 /files/ 带鉴权下发;前端按 kind 渲染图片预览/文件下载卡片。 + if rawType == "image" || rawType == "file" { + url, size, err := stageWebFile(payload, rawType == "image") + if err != nil { + return nil, err + } s.Publish(&sdk.Event{ Type: sdk.EventAgentOutput, Payload: map[string]interface{}{ - "content": payload, - "channel": "webui", - "kind": "channel_output", + "content": payload, + "channel": "webui", + "kind": "channel_output", + "output_type": rawType, + "url": url, + "size": size, }, }) + return map[string]interface{}{"status": "ok", "url": url, "size": size}, nil } + s.Publish(&sdk.Event{ + Type: sdk.EventAgentOutput, + Payload: map[string]interface{}{ + "content": payload, + "channel": "webui", + "kind": "channel_output", + }, + }) return map[string]interface{}{"status": "ok"}, nil })