mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +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:
@ -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