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 b78fd4f342
commit 9716e95ecd
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 &&