Files
Cyrene/devtools/public/index.html
T
AskaEth 26a61cb57c feat: 第四轮大版本更新 — 修复4个严重Bug、2个UI Bug,实现自主思考重构与主-子会话架构
## 🐛 Bug 修复
- 修复前端对话无响应:消除 ChatContainer 中的双重 WebSocket 连接,优化 sendMessage 失败提示
- 修复 Memory-Service 数据库迁移失败:ai-core 和 memory-service 均添加 ALTER TABLE ADD COLUMN IF NOT EXISTS 模式演化
- 修复语音/STT 不可用:添加 MediaRecorder API 降级方案,修复 whisper-cli 输出文件名错误
- 修复仪表盘数据库按钮失效:补充按钮 ID 属性,重写 controlDB() 控制逻辑

## 🎨 UI 修复
- 修正用户消息头像位置:从 flex-row-reverse 改为 justify-end
- 移除空聊天列表的 emoji 占位图标

##  新功能
- devtools 新增 STT 处理日志面板(环形缓冲区 + WebSocket 广播 + 可视化表格)
- 新增 ADMIN_NICKNAME 环境变量,支持自定义管理员昵称

## 🔧 改进
- 注册流程增加昵称必填字段(前后端同步)

## 🏗️ 架构重构
- 重构自主思考逻辑:从定时器轮询改为事件驱动(对话后触发 + 静默检测),优化提示词使其更自然人性化
- 实现主-子会话架构:新增 4 种子会话类型(general/memory/iot/knowledge),意图分析 → 并行分发 → 结果合成流程

## 📄 新增文档
- docs/architecture/main-session-sub-session-design.md — 子会话架构设计文档
2026-05-19 21:09:48 +08:00

3342 lines
146 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; }
.form-row > * { flex: 1; }
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 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>
</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>
<!-- 记忆管理 -->
<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>
</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': [] },
maxLogLines: 500,
logLayout: 'tabs',
// 性能
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [] },
// 会话
sessionsData: [],
sessionsAutoRefresh: null,
// 计时器
dashboardInterval: null,
statusInterval: null,
dbInterval: null,
// 仪表盘增量刷新 (Bug 7)
dashboardRenderCount: 0,
// 资源使用 60s 滑动窗口历史 (Bug 6)
resourceHistory: {},
// 记忆面板状态
memoryCache: [],
memoryUserId: 'admin_admin',
memoryFilterCategory: 'all',
memorySortBy: 'importance',
memorySortDir: 'desc',
memoryFilterImportance: 0,
memorySearchText: '',
memoryPanelInitialized: false,
// STT 语音识别日志面板状态
sttLogs: [],
sttAutoRefresh: null,
sttAutoRefreshInterval: null,
// 时间线面板状态
timelineData: [],
timelineUserId: 'admin_admin',
timelineFilterType: 'all',
timelineAutoRefresh: null,
timelineLimit: 100,
};
// ========== 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 = () => ws.close();
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 === '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';
}
function escapeId(id) {
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug' };
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;
// 更新侧边栏
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: '⏱️ 记忆时间线',
};
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;
}
}
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() {
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_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_admin" value="admin_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">
<div class="log-container" id="services-log-panel">
<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>
</div>
</div>
<div id="services-log-grid" style="display:none">
<div class="cards-grid cards-4">
<div class="log-container" id="log-panel-ai-core" style="height:280px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div>
<div class="log-container" id="log-panel-gateway" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div>
<div class="log-container" id="log-panel-iot-debug-service" style="height:280px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div>
<div class="log-container" id="log-panel-frontend" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div>
</div>
</div>
</div>
`;
renderServiceCards();
initSvcLogTabs();
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) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
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 = ['ai-core', 'gateway', 'iot-debug-service', 'frontend'].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 === 'tabs') {
STATE.logLayout = 'grid';
tabsPanel.style.display = 'none';
gridPanel.style.display = '';
logTabs.style.display = 'none';
btn.textContent = '📋 标签页';
['ai-core', 'gateway', 'iot-debug-service', 'frontend'].forEach(id => renderGridLog(id));
} else {
STATE.logLayout = 'tabs';
tabsPanel.style.display = '';
gridPanel.style.display = 'none';
logTabs.style.display = '';
btn.textContent = '📐 并列';
renderServiceLog();
}
}
async function clearSvcLogs() {
const id = STATE.activeLogTab;
await api(`/api/logs/${id}`, { method: 'DELETE' });
STATE.logLines[id] = [];
renderServiceLog();
}
// ---- 服务操作 ----
async function svcAction(cmd, serviceId) {
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}`;
const method = ['start','stop','restart','build','start-all','start-all-fresh','stop-all'].includes(cmd) ? 'POST' : 'GET';
const res = await api(url, { method });
showToast(res.message || res.error || `${cmd} 完成`, 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) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
// 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 tunnelRunning = data.tunnelRunning;
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">🔌 SSH 隧道状态</span>' +
'<span class="badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped') + '" id="db-tunnel-badge">' + (tunnelRunning ? '运行中' : '未运行') + '</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="tunnelAction(\'start\')"' + (tunnelRunning && allAlive ? ' disabled' : '') + '>▶ 启动隧道</button>' +
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="tunnelAction(\'stop\')"' + (!tunnelRunning ? ' disabled' : '') + '>⏹ 停止隧道</button>' +
'<button class="btn btn-sm" onclick="tunnelAction(\'restart\')">🔄 重启隧道</button>' +
'<button class="btn btn-sm" onclick="tunnelAction(\'status\')">📋 查看状态</button>' +
'</div>' +
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (tunnelRunning && !allAlive ? 'block' : 'none') + '">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</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>🔑 SSH 服务器: <code style="color:var(--text)">root@cd.yeij.top</code></div>' +
'<div>📁 隧道脚本: <code style="color:var(--text)">scripts/tunnel.sh</code></div>' +
'<div>💡 所有数据库端口通过 SSH 转发至 <code style="color:var(--text)">localhost</code>,无需修改 .env</div>' +
'</div>' +
'</div>';
STATE.dbInitialized = true;
} else {
// Bug 7: 增量更新 — 只更新状态徽章、计数器、端口卡片、检查时间
var el;
// 隧道状态徽章
el = document.getElementById('db-tunnel-badge');
if (el) {
el.className = 'badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped');
el.textContent = tunnelRunning ? '运行中' : '未运行';
}
// 端口通联计数
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 = !!(tunnelRunning && allAlive);
el = document.getElementById('db-tunnel-stop');
if (el) el.disabled = !tunnelRunning;
// 僵尸警告
el = document.getElementById('db-zombie-warn');
if (el) el.style.display = (tunnelRunning && !allAlive) ? 'block' : 'none';
}
}
function refreshDatabasePanel() {
renderDatabasePanel();
}
async function tunnelAction(action) {
showToast(`正在执行: ${action} 隧道...`, '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/tunnel/${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(`${action} 隧道完成`, 'success');
} else {
showToast(`${action} 完成 (查看日志)`, '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>';
}
container.innerHTML = statsHtml + tableHtml + voiceStatusHtml;
}
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_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();
} 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;
}
function toggleThinkingExpand(logId) {
var expandRow = document.getElementById(logId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
}
}
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_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);
}
}
</script>
<script src="iot-panel.js"></script>
<script>
// ========== 初始化 ==========
connectWS();
refreshStatus();
renderDashboard();
// 全局状态定时刷新
STATE.statusInterval = setInterval(refreshStatus, 5000);
</script>
</body>
</html>