diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css new file mode 100644 index 0000000..38cb0e4 --- /dev/null +++ b/internal/plugins/webui/dashboard.css @@ -0,0 +1,2099 @@ + :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; + } + + /* 首启人格向导(复用 confirm-* 弹窗)*/ + .persona-ta { + width: 100%; + display: none; + margin-bottom: 12px; + font: 12px/1.5 var(--font-mono, monospace); + padding: 8px; + border-radius: var(--radius-md, 8px); + border: 1px solid var(--glass-border); + background: var(--glass-bg); + color: var(--text-primary); + box-sizing: border-box; + } + .persona-warn { + display: none; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + margin-bottom: 10px; + } + .persona-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + } + .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; + } diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 9ef1009..3ef1e69 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -27,2105 +27,7 @@ }, 8000); @@ -2260,4377 +162,7 @@
diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js new file mode 100644 index 0000000..d99bc84 --- /dev/null +++ b/internal/plugins/webui/dashboard.js @@ -0,0 +1,4371 @@ + // ===== 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, + chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标 + chatTotal: 0, // 服务端历史总条数 + chatHasMore: false, // 是否还有更早历史可向上加载 + 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 = + ''; + var ICON_MOON = + ''; + + 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 += + '"; + }); + pop.innerHTML = + "

主题色

" + + dots + + "
" + + '

背景图片 URL

' + + '' + + '
' + + '' + + '模糊 ' + + '' + + '' + + blur + + "px
"; + 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 = "
" + escHtml(text) + "
"; + } + if ( + typeof DOMPurify !== "undefined" && + typeof DOMPurify.sanitize === "function" + ) { + try { + return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); + } catch (e) {} + } + return html + .replace(//gi, "") + .replace(/\son\w+\s*=\s*"[^"]*"/gi, "") + .replace(/\son\w+\s*=\s*'[^']*'/gi, "") + .replace(/javascript:/gi, ""); + } + function escHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + 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 = ""; + }); + + // ===== 首启人格向导 ===== + // 人格是配置项(core.agent.personal_prompt,默认模板不含任何版本号)。 + // 首次启动问一次「默认 / 自定义 / 稍后」,之后不再打扰; + // 不回答 = 稍后 = 保留默认人格,绝不阻塞启动。 + async function maybeShowPersonaWizard() { + var st; + try { + st = await api("/persona"); + } catch (e) { + return; // 拿不到状态就不打扰用户 + } + if (!st || st.initialized) return; + var ov = document.createElement("div"); + ov.className = "confirm-overlay"; + ov.style.display = "flex"; + ov.innerHTML = + '
' + + "

" + escHtml(__("人格设定", "Persona")) + "

" + + "

" + escHtml(__( + "首次启动:选一下助手的人格。选「使用默认」即可(之后可在设置里修改);自定义内容在下次重启后生效。", + "First run: pick your assistant's persona. \"Use default\" is fine (change it later in Settings); custom content takes effect after the next restart." + )) + "

" + + '
' + + '' + + '
' + + '" + + '" + + '" + + "
"; + document.body.appendChild(ov); + var ta = ov.querySelector(".persona-ta"); + var warn = ov.querySelector(".persona-warn"); + var customOpen = false; + if (st.file_override) { + warn.style.display = "block"; + warn.textContent = __( + "注意:检测到 personal/personal.md,它优先于这里的设置。", + "Note: personal/personal.md exists and takes precedence over this choice." + ); + } + function close() { + ov.remove(); + } + async function submit(mode, content) { + try { + var r = await api("/persona", { + method: "POST", + body: JSON.stringify({ mode: mode, content: content || "" }), + }); + if (r && r.restart_required) toast(__("已保存,重启后生效", "Saved; takes effect after restart")); + else toast(__("已保存", "Saved")); + } catch (e) { + toast(__("保存失败:", "Save failed: ") + e, true); + } + close(); + } + ov.querySelectorAll("button[data-mode]").forEach(function (b) { + b.onclick = function () { + var mode = b.getAttribute("data-mode"); + if (mode !== "custom") { + submit(mode); + return; + } + if (!customOpen) { // 第一次点:展开文本域并预填当前人格 + customOpen = true; + ta.style.display = "block"; + ta.value = st.current_prompt || ""; + ta.focus(); + return; + } + if (!ta.value.trim()) { + toast(__("内容不能为空", "Content cannot be empty"), true); + return; + } + submit("custom", ta.value); + }; + }); + } + + // ===== API ===== + async function api(p, o) { + var opts = { + credentials: "include", + headers: { "Content-Type": "application/json", ...o?.headers }, + ...o, + }; + // FormData 时不能手动设 Content-Type(浏览器需自动生成 multipart boundary) + if (o?.body instanceof FormData) { + delete opts.headers["Content-Type"]; + } + 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; + } + initChatDragDrop(); + } + 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(); + } + + // fmtBuild 把构建身份压成一行:commit + 构建时间(日期部分)。 + // + // 未注入 ldflags 的本地构建会得到 "unknown",此时只显示 SDK 版本, + // 避免把 "unknown / unknown" 这种噪音摆在概览卡上。 + function fmtBuild(b, s) { + var parts = []; + var commit = (b && b.commit) || (s && s.commit) || ""; + if (commit && commit !== "unknown") parts.push(commit); + var bt = (b && b.build_time) || (s && s.build_time) || ""; + if (bt && bt !== "unknown") { + // 2026-09-03T05:55:35Z → 2026-09-03 + parts.push(String(bt).split("T")[0]); + } + var sdkv = (b && b.sdk_compatible) || (s && s.sdk_version) || ""; + if (sdkv) parts.push("SDK " + sdkv); + return parts.length ? parts.join(" · ") : "-"; + } + + 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 = + '

' + + __("系统概览", "System Overview") + + "

" + + '
' + + __("运行状态", "Status") + + '' + + escHtml(s.status || "unknown") + + "
" + + '
' + + __("运行时间", "Uptime") + + '' + + (state.startedAt ? fmtUptime(Date.now() - state.startedAt) : "-") + + "
" + + '
' + + __("插件", "Plugins") + + '' + + ((k?.plugins || []).length || 0) + + "
" + + '
' + + __("版本", "Version") + + '' + + escHtml( + k?.build?.version + ? (k.build.kernel_name || "HomeAgent") + " v" + k.build.version + : s.version + ? "v" + s.version + : "-", + ) + + "
" + + // AGPL-3.0 §13:向网络使用者提供取得对应源码的入口。 + // 地址来自内核 meta.SourceURL(构建时可 -ldflags 覆盖), + // 修改后对外部署的分支必须把它指向自己的源码仓库。 + (k?.build?.source_url + ? '
' + + __("源码", "Source") + + '' + + escHtml(k.build.source_url) + + "
" + : "") + + '
' + + __("构建", "Build") + + '' + + escHtml(fmtBuild(k?.build, s)) + + "
"; + if (k) { + html += + '

' + + __("LLM 状态", "LLM Status") + + "

" + + '
Provider' + + (k.llm?.provider || __("未配置", "Not configured")) + + "
" + + '
' + + __("可用源", "Sources") + + '' + + (k.llm?.sources || 0) + + "
" + + '
' + + __("状态", "Status") + + '' + + (k.llm?.available + ? __("运行中", "Running") + : __("不可用", "Unavailable")) + + "
"; + html += + '

' + + __("记忆状态", "Memory Status") + + "

" + + '
' + + __("图记忆", "Graph Memory") + + '' + + (k.memory?.available + ? k.memory.entity_count + + __(" 实体, ", " entities, ") + + k.memory.relation_count + + __(" 关系", " relations") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文档记忆", "Document Memory") + + '' + + (k.documents?.available + ? k.documents.doc_count + __(" 文档", " docs") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文本记忆", "Text Memory") + + '' + + (k.text_memory?.available + ? k.text_memory.file_count + __(" 文件", " files") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("知识库", "Knowledge") + + '' + + (k.knowledge?.available + ? k.knowledge.item_count + __(" 项", " items") + : __("未初始化", "Uninitialized")) + + "
"; + html += + '

' + + __("运行时", "Runtime") + + "

" + + '
Goroutines' + + (k.runtime?.goroutines || "-") + + "
" + + '
' + + __("内存", "Memory") + + '' + + (k.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-") + + "
" + + '
Go ' + + __("版本", "Version") + + '' + + escHtml(k.runtime?.go_version || "-") + + "
"; + } + document.getElementById("tab-overview").innerHTML = html; + } + + // ===== Chat ===== + var _chatLayoutBuilt = false; + + function buildChatLayout() { + var cont = document.getElementById("tab-chat"); + var k = state.kernel || {}; + var html = '
'; + html += + '
' + + '' + + __("对话", "Chat") + + "" + + "" + + __("星图", "Star Map") + + "" + + "" + + __("终端", "Terminal") + + "" + + "" + + __("运行中命令", "Running Commands") + + "" + + "" + + __("记忆", "Memory") + + "" + + "" + + __("上下文", "Context") + + "" + + "" + + __("知识", "Knowledge") + + "
"; + html += + '
'; + html += + '

' + + __("对话", "Chat") + + '

'; + if (state.messages.length === 0) { + html += + '

' + + __( + "开始对话以测试 Agent 回复", + "Start a conversation to test Agent replies", + ) + + "

"; + } + html += + "
" + + '
' + + '' + + '" + + '' + + '" + + '" + + "
"; + html += + '

' + + __("星图", "Star Map") + + "

" + + '
'; + html += + '

' + + __("终端", "Terminal") + + ' 0

' + + '
'; + html += + '

' + + __("命令历史", "Command History") + + ' 0

' + + '
'; + html += + '

' + + __("记忆", "Memory") + + "

" + + '
' + + __("实体", "Entities") + + '' + + (k?.memory?.entity_count || "-") + + "
" + + '
' + + __("关系", "Relations") + + '' + + (k?.memory?.relation_count || "-") + + "
" + + '
' + + '' + + '" + + '
' + + "
"; + html += + '

' + + __("上下文", "Context") + + "

" + + '
' + + '' + + '" + + '
' + + "
"; + html += + '

' + + __("知识", "Knowledge") + + "

" + + '
' + + __("项目", "Items") + + '' + + (k?.knowledge?.item_count || "-") + + "
" + + '
' + + '' + + '" + + '
' + + '
' + + '' + + '' + + '" + + "
"; + html += "
"; + 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; + // 触顶(近顶部 60px)且服务端还有更早历史 → 向上懒加载下一页 + if (msgsEl.scrollTop < 60 && state.chatHasMore) { + loadOlderChat(); + } + }, + { 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 += + '' + + '' + + escHtml(nm) + + ""; + }); + return s; + } + var html = ""; + if (msgs.length === 0) { + html = + '

' + + __( + "开始对话以测试 Agent 回复", + "Start a conversation to test Agent replies", + ) + + "

"; + } else { + msgs.forEach(function (m, i) { + var role = m.role || "user"; + var c = m.content || ""; + // 附件消息:agent 经 output_send__webui 发送,或用户上传。 + // 用 role 区分方向(user=右侧你发的 / assistant=左侧小宅发的), + // 卡片加来源标签避免混淆。 + if (m.attachment) { + var att = m.attachment; + var isUserAtt = role === "user"; + var srcLabel = isUserAtt + ? __("你发送的", "You sent") + : __("小宅发送的", "Sent by agent"); + var attHtml = ""; + if (att.type === "image") { + attHtml = + '' + + '图片加载失败\'"/>'; + } else if (att.type === "audio") { + // 音频用原生播放器:与图片同理,附件能在聊天里直接消费才算可见。 + // preload="metadata" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。 + attHtml = + '' + + '' + + escHtml(att.name || "audio") + + (att.size ? " (" + formatBytes(att.size) + ")" : "") + + ""; + } else { + var sizeStr = att.size ? formatBytes(att.size) : ""; + attHtml = + '' + + '' + + "" + + escHtml(att.name || "附件") + + (sizeStr ? " (" + sizeStr + ")" : "") + + ""; + } + html += + '
' + + '
' + + srcLabel + + "
" + + attHtml + + (c ? '
' + renderMd(c) + "
" : "") + + "
"; + 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 + ? '' + : running + ? '' + : ''; + var statusHtml = + tc.status === "denied" + ? '' + + __("已拒绝", "Denied") + + "" + : running + ? '' + + __("调用中", "Running") + + "" + : '' + + __("完成", "Done") + + ""; + var pluginHtml = tc.plugin + ? '' + escHtml(tc.plugin) + "" + : ""; + tcs += + '
' + + '
' + + iconSvg + + '' + + escHtml(tc.tool || tc.name || "") + + "" + + pluginHtml + + statusHtml + + '
' + + '
"; + }); + } + 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 = + '' + + (newPending.length + ? '' + pillHtml() + "" + : ""); + if (c) { + body += + '
' + + liveRow + + '
' + + c + + "
"; + c = ""; + } else { + body += '
' + liveRow + "
"; + } + } else if (c) { + body += + '
' + + c + + "
"; + } + if (role === "system") { + html += + '
' + + (c || "") + + "
"; + } else if (isChan) { + html += + '
' + + '
' + + chanLetter(m.source) + + "
" + + '
' + + escHtml(m.source) + + "
" + + body + + "
"; + } else { + var userAvatar = + ''; + var aiAvatar = '小宅'; + html += + '
' + + '
' + + (role === "user" ? userAvatar : aiAvatar) + + "
" + + '
' + + body + + "
" + + "
"; + } + }); + } + if (state.chatLoading && !streamingLast) { + var aiAvatarL = '小宅'; + html += + '
' + + aiAvatarL + + '
' + + '' + + (newPending.length + ? '' + pillHtml() + "" + : "") + + "
"; + } + 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 ( + '
' + + '
' + + '' + + '' + + (isStreaming + ? __("思考中...", "Thinking...") + : __("思考", "Thinking")) + + "" + + '
' + + '
' + + (isStreaming + ? '
' + + escHtml(preview) + + '
' + : '
' + renderMd(text) + "
") + + "
" + ); + } + 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 = + '

' + + __( + "3D 星图不可用(CDN 加载失败)", + "Star map unavailable (CDN load failed)", + ) + + "

"; + state.starmapInit = true; + state.starmapLoading = false; + return; + } + if (!window.THREE) { + cont.innerHTML = + '
'; + 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 = + '

' + + __("暂无记忆数据", "No memory data") + + "

"; + 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 = + '

' + + __("加载失败", "Load failed") + + "

"; + 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); + } + + // 上传文件并注入 agent:multipart POST /api/v1/chat/file。 + // 服务端落盘 uploads/ 后注入「[用户发送了文件: 名字 (大小)] 已保存到 <路径>」; + // 回复与普通消息一样走 SSE 流式渲染。可选附言从输入框读取。 + // 拖拽文件到聊天区即发送(可选:先在输入框写附言) + function initChatDragDrop() { + var panel = document.getElementById("chat-panel-chat"); + if (!panel || panel.__dnd) return; + panel.__dnd = true; + panel.addEventListener("dragover", function (e) { + e.preventDefault(); + panel.style.outline = "2px dashed var(--accent, #4a90d9)"; + }); + panel.addEventListener("dragleave", function () { + panel.style.outline = ""; + }); + panel.addEventListener("drop", function (e) { + e.preventDefault(); + panel.style.outline = ""; + if (e.dataTransfer.files && e.dataTransfer.files.length) { + sendChatFile(e.dataTransfer.files[0]); + } + }); + // 粘贴截图/复制的文件直接发送 + document.addEventListener("paste", function (e) { + var chatVisible = + document.getElementById("chat-panel-chat") && + document.getElementById("chat-input"); + if (!chatVisible) return; + var items = e.clipboardData && e.clipboardData.files; + if (items && items.length && document.activeElement !== null) { + sendChatFile(items[0]); + } + }); + } + + async function sendChatFile(fileObj, extraText) { + if (!fileObj || state.chatLoading) return; + var inp = document.getElementById("chat-input"); + var message = (extraText || inp.value || "").trim(); + if (inp) inp.value = ""; + var fd = new FormData(); + fd.append("file", fileObj); + if (message) fd.append("message", message); + state.messages.push({ + role: "user", + content: message, + attachment: { + // 与服务端的 attType 判定保持一致(image/audio/file), + // 否则乐观渲染的卡片会在 SSE 回流后变成另一种样式。 + type: /^image\//.test(fileObj.type) + ? "image" + : /^audio\//.test(fileObj.type) + ? "audio" + : "file", + url: URL.createObjectURL(fileObj), + name: fileObj.name, + size: fileObj.size, + }, + }); + rerenderChat(true); + state.chatLoading = true; + state.chatStage = __("等待AI回复...", "Waiting for AI..."); + rerenderChat(true); + try { + await api("/chat/file", { method: "POST", body: fd, rawBody: true }); + // 回复经 SSE 流式到达,这里无需处理响应体 + } catch (e) { + toast( + __("文件发送失败:" + e.message, "File send failed: " + e.message), + true, + ); + state.chatLoading = false; + endChatTurn(); + } + } + + 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 = '
'; + try { + var data = await api( + "/memory?q=" + encodeURIComponent(q) + "&depth=2", + ); + r.innerHTML = + '
' +
+            escHtml(JSON.stringify(data, null, 2)) +
+            "
"; + } catch (e) { + r.innerHTML = + '

' + + __("查询失败: ", "Query failed: ") + + escHtml(e.message) + + "

"; + } + } + + async function queryMemoryContext() { + var q = document.getElementById("ctx-query")?.value; + var r = document.getElementById("ctx-result"); + if (!r) return; + r.innerHTML = '
'; + 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 = '
'; + if (summary) + html += + '
' + + __("摘要", "Summary") + + '' + + escHtml(summary) + + "
"; + html += + '
Token ' + + __("预估", "Estimate") + + '' + + tk + + "
"; + if (entities.length) { + html += + '
' + + __("实体", "Entities") + + '' + + entities + .map(function (e) { + return escHtml(e.name || e.id || ""); + }) + .join(", ") + + "
"; + } + html += + '
' +
+            escHtml(ctx) +
+            "
"; + r.innerHTML = html; + } catch (e) { + r.innerHTML = + '

' + + __("获取失败: ", "Get failed: ") + + escHtml(e.message) + + "

"; + } + } + + async function searchKnowledgeChat() { + var q = document.getElementById("know-query")?.value; + var r = document.getElementById("know-result-chat"); + if (!r || !q) return; + r.innerHTML = '
'; + try { + var data = await api("/knowledge?q=" + encodeURIComponent(q)); + r.innerHTML = + '
' +
+            escHtml(JSON.stringify(data, null, 2)) +
+            "
"; + } catch (e) { + r.innerHTML = + '

' + + __("搜索失败: ", "Search failed: ") + + escHtml(e.message) + + "

"; + } + } + + 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(); + } + + // 首屏分段加载条数:只拉最新 N 条,向上滚动触顶时再拉更早的。 + var CHAT_PAGE_SIZE = 40; + async function loadChatHistory() { + try { + var data = await api("/chat/history?limit=" + CHAT_PAGE_SIZE); + if (data && data.messages) { + state.messages = data.messages; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatTotal = typeof data.total === "number" ? data.total : data.messages.length; + state.chatHasMore = !!data.has_more; + } + } catch (e) {} + } + + // loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。 + // 保持滚动位置(插入前后 scrollHeight 差值补偿),避免视口跳动。 + var _loadingOlder = false; + async function loadOlderChat() { + if (_loadingOlder || !state.chatHasMore) return; + _loadingOlder = true; + var msgsEl = document.getElementById("chat-msgs"); + var prevH = msgsEl ? msgsEl.scrollHeight : 0; + var prevTop = msgsEl ? msgsEl.scrollTop : 0; + try { + var before = state.chatOffset || 0; + if (before <= 0) { + state.chatHasMore = false; + return; + } + var data = await api( + "/chat/history?limit=" + CHAT_PAGE_SIZE + "&before=" + before, + ); + if (data && data.messages && data.messages.length) { + state.messages = data.messages.concat(state.messages); + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + state.chatStick = false; // 向上翻页时不自动粘底 + renderChat(); + if (msgsEl) { + // 补偿新增内容高度,保持用户视觉位置不变 + msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH); + } + } else { + state.chatHasMore = false; + } + } catch (e) { + } finally { + _loadingOlder = false; + } + } + + // syncChatFromHistory 增量同步:对比服务端历史,仅追加新消息 DOM 节点, + // 不重建已有消息 → 无闪烁。用于 SSE 断连恢复期间的轮询兜底。 + function syncChatFromHistory() { + return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then(function (data) { + if (!data || !data.messages || data.messages.length === 0) return; + var serverMsgs = data.messages; + var localMsgs = state.messages; + // 首次加载(空列表)→ 全量赋值 + if (localMsgs.length === 0) { + state.messages = serverMsgs; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + rerenderChat(true); + return; + } + // 以「本地末尾 vs 服务端末尾」做依据。分段拉取只会回传最新页, + // 本地已向上翻页加载了更早内容,长度上本地可能 ≥ 服务端。 + var lastLocal = localMsgs[localMsgs.length - 1]; + var lastServer = serverMsgs[serverMsgs.length - 1]; + var localContent = lastLocal.content || lastLocal.Content || ""; + var serverContent = lastServer.content || lastServer.Content || ""; + // 服务端末尾和本地末尾内容相同 → 无新增(可能裁剪过的轻量项因不含 tool_calls args/result, + // 依赖 content+role+time 足以识别) + if (localContent === serverContent) { + // 检查是否本地末尾被服务端改写(如 stage 插件改写最终文本) + if (lastServer.role === "assistant" && localContent !== serverContent && serverContent) { + lastLocal.content = serverContent; + if (lastServer.ReasoningContent) lastLocal.reasoning_content = lastServer.ReasoningContent; + var msgsEl0 = document.getElementById("chat-msgs"); + if (msgsEl0 && msgsEl0.lastElementChild) { + var textEl0 = msgsEl0.lastElementChild.querySelector(".msg-bubble .text"); + if (textEl0) textEl0.innerHTML = renderMd(serverContent); + } + } + return; + } + // 有新增消息:服务端末尾与本地末尾不同 → 找出本地新增的差异,追加到 DOM。 + // 服务端只回传最新页,我们需在 localMsgs 末尾取交集起点(本地末尾在服务端页的位置) + // 简化:取服务端末尾3条与本地末尾3条比对,找到重含点的下标;若找不到全量替换最新页。 + var overlap = -1; + for (var k = Math.min(3, serverMsgs.length - 1, localMsgs.length - 1); k >= 1; k--) { + var sMsg = serverMsgs[serverMsgs.length - 1 - k]; + var lMsg = localMsgs[localMsgs.length - 1 - k]; + if (lMsg && (lMsg.content || "") === (sMsg.content || "") && lMsg.role === sMsg.role) { + overlap = k; + break; + } + } + var newMsgs; + if (overlap >= 0) { + // 本地末尾 overlap 条在服务端页里,之后的部分是新增 + newMsgs = serverMsgs.slice(serverMsgs.length - overlap); + if (newMsgs.length === 0) return; // 无新增(内容改写走上面的分支) + } else { + // 找不到重含点(本地领先太多,超出服务端页)→ 无法精确差异,安全退化为全量刷新 + state.messages = serverMsgs; + rerenderChat(true); + return; + } + var msgsEl = document.getElementById("chat-msgs"); + if (msgsEl) { + var aiAvatar = '小宅'; + var userAvatar = ''; + newMsgs.forEach(function (m) { + var role = m.role || m.Role || "user"; + var c = m.content || m.Content || ""; + if (role === "assistant") c = renderMd(c); else c = escHtml(c); + var isChan = !!(m.source && m.source !== "webui"); + var bubble = c ? '
' + c + '
' : '
'; + if (role === "system") { + msgsEl.insertAdjacentHTML("beforeend", '
' + c + '
'); + } else if (isChan) { + msgsEl.insertAdjacentHTML("beforeend", '
' + (m.source||"?")[0].toUpperCase() + '
' + escHtml(m.source) + '
' + bubble + '
'); + } else { + msgsEl.insertAdjacentHTML("beforeend", '
' + (role === "user" ? userAvatar : aiAvatar) + '
' + bubble + '
'); + } + }); + // 删除流式占位符(同步完成,下一次 SSE 会重建) + var streamingPh = msgsEl.querySelector(".msg-streaming-ph"); + if (streamingPh) streamingPh.remove(); + } + // 追加新消息对象到 state.messages + Array.prototype.push.apply(state.messages, newMsgs); + // 同步聊天占位符(如果有新消息但最后一条非 assistant → 显示流式占位) + syncStreamingPlaceholder(); + }).catch(function () {}); + } + // syncStreamingPlaceholder:同步聊天占位符的可见性 + function syncStreamingPlaceholder() { + var msgsEl = document.getElementById("chat-msgs"); + if (!msgsEl) return; + var existing = msgsEl.querySelector(".msg-streaming-ph"); + var lastMsg = state.messages.length ? state.messages[state.messages.length - 1] : null; + var showPh = state.chatLoading && (!lastMsg || lastMsg.role !== "assistant" || lastMsg._final); + if (showPh && !existing) { + var aiAvatar = '小宅'; + var pillHtml = ""; + (state.pendingTools || []).forEach(function (nm) { + pillHtml += '' + escHtml(nm) + ''; + }); + msgsEl.insertAdjacentHTML("beforeend", '
' + aiAvatar + '
' + (pillHtml ? '' + pillHtml + '' : '') + '
'); + } else if (!showPh && existing) { + existing.remove(); + } + } + + 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 = + '

' + + __("暂无终端会话", "No terminal sessions") + + "

"; + 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 = + '' + + __("[终端暂无输出]", "[No terminal output]") + + ""; + } else { + fullOut = escHtml(fullOut); + } + html += + '
'; + html += + '
"; + html += + '' + + escHtml(t.id || "-") + + ""; + html += + '' + + escHtml(t.command || "") + + ""; + html += + '' + + (running ? __("运行中", "Running") : __("已关闭", "Closed")) + + ""; + html += + '' + + escHtml(t.created_at || "") + + ""; + html += "
"; + html += + '
"; + }); + 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 = + '

' + + __("暂无运行中的命令", "No running commands") + + "

"; + return; + } + var html = + '"; + running.forEach(function (t) { + var scr = (state.termScreens && state.termScreens[t.id]) || null; + var out = scr ? scr.output : t.output || ""; + html += + "" + + '" + + '" + + '" + + ""; + if (out) { + html += + "" + + '"; + } + }); + html += "
' + + __("命令", "Command") + + "" + + __("状态", "Status") + + "" + + __("运行时长", "Uptime") + + "
' + + escHtml(t.command || t.id || "") + + "' + + __("运行中", "Running") + + "' + + escHtml(t.uptime || "-") + + "
' +
+              escHtml(out.substring(0, 2000)) +
+              "
"; + 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 === "audio" || + 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); + // 1) 立即 close 阻止浏览器原生自动重连与手动 setTimeout(connectSSE) 双连接竞态 + try { state.eventSource && state.eventSource.close(); state.eventSource = null; } catch(ex){} + // 2) 连接错误期间可能丢失事件,增量补拉历史(无闪烁) + syncChatFromHistory().catch(function(){}); + // 3) 2s 后手动重连(比原 5s 更快恢复) + setTimeout(connectSSE, 2000); + }; + // sync_required:Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放, + // 通知前端增量补拉历史——避免前端空等后续聚合事件导致「消息同步不及时」。 + es.addEventListener("sync_required", function(e) { + console.log("[SSE] sync_required received, incremental sync"); + syncChatFromHistory().catch(function(){}); + }); + // 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 = + '

' + + __("安装插件", "Install Plugin") + + "

" + + '
' + + '' + + '
" + + '
' + + '
"; + 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 += + '

' + + __("所有插件", "All Plugins") + + " (" + + Object.keys(allPluginNames).length + + ")

"; + var names = Object.keys(allPluginNames).sort(); + if (names.length === 0) { + html += + '

' + + __("暂无插件", "No plugins") + + "

"; + } else { + html += + ""; + 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 + ? '' + + __("已加载", "Loaded") + + "" + : loaded && isDisabled + ? '' + + __("运行中(禁用待生效)", "Running (disable pending)") + + "" + : isDisabled + ? '' + + __("已禁用", "Disabled") + + "" + : '' + + __("未加载", "Not Loaded") + + ""; + var actionsHtml = ""; + if (loaded) { + actionsHtml += + '"; + } else { + actionsHtml += + '"; + } + if (isExternal) { + actionsHtml += + '"; + } + html += + "" + + "" + + ""; + }); + html += "
" + + __("名称", "Name") + + "" + + __("状态", "Status") + + "" + + __("操作", "Actions") + + "
" + + escHtml(name) + + "" + + statusHtml + + "" + + actionsHtml + + "
"; + } + html += "
"; + if (installed.length > 0) { + html += + '

' + + __("已安装外部插件", "Installed Plugins") + + " (" + + installed.length + + ")

" + + ""; + installed.forEach(function (p) { + html += + "" + + "" + + "" + + '"; + }); + html += "
" + + __("名称", "Name") + + "" + + __("版本", "Version") + + "" + + __("描述", "Description") + + "" + + __("操作", "Actions") + + "
" + + escHtml(p.name) + + "" + + escHtml(p.version || "-") + + "" + + escHtml((p.description || "").substring(0, 50)) + + " " + + '
"; + } + if (state.pluginInfo) { + html += + '

' + + __("插件详情", "Plugin Details") + + ": " + + escHtml(state.pluginInfo.name) + + "

" + + "
" +
+            escHtml(JSON.stringify(state.pluginInfo, null, 2)) +
+            "
" + + '
"; + } + if (tools.length > 0) { + html += + '

' + + __("已注册工具", "Registered Tools") + + " (" + + tools.length + + ")

" + + '
'; + tools.forEach(function (t) { + html += + '' + + escHtml(t.name) + + ""; + }); + html += "
"; + } + html += + '

' + + __("系统操作", "System Operations") + + "

" + + '
"; + html += + '

' + + __("健康检查", "Health Check") + + '

'; + if (state.healthResult) { + html += renderHealthResult(state.healthResult); + } else { + html += + '

' + + __( + "尚未运行,点击右上角「运行」开始", + "Not run yet, click Run to start", + ) + + "

"; + } + html += "
"; + 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 = + '

' + + __("运行中...", "Running...") + + "

"; + try { + var r = await api("/kernel"); + var tools = r?.tools || []; + var healthTool = tools.find(function (t) { + return t.name === "healthcheck"; + }); + if (!healthTool) { + panel.innerHTML = + '

' + + __("healthcheck 工具未注册", "healthcheck tool not registered") + + "

"; + return; + } + panel.innerHTML = + '

' + + __( + "通过 Agent 对话触发 healthcheck...", + "Triggering healthcheck via Agent...", + ) + + "

"; + 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 = + "
" + escHtml(JSON.stringify(chatR, null, 2)) + "
"; + } catch (e) { + panel.innerHTML = + '

' + + __("错误: ", "Error: ") + + escHtml(e.message) + + "

"; + toast( + __("健康检查失败: ", "Health check failed: ") + e.message, + true, + ); + } + } + + function renderHealthResult(r) { + if (!r || !r.checks) + return ( + '

' + + __("暂无健康检查数据", "No health check data") + + "

" + ); + 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 = + '
' + + '' + + __("通过: ", "Pass: ") + + passed + + "" + + '' + + __("失败: ", "Fail: ") + + failed + + "" + + '' + + __("总计: ", "Total: ") + + checks.length + + "
"; + checks.forEach(function (c) { + var passClass = c.pass ? "check-pass" : "check-fail"; + if (c.status === "skip") passClass = "check-skip"; + html += + '
' + + '' + + escHtml(c.name) + + "" + + '' + + (c.status || "unknown") + + "" + + '' + + escHtml(c.detail || "") + + "
"; + }); + return html; + } + + // ===== Kernel ===== + function renderKernel() { + var k = state.kernel; + if (!k) { + document.getElementById("tab-kernel").innerHTML = + '

' + + __("内核未响应", "Kernel not responding") + + "

"; + return; + } + var html = + '

' + + __("运行时", "Runtime") + + "

" + + '
Goroutines' + + (k?.runtime?.goroutines || "-") + + "
" + + '
' + + __("内存", "Memory") + + '' + + (k?.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-") + + "
" + + '
Go ' + + __("版本", "Version") + + '' + + (k?.runtime?.go_version || "-") + + "
"; + html += + '

LLM

' + + '
Provider' + + (k.llm?.provider || __("未配置", "Not configured")) + + "
" + + '
' + + __("可用源", "Sources") + + '' + + (k.llm?.sources || 0) + + "
" + + '
' + + __("状态", "Status") + + '' + + (k.llm?.available + ? __("运行中", "Running") + : __("不可用", "Unavailable")) + + "
"; + html += + '

' + + __("记忆", "Memory") + + "

" + + '
' + + __("图记忆", "Graph Memory") + + '' + + (k.memory?.available + ? k.memory.entity_count + + __(" 实体, ", " entities, ") + + k.memory.relation_count + + __(" 关系", " relations") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文档记忆", "Document Memory") + + '' + + (k.documents?.available + ? k.documents.doc_count + __(" 文档", " docs") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文本记忆", "Text Memory") + + '' + + (k.text_memory?.available + ? k.text_memory.file_count + __(" 文件", " files") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("知识库", "Knowledge") + + '' + + (k.knowledge?.available + ? k.knowledge.item_count + __(" 项", " items") + : __("未初始化", "Uninitialized")) + + "
"; + html += + '

' + + __("插件", "Plugins") + + " (" + + (k.plugins?.length || 0) + + ")

"; + if (k.plugins?.length) { + html += '
'; + k.plugins.forEach(function (p) { + html += + '' + escHtml(p.name) + ""; + }); + html += "
"; + } else { + html += + '

' + __("无", "None") + "

"; + } + html += "
"; + 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 = + '
'; + if ( + regularKeys.length === 0 && + Object.keys(sourceMap).length === 0 && + Object.keys(mcpServerMap).length === 0 && + state.selectedSection !== "plugin.mcp" + ) { + html += + '

' + + escHtml(state.selectedSection) + + '

' + + __("暂无设置项", "No settings") + + "

"; + } 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 = + '"; + } else if (typ === "select") { + var selOpts = ""; + opts.forEach(function (o) { + selOpts += + '"; + }); + inp = + ""; + } else if (typ === "text") { + inp = + ""; + } else { + inp = + ""; + } + 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 += + '
"; + if (f.type === "select") { + var fopts = ""; + if (f.options) + f.options.forEach(function (o) { + fopts += + '"; + }); + extra += + ""; + } else { + extra += + ''; + } + extra += "
"; + }); + } + var descHtml = desc + ? '

' + + escHtml(desc) + + "

" + : ""; + html += + '
' + + escHtml(k) + + "
" + + inp + + descHtml + + extra + + '
"; + }); + // 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 += '

' + escHtml(headerLabel) + "

"; + 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 += + '"; + }); + html += + ""; + } else { + html += + "'; + } + }); + html += + '
' + + '" + + '
"; + }); + if ( + Object.keys(sourceMap).length > 0 || + state.selectedSection === "core.llm" + ) { + html += + '"; + } + // MCP Servers + if ( + state.selectedSection === "plugin.mcp" || + Object.keys(mcpServerMap).length > 0 + ) { + html += + '

' + + __("MCP 服务器", "MCP Servers") + + '

' + + __( + "配置 Model Context Protocol 服务端连接", + "Configure Model Context Protocol server connections", + ) + + "

"; + 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 += '

' + escHtml(srv) + "

"; + fields.forEach(function (f) { + var fk = baseKey + "." + f.key; + var fv = state.settings?.[fk] || ""; + var fieldId = "inp-" + fk.replace(/\./g, "_"); + html += + "'; + }); + html += + '
' + + '" + + '
"; + }); + html += + '"; + } + } + html += "
"; + 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 = + '

' + + __("设置", "Settings") + + '

' + + __("设置面板已加载", "Settings panel loaded") + + "

"; + 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 += + '

' + + __("已加载的适配器", "Loaded Adapters") + + " (" + + adapters.length + + ")

"; + if (adapters.length === 0) { + html += + '

' + + __("暂无适配器", "No adapters") + + "

"; + } else { + html += + ""; + adapters.forEach(function (a) { + html += + "" + + '"; + }); + html += "
" + + __("名称", "Name") + + "" + + __("版本", "Version") + + "" + + __("操作", "Actions") + + "
" + + escHtml(a.name) + + "" + + escHtml(a.version || "-") + + "
"; + } + html += "
"; + html += + '

' + + __("上传新适配器", "Upload New Adapter") + + "

" + + "" + + '' + + "" + + '' + + '
"; + } catch (e) { + html += + '

' + + __("加载适配器失败: ", "Failed to load adapters: ") + + escHtml(e.message) + + "

"; + } + 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(); + // 先加载历史再连 SSE:避免 SSE 事件先到与历史加载顺序不确定导致消息重复/丢失 + (async function () { + await loadChatHistory(); + renderAll(); + connectSSE(); + startUptimeTicker(); + maybeShowPersonaWizard(); + })(); + setInterval(renderAll, 15000); + // 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期 + // 丢失的事件(尤其是非 WebUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。 + // syncChatFromHistory 仅追加新消息 DOM 节点,不重建已有消息,无闪烁。 + setInterval(function () { + syncChatFromHistory().catch(function(){}); + }, 30000); diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 3abfb2b..51315b9 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -21,7 +21,7 @@ import ( // 本文件是 WebUI 的骨架:嵌入式前端资源、Handler 结构、构造、路由表、 // 鉴权/会话/日志中间件与静态页。各资源的具体 handler 见同包 handler_*.go。 -//go:embed dashboard.html mascot.webp logo.svg +//go:embed dashboard.html dashboard.css dashboard.js mascot.webp logo.svg var dashboardFS embed.FS var dashboardHTML string @@ -56,11 +56,30 @@ document.getElementById('login-form').addEventListener('submit',async(e)=>{e.pre document.getElementById('password').addEventListener('keydown',function(e){if(e.key==='Enter')document.getElementById('login-form').dispatchEvent(new Event('submit'))}); ` +// dashboardHTML 是组装好的控制台页面:dashboard.html 外壳 + dashboard.css + dashboard.js。 +// +// 前端刻意没有构建链,所以拆分的办法是:外壳里留 {{DASHBOARD_CSS}} / {{DASHBOARD_JS}} +// 两个占位符,启动时把两个资产原样填回去——**发出的 HTML 与拆分前逐字节一致**, +// 但 6637 行的单文件变成「外壳 + 样式 + 脚本」三份,便于编辑与评审。 func init() { - data, err := dashboardFS.ReadFile("dashboard.html") - if err == nil { - dashboardHTML = string(data) + html, err := dashboardFS.ReadFile("dashboard.html") + if err != nil { + return } + out := string(html) + for _, a := range []struct{ placeholder, file string }{ + {"{{DASHBOARD_CSS}}", "dashboard.css"}, + {"{{DASHBOARD_JS}}", "dashboard.js"}, + } { + body, err := dashboardFS.ReadFile(a.file) + if err != nil { + log.Printf("[webui] 读取前端资产 %s 失败: %v", a.file, err) + continue + } + // 资产文件末尾的换行由占位符所在行自己的换行承担,避免多出空行。 + out = strings.Replace(out, a.placeholder, strings.TrimRight(string(body), "\n"), 1) + } + dashboardHTML = out } type Handler struct { diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 13322ea..1acd10f 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -1292,3 +1292,43 @@ func TestChatPersistenceIsThrottled(t *testing.T) { // Close 幂等 h.Close() } + +// TestDashboardAssetsSplit 钉住前端的拆分方式: +// 外壳 dashboard.html 只留两个占位符,样式与脚本分别在 dashboard.css / dashboard.js, +// 启动时组装回**与拆分前逐字节一致**的页面(拆分当日已用 git 里的原文件比对通过)。 +func TestDashboardAssetsSplit(t *testing.T) { + shell, err := dashboardFS.ReadFile("dashboard.html") + if err != nil { + t.Fatalf("读 dashboard.html: %v", err) + } + for _, ph := range []string{"{{DASHBOARD_CSS}}", "{{DASHBOARD_JS}}"} { + if !strings.Contains(string(shell), ph) { + t.Fatalf("外壳里应保留占位符 %s", ph) + } + } + // 样式/脚本不能又塞回外壳(否则拆分名存实亡) + if strings.Contains(string(shell), "--sakura-300") { + t.Fatal("dashboard.html 里仍内联着样式,拆分未生效") + } + if strings.Contains(string(shell), "function saveSetting") { + t.Fatal("dashboard.html 里仍内联着脚本,拆分未生效") + } + css, err := dashboardFS.ReadFile("dashboard.css") + if err != nil || len(css) == 0 { + t.Fatalf("读 dashboard.css: %v (%d 字节)", err, len(css)) + } + js, err := dashboardFS.ReadFile("dashboard.js") + if err != nil || len(js) == 0 { + t.Fatalf("读 dashboard.js: %v (%d 字节)", err, len(js)) + } + // 组装结果:占位符必须都被替换掉,且样式/脚本内容都在里面 + if strings.Contains(dashboardHTML, "{{DASHBOARD_") { + t.Fatal("组装后的页面仍残留占位符") + } + if !strings.Contains(dashboardHTML, "--sakura-300") { + t.Fatal("组装后的页面缺样式") + } + if !strings.Contains(dashboardHTML, "function saveSetting") { + t.Fatal("组装后的页面缺脚本") + } +}