This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/devtools/public/index.html
T
AskaEth 7eb5e984c2 refactor: 认证系统重构 + DevTools CLI 重写 + 文档全面更新
- auth: Login 简化为管理员始终通过 .env 验证,GetProfile 修正 admin DB 查询
- devtools: .sh/.bat 同步重写为完整 CLI (start/stop/status/logs/build/db:*)
- docs: 新增 devtools.md,重写 Deploy.md (三种方式+Windows说明),更新 README/gateway-api
- voice-service: DashScope 实时流式 STT 支持
- gateway: Phase 6 多模型配置 + 多端客户端管理 + WebSocket 增强

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:55:47 +08:00

4498 lines
209 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cyrene DevTools</title>
<style>
/* ========== CSS Variables (深色主题) ========== */
:root {
--bg: #0f1117; --bg2: #1a1d27; --bg3: #252833; --bg4: #2d3140;
--border: #2d3140; --border2: #383d4a;
--text: #c9d1d9; --text2: #8b949e; --text3: #5d6470;
--accent: #f472b6; --accent2: #ec4899; --accent-bg: rgba(244,114,182,.12);
--green: #22c55e; --green-bg: rgba(34,197,94,.12);
--red: #ef4444; --red-bg: rgba(239,68,68,.12);
--yellow: #eab308; --yellow-bg: rgba(234,179,8,.12);
--blue: #3b82f6; --blue-bg: rgba(59,130,246,.12);
--orange: #f97316; --orange-bg: rgba(249,115,22,.12);
--sidebar-w: 220px; --sidebar-collapsed: 52px;
--radius: 10px; --radius-sm: 6px;
--transition: 0.2s ease;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); font-size: 13px; line-height: 1.5;
display: flex; height: 100vh; overflow: hidden;
}
/* ========== 侧边栏 ========== */
#sidebar {
width: var(--sidebar-w); min-width: var(--sidebar-collapsed);
background: var(--bg2); border-right: 1px solid var(--border);
display: flex; flex-direction: column; transition: width var(--transition);
overflow: hidden; z-index: 10;
}
#sidebar.collapsed { width: var(--sidebar-collapsed); }
#sidebar.collapsed .nav-label, #sidebar.collapsed .sidebar-title, #sidebar.collapsed .sidebar-footer-text { display: none; }
#sidebar.collapsed .nav-item { justify-content: center; padding: 10px 0; }
#sidebar.collapsed .nav-icon { margin-right: 0; }
.sidebar-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px; border-bottom: 1px solid var(--border); min-height: 56px;
}
.sidebar-title {
font-size: 14px; font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--blue));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
white-space: nowrap;
}
#toggle-sidebar {
background: none; border: none; color: var(--text2); cursor: pointer;
font-size: 18px; padding: 2px 6px; border-radius: 4px; line-height: 1;
}
#toggle-sidebar:hover { color: var(--text); background: var(--bg3); }
.sidebar-nav { flex: 1; padding: 8px; display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
border-radius: var(--radius-sm); cursor: pointer; color: var(--text2);
transition: all var(--transition); white-space: nowrap; border: none;
background: none; width: 100%; text-align: left; font-size: 13px;
}
.nav-item:hover { background: var(--bg3); color: var(--text); }
.nav-item.active { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
.nav-icon { font-size: 18px; width: 22px; text-align: center; flex-shrink: 0; }
.nav-label { flex: 1; }
.nav-badge {
background: var(--accent); color: #fff; font-size: 10px; padding: 1px 6px;
border-radius: 10px; font-weight: 600; display: none;
}
.sidebar-footer {
padding: 12px 16px; border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
.sidebar-footer-text { font-size: 11px; color: var(--text3); white-space: nowrap; }
#ws-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
#ws-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
#ws-dot.disconnected { background: var(--red); }
/* ========== 主内容区 ========== */
#main {
flex: 1; display: flex; flex-direction: column; overflow: hidden;
}
.main-header {
padding: 12px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
background: var(--bg2); min-height: 48px;
}
.main-header h2 { font-size: 15px; font-weight: 600; }
.main-header-actions { display: flex; gap: 8px; align-items: center; }
#panel-container {
flex: 1; overflow-y: auto; padding: 20px;
}
.panel { display: none; }
.panel.active { display: block; }
/* ========== 通用组件 ========== */
.card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; margin-bottom: 16px;
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--border);
}
.card-title { font-weight: 600; font-size: 14px; }
.cards-grid { display: grid; gap: 14px; }
.cards-2 { grid-template-columns: 1fr 1fr; }
.cards-3 { grid-template-columns: 1fr 1fr 1fr; }
.cards-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
.stat-card {
background: var(--bg3); border-radius: var(--radius-sm); padding: 14px;
display: flex; flex-direction: column; gap: 4px;
}
.stat-card .stat-value { font-size: 22px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.stat-card .stat-label { font-size: 11px; color: var(--text2); }
.stat-card.accent .stat-value { color: var(--accent); }
.stat-card.green .stat-value { color: var(--green); }
.stat-card.blue .stat-value { color: var(--blue); }
.stat-card.orange .stat-value { color: var(--orange); }
.btn {
padding: 6px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm);
cursor: pointer; font-size: 12px; font-weight: 500; background: var(--bg3);
color: var(--text); transition: all .15s; white-space: nowrap;
font-family: inherit;
}
.btn:hover { background: var(--bg4); border-color: var(--text2); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-xs { padding: 2px 8px; font-size: 10px; border-radius: 4px; }
.btn-accent { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-accent:hover { background: var(--accent2); border-color: var(--accent2); }
.btn-green { background: var(--green); color: #000; border-color: var(--green); }
.btn-green:hover { opacity: .9; }
.btn-red { background: var(--red); color: #fff; border-color: var(--red); }
.btn-red:hover { opacity: .9; }
.btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
/* 表格 */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
th { color: var(--text2); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
tr:hover td { background: rgba(255,255,255,.02); }
tr.expanded td { background: var(--bg3); }
/* 状态徽章 */
.badge {
display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; font-weight: 500;
}
.badge-running, .badge-idle { background: var(--green-bg); color: var(--green); }
.badge-stopped, .badge-error { background: var(--red-bg); color: var(--red); }
.badge-starting, .badge-building, .badge-thinking { background: var(--blue-bg); color: var(--blue); }
.badge-streaming { background: var(--yellow-bg); color: var(--yellow); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
@keyframes bluePulse { 0%,100%{box-shadow:0 0 4px var(--blue)} 50%{box-shadow:0 0 12px var(--blue)} }
.badge-thinking { animation: bluePulse 1.5s infinite; }
/* 表单 */
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: var(--text2); margin-bottom: 4px; font-weight: 500; }
.form-row { display: flex; gap: 10px; align-items: flex-start; }
.form-row > label { flex: 0 0 140px; padding-top: 6px; }
.form-row > :not(label) { flex: 1; }
.form-row .form-row-narrow { flex: 0 0 auto !important; }
input, select, textarea {
width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); font-size: 13px; font-family: inherit;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--accent);
}
textarea { resize: vertical; min-height: 70px; }
input[type="range"] { accent-color: var(--accent); padding: 0; }
/* 日志容器 */
.log-container {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
height: 280px; overflow-y: auto; padding: 10px;
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.6;
}
.log-line { padding: 1px 0; word-break: break-all; }
.log-line .ts { color: var(--text3); margin-right: 6px; }
.log-line.system { color: var(--blue); }
.log-line.stderr { color: var(--red); }
.log-line.error { color: var(--red); font-weight: 600; }
/* 日志标签 */
.log-tabs { display: flex; gap: 0; margin-bottom: 8px; }
.log-tab {
padding: 5px 14px; cursor: pointer; font-size: 12px; font-weight: 500;
color: var(--text2); border-bottom: 2px solid transparent; transition: all .15s;
background: none; border-top: none; border-left: none; border-right: none;
font-family: inherit;
}
.log-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.log-tab:hover { color: var(--text); }
/* 可折叠 */
.collapsible-header {
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; user-select: none;
}
.collapsible-header:hover { color: var(--text); }
.collapsible-body { display: none; margin-top: 12px; }
.collapsible-body.open { display: block; }
.collapse-arrow { transition: transform .2s; font-size: 12px; }
.collapse-arrow.open { transform: rotate(90deg); }
/* 图表 */
.chart-container { width: 100%; height: 140px; position: relative; }
.chart-svg { width: 100%; height: 100%; }
.chart-line { fill: none; stroke-width: 2; }
.chart-line.cpu { stroke: var(--blue); }
.chart-line.mem { stroke: var(--green); }
.chart-area { opacity: .15; }
.chart-area.cpu { fill: var(--blue); }
.chart-area.mem { fill: var(--green); }
.legend { display: flex; gap: 14px; font-size: 11px; color: var(--text2); }
/* 性能仪表盘进度条 */
.perf-dashboard { display: flex; flex-direction: column; gap: 14px; }
.perf-row { display: flex; align-items: center; gap: 12px; }
.perf-label { min-width: 60px; font-size: 12px; color: var(--text2); }
.perf-value { min-width: 52px; font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 600; text-align: right; }
.perf-bar-wrap { flex: 1; background: var(--bg); border-radius: 4px; height: 10px; overflow: hidden; }
.perf-bar { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
.perf-bar.cpu-low, .perf-bar.cpu-mid, .perf-bar.cpu-high, .perf-bar.mem-low, .perf-bar.mem-mid, .perf-bar.mem-high { background: var(--blue); }
.perf-bar.cpu-mid, .perf-bar.mem-mid { background: var(--yellow); }
.perf-bar.cpu-high, .perf-bar.mem-high { background: var(--red); }
.perf-bar.mem-low { background: var(--green); }
.perf-stat { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.perf-stat:last-child { border-bottom: none; }
.perf-stat-icon { font-size: 16px; width: 24px; text-align: center; }
.perf-stat-label { font-size: 12px; color: var(--text2); flex: 1; }
.perf-stat-value { font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 600; }
.legend-item { display: flex; align-items: center; gap: 5px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.legend-dot.cpu { background: var(--blue); }
.legend-dot.mem { background: var(--green); }
/* 空状态 */
.empty-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 32px; color: var(--text2);
}
.empty-state .icon { font-size: 36px; margin-bottom: 8px; }
/* 会话详情展开 */
.session-detail {
background: var(--bg); border: 1px solid var(--border2); border-radius: var(--radius-sm);
padding: 12px; margin-top: 8px;
}
.session-detail .detail-row { display: flex; gap: 20px; margin-bottom: 6px; font-size: 12px; }
.session-detail .detail-label { color: var(--text2); min-width: 80px; }
.msg-list { margin-top: 8px; }
.msg-item {
padding: 6px 10px; background: var(--bg3); border-radius: var(--radius-sm);
margin-bottom: 4px; font-size: 12px;
}
.msg-item .role { font-weight: 600; margin-right: 8px; }
.msg-item .role.user { color: var(--blue); }
.msg-item .role.assistant { color: var(--green); }
.msg-item .role.system { color: var(--yellow); }
/* Toast */
#toast {
position: fixed; bottom: 20px; right: 20px; z-index: 100;
padding: 10px 20px; border-radius: var(--radius-sm); font-size: 13px;
opacity: 0; transform: translateY(10px); transition: all .3s;
pointer-events: none;
}
#toast.show { opacity: 1; transform: translateY(0); }
#toast.success { background: var(--green); color: #000; }
#toast.error { background: var(--red); color: #fff; }
#toast.info { background: var(--blue); color: #fff; }
/* 响应式 */
@media (max-width: 900px) {
.cards-2, .cards-3, .cards-4 { grid-template-columns: 1fr; }
#sidebar { width: var(--sidebar-collapsed); }
#sidebar .nav-label, #sidebar .sidebar-title, #sidebar .sidebar-footer-text { display: none; }
#sidebar .nav-item { justify-content: center; padding: 10px 0; }
#sidebar .nav-icon { margin-right: 0; }
}
/* 服务卡片内的指标 */
.metrics { display: flex; gap: 10px; }
.metric { flex: 1; text-align: center; padding: 6px; background: var(--bg3); border-radius: var(--radius-sm); }
.metric .value { font-size: 16px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.metric .label { font-size: 10px; color: var(--text2); margin-top: 2px; }
/* 仪表盘快速操作 */
.quick-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
/* 刷新按钮旋转 */
/* 页面加载进度条 */
@keyframes loadingBar { 0% { width: 0; } 30% { width: 40%; } 60% { width: 75%; } 100% { width: 95%; } }
.loading-overlay {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 60px 20px; gap: 16px;
}
.loading-progress-bar {
width: 260px; height: 3px; background: var(--bg3); border-radius: 2px; overflow: hidden;
}
.loading-progress-fill {
height: 100%; background: linear-gradient(90deg, var(--accent), var(--blue));
border-radius: 2px; animation: loadingBar 3s ease-out forwards;
}
.loading-text { color: var(--text2); font-size: 13px; }
.loading-detail { color: var(--text3); font-size: 11px; }
.loading-icon { font-size: 36px; animation: loadingPulse 1.2s ease-in-out infinite; }
@keyframes loadingPulse {
0%, 100% { opacity: 0.35; } 50% { opacity: 1; }
}
/* 按钮加载状态 */
.btn.loading {
position: relative; pointer-events: none; opacity: 0.7;
}
.btn.loading::after {
content: ""; position: absolute;
width: 12px; height: 12px; top: 50%; left: 50%; margin: -6px 0 0 -6px;
border: 2px solid transparent; border-top-color: currentColor;
border-radius: 50%; animation: spin .6s linear infinite;
}
.btn.loading .btn-text { visibility: hidden; }
/* 操作反馈闪烁 */
@keyframes flashBorder {
0% { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
100% { border-color: var(--border); box-shadow: none; }
}
.card.action-pending {
animation: flashBorder .6s ease-in-out 3;
border-color: var(--accent);
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spinning { animation: spin 1s linear infinite; }
/* 数据库监看 */
.db-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.db-port-card {
background: var(--bg3); border-radius: var(--radius-sm); padding: 12px;
display: flex; align-items: center; gap: 10px; transition: all .2s;
border: 1px solid transparent;
}
.db-port-card.alive { border-color: var(--green); background: var(--green-bg); }
.db-port-card.dead { border-color: var(--red); background: var(--red-bg); opacity: .7; }
.db-port-card .db-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
}
.db-port-card.alive .db-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.db-port-card.dead .db-dot { background: var(--red); }
.db-port-card .db-info { flex: 1; min-width: 0; }
.db-port-card .db-name { font-size: 12px; font-weight: 600; }
.db-port-card .db-port-label { font-size: 10px; color: var(--text2); font-family: 'JetBrains Mono', monospace; }
.db-summary { display: flex; gap: 20px; align-items: center; padding: 12px 0; }
.db-summary-stat { text-align: center; }
.db-summary-stat .val { font-size: 20px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.db-summary-stat .lbl { font-size: 10px; color: var(--text2); }
.tunnel-log {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
max-height: 200px; overflow-y: auto; padding: 8px; margin-top: 8px;
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.5;
white-space: pre-wrap; word-break: break-all; color: var(--text2);
}
/* IoT 设备控制面板 */
.iot-device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
.iot-device-card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; transition: border-color .2s;
}
.iot-device-card:hover { border-color: var(--accent); }
.iot-device-card.on { border-color: var(--green); }
.iot-device-card.off { border-color: var(--border2); opacity: .85; }
.iot-device-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;
}
.iot-device-name { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
.iot-device-type { font-size: 10px; color: var(--text2); text-transform: uppercase; }
.iot-device-status { display: flex; align-items: center; gap: 6px; }
.iot-status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.iot-status-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
.iot-status-dot.off { background: var(--text3); }
.iot-device-props { margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
.iot-prop-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; padding: 4px 0;
}
.iot-prop-label { color: var(--text2); min-width: 50px; }
.iot-prop-value {
font-family: 'JetBrains Mono', monospace; font-weight: 600; min-width: 45px; text-align: right;
font-size: 12px;
}
.iot-prop-control { display: flex; align-items: center; gap: 6px; flex: 1; justify-content: flex-end; }
.iot-prop-control input[type="range"] { width: 100px; accent-color: var(--accent); }
.iot-device-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.iot-toggle-btn {
padding: 5px 14px; border-radius: var(--radius-sm); border: 1px solid;
cursor: pointer; font-size: 12px; font-weight: 600; transition: all .15s;
font-family: inherit;
}
.iot-toggle-btn.on { background: var(--green-bg); color: var(--green); border-color: var(--green); }
.iot-toggle-btn.on:hover { background: var(--green); color: #000; }
.iot-toggle-btn.off { background: var(--red-bg); color: var(--red); border-color: var(--red); }
.iot-toggle-btn.off:hover { background: var(--red); color: #fff; }
.iot-mode-btn {
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
cursor: pointer; font-size: 11px; background: var(--bg3); color: var(--text);
transition: all .15s; font-family: inherit;
}
.iot-mode-btn:hover { background: var(--bg4); border-color: var(--text2); }
.iot-mode-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
.iot-color-btn {
width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--border);
cursor: pointer; transition: all .15s; flex-shrink: 0;
}
.iot-color-btn:hover { border-color: var(--text2); transform: scale(1.15); }
.iot-color-btn.active { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
.iot-history-panel {
margin-top: 10px; border-top: 1px solid var(--border); padding-top: 8px;
}
.iot-history-item {
font-size: 11px; color: var(--text2); padding: 3px 0; display: flex; gap: 10px;
font-family: 'JetBrains Mono', monospace;
}
.iot-history-item .iot-hist-time { color: var(--text3); min-width: 60px; }
.iot-history-item .iot-hist-action { color: var(--accent); }
.iot-history-item .iot-hist-detail { color: var(--text2); }
.iot-refresh-bar {
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
}
.iot-last-update { font-size: 11px; color: var(--text3); }
/* ========== 记忆卡片样式 ========== */
.mem-card {
transition: all 0.2s ease;
}
.mem-card:hover {
box-shadow: 0 4px 20px rgba(0,0,0,.35);
transform: translateY(-2px);
border-color: var(--accent) !important;
}
.mem-card.mem-card-high:hover {
border-color: #f59e0b !important;
box-shadow: 0 4px 24px rgba(245,158,11,.25);
}
.mem-cat-tab.active {
background: var(--accent) !important;
color: #fff !important;
border-color: var(--accent) !important;
font-weight: 600;
}
/* ========== 记忆时间线样式 ========== */
.timeline-container {
position: relative;
padding-left: 40px;
}
.timeline-container::before {
content: '';
position: absolute;
left: 16px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, var(--blue) 0%, var(--border2) 50%, #a855f7 100%);
border-radius: 1px;
}
.timeline-item {
position: relative;
margin-bottom: 20px;
transition: all .2s ease;
}
.timeline-item:hover {
transform: translateX(2px);
}
.timeline-dot {
position: absolute;
left: -28px;
top: 14px;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
z-index: 2;
border: 2px solid var(--border2);
background: var(--bg2);
transition: all .2s;
}
.timeline-dot.memory {
border-color: var(--blue);
background: var(--blue-bg);
box-shadow: 0 0 8px rgba(59,130,246,.3);
}
.timeline-dot.thinking {
border-color: #a855f7;
background: rgba(168,85,247,.12);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
.timeline-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
cursor: pointer;
transition: all .2s ease;
}
.timeline-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 16px rgba(0,0,0,.2);
}
.timeline-card.memory-card {
border-left: 3px solid var(--blue);
}
.timeline-card.thinking-card {
border-left: 3px solid #a855f7;
}
.timeline-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.timeline-card-title {
font-weight: 600;
font-size: 13px;
flex: 1;
line-height: 1.4;
}
.timeline-card-title.memory { color: #60a5fa; }
.timeline-card-title.thinking { color: #c084fc; }
.timeline-card-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
color: var(--text3);
flex-shrink: 0;
}
.timeline-card-body {
font-size: 12px;
color: var(--text2);
line-height: 1.6;
margin-bottom: 8px;
}
.timeline-card-footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 10px;
color: var(--text3);
border-top: 1px solid var(--border);
padding-top: 8px;
}
.timeline-importance-stars {
color: #f59e0b;
font-size: 11px;
white-space: nowrap;
}
.timeline-trigger-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
}
.timeline-trigger-badge.scheduled { background: var(--blue-bg); color: var(--blue); }
.timeline-trigger-badge.manual { background: var(--orange-bg); color: var(--orange); }
.timeline-detail {
display: none;
margin-top: 10px;
padding: 12px;
background: var(--bg);
border: 1px solid var(--border2);
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.7;
}
.timeline-detail.open {
display: block;
}
.timeline-detail .detail-section {
margin-bottom: 10px;
}
.timeline-detail .detail-section:last-child {
margin-bottom: 0;
}
.timeline-detail .detail-label {
font-weight: 600;
font-size: 11px;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.timeline-detail .detail-content {
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
.timeline-detail .tool-call-item {
padding: 6px 10px;
background: var(--bg3);
border-radius: var(--radius-sm);
margin-bottom: 4px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
}
/* 筛选标签样式 */
.timeline-filter-tab {
padding: 4px 14px;
border-radius: 16px;
border: 1px solid var(--border);
cursor: pointer;
font-size: 12px;
background: var(--bg3);
color: var(--text2);
transition: all .15s;
font-family: inherit;
}
.timeline-filter-tab:hover { background: var(--bg4); color: var(--text); }
.timeline-filter-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
</style>
</head>
<body>
<!-- ========== 侧边栏 ========== -->
<aside id="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">🛠️ DevTools</span>
<button id="toggle-sidebar" title="折叠侧边栏"></button>
</div>
<nav class="sidebar-nav">
<button class="nav-item active" data-panel="dashboard">
<span class="nav-icon">🏠</span><span class="nav-label">仪表盘</span>
</button>
<button class="nav-item" data-panel="memory">
<span class="nav-icon">🧠</span><span class="nav-label">记忆管理</span>
</button>
<button class="nav-item" data-panel="sessions">
<span class="nav-icon">💬</span><span class="nav-label">会话监看</span>
<span class="nav-badge" id="sessions-badge">0</span>
</button>
<button class="nav-item" data-panel="services">
<span class="nav-icon">🖥</span><span class="nav-label">服务管理</span>
</button>
<button class="nav-item" data-panel="performance">
<span class="nav-icon">📊</span><span class="nav-label">性能监控</span>
</button>
<button class="nav-item" data-panel="iot">
<span class="nav-icon">🏠</span><span class="nav-label">IoT 设备</span>
<span class="nav-badge" id="iot-badge" style="display:none">0</span>
</button>
<button class="nav-item" data-panel="toolCalls">
<span class="nav-icon">🔧</span><span class="nav-label">工具调用</span>
</button>
<button class="nav-item" data-panel="stt">
<span class="nav-icon">🎤</span><span class="nav-label">语音识别</span>
<span class="nav-badge" id="stt-badge" style="display:none">0</span>
</button>
<button class="nav-item" data-panel="database">
<span class="nav-icon">🗄️</span><span class="nav-label">数据库监看</span>
<span class="nav-badge" id="db-badge" style="display:none"></span>
</button>
<button class="nav-item" data-panel="thinking">
<span class="nav-icon">💭</span><span class="nav-label">自主思考</span>
</button>
<button class="nav-item" data-panel="timeline">
<span class="nav-icon">⏱️</span><span class="nav-label">记忆时间线</span>
</button>
<button class="nav-item" data-panel="chatPlatforms">
<span class="nav-icon">💬</span><span class="nav-label">第三方聊天</span>
</button>
<button class="nav-item" data-panel="clients">
<span class="nav-icon">📱</span><span class="nav-label">客户端管理</span>
<span class="nav-badge" id="clients-badge" style="display:none">0</span>
</button>
<button class="nav-item" data-panel="modelConfig">
<span class="nav-icon">🤖</span><span class="nav-label">模型配置</span>
</button>
</nav>
<div class="sidebar-footer">
<span id="ws-dot" class="disconnected"></span>
<span class="sidebar-footer-text" id="ws-status-text">未连接</span>
</div>
</aside>
<!-- ========== 主内容区 ========== -->
<div id="main">
<div class="main-header">
<h2 id="panel-title">🏠 仪表盘</h2>
<div class="main-header-actions" id="panel-actions"></div>
</div>
<div id="panel-container">
<!-- 仪表盘 -->
<div class="panel active" id="panel-dashboard"><div class="loading-overlay" id="dashboard-loading"><div class="loading-icon">🛠️</div><div class="loading-text">正在连接服务并加载数据...</div><div class="loading-progress-bar"><div class="loading-progress-fill"></div></div><div class="loading-detail" id="loading-detail">初始化中</div></div></div>
<!-- 记忆管理 -->
<div class="panel" id="panel-memory"></div>
<!-- 会话监看 -->
<div class="panel" id="panel-sessions"></div>
<!-- 服务管理 -->
<div class="panel" id="panel-services"></div>
<!-- IoT 设备控制 -->
<div class="panel" id="panel-iot"></div>
<!-- 性能监控 -->
<div class="panel" id="panel-performance"></div>
<!-- 数据库监看 -->
<div class="panel" id="panel-database"></div>
<!-- 工具调用记录 -->
<div class="panel" id="panel-toolCalls"></div>
<!-- 语音识别日志 -->
<div class="panel" id="panel-stt"></div>
<!-- 自主思考日志 -->
<div class="panel" id="panel-thinking"></div>
<!-- 记忆时间线 -->
<div class="panel" id="panel-timeline"></div>
<!-- 第三方聊天 -->
<div class="panel" id="panel-chatPlatforms"></div>
<!-- 客户端管理 -->
<div class="panel" id="panel-clients"></div>
<div class="panel" id="panel-modelConfig"></div>
</div>
</div>
<!-- Toast -->
<div id="toast"></div>
<script>
// ========== 全局状态 ==========
const STATE = {
activePanel: 'dashboard',
sidebarCollapsed: false,
// 仪表盘
dashboardData: null,
// 服务
serviceStatus: {},
// 日志
activeLogTab: 'ai-core',
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [], 'memory-service': [], 'tool-engine': [], 'voice-service': [] },
maxLogLines: 500,
logLayout: 'grid',
// 性能
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [], 'memory-service': [], 'tool-engine': [], 'voice-service': [] },
// 会话
sessionsData: [],
sessionsAutoRefresh: null,
// 计时器
dashboardInterval: null,
statusInterval: null,
dbInterval: null,
// 仪表盘增量刷新 (Bug 7)
dashboardRenderCount: 0,
// 资源使用 60s 滑动窗口历史 (Bug 6)
resourceHistory: {},
// 记忆面板状态
memoryCache: [],
memoryUserId: 'admin',
memoryFilterCategory: 'all',
memorySortBy: 'importance',
memorySortDir: 'desc',
memoryFilterImportance: 0,
memorySearchText: '',
memoryPanelInitialized: false,
// STT 语音识别日志面板状态
sttLogs: [],
sttAutoRefresh: null,
sttAutoRefreshInterval: null,
// 时间线面板状态
timelineData: [],
timelineUserId: 'admin',
timelineFilterType: 'all',
timelineAutoRefresh: null,
timelineLimit: 100,
// 第三方聊天
chatConfigsAutoRefresh: null,
chatConfigs: [],
chatActivePlatform: null,
chatLogLimit: 100,
// 自主思考面板:记录展开的日志 ID
// 模型配置
modelConfigTab: 'providers',
modelConfigProviders: [],
modelConfigModels: [],
modelConfigRouting: [],
fetchedModels: [],
expandedThinkingLogs: {},
};
// ========== WebSocket ==========
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
let ws = null, wsRetryTimer = null;
function connectWS() {
if (ws && ws.readyState === WebSocket.OPEN) return;
try {
ws = new WebSocket(`${protocol}//${location.host}/ws`);
ws.onopen = () => {
document.getElementById('ws-dot').className = 'connected';
document.getElementById('ws-status-text').textContent = '已连接';
};
ws.onclose = () => {
document.getElementById('ws-dot').className = 'disconnected';
document.getElementById('ws-status-text').textContent = '断开(重连中)';
wsRetryTimer = setTimeout(connectWS, 3000);
};
ws.onerror = () => { document.getElementById('ws-dot').className = 'disconnected'; };
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'log') handleWSLog(msg.data);
if (msg.type === 'stt-log') handleSTTLog(msg);
if (msg.type === 'voice_transcript') handleVoiceTranscript(msg);
if (msg.type === 'status') {
STATE.serviceStatus = msg.data;
if (STATE.activePanel === 'services') renderServiceCards();
if (STATE.activePanel === 'dashboard') renderDashboard();
}
};
} catch(e) { setTimeout(connectWS, 3000); }
}
function handleWSLog(data) {
const { service, stream, text } = data;
if (!STATE.logLines[service]) STATE.logLines[service] = [];
const now = new Date();
const ts = now.toTimeString().slice(0,8);
STATE.logLines[service].push({ ts, stream, text });
if (STATE.logLines[service].length > STATE.maxLogLines) {
STATE.logLines[service].splice(0, STATE.logLines[service].length - STATE.maxLogLines);
}
if (STATE.activePanel === 'services') {
if (STATE.logLayout === 'tabs' && service === STATE.activeLogTab) renderServiceLog();
else if (STATE.logLayout === 'grid') renderGridLog(service);
}
}
function handleSTTLog(msg) {
const entry = msg.data || msg;
if (!entry || !entry.id) return;
// 添加到本地状态缓存(去重)
const exists = STATE.sttLogs.find(function(e) { return e.id === entry.id; });
if (!exists) {
STATE.sttLogs.unshift(entry);
if (STATE.sttLogs.length > 200) STATE.sttLogs.length = 200;
}
// 更新 badge
updateSTTBadge();
// 如果当前正在查看 STT 面板,增量更新表格
if (STATE.activePanel === 'stt') {
prependSTTTableRow(entry);
}
}
function updateSTTBadge() {
const badge = document.getElementById('stt-badge');
if (!badge) return;
const count = STATE.sttLogs.length;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
// ========== 工具函数 ==========
function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
function drawSparkline(canvas, data, color) {
if (!canvas || data.length < 2) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * w;
const y = h - ((data[i] - min) / range) * (h - 4) - 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
function formatUptime(ms) {
if (!ms || ms < 0) return '—';
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}h ${m%60}m`;
if (m > 0) return `${m}m ${s%60}s`;
return `${s}s`;
}
function formatTime(ts) {
if (!ts) return '—';
const d = new Date(ts);
return d.toLocaleString('zh-CN', { hour12: false });
}
function timeAgo(ts) {
if (!ts) return '—';
const diff = Date.now() - new Date(ts).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}秒前`;
if (s < 3600) return `${Math.floor(s/60)}分钟前`;
if (s < 86400) return `${Math.floor(s/3600)}小时前`;
return `${Math.floor(s/86400)}天前`;
}
function statusBadge(status) {
const map = {
running: 'badge-running', idle: 'badge-idle',
stopped: 'badge-stopped', error: 'badge-error',
starting: 'badge-starting', building: 'badge-building',
thinking: 'badge-thinking', streaming: 'badge-streaming',
};
return map[status] || 'badge-stopped';
}
const ALL_SVC_IDS = ['ai-core', 'gateway', 'frontend', 'iot-debug-service', 'memory-service', 'tool-engine', 'voice-service'];
function escapeId(id) {
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug', 'memory-service': 'Memory', 'tool-engine': 'Tool Engine', 'voice-service': 'Voice' };
return map[id] || id;
}
async function api(url, opts = {}) {
try {
const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
if (!res.ok) {
const text = await res.text();
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = null; }
return {
error: parsed?.error || text,
errorType: parsed?.errorType || null,
hint: parsed?.hint || null,
status: res.status,
};
}
const body = await res.json();
// 即使 HTTP 200body 中也可能包含 error 字段(如 Gateway 代理失败返回的 502
if (body && body.error) {
return { ...body, status: res.status };
}
return body;
} catch (err) {
return { error: err.message, status: 0 };
}
}
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
clearTimeout(t._timeout);
t._timeout = setTimeout(() => { t.className = ''; }, 3000);
}
// ========== 侧边栏导航 ==========
const panels = {};
document.querySelectorAll('.panel').forEach(p => { panels[p.id.replace('panel-','')] = p; });
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => switchPanel(btn.dataset.panel));
});
document.getElementById('toggle-sidebar').addEventListener('click', () => {
STATE.sidebarCollapsed = !STATE.sidebarCollapsed;
document.getElementById('sidebar').classList.toggle('collapsed', STATE.sidebarCollapsed);
});
function switchPanel(name) {
STATE.activePanel = name;
// Update URL hash (without triggering hashchange).
if (location.hash !== '#' + name) {
history.replaceState(null, '', '#' + name);
}
// 更新侧边栏
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-item[data-panel="${name}"]`);
if (navBtn) navBtn.classList.add('active');
// 更新标题
const titles = {
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
toolCalls: '🔧 工具调用记录', stt: '🎤 语音识别日志', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
chatPlatforms: '💬 第三方聊天配置与消息日志',
clients: '📱 客户端管理',
modelConfig: '🤖 模型配置管理',
};
document.getElementById('panel-title').textContent = titles[name] || name;
// 切换面板
Object.values(panels).forEach(p => p.classList.remove('active'));
if (panels[name]) panels[name].classList.add('active');
// 清除面板操作区
document.getElementById('panel-actions').innerHTML = '';
// 渲染面板
switch (name) {
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
}
}
function stopDashboardAutoRefresh() {
if (STATE.dashboardInterval) { clearInterval(STATE.dashboardInterval); STATE.dashboardInterval = null; }
}
function stopSessionsAutoRefresh() {
if (STATE.sessionsAutoRefresh) { clearInterval(STATE.sessionsAutoRefresh); STATE.sessionsAutoRefresh = null; }
}
function startDashboardAutoRefresh() {
stopDashboardAutoRefresh();
STATE.dashboardInterval = setInterval(() => {
if (STATE.activePanel === 'dashboard') renderDashboard();
}, 5000);
}
function startSessionsAutoRefresh() {
stopSessionsAutoRefresh();
STATE.sessionsAutoRefresh = setInterval(() => {
if (STATE.activePanel === 'sessions') loadSessions();
}, 5000);
}
function startDbAutoRefresh() {
stopDbAutoRefresh();
STATE.dbInterval = setInterval(() => {
if (STATE.activePanel === 'database') renderDatabasePanel();
}, 5000);
}
function stopDbAutoRefresh() {
if (STATE.dbInterval) { clearInterval(STATE.dbInterval); STATE.dbInterval = null; }
}
// ========== 面板1: 仪表盘 ==========
async function renderDashboard() {
// 更新加载提示(如果加载覆盖层还在)
var loadDetail = document.getElementById("loading-detail");
if (loadDetail) loadDetail.textContent = "正在加载仪表盘数据...";
const data = await api('/api/dashboard');
if (data.error) {
document.getElementById('panel-dashboard').innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
STATE.dashboardRenderCount = 0;
return;
}
STATE.dashboardData = data;
const svcs = data.services?.list || {};
const runningCount = data.services?.running || 0;
const totalSvcs = data.services?.total || Object.keys(svcs).length;
const isFirstRender = STATE.dashboardRenderCount === 0;
// Bug 7: 首次渲染完整 DOM,后续只做增量更新
if (isFirstRender) {
document.getElementById('panel-dashboard').innerHTML =
'<!-- 概览统计 -->' +
'<div class="cards-grid cards-4" style="margin-bottom:16px">' +
'<div class="stat-card green">' +
'<div class="stat-value" id="stat-running">' + runningCount + '/' + totalSvcs + '</div>' +
'<div class="stat-label">服务运行中</div>' +
'</div>' +
'<div class="stat-card blue">' +
'<div class="stat-value" id="stat-sessions">' + (data.sessions?.active ?? '—') + '</div>' +
'<div class="stat-label">活跃会话</div>' +
'</div>' +
'<div class="stat-card accent">' +
'<div class="stat-value" id="stat-memory">' + (data.memory?.total ?? '—') + '</div>' +
'<div class="stat-label">记忆条目</div>' +
'</div>' +
'<div class="stat-card orange">' +
'<div class="stat-value" id="stat-heap">' + (data.system?.heapUsedMB ?? '—') + ' MB</div>' +
'<div class="stat-label">DevTools 内存</div>' +
'</div>' +
'</div>' +
'<!-- 服务状态卡片 -->' +
'<div class="card">' +
'<div class="card-header">' +
'<span class="card-title">📡 服务状态</span>' +
'<div class="quick-actions">' +
'<button class="btn btn-sm btn-accent" onclick="svcAction(\'start-all\')">▶ 一键启动</button>' +
'<button class="btn btn-sm" onclick="svcAction(\'start-all-fresh\')">🔄 强制重启全部</button>' +
'<button class="btn btn-sm btn-red" onclick="svcAction(\'stop-all\')">⏹ 全部停止</button>' +
'</div>' +
'</div>' +
'<div class="cards-grid cards-4" id="dashboard-svc-cards"></div>' +
'</div>' +
'<!-- 数据库状态卡片 -->' +
'<div class="card" id="db-card">' +
'<div class="card-header">' +
'<span class="card-title">🗄️ 数据库</span>' +
'<span class="badge badge-stopped" id="db-status-badge">检查中...</span>' +
'</div>' +
'<div class="metrics">' +
'<div class="metric"><div class="value" id="db-type-display">PostgreSQL</div><div class="label">类型</div></div>' +
'<div class="metric"><div class="value" id="db-port-display">5432</div><div class="label">端口</div></div>' +
'<div class="metric"><div class="value" id="db-uptime-display">—</div><div class="label">状态</div></div>' +
'</div>' +
'<div class="btn-group" style="margin-top:10px">' +
'<button class="btn btn-xs btn-green" id="db-card-start-btn" onclick="controlDB(\'start\')">▶ 启动</button>' +
'<button class="btn btn-xs btn-red" id="db-card-stop-btn" onclick="controlDB(\'stop\')">⏹ 停止</button>' +
'<button class="btn btn-xs" id="db-card-restart-btn" onclick="controlDB(\'restart\')">🔄 重启</button>' +
'<a href="#" onclick="switchPanel(\'database\');return false" style="font-size:10px;color:var(--accent);text-decoration:none;margin-left:auto;align-self:center">🔍 详情 →</a>' +
'</div>' +
'</div>' +
'<!-- 性能快照 + 性能仪表盘 -->' +
'<div class="cards-grid cards-2">' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">⚡ 资源使用</span></div>' +
'<div id="dashboard-perf"></div>' +
'</div>' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">📊 性能仪表盘</span></div>' +
'<div id="performance-dashboard">' +
'<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>' +
'</div>' +
'</div>' +
'</div>' +
'<!-- 系统信息 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">💻 系统信息</span></div>' +
'<div style="display:flex;gap:20px;font-size:12px;flex-wrap:wrap" id="sys-info-row">' +
'<div><span style="color:var(--text2)">运行时间:</span> <span id="sys-uptime">' + formatUptime((data.system?.uptime || 0) * 1000) + '</span></div>' +
'<div><span style="color:var(--text2)">堆内存:</span> <span id="sys-heap">' + (data.system?.heapUsedMB ?? '—') + ' MB / ' + (data.system?.heapTotalMB ?? '—') + ' MB</span></div>' +
'<div><span style="color:var(--text2)">总消息数:</span> <span id="sys-msgs">' + (data.sessions?.totalMessages ?? 0) + '</span></div>' +
'<div><span style="color:var(--text2)">更新时间:</span> <span id="sys-time">' + formatTime(data.timestamp) + '</span></div>' +
'</div>' +
'</div>';
} else {
// Bug 7: 增量更新 — 只更新动态数值,不重建整个 DOM
var el;
el = document.getElementById('stat-running'); if (el) el.textContent = runningCount + '/' + totalSvcs;
el = document.getElementById('stat-sessions'); if (el) el.textContent = data.sessions?.active ?? '—';
el = document.getElementById('stat-memory'); if (el) el.textContent = data.memory?.total ?? '—';
el = document.getElementById('stat-heap'); if (el) el.textContent = (data.system?.heapUsedMB ?? '—') + ' MB';
// 系统信息
el = document.getElementById('sys-uptime'); if (el) el.textContent = formatUptime((data.system?.uptime || 0) * 1000);
el = document.getElementById('sys-heap'); if (el) el.textContent = (data.system?.heapUsedMB ?? '—') + ' MB / ' + (data.system?.heapTotalMB ?? '—') + ' MB';
el = document.getElementById('sys-msgs'); if (el) el.textContent = data.sessions?.totalMessages ?? 0;
el = document.getElementById('sys-time'); if (el) el.textContent = formatTime(data.timestamp);
}
// 渲染服务卡片
renderDashboardSvcCards(svcs);
// 渲染数据库卡片 (renderDBCard 本身就只更新 textContent,见 Bug 7 fix)
renderDBCard();
// Bug 6: 渲染资源使用卡片 (增量更新 + sparkline)
renderResourceUsage(data.performance?.perService || {});
// 渲染性能仪表盘 (updatePerformanceDashboard 内联更新)
updatePerformanceDashboard(data.performance?.perService || {});
STATE.dashboardRenderCount++;
}
// ========== 资源使用卡片渲染 (Bug 6: sparkline + 增量更新) ==========
function renderResourceUsage(perfData) {
const container = document.getElementById('dashboard-perf');
if (!container) return;
const entries = Object.entries(perfData);
const MAX_HISTORY = 60;
const firstRender = STATE.dashboardRenderCount === 0;
// 首次渲染: 创建完整 DOM 结构 (含 canvas)
if (firstRender || entries.length > 0 && !container.querySelector('.resource-row')) {
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📊</div>等待采样数据...</div>';
return;
}
container.innerHTML = entries.map(function (kv) {
const id = kv[0], p = kv[1];
return '<div class="resource-row" data-svc="' + id + '" style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);gap:8px">' +
'<span style="font-weight:500;min-width:70px">' + escapeId(id) + '</span>' +
'<canvas id="sparkline-' + id + '" width="120" height="28" style="flex-shrink:0"></canvas>' +
'<span class="resource-val" style="font-family:\'JetBrains Mono\',monospace;font-size:12px;color:var(--text2);min-width:110px;text-align:right">' +
'CPU ' + (p.cpu || 0) + '% | MEM ' + (p.mem || 0) + 'MB' +
'</span>' +
'</div>';
}).join('');
}
// 增量更新: 只更新数值和 sparkline
const rows = container.querySelectorAll('.resource-row');
rows.forEach(function (row) {
const svcId = row.getAttribute('data-svc');
const p = perfData[svcId];
if (!p) return;
// 更新数值
const valEl = row.querySelector('.resource-val');
if (valEl) valEl.textContent = 'CPU ' + (p.cpu || 0) + '% | MEM ' + (p.mem || 0) + 'MB';
// 更新 60s 滑动窗口历史
if (!STATE.resourceHistory[svcId]) STATE.resourceHistory[svcId] = { cpu: [], mem: [] };
var h = STATE.resourceHistory[svcId];
h.cpu.push(p.cpu || 0);
h.mem.push(p.mem || 0);
if (h.cpu.length > MAX_HISTORY) h.cpu.shift();
if (h.mem.length > MAX_HISTORY) h.mem.shift();
// 绘制 CPU sparkline
var cpuCanvas = document.getElementById('sparkline-' + svcId);
if (cpuCanvas) drawSparkline(cpuCanvas, h.cpu, '#3b82f6');
});
}
// ========== 性能仪表盘渲染 ==========
async function updatePerformanceDashboard(perfData) {
const container = document.getElementById('performance-dashboard');
if (!container) return; // 静默跳过:用户在其他页面
const entries = Object.entries(perfData);
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>';
return;
}
// 聚合数据
let totalCpu = 0, totalMem = 0, activeCount = 0;
for (const [, p] of entries) {
totalCpu += p.cpu || 0;
totalMem += p.mem || 0;
if (p.pid) activeCount++;
}
const avgCpu = entries.length > 0 ? Math.round(totalCpu / entries.length * 10) / 10 : 0;
const cpuLevel = avgCpu > 80 ? 'cpu-high' : avgCpu > 50 ? 'cpu-mid' : 'cpu-low';
const memLevel = totalMem > 1024 ? 'mem-high' : totalMem > 512 ? 'mem-mid' : 'mem-low';
// 计算平均延迟 (基于活跃连接和服务数估算,或使用 perf 数据中的 elapsed)
let avgLatency = '—';
let totalElapsed = 0, elapsedCount = 0;
for (const [, p] of entries) {
if (p.elapsed && p.elapsed > 0) { totalElapsed += p.elapsed; elapsedCount++; }
}
if (elapsedCount > 0) {
avgLatency = Math.round(totalElapsed / elapsedCount) + 'ms';
}
// 获取趋势数据 (从性能仪表盘 API)
let trendCpu = '→', trendMem = '→';
try {
const dashResp = await api('/api/performance/dashboard');
if (!dashResp.error && dashResp.summary?.trend) {
const t = dashResp.summary.trend;
trendCpu = t.cpu === 'up' ? '↑' : t.cpu === 'down' ? '↓' : '→';
trendMem = t.mem === 'up' ? '↑' : t.mem === 'down' ? '↓' : '→';
// 使用 API 返回的更精确的延迟数据
if (dashResp.summary.avgLatencyMs != null) {
avgLatency = dashResp.summary.avgLatencyMs + 'ms';
}
}
} catch { /* 忽略: 使用本地计算的数据 */ }
// Bug 7: 增量更新 — 首次创建 DOM 结构,后续只更新数值
const isFirstRender = !container.querySelector('.perf-dashboard');
if (isFirstRender) {
container.innerHTML =
'<div class="perf-dashboard">' +
'<!-- CPU 使用率 -->' +
'<div class="perf-row">' +
'<span class="perf-label">🖥 CPU</span>' +
'<div class="perf-bar-wrap">' +
'<div class="perf-bar ' + cpuLevel + '" id="perf-cpu-bar" style="width:' + Math.min(avgCpu, 100) + '%"></div>' +
'</div>' +
'<span class="perf-value" id="perf-cpu-val">' + avgCpu + '% ' + trendCpu + '</span>' +
'</div>' +
'<!-- 内存使用 -->' +
'<div class="perf-row">' +
'<span class="perf-label">💾 内存</span>' +
'<div class="perf-bar-wrap">' +
'<div class="perf-bar ' + memLevel + '" id="perf-mem-bar" style="width:' + Math.min(totalMem / 1024 * 100, 100) + '%"></div>' +
'</div>' +
'<span class="perf-value" id="perf-mem-val">' + Math.round(totalMem) + ' MB ' + trendMem + '</span>' +
'</div>' +
'<!-- 详细统计 -->' +
'<div style="margin-top:8px">' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">⏱</span>' +
'<span class="perf-stat-label">平均请求延迟</span>' +
'<span class="perf-stat-value" id="perf-latency" style="color:var(--yellow)">' + avgLatency + '</span>' +
'</div>' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">🔗</span>' +
'<span class="perf-stat-label">活跃连接数</span>' +
'<span class="perf-stat-value" id="perf-conns" style="color:var(--accent)">' + activeCount + '</span>' +
'</div>' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">📦</span>' +
'<span class="perf-stat-label">监控服务数</span>' +
'<span class="perf-stat-value" id="perf-svcs" style="color:var(--blue)">' + entries.length + '</span>' +
'</div>' +
'</div>' +
'</div>';
} else {
// 增量更新: 只更新数值
var cpuBar = document.getElementById('perf-cpu-bar');
if (cpuBar) {
cpuBar.className = 'perf-bar ' + cpuLevel;
cpuBar.style.width = Math.min(avgCpu, 100) + '%';
}
var cpuVal = document.getElementById('perf-cpu-val');
if (cpuVal) cpuVal.textContent = avgCpu + '% ' + trendCpu;
var memBar = document.getElementById('perf-mem-bar');
if (memBar) {
memBar.className = 'perf-bar ' + memLevel;
memBar.style.width = Math.min(totalMem / 1024 * 100, 100) + '%';
}
var memVal = document.getElementById('perf-mem-val');
if (memVal) memVal.textContent = Math.round(totalMem) + ' MB ' + trendMem;
var latEl = document.getElementById('perf-latency');
if (latEl) latEl.textContent = avgLatency;
var connEl = document.getElementById('perf-conns');
if (connEl) connEl.textContent = activeCount;
var svcEl = document.getElementById('perf-svcs');
if (svcEl) svcEl.textContent = entries.length;
}
}
function renderDashboardSvcCards(svcs) {
const container = document.getElementById('dashboard-svc-cards');
if (!container) return;
container.innerHTML = Object.entries(svcs).map(([id, svc]) => `
<div class="card" style="margin:0">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<span style="font-weight:600">${svc.name}</span>
<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>
</div>
<div class="metrics">
<div class="metric"><div class="value">${svc.pid || '—'}</div><div class="label">PID</div></div>
<div class="metric"><div class="value">${svc.port}</div><div class="label">端口</div></div>
<div class="metric"><div class="value">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
</div>
<div class="btn-group" style="margin-top:10px">
${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶</button>` : ''}
${svc.status === 'running' ? `<button class="btn btn-xs" onclick="svcAction('restart','${id}')">🔄</button>` : ''}
${svc.status === 'running' || svc.status === 'starting' ? `<button class="btn btn-xs btn-red" onclick="svcAction('stop','${id}')">⏹</button>` : ''}
${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-accent" onclick="svcAction('build','${id}')">🔨</button>` : ''}
</div>
</div>
`).join('');
}
// ========== 记忆分类颜色映射 ==========
const MEMORY_CAT_COLORS = {
'user_preference': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '用户偏好', icon: '💜' },
'personal_info': { bg: 'rgba(59,130,246,.15)', text: '#3b82f6', name: '个人信息', icon: '👤' },
'conversation': { bg: 'rgba(34,197,94,.15)', text: '#22c55e', name: '对话摘要', icon: '💬' },
'knowledge': { bg: 'rgba(249,115,22,.15)', text: '#f97316', name: '知识信息', icon: '📚' },
'event': { bg: 'rgba(239,68,68,.15)', text: '#ef4444', name: '事件记录', icon: '📅' },
'task': { bg: 'rgba(234,179,8,.15)', text: '#eab308', name: '任务计划', icon: '✅' },
'relationship': { bg: 'rgba(236,72,153,.15)', text: '#ec4899', name: '关系情感', icon: '💕' },
// 向后兼容旧分类
'preference': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '偏好', icon: '💜' },
'fact': { bg: 'rgba(59,130,246,.15)', text: '#3b82f6', name: '事实', icon: '👤' },
'experience': { bg: 'rgba(249,115,22,.15)', text: '#f97316', name: '经验', icon: '📚' },
'other': { bg: 'rgba(139,148,158,.15)', text: '#8b949e', name: '其他', icon: '📌' },
'habit': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '习惯', icon: '💜' },
};
function getCatColor(cat) {
return MEMORY_CAT_COLORS[cat] || MEMORY_CAT_COLORS['other'];
}
// ========== 面板2: 记忆管理 ==========
function renderMemoryPanel() {
const isFirst = !STATE.memoryPanelInitialized;
STATE.memoryPanelInitialized = true;
// 首次渲染完整 DOM 结构
if (isFirst) {
document.getElementById('panel-memory').innerHTML = `
<!-- 搜索 & 添加行 -->
<div class="cards-grid cards-2" style="margin-bottom:14px">
<div class="card" style="margin:0">
<div class="card-header"><span class="card-title">🔍 搜索与筛选</span></div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>用户ID</label>
<input type="text" id="mem-user-id" placeholder="admin" value="${escHtml(STATE.memoryUserId)}">
</div>
<div class="form-group" style="flex:2">
<label>全文搜索</label>
<input type="text" id="mem-search-text" placeholder="输入关键词搜索记忆内容..." value="${escHtml(STATE.memorySearchText)}"
oninput="STATE.memorySearchText=this.value;filterAndRenderMemories()">
</div>
</div>
<div class="form-row" style="margin-top:4px">
<div class="form-group" style="flex:1">
<label>最低重要性 ≥ <span id="mem-imp-val">${STATE.memoryFilterImportance}</span></label>
<input type="range" id="mem-filter-importance" min="0" max="10" value="${STATE.memoryFilterImportance}"
oninput="document.getElementById('mem-imp-val').textContent=this.value;STATE.memoryFilterImportance=parseInt(this.value);filterAndRenderMemories()">
</div>
<div class="form-group" style="flex:1;display:flex;align-items:flex-end">
<button class="btn btn-accent btn-sm" onclick="loadMemories()" style="width:100%">🔍 查询记忆</button>
</div>
</div>
</div>
<div class="card" style="margin:0">
<div class="card-header"><span class="card-title"> 添加记忆</span></div>
<div class="form-group"><label>用户ID</label><input type="text" id="mem-add-user-id" placeholder="admin" value="admin"></div>
<div class="form-group"><label>内容</label><textarea id="mem-add-content" placeholder="输入记忆内容..." rows="2"></textarea></div>
<div class="form-row">
<div class="form-group">
<label>分类</label>
<select id="mem-add-category">
<option value="user_preference">用户偏好</option>
<option value="personal_info">个人信息</option>
<option value="conversation">对话摘要</option>
<option value="knowledge">知识信息</option>
<option value="event">事件记录</option>
<option value="task">任务计划</option>
<option value="relationship">关系情感</option>
</select>
</div>
<div class="form-group">
<label>重要程度: <span id="mem-priority-val">5</span></label>
<input type="range" id="mem-add-importance" min="1" max="10" value="5" oninput="document.getElementById('mem-priority-val').textContent=this.value">
</div>
</div>
<button class="btn btn-accent btn-sm" onclick="addMemory()"> 添加</button>
</div>
</div>
<!-- 统计面板 -->
<div class="card" id="mem-stats-card" style="margin-bottom:14px">
<div class="card-header"><span class="card-title">📊 记忆统计</span></div>
<div id="mem-stats-content">
<div class="empty-state"><div class="icon">📊</div>加载记忆后显示统计</div>
</div>
</div>
<!-- 分类筛选标签栏 -->
<div class="card" style="margin-bottom:10px;padding:12px 16px">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
<div id="mem-cat-tabs" style="display:flex;gap:6px;flex-wrap:wrap">
<!-- 动态填充 -->
</div>
<div style="display:flex;align-items:center;gap:8px">
<select id="mem-sort-by" onchange="STATE.memorySortBy=this.value;filterAndRenderMemories()" style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">
<option value="importance" ${STATE.memorySortBy==='importance'?'selected':''}>⭐ 重要性</option>
<option value="created_at" ${STATE.memorySortBy==='created_at'?'selected':''}>🕐 创建时间</option>
<option value="updated_at" ${STATE.memorySortBy==='updated_at'?'selected':''}>🕐 更新时间</option>
<option value="category" ${STATE.memorySortBy==='category'?'selected':''}>🏷️ 分类</option>
<option value="access_count" ${STATE.memorySortBy==='access_count'?'selected':''}>📊 访问次数</option>
</select>
<select id="mem-sort-dir" onchange="STATE.memorySortDir=this.value;filterAndRenderMemories()" style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">
<option value="desc" ${STATE.memorySortDir==='desc'?'selected':''}>↓ 降序</option>
<option value="asc" ${STATE.memorySortDir==='asc'?'selected':''}>↑ 升序</option>
</select>
<span id="mem-result-count" style="font-size:11px;color:var(--text2);white-space:nowrap"></span>
</div>
</div>
</div>
<!-- 记忆卡片网格 -->
<div id="mem-cards-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px">
<div class="empty-state" style="grid-column:1/-1"><div class="icon">🧠</div>点击「查询记忆」加载记忆数据</div>
</div>
`;
// 初始化分类标签
renderCategoryTabs();
}
// 如果有缓存数据,直接渲染
if (STATE.memoryCache.length > 0) {
filterAndRenderMemories();
}
}
function renderCategoryTabs() {
const container = document.getElementById('mem-cat-tabs');
if (!container) return;
const categories = [
{ key: 'all', name: '全部', icon: '📋' },
{ key: 'user_preference', name: '用户偏好', icon: '💜' },
{ key: 'personal_info', name: '个人信息', icon: '👤' },
{ key: 'conversation', name: '对话摘要', icon: '💬' },
{ key: 'knowledge', name: '知识信息', icon: '📚' },
{ key: 'event', name: '事件记录', icon: '📅' },
{ key: 'task', name: '任务计划', icon: '✅' },
{ key: 'relationship', name: '关系情感', icon: '💕' },
];
container.innerHTML = categories.map(c => {
const active = STATE.memoryFilterCategory === c.key;
const style = active
? 'background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600'
: 'background:var(--bg3);color:var(--text2);border-color:var(--border)';
return `<button class="mem-cat-tab" data-cat="${c.key}"
style="padding:4px 12px;border-radius:16px;border:1px solid;cursor:pointer;font-size:11px;transition:all .15s;font-family:inherit;${style}"
onmouseenter="if(!this.classList.contains('active')){this.style.background='var(--bg4)';this.style.color='var(--text)'}"
onmouseleave="if(!this.classList.contains('active')){this.style.background='var(--bg3)';this.style.color='var(--text2)'}"
onclick="switchMemoryCategory('${c.key}')">${c.icon} ${c.name}</button>`;
}).join('');
}
function switchMemoryCategory(cat) {
STATE.memoryFilterCategory = cat;
renderCategoryTabs();
filterAndRenderMemories();
}
async function loadMemories() {
const userId = document.getElementById('mem-user-id').value.trim();
if (!userId) { showToast('请输入用户ID', 'error'); return; }
STATE.memoryUserId = userId;
const data = await api(`/api/memory/list?user_id=${encodeURIComponent(userId)}`);
if (data.error) {
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 和 AI-Core 服务已启动</span>';
}
const grid = document.getElementById('mem-cards-grid');
if (grid) grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="icon">⚠️</div>${escHtml(data.error)}${hint}</div>`;
document.getElementById('mem-result-count').textContent = '';
STATE.memoryCache = [];
renderStatsPanel();
return;
}
let memories = [];
if (Array.isArray(data)) memories = data;
else if (data.memories) memories = data.memories;
else if (data.results) memories = data.results;
STATE.memoryCache = memories;
filterAndRenderMemories();
}
async function searchMemory() {
const userId = document.getElementById('mem-user-id').value.trim();
const q = STATE.memorySearchText || document.getElementById('mem-search-text')?.value?.trim() || '';
if (!userId) { showToast('请输入用户ID', 'error'); return; }
if (!q) { showToast('请输入搜索关键词', 'error'); return; }
STATE.memoryUserId = userId;
const data = await api(`/api/memory/search?user_id=${encodeURIComponent(userId)}&q=${encodeURIComponent(q)}`);
if (data.error) {
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 和 AI-Core 服务已启动</span>';
}
const grid = document.getElementById('mem-cards-grid');
if (grid) grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="icon">⚠️</div>${escHtml(data.error)}${hint}</div>`;
document.getElementById('mem-result-count').textContent = '';
STATE.memoryCache = [];
renderStatsPanel();
return;
}
let memories = [];
if (Array.isArray(data)) memories = data;
else if (data.memories) memories = data.memories;
else if (data.results) memories = data.results;
STATE.memoryCache = memories;
filterAndRenderMemories();
}
// 兼容旧的 listMemory 调用
async function listMemory() {
loadMemories();
}
function filterAndRenderMemories() {
const memories = STATE.memoryCache || [];
// 1. 分类筛选
let filtered = memories;
if (STATE.memoryFilterCategory !== 'all') {
filtered = filtered.filter(m => m.category === STATE.memoryFilterCategory);
}
// 2. 重要性筛选
if (STATE.memoryFilterImportance > 0) {
filtered = filtered.filter(m => (m.importance || 0) >= STATE.memoryFilterImportance);
}
// 3. 全文搜索 (客户端二次过滤)
if (STATE.memorySearchText) {
const q = STATE.memorySearchText.toLowerCase();
filtered = filtered.filter(m => {
const content = (m.content || '').toLowerCase();
const summary = (m.summary || '').toLowerCase();
const keywords = (m.keywords || []).join(' ').toLowerCase();
return content.includes(q) || summary.includes(q) || keywords.includes(q);
});
}
// 4. 排序
const sortBy = STATE.memorySortBy;
const sortDir = STATE.memorySortDir === 'asc' ? 1 : -1;
filtered.sort((a, b) => {
let va, vb;
switch (sortBy) {
case 'importance':
va = a.importance || 0; vb = b.importance || 0; break;
case 'created_at':
va = new Date(a.created_at || 0).getTime(); vb = new Date(b.created_at || 0).getTime(); break;
case 'updated_at':
va = new Date(a.updated_at || a.created_at || 0).getTime();
vb = new Date(b.updated_at || b.created_at || 0).getTime(); break;
case 'category':
va = a.category || ''; vb = b.category || '';
return sortDir * va.localeCompare(vb);
case 'access_count':
va = a.access_count || 0; vb = b.access_count || 0; break;
default:
va = a.importance || 0; vb = b.importance || 0;
}
return sortDir * (va - vb);
});
// 5. 渲染统计
renderStatsPanel();
// 6. 渲染卡片
renderMemoryCards(filtered);
// 更新计数
const countEl = document.getElementById('mem-result-count');
if (countEl) {
countEl.textContent = `显示 ${filtered.length} / ${memories.length}`;
}
}
function renderStatsPanel() {
const container = document.getElementById('mem-stats-content');
if (!container) return;
const memories = STATE.memoryCache || [];
if (memories.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📊</div>暂无记忆数据</div>';
return;
}
// 计算各分类数量
const catCount = {};
let totalImportance = 0;
let totalAccess = 0;
memories.forEach(m => {
const cat = m.category || 'other';
catCount[cat] = (catCount[cat] || 0) + 1;
totalImportance += (m.importance || 0);
totalAccess += (m.access_count || 0);
});
const avgImportance = (totalImportance / memories.length).toFixed(1);
const maxCatCount = Math.max(1, ...Object.values(catCount));
// 分类分布条
const catOrder = ['user_preference', 'personal_info', 'conversation', 'knowledge', 'event', 'task', 'relationship'];
const barHtml = catOrder.map(cat => {
const count = catCount[cat] || 0;
const pct = Math.round((count / Math.max(1, memories.length)) * 100);
const cc = getCatColor(cat);
return count > 0 ? `<div style="display:flex;align-items:center;gap:4px;font-size:10px">
<span style="color:${cc.text};min-width:52px">${cc.icon} ${cc.name}</span>
<div style="flex:1;background:var(--bg);border-radius:3px;height:14px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:${cc.text};border-radius:3px;transition:width .3s;min-width:2px"></div>
</div>
<span style="color:var(--text2);min-width:32px;text-align:right;font-family:'JetBrains Mono',monospace">${count}</span>
</div>` : '';
}).join('');
container.innerHTML = `
<div class="cards-grid cards-4" style="margin-bottom:10px">
<div class="stat-card accent"><div class="stat-value">${memories.length}</div><div class="stat-label">📦 总记忆数</div></div>
<div class="stat-card blue"><div class="stat-value">${avgImportance}</div><div class="stat-label">⭐ 平均重要性</div></div>
<div class="stat-card green"><div class="stat-value">${totalAccess}</div><div class="stat-label">📊 总访问次数</div></div>
<div class="stat-card orange"><div class="stat-value">${Object.values(catCount).filter(n=>n>0).length}</div><div class="stat-label">🏷️ 分类数</div></div>
</div>
<div style="display:flex;flex-direction:column;gap:3px">${barHtml}</div>
`;
}
function renderMemoryCards(memories) {
const grid = document.getElementById('mem-cards-grid');
if (!grid) return;
if (memories.length === 0) {
const catName = STATE.memoryFilterCategory !== 'all'
? (getCatColor(STATE.memoryFilterCategory).name || STATE.memoryFilterCategory)
: '';
const msg = catName ? `${catName}」分类下暂无记忆` : '没有匹配的记忆';
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="icon">📭</div>${msg}</div>`;
return;
}
grid.innerHTML = memories.map(m => renderMemoryCard(m)).join('');
}
function renderMemoryCard(m) {
const cat = m.category || 'other';
const cc = getCatColor(cat);
const importance = m.importance || 1;
const isHighImportance = importance >= 8;
// 星级
const stars = importanceToStars(importance);
// 关键词标签
const keywords = m.keywords || [];
const kwTags = keywords.length > 0
? keywords.slice(0, 5).map(k => `<span style="display:inline-block;padding:1px 7px;background:var(--bg3);border-radius:10px;font-size:10px;color:var(--text2);margin:1px">${escHtml(k)}</span>`).join('')
: '';
// 来源
const sourceLabel = m.source === 'thinking' ? '🤔 后台思考' : m.source === 'conversation' ? '💬 对话' : '📝 ' + (m.source || '未知');
// 会话 ID 简短
const sid = m.session_id || '';
const sidShort = sid.length > 20 ? sid.substring(0, 18) + '…' : sid;
return `
<div class="mem-card ${isHighImportance ? 'mem-card-high' : ''}"
style="background:var(--bg2);border:1px solid ${isHighImportance ? '#f59e0b' : 'var(--border)'};border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:10px;transition:all .2s">
<!-- 头部: 分类标签 + 重要性 -->
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${cc.bg};color:${cc.text}">
${cc.icon} ${cc.name}
</span>
<span style="font-size:13px;color:#f59e0b" title="重要程度: ${importance}/10">${stars}</span>
</div>
<!-- 记忆内容 -->
<div style="flex:1">
<div style="color:var(--text);font-size:13px;line-height:1.6;word-break:break-word">
${escHtml(m.content || '')}
</div>
${m.summary ? `<div style="color:var(--text2);font-size:11px;margin-top:4px;font-style:italic">📌 ${escHtml(m.summary)}</div>` : ''}
</div>
<!-- 关键词标签 -->
${kwTags ? `<div style="display:flex;flex-wrap:wrap;gap:3px">${kwTags}</div>` : ''}
<!-- 底部元信息 -->
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:10px;color:var(--text3);border-top:1px solid var(--border);padding-top:8px">
<span title="来源">${sourceLabel}</span>
${sid ? `<span title="会话: ${escHtml(sid)}" style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">💬 ${escHtml(sidShort)}</span>` : ''}
<span title="访问次数">📊 ${m.access_count || 0}</span>
<span title="创建时间">📅 ${formatTime(m.created_at)}</span>
<span title="更新时间" style="${(m.updated_at && m.updated_at !== m.created_at) ? '' : 'display:none'}">🔄 ${formatTime(m.updated_at)}</span>
<button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除" style="margin-left:auto">🗑</button>
</div>
</div>
`;
}
function importanceToStars(imp) {
const full = Math.round(imp / 2); // 1-10 映射到 1-5 星
const empty = 5 - full;
return '★'.repeat(full) + '☆'.repeat(empty);
}
async function addMemory() {
const user_id = document.getElementById('mem-add-user-id').value.trim();
const content = document.getElementById('mem-add-content').value.trim();
const category = document.getElementById('mem-add-category').value;
const importance = parseInt(document.getElementById('mem-add-importance').value);
if (!user_id || !content) { showToast('请填写用户ID和内容', 'error'); return; }
const data = await api('/api/memory/add', {
method: 'POST',
body: JSON.stringify({ user_id, content, category, importance }),
});
if (data.error) { showToast(`添加失败: ${data.error}`, 'error'); return; }
showToast('记忆添加成功!', 'success');
document.getElementById('mem-add-content').value = '';
// 自动刷新列表
loadMemories();
}
async function deleteMemory(memoryId) {
if (!memoryId) { showToast('无效的记忆ID', 'error'); return; }
if (!confirm(`确定要删除记忆 ${memoryId.substring(0, 8)}... 吗?`)) return;
const data = await api(`/api/memory/${encodeURIComponent(memoryId)}`, { method: 'DELETE' });
if (data.error) { showToast(`删除失败: ${data.error}`, 'error'); return; }
showToast('记忆删除成功!', 'success');
// 从缓存中移除并重新渲染
STATE.memoryCache = (STATE.memoryCache || []).filter(m => (m.id || m.ID) !== memoryId);
filterAndRenderMemories();
}
// ========== 面板3: 会话监看 ==========
function renderSessionsPanel() {
document.getElementById('panel-actions').innerHTML = `
<button class="btn btn-sm" onclick="fetchActiveSessions()" id="sessions-refresh-btn">🔄 刷新</button>
<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>
`;
document.getElementById('panel-sessions').innerHTML = `
<div class="card">
<div class="card-header">
<span class="card-title">💬 活跃 WebSocket 会话</span>
<span id="sessions-count" style="font-size:12px;color:var(--text2)"></span>
</div>
<div id="sessions-grouped-container">
<div class="empty-state"><div class="icon">💬</div>加载中...</div>
</div>
</div>
`;
fetchActiveSessions();
}
async function fetchActiveSessions() {
const btn = document.getElementById('sessions-refresh-btn');
if (btn) btn.classList.add('spinning');
const data = await api('/api/sessions/active');
if (btn) btn.classList.remove('spinning');
const users = data.users || {};
// 计算总会话数
let totalSessions = 0;
const flatSessions = [];
for (const [userID, sessions] of Object.entries(users)) {
totalSessions += sessions.length;
for (const s of sessions) {
flatSessions.push({ ...s, _userID: userID });
}
}
STATE.sessionsData = flatSessions;
// 更新侧边栏徽章
const badge = document.getElementById('sessions-badge');
badge.textContent = totalSessions;
badge.style.display = totalSessions > 0 ? 'inline-block' : 'none';
// 更新计数
const countEl = document.getElementById('sessions-count');
if (countEl) countEl.textContent = `${Object.keys(users).length} 个用户,${totalSessions} 个活跃会话`;
const container = document.getElementById('sessions-grouped-container');
if (!container) return;
if (data.error) {
const errMsg = escHtml(data.error);
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 服务已启动</span>';
}
container.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${errMsg}${hint}</div>`;
return;
}
if (Object.keys(users).length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div>';
STATE.expandedSessions = [];
return;
}
// Bug 5: 保存当前展开的 session ID 列表,以便重建 DOM 后恢复
const previouslyExpanded = [];
const existingDetails = container.querySelectorAll('[id^="session-detail-"]');
existingDetails.forEach(function(el) {
if (el.style.display !== 'none') {
const match = el.id.match(/^session-detail-(\d+)$/);
if (match) previouslyExpanded.push(parseInt(match[1]));
}
});
let html = '';
let globalIndex = 0;
const flatSessionMap = []; // 记录 index -> session 映射,用于恢复展开
for (const [userID, sessions] of Object.entries(users)) {
html += `<div style="margin-bottom:16px">`;
html += `<div style="font-weight:600;font-size:14px;padding:8px 0;border-bottom:1px solid var(--border);margin-bottom:8px;color:var(--accent)">👤 User: ${escHtml(userID)}</div>`;
for (const s of sessions) {
const idx = globalIndex++;
flatSessionMap.push({ index: idx, session: s, userID: userID });
html += `
<div style="padding:6px 0 6px 20px">
<div id="session-row-${idx}" class="session-row" data-index="${idx}" style="cursor:pointer;display:flex;align-items:center;gap:10px;padding:6px 10px;background:var(--bg3);border-radius:var(--radius-sm)" onclick="toggleSessionDetail(${idx})">
<span class="collapse-arrow" id="session-arrow-${idx}">▶</span>
<span style="flex:1">💬 <strong>Session:</strong> <code style="font-size:11px;color:var(--accent)">${escHtml((s.session_id || '').substring(0, 24))}${(s.session_id || '').length > 24 ? '...' : ''}</code></span>
<span class="badge ${statusBadge(s.state || 'idle')}">${s.state || 'idle'}</span>
<span style="font-size:11px;color:var(--text2)">最近活动: ${timeAgo(s.last_activity)}</span>
</div>
<div id="session-detail-${idx}" style="display:none;margin-top:4px;margin-left:20px">
<div class="session-detail" id="session-detail-content-${idx}">
<div style="text-align:center;color:var(--text2);padding:8px">加载详情中...</div>
</div>
</div>
</div>`;
}
html += `</div>`;
}
container.innerHTML = html;
// Bug 5: 恢复之前展开的 session (通过 session_id 匹配新旧 index)
if (previouslyExpanded.length > 0) {
// 构建 session_id -> 旧 index 的映射
const oldSessionIdToIndex = {};
STATE.sessionsData.forEach(function(s, i) { oldSessionIdToIndex[s.session_id] = i; });
// 对每个之前展开的 index,找到对应的 session_id,再找到新的 index
const toExpandNewIndices = [];
previouslyExpanded.forEach(function(oldIdx) {
const oldSession = STATE.sessionsData[oldIdx];
if (oldSession) {
const sid = oldSession.session_id;
// 在新的 flatSessionMap 中按 session_id 查找
for (let j = 0; j < flatSessionMap.length; j++) {
if (flatSessionMap[j].session.session_id === sid) {
toExpandNewIndices.push(j);
break;
}
}
}
});
// 恢复展开
toExpandNewIndices.forEach(function(newIdx) {
restoreSessionExpand(newIdx);
});
}
}
// Bug 5 helper: 恢复展开的 session UI 并自动加载详情
async function restoreSessionExpand(index) {
const detailRow = document.getElementById('session-detail-' + index);
const arrow = document.getElementById('session-arrow-' + index);
if (!detailRow || !arrow) return;
detailRow.style.display = '';
arrow.classList.add('open');
// 直接加载详情内容 (不调用 toggleSessionDetail 以避免 flip-flop)
const session = STATE.sessionsData[index];
if (!session) return;
const contentEl = document.getElementById('session-detail-content-' + index);
if (!contentEl) return;
await loadSessionDetailContent(session, contentEl);
}
// 提取 session 详情加载逻辑为独立函数 (Bug 5 复用)
async function loadSessionDetailContent(session, contentEl) {
const data = await api('/api/sessions/' + session.session_id);
if (data.error) {
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请确认 Gateway 服务已启动</span>';
}
contentEl.innerHTML = '<div style="color:var(--red);text-align:center">' + escHtml(data.error) + hint + '</div>';
return;
}
const messages = data.recent_messages || [];
contentEl.innerHTML =
'<div class="detail-row">' +
'<span class="detail-label">会话ID:</span>' +
'<code style="font-size:11px">' + escHtml(data.session_id || session.session_id) + '</code>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">用户ID:</span>' +
'<span>' + escHtml(data.user_id || session.user_id) + '</span>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">状态:</span>' +
'<span class="badge ' + statusBadge(data.state || 'idle') + '">' + (data.state || 'idle') + '</span>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">消息数:</span>' +
'<span>' + (data.message_count || 0) + '</span>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">连接时间:</span>' +
'<span>' + formatTime(data.connected_at) + '</span>' +
'</div>' +
'<div class="detail-row">' +
'<span class="detail-label">最后活跃:</span>' +
'<span>' + formatTime(data.last_activity) + '</span>' +
'</div>' +
(messages.length > 0 ?
'<div style="margin-top:10px;font-weight:600;font-size:12px;color:var(--text2)">📝 最近消息 (' + messages.length + ')</div>' +
'<div class="msg-list">' +
messages.map(function(m) {
return '<div class="msg-item">' +
'<span class="role ' + m.role + '">' + m.role + '</span>' +
'<span style="color:var(--text2);font-size:10px;margin-right:6px">' + formatTime(m.timestamp) + '</span>' +
escHtml(m.content || '') +
'</div>';
}).join('') +
'</div>'
: '<div style="margin-top:8px;color:var(--text2);font-size:12px">暂无消息记录</div>');
}
// 保留旧 loadSessions 兼容其他调用
async function loadSessions() {
fetchActiveSessions();
}
async function toggleSessionDetail(index) {
const detailRow = document.getElementById('session-detail-' + index);
const arrow = document.getElementById('session-arrow-' + index);
const contentEl = document.getElementById('session-detail-content-' + index);
if (detailRow.style.display !== 'none') {
// 折叠
detailRow.style.display = 'none';
arrow.classList.remove('open');
return;
}
// 展开
detailRow.style.display = '';
arrow.classList.add('open');
const session = STATE.sessionsData[index];
if (!session) return;
// 委托给共用函数
await loadSessionDetailContent(session, contentEl);
}
// ========== 面板4: 服务管理 ==========
function renderServicesPanel() {
document.getElementById('panel-actions').innerHTML = `
<button class="btn btn-sm btn-accent" onclick="svcAction('start-all')">▶ 一键启动</button>
<button class="btn btn-sm" onclick="svcAction('start-all-fresh')">🔄 强制重启全部</button>
<button class="btn btn-sm btn-red" onclick="svcAction('stop-all')">⏹ 全部停止</button>
`;
document.getElementById('panel-services').innerHTML = `
<!-- 服务状态卡片 -->
<div class="card">
<div class="card-header"><span class="card-title">📡 服务管理</span></div>
<div class="cards-grid cards-4" id="services-svc-cards"></div>
</div>
<!-- 实时日志 -->
<div class="card">
<div class="card-header">
<span class="card-title">📋 实时日志</span>
<div class="btn-group">
<div class="log-tabs" id="services-log-tabs" style="margin:0"></div>
<button class="btn btn-xs" onclick="toggleSvcLogLayout()" id="btn-svc-log-layout">📋 标签页</button>
<button class="btn btn-xs" onclick="clearSvcLogs()">🗑 清空</button>
</div>
</div>
<div id="services-log-tabs-panel" style="display:none">
<div class="log-container" id="services-log-panel">
<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>
</div>
</div>
<div id="services-log-grid">
<div class="svc-log-grid" style="display:flex;gap:12px;overflow-x:auto;padding-bottom:8px">
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-ai-core" style="height:300px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-gateway" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-iot-debug-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-frontend" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-memory-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Memory</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-tool-engine" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Tool Engine</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-voice-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Voice</div></div></div>
</div>
</div>
</div>
`;
renderServiceCards();
initSvcLogTabs();
if (STATE.logLayout === 'grid') {
document.getElementById('services-log-tabs').style.display = 'none';
ALL_SVC_IDS.forEach(id => renderGridLog(id));
} else {
renderServiceLog();
}
}
function renderServiceCards() {
const container = document.getElementById('services-svc-cards');
if (!container) return;
const status = STATE.serviceStatus;
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ALL_SVC_IDS;
container.innerHTML = ids.map(id => {
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null };
const isRunning = svc.status === 'running';
const isStarting = svc.status === 'starting' || svc.status === 'building';
const isStopped = svc.status === 'stopped' || svc.status === 'error' || svc.status === 'unknown';
return `
<div class="card" style="margin:0">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<span style="font-weight:600">${svc.name}</span>
<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>
</div>
<div class="metrics">
<div class="metric"><div class="value">${svc.pid || '—'}</div><div class="label">PID</div></div>
<div class="metric"><div class="value">${svc.port}</div><div class="label">端口</div></div>
<div class="metric"><div class="value">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
</div>
<div class="btn-group" style="margin-top:10px">
${isStopped ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶ 启动</button>` : ''}
${isStopped || isStarting ? `<button class="btn btn-xs btn-accent" onclick="svcAction('build','${id}')">🔨 编译</button>` : ''}
${isRunning ? `<button class="btn btn-xs" onclick="svcAction('restart','${id}')">🔄 重启</button>` : ''}
${isRunning || isStarting ? `<button class="btn btn-xs btn-red" onclick="svcAction('stop','${id}')">⏹ 停止</button>` : ''}
${svc.healthUrl ? `<button class="btn btn-xs" onclick="checkHealth('${id}')">❤️</button>` : ''}
</div>
</div>`;
}).join('');
}
// ---- 日志功能 ----
function initSvcLogTabs() {
const tabs = document.getElementById('services-log-tabs');
if (!tabs) return;
tabs.innerHTML = ALL_SVC_IDS.map(id =>
`<button class="log-tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchSvcLogTab('${id}')">${escapeId(id)}</button>`
).join('');
}
function switchSvcLogTab(id) {
STATE.activeLogTab = id;
initSvcLogTabs();
renderServiceLog();
}
function renderServiceLog() {
const panel = document.getElementById('services-log-panel');
if (!panel) return;
const lines = STATE.logLines[STATE.activeLogTab] || [];
if (lines.length === 0) {
panel.innerHTML = '<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>';
return;
}
panel.innerHTML = lines.map(l =>
`<div class="log-line ${l.stream}"><span class="ts">${l.ts}</span>${escHtml(l.text)}</div>`
).join('');
panel.scrollTop = panel.scrollHeight;
}
function renderGridLog(service) {
const panel = document.getElementById(`log-panel-${service}`);
if (!panel) return;
const lines = STATE.logLines[service] || [];
if (lines.length === 0) {
panel.innerHTML = `<div class="empty-state"><div class="icon">📝</div>${escapeId(service)}</div>`;
return;
}
panel.innerHTML = lines.map(l =>
`<div class="log-line ${l.stream}"><span class="ts">${l.ts}</span>${escHtml(l.text)}</div>`
).join('');
panel.scrollTop = panel.scrollHeight;
}
function toggleSvcLogLayout() {
const btn = document.getElementById('btn-svc-log-layout');
const tabsPanel = document.getElementById('services-log-tabs-panel');
const gridPanel = document.getElementById('services-log-grid');
const logTabs = document.getElementById('services-log-tabs');
if (STATE.logLayout === 'grid') {
STATE.logLayout = 'tabs';
gridPanel.style.display = 'none';
tabsPanel.style.display = '';
logTabs.style.display = '';
btn.textContent = '📐 并列';
renderServiceLog();
} else {
STATE.logLayout = 'grid';
tabsPanel.style.display = 'none';
gridPanel.style.display = '';
logTabs.style.display = 'none';
btn.textContent = '📋 标签页';
ALL_SVC_IDS.forEach(id => renderGridLog(id));
}
}
async function clearSvcLogs() {
const id = STATE.activeLogTab;
await api(`/api/logs/${id}`, { method: 'DELETE' });
STATE.logLines[id] = [];
renderServiceLog();
}
// ---- 服务操作 ----
async function svcAction(cmd, serviceId) {
var actionLabels = { start: "启动", stop: "停止", restart: "重启", build: "编译", "start-all": "一键启动", "start-all-fresh": "强制重启全部", "stop-all": "全部停止" };
var label = actionLabels[cmd] || cmd;
var svcLabel = serviceId ? escapeId(serviceId) : "";
var msg = svcLabel ? "正在" + label + " " + svcLabel + "..." : "正在执行: " + label + "...";
showToast(msg, "info");
// 立即更新本地状态和 UI(乐观更新)
if (serviceId) {
var newStatus;
if (cmd === "start") newStatus = "starting";
else if (cmd === "stop") newStatus = "stopped";
else if (cmd === "restart") newStatus = "starting";
else if (cmd === "build") newStatus = "building";
if (newStatus) {
if (!STATE.serviceStatus[serviceId]) {
STATE.serviceStatus[serviceId] = { name: escapeId(serviceId), status: newStatus, pid: null, port: "-", uptime: 0 };
} else {
STATE.serviceStatus[serviceId].status = newStatus;
}
if (STATE.activePanel === "services") renderServiceCards();
if (STATE.activePanel === "dashboard") renderDashboardSvcCards(STATE.serviceStatus);
}
} else {
// 批量操作:给所有服务设置过渡状态
var newStatus = (cmd === "start-all" || cmd === "start-all-fresh") ? "starting" : "stopped";
ALL_SVC_IDS.forEach(function(id) {
if (!STATE.serviceStatus[id]) {
STATE.serviceStatus[id] = { name: escapeId(id), status: newStatus, pid: null, port: "-", uptime: 0 };
} else {
STATE.serviceStatus[id].status = newStatus;
}
});
if (STATE.activePanel === "services") renderServiceCards();
if (STATE.activePanel === "dashboard") renderDashboardSvcCards(STATE.serviceStatus);
}
// 发起 API 请求
let url;
if (cmd === "start-all") url = "/api/services/start-all";
else if (cmd === "start-all-fresh") url = "/api/services/start-all-fresh";
else if (cmd === "stop-all") url = "/api/services/stop-all";
else url = "/api/services/" + serviceId + "/" + cmd;
var method = ["start","stop","restart","build","start-all","start-all-fresh","stop-all"].indexOf(cmd) >= 0 ? "POST" : "GET";
var res = await api(url, { method: method });
showToast(res.message || res.error || (label + " 完成"), res.error ? "error" : "success");
refreshStatus();
}
async function checkHealth(id) {
const res = await api(`/api/proxy/${id}/health`);
if (res.error) {
showToast(`${id}: ${res.error}`, 'error');
} else {
showToast(`${id}: ${res.status} - ${JSON.stringify(res.data).substring(0, 100)}`, 'success');
}
}
async function refreshStatus() {
const status = await api('/api/services');
if (!status.error) {
STATE.serviceStatus = status;
if (STATE.activePanel === 'services') renderServiceCards();
if (STATE.activePanel === 'dashboard') renderDashboard();
}
if (STATE.activePanel === 'performance') refreshPerf();
}
// ========== 面板5: 性能监控 ==========
function renderPerformancePanel() {
document.getElementById('panel-performance').innerHTML = `
<div class="card">
<div class="card-header">
<span class="card-title">📊 性能分析</span>
<div class="legend">
<span class="legend-item"><span class="legend-dot cpu"></span> CPU %</span>
<span class="legend-item"><span class="legend-dot mem"></span> 内存 MB</span>
</div>
</div>
<div class="cards-grid cards-4" id="perf-panels"></div>
</div>
`;
refreshPerf();
}
async function refreshPerf() {
const [snap, history] = await Promise.all([
api('/api/performance'),
api('/api/performance/history'),
]);
if (history && !history.error) {
for (const [id, h] of Object.entries(history)) {
if (h && h.length > 0) STATE.perfHistory[id] = h.slice(-60);
}
}
renderPerfPanels(snap);
}
function renderPerfPanels(snap) {
const container = document.getElementById('perf-panels');
if (!container) return;
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ALL_SVC_IDS;
// Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值
const isFirstRender = !container.querySelector('.perf-card');
if (isFirstRender) {
container.innerHTML = ids.map(function(id) {
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
return '<div class="card perf-card" style="margin:0" data-svc="' + id + '">' +
'<div class="card-header">' +
'<span class="card-title">' + escapeId(id) + '</span>' +
'<span style="font-size:11px;color:var(--text2)" class="perf-snap-val">CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB</span>' +
'</div>' +
'<div class="chart-container">' +
'<svg viewBox="0 0 300 120" class="chart-svg" id="perf-chart-' + id + '">' +
drawChart(STATE.perfHistory[id] || []) +
'</svg>' +
'</div>' +
'</div>';
}).join('');
} else {
// 增量更新: 只更新 SVG 图表内容和快照数值
ids.forEach(function(id) {
var card = container.querySelector('.perf-card[data-svc="' + id + '"]');
if (!card) return;
var s = snap[id] || { pid: null, cpu: 0, mem: 0 };
var valEl = card.querySelector('.perf-snap-val');
if (valEl) valEl.textContent = 'CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB';
var svg = card.querySelector('.chart-svg');
if (svg) svg.innerHTML = drawChart(STATE.perfHistory[id] || []);
});
}
}
function drawChart(history) {
if (!history || history.length < 2) return '<text x="150" y="65" text-anchor="middle" fill="#8b949e" font-size="11">等待数据...</text>';
const w = 300, h = 120, pad = 10;
const maxCpu = Math.max(5, ...history.map(d => d.cpu));
const maxMem = Math.max(10, ...history.map(d => d.mem));
let cpuArea = '', cpuLine = '', memArea = '', memLine = '';
for (let i = 0; i < history.length; i++) {
const x = pad + (i / Math.max(1, history.length - 1)) * (w - 2*pad);
const cpuY = h - pad - (history[i].cpu / maxCpu) * (h - 2*pad);
const memY = h - pad - (history[i].mem / maxMem) * (h - 2*pad);
cpuLine += `${i===0?'M':'L'} ${x} ${cpuY} `;
memLine += `${i===0?'M':'L'} ${x} ${memY} `;
}
cpuArea = cpuLine + `L ${pad + (history.length-1)/(Math.max(1,history.length-1))*(w-2*pad)} ${h-pad} L ${pad} ${h-pad} Z`;
memArea = memLine + `L ${pad + (history.length-1)/(Math.max(1,history.length-1))*(w-2*pad)} ${h-pad} L ${pad} ${h-pad} Z`;
return `
<path d="${cpuArea}" class="chart-area cpu"/>
<path d="${cpuLine}" class="chart-line cpu"/>
<path d="${memArea}" class="chart-area mem"/>
<path d="${memLine}" class="chart-line mem"/>
`;
}
// ========== 面板: 数据库监看 ==========
async function fetchDatabaseStatus() {
return await api('/api/database/status');
}
async function renderDatabasePanel() {
const data = await fetchDatabaseStatus();
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="refreshDatabasePanel()" id="db-refresh-btn">🔄 刷新</button>' +
'<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>';
const panel = document.getElementById('panel-database');
if (data.error) {
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
STATE.dbInitialized = false;
return;
}
const ports = data.ports || [];
const allAlive = data.allAlive;
const aliveCount = data.aliveCount;
const totalPorts = data.totalPorts;
const pg = data.pgDetails;
// 更新侧边栏数据库徽章
const badge = document.getElementById('db-badge');
if (badge) {
if (allAlive) {
badge.style.display = 'inline';
badge.style.color = 'var(--green)';
} else if (aliveCount > 0) {
badge.style.display = 'inline';
badge.style.color = 'var(--yellow)';
} else {
badge.style.display = 'none';
}
}
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新动态元素
const isFirstRender = !STATE.dbInitialized;
if (isFirstRender) {
panel.innerHTML =
'<!-- 概览 -->' +
'<div class="card">' +
'<div class="card-header">' +
'<span class="card-title">🐳 数据库连接状态</span>' +
'<span class="badge ' + (allAlive ? 'badge-running' : (aliveCount > 0 ? 'badge-starting' : 'badge-error')) + '" id="db-tunnel-badge">' + (allAlive ? '全部在线' : (aliveCount > 0 ? '部分在线' : '离线')) + '</span>' +
'</div>' +
'<div class="db-summary">' +
'<div class="db-summary-stat">' +
'<div class="val" id="db-alive-count" style="color:' + (allAlive ? 'var(--green)' : 'var(--red)') + '">' + aliveCount + '/' + totalPorts + '</div>' +
'<div class="lbl">容器端口通联</div>' +
'</div>' +
(pg ?
'<div class="db-summary-stat">' +
'<div class="val" id="db-mem-count" style="color:var(--blue)">' + (pg.memories ?? '—') + '</div>' +
'<div class="lbl">记忆条目 (' + escHtml(pg.database || '') + ')</div>' +
'</div>'
: '<div class="db-summary-stat" style="display:none" id="db-mem-stat">' +
'<div class="val" id="db-mem-count" style="color:var(--blue)">—</div>' +
'<div class="lbl">记忆条目</div>' +
'</div>') +
'<div class="db-summary-stat">' +
'<div class="val" id="db-check-time" style="color:var(--text2)">' + formatTime(data.timestamp) + '</div>' +
'<div class="lbl">最后检查时间</div>' +
'</div>' +
'</div>' +
'<div class="db-grid" id="db-ports-grid">' +
ports.map(function(p) {
return '<div class="db-port-card ' + (p.alive ? 'alive' : 'dead') + '" data-port="' + p.port + '">' +
'<div class="db-dot"></div>' +
'<div class="db-info">' +
'<div class="db-name">' + escHtml(p.name) + '</div>' +
'<div class="db-port-label">:' + p.port + ' ' + (p.alive ? '✅' : '❌') + '</div>' +
'</div>' +
'</div>';
}).join('') +
'</div>' +
'</div>' +
'<!-- 数据库容器控制 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">🕹️ 数据库容器控制</span></div>' +
'<div class="btn-group" style="margin-bottom:8px">' +
'<button class="btn btn-green btn-sm" id="db-tunnel-start" onclick="dbContainerAction(\'start\')"' + (allAlive ? ' disabled' : '') + '>▶ 启动</button>' +
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="dbContainerAction(\'stop\')"' + (aliveCount === 0 ? ' disabled' : '') + '>⏹ 停止</button>' +
'<button class="btn btn-sm" onclick="dbContainerAction(\'restart\')">🔄 重启</button>' +
'</div>' +
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (aliveCount > 0 && !allAlive ? 'block' : 'none') + '">⚠️ 部分容器端口不通,请尝试重启 Docker 容器</div>' +
'<div id="tunnel-log-container" style="display:none">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
'<button class="btn btn-xs" onclick="document.getElementById(\'tunnel-log-container\').style.display=\'none\'">✕</button>' +
'</div>' +
'<div class="tunnel-log" id="tunnel-log"></div>' +
'</div>' +
'</div>' +
'<!-- 连接信息 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">📋 连接说明</span></div>' +
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
'<div>🐳 Docker Compose: <code style="color:var(--text)">docker compose -f docker-compose.dev.db.yml up -d</code></div>' +
'<div>📁 配置文件: <code style="color:var(--text)">docker-compose.dev.db.yml</code></div>' +
'<div>💡 所有数据库服务运行在本地 Docker 容器中,端口映射至 <code style="color:var(--text)">localhost</code></div>' +
'</div>' +
'</div>';
STATE.dbInitialized = true;
} else {
// Bug 7: 增量更新 — 只更新状态徽章、计数器、端口卡片、检查时间
var el;
// 数据库状态徽章
el = document.getElementById('db-tunnel-badge');
if (el) {
el.className = 'badge ' + (allAlive ? 'badge-running' : (aliveCount > 0 ? 'badge-starting' : 'badge-error'));
el.textContent = allAlive ? '全部在线' : (aliveCount > 0 ? '部分在线' : '离线');
}
// 端口通联计数
el = document.getElementById('db-alive-count');
if (el) {
el.textContent = aliveCount + '/' + totalPorts;
el.style.color = allAlive ? 'var(--green)' : 'var(--red)';
}
// 记忆条目
var memStat = document.getElementById('db-mem-stat');
el = document.getElementById('db-mem-count');
if (pg) {
if (memStat) memStat.style.display = '';
if (el) el.textContent = pg.memories ?? '—';
} else {
if (memStat) memStat.style.display = 'none';
}
// 检查时间
el = document.getElementById('db-check-time');
if (el) el.textContent = formatTime(data.timestamp);
// 更新端口卡片
var grid = document.getElementById('db-ports-grid');
if (grid) {
ports.forEach(function(p) {
var card = grid.querySelector('.db-port-card[data-port="' + p.port + '"]');
if (card) {
card.className = 'db-port-card ' + (p.alive ? 'alive' : 'dead');
var lbl = card.querySelector('.db-port-label');
if (lbl) lbl.textContent = ':' + p.port + ' ' + (p.alive ? '✅' : '❌');
}
});
}
// 更新按钮 disable 状态
el = document.getElementById('db-tunnel-start');
if (el) el.disabled = !!allAlive;
el = document.getElementById('db-tunnel-stop');
if (el) el.disabled = !(aliveCount > 0);
// 部分在线警告
el = document.getElementById('db-zombie-warn');
if (el) el.style.display = (aliveCount > 0 && !allAlive) ? 'block' : 'none';
}
}
function refreshDatabasePanel() {
renderDatabasePanel();
}
async function dbContainerAction(action) {
var labelMap = { start: '启动', stop: '停止', restart: '重启' };
var label = labelMap[action] || action;
showToast('正在' + label + '数据库容器...', 'info');
const logContainer = document.getElementById('tunnel-log-container');
const logEl = document.getElementById('tunnel-log');
logEl.textContent = '执行中...';
logContainer.style.display = 'block';
const data = await api('/api/db/' + action, { method: 'POST' });
if (data.error && !data.output) {
logEl.textContent = '错误: ' + data.error;
showToast('操作失败: ' + data.error, 'error');
} else {
logEl.textContent = data.output || data.error || '(无输出)';
if (data.success) {
showToast(label + '数据库完成', 'success');
} else {
showToast(label + '完成 (查看日志)', 'info');
}
setTimeout(refreshDatabasePanel, 1500);
}
}
// ========== 数据库卡片控制 ==========
var DB_ACTION_LABELS = {
start: '启动', stop: '停止', restart: '重启'
};
async function renderDBCard() {
const data = await api('/api/db/status');
const badge = document.getElementById('db-status-badge');
const typeDisplay = document.getElementById('db-type-display');
const portDisplay = document.getElementById('db-port-display');
const uptimeDisplay = document.getElementById('db-uptime-display');
if (data.error) {
if (badge) { badge.textContent = '错误'; badge.className = 'badge badge-error'; }
if (uptimeDisplay) uptimeDisplay.textContent = '错误';
updateDBCardButtons(true, false);
return;
}
const online = data.online;
if (badge) {
badge.textContent = online ? '🟢 在线' : '🔴 离线';
badge.className = 'badge ' + (online ? 'badge-running' : 'badge-error');
}
if (typeDisplay) typeDisplay.textContent = 'PostgreSQL';
if (portDisplay) portDisplay.textContent = data.port || 5432;
if (uptimeDisplay) uptimeDisplay.textContent = online ? '已连接' : '未连接';
// 同步按钮 disabled 状态: 在线时禁用启动,离线时禁用停止
updateDBCardButtons(false, online);
}
function updateDBCardButtons(disabled, online) {
var startBtn = document.getElementById('db-card-start-btn');
var stopBtn = document.getElementById('db-card-stop-btn');
if (disabled) {
if (startBtn) startBtn.disabled = true;
if (stopBtn) stopBtn.disabled = true;
} else {
if (startBtn) startBtn.disabled = !!online;
if (stopBtn) stopBtn.disabled = !online;
}
}
async function controlDB(action) {
var label = DB_ACTION_LABELS[action] || action;
showToast('正在' + label + '数据库...', 'info');
// 禁用所有按钮防止重复点击
updateDBCardButtons(true);
// 显示日志区域
var logContainer = document.getElementById('db-card-log-container');
var logEl = document.getElementById('db-card-log');
if (!logContainer) {
// 首次使用时动态创建日志区域
var dbCard = document.getElementById('db-card');
if (dbCard) {
logContainer = document.createElement('div');
logContainer.id = 'db-card-log-container';
logContainer.style.cssText = 'margin-top:8px;display:none';
logContainer.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
'<button class="btn btn-xs" onclick="var c=document.getElementById(\'db-card-log-container\');if(c)c.style.display=\'none\'">✕</button>' +
'</div>' +
'<div class="tunnel-log" id="db-card-log" style="max-height:120px"></div>';
dbCard.appendChild(logContainer);
logEl = document.getElementById('db-card-log');
}
}
if (logContainer) logContainer.style.display = 'block';
if (logEl) logEl.textContent = '执行中...';
var data = await api('/api/db/' + action, { method: 'POST' });
if (data.error && !data.output) {
if (logEl) logEl.textContent = '错误: ' + data.error;
showToast('操作失败: ' + data.error, 'error');
// 恢复按钮状态 — 重新读取在线状态
setTimeout(renderDBCard, 1000);
} else {
if (logEl) logEl.textContent = data.output || data.error || '(无输出)';
if (data.success) {
showToast('数据库' + label + '完成', 'success');
} else {
showToast(label + '完成 (查看日志)', 'info');
}
// 等待2秒后刷新数据库卡片和仪表盘
setTimeout(function() {
renderDBCard();
// 如果当前在仪表盘面板,刷新仪表盘
if (STATE.activePanel === 'dashboard') renderDashboard();
}, 2000);
}
}
// ========== 面板8: 工具调用记录 ==========
async function renderToolCallsPanel() {
var container = document.getElementById('panel-toolCalls');
if (!STATE.toolCallsPage) STATE.toolCallsPage = 1;
if (!STATE.toolCallsFilter) STATE.toolCallsFilter = '';
if (!STATE.toolCallsAutoRefresh) STATE.toolCallsAutoRefresh = null;
if (!STATE.toolCallsLimit) STATE.toolCallsLimit = 20;
var actionsEl = document.getElementById('panel-actions');
var autoRefreshOn = STATE.toolCallsAutoRefresh !== null;
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
'<input type="checkbox" id="toolcalls-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleToolCallsAutoRefresh(this.checked)">' +
'自动刷新</label>';
var statsData = null;
try {
var statsResp = await fetch('/api/tool-calls/stats');
if (statsResp.ok) statsData = await statsResp.json();
} catch(e) {}
var callsData = null;
try {
var params = '?page=' + STATE.toolCallsPage + '&limit=' + STATE.toolCallsLimit;
if (STATE.toolCallsFilter) params += '&tool_name=' + encodeURIComponent(STATE.toolCallsFilter);
var callsResp = await fetch('/api/tool-calls' + params);
if (callsResp.ok) callsData = await callsResp.json();
} catch(e) {}
if (!callsData || callsData.error) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
(callsData && callsData.error ? escHtml(callsData.error) : '无法连接到 Tool-Engine 服务,请在「服务管理」中启动 Tool-Engine') +
(callsData && callsData.hint ? '<br><small>' + escHtml(callsData.hint) + '</small>' : '') +
'</div>';
return;
}
var totalCalls = statsData ? statsData.total_calls : callsData.total;
var successRate = statsData ? (statsData.success_rate || 0).toFixed(1) : 0;
var avgDuration = statsData ? (statsData.avg_duration_ms || 0).toFixed(0) : 0;
var byTool = statsData && statsData.by_tool ? statsData.by_tool : [];
var toolNamesSet = {};
if (byTool.length > 0) {
for (var i = 0; i < byTool.length; i++) { toolNamesSet[byTool[i].tool_name] = true; }
}
if (callsData.calls) {
for (var i = 0; i < callsData.calls.length; i++) { toolNamesSet[callsData.calls[i].tool_name] = true; }
}
var toolNames = Object.keys(toolNamesSet).sort();
var statsCardsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
'<div class="stat-card accent"><div class="stat-value">' + totalCalls + '</div><div class="stat-label">总调用</div></div>' +
'<div class="stat-card green"><div class="stat-value">' + successRate + '%</div><div class="stat-label">成功率</div></div>' +
'<div class="stat-card blue"><div class="stat-value">' + avgDuration + 'ms</div><div class="stat-label">平均耗时</div></div>' +
'<div class="stat-card orange"><div class="stat-value">' + toolNames.length + '</div><div class="stat-label">工具数</div></div>' +
'</div>';
var filterHtml = '<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap;">' +
'<select id="toolcalls-filter-select" onchange="changeToolCallsFilter(this.value)" style="width:auto;min-width:160px;">' +
'<option value="">全部工具</option>';
for (var i = 0; i < toolNames.length; i++) {
filterHtml += '<option value="' + escHtml(toolNames[i]) + '"' + (STATE.toolCallsFilter === toolNames[i] ? ' selected' : '') + '>' + escHtml(toolNames[i]) + '</option>';
}
filterHtml += '</select>' +
'<button class="btn btn-sm" onclick="refreshToolCallsPanel()">🔄 刷新</button>' +
'</div>';
var distHtml = '';
if (byTool.length > 0) {
distHtml = '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">';
for (var i = 0; i < byTool.length; i++) {
var t = byTool[i];
distHtml += '<span class="badge" style="background:var(--bg3);font-size:11px;cursor:pointer;" onclick="changeToolCallsFilter(\'' + escHtml(t.tool_name) + '\')" title="' + escHtml(t.tool_name) + ': ' + t.count + ' 次, 成功率 ' + (t.count > 0 ? (t.success_count / t.count * 100).toFixed(0) : 0) + '%">' +
escHtml(t.tool_name) + ' ' + t.count + '</span>';
}
distHtml += '</div>';
}
var tableHtml = '<div class="table-wrap"><table>' +
'<thead><tr><th style="width:100px;">时间</th><th style="width:110px;">工具</th><th>参数</th><th>结果</th><th style="width:50px;">状态</th><th style="width:55px;">耗时</th></tr></thead><tbody>';
if (callsData.calls && callsData.calls.length > 0) {
for (var i = 0; i < callsData.calls.length; i++) {
var call = callsData.calls[i];
var argsStr = '';
try {
if (typeof call.arguments === 'string') {
var parsed = JSON.parse(call.arguments);
argsStr = JSON.stringify(parsed);
} else if (call.arguments) {
argsStr = JSON.stringify(call.arguments);
}
} catch(e) { argsStr = String(call.arguments || ''); }
if (argsStr.length > 60) argsStr = argsStr.slice(0, 57) + '...';
var outputStr = (call.output || call.error || '');
if (outputStr.length > 80) outputStr = outputStr.slice(0, 77) + '...';
var timeStr = call.created_at ? new Date(call.created_at).toLocaleTimeString('zh-CN', {hour12: false}) : '—';
var statusIcon = call.success ? '✅' : '❌';
var statusColor = call.success ? 'var(--green)' : 'var(--red)';
var durationStr = call.duration_ms ? call.duration_ms + 'ms' : '—';
var callId = 'tc-' + call.id;
tableHtml += '<tr class="toolcall-row" data-callid="' + callId + '" onclick="toggleToolCallExpand(\'' + callId + '\')" style="cursor:pointer;">' +
'<td style="font-size:11px;color:var(--text2);">' + escHtml(timeStr) + '</td>' +
'<td><span class="badge" style="background:var(--bg3);">' + escHtml(call.tool_name) + '</span></td>' +
'<td style="font-family:monospace;font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escHtml(argsStr) + '</td>' +
'<td style="font-family:monospace;font-size:11px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:' + (call.success ? 'var(--text2)' : 'var(--red)') + ';">' + escHtml(outputStr) + '</td>' +
'<td style="text-align:center;color:' + statusColor + ';">' + statusIcon + '</td>' +
'<td style="text-align:right;font-size:11px;color:var(--text2);">' + durationStr + '</td>' +
'</tr>';
// Expand row showing full args and output
var argsDisplay = call.arguments;
if (typeof argsDisplay === 'string') {
try { argsDisplay = JSON.parse(argsDisplay); } catch(e) {}
}
tableHtml += '<tr class="toolcall-expand" id="' + callId + '-expand" style="display:none;"><td colspan="6" style="background:var(--bg);padding:12px 24px;">' +
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
'<div><strong style="color:var(--text2);font-size:11px;">完整参数:</strong><pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:200px;overflow:auto;white-space:pre-wrap;">' + escHtml(JSON.stringify(argsDisplay, null, 2)) + '</pre></div>' +
'<div><strong style="color:var(--text2);font-size:11px;">完整输出:</strong><pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:200px;overflow:auto;white-space:pre-wrap;color:' + (call.success ? 'var(--text)' : 'var(--red)') + ';">' + escHtml(call.output || call.error || '') + '</pre></div>' +
'</div>' +
'<div style="margin-top:8px;font-size:11px;color:var(--text3);">Call ID: ' + escHtml(call.call_id || '—') + ' | User: ' + escHtml(call.user_id || '—') + ' | Session: ' + escHtml(call.session_id || '—') + ' | ' + (call.created_at ? new Date(call.created_at).toLocaleString('zh-CN', {hour12: false}) : '') + '</div>' +
'</td></tr>';
}
} else {
tableHtml += '<tr><td colspan="6" style="text-align:center;color:var(--text3);padding:30px;">暂无调用记录</td></tr>';
}
tableHtml += '</tbody></table></div>';
var paginationHtml = '';
if (callsData.total_pages > 0) {
paginationHtml = '<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px;font-size:12px;">' +
'<button class="btn btn-sm" ' + (callsData.page <= 1 ? 'disabled' : '') + ' onclick="goToolCallsPage(' + (callsData.page - 1) + ')">← 上一页</button>' +
'<span style="color:var(--text2);">第 ' + callsData.page + '/' + callsData.total_pages + ' 页</span>' +
'<button class="btn btn-sm" ' + (callsData.page >= callsData.total_pages ? 'disabled' : '') + ' onclick="goToolCallsPage(' + (callsData.page + 1) + ')">下一页 →</button>' +
'</div>';
}
container.innerHTML = statsCardsHtml + filterHtml + distHtml + tableHtml + paginationHtml;
}
function toggleToolCallExpand(callId) {
var expandRow = document.getElementById(callId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
}
}
function changeToolCallsFilter(toolName) {
STATE.toolCallsFilter = toolName;
STATE.toolCallsPage = 1;
renderToolCallsPanel();
}
function goToolCallsPage(page) {
STATE.toolCallsPage = page;
renderToolCallsPanel();
document.getElementById('panel-toolCalls').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function refreshToolCallsPanel() {
renderToolCallsPanel();
}
function toggleToolCallsAutoRefresh(on) {
if (STATE.toolCallsAutoRefresh) {
clearInterval(STATE.toolCallsAutoRefresh);
STATE.toolCallsAutoRefresh = null;
}
if (on) {
STATE.toolCallsAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'toolCalls') renderToolCallsPanel();
}, 5000);
}
}
// ========== 面板8.5: 语音识别日志 (STT) ==========
async function renderSTTPanel() {
var container = document.getElementById('panel-stt');
if (!STATE.sttAutoRefresh) STATE.sttAutoRefresh = null;
var actionsEl = document.getElementById('panel-actions');
var autoRefreshOn = STATE.sttAutoRefresh !== null;
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
'<input type="checkbox" id="stt-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleSTTAutoRefresh(this.checked)">' +
'自动刷新 (5s)</label>' +
'<button class="btn btn-sm" onclick="refreshSTTPanel()" style="margin-left:8px">🔄 刷新</button>' +
'<button class="btn btn-sm btn-red" onclick="clearSTTLogs()" style="margin-left:4px">🗑 清空</button>';
// 首次渲染时从 API 拉取数据覆盖本地缓存
var data = await api('/api/voice/logs?limit=200');
if (data.error) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
escHtml(data.error) +
(data.hint ? '<br><small>' + escHtml(data.hint) + '</small>' : '') +
'</div>';
return;
}
STATE.sttLogs = data.logs || [];
updateSTTBadge();
// 统计卡片
var totalLogs = data.total || STATE.sttLogs.length;
var successCount = 0, errorCount = 0, totalDurationMs = 0;
STATE.sttLogs.forEach(function(e) {
if (e.status === 'success') successCount++;
else errorCount++;
totalDurationMs += (e.durationMs || 0);
});
var avgDurationMs = STATE.sttLogs.length > 0 ? Math.round(totalDurationMs / STATE.sttLogs.length) : 0;
var statsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
'<div class="stat-card accent"><div class="stat-value">' + totalLogs + '</div><div class="stat-label">总请求数</div></div>' +
'<div class="stat-card green"><div class="stat-value">' + successCount + '</div><div class="stat-label">成功</div></div>' +
'<div class="stat-card red"><div class="stat-value">' + errorCount + '</div><div class="stat-label">失败</div></div>' +
'<div class="stat-card blue"><div class="stat-value">' + avgDurationMs + 'ms</div><div class="stat-label">平均处理时间</div></div>' +
'</div>';
// 构建表格
var tableHtml = '<div class="table-wrap"><table>' +
'<thead><tr>' +
'<th style="width:130px;">时间</th>' +
'<th style="width:60px;">状态</th>' +
'<th style="width:80px;">音频大小</th>' +
'<th style="width:70px;">预计时长</th>' +
'<th style="width:70px;">语言</th>' +
'<th style="width:80px;">处理时间</th>' +
'<th>识别结果</th>' +
'</tr></thead><tbody>';
if (STATE.sttLogs.length > 0) {
for (var i = 0; i < STATE.sttLogs.length; i++) {
var log = STATE.sttLogs[i];
var timeStr = log.timestamp ? new Date(log.timestamp).toLocaleString('zh-CN', {hour12: false}) : '—';
var statusBadgeHtml = log.status === 'success'
? '<span class="badge badge-running">✓ 成功</span>'
: '<span class="badge badge-error">✗ 失败</span>';
var audioSizeMB = log.audioSizeMB || '—';
var estDuration = log.estimatedDurationSec ? log.estimatedDurationSec + 's' : '—';
var lang = log.language || '—';
var durationMs = log.durationMs ? log.durationMs + 'ms' : '—';
var textDisplay = log.status === 'success'
? (log.text || '<span style="color:var(--text3);font-style:italic;">(空结果)</span>')
: ('<span style="color:var(--red);">' + escHtml(log.error || '未知错误') + '</span>');
var textLenLabel = log.textLength ? ' (' + log.textLength + '字)' : '';
var rowId = 'stt-row-' + i;
tableHtml += '<tr class="stt-row" data-rowid="' + rowId + '" style="cursor:pointer;" onclick="toggleSTTExpand(\'' + rowId + '\')">' +
'<td style="font-size:11px;color:var(--text2);white-space:nowrap;">' + escHtml(timeStr) + '</td>' +
'<td>' + statusBadgeHtml + '</td>' +
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;">' + escHtml(audioSizeMB) + ' MB</td>' +
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;">' + escHtml(estDuration) + '</td>' +
'<td style="text-align:center;">' + escHtml(lang) + '</td>' +
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;color:' + (log.durationMs > 5000 ? 'var(--orange)' : 'var(--text2)') + ';">' + escHtml(durationMs) + '</td>' +
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;">' + (log.status === 'success' ? escHtml((log.text || '').substring(0, 80)) + textLenLabel : textDisplay) + '</td>' +
'</tr>';
// 展开行: 完整内容
tableHtml += '<tr class="stt-expand" id="' + rowId + '-expand" style="display:none;">' +
'<td colspan="7" style="background:var(--bg);padding:12px 24px;">' +
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
'<div><strong style="color:var(--text2);font-size:11px;">文件名:</strong> <span style="font-size:11px;">' + escHtml(log.filename || '—') + '</span></div>' +
'<div><strong style="color:var(--text2);font-size:11px;">日志 ID:</strong> <code style="font-size:10px;">' + escHtml(log.id || '—') + '</code></div>' +
'</div>';
if (log.status === 'success') {
tableHtml += '<div style="margin-top:8px;"><strong style="color:var(--text2);font-size:11px;">完整识别结果 (' + (log.textLength || 0) + ' 字):</strong>' +
'<pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:200px;overflow:auto;white-space:pre-wrap;">' + escHtml(log.text || '') + '</pre></div>';
} else {
tableHtml += '<div style="margin-top:8px;"><strong style="color:var(--red);font-size:11px;">错误详情:</strong>' +
'<pre style="background:var(--red-bg);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:120px;overflow:auto;white-space:pre-wrap;color:var(--red);">' + escHtml(log.error || '未知错误') + '</pre></div>';
}
tableHtml += '</td></tr>';
}
} else {
tableHtml += '<tr><td colspan="7" style="text-align:center;color:var(--text3);padding:30px;">暂无语音识别记录</td></tr>';
}
tableHtml += '</tbody></table></div>';
// Voice Service 状态提示
var voiceStatusHtml = '';
try {
var voiceStatusResp = await fetch('/api/voice/health');
if (!voiceStatusResp.ok) {
voiceStatusHtml = '<div style="margin-top:12px;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--yellow);">' +
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
}
} catch(e) {
voiceStatusHtml = '<div style="margin-top:12px;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--yellow);">' +
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
}
// 语音录制测试卡片
var recorderHtml = buildVoiceRecorderCard();
container.innerHTML = statsHtml + recorderHtml + tableHtml + voiceStatusHtml;
}
// ---- 语音录制测试 ----
var voiceMediaRecorder = null;
var voiceAudioChunks = [];
function buildVoiceRecorderCard() {
var isRecording = !!voiceMediaRecorder;
return '<div class="card" style="margin-bottom:14px">' +
'<div class="card-header"><span class="card-title">🎙️ 语音录制测试</span>' +
'<span style="font-size:11px;color:var(--text3)">录音后自动发送到 ASR 模型识别并传入对话</span></div>' +
'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
'<button class="btn btn-accent" id="btn-record-start" onclick="startVoiceRecord()" ' + (isRecording ? 'disabled' : '') + '>🎙️ 开始录音</button>' +
'<button class="btn btn-red" id="btn-record-stop" onclick="stopVoiceRecord()" ' + (isRecording ? '' : 'disabled') + '>⏹️ 停止录音</button>' +
'<span id="record-status" style="font-size:12px;color:var(--text2)">' + (isRecording ? '🔴 录音中...' : '点击按钮开始录音') + '</span>' +
'<span id="record-timer" style="font-size:12px;font-family:monospace;color:var(--accent)"></span>' +
'</div>' +
'<div id="voice-result" style="margin-top:10px;font-size:12px;color:var(--text2)"></div>' +
'</div>';
}
var voiceRecordTimer = null;
var voiceRecordSeconds = 0;
async function startVoiceRecord() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('当前浏览器不支持录音功能');
return;
}
try {
var stream = await navigator.mediaDevices.getUserMedia({ audio: true });
voiceAudioChunks = [];
voiceMediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm' });
voiceMediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) voiceAudioChunks.push(e.data); };
voiceMediaRecorder.onstop = function() {
stream.getTracks().forEach(function(t) { t.stop(); });
processVoiceRecording();
};
voiceMediaRecorder.start();
voiceRecordSeconds = 0;
updateVoiceRecordUI(true);
voiceRecordTimer = setInterval(function() {
voiceRecordSeconds++;
var el = document.getElementById('record-timer');
if (el) el.textContent = voiceRecordSeconds + 's';
}, 1000);
} catch(e) {
alert('无法访问麦克风: ' + e.message);
}
}
function stopVoiceRecord() {
if (voiceMediaRecorder && voiceMediaRecorder.state === 'recording') {
voiceMediaRecorder.stop();
}
if (voiceRecordTimer) { clearInterval(voiceRecordTimer); voiceRecordTimer = null; }
updateVoiceRecordUI(false);
}
function updateVoiceRecordUI(isRecording) {
var startBtn = document.getElementById('btn-record-start');
var stopBtn = document.getElementById('btn-record-stop');
var statusEl = document.getElementById('record-status');
var timerEl = document.getElementById('record-timer');
if (startBtn) startBtn.disabled = isRecording;
if (stopBtn) stopBtn.disabled = !isRecording;
if (statusEl) statusEl.innerHTML = isRecording ? '🔴 录音中...' : '点击按钮开始录音';
if (timerEl) timerEl.textContent = isRecording ? '0s' : '';
}
async function processVoiceRecording() {
var resultEl = document.getElementById('voice-result');
if (!resultEl) return;
if (voiceAudioChunks.length === 0) {
resultEl.innerHTML = '<span style="color:var(--orange)">录音数据为空</span>';
return;
}
var blob = new Blob(voiceAudioChunks, { type: 'audio/webm' });
resultEl.innerHTML = '<span style="color:var(--text3)">📤 发送音频到 ASR 引擎 (' + (blob.size / 1024).toFixed(1) + ' KB)...</span>';
var reader = new FileReader();
reader.onload = function() {
var base64 = reader.result.split(',')[1];
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'voice_input',
mode: 'voice_msg',
audio_data: base64,
session_id: STATE.activeSession || 'default',
timestamp: Date.now()
}));
resultEl.innerHTML = '<span style="color:var(--text3)">⏳ 等待语音识别结果...</span>';
} else {
resultEl.innerHTML = '<span style="color:var(--red)">WebSocket 未连接,无法发送语音</span>';
}
};
reader.readAsDataURL(blob);
}
function handleVoiceTranscript(msg) {
var resultEl = document.getElementById('voice-result');
if (!resultEl) return;
if (msg.text) {
resultEl.innerHTML = '<div style="padding:8px 12px;background:var(--green-bg);border-radius:var(--radius-sm);border:1px solid var(--green)">' +
'<strong style="color:var(--green)">✅ 识别结果:</strong> ' + escHtml(msg.text) + '</div>';
}
}
function prependSTTTableRow(entry) {
var container = document.getElementById('panel-stt');
var table = container.querySelector('table tbody');
if (!table) return;
// 简单刷新整个面板以保持统计准确
renderSTTPanel();
}
function toggleSTTExpand(rowId) {
var expandRow = document.getElementById(rowId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
}
}
function refreshSTTPanel() {
renderSTTPanel();
}
function clearSTTLogs() {
STATE.sttLogs = [];
updateSTTBadge();
if (STATE.activePanel === 'stt') renderSTTPanel();
}
function toggleSTTAutoRefresh(on) {
if (STATE.sttAutoRefresh) {
clearInterval(STATE.sttAutoRefresh);
STATE.sttAutoRefresh = null;
}
if (on) {
STATE.sttAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'stt') renderSTTPanel();
}, 5000);
}
}
// ========== 面板9: 自主思考日志 ==========
async function renderThinkingPanel() {
var container = document.getElementById('panel-thinking');
if (!STATE.thinkingPage) STATE.thinkingPage = 1;
if (!STATE.thinkingAutoRefresh) STATE.thinkingAutoRefresh = null;
if (!STATE.thinkingLimit) STATE.thinkingLimit = 20;
if (!STATE.thinkingUserId) STATE.thinkingUserId = 'admin';
var actionsEl = document.getElementById('panel-actions');
var autoRefreshOn = STATE.thinkingAutoRefresh !== null;
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
'<input type="checkbox" id="thinking-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleThinkingAutoRefresh(this.checked)">' +
'自动刷新 (5s)</label>';
var statsData = null;
try {
var statsResp = await fetch('/api/v1/thinking/stats?user_id=' + encodeURIComponent(STATE.thinkingUserId));
if (statsResp.ok) {
statsData = await statsResp.json();
// 兼容旧格式 {stats: {...}} 和新格式 {total_logs: ..., ...}
if (statsData && statsData.stats) statsData = statsData.stats;
}
} catch(e) {}
var logsData = null;
try {
var params = '?user_id=' + encodeURIComponent(STATE.thinkingUserId) + '&limit=' + STATE.thinkingLimit + '&offset=' + ((STATE.thinkingPage - 1) * STATE.thinkingLimit);
var logsResp = await fetch('/api/v1/thinking' + params);
if (logsResp.ok) logsData = await logsResp.json();
} catch(e) {}
if (!logsData || logsData.error) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
(logsData && logsData.error ? escHtml(logsData.error) : '无法连接到 Memory-Service,请在「服务管理」中启动 Memory-Service') +
(logsData && logsData.hint ? '<br><small>' + escHtml(logsData.hint) + '</small>' : '') +
'</div>';
return;
}
var totalLogs = statsData ? statsData.total_logs : (logsData.total || 0);
var totalToolCalls = statsData ? statsData.total_tool_calls : 0;
var avgContentLen = statsData ? (statsData.avg_content_length || 0).toFixed(0) : 0;
var latestAt = statsData ? (statsData.latest_at ? formatTime(statsData.latest_at) : '—') : '—';
var statsCardsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
'<div class="stat-card accent"><div class="stat-value">' + totalLogs + '</div><div class="stat-label">总思考次数</div></div>' +
'<div class="stat-card green"><div class="stat-value">' + totalToolCalls + '</div><div class="stat-label">总工具调用</div></div>' +
'<div class="stat-card blue"><div class="stat-value">' + avgContentLen + '</div><div class="stat-label">平均长度(字符)</div></div>' +
'<div class="stat-card orange"><div class="stat-value">' + latestAt + '</div><div class="stat-label">最近思考</div></div>' +
'</div>';
var filterHtml = '<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap;">' +
'<button class="btn btn-sm" onclick="refreshThinkingPanel()">🔄 刷新</button>' +
'<span style="font-size:11px;color:var(--text3);">用户: ' + escHtml(STATE.thinkingUserId) + '</span>' +
'</div>';
var tableHtml = '<div class="table-wrap"><table>' +
'<thead><tr><th style="width:130px;">时间</th><th style="width:80px;">工具调用</th><th style="width:80px;">内容长度</th><th>内容摘要</th></tr></thead><tbody>';
var logs = logsData.logs || [];
if (logs.length > 0) {
for (var i = 0; i < logs.length; i++) {
var log = logs[i];
var timeStr = log.created_at ? new Date(log.created_at).toLocaleString('zh-CN', {hour12: false}) : '—';
var toolCallCount = log.tool_call_count || 0;
var contentLen = log.content_length || 0;
var summary = log.content || '';
// Extract first line or first 120 chars as summary
var firstLine = summary.split('\n')[0];
if (firstLine.length > 120) firstLine = firstLine.slice(0, 117) + '...';
var logId = 'th-' + log.id;
tableHtml += '<tr class="thinking-row" data-logid="' + logId + '" onclick="toggleThinkingExpand(\'' + logId + '\')" style="cursor:pointer;">' +
'<td style="font-size:11px;color:var(--text2);">' + escHtml(timeStr) + '</td>' +
'<td style="text-align:center;"><span class="badge" style="background:' + (toolCallCount > 0 ? 'var(--accent)' : 'var(--bg3)') + ';">' + toolCallCount + '</span></td>' +
'<td style="text-align:right;font-size:11px;color:var(--text2);">' + contentLen + '</td>' +
'<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;">' + escHtml(firstLine) + '</td>' +
'</tr>';
// Expand row with full content and tool calls
var toolCallsDisplay = '无';
try {
var parsed = typeof log.tool_calls === 'string' ? JSON.parse(log.tool_calls) : log.tool_calls;
if (parsed && Array.isArray(parsed) && parsed.length > 0) {
toolCallsDisplay = '';
for (var j = 0; j < parsed.length; j++) {
var tc = parsed[j];
var tcName = tc.function ? (tc.function.name || '未知') : (tc.name || '未知');
var tcArgs = tc.function ? (tc.function.arguments || '') : (tc.arguments || '');
if (typeof tcArgs === 'object') tcArgs = JSON.stringify(tcArgs);
toolCallsDisplay += '<div style="margin-bottom:6px;padding:6px;background:var(--bg2);border-radius:4px;">' +
'<strong style="color:var(--accent2);">' + escHtml(tcName) + '</strong>' +
'<pre style="font-size:10px;margin:4px 0 0;white-space:pre-wrap;color:var(--text2);">' + escHtml(String(tcArgs).slice(0, 500)) + '</pre>' +
'</div>';
}
}
} catch(e) { toolCallsDisplay = '<span style="color:var(--text3);">解析失败</span>'; }
tableHtml += '<tr class="thinking-expand" id="' + logId + '-expand" style="display:none;"><td colspan="4" style="background:var(--bg);padding:12px 24px;">' +
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
'<div><strong style="color:var(--text2);font-size:11px;">完整思考内容:</strong><pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:300px;overflow:auto;white-space:pre-wrap;">' + escHtml(log.content || '') + '</pre></div>' +
'<div><strong style="color:var(--text2);font-size:11px;">工具调用详情:</strong><div style="margin-top:4px;max-height:300px;overflow:auto;">' + toolCallsDisplay + '</div></div>' +
'</div>' +
'<div style="margin-top:8px;font-size:11px;color:var(--text3);">ID: ' + escHtml(log.id || '—') + ' | User: ' + escHtml(log.user_id || '—') + ' | 内容长度: ' + (log.content_length || 0) + ' | ' + (log.created_at ? new Date(log.created_at).toLocaleString('zh-CN', {hour12: false}) : '') + '</div>' +
'</td></tr>';
}
} else {
tableHtml += '<tr><td colspan="4" style="text-align:center;color:var(--text3);padding:30px;">暂无自主思考记录</td></tr>';
}
tableHtml += '</tbody></table></div>';
var totalPages = Math.max(1, Math.ceil((logsData.total || 0) / STATE.thinkingLimit));
var paginationHtml = '';
if (totalPages > 1) {
paginationHtml = '<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px;font-size:12px;">' +
'<button class="btn btn-sm" ' + (STATE.thinkingPage <= 1 ? 'disabled' : '') + ' onclick="goThinkingPage(' + (STATE.thinkingPage - 1) + ')">← 上一页</button>' +
'<span style="color:var(--text2);">第 ' + STATE.thinkingPage + '/' + totalPages + ' 页</span>' +
'<button class="btn btn-sm" ' + (STATE.thinkingPage >= totalPages ? 'disabled' : '') + ' onclick="goThinkingPage(' + (STATE.thinkingPage + 1) + ')">下一页 →</button>' +
'</div>';
}
container.innerHTML = statsCardsHtml + filterHtml + tableHtml + paginationHtml;
// 恢复之前展开的思考日志
Object.keys(STATE.expandedThinkingLogs).forEach(function(logId) {
if (STATE.expandedThinkingLogs[logId]) {
var expandRow = document.getElementById(logId + '-expand');
if (expandRow) expandRow.style.display = '';
}
});
}
function toggleThinkingExpand(logId) {
var expandRow = document.getElementById(logId + '-expand');
if (expandRow) {
var isExpanded = expandRow.style.display !== 'none';
if (isExpanded) {
expandRow.style.display = 'none';
STATE.expandedThinkingLogs[logId] = false;
} else {
expandRow.style.display = '';
STATE.expandedThinkingLogs[logId] = true;
}
}
}
function goThinkingPage(page) {
STATE.thinkingPage = page;
renderThinkingPanel();
document.getElementById('panel-thinking').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function refreshThinkingPanel() {
renderThinkingPanel();
}
function toggleThinkingAutoRefresh(on) {
if (STATE.thinkingAutoRefresh) {
clearInterval(STATE.thinkingAutoRefresh);
STATE.thinkingAutoRefresh = null;
}
if (on) {
STATE.thinkingAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'thinking') renderThinkingPanel();
}, 5000);
}
}
// ========== 面板10: 记忆时间线 ==========
function stopTimelineAutoRefresh() {
if (STATE.timelineAutoRefresh) { clearInterval(STATE.timelineAutoRefresh); STATE.timelineAutoRefresh = null; }
}
function startTimelineAutoRefresh() {
stopTimelineAutoRefresh();
STATE.timelineAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'timeline') renderTimelinePanel();
}, 30000);
}
function importanceToStarsTimeline(imp) {
var full = Math.round(imp / 2);
var empty = 5 - full;
return '<span style="color:#f59e0b">' + '★'.repeat(full) + '</span><span style="color:var(--text3)">' + '☆'.repeat(empty) + '</span>';
}
async function renderTimelinePanel() {
var container = document.getElementById('panel-timeline');
if (!container) return;
var actionsEl = document.getElementById('panel-actions');
var autoRefreshOn = STATE.timelineAutoRefresh !== null;
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
'<input type="checkbox" id="timeline-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleTimelineAutoRefresh(this.checked)">' +
'自动刷新 (30s)</label>' +
'<button class="btn btn-sm" onclick="renderTimelinePanel()" style="margin-left:8px">🔄 刷新</button>';
// 加载数据
var userId = STATE.timelineUserId || 'admin';
var data = await api('/api/memory-timeline?user_id=' + encodeURIComponent(userId) + '&limit=' + STATE.timelineLimit);
if (data.error) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) +
(data.hint ? '<br><small>' + escHtml(data.hint) + '</small>' : '') + '</div>';
return;
}
var timeline = data.timeline || [];
var stats = data.stats || {};
STATE.timelineData = timeline;
// 筛选
var filtered = timeline;
if (STATE.timelineFilterType && STATE.timelineFilterType !== 'all') {
filtered = timeline.filter(function(item) { return item.type === STATE.timelineFilterType; });
}
// 统计卡片
var memCount = stats.total_memories || 0;
var thinkCount = stats.total_thinking || 0;
var latestMemTime = stats.latest_memory_time ? formatTime(stats.latest_memory_time) : '—';
var latestThinkTime = stats.latest_thinking_time ? formatTime(stats.latest_thinking_time) : '—';
var statsCardsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
'<div class="stat-card accent"><div class="stat-value">' + memCount + '</div><div class="stat-label">🧠 总记忆数</div></div>' +
'<div class="stat-card blue"><div class="stat-value">' + thinkCount + '</div><div class="stat-label">💭 总思考次数</div></div>' +
'<div class="stat-card green"><div class="stat-value">' + latestMemTime + '</div><div class="stat-label">📅 最新记忆</div></div>' +
'<div class="stat-card orange"><div class="stat-value">' + latestThinkTime + '</div><div class="stat-label">🕐 最新思考</div></div>' +
'</div>';
// 筛选栏
var allActive = STATE.timelineFilterType === 'all' ? ' active' : '';
var memActive = STATE.timelineFilterType === 'memory' ? ' active' : '';
var thinkActive = STATE.timelineFilterType === 'thinking' ? ' active' : '';
var filterHtml = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;">' +
'<span style="font-size:12px;color:var(--text2);">筛选类型:</span>' +
'<button class="timeline-filter-tab' + allActive + '" onclick="filterTimeline(\'all\')">📋 全部</button>' +
'<button class="timeline-filter-tab' + memActive + '" onclick="filterTimeline(\'memory\')">🧠 记忆</button>' +
'<button class="timeline-filter-tab' + thinkActive + '" onclick="filterTimeline(\'thinking\')">💭 思考</button>' +
'<span style="font-size:11px;color:var(--text3);margin-left:auto;">显示 ' + filtered.length + ' / ' + timeline.length + ' 条</span>' +
'</div>';
// 时间线主体
var timelineHtml = '';
if (filtered.length === 0) {
timelineHtml = '<div class="empty-state"><div class="icon">📭</div>暂无匹配的时间线条目</div>';
} else {
timelineHtml = '<div class="timeline-container">';
for (var i = 0; i < filtered.length; i++) {
var item = filtered[i];
var itemId = 'tl-' + i;
var isMemory = item.type === 'memory';
// 圆点图标
var dotIcon = isMemory ? '🧠' : '💭';
var dotClass = isMemory ? 'memory' : 'thinking';
var cardClass = isMemory ? 'memory-card' : 'thinking-card';
var titleClass = isMemory ? 'memory' : 'thinking';
// 标题
var title = item.title || (isMemory ? '记忆' : '思考');
// 摘要
var summary = '';
if (isMemory) {
summary = item.summary || item.content || '';
if (summary.length > 200) summary = summary.substring(0, 197) + '...';
} else {
summary = item.summary || '';
}
// 重要性星级
var starsHtml = '';
if (isMemory && item.importance) {
starsHtml = '<span class="timeline-importance-stars">' + importanceToStarsTimeline(item.importance) + '</span>';
}
// 分类标签
var catLabel = '';
if (isMemory && item.category) {
var cc = getCatColor(item.category);
catLabel = '<span style="display:inline-block;padding:1px 8px;border-radius:10px;font-size:10px;font-weight:500;background:' + cc.bg + ';color:' + cc.text + ';">' + cc.icon + ' ' + cc.name + '</span>';
}
// 工具调用数
var toolCallLabel = '';
if (!isMemory && item.tool_call_count > 0) {
toolCallLabel = '<span style="display:inline-block;padding:1px 8px;border-radius:10px;font-size:10px;font-weight:500;background:var(--accent-bg);color:var(--accent);">🔧 ' + item.tool_call_count + ' 次工具调用</span>';
}
// 触发方式
var triggerLabel = '';
if (!isMemory) {
var trigger = item.trigger || '定时';
var triggerClass = trigger === '手动' ? 'manual' : 'scheduled';
triggerLabel = '<span class="timeline-trigger-badge ' + triggerClass + '">' + (trigger === '手动' ? '👆 手动' : '⏰ 定时') + '</span>';
}
// 来源
var sourceLabel = '';
if (isMemory) {
var srcText = item.source === 'thinking' ? '🤔 后台思考' : item.source === 'conversation' ? '💬 对话' : '📝 ' + (item.source || '未知');
sourceLabel = '<span>' + srcText + '</span>';
}
timelineHtml += '<div class="timeline-item" id="' + itemId + '">' +
'<div class="timeline-dot ' + dotClass + '">' + dotIcon + '</div>' +
'<div class="timeline-card ' + cardClass + '" onclick="toggleTimelineDetail(\'' + itemId + '\')">' +
'<div class="timeline-card-header">' +
'<span class="timeline-card-title ' + titleClass + '">' + escHtml(title) + '</span>' +
'<div class="timeline-card-meta">' +
starsHtml +
'<span style="font-size:10px;white-space:nowrap;">' + formatTime(item.timestamp) + '</span>' +
'</div>' +
'</div>' +
(summary ? '<div class="timeline-card-body">' + escHtml(summary) + '</div>' : '') +
'<div class="timeline-card-footer">' +
catLabel +
toolCallLabel +
triggerLabel +
sourceLabel +
(item.session_id ? '<span style="font-size:10px;color:var(--text3);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">💬 ' + escHtml((item.session_id || '').substring(0, 20)) + '</span>' : '') +
'</div>' +
'</div>' +
// 详情展开区
'<div class="timeline-detail" id="' + itemId + '-detail">' +
renderTimelineDetail(item) +
'</div>' +
'</div>';
}
timelineHtml += '</div>';
}
container.innerHTML = statsCardsHtml + filterHtml + timelineHtml;
}
function renderTimelineDetail(item) {
var html = '';
if (item.type === 'memory') {
// 记忆详情
html += '<div class="detail-section">' +
'<div class="detail-label">📝 完整内容</div>' +
'<div class="detail-content">' + escHtml(item.content || '') + '</div>' +
'</div>';
if (item.keywords && item.keywords.length > 0) {
html += '<div class="detail-section">' +
'<div class="detail-label">🏷️ 关键词</div>' +
'<div style="display:flex;gap:4px;flex-wrap:wrap;">' +
item.keywords.map(function(k) { return '<span style="padding:2px 8px;background:var(--bg3);border-radius:10px;font-size:10px;">' + escHtml(k) + '</span>'; }).join('') +
'</div>' +
'</div>';
}
html += '<div class="detail-section" style="display:flex;gap:20px;flex-wrap:wrap;">' +
'<span><strong>重要性:</strong> ' + (item.importance || 0) + '/10</span>' +
'<span><strong>访问次数:</strong> ' + (item.access_count || 0) + '</span>' +
'<span><strong>来源:</strong> ' + escHtml(item.source || '未知') + '</span>' +
'<span><strong>ID:</strong> <code style="font-size:10px;">' + escHtml((item.id || '').substring(0, 16)) + '</code></span>' +
'</div>';
} else {
// 思考详情
html += '<div class="detail-section">' +
'<div class="detail-label">💭 思考内容</div>' +
'<div class="detail-content">' + escHtml(item.content || '') + '</div>' +
'</div>';
if (item.tool_calls) {
var toolCalls = item.tool_calls;
try {
if (typeof toolCalls === 'string') toolCalls = JSON.parse(toolCalls);
} catch(e) { toolCalls = null; }
if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
html += '<div class="detail-section">' +
'<div class="detail-label">🔧 工具调用详情 (' + toolCalls.length + ')</div>';
for (var i = 0; i < toolCalls.length; i++) {
var tc = toolCalls[i];
var tcName = tc.function ? (tc.function.name || '未知') : (tc.name || '未知');
var tcArgs = tc.function ? (tc.function.arguments || '') : (tc.arguments || '');
if (typeof tcArgs === 'object') tcArgs = JSON.stringify(tcArgs);
html += '<div class="tool-call-item">' +
'<strong style="color:var(--accent2);">' + escHtml(tcName) + '</strong>' +
'<pre style="font-size:10px;margin:4px 0 0;white-space:pre-wrap;color:var(--text2);">' + escHtml(String(tcArgs).slice(0, 300)) + '</pre>' +
'</div>';
}
html += '</div>';
}
}
html += '<div class="detail-section" style="display:flex;gap:20px;flex-wrap:wrap;">' +
'<span><strong>内容长度:</strong> ' + (item.content_length || 0) + ' 字符</span>' +
'<span><strong>工具调用数:</strong> ' + (item.tool_call_count || 0) + '</span>' +
'<span><strong>触发方式:</strong> ' + escHtml(item.trigger || '定时') + '</span>' +
'<span><strong>ID:</strong> <code style="font-size:10px;">' + escHtml((item.id || '').substring(0, 16)) + '</code></span>' +
'</div>';
}
return html;
}
function toggleTimelineDetail(itemId) {
var detail = document.getElementById(itemId + '-detail');
if (detail) {
detail.classList.toggle('open');
}
}
function filterTimeline(type) {
STATE.timelineFilterType = type;
renderTimelinePanel();
}
function toggleTimelineAutoRefresh(on) {
if (STATE.timelineAutoRefresh) {
clearInterval(STATE.timelineAutoRefresh);
STATE.timelineAutoRefresh = null;
}
if (on) {
STATE.timelineAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'timeline') renderTimelinePanel();
}, 30000);
}
}
// ========== 面板: 第三方聊天配置 ==========
var PLATFORM_FIELDS = {
qq: [{ key: 'bot_port', label: 'Bot WebSocket 端口', placeholder: '8096' }],
telegram: [
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://your-domain.com' }
],
webhook: [
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://hook.example.com/chat' },
{ key: 'secret', label: 'Secret Token', placeholder: '(可选)' }
],
wechat: [
{ key: 'corp_id', label: '企业ID (Corp ID)', placeholder: 'ww...' },
{ key: 'corp_secret', label: '应用Secret', placeholder: '' },
{ key: 'agent_id', label: 'Agent ID', placeholder: '1000001' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' }
],
feishu: [
{ key: 'app_id', label: 'App ID', placeholder: 'cli_...' },
{ key: 'app_secret', label: 'App Secret', placeholder: '' },
{ key: 'verification_token', label: 'Verification Token', placeholder: '' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' }
],
discord: [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'MT...' },
{ key: 'application_id', label: 'Application ID', placeholder: '123456789...' }
]
};
var PLATFORM_ICONS = { qq: '🐧', telegram: '✈️', webhook: '🪝', wechat: '💚', feishu: '🕊️', discord: '🎮' };
var PLATFORM_LABELS = { qq: 'QQ', telegram: 'Telegram', webhook: 'Webhook', wechat: 'WeChat', feishu: 'Feishu', discord: 'Discord' };
function startChatAutoRefresh() {
stopChatAutoRefresh();
STATE.chatConfigsAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'chatPlatforms') {
loadChatConfigs();
if (STATE.chatActivePlatform) refreshChatLogs(STATE.chatActivePlatform);
}
}, 10000);
}
function stopChatAutoRefresh() {
if (STATE.chatConfigsAutoRefresh) { clearInterval(STATE.chatConfigsAutoRefresh); STATE.chatConfigsAutoRefresh = null; }
}
function renderChatPlatformsPanel() {
if (STATE.chatActivePlatform) { renderChatPlatformDetail(STATE.chatActivePlatform); return; }
var panel = document.getElementById('panel-chatPlatforms');
panel.innerHTML = '<div class="card"><div class="card-header"><span class="card-title">🔗 平台配置列表</span>' +
'<button class="btn btn-sm btn-accent" onclick="showChatConfigForm()"> 添加配置</button></div>' +
'<div class="table-wrap"><table id="chat-configs-table"><thead><tr>' +
'<th>平台</th><th>启用</th><th>连接</th><th>关键配置</th><th>更新时间</th><th>操作</th>' +
'</tr></thead><tbody id="chat-configs-tbody">' +
'<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>加载中...</div></td></tr></tbody></table></div></div>';
document.getElementById('panel-actions').innerHTML = '<button class="btn btn-sm" onclick="refreshChatConfigs()">🔄 刷新</button>';
loadChatConfigs();
}
async function loadChatConfigs() {
var data = await api('/api/chat-platforms/configs');
var tbody = document.getElementById('chat-configs-tbody');
if (!tbody) return;
if (data.error) { tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div></td></tr>'; return; }
STATE.chatConfigs = data.configs || [];
renderChatConfigsTable();
}
function renderChatConfigsTable() {
var tbody = document.getElementById('chat-configs-tbody');
if (!tbody) return;
var configs = STATE.chatConfigs;
if (configs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>暂无配置,点击「添加配置」创建</div></td></tr>';
return;
}
tbody.innerHTML = configs.map(function(c) {
var icon = PLATFORM_ICONS[c.name] || '🔗';
var label = c.label || PLATFORM_LABELS[c.name] || c.name;
var connBadge = c.connected ? '<span class="badge badge-running">已连接</span>' : '<span class="badge badge-stopped">未连接</span>';
var enabledBadge = c.enabled !== false ? '<span class="badge badge-running">启用</span>' : '<span class="badge badge-stopped">禁用</span>';
var keys = (c.fields && Object.keys(c.fields).length > 0)
? Object.keys(c.fields).map(function(k) { return k + '=' + (c.fields[k] ? '***' : '(空)'); }).join(', ')
: '—';
var updated = c.updated_at ? timeAgo(c.updated_at) : '—';
return '<tr>' +
'<td><strong>' + icon + ' ' + escHtml(label) + '</strong></td>' +
'<td>' + enabledBadge + '</td>' +
'<td>' + connBadge + '</td>' +
'<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(keys) + '</td>' +
'<td>' + updated + '</td>' +
'<td><div class="btn-group">' +
'<button class="btn btn-xs" onclick="editChatConfig(\'' + escHtml(c.name) + '\')">✏️ 编辑</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteChatConfig(\'' + escHtml(c.name) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
}
function refreshChatConfigs() { loadChatConfigs(); }
function showChatConfigForm() {
var panel = document.getElementById('panel-chatPlatforms');
var options = ['qq', 'telegram', 'webhook', 'wechat', 'feishu', 'discord'];
panel.innerHTML = '<div class="card"><div class="card-header"><span class="card-title"> 选择要配置的平台</span>' +
'<button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 取消</button></div>' +
'<div class="cards-grid cards-3">' +
options.map(function(p) {
return '<div class="card" style="cursor:pointer;text-align:center;padding:20px" onclick="startNewConfig(\'' + p + '\')">' +
'<div style="font-size:32px;margin-bottom:8px">' + (PLATFORM_ICONS[p] || '🔗') + '</div>' +
'<div style="font-weight:600">' + (PLATFORM_LABELS[p] || p) + '</div></div>';
}).join('') + '</div></div>';
document.getElementById('panel-actions').innerHTML = '';
}
function startNewConfig(name) { STATE.chatActivePlatform = name; renderChatPlatformsPanel(); }
function editChatConfig(name) {
if (!STATE.chatConfigs.some(function(c) { return c.name === name; })) {
STATE.chatActivePlatform = name;
renderChatPlatformsPanel();
} else {
STATE.chatActivePlatform = name;
renderChatPlatformsPanel();
}
}
function renderChatPlatformDetail(name) {
var cfg = null;
for (var i = 0; i < STATE.chatConfigs.length; i++) {
if (STATE.chatConfigs[i].name === name) { cfg = STATE.chatConfigs[i]; break; }
}
var icon = PLATFORM_ICONS[name] || '🔗';
var panel = document.getElementById('panel-chatPlatforms');
panel.innerHTML =
'<div style="margin-bottom:14px"><button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 返回列表</button></div>' +
'<div class="card"><div class="card-header"><span class="card-title">' + icon + ' ' + escHtml(name) + ' 配置</span><span id="cfg-save-status"></span></div>' +
'<div class="card-body" id="chat-config-form"></div></div>' +
'<div class="card" style="margin-top:14px"><div class="card-header"><span class="card-title">📋 消息日志 (最近 ' + STATE.chatLogLimit + ' 条)</span>' +
'<div class="btn-group">' +
'<button class="btn btn-xs" onclick="refreshChatLogs(\'' + escHtml(name) + '\')">🔄 刷新</button>' +
'<select id="chat-log-limit" onchange="STATE.chatLogLimit=parseInt(this.value);refreshChatLogs(\'' + escHtml(name) + '\')" ' +
'style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
'<option value="50">50条</option><option value="100" selected>100条</option><option value="200">200条</option><option value="500">500条</option></select></div></div>' +
'<div id="chat-log-container" style="max-height:400px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px">' +
'<div class="empty-state"><div class="icon">📝</div>加载中...</div></div></div>';
document.getElementById('panel-actions').innerHTML = '';
renderChatConfigForm(name, cfg);
refreshChatLogs(name);
}
function renderChatConfigForm(name, cfg) {
var fields = PLATFORM_FIELDS[name] || [];
var container = document.getElementById('chat-config-form');
if (!container) return;
var currentFields = (cfg && cfg.fields) || {};
var enabled = cfg ? (cfg.enabled !== false) : true;
var fieldsHTML = fields.map(function(f) {
var val = currentFields[f.key] || '';
return '<div class="form-group"><label>' + escHtml(f.label) + '</label>' +
'<input type="text" id="cfg-field-' + escHtml(f.key) + '" value="' + escHtml(val) + '" placeholder="' + escHtml(f.placeholder || '') + '"></div>';
}).join('');
container.innerHTML =
'<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
'<input type="checkbox" id="cfg-field-enabled" ' + (enabled ? 'checked' : '') + ' style="width:auto"><span>启用此平台</span></label></div>' +
fieldsHTML +
'<div class="form-group"><label>显示名称</label>' +
'<input type="text" id="cfg-field-label" value="' + escHtml((cfg && cfg.label) || '') + '" placeholder="' + escHtml(name) + '"></div>' +
'<div class="btn-group" style="margin-top:12px"><button class="btn btn-sm btn-accent" onclick="saveChatConfig(\'' + escHtml(name) + '\')">💾 保存配置</button></div>';
}
async function saveChatConfig(name) {
var fields = {};
var fieldDefs = PLATFORM_FIELDS[name] || [];
fieldDefs.forEach(function(f) {
var el = document.getElementById('cfg-field-' + f.key);
if (el) fields[f.key] = el.value;
});
var enabledEl = document.getElementById('cfg-field-enabled');
var enabled = enabledEl ? enabledEl.checked : true;
var labelEl = document.getElementById('cfg-field-label');
var label = labelEl ? labelEl.value : '';
var data = await api('/api/chat-platforms/configs/' + encodeURIComponent(name), {
method: 'POST',
body: JSON.stringify({ name: name, enabled: enabled, label: label, fields: fields })
});
if (data.error) { showToast('保存失败: ' + data.error, 'error'); return; }
showToast('配置已保存 (需重启平台桥接服务生效)', 'success');
await loadChatConfigs();
renderChatPlatformDetail(name);
}
async function deleteChatConfig(name) {
if (!confirm('确认删除 ' + name + ' 的配置?')) return;
var data = await api('/api/chat-platforms/configs/' + encodeURIComponent(name), { method: 'DELETE' });
if (data.error) { showToast('删除失败: ' + data.error, 'error'); return; }
showToast('配置已删除', 'success');
STATE.chatActivePlatform = null;
await loadChatConfigs();
renderChatPlatformsPanel();
}
async function refreshChatLogs(name) {
var limit = STATE.chatLogLimit || 100;
var data = await api('/api/chat-platforms/logs/' + encodeURIComponent(name) + '?limit=' + limit);
var container = document.getElementById('chat-log-container');
if (!container) return;
if (data.error) { container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>'; return; }
var logs = data.logs || [];
STATE.chatLogs = STATE.chatLogs || {};
STATE.chatLogs[name] = logs;
if (logs.length === 0) { container.innerHTML = '<div class="empty-state"><div class="icon">📝</div>暂无消息日志</div>'; return; }
container.innerHTML = logs.map(function(l) {
var arrow = l.direction === 'incoming' ? '← 收到' : '→ 发送';
var color = l.direction === 'incoming' ? 'var(--blue)' : 'var(--green)';
var time = new Date(l.timestamp).toLocaleString('zh-CN', { hour12: false });
var content = (l.content || '').length > 300 ? (l.content || '').substring(0, 297) + '...' : (l.content || '');
return '<div style="padding:6px 10px;border-bottom:1px solid var(--border);font-size:12px">' +
'<span style="color:' + color + ';font-weight:600">' + arrow + '</span> ' +
'<span style="color:var(--text3)">' + time + '</span> ' +
'<span style="color:var(--text2)">[' + escHtml(l.sender_name || l.sender_id || '-') + ']</span> ' +
'<span>' + escHtml(content) + '</span>' +
(l.error ? ' <span style="color:var(--red)">⚠ ' + escHtml(l.error) + '</span>' : '') +
'</div>';
}).join('');
}
// ========== 模型配置管理面板 ==========
function renderModelConfigPanel() {
var panel = document.getElementById('panel-modelConfig');
var activeTab = STATE.modelConfigTab || 'providers';
var tabs = [
{ id: 'providers', label: '🔌 模型提供商' },
{ id: 'models', label: '🧠 模型定义' },
{ id: 'routing', label: '🔀 用途路由' },
];
var tabBar = '<div class="tab-bar" style="margin-bottom:14px;display:flex;gap:6px;flex-wrap:wrap">' +
tabs.map(function(t) {
return '<button class="btn btn-sm' + (activeTab === t.id ? ' btn-accent' : '') +
'" onclick="STATE.modelConfigTab=\'' + t.id + '\';renderModelConfigPanel();">' + t.label + '</button>';
}).join('') + '</div>';
panel.innerHTML = tabBar + '<div id="model-config-content" class="card"><div class="card-body">加载中...</div></div>';
document.getElementById('panel-actions').innerHTML = '';
switch (activeTab) {
case 'providers': renderProvidersTab(); break;
case 'models': renderModelsTab(); break;
case 'routing': renderRoutingTab(); break;
}
}
// ---- Providers tab ----
async function renderProvidersTab() {
var container = document.getElementById('model-config-content');
var data = await api('/api/model-config/providers');
STATE.modelConfigProviders = data.providers || [];
var rows = STATE.modelConfigProviders.length === 0
? '<tr><td colspan="5"><div class="empty-state"><div class="icon">🔌</div>暂无模型提供商,请添加</div></td></tr>'
: STATE.modelConfigProviders.map(function(p) {
var updated = p.updated_at ? timeAgo(p.updated_at) : '—';
return '<tr>' +
'<td><strong>' + escHtml(p.name) + '</strong></td>' +
'<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(p.base_url) + '</td>' +
'<td>' + (p.timeout_sec || '—') + 's</td>' +
'<td>' + updated + '</td>' +
'<td><div class="btn-group">' +
'<button class="btn btn-xs" onclick="showProviderForm(\'' + escHtml(p.name) + '\')">✏️</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteModelProvider(\'' + escHtml(p.name) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
container.innerHTML =
'<div class="card-header"><span class="card-title">🔌 模型提供商</span>' +
'<button class="btn btn-sm btn-accent" onclick="showProviderForm()"> 添加</button></div>' +
'<div class="table-wrap"><table><thead><tr>' +
'<th>名称</th><th>Base URL</th><th>超时</th><th>更新时间</th><th>操作</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
var PROVIDER_TEMPLATES = [
{ name: 'deepseek', label: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.deepseek.com/models' },
{ name: 'dashscope', label: '阿里百炼 (DashScope)', base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/models' },
{ name: 'zhipu', label: '智谱 AI (GLM)', base_url: 'https://open.bigmodel.cn/api/paas/v4', timeout_sec: 120, max_retries: 3, models_url: 'https://open.bigmodel.cn/api/paas/v4/models' },
{ name: 'moonshot', label: 'Moonshot (Kimi)', base_url: 'https://api.moonshot.cn/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.moonshot.cn/v1/models' },
{ name: 'siliconflow', label: '硅基流动 (SiliconFlow)', base_url: 'https://api.siliconflow.cn/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.siliconflow.cn/v1/models' },
{ name: 'lingyi', label: '零一万物', base_url: 'https://api.lingyiwanwu.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.lingyiwanwu.com/v1/models' },
{ name: 'qianfan', label: '百度千帆', base_url: 'https://qianfan.baidubce.com/v2', timeout_sec: 120, max_retries: 3, models_url: 'https://qianfan.baidubce.com/v2/models' },
{ name: 'xfyun', label: '讯飞星火', base_url: 'https://spark-api-open.xf-yun.com/v1', timeout_sec: 120, max_retries: 3, models_url: '' },
{ name: 'minimax', label: 'MiniMax', base_url: 'https://api.minimax.chat/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.minimax.chat/v1/models' },
{ name: 'openai', label: 'OpenAI', base_url: 'https://api.openai.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.openai.com/v1/models' },
{ name: 'custom', label: '💡 自定义...', base_url: '', timeout_sec: 120, max_retries: 3, models_url: '' },
];
var MODEL_TEMPLATES = {
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
dashscope: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-max-longcontext', 'qwen-vl-plus', 'qwen-coder-turbo'],
zhipu: ['glm-4-flash', 'glm-4-plus', 'glm-4-long', 'glm-4v-flash', 'glm-4-air'],
moonshot: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
siliconflow: ['Qwen/Qwen3-235B-A22B', 'Qwen/Qwen2.5-72B-Instruct', 'Qwen/Qwen2.5-7B-Instruct', 'deepseek-ai/DeepSeek-V3', 'Pro/THUDM/glm-4-9b-chat'],
lingyi: ['yi-large', 'yi-medium', 'yi-lightning', 'yi-vision'],
qianfan: ['ernie-speed-128k', 'ernie-4.0-8k', 'ernie-3.5-8k', 'ernie-speed-pro-128k'],
xfyun: ['spark-lite', 'spark-pro-128k', 'spark-max', 'spark-4.0-ultra'],
minimax: ['abab6.5s-chat', 'abab6.5-chat'],
openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini'],
};
function showProviderForm(name) {
var existing = null;
if (name) {
for (var i = 0; i < STATE.modelConfigProviders.length; i++) {
if (STATE.modelConfigProviders[i].name === name) { existing = STATE.modelConfigProviders[i]; break; }
}
}
var isEdit = !!existing;
var formTitle = isEdit ? '✏️ 编辑 ' + escHtml(name) : ' 添加模型提供商';
var defaults = existing || { name: '', base_url: 'https://api.deepseek.com/v1', api_key: '', timeout_sec: 120, max_retries: 3 };
var templateOptions = PROVIDER_TEMPLATES.map(function(t) {
return '<option value="' + t.name + '" data-url="' + escHtml(t.base_url) + '" data-timeout="' + t.timeout_sec + '" data-retries="' + t.max_retries + '">' + escHtml(t.label) + '</option>';
}).join('');
var container = document.getElementById('model-config-content');
container.innerHTML =
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
'<button class="btn btn-sm" onclick="renderProvidersTab()">← 返回</button></div>' +
'<div class="card-body"><form onsubmit="event.preventDefault();saveProviderForm(\'' + escHtml(name || '') + '\');">' +
(isEdit ? '' :
'<div class="form-row"><label>📋 快速模板</label>' +
'<select id="prov-template" class="input" onchange="applyProviderTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择提供商模板自动填充 --</option>' + templateOptions + '</select></div>') +
'<div class="form-row"><label>Provider 名称 ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
'<input id="prov-name" class="input" value="' + escHtml(defaults.name) + '" ' + (isEdit ? 'readonly' : 'placeholder="如 deepseek, openai"') + ' required></div>' +
'<div class="form-row"><label>Base URL <span style="color:var(--red)">*</span></label>' +
'<input id="prov-url" class="input" value="' + escHtml(defaults.base_url) + '" placeholder="https://api.deepseek.com/v1" required></div>' +
'<div class="form-row"><label>API Key</label>' +
'<input id="prov-key" class="input" type="password" value="' + escHtml(defaults.api_key || '') + '" placeholder="sk-xxx"></div>' +
'<div class="form-row" style="display:flex;gap:12px"><div style="flex:1"><label>超时 (秒)</label>' +
'<input id="prov-timeout" class="input" type="number" value="' + (defaults.timeout_sec || 120) + '"></div>' +
'<div style="flex:1"><label>最大重试</label>' +
'<input id="prov-retries" class="input" type="number" value="' + (defaults.max_retries || 3) + '"></div></div>' +
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
'</form></div>';
}
function applyProviderTemplate(templateName) {
if (!templateName || templateName === 'custom') return;
var sel = document.getElementById('prov-template');
var opt = sel.options[sel.selectedIndex];
document.getElementById('prov-name').value = opt.value;
document.getElementById('prov-url').value = opt.getAttribute('data-url') || '';
var timeout = parseInt(opt.getAttribute('data-timeout')) || 120;
var retries = parseInt(opt.getAttribute('data-retries')) || 3;
var timeoutEl = document.getElementById('prov-timeout');
var retriesEl = document.getElementById('prov-retries');
if (timeoutEl && !timeoutEl.value) timeoutEl.value = timeout;
if (retriesEl && !retriesEl.value) retriesEl.value = retries;
}
function updateModelTemplateOptions() {
var provider = document.getElementById('model-provider').value;
var area = document.getElementById('model-template-area');
if (!area) return;
STATE.fetchedModels = [];
var models = MODEL_TEMPLATES[provider] || [];
area.innerHTML =
'<select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择模型模板 / 查询获取 --</option>' +
models.map(function(m) { return '<option value="' + escHtml(m) + '">' + escHtml(m) + '</option>'; }).join('') +
'</select>';
var btn = document.getElementById('btn-fetch-models');
if (!btn) return;
var tmpl = null;
for (var i = 0; i < PROVIDER_TEMPLATES.length; i++) {
if (PROVIDER_TEMPLATES[i].name === provider) { tmpl = PROVIDER_TEMPLATES[i]; break; }
}
btn.disabled = !(tmpl && tmpl.models_url);
}
function applyModelTemplate(modelName) {
if (!modelName) return;
document.getElementById('model-name').value = modelName;
var idEl = document.getElementById('model-id');
if (idEl && !idEl.value) idEl.value = modelName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
}
async function fetchProviderModels() {
var provider = document.getElementById('model-provider').value;
if (!provider) { alert('请先选择 Provider'); return; }
var tmpl = null;
for (var i = 0; i < PROVIDER_TEMPLATES.length; i++) {
if (PROVIDER_TEMPLATES[i].name === provider) { tmpl = PROVIDER_TEMPLATES[i]; break; }
}
if (!tmpl || !tmpl.models_url) { alert('该 Provider 不支持在线查询模型列表(讯飞星火等使用非标准接口)'); return; }
var btn = document.getElementById('btn-fetch-models');
if (btn) { btn.disabled = true; btn.textContent = '⏳ 查询中...'; }
try {
var result = await api('/api/model-config/fetch-models/' + encodeURIComponent(provider) + '?url=' + encodeURIComponent(tmpl.models_url));
if (result.error) { alert('查询失败: ' + result.error + (result.body ? '\n' + result.body.substring(0, 200) : '')); return; }
var models = result.models || [];
if (models.length === 0) { alert('该 Provider 未返回任何模型'); return; }
STATE.fetchedModels = models;
renderFetchedModelList(models, '');
} catch(e) {
alert('查询模型列表出错: ' + e.message);
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🔍 查询'; }
}
}
function renderFetchedModelList(models, filter) {
var area = document.getElementById('model-template-area');
if (!area) return;
var filterLower = (filter || '').toLowerCase();
var filtered = filterLower ? models.filter(function(m) { return m.toLowerCase().indexOf(filterLower) >= 0; }) : models;
var countInfo = filterLower ? '\uff08' + filtered.length + '/' + models.length + '\uff09' : '\uff08共 ' + models.length + ' \u4e2a\uff09';
// 搜索栏 + 结果列表分离:搜索框保持在 DOM 中,oninput 只更新结果列表
var html = '<div id="model-search-bar" style="display:flex;gap:8px;margin-bottom:8px">' +
'<input id="model-search-input" class="input" type="text" placeholder="\U0001f50d \u641c索模型名称...' + countInfo + '" value="' + escHtml(filter) + '"' +
' oninput="filterFetchedModels()" style="flex:1;background:var(--bg);font-size:12px">' +
'<button type="button" class="btn btn-xs" onclick="clearModelSearch()" title="\u6e05\u9664\u641c索">\u2715</button></div>' +
'<div id="model-search-results" style="max-height:220px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg)">';
if (filtered.length === 0) {
html += '<div class="empty-state" style="padding:12px"><div class="icon">\U0001f50d</div>\u65e0\u5339\u914d\u6a21\u578b</div>';
} else {
html += filtered.map(function(m) {
return '<div class="fetched-model-item"' +
' data-model="' + escHtml(m) + '"' +
' onclick="var mn=this.getAttribute(\'data-model\');selectFetchedModel(mn);"' +
' style="padding:6px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border);transition:background .12s"' +
' onmouseenter="this.style.background=\'var(--bg3)\'"' +
' onmouseleave="this.style.background=\'\'">' + escHtml(m) + '</div>';
}).join('');
}
html += '</div>';
area.innerHTML = html;
}
// filterFetchedModels 仅更新结果列表,不重建搜索框,解决输入时焦点丢失问题
function filterFetchedModels() {
var input = document.getElementById('model-search-input');
var results = document.getElementById('model-search-results');
if (!input || !results) return;
var filter = input.value;
var models = STATE.fetchedModels;
var filterLower = filter.toLowerCase();
var filtered = filterLower ? models.filter(function(m) { return m.toLowerCase().indexOf(filterLower) >= 0; }) : models;
var countInfo = filterLower ? '\uff08' + filtered.length + '/' + models.length + '\uff09' : '\uff08共 ' + models.length + ' \u4e2a\uff09';
input.placeholder = '\U0001f50d \u641c索模型名称...' + countInfo;
if (filtered.length === 0) {
results.innerHTML = '<div class="empty-state" style="padding:12px"><div class="icon">\U0001f50d</div>\u65e0\u5339\u914d\u6a21型</div>';
} else {
results.innerHTML = filtered.map(function(m) {
return '<div class="fetched-model-item" data-model="' + escHtml(m) + '"' +
' onclick="var mn=this.getAttribute(\'data-model\');selectFetchedModel(mn);"' +
' style="padding:6px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border);transition:background .12s"' +
' onmouseenter="this.style.background=\'var(--bg3)\'"' +
' onmouseleave="this.style.background=\'\'">' + escHtml(m) + '</div>';
}).join('');
}
}
function clearModelSearch() {
var input = document.getElementById('model-search-input');
if (input) { input.value = ''; input.focus(); }
filterFetchedModels();
}
function selectFetchedModel(modelName) {
document.getElementById('model-name').value = modelName;
var idEl = document.getElementById('model-id');
if (idEl && !idEl.value) idEl.value = modelName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
var items = document.querySelectorAll('.fetched-model-item');
for (var i = 0; i < items.length; i++) {
items[i].style.background = (items[i].textContent === modelName) ? 'var(--accent-bg)' : '';
}
}
function resetModelTemplateArea() {
var area = document.getElementById('model-template-area');
if (!area) return;
STATE.fetchedModels = [];
var provider = document.getElementById('model-provider').value;
var models = MODEL_TEMPLATES[provider] || [];
area.innerHTML =
'<select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择模型模板 / 查询获取 --</option>' +
models.map(function(m) { return '<option value="' + escHtml(m) + '">' + escHtml(m) + '</option>'; }).join('') +
'</select>';
}
async function saveProviderForm(name) {
var data = {
name: document.getElementById('prov-name').value.trim(),
base_url: document.getElementById('prov-url').value.trim(),
api_key: document.getElementById('prov-key').value,
timeout_sec: parseInt(document.getElementById('prov-timeout').value) || 120,
max_retries: parseInt(document.getElementById('prov-retries').value) || 3,
};
var saveName = name || data.name;
if (!saveName || !data.base_url) { alert('名称和 Base URL 为必填项'); return; }
var result = await api('/api/model-config/providers/' + encodeURIComponent(saveName), { method: 'POST', body: JSON.stringify(data) });
if (result.error) { alert('保存失败: ' + result.error); return; }
STATE.modelConfigTab = 'providers';
renderModelConfigPanel();
}
async function deleteModelProvider(name) {
if (!confirm('确定删除 Provider "' + name + '"?\n注意:关联的模型和路由也会受影响。')) return;
var result = await api('/api/model-config/providers/' + encodeURIComponent(name), { method: 'DELETE' });
if (result.error) { alert('删除失败: ' + result.error); return; }
renderProvidersTab();
}
// ---- Models tab ----
async function renderModelsTab() {
var container = document.getElementById('model-config-content');
var data = await api('/api/model-config/models');
STATE.modelConfigModels = data.models || [];
var rows = STATE.modelConfigModels.length === 0
? '<tr><td colspan="6"><div class="empty-state"><div class="icon">🧠</div>暂无模型定义,请添加</div></td></tr>'
: STATE.modelConfigModels.map(function(m) {
var enabledBadge = m.enabled !== false ? '<span class="badge badge-running">启用</span>' : '<span class="badge badge-stopped">禁用</span>';
var tags = (m.tags && m.tags.length > 0) ? m.tags.join(', ') : '—';
var updated = m.updated_at ? timeAgo(m.updated_at) : '—';
return '<tr>' +
'<td><strong>' + escHtml(m.id) + '</strong></td>' +
'<td>' + escHtml(m.name) + '</td>' +
'<td>' + escHtml(m.provider) + '</td>' +
'<td>' + enabledBadge + '</td>' +
'<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(tags) + '</td>' +
'<td><div class="btn-group">' +
'<button class="btn btn-xs" onclick="showModelForm(\'' + escHtml(m.id) + '\')">✏️</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteModelConfig(\'' + escHtml(m.id) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
container.innerHTML =
'<div class="card-header"><span class="card-title">🧠 模型定义</span>' +
'<button class="btn btn-sm btn-accent" onclick="showModelForm()"> 添加</button></div>' +
'<div class="table-wrap"><table><thead><tr>' +
'<th>ID</th><th>模型名</th><th>Provider</th><th>状态</th><th>标签</th><th>操作</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function showModelForm(id) {
var existing = null;
if (id) {
for (var i = 0; i < STATE.modelConfigModels.length; i++) {
if (STATE.modelConfigModels[i].id === id) { existing = STATE.modelConfigModels[i]; break; }
}
}
var isEdit = !!existing;
var formTitle = isEdit ? '✏️ 编辑模型 ' + escHtml(id) : ' 添加模型';
var defaults = existing || { id: '', name: '', provider: '', description: '', priority: 0, tags: [], params: {}, enabled: true };
var tagsStr = (defaults.tags && defaults.tags.length > 0) ? defaults.tags.join(', ') : '';
var paramsStr = JSON.stringify(defaults.params || {}, null, 2);
var container = document.getElementById('model-config-content');
container.innerHTML =
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
'<button class="btn btn-sm" onclick="renderModelsTab()">← 返回</button></div>' +
'<div class="card-body"><form onsubmit="event.preventDefault();saveModelForm(\'' + escHtml(id || '') + '\');">' +
'<div class="form-row"><label>模型 ID ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
'<input id="model-id" class="input" value="' + escHtml(defaults.id) + '" ' + (isEdit ? 'readonly' : 'placeholder="如 primary_chat"') + ' required></div>' +
'<div class="form-row"><label>模型名称 <span style="color:var(--red)">*</span></label>' +
'<input id="model-name" class="input" value="' + escHtml(defaults.name) + '" placeholder="deepseek-v4-flash" required></div>' +
'<div class="form-row"><label>Provider <span style="color:var(--red)">*</span></label>' +
'<select id="model-provider" class="input" required onchange="updateModelTemplateOptions()"><option value="">-- 选择 Provider --</option>' +
STATE.modelConfigProviders.map(function(p) {
return '<option value="' + escHtml(p.name) + '"' + (defaults.provider === p.name ? ' selected' : '') + '>' + escHtml(p.name) + '</option>';
}).join('') + '</select></div>' +
(isEdit ? '' :
'<div class="form-row"><label>📋 快速模板</label>' +
'<div>' +
'<div style="display:flex;gap:8px">' +
'<div id="model-template-area" style="flex:1"><select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
'<option value="">-- 选择模型模板 / 查询获取 --</option></select></div>' +
'<button type="button" class="btn btn-sm" id="btn-fetch-models" onclick="fetchProviderModels()" style="white-space:nowrap" disabled>🔍 查询</button></div>' +
'<div style="font-size:11px;color:var(--text3);margin-top:4px">选择 Provider 后可用模板或点击查询在线获取模型列表</div>' +
'</div></div>') +
'<div class="form-row"><label>描述</label>' +
'<input id="model-desc" class="input" value="' + escHtml(defaults.description || '') + '" placeholder="用于日常对话的模型"></div>' +
'<div class="form-row" style="display:flex;gap:12px"><div style="flex:1"><label>优先级</label>' +
'<input id="model-priority" class="input" type="number" value="' + (defaults.priority || 0) + '"></div>' +
'<div style="flex:1;display:flex;align-items:flex-end;padding-bottom:4px"><label style="display:flex;align-items:center;gap:6px;cursor:pointer">' +
'<input type="checkbox" id="model-enabled"' + (defaults.enabled !== false ? ' checked' : '') + '> 启用</label></div></div>' +
'<div class="form-row"><label>标签 (逗号分隔)</label>' +
'<input id="model-tags" class="input" value="' + escHtml(tagsStr) + '" placeholder="chat, fast"></div>' +
'<div class="form-row"><label>模型参数 (JSON)</label>' +
'<textarea id="model-params" class="input" rows="3" style="font-family:monospace;font-size:12px">' + escHtml(paramsStr) + '</textarea></div>' +
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
'</form></div>';
}
async function saveModelForm(id) {
var tagsStr = document.getElementById('model-tags').value.trim();
var tags = tagsStr ? tagsStr.split(',').map(function(t) { return t.trim(); }).filter(Boolean) : [];
var paramsStr = document.getElementById('model-params').value.trim();
var params = {};
try { if (paramsStr) params = JSON.parse(paramsStr); } catch(e) { alert('模型参数 JSON 格式错误: ' + e.message); return; }
var data = {
id: document.getElementById('model-id').value.trim(),
name: document.getElementById('model-name').value.trim(),
provider: document.getElementById('model-provider').value,
description: document.getElementById('model-desc').value.trim(),
priority: parseInt(document.getElementById('model-priority').value) || 0,
enabled: document.getElementById('model-enabled').checked,
tags: tags,
params: params,
};
var saveId = id || data.id;
if (!saveId || !data.name || !data.provider) { alert('模型 ID、名称和 Provider 为必填项'); return; }
var result = await api('/api/model-config/models/' + encodeURIComponent(saveId), { method: 'POST', body: JSON.stringify(data) });
if (result.error) { alert('保存失败: ' + result.error); return; }
STATE.modelConfigTab = 'models';
renderModelConfigPanel();
}
async function deleteModelConfig(id) {
if (!confirm('确定删除模型 "' + id + '"?')) return;
var result = await api('/api/model-config/models/' + encodeURIComponent(id), { method: 'DELETE' });
if (result.error) { alert('删除失败: ' + result.error); return; }
renderModelsTab();
}
// ---- Routing tab ----
async function renderRoutingTab() {
var container = document.getElementById('model-config-content');
var data = await api('/api/model-config/routing');
STATE.modelConfigRouting = data.routing || [];
var rows = STATE.modelConfigRouting.length === 0
? '<tr><td colspan="4"><div class="empty-state"><div class="icon">🔀</div>暂无路由规则,请添加</div></td></tr>'
: STATE.modelConfigRouting.map(function(r) {
var chain = (r.fallback_chain && r.fallback_chain.length > 0) ? r.fallback_chain.join(' → ') : '—';
var requiredBadge = r.required ? '<span class="badge badge-running">必需</span>' : '<span class="badge badge-stopped">可选</span>';
return '<tr>' +
'<td><strong>' + escHtml(r.purpose) + '</strong></td>' +
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(chain) + '</td>' +
'<td>' + requiredBadge + '</td>' +
'<td><div class="btn-group">' +
'<button class="btn btn-xs" onclick="showRoutingForm(\'' + escHtml(r.purpose) + '\')">✏️</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteRoutingRule(\'' + escHtml(r.purpose) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
container.innerHTML =
'<div class="card-header"><span class="card-title">🔀 用途路由</span>' +
'<button class="btn btn-sm btn-accent" onclick="showRoutingForm()"> 添加</button></div>' +
'<div class="table-wrap"><table><thead><tr>' +
'<th>用途</th><th>回退链</th><th>必需性</th><th>操作</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function showRoutingForm(purpose) {
var existing = null;
if (purpose) {
for (var i = 0; i < STATE.modelConfigRouting.length; i++) {
if (STATE.modelConfigRouting[i].purpose === purpose) { existing = STATE.modelConfigRouting[i]; break; }
}
}
var isEdit = !!existing;
var formTitle = isEdit ? '✏️ 编辑路由 ' + escHtml(purpose) : ' 添加路由';
var defaults = existing || { purpose: '', fallback_chain: [], required: false };
var existingChain = defaults.fallback_chain || [];
var models = STATE.modelConfigModels;
var modelCheckboxes = '';
if (models.length === 0) {
modelCheckboxes = '<div class="empty-state" style="padding:16px"><div class="icon">🧠</div>暂无模型定义,请先在「模型定义」标签中添加模型</div>';
} else {
modelCheckboxes = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:6px;max-height:260px;overflow-y:auto;padding:4px 0">' +
models.map(function(m) {
var checked = existingChain.indexOf(m.id) >= 0 ? ' checked' : '';
var providerLabel = m.provider ? ' <span style="color:var(--text3);font-size:11px">(' + escHtml(m.provider) + ')</span>' : '';
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg3);border-radius:var(--radius-sm);cursor:pointer;font-size:12px;transition:background .15s" onmouseenter="this.style.background=\'var(--bg4)\'" onmouseleave="this.style.background=\'var(--bg3)\'">' +
'<input type="checkbox" name="routing-model" value="' + escHtml(m.id) + '"' + checked + ' style="accent-color:var(--accent)">' +
'<span style="flex:1"><strong>' + escHtml(m.name || m.id) + '</strong>' + providerLabel + '</span>' +
'</label>';
}).join('') + '</div>';
}
var container = document.getElementById('model-config-content');
container.innerHTML =
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
'<button class="btn btn-sm" onclick="renderRoutingTab()">← 返回</button></div>' +
'<div class="card-body"><form onsubmit="event.preventDefault();saveRoutingForm(\'' + escHtml(purpose || '') + '\');">' +
'<div class="form-row"><label>用途 ID ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
'<select id="routing-purpose" class="input" ' + (isEdit ? 'disabled' : 'required') + '>' +
'<option value="">-- 选择用途 --</option>' +
'<option value="chat"'+ (defaults.purpose === 'chat' ? ' selected' : '') +'>chat (日常对话)</option>' +
'<option value="deep_thinking"'+ (defaults.purpose === 'deep_thinking' ? ' selected' : '') +'>deep_thinking (深度思考/复杂推理)</option>' +
'<option value="code"'+ (defaults.purpose === 'code' ? ' selected' : '') +'>code (代码生成)</option>' +
'<option value="vision"'+ (defaults.purpose === 'vision' ? ' selected' : '') +'>vision (视觉理解)</option>' +
'<option value="ocr"'+ (defaults.purpose === 'ocr' ? ' selected' : '') +'>ocr (文字识别/OCR)</option>' +
'<option value="math"'+ (defaults.purpose === 'math' ? ' selected' : '') +'>math (数学推理)</option>' +
'<option value="translation"'+ (defaults.purpose === 'translation' ? ' selected' : '') +'>translation (翻译)</option>' +
'<option value="intent_analysis"'+ (defaults.purpose === 'intent_analysis' ? ' selected' : '') +'>intent_analysis (意图分析)</option>' +
'<option value="tool_calling"'+ (defaults.purpose === 'tool_calling' ? ' selected' : '') +'>tool_calling (工具调用/Function Calling)</option>' +
'<option value="memory_extraction"'+ (defaults.purpose === 'memory_extraction' ? ' selected' : '') +'>memory_extraction (记忆提取)</option>' +
'<option value="roleplay"'+ (defaults.purpose === 'roleplay' ? ' selected' : '') +'>roleplay (角色扮演)</option>' +
'<option value="long_context"'+ (defaults.purpose === 'long_context' ? ' selected' : '') +'>long_context (长文档处理)</option>' +
'<option value="speech_recognition"'+ (defaults.purpose === 'speech_recognition' ? ' selected' : '') +'>speech_recognition (实时语音识别)</option>' +
'<option value="speech_recognition_offline"'+ (defaults.purpose === 'speech_recognition_offline' ? ' selected' : '') +'>speech_recognition_offline (非实时语音识别)</option>' +
'</select></div>' +
'<div style="margin-bottom:10px">' +
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">' +
'<label style="flex:0 0 140px;padding-top:6px">回退模型链 <span style="color:var(--text2);font-weight:400">(勾选即加入,顺序=表格显示顺序)</span></label>' +
(models.length > 0 ? '<div class="btn-group">' +
'<button type="button" class="btn btn-xs" onclick="var cbs=document.querySelectorAll(\'input[name=routing-model]\');cbs.forEach(function(c){c.checked=true})">全选</button>' +
'<button type="button" class="btn btn-xs" onclick="var cbs=document.querySelectorAll(\'input[name=routing-model]\');cbs.forEach(function(c){c.checked=false})">取消全选</button>' +
'</div>' : '') +
'</div>' +
modelCheckboxes + '</div>' +
'<div class="form-row"><label style="display:flex;align-items:center;gap:6px;cursor:pointer">' +
'<input type="checkbox" id="routing-required"' + (defaults.required ? ' checked' : '') + '> 必需 (所有模型不可用时返回错误,而非回退到 .env)</label></div>' +
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
'</form></div>';
}
async function saveRoutingForm(purpose) {
// 收集所有勾选的模型 (按 DOM 顺序 = 表格显示顺序)
var checkedCbs = document.querySelectorAll('input[name="routing-model"]:checked');
var chain = [];
for (var i = 0; i < checkedCbs.length; i++) {
chain.push(checkedCbs[i].value);
}
var data = {
purpose: purpose || document.getElementById('routing-purpose').value,
fallback_chain: chain,
required: document.getElementById('routing-required').checked,
};
if (!data.purpose) { alert('请选择用途'); return; }
if (chain.length === 0) { alert('回退模型链不能为空'); return; }
var result = await api('/api/model-config/routing/' + encodeURIComponent(data.purpose), { method: 'POST', body: JSON.stringify(data) });
if (result.error) { alert('保存失败: ' + result.error); return; }
STATE.modelConfigTab = 'routing';
renderModelConfigPanel();
}
async function deleteRoutingRule(purpose) {
if (!confirm('确定删除路由 "' + purpose + '"?')) return;
var result = await api('/api/model-config/routing/' + encodeURIComponent(purpose), { method: 'DELETE' });
if (result.error) { alert('删除失败: ' + result.error); return; }
renderRoutingTab();
}
// ========== 客户端管理面板 ==========
function renderClientsPanel() {
var panel = document.getElementById('panel-clients');
panel.innerHTML =
'<div class="card">' +
'<div class="card-header">' +
'<span class="card-title">📱 已连接设备</span>' +
'<div class="btn-group">' +
'<button class="btn btn-sm" onclick="loadClients()">🔄 刷新</button>' +
'</div>' +
'</div>' +
'<div id="clients-online"></div>' +
'</div>' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">📋 历史设备</span></div>' +
'<div id="clients-offline"></div>' +
'</div>' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">💡 跨端说明</span></div>' +
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
'<p>每个浏览器/设备首次连接时会分配唯一的 <b>Client ID</b>(存储在浏览器 localStorage)。</p>' +
'<p>后续所有消息都会携带此 ID,昔涟可据此判断用户当前使用的设备。</p>' +
'<p><b>设备名称</b> 自动从 User-Agent 推断,你也可以在下方为设备添加备注。</p>' +
'<p>在线设备 = 当前 WebSocket 连接已建立;离线设备 = 曾连接过但当前断开。</p>' +
'</div>' +
'</div>';
loadClients();
}
async function loadClients() {
var data = await api('/api/clients');
var onlineDiv = document.getElementById('clients-online');
var offlineDiv = document.getElementById('clients-offline');
if (!onlineDiv || !offlineDiv) return;
if (data.error) {
onlineDiv.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '<br><span style="font-size:11px">Gateway 服务可能未启动</span></div>';
offlineDiv.innerHTML = '';
return;
}
var clients = data.clients || [];
var onlineClients = clients.filter(function(c) { return c.online; });
var offlineClients = clients.filter(function(c) { return !c.online; });
// Update badge
var badge = document.getElementById('clients-badge');
if (badge) {
var onlineCount = onlineClients.length;
if (onlineCount > 0) {
badge.textContent = onlineCount;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
if (clients.length === 0) {
onlineDiv.innerHTML = '<div class="empty-state"><div class="icon">📱</div>暂无已连接的客户端</div>';
offlineDiv.innerHTML = '<div class="empty-state"><div class="icon">📋</div>暂无历史设备记录</div>';
return;
}
onlineDiv.innerHTML = onlineClients.length > 0
? '<div class="table-wrap"><table>' +
'<thead><tr><th>状态</th><th>设备名</th><th>Client ID</th><th>备注</th><th>首次连接</th><th>最后活跃</th><th>操作</th></tr></thead>' +
'<tbody>' + onlineClients.map(function(c) { return clientRowHTML(c); }).join('') + '</tbody>' +
'</table></div>'
: '<div class="empty-state"><div class="icon">📱</div>暂无在线客户端</div>';
offlineDiv.innerHTML = offlineClients.length > 0
? '<div class="table-wrap"><table>' +
'<thead><tr><th>状态</th><th>设备名</th><th>Client ID</th><th>备注</th><th>首次连接</th><th>最后活跃</th><th>操作</th></tr></thead>' +
'<tbody>' + offlineClients.map(function(c) { return clientRowHTML(c); }).join('') + '</tbody>' +
'</table></div>'
: '<div class="empty-state"><div class="icon">📋</div>暂无历史离线设备</div>';
}
function clientRowHTML(c) {
var statusClass = c.online ? 'badge-running' : 'badge-stopped';
var statusText = c.online ? '在线' : '离线';
return '<tr>' +
'<td><span class="badge ' + statusClass + '">' + statusText + '</span></td>' +
'<td><strong>' + escHtml(c.device_name || '未知设备') + '</strong></td>' +
'<td style="font-family:\'JetBrains Mono\',monospace;font-size:11px;color:var(--text2)">' + escHtml(c.client_id || '') + '</td>' +
'<td>' +
'<span id="client-note-' + escHtml(c.client_id || '') + '" style="cursor:pointer;color:var(--accent)" title="点击编辑备注" onclick="editClientNote(\'' + escHtml(c.client_id || '') + '\')">' +
(c.note ? escHtml(c.note) : '<span style="color:var(--text3);font-style:italic">点击添加备注</span>') +
'</span>' +
'</td>' +
'<td style="font-size:11px;color:var(--text3)">' + formatTime(c.first_seen_at) + '</td>' +
'<td style="font-size:11px;color:var(--text3)">' + formatTime(c.last_seen_at) + '</td>' +
'<td><button class="btn btn-xs" onclick="editClientNote(\'' + escHtml(c.client_id || '') + '\')">✏️ 备注</button></td>' +
'</tr>';
}
async function editClientNote(clientID) {
var currentNote = document.getElementById('client-note-' + clientID);
var oldNote = (currentNote && currentNote.textContent !== '点击添加备注') ? currentNote.textContent : '';
var note = prompt('为设备 ' + clientID + ' 输入备注:', oldNote);
if (note === null) return; // cancelled
var resp = await api('/api/clients/' + encodeURIComponent(clientID) + '/note', {
method: 'PUT',
body: JSON.stringify({ note: note }),
});
if (resp.error) {
showToast('更新备注失败: ' + resp.error, 'error');
} else {
showToast('备注已更新', 'success');
loadClients();
}
}
</script>
<script src="iot-panel.js"></script>
<script>
// ========== 初始化 ==========
// Listen for browser back/forward navigation.
window.addEventListener('hashchange', function() {
var hash = location.hash.replace('#', '');
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig'];
if (hash && validPanels.indexOf(hash) >= 0 && hash !== STATE.activePanel) {
switchPanel(hash);
}
});
connectWS();
refreshStatus();
// Restore last panel from URL hash, or default to dashboard.
var initHash = location.hash.replace('#', '');
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig'];
if (initHash && validPanels.indexOf(initHash) >= 0) {
switchPanel(initHash);
} else {
switchPanel('dashboard');
location.hash = '#dashboard';
}
// 全局状态定时刷新
STATE.statusInterval = setInterval(refreshStatus, 5000);
</script>
</body>
</html>