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