fix(cmd/files/webui): shell 语义修复 + 根沙箱误判 + 上传注入走 interrupt + UI 区分附件来源

1. cmd_run 改经 /bin/bash -c 执行完整 shell 语法
   旧实现 shellUnquote 拆词后直接 exec:'pwd; ls /' 变成执行名为
   'pwd;' 的程序(exit -1)、heredoc 被截断、管道/命令替换全部失效——
   agent 多次反馈命令解析奇怪即此。危险命令拦截(kill homed 等)保留。

2. files 沙箱根目录判断修复
   pathWithinSandbox 在 base='/' 时 prefix 变 '//',所有绝对路径误判
   逃逸(生产实锤:files.dir=/ 下 files_read/write/ls 全部报 outside
   sandbox)。根沙箱直接放行。

3. webui 文件上传注入改走 interrupt(system 角色)
   文件元信息不再混入用户消息气泡;用户附言作为正常消息先行注入,
   文件说明紧随其后以 no_memory interrupt 补充——对齐 terminal_watch/
   timer 工具提醒模式,聊天流保持干净。

4. 前端附件卡片按 role 区分来源
   user=右侧+『你发送的』标签+accent 底色;assistant=左侧+『小宅发送的』。
   📌 emoji 按钮换为 SVG 图标,前端 emoji 清零。
This commit is contained in:
JianFeeeee
2026-08-26 10:24:47 +08:00
parent 534232b768
commit cb76828e43
5 changed files with 394 additions and 225 deletions

View File

@ -396,9 +396,17 @@ func main() {
- transcribe_audio — 转写用户上传的音频
- ocr_image — 识别图片中的文字
命令与文件操作策略:
- cmd_run 经完整 shellbash执行支持管道、分号、&&、命令替换、heredoc、重定向。
- 多步交互式程序vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
- 写文件优先 files_write原子+留档),生成多行内容时可用 heredoc 或 files_write
不要用 echo 拼接长文本。
- 读用户发来的文件用 files_read向 webui 回传图片/文件用 output_send__webui(type=image/file)。
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
回复你的真实想法,用自然语言与用户交流。`
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultPrompt)
if sysPrompt == "" {
sysPrompt = defaultPrompt

View File

@ -159,18 +159,20 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Windows 上必须经 cmd.exe /c 执行chcp 65001 预置为 UTF-8 输出),
// 直接 exec 会把整条命令当成一个程序路径导致所有命令失败。
// Linux/Unix 经 /bin/sh -c 执行完整 shell 语法:管道、分号、&&、
// 命令替换、heredoc、重定向全部可用。旧实现 shellUnquote 拆词后
// 直接 exec导致 pwd; ls / 变成执行名为 "pwd;" 的程序exit -1
// heredoc 被截断——agent 多次反馈"命令解析奇怪"即此。
var cmd *exec.Cmd
if isWindows {
execCmd := "chcp 65001>nul & " + command
cmd = exec.CommandContext(ctx, "cmd.exe", "/d", "/c", execCmd)
} else {
parts := shellUnquote(command)
if len(parts) == 0 {
return map[string]interface{}{"error": "command is required"}, nil
shell := "/bin/sh"
if _, err := os.Stat("/bin/bash"); err == nil {
shell = "/bin/bash" // bash 支持更完整的语法(数组、[[ ]] 等)
}
cmd = exec.CommandContext(ctx, parts[0], parts[1:]...)
cmd = exec.CommandContext(ctx, shell, "-c", command)
}
if workdir != "" {
cmd.Dir = workdir
@ -210,11 +212,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.recordCmd(rec)
return map[string]interface{}{
"status": "ok",
"stdout": rec.Stdout,
"stderr": rec.Stderr,
"exit_code": exitCode,
"command": command,
"status": "ok",
"stdout": rec.Stdout,
"stderr": rec.Stderr,
"exit_code": exitCode,
"command": command,
}, nil
})

View File

@ -201,6 +201,12 @@ func pathWithinSandbox(abs, base string) bool {
if equalFoldPath(abs, base) {
return true
}
// 根沙箱Linux "/"表示整机可访问Clean("") 会返回 "."
// 而 prefix 变成 "//" 导致所有绝对路径误判逃逸生产实锤files.dir=/ 时
// 全部 files_read/write 报 path outside sandbox。根目录直接放行。
if base == string(filepath.Separator) {
return true
}
// 卷根沙箱C:\、D:\ 等)表示整机可访问
if isWindowsBuild && len(base) == 3 && base[1] == ':' && base[2] == '\\' {
return true

View File

@ -27,7 +27,7 @@
}, 8000);
</script>
<style>
:root {
:root {
/* ===== NapCat Design DNA tokens ===== */
--sakura-100: #ffe4e9;
--sakura-200: #ffcdd9;
@ -53,17 +53,20 @@
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.28);
--shadow-lg: 0 12px 36px rgba(0, 0, 0, 0.38);
--shadow-glow: 0 0 0 1px rgba(255, 127, 172, 0.4),
--shadow-glow:
0 0 0 1px rgba(255, 127, 172, 0.4),
0 4px 20px rgba(255, 127, 172, 0.18);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-pill: 999px;
--font-sans: "Segoe UI", "Segoe UI Variable Text", -apple-system,
--font-sans:
"Segoe UI", "Segoe UI Variable Text", -apple-system,
BlinkMacSystemFont, Roboto, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular",
"SF Mono", "Menlo", "Consolas", monospace;
--font-mono:
"JetBrains Mono", ui-monospace, "SFMono-Regular", "SF Mono", "Menlo",
"Consolas", monospace;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--dur-micro: 150ms;
--dur-normal: 300ms;
@ -90,7 +93,7 @@
--msg-user-color: #ffb9d0;
--msg-assistant-bg: rgba(136, 192, 208, 0.14);
--msg-assistant-color: #a9d6e3;
--msg-system-bg: rgba(63, 110, 245, 0.16);
--msg-system-bg: rgba(63, 110, 245, 0.16);
--msg-system-color: #a3b8ff;
--msg-bubble-bg: #161b2e;
--msg-bubble-color: #e9edf6;
@ -99,7 +102,7 @@
--btn-ghost-border: rgba(255, 255, 255, 0.14);
--btn-ghost-hover-bg: rgba(255, 255, 255, 0.07);
--save-btn-border: #d99a2b;
--loading-border: rgba(255, 255, 255, 0.12);
--loading-border: rgba(255, 255, 255, 0.12);
--loading-top: #ff7fac;
--grad-a: rgba(255, 127, 172, 0.14);
--grad-b: rgba(136, 192, 208, 0.12);
@ -111,7 +114,7 @@
--c-amber: #fbbf24;
--c-blue: #60a5fa;
}
[data-theme="light"] {
[data-theme="light"] {
--glass-bg: rgba(255, 255, 255, 0.66);
--glass-bg-strong: rgba(255, 255, 255, 0.88);
--glass-border: rgba(255, 127, 172, 0.22);
@ -119,8 +122,8 @@
--shadow-sm: 0 1px 3px rgba(153, 27, 75, 0.08);
--shadow-md: 0 6px 20px rgba(153, 27, 75, 0.1);
--shadow-lg: 0 16px 40px rgba(153, 27, 75, 0.14);
--shadow-glow: 0 0 0 1px rgba(243, 59, 124, 0.3),
0 6px 22px rgba(243, 59, 124, 0.14);
--shadow-glow:
0 0 0 1px rgba(243, 59, 124, 0.3), 0 6px 22px rgba(243, 59, 124, 0.14);
--bg-primary: #ffffff;
--bg-secondary: rgba(255, 255, 255, 0.78);
--bg-card: rgba(255, 255, 255, 0.72);
@ -143,7 +146,7 @@
--msg-user-color: #c2185b;
--msg-assistant-bg: #e3f2f6;
--msg-assistant-color: #2f7188;
--msg-system-bg: #e6ecfe;
--msg-system-bg: #e6ecfe;
--msg-system-color: #3f6ef5;
--msg-bubble-bg: #ffffff;
--msg-bubble-color: #262a33;
@ -152,7 +155,7 @@
--btn-ghost-border: rgba(201, 36, 98, 0.22);
--btn-ghost-hover-bg: #ffe4e9;
--save-btn-border: #d99a2b;
--loading-border: rgba(201, 36, 98, 0.18);
--loading-border: rgba(201, 36, 98, 0.18);
--loading-top: #c92462;
--grad-a: rgba(255, 127, 172, 0.16);
--grad-b: rgba(136, 192, 208, 0.12);
@ -300,8 +303,8 @@
box-sizing: border-box;
font-family: var(--font-sans);
}
body {
background:
body {
background:
radial-gradient(
900px 700px at 85% -10%,
var(--grad-a),
@ -554,7 +557,9 @@ background:
margin: 3px;
padding: 0;
vertical-align: middle;
transition: transform 0.15s, box-shadow 0.15s;
transition:
transform 0.15s,
box-shadow 0.15s;
}
.palette-pop button.cdot:hover {
transform: scale(1.25);
@ -800,7 +805,11 @@ background:
transform: scale(0.96);
}
.btn-primary {
background: linear-gradient(120deg, var(--sakura-400), var(--sakura-500));
background: linear-gradient(
120deg,
var(--sakura-400),
var(--sakura-500)
);
color: #fff;
box-shadow: 0 2px 12px rgba(255, 127, 172, 0.35);
}
@ -1441,14 +1450,23 @@ background:
height: 2px;
border-radius: 2px;
margin-top: 6px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
background: linear-gradient(
90deg,
transparent,
var(--accent),
transparent
);
background-size: 200% 100%;
animation: reasoningSweep 1.2s var(--ease-out) infinite;
opacity: 0.6;
}
@keyframes reasoningSweep {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
/* ===== 工具卡片PiDeck 风格,扁平不套气泡)===== */
@ -1462,7 +1480,9 @@ background:
border: 1px solid var(--kv-border);
border-left: 3px solid var(--accent);
cursor: pointer;
transition: border-color 0.2s var(--ease-out), background 0.2s var(--ease-out);
transition:
border-color 0.2s var(--ease-out),
background 0.2s var(--ease-out);
}
.tool-card.tc-running {
border-left-color: var(--accent);
@ -2100,7 +2120,10 @@ background:
/>HomeAgent
</h1>
<div class="nav-links">
<a class="active" onclick="switchTab('overview')" data-i18n="navOverview"
<a
class="active"
onclick="switchTab('overview')"
data-i18n="navOverview"
>概览</a
>
<a onclick="switchTab('chat')" data-i18n="navChat">对话</a>
@ -2110,16 +2133,24 @@ background:
<a onclick="switchTab('kernel')" data-i18n="navKernel">内核</a>
</div>
<div class="nav-footer">
<div
style="display: flex; align-items: center; gap: 4px"
>
<div style="display: flex; align-items: center; gap: 4px">
<button
class="theme-btn"
onclick="toggleTheme()"
id="theme-btn"
title="切换亮色/暗色模式"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</button>
<button
class="theme-btn"
@ -2127,7 +2158,23 @@ background:
id="palette-btn"
title="外观:主题色 / 背景图"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 1 10-10c0 2.2-1.8 4-4 4h-2a3 3 0 0 0-3 3c0 1.1.9 2 2 2 1.4 0 2.5-1.1 3.6-2.1C19.3 18.5 20 20 21.5 20.5A1 1 0 0 0 22 19c0-4-3.6-7-10-7-5.5 0-9 3.8-9 9z"/><circle cx="7.5" cy="10.5" r="1"/><circle cx="12" cy="7.5" r="1"/><circle cx="16.5" cy="10.5" r="1"/></svg>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12 22a10 10 0 1 1 10-10c0 2.2-1.8 4-4 4h-2a3 3 0 0 0-3 3c0 1.1.9 2 2 2 1.4 0 2.5-1.1 3.6-2.1C19.3 18.5 20 20 21.5 20.5A1 1 0 0 0 22 19c0-4-3.6-7-10-7-5.5 0-9 3.8-9 9z"
/>
<circle cx="7.5" cy="10.5" r="1" />
<circle cx="12" cy="7.5" r="1" />
<circle cx="16.5" cy="10.5" r="1" />
</svg>
</button>
<button
class="btn btn-ghost btn-sm"
@ -2176,13 +2223,13 @@ background:
</div>
</header>
<div class="container" id="app">
<div id="tab-overview" class="tab-content active"></div>
<div id="tab-chat" class="tab-content"></div>
<div id="tab-plugins" class="tab-content"></div>
<div id="tab-settings" class="tab-content"></div>
<div id="tab-adapters" class="tab-content"></div>
<div id="tab-kernel" class="tab-content"></div>
</div>
<div id="tab-overview" class="tab-content active"></div>
<div id="tab-chat" class="tab-content"></div>
<div id="tab-plugins" class="tab-content"></div>
<div id="tab-settings" class="tab-content"></div>
<div id="tab-adapters" class="tab-content"></div>
<div id="tab-kernel" class="tab-content"></div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
@ -2261,7 +2308,6 @@ background:
var ICON_MOON =
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
function setTheme(name) {
document.documentElement.setAttribute("data-theme", name);
var btn = document.getElementById("theme-btn");
@ -2284,7 +2330,7 @@ background:
violet: "#a78bfa",
emerald: "#34d399",
amber: "#fbbf24",
blue: "#60a5fa"
blue: "#60a5fa",
};
function setColor(name) {
@ -2294,7 +2340,8 @@ background:
if (!pop) return;
var btns = pop.querySelectorAll("button.cdot");
for (var i = 0; i < btns.length; i++) {
btns[i].className = btns[i].getAttribute("data-c") === name ? "cdot on" : "cdot";
btns[i].className =
btns[i].getAttribute("data-c") === name ? "cdot on" : "cdot";
}
}
@ -2307,7 +2354,7 @@ background:
}
document.documentElement.style.setProperty(
"--bg-img",
'url("' + url.replace(/"/g, '\\"') + '")'
'url("' + url.replace(/"/g, '\\"') + '")',
);
localStorage.setItem("ha-bg-img", url);
}
@ -2333,20 +2380,35 @@ background:
var dots = "";
Object.keys(PALETTES).forEach(function (k) {
dots +=
'<button class="cdot" data-c="' + k + '" title="' + k + '" style="background:' +
PALETTES[k] + '"' +
(k === cur ? "" : "") + ' onclick="setColor(\'' + k + '\')"></button>';
'<button class="cdot" data-c="' +
k +
'" title="' +
k +
'" style="background:' +
PALETTES[k] +
'"' +
(k === cur ? "" : "") +
" onclick=\"setColor('" +
k +
"')\"></button>";
});
pop.innerHTML =
'<h4>主题色</h4><div>' + dots + '</div>' +
"<h4>主题色</h4><div>" +
dots +
"</div>" +
'<h4 style="margin-top:8px">背景图片 URL</h4>' +
'<input type="text" id="bg-img-input" placeholder="https://...jpg / png" value="' +
escHtml(img) + '">' +
escHtml(img) +
'">' +
'<div class="pp-row"><button class="btn btn-ghost btn-sm" onclick="applyBgImg(document.getElementById(\'bg-img-input\').value)">应用</button>' +
'<button class="btn btn-ghost btn-sm" onclick="applyBgImg(\'\')">清除</button>' +
'<span class="pp-val" style="margin-left:auto;min-width:90px;font-size:10px">模糊 ' +
'<input type="range" id="bg-blur-range" min="0" max="30" value="' + blur + '" oninput="applyBgBlur(this.value)" style="width:80px;display:inline-block;vertical-align:middle">' +
'<span id="bg-blur-val">' + blur + 'px</span></span></div>';
'<input type="range" id="bg-blur-range" min="0" max="30" value="' +
blur +
'" oninput="applyBgBlur(this.value)" style="width:80px;display:inline-block;vertical-align:middle">' +
'<span id="bg-blur-val">' +
blur +
"px</span></span></div>";
setColor(cur);
var rr = document.getElementById("bg-blur-range");
if (rr) rr.value = blur;
@ -2379,7 +2441,10 @@ background:
if (!n || n <= 0) return "";
var units = ["B", "KB", "MB", "GB"];
var i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return (i === 0 ? n : n.toFixed(1)) + " " + units[i];
}
@ -2388,14 +2453,21 @@ background:
if (typeof text !== "string") text = String(text || "");
var html;
if (typeof marked !== "undefined") {
try { html = marked.parse(text); }
catch (e) { html = escHtml(text); }
try {
html = marked.parse(text);
} catch (e) {
html = escHtml(text);
}
} else {
html = "<pre>" + escHtml(text) + "</pre>";
}
if (typeof DOMPurify !== "undefined" && typeof DOMPurify.sanitize === "function") {
try { return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); }
catch (e) {}
if (
typeof DOMPurify !== "undefined" &&
typeof DOMPurify.sanitize === "function"
) {
try {
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
} catch (e) {}
}
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
@ -2411,7 +2483,6 @@ background:
.replace(/"/g, "&quot;");
}
function toast(m, isError) {
var t = document.getElementById("toast");
t.textContent = m;
@ -2425,16 +2496,13 @@ background:
// ===== 8.6 Unified toast + confirm dialog =====
// ===== 8.4 Card 3D tilt + cursor glow =====
document.addEventListener("mousemove", function (e) {
var card = e.target.closest
? e.target.closest(".card.tilt")
: null;
var card = e.target.closest ? e.target.closest(".card.tilt") : null;
if (card) {
var r = card.getBoundingClientRect();
card.style.setProperty("--mx", (e.clientX - r.left) + "px");
card.style.setProperty("--my", (e.clientY - r.top) + "px");
card.style.setProperty("--mx", e.clientX - r.left + "px");
card.style.setProperty("--my", e.clientY - r.top + "px");
var rx = ((e.clientY - r.top) / r.height - 0.5) * -4;
var ry = ((e.clientX - r.left) / r.width - 0.5) * 4;
card.style.transform =
@ -2446,9 +2514,7 @@ background:
}
});
document.addEventListener("mouseleave", function (e) {
var card = e.target.closest
? e.target.closest(".card.tilt")
: null;
var card = e.target.closest ? e.target.closest(".card.tilt") : null;
if (card) card.style.transform = "";
});
@ -2488,8 +2554,7 @@ background:
if (match) match.classList.add("active");
var crumb = document.querySelector(".crumb-current");
if (crumb) {
var crumbKey =
"nav" + n.charAt(0).toUpperCase() + n.slice(1);
var crumbKey = "nav" + n.charAt(0).toUpperCase() + n.slice(1);
var m = window._i18n && window._i18n[crumbKey];
crumb.setAttribute("data-i18n", crumbKey);
if (m) crumb.textContent = __(m[0], m[1]);
@ -2585,7 +2650,9 @@ background:
try {
if (!window.matchMedia("(hover: none)").matches) {
document
.querySelectorAll("#tab-overview .card, #tab-plugins .card, #tab-kernel .card")
.querySelectorAll(
"#tab-overview .card, #tab-plugins .card, #tab-kernel .card",
)
.forEach(function (c) {
if (!c.querySelector(".tilt-glow")) {
var g = document.createElement("span");
@ -2748,25 +2815,26 @@ background:
'<span class="active" onclick="switchChatPanel(\'chat\',this)">' +
__("对话", "Chat") +
"</span>" +
'<span onclick="switchChatPanel(\'starmap\',this)">' +
"<span onclick=\"switchChatPanel('starmap',this)\">" +
__("星图", "Star Map") +
"</span>" +
'<span onclick="switchChatPanel(\'terminal\',this)">' +
"<span onclick=\"switchChatPanel('terminal',this)\">" +
__("终端", "Terminal") +
"</span>" +
'<span onclick="switchChatPanel(\'cmd\',this)">' +
"<span onclick=\"switchChatPanel('cmd',this)\">" +
__("运行中命令", "Running Commands") +
"</span>" +
'<span onclick="switchChatPanel(\'memory\',this)">' +
"<span onclick=\"switchChatPanel('memory',this)\">" +
__("记忆", "Memory") +
"</span>" +
'<span onclick="switchChatPanel(\'context\',this)">' +
"<span onclick=\"switchChatPanel('context',this)\">" +
__("上下文", "Context") +
"</span>" +
'<span onclick="switchChatPanel(\'knowledge\',this)">' +
"<span onclick=\"switchChatPanel('knowledge',this)\">" +
__("知识", "Knowledge") +
"</span></div>";
html += '<div class="chat-panel active" id="chat-panel-chat"><div class="chat-main">';
html +=
'<div class="chat-panel active" id="chat-panel-chat"><div class="chat-main">';
html +=
'<div class="card"><h2>' +
__("对话", "Chat") +
@ -2788,7 +2856,9 @@ background:
'<input type="file" id="chat-file" style="display:none" onchange="sendChatFile(this.files[0])">' +
'<button class="btn" onclick=\'document.getElementById("chat-file").click()\' id="chat-file-btn" title="' +
__("发送文件", "Send file") +
'" style="padding:0 12px">📎</button>' +
'" style="padding:0 12px;display:flex;align-items:center">' +
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>' +
"</button>" +
'<input id="chat-input" placeholder="' +
__("输入消息...", "Type a message...") +
'" onkeydown="if(event.key==\'Enter\')sendChat()">' +
@ -2924,9 +2994,7 @@ background:
"scroll",
function () {
state.chatStick =
msgsEl.scrollHeight -
msgsEl.scrollTop -
msgsEl.clientHeight <
msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight <
80;
},
{ passive: true },
@ -2942,24 +3010,25 @@ background:
);
}
var msgs = state.messages;
var sig = msgs
.map(function (m) {
var c = m.content || "";
return (
(m.role || "") +
":" +
c.length +
":" +
c.slice(-40) +
":" +
(m.tool_calls || [])
.map(function (t) {
return (t.tool || t.name || "") + "/" + (t.status || "");
})
.join(",")
);
})
.join("|") +
var sig =
msgs
.map(function (m) {
var c = m.content || "";
return (
(m.role || "") +
":" +
c.length +
":" +
c.slice(-40) +
":" +
(m.tool_calls || [])
.map(function (t) {
return (t.tool || t.name || "") + "/" + (t.status || "");
})
.join(",")
);
})
.join("|") +
"|L" +
(state.chatLoading ? "1" : "0") +
"|P" +
@ -3023,26 +3092,50 @@ background:
msgs.forEach(function (m, i) {
var role = m.role || "user";
var c = m.content || "";
// 附件消息agent 经 output_send__webui 发送的 image/file
// 附件消息agent 经 output_send__webui 发送,或用户上传。
// 用 role 区分方向user=右侧你发的 / assistant=左侧小宅发的),
// 卡片加来源标签避免混淆。
if (m.attachment) {
var att = m.attachment;
var isUserAtt = role === "user";
var srcLabel = isUserAtt
? __("你发送的", "You sent")
: __("小宅发送的", "Sent by agent");
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) + '" ' +
'<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">' +
'<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:' +
(isUserAtt
? "var(--accent-weak, rgba(74,144,217,0.12))"
: "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>';
"<span>" +
escHtml(att.name || "附件") +
(sizeStr ? " <small>(" + sizeStr + ")</small>" : "") +
"</span></a>";
}
html +=
'<div class="msg assistant"><div class="msg-bubble"><div class="att-wrap">' +
'<div class="msg ' +
(isUserAtt ? "user" : "assistant") +
'"><div class="msg-bubble"><div class="att-wrap">' +
'<div style="font-size:11px;opacity:0.65;margin-bottom:4px">' +
srcLabel +
"</div>" +
attHtml +
(c ? '<div class="text">' + renderMd(c) + "</div>" : "") +
"</div></div></div>";
@ -3056,65 +3149,82 @@ background:
c = escHtml(c);
}
var isChan = !!(m.source && m.source !== "webui");
var rc = "";
if (m.reasoning_content) {
rc = renderReasoningCard(m.reasoning_content, isStreamingLast);
}
var tcs = "";
if (m.tool_calls && m.tool_calls.length > 0) {
m.tool_calls.forEach(function (tc) {
var argsStr =
typeof tc.args === "object"
? JSON.stringify(tc.args, null, 1)
: tc.args || "";
var resultStr = tc.result
? typeof tc.result === "object"
? JSON.stringify(tc.result, null, 1)
: String(tc.result)
var rc = "";
if (m.reasoning_content) {
rc = renderReasoningCard(m.reasoning_content, isStreamingLast);
}
var tcs = "";
if (m.tool_calls && m.tool_calls.length > 0) {
m.tool_calls.forEach(function (tc) {
var argsStr =
typeof tc.args === "object"
? JSON.stringify(tc.args, null, 1)
: tc.args || "";
var resultStr = tc.result
? typeof tc.result === "object"
? JSON.stringify(tc.result, null, 1)
: String(tc.result)
: "";
var running = !resultStr && tc.status !== "denied";
var error =
tc.status === "error" || tc.status === "denied" || !!tc.error;
var drip =
newlyDone.indexOf(tc.tool || tc.name || "") !== -1
? " tool-drip-in"
: "";
var running = !resultStr && tc.status !== "denied";
var error = tc.status === "error" || tc.status === "denied" || !!tc.error;
var drip = newlyDone.indexOf(tc.tool || tc.name || "") !== -1 ? " tool-drip-in" : "";
var iconSvg =
error
? '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/></svg>'
: running
? '<span class="tc-spinner"></span>'
: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>';
var statusHtml =
tc.status === "denied"
? '<span class="tc-state tc-deny">' + __("已拒绝", "Denied") + "</span>"
: running
? '<span class="tc-state tc-run">' + __("调用中", "Running") + "</span>"
: '<span class="tc-state tc-done">' + __("完成", "Done") + "</span>";
var pluginHtml = tc.plugin
? '<span class="tc-plugin">' + escHtml(tc.plugin) + "</span>"
: "";
tcs +=
'<div class="tool-card' +
(error ? " tc-error" : running ? " tc-running" : " tc-done") +
drip +
'" data-tool="' +
escHtml(tc.tool || tc.name || "") +
'" onclick="toggleToolCall(this)">' +
'<div class="tc-line"><span class="tc-ico">' +
iconSvg +
'</span><span class="tc-name">' +
escHtml(tc.tool || tc.name || "") +
"</span>" +
pluginHtml +
statusHtml +
'<span class="tc-caret">▾</span></div>' +
'<div class="tc-detail" style="display:none">' +
(argsStr && argsStr !== "{}"
? '<div class="tc-args"><div class="tc-detail-label">' + __("参数", "Args") + "</div>" + escHtml(argsStr) + "</div>"
: "") +
(resultStr
? '<div class="tc-result"><div class="tc-detail-label">' + __("结果", "Result") + "</div>" + escHtml(resultStr) + "</div>"
: "") +
"</div></div>";
});
}
var iconSvg = error
? '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/></svg>'
: running
? '<span class="tc-spinner"></span>'
: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>';
var statusHtml =
tc.status === "denied"
? '<span class="tc-state tc-deny">' +
__("已拒绝", "Denied") +
"</span>"
: running
? '<span class="tc-state tc-run">' +
__("调用中", "Running") +
"</span>"
: '<span class="tc-state tc-done">' +
__("完成", "Done") +
"</span>";
var pluginHtml = tc.plugin
? '<span class="tc-plugin">' + escHtml(tc.plugin) + "</span>"
: "";
tcs +=
'<div class="tool-card' +
(error ? " tc-error" : running ? " tc-running" : " tc-done") +
drip +
'" data-tool="' +
escHtml(tc.tool || tc.name || "") +
'" onclick="toggleToolCall(this)">' +
'<div class="tc-line"><span class="tc-ico">' +
iconSvg +
'</span><span class="tc-name">' +
escHtml(tc.tool || tc.name || "") +
"</span>" +
pluginHtml +
statusHtml +
'<span class="tc-caret">▾</span></div>' +
'<div class="tc-detail" style="display:none">' +
(argsStr && argsStr !== "{}"
? '<div class="tc-args"><div class="tc-detail-label">' +
__("参数", "Args") +
"</div>" +
escHtml(argsStr) +
"</div>"
: "") +
(resultStr
? '<div class="tc-result"><div class="tc-detail-label">' +
__("结果", "Result") +
"</div>" +
escHtml(resultStr) +
"</div>"
: "") +
"</div></div>";
});
}
var body = rc + tcs;
var growCls = m._grow ? " grow-in" : "";
if (m._grow) m._grow = false;
@ -3167,8 +3277,7 @@ background:
} else {
var userAvatar =
'<svg viewBox="0 0 24 24" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-6 8-6s8 2 8 6"/></svg>';
var aiAvatar =
'<img src="/mascot.webp" alt="小宅">';
var aiAvatar = '<img src="/mascot.webp" alt="小宅">';
html +=
'<div class="msg msg-' +
role +
@ -3184,8 +3293,7 @@ background:
});
}
if (state.chatLoading && !streamingLast) {
var aiAvatarL =
'<img src="/mascot.webp" alt="小宅">';
var aiAvatarL = '<img src="/mascot.webp" alt="小宅">';
html +=
'<div class="msg msg-assistant"><div class="msg-avatar">' +
aiAvatarL +
@ -3243,10 +3351,15 @@ background:
// 不重建 DOM。正文节流 parse>200 字符或 >300ms 才 renderMd小增量纯文本追加。
function renderChatStreamChunk() {
var msgsEl = document.getElementById("chat-msgs");
var last = state.messages.length ? state.messages[state.messages.length - 1] : null;
var last = state.messages.length
? state.messages[state.messages.length - 1]
: null;
if (!msgsEl || !last) return;
var el = msgsEl.lastElementChild;
if (!el) { renderChat(); return; }
if (!el) {
renderChat();
return;
}
var textEl = el.querySelector(".msg-bubble .text");
var c = last.content || "";
if (textEl && c) {
@ -3263,16 +3376,22 @@ background:
el.__lastLen = c.length;
}
if (state.chatStick !== false) {
try { msgsEl.scrollTop = msgsEl.scrollHeight; } catch (e) {}
try {
msgsEl.scrollTop = msgsEl.scrollHeight;
} catch (e) {}
}
return;
}
// 思考预览更新(流式中折叠,只刷 preview 文本)
var rcPrev = el.querySelector(".reasoning-preview");
if (rcPrev && last.reasoning_content) {
rcPrev.textContent = last.reasoning_content.replace(/[\s\n]+/g, " ").slice(0, 60);
rcPrev.textContent = last.reasoning_content
.replace(/[\s\n]+/g, " ")
.slice(0, 60);
if (state.chatStick !== false) {
try { msgsEl.scrollTop = msgsEl.scrollHeight; } catch (e) {}
try {
msgsEl.scrollTop = msgsEl.scrollHeight;
} catch (e) {}
}
return;
}
@ -3295,18 +3414,30 @@ background:
var preview =
typeof marked !== "undefined"
? text.replace(/[\s\n]+/g, " ").slice(0, 60)
: escHtml(text).replace(/<[^>]+>/g, " ").slice(0, 60);
: escHtml(text)
.replace(/<[^>]+>/g, " ")
.slice(0, 60);
return (
'<div class="reasoning-card' + (isStreaming ? " rc-streaming" : "") + '">' +
'<div class="reasoning-card' +
(isStreaming ? " rc-streaming" : "") +
'">' +
'<div class="reasoning-head" onclick="toggleReasoning(this)">' +
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rc-ico"><path d="M9 3a2 2 0 0 0-2 2v2a2 2 0 0 1-2 2H3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a2 2 0 0 1-2-2V3a2 2 0 0 0-2-2H9zM12 8v4m0 4h.01"/></svg>' +
'<span class="rc-title">' + (isStreaming ? __("思考中...", "Thinking...") : __("思考", "Thinking")) + '</span>' +
'<span class="rc-chev">▾</span></div>' +
'<div class="reasoning-body" style="display:' + (isStreaming ? "block" : "none") + '">' +
'<span class="rc-title">' +
(isStreaming
? '<div class="reasoning-preview">' + escHtml(preview) + '</div><div class="reasoning-sweep"></div>'
: '<div class="reasoning-content">' + renderMd(text) + '</div>') +
'</div></div>'
? __("思考中...", "Thinking...")
: __("思考", "Thinking")) +
"</span>" +
'<span class="rc-chev">▾</span></div>' +
'<div class="reasoning-body" style="display:' +
(isStreaming ? "block" : "none") +
'">' +
(isStreaming
? '<div class="reasoning-preview">' +
escHtml(preview) +
'</div><div class="reasoning-sweep"></div>'
: '<div class="reasoning-content">' + renderMd(text) + "</div>") +
"</div></div>"
);
}
function toggleReasoning(el) {
@ -3407,7 +3538,6 @@ background:
}
}
function initChatStarmap() {
var cont = document.getElementById("sm-container-chat");
if (!cont) return;
@ -3777,7 +3907,10 @@ background:
await api("/chat/file", { method: "POST", body: fd, rawBody: true });
// 回复经 SSE 流式到达,这里无需处理响应体
} catch (e) {
toast(__("文件发送失败:" + e.message, "File send failed: " + e.message), true);
toast(
__("文件发送失败:" + e.message, "File send failed: " + e.message),
true,
);
state.chatLoading = false;
endChatTurn();
}
@ -4091,9 +4224,7 @@ background:
if (!text) return;
el.textContent += text;
if (el.textContent.length > 262144) {
el.textContent = el.textContent.slice(
el.textContent.length - 262144,
);
el.textContent = el.textContent.slice(el.textContent.length - 262144);
}
el.scrollTop = el.scrollHeight;
}
@ -4114,8 +4245,7 @@ background:
var html = "";
list.forEach(function (t, i) {
var detailId = "term-detail-" + i;
var scr =
(state.termScreens && state.termScreens[t.id]) || null;
var scr = (state.termScreens && state.termScreens[t.id]) || null;
var running = scr ? scr.running : !!t.running;
var fullOut = scr ? scr.output : t.output || "";
if (!fullOut) {
@ -4190,10 +4320,7 @@ background:
if (running.length === 0) {
r.innerHTML =
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' +
__(
"暂无运行中的命令",
"No running commands",
) +
__("暂无运行中的命令", "No running commands") +
"</p>";
return;
}
@ -4296,11 +4423,7 @@ background:
var lastM2 = state.messages.length
? state.messages[state.messages.length - 1]
: null;
if (
lastM2 &&
lastM2.role === "assistant" &&
!lastM2._final
) {
if (lastM2 && lastM2.role === "assistant" && !lastM2._final) {
// 聚合最终响应:覆盖 delta 累积的中间内容(以聚合为准,
// 含 stage 插件改写后的最终文本),并置 final 结束本轮流式。
lastM2._grow = true;
@ -4750,7 +4873,10 @@ background:
} else {
html +=
'<p style="color:var(--text-muted);font-size:13px">' +
__("尚未运行,点击右上角「运行」开始", "Not run yet, click Run to start") +
__(
"尚未运行,点击右上角「运行」开始",
"Not run yet, click Run to start",
) +
"</p>";
}
html += "</div></div>";
@ -4864,25 +4990,24 @@ background:
return;
removingPlugins[name] = true;
try {
var raw = await api(
"/plugins/" + encodeURIComponent(name),
{ method: "DELETE", raw: true },
);
var raw = await api("/plugins/" + encodeURIComponent(name), {
method: "DELETE",
raw: true,
});
var ct = raw.headers.get("content-type") || "";
var body = ct.includes("json")
? await raw.json()
: await raw.text();
var body = ct.includes("json") ? await raw.json() : await raw.text();
if (!raw.ok) {
var em =
(body && (body.error || body.details)) ||
("HTTP " + raw.status);
(body && (body.error || body.details)) || "HTTP " + raw.status;
toast(__("卸载失败: ", "Unload failed: ") + em, true);
// 内置插件或路径错误时刷新一次列表保持状态一致
loadInstalledPlugins();
renderPlugins();
return;
}
toast(__("已卸载: ", "Unloaded: ") + (body.name || body.status || name));
toast(
__("已卸载: ", "Unloaded: ") + (body.name || body.status || name),
);
await loadInstalledPlugins();
// 同步内核插件/禁用列表,确保列表与工具立即消失
try {
@ -5303,8 +5428,6 @@ background:
}
}
function starmapAnimate() {
starmapRaf = requestAnimationFrame(starmapAnimate);
if (starmapCtrl) starmapCtrl.update();

View File

@ -163,7 +163,7 @@ type ChatMsg struct {
type Attachment struct {
Type string `json:"type"` // "image" | "file"
URL string `json:"url"` // /files/<name> 或远程 http(s) URL
Size int64 `json:"size,omitempty"` // 字节数(远程 URL 为 0
Size int64 `json:"size,omitempty"` // 字节数(远程 URL 为 0
Name string `json:"name,omitempty"` // 展示用文件名
}
@ -1347,20 +1347,49 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
attType = "image"
}
// 注入 agent 的文本qq 插件模式:[xx发送了文件] + 路径)
// 注入 agent:文件元信息走 interrupt 通道(内核以 system 角色注入 LLM
// 不写入用户对话履历、不产生独立用户气泡——对齐 terminal_watch/timer 的
// 工具提醒模式)。用户的附言若有则作为正常消息先行注入。
// qq 插件同款文本格式:[xx发送了文件] + 路径agent 用 files_read 消费。
humanSize := formatBytesGo(sz)
text := fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n文件已保存到: %s\n可用 files_read 等工具读取此路径处理。",
map[string]string{"image": "图片", "file": "文件"}[attType], base, humanSize, savePath)
if message != "" {
text = message + "\n" + text
}
source := "webui"
if deviceID != "" {
source = "webui/" + deviceID
}
fileNote := fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n文件已保存到: %s\n可用 files_read 等工具读取此路径处理。",
map[string]string{"image": "图片", "file": "文件"}[attType], base, humanSize, savePath)
if message != "" {
text := message
go func() {
// 附言作为用户消息(带附件卡片)注入;文件说明紧随其后以 interrupt 补充
payload2 := map[string]interface{}{"content": text}
if deviceID != "" {
payload2["device_id"] = deviceID
payload2["device_name"] = deviceName
}
if clientMsgID != "" {
payload2["client_msg_id"] = clientMsgID + "-note"
}
h.sdk.InjectInput(source, "webui", "text", func() map[string]interface{} {
p := payload2
p["upload_url"] = dlURL
p["upload_type"] = attType
p["upload_size"] = sz
p["upload_name"] = base
return p
}())
}()
time.Sleep(100 * time.Millisecond) // 保证附言先入队
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{"content": fileNote, "no_memory": true})
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "accepted",
"file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType},
})
return
}
// 无附言:仅文件说明,直接同步注入并等待回复(与普通聊天体验一致)
payload := map[string]interface{}{
"content": text,
"content": fileNote,
"upload_url": dlURL,
"upload_type": attType,
"upload_size": sz,
@ -1395,7 +1424,7 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
reasoning, _ := resp.Payload["reasoning_content"].(string)
result := map[string]interface{}{
"response": content,
"file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType},
"file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType},
}
if reasoning != "" {
result["reasoning_content"] = reasoning
@ -1461,6 +1490,7 @@ func (h *Handler) handleUploads(w http.ResponseWriter, r *http.Request) {
// - 有 LLM 在跑cancelLLM 取消当前请求 + 中断入队process() 以
// [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入;
// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。
//
// message 可选:空则纯取消(仍会注入空内容中断触发取消)。
func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {