feat(webui): agent 可向 webui 发送图片/文件,前端内联展示与下载

输出通道能力升级:webui 通道从 CapText(1) 扩展为
CapText|CapFile|CapImage(7),agent 经 output_send__webui 即可发送
image/file(此前仅文本)。

服务端:
- stageWebFile 把本地路径文件拷贝到 <data>/webui_files/<hex>.<ext>
  (随机名防猜测、危险扩展名强制 .bin),http(s) URL 直接透传不落盘
- 新增 GET /files/<name>(requireWeb 与 dashboard 同鉴权):扩展名
  白名单映射 Content-Type,图片/音视频 inline、其余 attachment 下载,
  nosniff + 路径穿越拒绝
- SSE agent_output 事件携带 output_type/url/size 字段

前端(dashboard.html):
- channel_output 识别附件消息:image 渲染内联预览(点击原图)、
  file 渲染下载卡片(含大小);formatBytes 人性化显示

典型场景:agent 把 remotedevice 回传的录像/截图(device_media/*.mp4)
直接发给 webui,用户在聊天里看到视频预览或一键下载。

新增 TestStageWebFileAndDownload / TestHandleFilesAuth 覆盖。
This commit is contained in:
JianFeeeee
2026-08-26 09:17:07 +08:00
parent fad490dca0
commit 0afa84a13f
4 changed files with 302 additions and 6 deletions

View File

@ -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 =
'<a href="' + escHtml(att.url) + '" target="_blank" rel="noopener">' +
'<img class="chat-attachment-img" src="' + escHtml(att.url) + '" ' +
'alt="image" loading="lazy" style="max-width:320px;max-height:240px;border-radius:10px;display:block;cursor:zoom-in" ' +
'onerror="this.parentElement.innerHTML=\'<span class=\\"att-err\\">图片加载失败</span>\'"/></a>';
} else {
var sizeStr = att.size ? formatBytes(att.size) : "";
attHtml =
'<a class="chat-attachment-file" href="' + escHtml(att.url) + '" download ' +
'style="display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:10px;background:var(--bg-sec,#f0f2f5);text-decoration:none;color:inherit">' +
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>' +
'<span>' + escHtml(att.name || "附件") + (sizeStr ? ' <small>(' + sizeStr + ')</small>' : '') + '</span></a>';
}
html +=
'<div class="msg assistant"><div class="msg-bubble"><div class="att-wrap">' +
attHtml +
(c ? '<div class="text">' + renderMd(c) + "</div>" : "") +
"</div></div></div>";
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 &&

View File

@ -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/<name>:仅限 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")

View File

@ -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("<b>x</b>"), 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)
}
}
}

View File

@ -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 发送文件时的中转目录(<data>/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)
// 中转目录:<data>/webui_filesagent 发送 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
})