Files
HomeAgent/internal/plugins/webui/dashboard.html
JianFeeeee 0afa84a13f 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 覆盖。
2026-08-26 09:17:07 +08:00

6092 lines
202 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HomeAgent Dashboard</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<script
src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"
onerror="console.warn('marked CDN failed')"
></script>
<script
src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"
onerror="console.warn('DOMPurify CDN failed')"
></script>
<script>
setTimeout(function () {
if (!window.THREE) window._THREE_FAILED = true;
}, 8000);
</script>
<style>
:root {
/* ===== NapCat Design DNA tokens ===== */
--sakura-100: #ffe4e9;
--sakura-200: #ffcdd9;
--sakura-300: #ff9eb5;
--sakura-400: #ff7fac;
--sakura-500: #f33b7c;
--sakura-600: #c92462;
--sakura-700: #991b4b;
--frost-100: #d7e8ee;
--frost-200: #a9d6e3;
--frost-300: #88c0d0;
--frost-400: #5ea8bf;
--frost-500: #3f88a3;
--success: #17a964;
--warning: #d99a2b;
--error: #db3694;
--info: #3f6ef5;
--glass-bg: rgba(13, 18, 34, 0.6);
--glass-bg-strong: rgba(13, 18, 34, 0.82);
--glass-blur: 14px;
--glass-border: rgba(255, 255, 255, 0.08);
--glass-hover: rgba(255, 255, 255, 0.05);
--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),
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,
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;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--dur-micro: 150ms;
--dur-normal: 300ms;
/* ===== Compat tokens (rethemed) ===== */
--bg-primary: #0b1020;
--bg-secondary: rgba(17, 24, 44, 0.72);
--bg-card: rgba(17, 24, 44, 0.6);
--bg-input: rgba(13, 18, 34, 0.75);
--bg-hover: rgba(255, 255, 255, 0.06);
--text-primary: #eef1f8;
--text-secondary: #a7b0c4;
--text-muted: #77809a;
--border-color: rgba(255, 255, 255, 0.09);
--accent: #ff7fac;
--accent-bg: rgba(255, 127, 172, 0.14);
--toast-bg: rgba(23, 169, 100, 0.16);
--toast-color: #6ee7a8;
--toast-error-bg: rgba(219, 54, 148, 0.18);
--toast-error-color: #ff9ec6;
--pre-color: #cdd3f5;
--pre-bg: rgba(10, 14, 28, 0.85);
--chat-bg: rgba(10, 14, 28, 0.7);
--msg-user-bg: rgba(255, 127, 172, 0.16);
--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-color: #a3b8ff;
--msg-bubble-bg: #161b2e;
--msg-bubble-color: #e9edf6;
--msg-bubble-border: rgba(255, 255, 255, 0.1);
--kv-border: rgba(255, 255, 255, 0.07);
--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-top: #ff7fac;
--grad-a: rgba(255, 127, 172, 0.14);
--grad-b: rgba(136, 192, 208, 0.12);
--grad-c: rgba(243, 59, 124, 0.1);
--c-sakura: #ff7fac;
--c-cyan: #2dd4bf;
--c-violet: #a78bfa;
--c-emerald: #34d399;
--c-amber: #fbbf24;
--c-blue: #60a5fa;
}
[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);
--glass-hover: rgba(255, 127, 172, 0.07);
--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);
--bg-primary: #ffffff;
--bg-secondary: rgba(255, 255, 255, 0.78);
--bg-card: rgba(255, 255, 255, 0.72);
--bg-input: rgba(255, 224, 233, 0.55);
--bg-hover: rgba(255, 127, 172, 0.08);
--text-primary: #3b2030;
--text-secondary: #7a5c6b;
--text-muted: #a48a96;
--border-color: rgba(201, 36, 98, 0.14);
--accent: #c92462;
--accent-bg: #ffe4e9;
--toast-bg: rgba(23, 169, 100, 0.14);
--toast-color: #128a52;
--toast-error-bg: rgba(219, 54, 148, 0.12);
--toast-error-color: #c2185b;
--pre-color: #5c4060;
--pre-bg: #fff0f5;
--chat-bg: #fff0f5;
--msg-user-bg: #ffe4e9;
--msg-user-color: #c2185b;
--msg-assistant-bg: #e3f2f6;
--msg-assistant-color: #2f7188;
--msg-system-bg: #e6ecfe;
--msg-system-color: #3f6ef5;
--msg-bubble-bg: #ffffff;
--msg-bubble-color: #262a33;
--msg-bubble-border: rgba(201, 36, 98, 0.14);
--kv-border: rgba(201, 36, 98, 0.1);
--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-top: #c92462;
--grad-a: rgba(255, 127, 172, 0.16);
--grad-b: rgba(136, 192, 208, 0.12);
--grad-c: rgba(243, 59, 124, 0.12);
}
[data-color="sakura"][data-theme="light"] {
--bg-primary: #ffffff;
--bg-input: rgba(255, 224, 233, 0.55);
--bg-hover: rgba(255, 127, 172, 0.08);
--pre-bg: #fff0f5;
--chat-bg: #fff0f5;
--msg-bubble-border: rgba(201, 36, 98, 0.14);
--btn-ghost-hover-bg: #ffe4e9;
--grad-a: rgba(255, 127, 172, 0.14);
--grad-c: rgba(243, 59, 124, 0.09);
}
[data-color="cyan"] {
--accent: #2dd4bf;
--accent-bg: rgba(45, 212, 191, 0.14);
--msg-user-bg: rgba(45, 212, 191, 0.16);
--msg-user-color: #99f6e4;
--loading-top: #2dd4bf;
--grad-a: rgba(45, 212, 191, 0.14);
--grad-c: rgba(45, 212, 191, 0.1);
}
[data-color="cyan"][data-theme="light"] {
--accent: #0f766e;
--accent-bg: #ccfbf1;
--msg-user-bg: #ccfbf1;
--msg-user-color: #0f766e;
--loading-top: #0f766e;
--bg-primary: #f0fdfa;
--bg-input: rgba(204, 251, 241, 0.6);
--bg-hover: rgba(13, 148, 136, 0.08);
--pre-bg: #e7f9f3;
--chat-bg: #e7f9f3;
--msg-bubble-border: rgba(13, 148, 136, 0.14);
--btn-ghost-hover-bg: #ccfbf1;
--grad-a: rgba(13, 148, 136, 0.14);
--grad-c: rgba(13, 148, 136, 0.09);
}
[data-color="violet"] {
--accent: #a78bfa;
--accent-bg: rgba(167, 139, 250, 0.14);
--msg-user-bg: rgba(167, 139, 250, 0.16);
--msg-user-color: #ddd6fe;
--loading-top: #a78bfa;
--grad-a: rgba(167, 139, 250, 0.14);
--grad-c: rgba(167, 139, 250, 0.1);
}
[data-color="violet"][data-theme="light"] {
--accent: #7c3aed;
--accent-bg: #ede9fe;
--msg-user-bg: #ede9fe;
--msg-user-color: #7c3aed;
--loading-top: #7c3aed;
--bg-primary: #f8f6ff;
--bg-input: rgba(237, 233, 254, 0.6);
--bg-hover: rgba(124, 58, 237, 0.08);
--pre-bg: #f3f0ff;
--chat-bg: #f3f0ff;
--msg-bubble-border: rgba(124, 58, 237, 0.14);
--btn-ghost-hover-bg: #ede9fe;
--grad-a: rgba(124, 58, 237, 0.13);
--grad-c: rgba(124, 58, 237, 0.08);
}
[data-color="emerald"] {
--accent: #34d399;
--accent-bg: rgba(52, 211, 153, 0.14);
--msg-user-bg: rgba(52, 211, 153, 0.16);
--msg-user-color: #a7f3d0;
--loading-top: #34d399;
--grad-a: rgba(52, 211, 153, 0.14);
--grad-c: rgba(52, 211, 153, 0.1);
}
[data-color="emerald"][data-theme="light"] {
--accent: #059669;
--accent-bg: #d1fae5;
--msg-user-bg: #d1fae5;
--msg-user-color: #059669;
--loading-top: #059669;
--bg-primary: #f0fdf6;
--bg-input: rgba(209, 250, 229, 0.6);
--bg-hover: rgba(5, 150, 105, 0.08);
--pre-bg: #e9f9f0;
--chat-bg: #e9f9f0;
--msg-bubble-border: rgba(5, 150, 105, 0.14);
--btn-ghost-hover-bg: #d1fae5;
--grad-a: rgba(5, 150, 105, 0.14);
--grad-c: rgba(5, 150, 105, 0.09);
}
[data-color="amber"] {
--accent: #fbbf24;
--accent-bg: rgba(251, 191, 36, 0.14);
--msg-user-bg: rgba(251, 191, 36, 0.16);
--msg-user-color: #fde68a;
--loading-top: #fbbf24;
--grad-a: rgba(251, 191, 36, 0.14);
--grad-c: rgba(251, 191, 36, 0.1);
}
[data-color="amber"][data-theme="light"] {
--accent: #d97706;
--accent-bg: #fef3c7;
--msg-user-bg: #fef3c7;
--msg-user-color: #b45309;
--loading-top: #d97706;
--bg-primary: #fffdf4;
--bg-input: rgba(254, 243, 199, 0.6);
--bg-hover: rgba(217, 119, 6, 0.08);
--pre-bg: #fdf6e1;
--chat-bg: #fdf6e1;
--msg-bubble-border: rgba(217, 119, 6, 0.14);
--btn-ghost-hover-bg: #fef3c7;
--grad-a: rgba(217, 119, 6, 0.13);
--grad-c: rgba(217, 119, 6, 0.08);
}
[data-color="blue"] {
--accent: #60a5fa;
--accent-bg: rgba(96, 165, 250, 0.14);
--msg-user-bg: rgba(96, 165, 250, 0.16);
--msg-user-color: #bfdbfe;
--loading-top: #60a5fa;
--grad-a: rgba(96, 165, 250, 0.14);
--grad-c: rgba(96, 165, 250, 0.1);
}
[data-color="blue"][data-theme="light"] {
--accent: #2563eb;
--accent-bg: #dbeafe;
--msg-user-bg: #dbeafe;
--msg-user-color: #2563eb;
--loading-top: #2563eb;
--bg-primary: #f7faff;
--bg-input: rgba(219, 234, 254, 0.6);
--bg-hover: rgba(37, 99, 235, 0.08);
--pre-bg: #eef4ff;
--chat-bg: #eef4ff;
--msg-bubble-border: rgba(37, 99, 235, 0.14);
--btn-ghost-hover-bg: #dbeafe;
--grad-a: rgba(37, 99, 235, 0.13);
--grad-c: rgba(37, 99, 235, 0.08);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--font-sans);
}
body {
background:
radial-gradient(
900px 700px at 85% -10%,
var(--grad-a),
transparent 60%
),
radial-gradient(
800px 600px at -10% 20%,
var(--grad-b),
transparent 60%
),
radial-gradient(
700px 500px at 50% 110%,
var(--grad-c),
transparent 60%
),
var(--bg-primary);
background-attachment: fixed;
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
font-size: 14px;
text-rendering: optimizeLegibility;
user-select: none;
-webkit-user-select: none;
transition:
background 0.3s,
color 0.2s;
}
.selectable,
.msg .text,
.msg .tc-args,
.msg .tc-result,
.msg .reasoning-body,
pre,
input,
textarea,
.term-buf {
user-select: text;
-webkit-user-select: text;
}
nav {
background: var(--bg-secondary);
backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
width: 16rem;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 100;
display: flex;
flex-direction: column;
border-right: 1px solid var(--glass-border);
padding: 16px 12px;
overflow-y: auto;
overflow-x: hidden;
transition:
transform var(--dur-normal) var(--ease-out),
background 0.2s,
border 0.2s;
}
nav h1 {
font-size: 16px;
font-weight: 700;
color: var(--accent);
margin: 4px 12px 16px;
white-space: nowrap;
letter-spacing: -0.01em;
display: flex;
align-items: center;
}
.nav-links {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
nav a {
padding: 10px 12px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
cursor: pointer;
border-radius: var(--radius-md);
border-left: 3px solid transparent;
transition:
all var(--dur-micro) var(--ease-out),
transform var(--dur-micro) var(--ease-out);
}
nav a:hover {
color: var(--text-primary);
background: var(--glass-hover);
transform: translateX(2px);
}
nav a.active {
color: var(--accent);
background: var(--accent-bg);
border-left-color: var(--accent);
text-shadow: 0 0 14px rgba(255, 127, 172, 0.45);
}
.nav-footer {
border-top: 1px solid var(--glass-border);
padding: 12px 4px 4px;
display: flex;
justify-content: center;
}
.main-wrap {
margin-left: 16rem;
min-height: 100vh;
display: flex;
flex-direction: column;
transition: margin-left var(--dur-normal) var(--ease-out);
}
body.sidebar-collapsed nav {
transform: translateX(-100%);
}
body.sidebar-collapsed .main-wrap {
margin-left: 0;
}
.topbar {
position: sticky;
top: 0;
z-index: 90;
height: 48px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 20px;
background: var(--bg-secondary);
backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
border-bottom: 1px solid var(--glass-border);
}
.sidebar-toggle {
background: transparent;
border: 1px solid var(--glass-border);
color: var(--text-secondary);
cursor: pointer;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.2;
transition:
all var(--dur-micro) var(--ease-out),
transform var(--dur-micro) var(--ease-out);
}
.sidebar-toggle:hover {
color: var(--accent);
border-color: rgba(255, 127, 172, 0.5);
transform: scale(1.05);
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
animation: breadcrumbIn var(--dur-normal) var(--ease-out);
}
@keyframes breadcrumbIn {
from {
opacity: 0;
transform: translateX(-6px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.crumb-home {
color: var(--accent);
font-weight: 600;
}
.crumb-sep {
color: var(--text-muted);
opacity: 0.6;
}
.crumb-current {
color: var(--text-primary);
font-weight: 500;
}
.theme-btn {
background: var(--glass-bg-strong);
border: 1px solid var(--glass-border);
backdrop-filter: blur(8px);
color: var(--text-secondary);
cursor: pointer;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-size: 14px;
line-height: 1;
margin-right: 8px;
transition:
all var(--dur-micro) var(--ease-out),
transform var(--dur-micro) var(--ease-out);
}
.theme-btn:hover {
color: var(--accent);
border-color: rgba(255, 127, 172, 0.5);
box-shadow: var(--shadow-glow);
transform: scale(1.06);
}
#bg-layer {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: var(--bg-img, none);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transform: scale(1.04);
filter: blur(calc(var(--bg-blur, 0) * 1px));
transition: filter 0.25s var(--ease-out);
}
.palette-pop {
position: fixed;
bottom: 66px;
left: 16px;
width: 216px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 130px);
overflow-y: auto;
padding: 12px;
background: var(--glass-bg-strong);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
display: none;
z-index: 300;
}
.palette-pop.on {
display: block;
animation: viewIn 0.16s var(--ease-out);
}
.palette-pop h4 {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
margin: 2px 0 6px;
}
.palette-pop button.cdot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
cursor: pointer;
margin: 3px;
padding: 0;
vertical-align: middle;
transition: transform 0.15s, box-shadow 0.15s;
}
.palette-pop button.cdot:hover {
transform: scale(1.25);
}
.palette-pop button.cdot.on {
border-color: var(--text-primary);
box-shadow: var(--shadow-glow);
transform: scale(1.15);
}
.palette-pop input[type="text"] {
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 5px 8px;
color: var(--text-primary);
font-size: 11px;
width: 100%;
margin-bottom: 6px;
outline: none;
}
.palette-pop .pp-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
margin-top: 4px;
}
.palette-pop .pp-row .btn {
padding: 3px 9px;
font-size: 11px;
white-space: nowrap;
}
.palette-pop .pp-val {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text-muted);
min-width: 0;
text-align: right;
margin-left: auto;
}
.palette-pop input[type="range"] {
width: 72px;
margin: 0;
accent-color: var(--accent);
vertical-align: middle;
}
.container {
padding: 24px;
max-width: none;
}
.card {
position: relative;
background: var(--bg-card);
backdrop-filter: blur(var(--glass-blur)) saturate(1.3);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.3);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
transition:
background 0.2s,
border 0.2s,
box-shadow var(--dur-normal) var(--ease-out),
transform var(--dur-normal) var(--ease-out);
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
border-color: rgba(255, 127, 172, 0.25);
}
.card.tilt {
transform-style: preserve-3d;
transition:
box-shadow var(--dur-normal) var(--ease-out),
transform 0.15s ease-out;
}
.card.tilt .tilt-glow {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease-out;
background: radial-gradient(
240px circle at var(--mx, 50%) var(--my, 50%),
rgba(255, 127, 172, 0.12),
transparent 70%
);
mix-blend-mode: normal;
}
.card.tilt:hover .tilt-glow {
opacity: 1;
}
@media (hover: none), (prefers-reduced-motion: reduce) {
.card.tilt {
transform: none !important;
}
.card.tilt .tilt-glow {
display: none;
}
}
.card h2 {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.card h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin: 16px 0 8px;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.02em;
background: linear-gradient(
120deg,
var(--sakura-400),
var(--frost-300)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
letter-spacing: 0.02em;
}
.stat-card {
padding: 16px 20px;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
box-shadow: 0 0 6px currentColor;
}
.dot-green {
background: var(--success);
color: var(--success);
}
.dot-yellow {
background: var(--warning);
color: var(--warning);
}
.dot-red {
background: var(--error);
color: var(--error);
}
.dot-gray {
background: var(--text-muted);
color: var(--text-muted);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
padding: 8px 10px;
color: var(--text-muted);
font-weight: 500;
border-bottom: 1px solid var(--border-color);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 8px 10px;
border-bottom: 1px solid var(--kv-border);
}
tr:hover td {
background: var(--bg-hover);
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.badge-green {
background: rgba(23, 169, 100, 0.16);
color: var(--success);
}
.badge-red {
background: rgba(219, 54, 148, 0.16);
color: var(--error);
}
.badge-yellow {
background: rgba(217, 154, 43, 0.16);
color: var(--warning);
}
.badge-blue {
background: var(--accent-bg);
color: var(--accent);
}
.badge-gray {
background: var(--glass-hover);
color: var(--text-secondary);
}
.btn {
padding: 6px 16px;
border-radius: var(--radius-pill);
border: none;
font-size: 12px;
cursor: pointer;
font-weight: 600;
letter-spacing: 0.01em;
transition:
all var(--dur-micro) var(--ease-out),
transform var(--dur-micro) var(--ease-out);
}
.btn:active {
transform: scale(0.96);
}
.btn-primary {
background: linear-gradient(120deg, var(--sakura-400), var(--sakura-500));
color: #fff;
box-shadow: 0 2px 12px rgba(255, 127, 172, 0.35);
}
.btn-primary:hover {
filter: brightness(1.08);
box-shadow: 0 4px 18px rgba(255, 127, 172, 0.45);
}
.btn-danger {
background: var(--error);
color: #fff;
}
.btn-danger:hover {
filter: brightness(1.1);
}
.btn-sm {
padding: 4px 12px;
font-size: 11px;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--btn-ghost-border);
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--btn-ghost-hover-bg);
color: var(--text-primary);
border-color: rgba(255, 127, 172, 0.4);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: tabIn var(--dur-normal) var(--ease-out);
}
@keyframes tabIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
input,
textarea,
select {
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
width: 100%;
margin-bottom: 10px;
outline: none;
transition:
border 0.15s,
background 0.2s,
color 0.2s,
box-shadow 0.15s;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent);
box-shadow: var(--shadow-glow);
background: var(--bg-input);
}
textarea {
resize: vertical;
min-height: 80px;
font-family: var(--font-mono);
font-size: 12px;
}
label {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 3px;
font-weight: 500;
}
pre {
background: var(--pre-bg);
border-radius: var(--radius-sm);
padding: 12px;
font-size: 12px;
overflow-x: auto;
color: var(--pre-color);
font-family: var(--font-mono);
max-height: 400px;
overflow-y: auto;
}
code {
font-family: var(--font-mono);
font-size: 12px;
color: var(--pre-color);
}
.settings-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
background: var(--bg-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 6px;
box-shadow: var(--shadow-sm);
}
.settings-tabs span {
display: inline-block;
padding: 7px 16px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
text-decoration: none;
border-radius: var(--radius-pill);
border: 1px solid transparent;
transition: all 0.15s;
}
.settings-tabs span:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.settings-tabs span.active {
background: var(--accent-bg);
color: var(--accent);
border-color: rgba(255, 127, 172, 0.35);
}
.settings-content {
flex: 1;
min-width: 0;
}
.settings-content > .card {
margin-bottom: 16px;
}
.settings-key {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
margin-bottom: 2px;
}
.reasoning {
border-left: 2px solid var(--frost-400);
padding-left: 12px;
margin: 8px 0;
font-size: 12px;
color: var(--text-muted);
}
.reasoning-title {
cursor: pointer;
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
user-select: none;
margin-bottom: 4px;
}
.reasoning-body {
color: var(--text-muted);
line-height: 1.5;
}
.reasoning-body p {
margin: 4px 0;
}
.msg-content .text h1,
.msg-content .text h2,
.msg-content .text h3 {
font-size: 1em;
margin: 8px 0 4px;
color: var(--text-primary);
}
.msg-content .text p {
margin: 4px 0;
line-height: 1.5;
}
.msg-content .text ul,
.msg-content .text ol {
padding-left: 20px;
margin: 4px 0;
}
.msg-content .text li {
margin: 2px 0;
}
.msg-content .text code {
background: var(--pre-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.msg-content .text pre {
background: var(--pre-bg);
border-radius: 6px;
padding: 10px;
margin: 8px 0;
overflow-x: auto;
font-size: 11px;
max-height: 300px;
}
.msg-content .text pre code {
background: none;
padding: 0;
}
.msg-content .text blockquote {
border-left: 3px solid var(--border-color);
padding-left: 10px;
margin: 8px 0;
color: var(--text-secondary);
}
.msg-content .text table {
border-collapse: collapse;
margin: 8px 0;
font-size: 12px;
width: 100%;
}
.msg-content .text th,
.msg-content .text td {
border: 1px solid var(--border-color);
padding: 4px 8px;
text-align: left;
}
.msg-content .text img {
max-width: 100%;
border-radius: 6px;
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--toast-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
color: var(--toast-color);
padding: 10px 22px;
border-radius: var(--radius-md);
font-size: 13px;
display: none;
z-index: 100;
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-md);
animation: toastIn 0.3s var(--ease-out);
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.toast.error {
background: var(--toast-error-bg);
color: var(--toast-error-color);
}
.toast.success {
background: var(--toast-bg);
color: var(--toast-color);
border-color: rgba(23, 169, 100, 0.35);
}
.toast.warn {
background: rgba(217, 154, 43, 0.16);
color: var(--warning);
border-color: rgba(217, 154, 43, 0.35);
}
.confirm-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(8, 10, 20, 0.55);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 200;
animation: fadeIn 0.2s ease;
}
.confirm-box {
background: var(--glass-bg-strong);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 380px;
width: 90%;
box-shadow: var(--shadow-lg);
animation: tabIn 0.25s var(--ease-out);
}
.confirm-box h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 10px;
color: var(--text-primary);
}
.confirm-box p {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.55;
margin-bottom: 18px;
word-break: break-all;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px dashed var(--glass-border);
border-radius: var(--radius-lg);
margin: 8px 0;
}
.empty-state p {
font-size: 14px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.empty-state .icon {
font-size: 40px;
margin-bottom: 12px;
opacity: 0.6;
filter: drop-shadow(0 4px 10px rgba(255, 127, 172, 0.3));
}
.chat-layout {
display: flex;
flex-direction: column;
gap: 16px;
height: calc(100vh - 112px);
min-height: 60vh;
}
.chat-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
.chat-tabs span {
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
color: var(--text-muted);
border-radius: var(--radius-pill);
border: 1px solid transparent;
transition: all 0.15s;
}
.chat-tabs span:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.chat-tabs span.active {
color: var(--accent);
background: var(--accent-bg);
border-color: rgba(255, 127, 172, 0.35);
}
.chat-panel {
display: none;
flex: 1;
min-height: 0;
flex-direction: column;
}
.chat-panel.active {
display: flex;
}
.chat-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-main .card {
flex: 1;
display: flex;
flex-direction: column;
margin-bottom: 0;
min-height: 0;
background: transparent;
border: none;
box-shadow: none;
backdrop-filter: none;
padding: 0;
}
.chat-main .card h2 {
flex-shrink: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 18px 18px 26px;
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.msg {
display: flex;
gap: 8px;
margin-bottom: 2px;
align-items: flex-start;
max-width: 100%;
}
.msg-user {
flex-direction: row-reverse;
align-self: flex-end;
}
.msg-assistant {
align-self: flex-start;
}
.msg-system {
align-self: center;
max-width: 90%;
}
.msg-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.msg-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.msg-assistant .msg-avatar img {
transform: scale(1.5);
}
.msg-avatar svg {
display: block;
width: 16px;
height: 16px;
}
.msg-user .msg-avatar {
background: var(--msg-user-bg);
color: var(--msg-user-color);
}
.msg-assistant .msg-avatar {
background: transparent;
}
.msg-system .msg-avatar {
background: var(--msg-system-bg);
color: var(--msg-system-color);
}
.msg-content {
min-width: 0;
flex: 1;
}
.msg-content .msg-bubble + .msg-bubble {
margin-top: 6px;
}
.msg-channel {
align-self: flex-start;
}
.msg-channel .msg-avatar.chan-avatar {
color: #ffffff;
font-weight: 700;
font-size: 13px;
text-transform: uppercase;
}
.msg-channel .msg-chan-name {
font-size: 10px;
color: var(--text-muted);
opacity: 0.8;
margin-bottom: 2px;
padding-left: 4px;
letter-spacing: 0.5px;
}
.msg-channel .msg-bubble {
background: var(--msg-bubble-bg);
color: var(--msg-bubble-color);
border-bottom-left-radius: 4px;
border-color: var(--msg-bubble-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
}
.msg-bubble {
padding: 9px 14px;
border-radius: var(--radius-md);
font-size: 15px;
line-height: 1.62;
word-break: break-word;
position: relative;
border: 1px solid transparent;
transition: box-shadow var(--dur-normal) var(--ease-out);
}
.msg-user .msg-bubble {
background: var(--msg-bubble-bg);
color: var(--msg-bubble-color);
border-bottom-right-radius: 4px;
border-color: var(--msg-bubble-border);
}
.msg-assistant .msg-bubble {
background: var(--msg-bubble-bg);
color: var(--msg-bubble-color);
border-bottom-left-radius: 4px;
border-color: var(--msg-bubble-border);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.msg-system .msg-bubble {
background: var(--msg-system-bg);
color: var(--msg-system-color);
text-align: center;
font-size: 12px;
border-color: var(--msg-bubble-border);
}
.msg-bubble .text {
white-space: pre-wrap;
}
.msg-bubble .text p {
margin: 4px 0;
}
.msg-bubble .text h1,
.msg-bubble .text h2,
.msg-bubble .text h3 {
font-size: 1em;
margin: 8px 0 4px;
}
.msg-bubble .text ul,
.msg-bubble .text ol {
padding-left: 20px;
margin: 4px 0;
}
.msg-bubble .text pre {
background: var(--pre-bg);
color: var(--pre-color);
border-radius: 6px;
padding: 8px;
margin: 4px 0;
overflow-x: auto;
font-size: 11.5px;
line-height: 1.5;
border: 1px solid var(--kv-border);
max-height: 200px;
}
.msg-bubble .text code {
background: var(--pre-bg);
color: var(--pre-color);
padding: 1px 4px;
border-radius: 3px;
font-size: 11.5px;
}
.msg-bubble .text pre code {
background: none;
padding: 0;
}
.msg-bubble .text blockquote {
border-left: 3px solid var(--border-color);
padding-left: 8px;
margin: 4px 0;
opacity: 0.8;
}
.msg-bubble .text table {
border-collapse: collapse;
margin: 4px 0;
font-size: 12px;
width: 100%;
}
.msg-bubble .text th,
.msg-bubble .text td {
border: 1px solid var(--border-color);
padding: 3px 6px;
text-align: left;
}
.msg-bubble .text img {
max-width: 100%;
border-radius: 6px;
}
/* ===== 思考卡片PiDeck 风格)===== */
.reasoning-card {
margin: 6px 0;
border: 1px dashed var(--kv-border);
border-radius: var(--radius-sm);
background: var(--bg-panel, var(--bg-hover));
overflow: hidden;
}
.reasoning-card .reasoning-head {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
font-size: 11px;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
}
.reasoning-card .reasoning-head:hover {
background: var(--bg-hover);
}
.reasoning-card .rc-ico {
color: var(--accent);
flex-shrink: 0;
}
.reasoning-card .rc-title {
font-weight: 600;
color: var(--text-primary);
}
.reasoning-card .rc-chev {
margin-left: auto;
font-size: 10px;
color: var(--text-muted);
transition: transform 0.2s var(--ease-out);
}
.reasoning-card.open .rc-chev {
transform: rotate(180deg);
}
.reasoning-card .reasoning-body {
padding: 4px 10px 8px;
}
.reasoning-card .reasoning-content {
line-height: 1.5;
color: var(--text-tertiary);
font-size: 11.5px;
}
.reasoning-card .reasoning-content p {
margin: 4px 0;
}
.reasoning-card .reasoning-sweep {
height: 2px;
border-radius: 2px;
margin-top: 6px;
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; }
}
/* ===== 工具卡片PiDeck 风格,扁平不套气泡)===== */
.tool-card {
background: var(--bg-panel, var(--bg-hover));
color: var(--text-secondary);
border-radius: var(--radius-sm);
padding: 7px 10px;
margin: 6px 0;
font-size: 11px;
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);
}
.tool-card.tc-running {
border-left-color: var(--accent);
}
.tool-card.tc-done {
border-left-color: rgba(23, 169, 100, 0.8);
}
.tool-card.tc-error {
border-left-color: var(--error, #db3694);
}
.tool-card .tc-line {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
user-select: none;
}
.tool-card .tc-ico {
display: inline-flex;
flex-shrink: 0;
color: var(--accent);
}
.tool-card.tc-error .tc-ico {
color: var(--error, #db3694);
}
.tool-card.tc-done .tc-ico {
color: rgba(23, 169, 100, 0.9);
}
.tool-card .tc-name {
font-weight: 600;
color: var(--text-primary);
}
.tool-card .tc-plugin {
font-size: 9.5px;
color: var(--text-muted);
background: var(--pre-bg);
border-radius: 4px;
padding: 0 4px;
line-height: 1.6;
flex-shrink: 0;
}
.tool-card .tc-state {
margin-left: auto;
font-size: 9.5px;
font-weight: 500;
padding: 1px 6px;
border-radius: 999px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tool-card .tc-run {
background: rgba(63, 110, 245, 0.18);
color: #a3b8ff;
}
.tool-card .tc-done {
background: rgba(23, 169, 100, 0.18);
color: #6ee7a8;
}
.tool-card .tc-deny {
background: rgba(219, 54, 148, 0.18);
color: #ff9ec6;
}
.tool-card .tc-spinner {
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid rgba(63, 110, 245, 0.25);
border-top-color: #3f6ef5;
animation: spin 0.7s linear infinite;
display: inline-block;
}
.tool-card .tc-caret {
font-size: 10px;
color: var(--text-muted);
transition: transform 0.15s var(--ease-out);
}
.tool-card.open .tc-caret {
transform: rotate(180deg);
}
.tool-card .tc-detail {
margin-top: 6px;
}
.tool-card .tc-detail-label {
font-size: 9.5px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 2px;
}
.tool-card .tc-args,
.tool-card .tc-result {
font-family: var(--font-mono);
font-size: 11px;
opacity: 0.85;
white-space: pre-wrap;
word-break: break-all;
margin-top: 5px;
background: var(--pre-bg);
color: var(--pre-color);
border-radius: 4px;
padding: 5px 7px;
}
.tool-card .tc-result {
opacity: 0.72;
max-height: 80px;
overflow-y: auto;
}
[data-theme="light"] .tool-card .tc-run {
color: #3f6ef5;
}
[data-theme="light"] .tool-card .tc-done {
color: #128a52;
}
[data-theme="light"] .tool-card .tc-deny {
color: #c2185b;
}
.tool-card.tool-drip-in {
animation: toolDripIn 0.32s var(--ease-out) both;
}
@keyframes toolDripIn {
0% {
opacity: 0;
transform: translateY(-18px) scale(0.96);
}
70% {
opacity: 1;
transform: translateY(2px) scale(1.005);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.msg-bubble .thinking-tools {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.msg-bubble .thinking-tool {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
background: var(--pre-bg);
border-radius: 999px;
padding: 3px 10px;
}
.msg-bubble .thinking-tool.pill-in {
animation: pillIn 0.25s var(--ease-out) both;
}
@keyframes pillIn {
from {
opacity: 0;
transform: translateX(8px);
}
to {
opacity: 1;
transform: none;
}
}
.msg-bubble .live-spinner {
width: 15px;
height: 15px;
border-radius: 50%;
border: 2px solid var(--msg-user-bg);
border-top-color: var(--accent);
animation: spin 0.7s linear infinite;
flex-shrink: 0;
display: inline-block;
vertical-align: middle;
}
.msg-bubble .live-spinner + .thinking-tools,
.msg-bubble .live-spinner + .text {
margin-left: 12px;
}
.msg-bubble.grow-in {
animation: bubbleGrow 0.5s var(--ease-out) both;
}
.msg-bubble.grow-in .text {
animation: textFadeIn 0.35s ease 0.125s both;
}
@keyframes bubbleGrow {
from {
max-height: 28px;
opacity: 0.4;
}
to {
max-height: 3000px;
opacity: 1;
}
}
@keyframes textFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.tool-call.tool-drip-in {
animation: toolDripIn 0.3s var(--ease-out) both;
}
@keyframes toolDripIn {
from {
opacity: 0;
transform: translateY(-14px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
/* 实时终端画面:模拟 agent 操控的终端屏幕 */
.term-screen {
background: #0f1115;
border: 1px solid #2a2e35;
border-radius: 8px;
margin: 6px 0 4px 0;
overflow: hidden;
font-size: 11px;
line-height: 1.45;
}
.term-screen .term-head {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #1a1d23;
color: #9aa0a8;
font-size: 10px;
border-bottom: 1px solid #2a2e35;
font-family: var(--font-mono);
}
.term-screen .term-head .term-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3fb950;
flex-shrink: 0;
}
.term-screen .term-head .term-dot.stopped {
background: #d29922;
}
.term-screen pre.term-buf {
margin: 0;
padding: 6px 8px;
background: transparent;
color: #d8dee9;
font-family: var(--font-mono);
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
max-height: 260px;
overflow-y: auto;
scrollbar-width: thin;
}
.term-screen pre.term-buf::-webkit-scrollbar {
width: 6px;
}
.term-screen pre.term-buf::-webkit-scrollbar-thumb {
background: #33373d;
border-radius: 3px;
}
.chat-input-row {
display: flex;
gap: 8px;
flex-shrink: 0;
padding-top: 10px;
}
.chat-input-row input {
flex: 1;
margin-bottom: 0;
}
.chat-input-row button {
flex-shrink: 0;
margin-bottom: 0;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--loading-border);
border-radius: 50%;
border-top-color: var(--loading-top);
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.monaco-like {
font-family: var(--font-mono);
font-size: 12px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.kv-row {
display: flex;
padding: 6px 0;
border-bottom: 1px solid var(--kv-border);
font-size: 13px;
}
.kv-row .key {
color: var(--text-muted);
width: 180px;
flex-shrink: 0;
}
.kv-row .val {
color: var(--text-primary);
word-break: break-all;
}
.tool-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
background: var(--accent-bg);
color: var(--accent);
margin: 1px;
}
.check-pass {
color: var(--success);
}
.check-fail {
color: var(--error);
}
.check-skip {
color: var(--text-secondary);
}
.memory-graph {
width: 100%;
height: 300px;
background: var(--bg-input);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 13px;
}
.health-panel {
display: grid;
gap: 8px;
}
.health-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--bg-input);
border-radius: var(--radius-sm);
font-size: 13px;
border: 1px solid transparent;
transition:
border var(--dur-micro),
background var(--dur-micro);
}
.health-item:hover {
border-color: rgba(255, 127, 172, 0.25);
}
.health-item .check-name {
flex: 1;
}
.health-item .check-status {
font-size: 11px;
font-weight: 500;
}
.fade-in {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#sm-container-chat {
height: 260px;
background: var(--bg-input);
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
}
#sm-container-chat canvas {
display: block;
}
.toggle-row {
margin-top: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.toggle-row .label-text {
color: var(--text-muted);
font-size: 12px;
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.toggle-track {
position: absolute;
inset: 0;
background: var(--bg-input);
border-radius: 10px;
transition: all 0.3s;
border: 1px solid var(--border-color);
}
.toggle-track.on {
background: var(--accent-bg);
border-color: var(--accent);
box-shadow: var(--shadow-glow);
}
.toggle-knob {
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
background: var(--text-muted);
border-radius: 50%;
transition: all 0.3s;
}
.toggle-knob.on {
left: 18px;
background: var(--accent);
}
.toggle-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-muted);
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn:hover {
background: var(--accent-bg);
color: var(--text-primary);
}
.toggle-btn.on {
background: var(--accent-bg);
color: var(--accent);
border-color: var(--accent);
}
.label-text {
color: var(--text-muted);
font-size: 12px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--loading-border);
border-top: 3px solid var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.sidebar-subnav {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 10px;
}
.sidebar-subnav span {
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.sidebar-subnav span:hover {
color: var(--text-primary);
}
.sidebar-subnav span.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
@media (max-width: 900px) {
.chat-layout {
height: auto;
}
.msg {
max-width: 100%;
}
}
@media (max-width: 768px) {
nav {
width: 14rem;
box-shadow: var(--shadow-lg);
}
.main-wrap {
margin-left: 0;
}
nav h1 {
font-size: 15px;
}
.topbar {
padding: 0 12px;
}
.breadcrumb .crumb-home {
display: none;
}
.container {
padding: 12px;
}
.card {
padding: 12px;
}
.grid-2,
.grid-3,
.grid-4 {
grid-template-columns: 1fr;
}
.stat-value {
font-size: 20px;
}
.settings-layout {
flex-direction: column;
}
.settings-tabs {
max-height: 200px;
overflow-y: auto;
}
.kv-row {
flex-direction: column;
gap: 2px;
}
.kv-row .key {
width: auto;
}
.msg-avatar {
width: 24px;
height: 24px;
font-size: 11px;
}
.health-item {
flex-wrap: wrap;
gap: 4px;
}
.health-item .check-name {
flex: auto;
width: 100%;
}
}
/* ===== 8.7 Accessibility ===== */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
::selection {
background: rgba(255, 127, 172, 0.35);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ===== Themed scrollbar ===== */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 127, 172, 0.25);
border-radius: var(--radius-pill);
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 127, 172, 0.45);
background-clip: content-box;
border: 2px solid transparent;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 127, 172, 0.35) transparent;
}
</style>
</head>
<body>
<div id="bg-layer"></div>
<div class="palette-pop" id="palette-pop"></div>
<nav id="sidebar">
<h1>
<img
src="/logo.svg"
alt="HomeAgent"
style="
height: 22px;
width: 22px;
vertical-align: middle;
margin-right: 6px;
flex-shrink: 0;
border-radius: 6px;
"
/>HomeAgent
</h1>
<div class="nav-links">
<a class="active" onclick="switchTab('overview')" data-i18n="navOverview"
>概览</a
>
<a onclick="switchTab('chat')" data-i18n="navChat">对话</a>
<a onclick="switchTab('plugins')" data-i18n="navPlugins">插件</a>
<a onclick="switchTab('settings')" data-i18n="navSettings">设置</a>
<a onclick="switchTab('adapters')" data-i18n="navAdapters">适配器</a>
<a onclick="switchTab('kernel')" data-i18n="navKernel">内核</a>
</div>
<div class="nav-footer">
<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>
</button>
<button
class="theme-btn"
onclick="toggleAppearance()"
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>
</button>
<button
class="btn btn-ghost btn-sm"
onclick="toggleLang()"
id="lang-btn"
title="切换语言/Language"
style="font-size: 13px; min-width: 28px"
>
EN
</button>
<button
class="btn btn-ghost btn-sm"
onclick="logout()"
data-i18n="navLogout"
>
退出登录
</button>
</div>
</div>
</nav>
<div class="main-wrap">
<header class="topbar">
<button
class="sidebar-toggle"
onclick="toggleSidebar()"
id="sidebar-toggle"
title="切换侧边栏"
aria-label="切换侧边栏"
>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="breadcrumb" id="breadcrumb">
<span class="crumb-home">HomeAgent</span>
<span class="crumb-sep">/</span>
<span class="crumb-current" data-i18n="navOverview">概览</span>
</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>
<div id="toast" class="toast"></div>
<script>
// ===== State =====
let state = {
status: {},
kernel: null,
settings: {},
meta: {},
pluginMeta: {},
disabledPlugins: [],
settingsPlugins: ["core"],
selectedSection: "core",
messages: [],
chatLoading: false,
chatStage: "",
healthResult: null,
starmapInit: false,
starmapLoading: false,
starmapData: null,
chatHistory: [],
terminals: [],
cmdHistory: [],
termScreens: {},
chatStick: true,
pendingTools: [],
eventSource: null,
chatFinalIdx: -1,
lang: localStorage.getItem("ha-lang") || "zh",
};
// ===== I18n =====
window._i18n = {
navOverview: ["概览", "Overview"],
navChat: ["对话", "Chat"],
navPlugins: ["插件", "Plugins"],
navSettings: ["设置", "Settings"],
navAdapters: ["适配器", "Adapters"],
navKernel: ["内核", "Kernel"],
navLogout: ["退出登录", "Logout"],
themeToggle: ["切换亮色/暗色模式", "Toggle theme"],
};
function __(zh, en) {
return state.lang === "en" ? en : zh;
}
function L() {
return state.lang;
}
function toggleLang() {
state.lang = state.lang === "zh" ? "en" : "zh";
localStorage.setItem("ha-lang", state.lang);
document.querySelectorAll("[data-i18n]").forEach(function (el) {
var k = el.getAttribute("data-i18n");
var m = window._i18n && window._i18n[k];
if (m) el.textContent = __(m[0], m[1]);
});
renderAll();
}
function applyI18n() {
var lang = state.lang;
var btn = document.getElementById("lang-btn");
if (btn) btn.textContent = lang === "zh" ? "EN" : "中";
document.querySelectorAll("[data-i18n]").forEach(function (el) {
var k = el.getAttribute("data-i18n");
var m = window._i18n && window._i18n[k];
if (m) el.textContent = lang === "en" ? m[1] : m[0];
});
}
// ===== Theme =====
var ICON_SUN =
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4m11.4-11.4 1.4-1.4"/></svg>';
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");
if (btn) btn.innerHTML = name === "light" ? ICON_SUN : ICON_MOON;
}
function saveTheme(name) {
localStorage.setItem("ha-theme", name);
setTheme(name);
}
function toggleTheme() {
var cur = document.documentElement.getAttribute("data-theme");
saveTheme(cur === "light" ? "dark" : "light");
}
var PALETTES = {
sakura: "#ff7fac",
cyan: "#2dd4bf",
violet: "#a78bfa",
emerald: "#34d399",
amber: "#fbbf24",
blue: "#60a5fa"
};
function setColor(name) {
document.documentElement.setAttribute("data-color", name);
localStorage.setItem("ha-color", name);
var pop = document.getElementById("palette-pop");
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";
}
}
function applyBgImg(url) {
url = (url || "").trim();
if (!url) {
document.documentElement.style.setProperty("--bg-img", "none");
localStorage.removeItem("ha-bg-img");
return;
}
document.documentElement.style.setProperty(
"--bg-img",
'url("' + url.replace(/"/g, '\\"') + '")'
);
localStorage.setItem("ha-bg-img", url);
}
function applyBgBlur(n) {
n = Math.max(0, Math.min(30, Number(n) || 0));
document.documentElement.style.setProperty("--bg-blur", String(n));
localStorage.setItem("ha-bg-blur", String(n));
var v = document.getElementById("bg-blur-val");
if (v) v.textContent = n + "px";
var r = document.getElementById("bg-blur-range");
if (r) r.value = String(n);
}
function toggleAppearance() {
var pop = document.getElementById("palette-pop");
if (!pop) return;
var on = pop.classList.contains("on");
if (!pop.querySelector("button.cdot")) {
var cur = localStorage.getItem("ha-color") || "sakura";
var img = localStorage.getItem("ha-bg-img") || "";
var blur = localStorage.getItem("ha-bg-blur") || "0";
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>';
});
pop.innerHTML =
'<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) + '">' +
'<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>';
setColor(cur);
var rr = document.getElementById("bg-blur-range");
if (rr) rr.value = blur;
var vv = document.getElementById("bg-blur-val");
if (vv) vv.textContent = blur + "px";
}
pop.classList.toggle("on", !on);
}
(function initAppearance() {
var img = localStorage.getItem("ha-bg-img");
var blur = localStorage.getItem("ha-bg-blur");
if (img) applyBgImg(img);
if (blur) applyBgBlur(blur);
})();
(function () {
var saved = localStorage.getItem("ha-theme");
setTheme(saved || "light");
var savedColor = localStorage.getItem("ha-color");
if (savedColor) setColor(savedColor);
var sb = localStorage.getItem("ha-sidebar");
if (sb === "1" || (!sb && window.innerWidth <= 768)) {
document.body.classList.add("sidebar-collapsed");
}
})();
// 字节数人性化显示(附件卡片用)
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 || "");
var html;
if (typeof marked !== "undefined") {
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) {}
}
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/\son\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\son\w+\s*=\s*'[^']*'/gi, "")
.replace(/javascript:/gi, "");
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function toast(m, isError) {
var t = document.getElementById("toast");
t.textContent = m;
t.className = "toast" + (isError ? " error" : "");
t.style.display = "block";
clearTimeout(t._hideTimer);
t._hideTimer = setTimeout(function () {
t.style.display = "none";
}, 3000);
}
// ===== 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;
if (card) {
var r = card.getBoundingClientRect();
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 =
"perspective(1000px) rotateX(" +
rx.toFixed(2) +
"deg) rotateY(" +
ry.toFixed(2) +
"deg) translateY(-1px)";
}
});
document.addEventListener("mouseleave", function (e) {
var card = e.target.closest
? e.target.closest(".card.tilt")
: null;
if (card) card.style.transform = "";
});
// ===== API =====
async function api(p, o) {
var opts = {
credentials: "include",
headers: { "Content-Type": "application/json", ...o?.headers },
...o,
};
var r = await fetch("/api/v1" + p, opts);
if (r.status === 401) {
location.href = "/login";
throw new Error("unauthorized");
}
if (opts.raw) return r;
var ct = r.headers.get("content-type") || "";
if (ct.includes("json")) return r.json();
return r.text();
}
// ===== Navigation =====
function switchTab(n) {
document.querySelectorAll(".tab-content").forEach(function (e) {
e.classList.remove("active");
});
var el = document.getElementById("tab-" + n);
if (el) el.classList.add("active");
document.querySelectorAll("nav a").forEach(function (e) {
e.classList.remove("active");
});
var match = document.querySelector('nav a[onclick*="' + n + '"]');
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 m = window._i18n && window._i18n[crumbKey];
crumb.setAttribute("data-i18n", crumbKey);
if (m) crumb.textContent = __(m[0], m[1]);
else crumb.textContent = n;
}
if (window.innerWidth <= 768) {
document.body.classList.add("sidebar-collapsed");
}
if (n === "chat") {
state.chatStick = true;
var msgsEl = document.getElementById("chat-msgs");
if (msgsEl) {
msgsEl.scrollTop = msgsEl.scrollHeight;
}
}
renderAll();
}
function toggleSidebar() {
document.body.classList.toggle("sidebar-collapsed");
var saved = localStorage.getItem("ha-sidebar");
localStorage.setItem(
"ha-sidebar",
document.body.classList.contains("sidebar-collapsed") ? "1" : "0",
);
}
// ===== Tab Render Dispatch =====
async function renderAll() {
try {
var s = await api("/status");
state.status = s;
state.startedAt = s.startedAt
? new Date(s.startedAt).getTime()
: null;
} catch (e) {}
try {
state.kernel = await api("/kernel");
} catch (e) {}
try {
var s = await api("/settings");
state.settings = s.settings || {};
state.meta = s.meta || {};
state.settingsPlugins = s.plugins || ["core"];
state.pluginMeta = s.plugin_meta || {};
state.disabledPlugins = s.disabled_plugins || [];
} catch (e) {}
try {
state.installedPlugins = await api("/plugins");
} catch (e) {}
try {
await loadTerminals();
} catch (e) {}
try {
await loadCmdHistory();
} catch (e) {}
try {
renderOverview();
} catch (e) {
console.error("renderOverview", e);
}
try {
renderChat();
} catch (e) {
console.error("renderChat", e);
}
try {
renderChatStarmap();
} catch (e) {
console.error("renderChatStarmap", e);
}
try {
renderPlugins();
} catch (e) {
console.error("renderPlugins", e);
}
try {
renderKernel();
} catch (e) {
console.error("renderKernel", e);
}
try {
renderOneSettings();
} catch (e) {
console.error("renderOneSettings", e);
}
try {
renderAdapters();
} catch (e) {
console.error("renderAdapters", e);
}
try {
if (!window.matchMedia("(hover: none)").matches) {
document
.querySelectorAll("#tab-overview .card, #tab-plugins .card, #tab-kernel .card")
.forEach(function (c) {
if (!c.querySelector(".tilt-glow")) {
var g = document.createElement("span");
g.className = "tilt-glow";
c.appendChild(g);
c.classList.add("tilt");
}
});
}
} catch (e) {}
applyI18n();
}
function fmtUptime(ms) {
var s = Math.floor(ms / 1000);
if (s < 60) return s + "s";
var m = Math.floor(s / 60);
s = s % 60;
if (m < 60) return m + "m " + s + "s";
var h = Math.floor(m / 60);
m = m % 60;
return h + "h " + m + "m " + s + "s";
}
var uptimeTick = null;
function startUptimeTicker() {
if (uptimeTick) clearInterval(uptimeTick);
uptimeTick = setInterval(function () {
var el = document.querySelector("#uptime-val");
if (el && state.startedAt) {
var now = Date.now();
el.textContent = fmtUptime(now - state.startedAt);
} else if (!state.startedAt) {
var el2 = document.querySelector("#uptime-val");
if (el2) el2.textContent = "-";
}
}, 1000);
}
// ===== Overview =====
function renderOverview() {
var s = state.status || {};
var k = state.kernel;
var html =
'<div class="card"><h2>' +
__("系统概览", "System Overview") +
"</h2>" +
'<div class="kv-row"><span class="key">' +
__("运行状态", "Status") +
'</span><span class="val"><span class="status-dot ' +
(s.status === "running" ? "dot-green" : "dot-yellow") +
'"></span>' +
escHtml(s.status || "unknown") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("运行时间", "Uptime") +
'</span><span class="val"><span id="uptime-val">' +
(state.startedAt ? fmtUptime(Date.now() - state.startedAt) : "-") +
"</span></span></div>" +
'<div class="kv-row"><span class="key">' +
__("插件", "Plugins") +
'</span><span class="val">' +
((k?.plugins || []).length || 0) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("版本", "Version") +
'</span><span class="val">' +
escHtml(s.version || "0.1.0") +
"</span></div></div>";
if (k) {
html +=
'<div class="card"><h2>' +
__("LLM 状态", "LLM Status") +
"</h2>" +
'<div class="kv-row"><span class="key">Provider</span><span class="val">' +
(k.llm?.provider || __("未配置", "Not configured")) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("可用源", "Sources") +
'</span><span class="val">' +
(k.llm?.sources || 0) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("状态", "Status") +
'</span><span class="val"><span class="status-dot ' +
(k.llm?.available ? "dot-green" : "dot-red") +
'"></span>' +
(k.llm?.available
? __("运行中", "Running")
: __("不可用", "Unavailable")) +
"</span></div></div>";
html +=
'<div class="card"><h2>' +
__("记忆状态", "Memory Status") +
"</h2>" +
'<div class="kv-row"><span class="key">' +
__("图记忆", "Graph Memory") +
'</span><span class="val"><span class="status-dot ' +
(k.memory?.available ? "dot-green" : "dot-gray") +
'"></span>' +
(k.memory?.available
? k.memory.entity_count +
__(" 实体, ", " entities, ") +
k.memory.relation_count +
__(" 关系", " relations")
: __("未初始化", "Uninitialized")) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("文档记忆", "Document Memory") +
'</span><span class="val">' +
(k.documents?.available
? k.documents.doc_count + __(" 文档", " docs")
: __("未初始化", "Uninitialized")) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("文本记忆", "Text Memory") +
'</span><span class="val">' +
(k.text_memory?.available
? k.text_memory.file_count + __(" 文件", " files")
: __("未初始化", "Uninitialized")) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("知识库", "Knowledge") +
'</span><span class="val">' +
(k.knowledge?.available
? k.knowledge.item_count + __(" 项", " items")
: __("未初始化", "Uninitialized")) +
"</span></div></div>";
html +=
'<div class="card"><h2>' +
__("运行时", "Runtime") +
"</h2>" +
'<div class="kv-row"><span class="key">Goroutines</span><span class="val">' +
(k.runtime?.goroutines || "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("内存", "Memory") +
'</span><span class="val">' +
(k.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">Go ' +
__("版本", "Version") +
'</span><span class="val">' +
escHtml(k.runtime?.go_version || "-") +
"</span></div></div>";
}
document.getElementById("tab-overview").innerHTML = html;
}
// ===== Chat =====
var _chatLayoutBuilt = false;
function buildChatLayout() {
var cont = document.getElementById("tab-chat");
var k = state.kernel || {};
var html = '<div class="chat-layout">';
html +=
'<div class="chat-tabs">' +
'<span class="active" onclick="switchChatPanel(\'chat\',this)">' +
__("对话", "Chat") +
"</span>" +
'<span onclick="switchChatPanel(\'starmap\',this)">' +
__("星图", "Star Map") +
"</span>" +
'<span onclick="switchChatPanel(\'terminal\',this)">' +
__("终端", "Terminal") +
"</span>" +
'<span onclick="switchChatPanel(\'cmd\',this)">' +
__("运行中命令", "Running Commands") +
"</span>" +
'<span onclick="switchChatPanel(\'memory\',this)">' +
__("记忆", "Memory") +
"</span>" +
'<span onclick="switchChatPanel(\'context\',this)">' +
__("上下文", "Context") +
"</span>" +
'<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="card"><h2>' +
__("对话", "Chat") +
' <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:none">' +
escHtml(state.chatStage || "") +
'</span></h2><div class="chat-messages" id="chat-msgs">';
if (state.messages.length === 0) {
html +=
'<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' +
__(
"开始对话以测试 Agent 回复",
"Start a conversation to test Agent replies",
) +
"</p></div>";
}
html +=
"</div>" +
'<div class="chat-input-row">' +
'<input id="chat-input" placeholder="' +
__("输入消息...", "Type a message...") +
'" onkeydown="if(event.key==\'Enter\')sendChat()">' +
'<button class="btn" onclick="interruptChat()" id="chat-stop-btn" style="display:none;background:var(--danger, #d1383d);color:#fff">' +
__("停止", "Stop") +
"</button>" +
'<button class="btn btn-primary" onclick="sendChat()" id="chat-send-btn">' +
__("发送", "Send") +
"</button>" +
"</div></div></div></div>";
html +=
'<div class="chat-panel" id="chat-panel-starmap"><div class="card"><h2>' +
__("星图", "Star Map") +
"</h2>" +
'<div id="sm-container-chat" style="display:flex;align-items:center;justify-content:center;min-height:480px"><div class="loading-spinner"></div></div></div></div>';
html +=
'<div class="chat-panel" id="chat-panel-terminal"><div class="card"><h2>' +
__("终端", "Terminal") +
' <span id="term-count-badge" class="badge badge-blue">0</span></h2>' +
'<div id="term-list" style="max-height:60vh;overflow-y:auto;font-size:12px"></div></div></div>';
html +=
'<div class="chat-panel" id="chat-panel-cmd"><div class="card"><h2>' +
__("命令历史", "Command History") +
' <span id="cmd-count-badge" class="badge badge-blue">0</span></h2>' +
'<div id="cmd-list" style="max-height:60vh;overflow-y:auto;font-size:12px"></div></div></div>';
html +=
'<div class="chat-panel" id="chat-panel-memory"><div class="card"><h2>' +
__("记忆", "Memory") +
"</h2>" +
'<div class="kv-row"><span class="key">' +
__("实体", "Entities") +
'</span><span class="val">' +
(k?.memory?.entity_count || "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("关系", "Relations") +
'</span><span class="val">' +
(k?.memory?.relation_count || "-") +
"</span></div>" +
'<div style="margin-top:8px">' +
'<input id="mem-query" placeholder="' +
__("关键词查询", "Keyword query") +
'">' +
'<button class="btn btn-primary btn-sm" onclick="queryMemoryChat()">' +
__("查询", "Query") +
"</button>" +
'</div><div id="mem-result-chat" style="margin-top:8px;max-height:180px;overflow:auto"></div>' +
"</div></div>";
html +=
'<div class="chat-panel" id="chat-panel-context"><div class="card"><h2>' +
__("上下文", "Context") +
"</h2>" +
'<div style="margin-top:8px">' +
'<input id="ctx-query" placeholder="' +
__("输入当前话题", "Enter current topic") +
'">' +
'<button class="btn btn-primary btn-sm" onclick="queryMemoryContext()">' +
__("获取上下文", "Get Context") +
"</button>" +
'</div><div id="ctx-result" style="margin-top:8px;max-height:200px;overflow:auto"></div>' +
"</div></div>";
html +=
'<div class="chat-panel" id="chat-panel-knowledge"><div class="card"><h2>' +
__("知识", "Knowledge") +
"</h2>" +
'<div class="kv-row"><span class="key">' +
__("项目", "Items") +
'</span><span class="val">' +
(k?.knowledge?.item_count || "-") +
"</span></div>" +
'<div style="margin-top:8px">' +
'<input id="know-query" placeholder="' +
__("搜索知识", "Search knowledge") +
'">' +
'<button class="btn btn-primary btn-sm" onclick="searchKnowledgeChat()">' +
__("搜索", "Search") +
"</button>" +
'</div><div id="know-result-chat" style="margin-top:8px;max-height:180px;overflow:auto"></div>' +
'<div style="margin-top:12px;border-top:1px solid var(--border-color);padding-top:8px">' +
'<input id="know-name" placeholder="' +
__("知识名称", "Knowledge name") +
'" style="margin-bottom:4px">' +
'<textarea id="know-content" placeholder="' +
__("内容", "Content") +
'" style="min-height:50px;margin-bottom:4px"></textarea>' +
'<button class="btn btn-primary btn-sm" onclick="createKnowledgeChat()">' +
__("创建", "Create") +
"</button>" +
"</div></div></div>";
html += "</div>";
cont.innerHTML = html;
_chatLayoutBuilt = true;
}
var CHAN_COLORS = [
"#e08a5f",
"#5f9fe0",
"#6bbf8f",
"#c06bbf",
"#d9a13b",
"#5fb3bf",
"#b06b6b",
"#7f8ce0",
];
function chanColor(src) {
var h = 0;
for (var i = 0; i < src.length; i++) {
h = (h * 31 + src.charCodeAt(i)) >>> 0;
}
return CHAN_COLORS[h % CHAN_COLORS.length];
}
function chanLetter(src) {
var s = (src || "").trim();
if (!s) return "C";
var ch = s.charAt(0).toUpperCase();
return /[A-Za-z0-9]/.test(ch) ? ch : "C";
}
function renderChat() {
if (!_chatLayoutBuilt) {
buildChatLayout();
renderChatStarmap();
renderTerminals();
renderCmdHistory();
}
var msgsEl = document.getElementById("chat-msgs");
if (!msgsEl) return;
if (!msgsEl._stickBound) {
msgsEl._stickBound = true;
msgsEl.addEventListener(
"scroll",
function () {
state.chatStick =
msgsEl.scrollHeight -
msgsEl.scrollTop -
msgsEl.clientHeight <
80;
},
{ passive: true },
);
msgsEl.addEventListener(
"load",
function () {
if (state.chatStick === true) {
msgsEl.scrollTop = msgsEl.scrollHeight;
}
},
true,
);
}
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("|") +
"|L" +
(state.chatLoading ? "1" : "0") +
"|P" +
(state.pendingTools || []).join(",");
if (msgsEl._chatSig === sig && msgsEl.childElementCount > 0) {
if (state.chatStick === true) {
msgsEl.scrollTop = msgsEl.scrollHeight;
}
return;
}
msgsEl._chatSig = sig;
var prevPending = msgsEl._lastPending || [];
var newPending = (state.pendingTools || []).slice();
if (state.chatLoading) {
var lastM = msgs.length ? msgs[msgs.length - 1] : null;
if (lastM && lastM.role === "assistant") {
(lastM.tool_calls || []).forEach(function (tc) {
if (!tc.result && tc.status !== "denied") {
var nm = tc.tool || tc.name || "";
if (newPending.indexOf(nm) === -1) newPending.push(nm);
}
});
}
}
var newlyDone = prevPending.filter(function (n) {
return newPending.indexOf(n) === -1;
});
msgsEl._lastPending = newPending;
var streamingLast = !!(
state.chatLoading &&
lastM &&
lastM.role === "assistant" &&
!lastM._final
);
function pillHtml() {
var s = "";
newPending.forEach(function (nm) {
var anim = prevPending.indexOf(nm) !== -1 ? "" : " pill-in";
s +=
'<span class="thinking-tool' +
anim +
'" data-tool="' +
escHtml(nm) +
'">' +
'<svg viewBox="0 0 24 24" width="11" height="11" 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>' +
escHtml(nm) +
"</span>";
});
return s;
}
var html = "";
if (msgs.length === 0) {
html =
'<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' +
__(
"开始对话以测试 Agent 回复",
"Start a conversation to test Agent replies",
) +
"</p></div>";
} else {
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") {
c = escHtml(c);
} else {
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 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 body = rc + tcs;
var growCls = m._grow ? " grow-in" : "";
if (m._grow) m._grow = false;
var isStreamingLast = i === msgs.length - 1 && streamingLast;
if (isStreamingLast) {
var liveRow =
'<span class="live-spinner"></span>' +
(newPending.length
? '<span class="thinking-tools">' + pillHtml() + "</span>"
: "");
if (c) {
body +=
'<div class="msg-bubble' +
growCls +
'">' +
liveRow +
'<div class="text">' +
c +
"</div></div>";
c = "";
} else {
body += '<div class="msg-bubble">' + liveRow + "</div>";
}
} else if (c) {
body +=
'<div class="msg-bubble' +
growCls +
'"><div class="text">' +
c +
"</div></div>";
}
if (role === "system") {
html +=
'<div class="msg msg-system"><div class="msg-bubble">' +
(c || "") +
"</div></div>";
} else if (isChan) {
html +=
'<div class="msg msg-channel">' +
'<div class="msg-avatar chan-avatar" style="background:' +
chanColor(m.source) +
'">' +
chanLetter(m.source) +
"</div>" +
'<div class="msg-content"><div class="msg-chan-name">' +
escHtml(m.source) +
"</div>" +
body +
"</div></div>";
} 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="小宅">';
html +=
'<div class="msg msg-' +
role +
'">' +
'<div class="msg-avatar">' +
(role === "user" ? userAvatar : aiAvatar) +
"</div>" +
'<div class="msg-content">' +
body +
"</div>" +
"</div>";
}
});
}
if (state.chatLoading && !streamingLast) {
var aiAvatarL =
'<img src="/mascot.webp" alt="小宅">';
html +=
'<div class="msg msg-assistant"><div class="msg-avatar">' +
aiAvatarL +
'</div><div class="msg-content"><div class="msg-bubble">' +
'<span class="live-spinner"></span>' +
(newPending.length
? '<span class="thinking-tools">' + pillHtml() + "</span>"
: "") +
"</div></div></div>";
}
msgsEl.innerHTML = html;
if (state.chatStick !== false) {
msgsEl.scrollTop = msgsEl.scrollHeight;
}
updateChatBadge();
}
function updateChatBadge() {
var badge = document.getElementById("chat-stage");
if (!badge) return;
badge.textContent = state.chatStage || "";
badge.style.display = "none";
}
// 流式渲染分发流式增量只更新最后一条正文vs 全量重建
var _rerenderTimer = null;
function rerenderChat(full) {
// 流式中且非强制全量:走增量路径(防抖合并 chunk只更新最后一条消息节点
if (!full && state.chatLoading) {
var msgs = state.messages;
var last = msgs.length ? msgs[msgs.length - 1] : null;
if (last && last.role === "assistant" && !last._final) {
if (_rerenderTimer) return; // 已有排程的增量更新
_rerenderTimer = setTimeout(function () {
_rerenderTimer = null;
renderChatStreamChunk();
}, 90);
return;
}
}
// 非流式(完成/工具/历史变化):全量重渲(含防抖合并)
if (_rerenderTimer) clearTimeout(_rerenderTimer);
_rerenderTimer = setTimeout(function () {
_rerenderTimer = null;
renderChat();
if (full) {
renderChatStarmap();
renderTerminals();
renderCmdHistory();
}
}, 90);
}
// 流式增量渲染(移植自 GUI仅更新最后一条 assistant 消息的正文与思考预览,
// 不重建 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;
if (!msgsEl || !last) return;
var el = msgsEl.lastElementChild;
if (!el) { renderChat(); return; }
var textEl = el.querySelector(".msg-bubble .text");
var c = last.content || "";
if (textEl && c) {
var now = Date.now();
var lastParse = el.__lastParse || 0;
var lastLen = el.__lastLen || 0;
if (c.length - lastLen > 200 || now - lastParse > 300) {
textEl.innerHTML = renderMd(c);
el.__lastParse = now;
el.__lastLen = c.length;
} else {
var tail = c.slice(lastLen);
if (tail) textEl.appendChild(document.createTextNode(tail));
el.__lastLen = c.length;
}
if (state.chatStick !== false) {
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);
if (state.chatStick !== false) {
try { msgsEl.scrollTop = msgsEl.scrollHeight; } catch (e) {}
}
return;
}
// 结构变化兜底:全量
renderChat();
}
function toggleToolCall(el) {
var d = el.querySelector(".tc-detail");
if (!d) return;
var open = d.style.display !== "none";
d.style.display = open ? "none" : "block";
if (open) {
el.classList.remove("open");
} else {
el.classList.add("open");
}
}
function renderReasoningCard(text, isStreaming) {
var preview =
typeof marked !== "undefined"
? text.replace(/[\s\n]+/g, " ").slice(0, 60)
: escHtml(text).replace(/<[^>]+>/g, " ").slice(0, 60);
return (
'<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") + '">' +
(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) {
var card = el.closest(".reasoning-card");
if (!card) return;
var body = card.querySelector(".reasoning-body");
if (!body) return;
var open = body.style.display !== "none";
body.style.display = open ? "none" : "block";
if (open) {
card.classList.remove("open");
} else {
card.classList.add("open");
}
}
function renderChatStarmap() {
var cont = document.getElementById("sm-container-chat");
if (!cont) return;
if (
window._THREE_FAILED ||
(!window.THREE && window._THREE_FAILED !== undefined)
) {
cont.innerHTML =
'<p style="color:var(--text-muted);padding:20px;text-align:center;font-size:11px">' +
__(
"3D 星图不可用CDN 加载失败)",
"Star map unavailable (CDN load failed)",
) +
"</p>";
state.starmapInit = true;
state.starmapLoading = false;
return;
}
if (!window.THREE) {
cont.innerHTML =
'<div style="display:flex;align-items:center;justify-content:center;height:100%;padding:20px"><div class="loading-spinner"></div></div>';
state.starmapInit = false;
state.starmapLoading = false;
return;
}
if (cont.querySelector("canvas")) {
var rect = cont.getBoundingClientRect();
if (starmapRen && rect.width > 0)
starmapRen.setSize(rect.width, Math.max(rect.height, 250));
return;
}
if (state.starmapInit) {
if (starmapRen) {
var rect = cont.getBoundingClientRect();
if (rect.width > 0)
starmapRen.setSize(rect.width, Math.max(rect.height, 250));
cont.appendChild(starmapRen.domElement);
starmapRen.domElement.style.display = "block";
} else {
// starmapRen was destroyed (e.g. re-render cycle), restart
state.starmapInit = false;
state.starmapLoading = false;
}
return;
}
if (state.starmapLoading) return;
state.starmapLoading = true;
loadChatStarmapData();
}
async function loadChatStarmapData() {
try {
var resp = await api("/memory/graph");
if (
!resp ||
!resp.success ||
!resp.data ||
!resp.data.nodes ||
resp.data.nodes.length === 0
) {
document.getElementById("sm-container-chat").innerHTML =
'<p style="color:var(--text-muted);padding:20px;text-align:center">' +
__("暂无记忆数据", "No memory data") +
"</p>";
state.starmapInit = true;
state.starmapLoading = false;
return;
}
var d = resp.data;
starmapNodes = d.nodes || [];
starmapEdges = d.edges || [];
state.starmapInit = true;
state.starmapLoading = false;
initChatStarmap();
} catch (e) {
document.getElementById("sm-container-chat").innerHTML =
'<p style="color:var(--text-muted);padding:20px;text-align:center">' +
__("加载失败", "Load failed") +
"</p>";
state.starmapInit = true;
state.starmapLoading = false;
}
}
function initChatStarmap() {
var cont = document.getElementById("sm-container-chat");
if (!cont) return;
var rect = cont.getBoundingClientRect();
var w = Math.max(rect.width || 300, 100);
var h = Math.max(rect.height || 250, 100);
if (starmapRen) {
starmapRen.setSize(w, h);
cont.appendChild(starmapRen.domElement);
starmapRen.domElement.style.display = "block";
return;
}
starmapScene = new THREE.Scene();
starmapScene.fog = new THREE.FogExp2(0x0a0a1a, 0.015);
starmapCam = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000);
starmapCam.position.set(0, 20, 40);
starmapRen = new THREE.WebGLRenderer({ antialias: true, alpha: true });
starmapRen.setSize(w, h);
starmapRen.setPixelRatio(Math.min(window.devicePixelRatio, 2));
starmapRen.setClearColor(0x0a0a1a, 1);
cont.innerHTML = "";
cont.appendChild(starmapRen.domElement);
starmapCtrl = new THREE.OrbitControls(
starmapCam,
starmapRen.domElement,
);
starmapCtrl.enableDamping = true;
starmapCtrl.dampingFactor = 0.05;
starmapCtrl.rotateSpeed = 0.5;
starmapCtrl.zoomSpeed = 0.8;
var al = new THREE.AmbientLight(0x444466, 0.6);
starmapScene.add(al);
var dl = new THREE.DirectionalLight(0xffffff, 0.8);
dl.position.set(50, 100, 50);
starmapScene.add(dl);
createStarField();
createNebula();
buildChatStarmapGraph();
starmapRen.domElement.addEventListener("mousemove", onStarmapMove);
starmapRen.domElement.addEventListener("click", onStarmapClick);
window.addEventListener("resize", onStarmapResize);
if (starmapRaf) cancelAnimationFrame(starmapRaf);
starmapAnimate();
}
function buildChatStarmapGraph() {
starmapNodeMeshes.forEach(function (m) {
starmapScene.remove(m);
});
starmapEdgeLines.forEach(function (l) {
starmapScene.remove(l);
});
starmapNodeMeshes = [];
starmapEdgeLines = [];
if (starmapNodes.length === 0) return;
// Calculate node degrees for leaf node detection
var nodeDegs = {};
starmapNodes.forEach(function (n) {
nodeDegs[n.id] = 0;
});
starmapEdges.forEach(function (e) {
nodeDegs[e.source_id] = (nodeDegs[e.source_id] || 0) + 1;
nodeDegs[e.target_id] = (nodeDegs[e.target_id] || 0) + 1;
});
var nodeMap = {};
starmapNodes.forEach(function (n) {
nodeMap[n.id] = n;
});
var sorted = starmapNodes.slice().sort(function (a, b) {
return (b.mention_count || 0) - (a.mention_count || 0);
});
var mc = sorted.map(function (n) {
return n.mention_count || 0;
});
var maxMc = Math.max(...mc, 1),
minMc = Math.min(...mc, 0),
rng = maxMc - minMc || 1;
// Layout positions
var pos = {};
var baseR = 15,
maxR = 80;
var total = sorted.length;
var acc = 0;
sorted.forEach(function (n, i) {
var m = n.mention_count || 0,
mn = rng > 0 ? (m - minMc) / rng : 0;
var radius = baseR + mn * (maxR - baseR);
var baseStep = (Math.PI * 2) / total;
var extra = mn * baseStep * 2;
var angle = acc + extra / 2;
acc += baseStep + extra;
pos[n.id] = {
x: radius * Math.cos(angle),
y: (Math.random() - 0.5) * (10 + mn * 20),
z: radius * Math.sin(angle),
mn: mn,
rad: radius,
};
});
// Leaf nodes (degree 1) reposition near parent
sorted.forEach(function (n) {
var deg = nodeDegs[n.id] || 0;
if (deg !== 1) return;
var edge = starmapEdges.find(function (e) {
return e.source_id === n.id || e.target_id === n.id;
});
if (!edge) return;
var parentId =
edge.source_id === n.id ? edge.target_id : edge.source_id;
if (!pos[parentId]) return;
var pp = pos[parentId];
var m = n.mention_count || 0,
mn = rng > 0 ? (m - minMc) / rng : 0;
var off = 6 + mn * 8 + Math.random() * 4;
var a2 = Math.random() * Math.PI * 2;
pos[n.id] = {
x: pp.x + off * Math.cos(a2),
y: pp.y + (Math.random() - 0.5) * (4 + mn * 6),
z: pp.z + off * Math.sin(a2),
mn: mn,
rad: off,
};
});
// Force-directed simulation
for (var it = 0; it < 50; it++) {
var ids = Object.keys(pos);
// Repulsion
for (var i = 0; i < ids.length; i++) {
for (var j = i + 1; j < ids.length; j++) {
var a = pos[ids[i]],
b = pos[ids[j]];
var dx = a.x - b.x,
dy = a.y - b.y,
dz = a.z - b.z,
d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1;
var rf = 0.5 + (a.mn + b.mn) * 0.5;
if (d < 25) {
var force = (0.06 * rf) / Math.max(d, 0.5);
a.x += (dx / d) * force;
a.y += (dy / d) * force;
a.z += (dz / d) * force;
b.x -= (dx / d) * force;
b.y -= (dy / d) * force;
b.z -= (dz / d) * force;
}
}
}
// Attraction along edges
starmapEdges.forEach(function (e) {
var a = pos[e.source_id],
b = pos[e.target_id];
if (!a || !b) return;
var dx = b.x - a.x,
dy = b.y - a.y,
dz = b.z - a.z,
d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1;
var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3);
if (d > 20) {
var force = 0.04 * af;
a.x += (dx / d) * force;
a.y += (dy / d) * force;
a.z += (dz / d) * force;
b.x -= (dx / d) * force;
b.y -= (dy / d) * force;
b.z -= (dz / d) * force;
}
});
// Centering constraint
ids.forEach(function (id) {
var p = pos[id];
var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z);
var maxA = maxR * 1.5;
if (dist > maxA) {
var s = maxA / dist;
p.x *= s;
p.y *= s;
p.z *= s;
}
});
}
// Create nodes
starmapNodes.forEach(function (n) {
var p = pos[n.id];
if (!p) return;
var mn = n.mention_count || 0,
mnr = rng > 0 ? (mn - minMc) / rng : 0;
var rad = 0.5 + mnr * 2.0;
var col = smTypeColors[n.type] || 0xcccccc;
var ei = 0.3 + mnr * 0.7;
var g = new THREE.SphereGeometry(rad, 16, 12);
var mat = new THREE.MeshPhongMaterial({
color: col,
emissive: col,
emissiveIntensity: ei,
shininess: 30,
});
var mesh = new THREE.Mesh(g, mat);
mesh.position.set(p.x, p.y, p.z);
mesh.userData.nodeData = n;
mesh.userData.nodeId = n.id;
mesh.userData.baseEmissive = ei;
// Glow sphere
var gr = rad * 1.2 + mnr * 0.5;
var gg = new THREE.SphereGeometry(gr, 16, 12);
var gm = new THREE.MeshBasicMaterial({
color: col,
transparent: true,
opacity: 0.12 + mnr * 0.08,
side: THREE.BackSide,
blending: THREE.AdditiveBlending,
});
var gs = new THREE.Mesh(gg, gm);
mesh.add(gs);
mesh.userData.glowSphere = gs;
// Label sprite
var canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 64;
var ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, 256, 64);
ctx.font = "Bold 24px Courier New";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "#aaccff";
ctx.shadowBlur = 8;
ctx.fillStyle = "#ffffff";
ctx.fillText((n.name || n.id).substring(0, 12), 128, 32);
var tex = new THREE.CanvasTexture(canvas);
tex.needsUpdate = true;
var spMat = new THREE.SpriteMaterial({
map: tex,
transparent: true,
opacity: 0.9,
depthTest: false,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
var sprite = new THREE.Sprite(spMat);
sprite.scale.set(8, 2, 1);
sprite.position.y = rad + 2;
mesh.add(sprite);
starmapScene.add(mesh);
starmapNodeMeshes.push(mesh);
});
// Create edges
starmapEdges.forEach(function (e) {
var a = pos[e.source_id],
b = pos[e.target_id];
if (!a || !b) return;
var col =
smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466;
var pts = [
new THREE.Vector3(a.x, a.y, a.z),
new THREE.Vector3(b.x, b.y, b.z),
];
var geo = new THREE.BufferGeometry().setFromPoints(pts);
var mat = new THREE.LineBasicMaterial({
color: col,
transparent: true,
opacity: 0.4,
});
var line = new THREE.Line(geo, mat);
line.userData = { edgeId: e.id, edgeData: e };
starmapScene.add(line);
starmapEdgeLines.push(line);
});
}
// 回合收尾:由 SSE 事件agent_output final / reset 帧)或 watchdog 驱动。
// POST 结束 ≠ 回合结束agent 可能还在生成(排队+长生成),提前复位
// chatLoading 会让后续 delta 走全量重建、停止按钮消失、用户误发重复消息。
function endChatTurn() {
if (!state.chatLoading) return;
state.chatLoading = false;
state.chatStage = "";
if (state._turnWatchdog) {
clearTimeout(state._turnWatchdog);
state._turnWatchdog = null;
}
var btn = document.getElementById("chat-send-btn");
if (btn) {
btn.disabled = false;
btn.textContent = __("发送", "Send");
}
var sb = document.getElementById("chat-stop-btn");
if (sb) sb.style.display = "none";
rerenderChat(true);
}
// 回合看门狗POST 已 abort 且 SSE 迟迟无终帧时兕底收尾(连接不稳/事件丢失),
// 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。
function armTurnWatchdog() {
if (state._turnWatchdog) clearTimeout(state._turnWatchdog);
state._turnWatchdog = setTimeout(function () {
state._turnWatchdog = null;
if (state.chatLoading) {
endChatTurn();
toast(
__(
"长时间未收到回复,连接可能不稳定;回复可能已生成,可刷新页面查看",
"No reply received for a long time; the reply may have been generated, refresh to check",
),
true,
);
}
}, 120000);
}
async function sendChat() {
var inp = document.getElementById("chat-input");
var btn = document.getElementById("chat-send-btn");
var stopBtn = document.getElementById("chat-stop-btn");
var text = inp.value.trim();
if (!text || state.chatLoading) return;
state.chatStick = true;
state.chatFinalIdx = -1;
state.messages.push({ role: "user", content: text });
inp.value = "";
rerenderChat(true);
state.chatLoading = true;
state.chatStage = __("等待AI回复...", "Waiting for AI...");
btn.disabled = true;
btn.textContent = "";
if (stopBtn) stopBtn.style.display = ""; // 生成期间可停止
rerenderChat(true);
// 触发式 POST短超时仅确认受理回复靠 SSE 流式渲染(对齐 GUI 行为)。
try {
var ctrl = new AbortController();
var ackTimer = setTimeout(function () {
ctrl.abort();
}, 15000);
var r = null;
try {
r = await api("/chat", {
method: "POST",
body: JSON.stringify({ message: text }),
signal: ctrl.signal,
});
} catch (ackErr) {
// 同步超时/失败:不阻塞 UI等 SSE 兑底;明确提示"可能已发送"
console.warn("[sendChat] trigger failed: " + ackErr.message);
toast(
__(
"请求超时(可能已发送,请稍候或在收到回复前勿重复发送)",
"Request timeout (may have been sent; wait for reply before resending)",
),
true,
);
r = null;
} finally {
clearTimeout(ackTimer);
}
var last = state.messages[state.messages.length - 1];
if (r && r.response) {
if (last && last.role === "assistant" && last._streaming) {
last.content = r.response || __("(无响应)", "(no response)");
last._grow = true;
if (!last.reasoning_content) {
last.reasoning_content = r.reasoning_content || "";
}
last._final = true;
delete last._streaming;
} else {
state.messages.push({
role: "assistant",
content: r.response || __("(无响应)", "(no response)"),
reasoning_content: r.reasoning_content,
tool_calls:
last && last.role === "assistant" && last.tool_calls
? last.tool_calls
: [],
_final: true,
_grow: true,
});
}
state.chatFinalIdx = state.messages.length - 1;
}
rerenderChat(true);
} catch (e) {
// 真实错误(非受理超时):展示错误信息
if (!String(e.message || "").includes("aborted")) {
state.messages.push({
role: "assistant",
content: __("错误: ", "Error: ") + e.message,
_final: true,
});
rerenderChat(true);
toast(__("请求失败: ", "Request failed: ") + e.message, true);
}
} finally {
if (r && r.response) {
// 同步兜底已拿到完整回复:回合结束
endChatTurn();
} else {
// 触发式受理POST 已 abort/失败):回合仍打开,等 SSE 流式渲染;
// 由 agent_output final / reset 帧 / watchdog 收尾
armTurnWatchdog();
}
}
}
// 停止生成 / 发送中断消息。核心拦截语义:有 LLM 在跑则取消当前
// 请求并以 [中断消息] 重启轮次;无则在跑则作为普通消息处理。
async function interruptChat() {
try {
await api("/chat/interrupt", {
method: "POST",
body: JSON.stringify({}),
});
toast(__("已发送中断信号", "Interrupt signal sent"));
} catch (e) {
toast(__("中断失败: ", "Interrupt failed: ") + e.message, true);
}
}
async function queryMemoryChat() {
var q = document.getElementById("mem-query")?.value;
var r = document.getElementById("mem-result-chat");
if (!r || !q) return;
r.innerHTML = '<div class="loading"></div>';
try {
var data = await api(
"/memory?q=" + encodeURIComponent(q) + "&depth=2",
);
r.innerHTML =
'<pre style="font-size:11px">' +
escHtml(JSON.stringify(data, null, 2)) +
"</pre>";
} catch (e) {
r.innerHTML =
'<p style="color:#fca5a5">' +
__("查询失败: ", "Query failed: ") +
escHtml(e.message) +
"</p>";
}
}
async function queryMemoryContext() {
var q = document.getElementById("ctx-query")?.value;
var r = document.getElementById("ctx-result");
if (!r) return;
r.innerHTML = '<div class="loading"></div>';
try {
var data = await api(
"/memory/context?q=" + encodeURIComponent(q || ""),
);
var ctx = data?.context || __("无上下文", "No context");
var summary = data?.summary || "";
var entities = data?.entities || [];
var tk = data?.token_estimate || 0;
var html = '<div style="font-size:11px">';
if (summary)
html +=
'<div class="kv-row"><span class="key">' +
__("摘要", "Summary") +
'</span><span class="val">' +
escHtml(summary) +
"</span></div>";
html +=
'<div class="kv-row"><span class="key">Token ' +
__("预估", "Estimate") +
'</span><span class="val">' +
tk +
"</span></div>";
if (entities.length) {
html +=
'<div class="kv-row"><span class="key">' +
__("实体", "Entities") +
'</span><span class="val">' +
entities
.map(function (e) {
return escHtml(e.name || e.id || "");
})
.join(", ") +
"</span></div>";
}
html +=
'<pre style="font-size:11px;margin-top:8px">' +
escHtml(ctx) +
"</pre></div>";
r.innerHTML = html;
} catch (e) {
r.innerHTML =
'<p style="color:#fca5a5">' +
__("获取失败: ", "Get failed: ") +
escHtml(e.message) +
"</p>";
}
}
async function searchKnowledgeChat() {
var q = document.getElementById("know-query")?.value;
var r = document.getElementById("know-result-chat");
if (!r || !q) return;
r.innerHTML = '<div class="loading"></div>';
try {
var data = await api("/knowledge?q=" + encodeURIComponent(q));
r.innerHTML =
'<pre style="font-size:11px">' +
escHtml(JSON.stringify(data, null, 2)) +
"</pre>";
} catch (e) {
r.innerHTML =
'<p style="color:#fca5a5">' +
__("搜索失败: ", "Search failed: ") +
escHtml(e.message) +
"</p>";
}
}
async function createKnowledgeChat() {
var name = document.getElementById("know-name")?.value;
var content = document.getElementById("know-content")?.value;
if (!name || !content) {
toast(
__("名称和内容不能为空", "Name and content cannot be empty"),
true,
);
return;
}
try {
var r = await api("/knowledge", {
method: "POST",
body: JSON.stringify({ name: name, content: content }),
});
if (r.status || r.id) {
toast(
__("知识「", 'Knowledge "') + name + __("」已创建", '" created'),
);
document.getElementById("know-name").value = "";
document.getElementById("know-content").value = "";
} else {
toast(__("创建失败", "Create failed"), true);
}
} catch (e) {
toast(__("创建失败: ", "Create failed: ") + e.message, true);
}
}
function switchChatPanel(tab, el) {
var panels = {
chat: document.getElementById("chat-panel-chat"),
starmap: document.getElementById("chat-panel-starmap"),
terminal: document.getElementById("chat-panel-terminal"),
cmd: document.getElementById("chat-panel-cmd"),
memory: document.getElementById("chat-panel-memory"),
context: document.getElementById("chat-panel-context"),
knowledge: document.getElementById("chat-panel-knowledge"),
};
Object.keys(panels).forEach(function (k) {
var p = panels[k];
if (p) p.classList.toggle("active", k === tab);
});
if (el) {
var parent = el.parentElement;
if (parent) {
Array.from(parent.children).forEach(function (ch) {
ch.classList.remove("active");
});
el.classList.add("active");
}
}
if (tab === "starmap") {
if (!state.starmapInit) {
state.starmapLoading = false;
renderChatStarmap();
} else if (starmapRen) {
var cont = document.getElementById("sm-container-chat");
if (cont) {
var rect = cont.getBoundingClientRect();
if (rect.width > 0)
starmapRen.setSize(rect.width, Math.max(rect.height, 250));
if (!cont.contains(starmapRen.domElement))
cont.appendChild(starmapRen.domElement);
}
}
}
if (tab === "chat") {
state.chatStick = true;
var msgsEl = document.getElementById("chat-msgs");
if (msgsEl) {
try {
msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: "smooth" });
} catch (e) {
msgsEl.scrollTop = msgsEl.scrollHeight;
}
}
}
if (tab === "context") queryMemoryContext();
}
async function loadChatHistory() {
try {
var data = await api("/chat/history");
if (data && data.messages) state.messages = data.messages;
} catch (e) {}
}
async function loadTerminals() {
try {
var data = await api("/terminals");
if (data && data.terminals) state.terminals = data.terminals;
} catch (e) {}
}
async function loadCmdHistory() {
try {
var data = await api("/cmd/history");
if (data && data.history) state.cmdHistory = data.history;
} catch (e) {}
}
function appendTermBuf(el, text) {
if (!text) return;
el.textContent += text;
if (el.textContent.length > 262144) {
el.textContent = el.textContent.slice(
el.textContent.length - 262144,
);
}
el.scrollTop = el.scrollHeight;
}
function renderTerminals() {
var r = document.getElementById("term-list");
var cnt = document.getElementById("term-count-badge");
if (!r) return;
var list = state.terminals || [];
if (cnt) cnt.textContent = list.length;
if (list.length === 0) {
r.innerHTML =
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' +
__("暂无终端会话", "No terminal sessions") +
"</p>";
return;
}
var html = "";
list.forEach(function (t, i) {
var detailId = "term-detail-" + i;
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) {
fullOut =
'<span style="color:#5c6672">' +
__("[终端暂无输出]", "[No terminal output]") +
"</span>";
} else {
fullOut = escHtml(fullOut);
}
html +=
'<div style="border:1px solid var(--border-color);border-radius:6px;margin-bottom:4px;font-size:11px">';
html +=
'<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;cursor:pointer;background:var(--bg-hover)" onclick="var d=document.getElementById(\'' +
detailId +
"');d.style.display=d.style.display==='none'?'block':'none'\">";
html +=
'<span style="font-family:monospace;font-size:10px;flex:1">' +
escHtml(t.id || "-") +
"</span>";
html +=
'<span style="flex:1;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
escHtml(t.command || "") +
"</span>";
html +=
'<span class="badge ' +
(running ? "badge-green" : "badge-red") +
'">' +
(running ? __("运行中", "Running") : __("已关闭", "Closed")) +
"</span>";
html +=
'<span style="color:var(--text-muted);font-size:10px">' +
escHtml(t.created_at || "") +
"</span>";
html += "</div>";
html +=
'<div id="' +
detailId +
'" style="display:none;padding:8px;border-top:1px solid var(--border-color);background:var(--bg-input)">';
html += '<div class="term-screen">';
html +=
'<div class="term-head"><span class="term-dot' +
(running ? "" : " stopped") +
'" id="term-dot-' +
escHtml(t.id) +
'"></span><span style="font-weight:600">' +
escHtml(t.id) +
"</span><span>" +
escHtml(t.command || "") +
'</span><span style="flex:1"></span><span>' +
escHtml(t.uptime || "") +
"</span></div>";
html +=
'<pre class="term-buf" id="term-buf-' +
escHtml(t.id) +
'">' +
fullOut +
"</pre>";
html += "</div></div></div>";
});
r.innerHTML = html;
}
function renderCmdHistory() {
var r = document.getElementById("cmd-list");
var cnt = document.getElementById("cmd-count-badge");
if (!r) return;
var running = (state.terminals || []).filter(function (t) {
return t.running;
});
if (cnt) cnt.textContent = running.length;
if (running.length === 0) {
r.innerHTML =
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' +
__(
"暂无运行中的命令",
"No running commands",
) +
"</p>";
return;
}
var html =
'<table style="font-size:10px"><tr><th>' +
__("命令", "Command") +
"</th><th>" +
__("状态", "Status") +
"</th><th>" +
__("运行时长", "Uptime") +
"</th></tr>";
running.forEach(function (t) {
var scr = (state.termScreens && state.termScreens[t.id]) || null;
var out = scr ? scr.output : t.output || "";
html +=
"<tr>" +
'<td style="font-family:monospace;max-width:200px;overflow:hidden;text-overflow:ellipsis">' +
escHtml(t.command || t.id || "") +
"</td>" +
'<td><span class="badge badge-green">' +
__("运行中", "Running") +
"</span></td>" +
'<td style="color:var(--text-muted);white-space:nowrap">' +
escHtml(t.uptime || "-") +
"</td>" +
"</tr>";
if (out) {
html +=
"<tr>" +
'<td colspan="3" style="padding:0"><pre style="margin:0;padding:4px 8px;max-height:120px;overflow:auto;background:var(--bg-input);border-radius:4px;font-size:10px;color:var(--text-secondary)">' +
escHtml(out.substring(0, 2000)) +
"</pre></td></tr>";
}
});
html += "</table>";
r.innerHTML = html;
}
function connectSSE() {
if (state.eventSource) {
console.log("[SSE] closing old connection");
state.eventSource.close();
}
var es;
try {
es = new EventSource("/api/v1/chat/events", {
withCredentials: true,
});
state.eventSource = es;
} catch (ex) {
console.error("[SSE] create failed", ex);
return;
}
if (!es) {
console.error("[SSE] es is null");
return;
}
console.log("[SSE] connected");
es.addEventListener("agent_output", function (e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
console.log(
"[SSE] agent_output received",
p.content ? p.content.substring(0, 50) : "(empty)",
);
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: att ? "" : p.content,
source: p.channel || "",
_final: true,
_grow: true,
attachment: att,
};
if (
state.chatFinalIdx >= 0 &&
state.chatFinalIdx < state.messages.length
) {
state.messages.splice(state.chatFinalIdx, 0, cm);
state.chatFinalIdx++;
} else {
state.messages.push(cm);
}
rerenderChat();
return;
}
var lastM2 = state.messages.length
? state.messages[state.messages.length - 1]
: null;
if (
lastM2 &&
lastM2.role === "assistant" &&
!lastM2._final
) {
// 聚合最终响应:覆盖 delta 累积的中间内容(以聚合为准,
// 含 stage 插件改写后的最终文本),并置 final 结束本轮流式。
lastM2._grow = true;
lastM2.content = p.content;
lastM2._final = true;
rerenderChat();
endChatTurn();
return;
}
if (
lastM2 &&
lastM2.role === "assistant" &&
lastM2._final &&
!lastM2.source
) {
// 去重:同一轮的重复帧(如 SSE 重连回放)内容相同则忽略;
// 内容不同视为新一轮输出(上一轮已 final 且无 source开新消息。
// 旧逻辑无条件 return 会丢弃多轮连发时新一轮的最终回复。
if (lastM2.content === p.content) {
endChatTurn();
return;
}
}
state.messages.push({
role: "assistant",
content: p.content,
_streaming: true,
_grow: true,
_final: true,
});
rerenderChat();
endChatTurn();
} catch (ex) {
console.error("[SSE] agent_output error", ex);
}
});
// token 级流式增量:逐块追加到当前回复内容(流式生成中);
// reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。
es.addEventListener("content_delta", function (e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
if (p.channel === "_consolidation_") return;
if (p.reset) {
var lm = state.messages.length
? state.messages[state.messages.length - 1]
: null;
if (lm && lm.role === "assistant" && !lm._final) {
lm._final = true;
rerenderChat();
}
// 轮次作废(用户中断):定格已显示内容;核心会以 [中断消息]
// 重启轮次,保持回合打开让确认回复继续流式渲染,
// 由其 agent_output final / watchdog 收尾。
armTurnWatchdog();
return;
}
if (!p.content) return;
state.chatStage = __("AI 回复中...", "AI replying...");
var last =
state.messages.length > 0
? state.messages[state.messages.length - 1]
: null;
if (!last || last.role !== "assistant" || last._final) {
state.messages.push({
role: "assistant",
content: "",
tool_calls: [],
_streaming: true,
_grow: true,
});
last = state.messages[state.messages.length - 1];
}
last.content += p.content;
rerenderChat();
} catch (ex) {}
});
es.addEventListener("terminal_output", function (e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
if (!p.terminal_id) return;
var id = p.terminal_id;
if (!state.termScreens) state.termScreens = {};
var scr =
state.termScreens[id] ||
(state.termScreens[id] = { output: "", running: true });
if (p.output) scr.output += p.output;
if (typeof p.running === "boolean") scr.running = p.running;
var bufel = document.getElementById("term-buf-" + id);
if (bufel) {
appendTermBuf(bufel, p.output || "");
var dot = document.getElementById("term-dot-" + id);
if (dot)
dot.className = "term-dot" + (scr.running ? "" : " stopped");
}
} catch (ex) {
console.error("[SSE] terminal_output error", ex);
}
});
es.addEventListener("reasoning", function (e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
if (p.channel === "_consolidation_") return;
if (p.content) {
state.chatStage = __("AI 思考中...", "AI thinking...");
var last =
state.messages.length > 0
? state.messages[state.messages.length - 1]
: null;
if (!last || last.role !== "assistant" || last._final) {
state.messages.push({
role: "assistant",
content: "",
reasoning_content: "",
tool_calls: [],
_streaming: true,
});
last = state.messages[state.messages.length - 1];
}
// 聚合 reasoning 帧携带全文:直接覆盖(若已有 delta 累积则等价)
last.reasoning_content = p.content;
rerenderChat();
}
} catch (ex) {}
});
// token 级流式增量逐块追加到当前思考内容reset 帧表示轮次作废
es.addEventListener("reasoning_delta", function (e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
if (p.channel === "_consolidation_") return;
if (p.reset) return; // 轮次作废(用户中断):清空累积中的思考
if (!p.content) return;
state.chatStage = __("AI 思考中...", "AI thinking...");
var last =
state.messages.length > 0
? state.messages[state.messages.length - 1]
: null;
if (!last || last.role !== "assistant" || last._final) {
state.messages.push({
role: "assistant",
content: "",
reasoning_content: "",
tool_calls: [],
_streaming: true,
});
last = state.messages[state.messages.length - 1];
}
last.reasoning_content = (last.reasoning_content || "") + p.content;
rerenderChat();
} catch (ex) {}
});
es.addEventListener("tool_call", function (e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
console.log("[SSE] tool_call", p);
if (!p.tool) return;
var last =
state.messages.length > 0
? state.messages[state.messages.length - 1]
: null;
if (!last || last.role !== "assistant" || last._final) {
state.messages.push({
role: "assistant",
content: "",
tool_calls: [],
_streaming: true,
});
last = state.messages[state.messages.length - 1];
}
if (!last.tool_calls) last.tool_calls = [];
last.tool_calls.push({
tool: p.tool,
name: p.tool,
args: p.args || {},
result: p.result || "",
status: p.status || "ok",
plugin: p.plugin || "",
});
var pidx = (state.pendingTools || []).indexOf(p.tool);
if (pidx !== -1) state.pendingTools.splice(pidx, 1);
state.chatStage = __("工具调用: ", "Tool: ") + (p.tool || "");
rerenderChat();
} catch (ex) {
console.error("[SSE] tool_call error", ex);
}
});
// 注:后端不发布 tool_result 类型事件(工具结果随 EventToolCall 一次发出),无此监听器。
es.addEventListener("stage", function (e) {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
var phase = p.phase || "";
var tool = p.tool || "";
if (p.channel === "_consolidation_") return;
console.log("[SSE] stage event", phase, tool);
if (phase === "pre_action") {
state.chatStage = __("AI 思考中...", "AI thinking...");
} else if (phase === "before_toolcall") {
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
if (!state.pendingTools) state.pendingTools = [];
state.pendingTools.push(tool);
rerenderChat();
}
} else if (phase === "before_output") {
state.chatStage = __("生成回复中...", "Generating response...");
}
var badge = document.getElementById("chat-stage");
if (badge) {
badge.textContent = state.chatStage || "";
badge.style.display = "none";
}
} catch (ex) {
console.error("[SSE] stage error", ex);
}
});
es.onopen = function () {
console.log("[SSE] connection opened");
};
es.onerror = function (e) {
console.error("[SSE] error", e);
setTimeout(connectSSE, 5000);
};
// Periodically refresh sidebar data
if (state._sidebarRefresh) clearInterval(state._sidebarRefresh);
state._sidebarRefresh = setInterval(async function () {
try {
var td = await api("/terminals");
if (td && td.terminals) state.terminals = td.terminals;
} catch (e) {}
try {
var ch = await api("/cmd/history");
if (ch && ch.history) state.cmdHistory = ch.history;
} catch (e) {}
renderTerminals();
renderCmdHistory();
}, 5000);
}
// ===== Plugins =====
function renderPlugins() {
var k = state.kernel;
var plugins = k?.plugins || [];
var tools = k?.tools || [];
var installed = state.installedPlugins || [];
var html =
'<div class="card"><h2>' +
__("安装插件", "Install Plugin") +
"</h2>" +
'<div style="display:flex;gap:8px;margin-bottom:8px">' +
'<input id="plugin-url" placeholder=".hmap ' +
__("包下载 URL", "Package URL") +
'" style="flex:1" onkeydown="if(event.key==\'Enter\')installPlugin()">' +
'<button class="btn btn-primary" onclick="installPlugin()">' +
__("安装", "Install") +
"</button></div>" +
'<div><input type="file" id="plugin-file" accept=".hmap" style="display:inline;width:auto" onchange="installPluginFile(this.files[0])">' +
'<label for="plugin-file" class="btn btn-ghost" style="cursor:pointer">' +
__("选择 .hmap 文件上传", "Upload .hmap file") +
"</label></div></div>";
var disabledNames = {};
(state.disabledPlugins || []).forEach(function (d) {
disabledNames[d.name] = d;
});
var installedNames = (state.installedPlugins || []).map(function (p) {
return p.name;
});
var allPluginNames = {};
plugins.forEach(function (p) {
allPluginNames[p.name] = true;
});
state.disabledPlugins.forEach(function (d) {
allPluginNames[d.name] = true;
});
html +=
'<div class="card"><h2>' +
__("所有插件", "All Plugins") +
" (" +
Object.keys(allPluginNames).length +
")</h2>";
var names = Object.keys(allPluginNames).sort();
if (names.length === 0) {
html +=
'<div class="empty-state"><p>' +
__("暂无插件", "No plugins") +
"</p></div>";
} else {
html +=
"<table><tr><th>" +
__("名称", "Name") +
"</th><th>" +
__("状态", "Status") +
"</th><th>" +
__("操作", "Actions") +
"</th></tr>";
names.forEach(function (name) {
var isExternal = installedNames.indexOf(name) >= 0;
var isDisabled = disabledNames[name];
var loaded = plugins.some(function (p) {
return p.name === name;
});
var statusHtml =
loaded && !isDisabled
? '<span class="badge badge-green">' +
__("已加载", "Loaded") +
"</span>"
: loaded && isDisabled
? '<span class="badge badge-yellow">' +
__("运行中(禁用待生效)", "Running (disable pending)") +
"</span>"
: isDisabled
? '<span class="badge badge-red">' +
__("已禁用", "Disabled") +
"</span>"
: '<span class="badge badge-gray">' +
__("未加载", "Not Loaded") +
"</span>";
var actionsHtml = "";
if (loaded) {
actionsHtml +=
'<button class="btn btn-sm btn-warning" onclick="disablePlugin(\'' +
escHtml(name) +
'\')" style="margin-right:4px">' +
__("禁用", "Disable") +
"</button>";
} else {
actionsHtml +=
'<button class="btn btn-sm btn-success" onclick="enablePlugin(\'' +
escHtml(name) +
'\')" style="margin-right:4px">' +
__("启用", "Enable") +
"</button>";
}
if (isExternal) {
actionsHtml +=
'<button class="btn btn-sm btn-danger" onclick="removePlugin(\'' +
escHtml(name) +
"')\">" +
__("卸载", "Unload") +
"</button>";
}
html +=
"<tr><td>" +
escHtml(name) +
"</td>" +
"<td>" +
statusHtml +
"</td>" +
"<td>" +
actionsHtml +
"</td></tr>";
});
html += "</table>";
}
html += "</div>";
if (installed.length > 0) {
html +=
'<div class="card"><h2>' +
__("已安装外部插件", "Installed Plugins") +
" (" +
installed.length +
")</h2>" +
"<table><tr><th>" +
__("名称", "Name") +
"</th><th>" +
__("版本", "Version") +
"</th><th>" +
__("描述", "Description") +
"</th><th>" +
__("操作", "Actions") +
"</th></tr>";
installed.forEach(function (p) {
html +=
"<tr><td>" +
escHtml(p.name) +
"</td>" +
"<td>" +
escHtml(p.version || "-") +
"</td>" +
"<td>" +
escHtml((p.description || "").substring(0, 50)) +
"</td>" +
'<td><button class="btn btn-sm btn-ghost" onclick="showPluginInfo(\'' +
escHtml(p.name) +
"')\">" +
__("详情", "Details") +
"</button> " +
'<button class="btn btn-sm btn-danger" onclick="removePlugin(\'' +
escHtml(p.name) +
"')\">" +
__("卸载", "Unload") +
"</button></td></tr>";
});
html += "</table></div>";
}
if (state.pluginInfo) {
html +=
'<div class="card"><h2>' +
__("插件详情", "Plugin Details") +
": " +
escHtml(state.pluginInfo.name) +
"</h2>" +
"<pre>" +
escHtml(JSON.stringify(state.pluginInfo, null, 2)) +
"</pre>" +
'<button class="btn btn-ghost" onclick="closePluginInfo()">' +
__("关闭", "Close") +
"</button></div>";
}
if (tools.length > 0) {
html +=
'<div class="card"><h2>' +
__("已注册工具", "Registered Tools") +
" (" +
tools.length +
")</h2>" +
'<div style="display:flex;flex-wrap:wrap;gap:4px">';
tools.forEach(function (t) {
html +=
'<span class="tool-badge" title="' +
escHtml(t.description || "") +
'">' +
escHtml(t.name) +
"</span>";
});
html += "</div></div>";
}
html +=
'<div class="card"><h2>' +
__("系统操作", "System Operations") +
"</h2>" +
'<button class="btn btn-primary" onclick="reloadPlugins()">' +
__("重载插件", "Reload Plugins") +
"</button></div>";
html +=
'<div class="card health-card"><div class="health-head"><h2>' +
__("健康检查", "Health Check") +
'</h2><button class="btn btn-ghost btn-sm" onclick="runHealthcheck()">' +
__("运行", "Run") +
'</button></div><div id="health-panel">';
if (state.healthResult) {
html += renderHealthResult(state.healthResult);
} else {
html +=
'<p style="color:var(--text-muted);font-size:13px">' +
__("尚未运行,点击右上角「运行」开始", "Not run yet, click Run to start") +
"</p>";
}
html += "</div></div>";
document.getElementById("tab-plugins").innerHTML = html;
}
async function loadInstalledPlugins() {
try {
state.installedPlugins = await api("/plugins");
} catch (e) {
state.installedPlugins = [];
}
}
async function installPlugin() {
var inp = document.getElementById("plugin-url");
var url = inp?.value.trim();
if (!url) {
toast(__("请输入插件包 URL", "Please enter plugin URL"), true);
return;
}
try {
var raw = await api("/plugins", {
method: "POST",
body: JSON.stringify({ url: url }),
raw: true,
});
var ct = raw.headers.get("content-type") || "";
var r = ct.includes("json") ? await raw.json() : await raw.text();
if (!raw.ok || (r && r.error)) {
toast(
__("安装失败: ", "Install failed: ") +
((r && (r.error || r.details)) || "HTTP " + raw.status),
true,
);
return;
}
toast(
__("安装结果: ", "Install result: ") +
(r.status || JSON.stringify(r)),
);
if (r.action === "reload_required")
toast(
__(
"已安装,请点击「重载插件」加载",
'Installed, click "Reload Plugins" to load',
),
false,
);
await loadInstalledPlugins();
renderPlugins();
} catch (e) {
toast(__("安装失败: ", "Install failed: ") + e.message, true);
}
}
async function installPluginFile(file) {
if (!file) return;
try {
var r = await fetch("/api/v1/plugins", {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
});
var data = await r.json();
toast(
__("上传安装: ", "Upload install: ") +
(data.status || JSON.stringify(data)),
);
if (data.action === "reload_required")
toast(
__(
"已安装,请点击「重载插件」加载",
'Installed, click "Reload Plugins" to load',
),
false,
);
loadInstalledPlugins();
renderPlugins();
} catch (e) {
toast(__("上传失败: ", "Upload failed: ") + e.message, true);
}
}
async function showPluginInfo(name) {
try {
state.pluginInfo = await api("/plugins/" + encodeURIComponent(name));
renderPlugins();
} catch (e) {
toast(__("获取详情失败: ", "Get details failed: ") + e.message, true);
}
}
function closePluginInfo() {
state.pluginInfo = null;
renderPlugins();
}
var removingPlugins = {};
async function removePlugin(name) {
if (removingPlugins[name]) return; // 防重复点击
if (
!confirm(
__("确定卸载插件", "Are you sure to unload plugin") +
"「" +
name +
"」?",
)
)
return;
removingPlugins[name] = true;
try {
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();
if (!raw.ok) {
var em =
(body && (body.error || body.details)) ||
("HTTP " + raw.status);
toast(__("卸载失败: ", "Unload failed: ") + em, true);
// 内置插件或路径错误时刷新一次列表保持状态一致
loadInstalledPlugins();
renderPlugins();
return;
}
toast(__("已卸载: ", "Unloaded: ") + (body.name || body.status || name));
await loadInstalledPlugins();
// 同步内核插件/禁用列表,确保列表与工具立即消失
try {
state.kernel = await api("/kernel");
var s = await api("/settings");
state.disabledPlugins = s.disabled_plugins || [];
} catch (e2) {}
renderPlugins();
} catch (e) {
toast(__("卸载失败: ", "Unload failed: ") + e.message, true);
} finally {
delete removingPlugins[name];
}
}
async function disablePlugin(name) {
if (name === "webui") {
var r = confirm(
__(
"禁用 WebUI 后将无法通过 URL:端口访问此管理面板,若要重新启用需要通过 CLI 命令 /plugin enable webui 恢复。\n\n确定要禁用吗",
"Disabling WebUI will make this management panel inaccessible via URL:port. To re-enable, use CLI command /plugin enable webui.\n\nAre you sure?",
),
);
if (!r) return;
}
try {
var r = await api(
"/plugins/" + encodeURIComponent(name) + "/disable",
{ method: "POST" },
);
toast(__("已禁用: ", "Disabled: ") + name);
state.kernel = await api("/kernel");
var s = await api("/settings");
state.disabledPlugins = s.disabled_plugins || [];
renderPlugins();
} catch (e) {
toast(__("禁用失败: ", "Disable failed: ") + e.message, true);
}
}
async function enablePlugin(name) {
try {
var r = await api(
"/plugins/" + encodeURIComponent(name) + "/enable",
{ method: "POST" },
);
toast(__("已启用: ", "Enabled: ") + name);
state.kernel = await api("/kernel");
var s = await api("/settings");
state.disabledPlugins = s.disabled_plugins || [];
renderPlugins();
} catch (e) {
toast(__("启用失败: ", "Enable failed: ") + e.message, true);
}
}
async function reloadPlugins() {
try {
var r = await api("/plugins/reload", { method: "POST" });
toast(__("插件已重载", "Plugins reloaded"));
state.kernel = await api("/kernel");
renderPlugins();
} catch (e) {
toast(__("重载失败: ", "Reload failed: ") + e.message, true);
}
}
async function runHealthcheck() {
var panel = document.getElementById("health-panel");
if (!panel) return;
panel.innerHTML =
'<div class="loading" style="margin:12px auto"></div><p style="text-align:center;color:var(--text-muted)">' +
__("运行中...", "Running...") +
"</p>";
try {
var r = await api("/kernel");
var tools = r?.tools || [];
var healthTool = tools.find(function (t) {
return t.name === "healthcheck";
});
if (!healthTool) {
panel.innerHTML =
'<p style="color:var(--text-secondary)">' +
__("healthcheck 工具未注册", "healthcheck tool not registered") +
"</p>";
return;
}
panel.innerHTML =
'<p style="color:var(--text-secondary)">' +
__(
"通过 Agent 对话触发 healthcheck...",
"Triggering healthcheck via Agent...",
) +
"</p>";
var chatR = await api("/chat", {
method: "POST",
body: JSON.stringify({
message: __(
"请运行 healthcheck 工具进行全面健康检查并报告结果",
"Please run the healthcheck tool for a full system check and report the results",
),
}),
});
panel.innerHTML =
"<pre>" + escHtml(JSON.stringify(chatR, null, 2)) + "</pre>";
} catch (e) {
panel.innerHTML =
'<p style="color:#fca5a5">' +
__("错误: ", "Error: ") +
escHtml(e.message) +
"</p>";
toast(
__("健康检查失败: ", "Health check failed: ") + e.message,
true,
);
}
}
function renderHealthResult(r) {
if (!r || !r.checks)
return (
'<p style="color:var(--text-secondary)">' +
__("暂无健康检查数据", "No health check data") +
"</p>"
);
var checks = r.checks || [];
var passed = checks.filter(function (c) {
return c.pass;
}).length;
var failed = checks.filter(function (c) {
return !c.pass;
}).length;
var html =
'<div style="margin-bottom:12px;display:flex;gap:16px;align-items:center">' +
'<span class="badge badge-green">' +
__("通过: ", "Pass: ") +
passed +
"</span>" +
'<span class="badge ' +
(failed > 0 ? "badge-red" : "badge-green") +
'">' +
__("失败: ", "Fail: ") +
failed +
"</span>" +
'<span class="badge badge-blue">' +
__("总计: ", "Total: ") +
checks.length +
"</span></div>";
checks.forEach(function (c) {
var passClass = c.pass ? "check-pass" : "check-fail";
if (c.status === "skip") passClass = "check-skip";
html +=
'<div class="health-item">' +
'<span class="check-name">' +
escHtml(c.name) +
"</span>" +
'<span class="check-status ' +
passClass +
'">' +
(c.status || "unknown") +
"</span>" +
'<span style="color:var(--text-muted);font-size:11px">' +
escHtml(c.detail || "") +
"</span></div>";
});
return html;
}
// ===== Kernel =====
function renderKernel() {
var k = state.kernel;
if (!k) {
document.getElementById("tab-kernel").innerHTML =
'<div class="card"><p style="color:var(--text-muted)">' +
__("内核未响应", "Kernel not responding") +
"</p></div>";
return;
}
var html =
'<div class="card"><h2>' +
__("运行时", "Runtime") +
"</h2>" +
'<div class="kv-row"><span class="key">Goroutines</span><span class="val">' +
(k?.runtime?.goroutines || "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("内存", "Memory") +
'</span><span class="val">' +
(k?.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">Go ' +
__("版本", "Version") +
'</span><span class="val">' +
(k?.runtime?.go_version || "-") +
"</span></div></div>";
html +=
'<div class="card"><h2>LLM</h2>' +
'<div class="kv-row"><span class="key">Provider</span><span class="val">' +
(k.llm?.provider || __("未配置", "Not configured")) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("可用源", "Sources") +
'</span><span class="val">' +
(k.llm?.sources || 0) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("状态", "Status") +
'</span><span class="val"><span class="status-dot ' +
(k.llm?.available ? "dot-green" : "dot-red") +
'"></span>' +
(k.llm?.available
? __("运行中", "Running")
: __("不可用", "Unavailable")) +
"</span></div></div>";
html +=
'<div class="card"><h2>' +
__("记忆", "Memory") +
"</h2>" +
'<div class="kv-row"><span class="key">' +
__("图记忆", "Graph Memory") +
'</span><span class="val">' +
(k.memory?.available
? k.memory.entity_count +
__(" 实体, ", " entities, ") +
k.memory.relation_count +
__(" 关系", " relations")
: __("未初始化", "Uninitialized")) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("文档记忆", "Document Memory") +
'</span><span class="val">' +
(k.documents?.available
? k.documents.doc_count + __(" 文档", " docs")
: __("未初始化", "Uninitialized")) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("文本记忆", "Text Memory") +
'</span><span class="val">' +
(k.text_memory?.available
? k.text_memory.file_count + __(" 文件", " files")
: __("未初始化", "Uninitialized")) +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("知识库", "Knowledge") +
'</span><span class="val">' +
(k.knowledge?.available
? k.knowledge.item_count + __(" 项", " items")
: __("未初始化", "Uninitialized")) +
"</span></div></div>";
html +=
'<div class="card"><h2>' +
__("插件", "Plugins") +
" (" +
(k.plugins?.length || 0) +
")</h2>";
if (k.plugins?.length) {
html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
k.plugins.forEach(function (p) {
html +=
'<span class="badge badge-blue">' + escHtml(p.name) + "</span>";
});
html += "</div>";
} else {
html +=
'<p style="color:var(--text-muted)">' + __("无", "None") + "</p>";
}
html += "</div>";
document.getElementById("tab-kernel").innerHTML = html;
}
// ===== Star Map =====
var starmapScene = null,
starmapCam = null,
starmapRen = null,
starmapCtrl = null;
var starmapNodes = [],
starmapEdges = [];
var starmapNodeMeshes = [],
starmapEdgeLines = [],
starmapStarField = null;
var starmapHovered = null,
starmapSelected = null,
starmapAutoView = true;
var starmapRaf = null;
var smTypeColors = {
person: 0x4488ff,
task: 0xff8844,
ai: 0xaa44ff,
concept: 0x44ff88,
object: 0xff4444,
};
var smEdgeColors = {
喜欢: 0xff6b6b,
学习: 0x4ecdc4,
属于: 0x45b7d1,
相关: 0x96ceb4,
使用: 0xfeca57,
创建: 0xff9ff3,
};
function createStarField() {
var c = 3000;
var p = new Float32Array(c * 3),
cl = new Float32Array(c * 3),
s = new Float32Array(c);
for (var i = 0; i < c; i++) {
var i3 = i * 3;
var r = 400 + Math.random() * 600,
th = Math.random() * Math.PI * 2,
ph = Math.acos(2 * Math.random() - 1);
p[i3] = r * Math.sin(ph) * Math.cos(th);
p[i3 + 1] = r * Math.sin(ph) * Math.sin(th);
p[i3 + 2] = r * Math.cos(ph);
if (Math.random() < 0.7) {
cl[i3] = 0.8 + Math.random() * 0.2;
cl[i3 + 1] = 0.8 + Math.random() * 0.2;
cl[i3 + 2] = 1;
} else {
cl[i3] = 1;
cl[i3 + 1] = 0.9 + Math.random() * 0.1;
cl[i3 + 2] = 0.8 + Math.random() * 0.2;
}
s[i] = 0.5 + Math.random() * 2;
}
var g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(p, 3));
g.setAttribute("color", new THREE.BufferAttribute(cl, 3));
g.setAttribute("size", new THREE.BufferAttribute(s, 1));
var m = new THREE.PointsMaterial({
size: 1.5,
vertexColors: true,
transparent: true,
opacity: 0.8,
sizeAttenuation: true,
});
starmapStarField = new THREE.Points(g, m);
starmapScene.add(starmapStarField);
}
function onStarmapMove(e) {
if (!starmapRen || !starmapCam) return;
var rect = starmapRen.domElement.getBoundingClientRect();
var mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1,
);
var rc = new THREE.Raycaster();
rc.setFromCamera(mouse, starmapCam);
var hits = rc.intersectObjects(starmapNodeMeshes);
if (hits.length > 0) {
var n = hits[0].object;
if (starmapHovered !== n) {
if (starmapHovered) starmapHovered.scale.set(1, 1, 1);
starmapHovered = n;
n.scale.set(1.2, 1.2, 1.2);
}
} else {
if (starmapHovered) {
starmapHovered.scale.set(1, 1, 1);
starmapHovered = null;
}
}
}
function onStarmapClick(e) {
if (!starmapRen || !starmapCam) return;
var rect = starmapRen.domElement.getBoundingClientRect();
var mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1,
);
var rc = new THREE.Raycaster();
rc.setFromCamera(mouse, starmapCam);
var hits = rc.intersectObjects(starmapNodeMeshes);
if (hits.length > 0) {
var n = hits[0].object;
starmapSelected = starmapSelected === n ? null : n;
if (starmapAutoView && starmapSelected)
flyStarmapTo(starmapSelected.userData.nodeId, 500);
onStarmapMove(e);
} else {
starmapSelected = null;
}
}
function flyStarmapTo(nodeId, dur) {
if (!starmapAutoView) return;
var m = starmapNodeMeshes.find(function (x) {
return x.userData.nodeId === nodeId;
});
if (!m) return;
var tp = m.position.clone(),
sp = starmapCam.position.clone(),
st = starmapCtrl.target.clone();
var dist = tp.length() + 25,
ep = new THREE.Vector3(tp.x, tp.y + dist * 0.4, tp.z + dist * 0.8);
var t0 = Date.now();
(function lerp() {
var t = Math.min((Date.now() - t0) / dur, 1),
e = 1 - Math.pow(1 - t, 3);
starmapCam.position.lerpVectors(sp, ep, e);
starmapCtrl.target.lerpVectors(st, tp, e);
if (t < 1) requestAnimationFrame(lerp);
})();
}
function onStarmapResize() {
if (!starmapRen || !starmapCam) return;
var cont = starmapRen.domElement.parentElement;
if (!cont) return;
var rect = cont.getBoundingClientRect();
var w = rect.width || 800,
h = Math.max(rect.height || 250, 100);
if (w > 0 && h > 0) {
starmapCam.aspect = w / h;
starmapCam.updateProjectionMatrix();
starmapRen.setSize(w, h);
}
}
function starmapAnimate() {
starmapRaf = requestAnimationFrame(starmapAnimate);
if (starmapCtrl) starmapCtrl.update();
if (starmapStarField) starmapStarField.rotation.y += 0.0001;
if (starmapRen && starmapScene && starmapCam)
starmapRen.render(starmapScene, starmapCam);
}
function createNebula() {
var nc = 500;
var p = new Float32Array(nc * 3),
cl = new Float32Array(nc * 3);
for (var i = 0; i < nc; i++) {
var i3 = i * 3;
p[i3] = (Math.random() - 0.5) * 800;
p[i3 + 1] = (Math.random() - 0.5) * 800;
p[i3 + 2] = (Math.random() - 0.5) * 800;
var ch = Math.random();
if (ch < 0.33) {
cl[i3] = 0.5 + Math.random() * 0.3;
cl[i3 + 1] = 0.2 + Math.random() * 0.2;
cl[i3 + 2] = 0.7 + Math.random() * 0.3;
} else if (ch < 0.66) {
cl[i3] = 0.2 + Math.random() * 0.2;
cl[i3 + 1] = 0.3 + Math.random() * 0.3;
cl[i3 + 2] = 0.8 + Math.random() * 0.2;
} else {
cl[i3] = 0.7 + Math.random() * 0.3;
cl[i3 + 1] = 0.2 + Math.random() * 0.2;
cl[i3 + 2] = 0.5 + Math.random() * 0.3;
}
}
var g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(p, 3));
g.setAttribute("color", new THREE.BufferAttribute(cl, 3));
var m = new THREE.PointsMaterial({
size: 8,
vertexColors: true,
transparent: true,
opacity: 0.15,
sizeAttenuation: true,
blending: THREE.AdditiveBlending,
});
var np = new THREE.Points(g, m);
starmapScene.add(np);
}
// ===== Settings =====
function pluginDisplayName(p) {
if (p === "core") return __("核心", "Core");
var name = p.replace("plugin.", "");
var meta = state.pluginMeta && state.pluginMeta[name];
if (meta)
return state.lang === "en"
? meta.name_en || name
: meta.name_zh || name;
return name;
}
function renderSettingsSidebar() {
var el = document.querySelector(".settings-tabs");
if (!el) return;
el.innerHTML = "";
state.settingsPlugins.forEach(function (p) {
var a = document.createElement("span");
a.textContent = pluginDisplayName(p);
if (p === state.selectedSection) a.className = "active";
a.onclick = function () {
state.selectedSection = p;
renderOneSettings();
};
el.appendChild(a);
});
}
function renderOneSettings() {
var prefix = state.selectedSection + ".";
var allKeys = Object.keys(state.settings || {});
var filtered = allKeys.filter(function (k) {
return k === prefix.slice(0, -1) || k.startsWith(prefix);
});
filtered.sort();
var hideTopLlms = [
"core.llm.base_url",
"core.llm.model",
"core.llm.api_key",
"core.llm.adapter",
"core.llm.adapter_path",
"core.llm.thinking_enabled",
];
var sourceKeys = filtered.filter(function (k) {
return k.startsWith("core.llm.sources.");
});
var sourceMap = {};
sourceKeys.forEach(function (k) {
var parts = k.split(".");
var srcName = parts[3];
if (!sourceMap[srcName]) sourceMap[srcName] = {};
sourceMap[srcName][k] = true;
});
var mcpServerKeys = filtered.filter(function (k) {
return (
k.startsWith("plugin.mcp.servers.") && k.split(".").length >= 5
);
});
var mcpServerMap = {};
mcpServerKeys.forEach(function (k) {
var parts = k.split(".");
var srvName = parts[3];
if (!mcpServerMap[srvName]) mcpServerMap[srvName] = {};
mcpServerMap[srvName][k] = true;
});
var regularKeys = filtered.filter(function (k) {
return (
!k.startsWith("core.llm.sources.") &&
hideTopLlms.indexOf(k) === -1 &&
!k.startsWith("plugin.mcp.servers.") &&
k !== "plugin.mcp.servers"
);
});
var html =
'<div class="settings-layout"><div class="settings-tabs"></div><div class="settings-content">';
if (
regularKeys.length === 0 &&
Object.keys(sourceMap).length === 0 &&
Object.keys(mcpServerMap).length === 0 &&
state.selectedSection !== "plugin.mcp"
) {
html +=
'<div class="card"><h2>' +
escHtml(state.selectedSection) +
'</h2><p style="color:var(--text-muted)">' +
__("暂无设置项", "No settings") +
"</p></div>";
} else {
regularKeys.forEach(function (k) {
var v = state.settings[k];
var sv = typeof v === "object" ? JSON.stringify(v) : String(v);
var m = state.meta?.[k];
var shortName = k.split(".").pop().replace(/_/g, " ");
var label = m?.display_name || shortName;
var desc = m?.description || "";
var typ = m?.type || "string";
var ph = m?.placeholder || "";
var opts = m?.options || [];
var inpId = "inp-" + k.replace(/\./g, "_");
var inp = "";
if (typ === "bool") {
var chk = sv === "true" ? "checked" : "";
inp =
'<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="' +
inpId +
'" ' +
chk +
" onchange=\"markDirty('" +
k +
'\')" style="width:auto;margin:0"> ' +
label +
"</label>";
} else if (typ === "select") {
var selOpts = "";
opts.forEach(function (o) {
selOpts +=
'<option value="' +
o +
'"' +
(sv === o ? " selected" : "") +
">" +
o +
"</option>";
});
inp =
"<label>" +
label +
'</label><select id="' +
inpId +
'" onchange="markDirty(\'' +
k +
"')\">" +
selOpts +
"</select>";
} else if (typ === "text") {
inp =
"<label>" +
label +
'</label><textarea id="' +
inpId +
'" placeholder="' +
escHtml(ph) +
'" onchange="markDirty(\'' +
k +
"')\">" +
escHtml(sv) +
"</textarea>";
} else {
inp =
"<label>" +
label +
'</label><input type="text" id="' +
inpId +
'" value="' +
escHtml(sv) +
'" placeholder="' +
escHtml(ph) +
'" onchange="markDirty(\'' +
k +
"')\">";
}
var extra = "";
if (m?.extra) {
m.extra.forEach(function (f) {
var fk = (k ? k + "." : "") + f.key;
var fv = state.settings?.[fk];
var fph = f.placeholder || __("输入", "Enter ") + f.label;
extra +=
'<div class="form-row" style="margin-left:16px;margin-top:4px"><label>' +
f.label +
"</label>";
if (f.type === "select") {
var fopts = "";
if (f.options)
f.options.forEach(function (o) {
fopts +=
'<option value="' +
o +
'"' +
(fv === o ? "selected" : "") +
">" +
o +
"</option>";
});
extra +=
"<select onchange=\"saveSetting('" +
fk +
"',this.value)\">" +
fopts +
"</select>";
} else {
extra +=
'<input type="text" value="' +
escHtml(fv || "") +
'" placeholder="' +
escHtml(fph) +
'" onchange="markDirty(\'' +
fk +
'\')" style="margin-bottom:0">';
}
extra += "</div>";
});
}
var descHtml = desc
? '<p style="font-size:11px;color:var(--text-muted);margin:-6px 0 10px">' +
escHtml(desc) +
"</p>"
: "";
html +=
'<div class="card"><div class="settings-key">' +
escHtml(k) +
"</div>" +
inp +
descHtml +
extra +
'<button class="btn btn-sm btn-ghost" style="border-color:var(--save-btn-border);margin-top:4px" onclick="saveSetting(\'' +
k +
"')\">" +
__("保存", "Save") +
"</button></div>";
});
// LLM Sources
Object.keys(sourceMap)
.sort()
.forEach(function (src) {
var baseKey = "core.llm.sources." + src;
var srcData =
state.settings?.[baseKey + ".adapter"] ||
state.settings?.[baseKey + ".base_url"] ||
"";
var fields = [
{
key: "adapter",
label: __("适配器", "Adapter"),
type: "text",
},
{ key: "base_url", label: "Base URL", type: "text" },
{ key: "model", label: __("模型", "Model"), type: "text" },
{ key: "api_key", label: "API Key", type: "text" },
{
key: "thinking_enabled",
label: __("思考模式", "Thinking Mode"),
type: "select",
options: ["true", "false"],
},
{
key: "adapter_path",
label: __("适配器路径", "Adapter Path"),
type: "text",
},
];
var headerLabel = mL10n(src, "LLM Source: " + src);
html += '<div class="card"><h2>' + escHtml(headerLabel) + "</h2>";
fields.forEach(function (f) {
var fk = baseKey + "." + f.key;
var fv = state.settings?.[fk] || "";
var flabel = f.label;
var fieldId = "inp-" + fk.replace(/\./g, "_");
if (f.type === "select") {
var fopts = "";
f.options.forEach(function (o) {
fopts +=
'<option value="' +
o +
'"' +
(fv === o ? "selected" : "") +
">" +
o +
"</option>";
});
html +=
"<label>" +
flabel +
'</label><select id="' +
fieldId +
'" onchange="markDirty(\'' +
fk +
'\')" style="margin-bottom:4px">' +
fopts +
"</select>";
} else {
html +=
"<label>" +
flabel +
'</label><input type="text" id="' +
fieldId +
'" value="' +
escHtml(fv) +
'" placeholder="' +
(f.key === "api_key"
? __("输入 API Key", "Enter API Key")
: __("输入", "Enter ") + flabel) +
'" onchange="markDirty(\'' +
fk +
'\')" style="margin-bottom:4px">';
}
});
html +=
'<div style="display:flex;gap:8px;margin-top:8px">' +
'<button class="btn btn-sm btn-ghost" style="border-color:var(--save-btn-border)" onclick="saveSetting(\'' +
baseKey +
".adapter');saveSetting('" +
baseKey +
".base_url');saveSetting('" +
baseKey +
".model');saveSetting('" +
baseKey +
".api_key');var inp=document.getElementById('" +
("inp-" + baseKey + ".thinking_enabled").replace(/\./g, "_") +
"');if(inp)saveSetting('" +
baseKey +
".thinking_enabled');toast('" +
__("源", "Source") +
" \\'" +
src +
"\\' " +
__("已保存", "saved") +
"')\">" +
__("保存", "Save") +
"</button>" +
'<button class="btn btn-sm btn-danger" onclick="deleteSource(\'' +
src +
"')\">" +
__("删除", "Delete") +
"</button></div></div>";
});
if (
Object.keys(sourceMap).length > 0 ||
state.selectedSection === "core.llm"
) {
html +=
'<button class="btn btn-ghost btn-sm" onclick="showAddSourceDialog()" style="margin-bottom:16px">+ ' +
__("添加 LLM 源", "Add LLM Source") +
"</button>";
}
// MCP Servers
if (
state.selectedSection === "plugin.mcp" ||
Object.keys(mcpServerMap).length > 0
) {
html +=
'<div class="card"><h2>' +
__("MCP 服务器", "MCP Servers") +
'</h2><p style="font-size:11px;color:var(--text-muted);margin-bottom:8px">' +
__(
"配置 Model Context Protocol 服务端连接",
"Configure Model Context Protocol server connections",
) +
"</p></div>";
Object.keys(mcpServerMap)
.sort()
.forEach(function (srv) {
var baseKey = "plugin.mcp.servers." + srv;
var fields = [
{
key: "command",
label: __("启动命令", "Command"),
type: "text",
},
{ key: "url", label: "SSE URL", type: "text" },
{
key: "args",
label: __("参数(JSON数组)", "Args (JSON array)"),
type: "text",
},
{
key: "env",
label: __("环境变量(JSON数组)", "Env (JSON array)"),
type: "text",
},
];
html += '<div class="card"><h2>' + escHtml(srv) + "</h2>";
fields.forEach(function (f) {
var fk = baseKey + "." + f.key;
var fv = state.settings?.[fk] || "";
var fieldId = "inp-" + fk.replace(/\./g, "_");
html +=
"<label>" +
f.label +
'</label><input type="text" id="' +
fieldId +
'" value="' +
escHtml(fv) +
'" placeholder="' +
__("输入", "Enter ") +
f.label +
'" onchange="markDirty(\'' +
fk +
'\')" style="margin-bottom:4px">';
});
html +=
'<div style="display:flex;gap:8px;margin-top:8px">' +
'<button class="btn btn-sm btn-ghost" style="border-color:var(--save-btn-border)" onclick="saveSetting(\'' +
baseKey +
".command');saveSetting('" +
baseKey +
".url');saveSetting('" +
baseKey +
".args');saveSetting('" +
baseKey +
".env');toast('MCP \\'" +
srv +
"\\' " +
__("已保存", "saved") +
"')\">" +
__("保存", "Save") +
"</button>" +
'<button class="btn btn-sm btn-danger" onclick="deleteMCPServer(\'' +
srv +
"')\">" +
__("删除", "Delete") +
"</button></div></div>";
});
html +=
'<button class="btn btn-ghost btn-sm" onclick="addMCPSource()" style="margin-bottom:16px">+ ' +
__("添加 MCP 服务器", "Add MCP Server") +
"</button>";
}
}
html += "</div></div>";
document.getElementById("tab-settings").innerHTML = html;
renderSettingsSidebar();
}
function markDirty(k) {
var inp = document.getElementById("inp-" + k.replace(/\./g, "_"));
if (inp) inp.style.borderColor = "var(--save-btn-border)";
}
async function saveSetting(k) {
var inp = document.getElementById("inp-" + k.replace(/\./g, "_"));
if (!inp) return;
var val;
var m = state.meta?.[k];
if (m?.type === "bool") {
val = inp.checked ? "true" : "false";
} else if (m?.type === "select") {
val = inp.value;
} else {
var raw = inp.value;
try {
val = JSON.parse(raw);
} catch (e) {
val = raw;
}
}
try {
var r = await api("/settings", {
method: "PUT",
body: JSON.stringify({ key: k, value: val }),
});
if (r.status === "ok") {
inp.style.borderColor = "";
state.settings[k] = val;
toast(__("已保存: ", "Saved: ") + k);
} else {
toast(
__("保存失败: ", "Save failed: ") + (r.error || "unknown"),
true,
);
}
} catch (e) {
toast(__("保存失败: ", "Save failed: ") + e.message, true);
}
}
function renderConfigDisabled() {
document.getElementById("tab-settings").innerHTML =
'<div class="card"><h2>' +
__("设置", "Settings") +
'</h2><p style="color:var(--text-muted)">' +
__("设置面板已加载", "Settings panel loaded") +
"</p></div>";
renderOneSettings();
}
function mL10n(key, fallback) {
var meta = state.meta?.[key];
if (meta?.display_name) return meta.display_name;
return fallback || key;
}
function showAddSourceDialog() {
var name = prompt(
__(
"输入新 LLM 源名称(如 openai、anthropic:",
"Enter new LLM source name (e.g. openai, anthropic):",
),
);
if (!name || !name.trim()) return;
name = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]/g, "_");
if (!name) {
toast(__("名称无效", "Invalid name"), true);
return;
}
var keys = [
"base_url",
"model",
"api_key",
"adapter",
"adapter_path",
"thinking_enabled",
];
var values = {
base_url: "https://api." + name + ".com",
model: "",
api_key: "",
adapter: name,
adapter_path: "",
thinking_enabled: "false",
};
var promises = keys.map(function (f) {
return api("/settings", {
method: "PUT",
body: JSON.stringify({
key: "core.llm.sources." + name + "." + f,
value: values[f],
}),
});
});
Promise.all(promises)
.then(function () {
toast(
__("源", "Source") +
' "' +
name +
'" ' +
__(
"已创建,请配置各项参数",
"created, please configure parameters",
),
);
renderAll();
})
.catch(function (e) {
toast(__("创建失败: ", "Create failed: ") + e.message, true);
});
}
async function deleteSource(name) {
if (
!confirm(
__("确认删除源", "Are you sure to delete source") +
' "' +
name +
'"?',
)
)
return;
var base = "core.llm.sources." + name;
var fields = [
"adapter",
"base_url",
"model",
"api_key",
"thinking_enabled",
"adapter_path",
];
try {
for (var f of fields) {
await api("/settings", {
method: "PUT",
body: JSON.stringify({ key: base + "." + f, value: null }),
});
}
toast(
__("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted"),
);
renderAll();
} catch (e) {
toast(__("删除失败: ", "Delete failed: ") + e.message, true);
}
}
function addMCPSource() {
var name = prompt(
__("输入新 MCP 服务器名称:", "Enter new MCP server name:"),
);
if (!name || !name.trim()) return;
name = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]/g, "_");
if (!name) {
toast(__("名称无效", "Invalid name"), true);
return;
}
var fields = ["command", "url", "args", "env"];
var values = { command: "", url: "", args: "[]", env: "[]" };
var promises = fields.map(function (f) {
return api("/settings", {
method: "PUT",
body: JSON.stringify({
key: "plugin.mcp.servers." + name + "." + f,
value: values[f],
}),
});
});
Promise.all(promises)
.then(function () {
toast(
"MCP " +
__("服务器", "server") +
' "' +
name +
'" ' +
__("已创建", "created"),
);
renderAll();
})
.catch(function (e) {
toast(__("创建失败: ", "Create failed: ") + e.message, true);
});
}
async function deleteMCPServer(name) {
if (
!confirm(
__("确认删除 MCP 服务器", "Are you sure to delete MCP server") +
' "' +
name +
'"?',
)
)
return;
var fields = ["command", "url", "args", "env"];
try {
for (var f of fields) {
await api("/settings", {
method: "PUT",
body: JSON.stringify({
key: "plugin.mcp.servers." + name + "." + f,
value: null,
}),
});
}
toast(
"MCP " +
__("服务器", "server") +
' "' +
name +
'" ' +
__("已删除", "deleted"),
);
renderAll();
} catch (e) {
toast(__("删除失败: ", "Delete failed: ") + e.message, true);
}
}
// ===== Adapters =====
async function renderAdapters() {
var html = "";
try {
var r = await api("/adapters");
var adapters = r.adapters || [];
window._adapters = adapters;
html +=
'<div class="card"><h2>' +
__("已加载的适配器", "Loaded Adapters") +
" (" +
adapters.length +
")</h2>";
if (adapters.length === 0) {
html +=
'<p style="color:var(--text-muted)">' +
__("暂无适配器", "No adapters") +
"</p>";
} else {
html +=
"<table><tr><th>" +
__("名称", "Name") +
"</th><th>" +
__("版本", "Version") +
"</th><th>" +
__("操作", "Actions") +
"</th></tr>";
adapters.forEach(function (a) {
html +=
"<tr><td>" +
escHtml(a.name) +
"</td><td>" +
escHtml(a.version || "-") +
"</td>" +
'<td><button class="btn btn-danger btn-sm" onclick="deleteAdapter(\'' +
escHtml(a.name) +
"')\">" +
__("删除", "Delete") +
"</button></td></tr>";
});
html += "</table>";
}
html += "</div>";
html +=
'<div class="card"><h2>' +
__("上传新适配器", "Upload New Adapter") +
"</h2>" +
"<label>" +
__("适配器名称(不带 .lua", "Adapter name (without .lua)") +
"</label>" +
'<input id="adapter-name" placeholder="' +
__("如 openai", "e.g. openai") +
'">' +
"<label>" +
__("Lua 脚本代码", "Lua Script Code") +
"</label>" +
'<textarea id="adapter-code" rows="12" placeholder="-- ' +
__("返回一个适配器表", "return an adapter table") +
'\nreturn {\n name = \&quot;openai\&quot;,\n version = \&quot;1.0\&quot;,\n transform_request = function(raw) ... end,\n transform_response = function(raw) ... end,\n}"></textarea>' +
'<button class="btn btn-primary" onclick="uploadAdapter()">' +
__("上传", "Upload") +
"</button></div>";
} catch (e) {
html +=
'<div class="card"><p style="color:var(--text-muted)">' +
__("加载适配器失败: ", "Failed to load adapters: ") +
escHtml(e.message) +
"</p></div>";
}
document.getElementById("tab-adapters").innerHTML = html;
}
async function uploadAdapter() {
var name = document.getElementById("adapter-name")?.value;
var code = document.getElementById("adapter-code")?.value;
if (!name || !code) {
toast(
__("名称和代码不能为空", "Name and code cannot be empty"),
true,
);
return;
}
try {
var r = await api("/adapters", {
method: "POST",
body: JSON.stringify({ name: name, code: code }),
});
if (r.status === "loaded") {
toast(
__("适配器", "Adapter") +
' "' +
name +
'" ' +
__("已加载", "loaded"),
);
renderAdapters();
} else {
toast(
__("上传失败: ", "Upload failed: ") + (r.error || "unknown"),
true,
);
}
} catch (e) {
toast(__("上传失败: ", "Upload failed: ") + e.message, true);
}
}
async function deleteAdapter(name) {
if (
!confirm(
__("确定删除适配器", "Are you sure to delete adapter") +
' "' +
name +
'"',
)
)
return;
try {
var r = await api("/adapters/" + encodeURIComponent(name), {
method: "DELETE",
});
if (r.status === "deleted") {
toast(
__("适配器", "Adapter") +
' "' +
name +
'" ' +
__("已删除", "deleted"),
);
renderAdapters();
} else {
toast(__("删除失败", "Delete failed"), true);
}
} catch (e) {
toast(__("删除失败: ", "Delete failed: ") + e.message, true);
}
}
// ===== Init =====
async function logout() {
try {
await fetch("/api/v1/logout", {
method: "POST",
credentials: "include",
});
} catch (e) {}
location.href = "/login";
}
renderConfigDisabled();
connectSSE();
(async function () {
await loadChatHistory();
renderAll();
startUptimeTicker();
})();
setInterval(renderAll, 15000);
</script>
</body>
</html>