mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
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:
@ -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 &&
|
||||
|
||||
@ -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")
|
||||
|
||||
83
internal/plugins/webui/handler_files_test.go
Normal file
83
internal/plugins/webui/handler_files_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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_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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user