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 70f9b32a11
commit cb8b8eba48
5 changed files with 394 additions and 225 deletions

View File

@ -396,9 +396,17 @@ func main() {
- transcribe_audio — 转写用户上传的音频 - transcribe_audio — 转写用户上传的音频
- ocr_image — 识别图片中的文字 - ocr_image — 识别图片中的文字
命令与文件操作策略:
- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、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) sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultPrompt)
if sysPrompt == "" { if sysPrompt == "" {
sysPrompt = defaultPrompt sysPrompt = defaultPrompt

View File

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

View File

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

View File

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

View File

@ -163,7 +163,7 @@ type ChatMsg struct {
type Attachment struct { type Attachment struct {
Type string `json:"type"` // "image" | "file" Type string `json:"type"` // "image" | "file"
URL string `json:"url"` // /files/<name> 或远程 http(s) URL 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"` // 展示用文件名 Name string `json:"name,omitempty"` // 展示用文件名
} }
@ -1347,20 +1347,49 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
attType = "image" attType = "image"
} }
// 注入 agent 的文本(qq 插件模式:[xx发送了文件] + 路径) // 注入 agent:文件元信息走 interrupt 通道(内核以 system 角色注入 LLM,
// 不写入用户对话履历、不产生独立用户气泡——对齐 terminal_watch/timer 的
// 工具提醒模式)。用户的附言若有则作为正常消息先行注入。
// qq 插件同款文本格式:[xx发送了文件] + 路径,agent 用 files_read 消费。
humanSize := formatBytesGo(sz) 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" source := "webui"
if deviceID != "" { if deviceID != "" {
source = "webui/" + 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{}{ payload := map[string]interface{}{
"content": text, "content": fileNote,
"upload_url": dlURL, "upload_url": dlURL,
"upload_type": attType, "upload_type": attType,
"upload_size": sz, "upload_size": sz,
@ -1395,7 +1424,7 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
reasoning, _ := resp.Payload["reasoning_content"].(string) reasoning, _ := resp.Payload["reasoning_content"].(string)
result := map[string]interface{}{ result := map[string]interface{}{
"response": content, "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 != "" { if reasoning != "" {
result["reasoning_content"] = reasoning result["reasoning_content"] = reasoning
@ -1461,6 +1490,7 @@ func (h *Handler) handleUploads(w http.ResponseWriter, r *http.Request) {
// - 有 LLM 在跑:cancelLLM 取消当前请求 + 中断入队,process() 以 // - 有 LLM 在跑:cancelLLM 取消当前请求 + 中断入队,process() 以
// [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入; // [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入;
// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。 // - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。
//
// message 可选:空则纯取消(仍会注入空内容中断触发取消)。 // message 可选:空则纯取消(仍会注入空内容中断触发取消)。
func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {