937742df02
- chat_handler.go: HandleWebSocket 新增 admin_ 前缀检查,非管理员返回 403 - index.html: 记忆面板新增时间排序下拉框和话题(会话)列 - renderMemoryResults 拆分为缓存+排序渲染两阶段 - 修复: 修复 typo 'seession_id' -> 'session_id'
1440 lines
58 KiB
HTML
1440 lines
58 KiB
HTML
<!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; }
|
||
</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>
|
||
</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>
|
||
<!-- 性能监控 -->
|
||
<div class="panel" id="panel-performance"></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,
|
||
};
|
||
|
||
// ========== 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 === '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 escHtml(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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 200,body 中也可能包含 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: '🖥 服务管理', performance: '📊 性能监控',
|
||
};
|
||
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(); break;
|
||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); break;
|
||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); break;
|
||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); break;
|
||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); 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);
|
||
}
|
||
|
||
// ========== 面板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>`;
|
||
return;
|
||
}
|
||
STATE.dashboardData = data;
|
||
|
||
const svcs = data.services?.list || {};
|
||
const runningCount = data.services?.running || 0;
|
||
const totalSvcs = data.services?.total || Object.keys(svcs).length;
|
||
|
||
document.getElementById('panel-dashboard').innerHTML = `
|
||
<!-- 概览统计 -->
|
||
<div class="cards-grid cards-4" style="margin-bottom:16px">
|
||
<div class="stat-card green">
|
||
<div class="stat-value">${runningCount}/${totalSvcs}</div>
|
||
<div class="stat-label">服务运行中</div>
|
||
</div>
|
||
<div class="stat-card blue">
|
||
<div class="stat-value">${data.sessions?.active ?? '—'}</div>
|
||
<div class="stat-label">活跃会话</div>
|
||
</div>
|
||
<div class="stat-card accent">
|
||
<div class="stat-value">${data.memory?.total ?? '—'}</div>
|
||
<div class="stat-label">记忆条目</div>
|
||
</div>
|
||
<div class="stat-card orange">
|
||
<div class="stat-value">${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="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">
|
||
<div><span style="color:var(--text2)">运行时间:</span> ${formatUptime((data.system?.uptime || 0) * 1000)}</div>
|
||
<div><span style="color:var(--text2)">堆内存:</span> ${data.system?.heapUsedMB ?? '—'} MB / ${data.system?.heapTotalMB ?? '—'} MB</div>
|
||
<div><span style="color:var(--text2)">总消息数:</span> ${data.sessions?.totalMessages ?? 0}</div>
|
||
<div><span style="color:var(--text2)">更新时间:</span> ${formatTime(data.timestamp)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 渲染服务卡片
|
||
renderDashboardSvcCards(svcs);
|
||
|
||
// 渲染性能快照
|
||
const perfContainer = document.getElementById('dashboard-perf');
|
||
const perf = data.performance?.perService || {};
|
||
perfContainer.innerHTML = Object.entries(perf).map(([id, p]) => `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border)">
|
||
<span style="font-weight:500">${escapeId(id)}</span>
|
||
<span style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text2)">
|
||
CPU ${p.cpu || 0}% | MEM ${p.mem || 0}MB
|
||
</span>
|
||
</div>
|
||
`).join('') || '<div class="empty-state"><div class="icon">📊</div>等待采样数据...</div>';
|
||
|
||
// 渲染性能仪表盘
|
||
updatePerformanceDashboard(data.performance?.perService || {});
|
||
}
|
||
|
||
// ========== 性能仪表盘渲染 ==========
|
||
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 { /* 忽略: 使用本地计算的数据 */ }
|
||
|
||
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}" style="width:${Math.min(avgCpu, 100)}%"></div>
|
||
</div>
|
||
<span class="perf-value">${avgCpu}% ${trendCpu}</span>
|
||
</div>
|
||
|
||
<!-- 内存使用 -->
|
||
<div class="perf-row">
|
||
<span class="perf-label">💾 内存</span>
|
||
<div class="perf-bar-wrap">
|
||
<div class="perf-bar ${memLevel}" style="width:${Math.min(totalMem / 1024 * 100, 100)}%"></div>
|
||
</div>
|
||
<span class="perf-value">${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" 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" 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" style="color:var(--blue)">${entries.length}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 各服务简要 -->
|
||
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:8px">
|
||
${entries.map(([id, p]) => `
|
||
<div class="perf-stat">
|
||
<span class="perf-stat-icon" style="font-size:12px">${p.pid ? '🟢' : '🔴'}</span>
|
||
<span class="perf-stat-label">${escapeId(id)}</span>
|
||
<span class="perf-stat-value" style="font-size:11px;color:var(--text2)">
|
||
CPU ${p.cpu || 0}% · MEM ${p.mem || 0}MB
|
||
</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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('');
|
||
}
|
||
|
||
// ========== 面板2: 记忆管理 ==========
|
||
function renderMemoryPanel() {
|
||
document.getElementById('panel-memory').innerHTML = `
|
||
<div class="cards-grid cards-2">
|
||
<!-- 搜索区域 -->
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">🔍 搜索记忆</span></div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label>用户ID</label><input type="text" id="mem-user-id" placeholder="admin_admin" value="admin_admin"></div>
|
||
<div class="form-group"><label>关键词</label><input type="text" id="mem-search-q" placeholder="输入搜索关键词..."></div>
|
||
</div>
|
||
<div class="btn-group" style="margin-top:4px">
|
||
<button class="btn btn-accent btn-sm" onclick="searchMemory()">🔍 搜索</button>
|
||
<button class="btn btn-sm" onclick="listMemory()">📋 列表全部</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 添加记忆 -->
|
||
<div class="card">
|
||
<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="输入记忆内容..."></textarea></div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>分类</label>
|
||
<select id="mem-add-category">
|
||
<option value="preference">偏好</option>
|
||
<option value="fact">事实</option>
|
||
<option value="experience">经验</option>
|
||
<option value="other">其他</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>优先级: <span id="mem-priority-val">3</span></label>
|
||
<input type="range" id="mem-add-priority" min="1" max="5" value="3" 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">
|
||
<div class="card-header">
|
||
<span class="card-title">📋 记忆列表</span>
|
||
<span style="display:flex;align-items:center;gap:8px">
|
||
<select id="mem-sort-order" onchange="sortAndRenderMemories()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:2px 6px;font-size:11px">
|
||
<option value="desc">🕐 最新优先</option>
|
||
<option value="asc">🕐 最早优先</option>
|
||
</select>
|
||
<span id="mem-result-count" style="font-size:11px;color:var(--text2)"></span>
|
||
</span>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>内容</th><th>分类</th><th>优先级</th><th>用户</th><th>话题 (会话)</th><th>创建时间</th><th style="width:50px">操作</th></tr></thead>
|
||
<tbody id="mem-table-body">
|
||
<tr><td colspan="7"><div class="empty-state"><div class="icon">🧠</div>使用搜索或列表按钮加载记忆</div></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function searchMemory() {
|
||
const userId = document.getElementById('mem-user-id').value.trim();
|
||
const q = document.getElementById('mem-search-q').value.trim();
|
||
if (!userId) { showToast('请输入用户ID', 'error'); return; }
|
||
if (!q) { showToast('请输入搜索关键词', 'error'); return; }
|
||
|
||
const data = await api(`/api/memory/search?user_id=${encodeURIComponent(userId)}&q=${encodeURIComponent(q)}`);
|
||
renderMemoryResults(data);
|
||
}
|
||
|
||
async function listMemory() {
|
||
const userId = document.getElementById('mem-user-id').value.trim();
|
||
if (!userId) { showToast('请输入用户ID', 'error'); return; }
|
||
|
||
const data = await api(`/api/memory/list?user_id=${encodeURIComponent(userId)}`);
|
||
renderMemoryResults(data);
|
||
}
|
||
|
||
function renderMemoryResults(data) {
|
||
const tbody = document.getElementById('mem-table-body');
|
||
const countEl = document.getElementById('mem-result-count');
|
||
|
||
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>';
|
||
}
|
||
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}${hint}</div></td></tr>`;
|
||
countEl.textContent = '';
|
||
STATE.memoryCache = [];
|
||
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;
|
||
sortAndRenderMemories();
|
||
}
|
||
|
||
function sortAndRenderMemories() {
|
||
const tbody = document.getElementById('mem-table-body');
|
||
const countEl = document.getElementById('mem-result-count');
|
||
const sortOrder = document.getElementById('mem-sort-order')?.value || 'desc';
|
||
|
||
let memories = [...(STATE.memoryCache || [])];
|
||
|
||
// 按创建时间排序
|
||
memories.sort((a, b) => {
|
||
const ta = new Date(a.created_at || 0).getTime();
|
||
const tb = new Date(b.created_at || 0).getTime();
|
||
return sortOrder === 'asc' ? ta - tb : tb - ta;
|
||
});
|
||
|
||
countEl.textContent = `共 ${memories.length} 条`;
|
||
|
||
if (memories.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="icon">📭</div>没有找到记忆</div></td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = memories.map(m => {
|
||
// 会话ID 简短显示
|
||
const sid = m.session_id || '—';
|
||
const sidShort = sid.length > 16 ? sid.substring(0, 14) + '…' : sid;
|
||
return `
|
||
<tr>
|
||
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(m.content || '')}">${escHtml((m.content || '').substring(0, 80))}</td>
|
||
<td><span class="badge badge-idle">${escHtml(m.category || 'other')}</span></td>
|
||
<td>${m.priority ?? 1}</td>
|
||
<td style="color:var(--text2)">${escHtml(m.user_id || '—')}</td>
|
||
<td style="color:var(--text2);font-size:11px" title="${escHtml(sid)}">${escHtml(sidShort)}</td>
|
||
<td style="color:var(--text2);font-size:11px">${formatTime(m.created_at)}</td>
|
||
<td><button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除">🗑</button></td>
|
||
</tr>
|
||
`}).join('');
|
||
}
|
||
|
||
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 priority = parseInt(document.getElementById('mem-add-priority').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, priority }),
|
||
});
|
||
|
||
if (data.error) { showToast(`添加失败: ${data.error}`, 'error'); return; }
|
||
|
||
showToast('记忆添加成功!', 'success');
|
||
document.getElementById('mem-add-content').value = '';
|
||
// 自动刷新列表
|
||
listMemory();
|
||
}
|
||
|
||
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');
|
||
// 自动刷新列表
|
||
listMemory();
|
||
}
|
||
|
||
// ========== 面板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>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
let globalIndex = 0;
|
||
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++;
|
||
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;
|
||
}
|
||
|
||
// 保留旧 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;
|
||
|
||
// 获取详情
|
||
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(m => `
|
||
<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>'}
|
||
`;
|
||
}
|
||
|
||
// ========== 面板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'];
|
||
|
||
container.innerHTML = ids.map(id => {
|
||
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
|
||
return `
|
||
<div class="card" style="margin:0">
|
||
<div class="card-header">
|
||
<span class="card-title">${escapeId(id)}</span>
|
||
<span style="font-size:11px;color:var(--text2)">CPU ${s.cpu}% | MEM ${s.mem}MB</span>
|
||
</div>
|
||
<div class="chart-container">
|
||
<svg viewBox="0 0 300 120" class="chart-svg">
|
||
${drawChart(STATE.perfHistory[id] || [])}
|
||
</svg>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
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"/>
|
||
`;
|
||
}
|
||
|
||
// ========== 初始化 ==========
|
||
connectWS();
|
||
refreshStatus();
|
||
renderDashboard();
|
||
|
||
// 全局状态定时刷新
|
||
STATE.statusInterval = setInterval(refreshStatus, 5000);
|
||
</script>
|
||
</body>
|
||
</html>
|