a9c79d7887
- 新增ASR语音识别管线: QQ语音→下载音频→qwen3-asr-flash转录→注入用户消息 - 模型名称全部从models.json路由获取,无硬编码 - 修复群聊中AI将非管理员用户误称为管理员昵称(叶酱)的问题 - 助手回复缓存时标注[回复 昵称 (UID)],防止对话历史中身份混淆 - 群聊上下文指令改为肯定性表述,移除具体名称提及 - trace面板时间戳改为YYYY-MM-DD HH:MM:SS格式,耗时统一显示为秒 - 修复Go time.Duration纳秒值在前端显示问题(Duration/1e6转毫秒) - 新增video_tool插件模板 - 优化OpenAI adapter reasoning_content处理 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6441 lines
302 KiB
HTML
6441 lines
302 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 ethend</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,
|
||
#sidebar.collapsed .nav-group > summary { display: none; }
|
||
#sidebar.collapsed .nav-item { justify-content: center; padding: 10px 0; }
|
||
#sidebar.collapsed .nav-group > .nav-item { padding-left: 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; overflow-y: auto; overflow-x: hidden; }
|
||
.sidebar-nav::-webkit-scrollbar { width: 4px; }
|
||
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
||
.nav-item {
|
||
display: flex; align-items: center; gap: 10px; padding: 12px 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;
|
||
}
|
||
|
||
/* 侧边栏分类折叠组 */
|
||
.nav-group {
|
||
border: none; margin: 0; padding: 0;
|
||
}
|
||
.nav-group > summary {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 8px 12px 6px; border-radius: var(--radius-sm);
|
||
cursor: pointer; color: var(--text3); font-size: 10px; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: 0.06em;
|
||
list-style: none; user-select: none; transition: all var(--transition);
|
||
}
|
||
.nav-group > summary::-webkit-details-marker { display: none; }
|
||
.nav-group > summary::marker { display: none; content: none; }
|
||
.nav-group > summary:hover { background: var(--bg3); color: var(--text2); }
|
||
.nav-group > summary .group-arrow {
|
||
margin-left: auto; font-size: 9px; transition: transform var(--transition); opacity: 0.6;
|
||
}
|
||
.nav-group[open] > summary .group-arrow { transform: rotate(90deg); }
|
||
.nav-group > .nav-item { padding-left: 28px; }
|
||
|
||
#sidebar.collapsed .nav-group > summary,
|
||
#sidebar.collapsed .nav-group > .nav-item { padding-left: 0; }
|
||
|
||
.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); }
|
||
.badge-docker { background: rgba(56,139,253,.12); color: #388bfd; border: 1px solid rgba(56,139,253,.3); }
|
||
.docker-hint { font-size: 11px; color: var(--text3); font-style: italic; }
|
||
|
||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||
@keyframes bluePulse { 0%,100%{box-shadow:0 0 4px var(--blue)} 50%{box-shadow:0 0 12px var(--blue)} }
|
||
.badge-thinking { animation: bluePulse 1.5s infinite; }
|
||
|
||
/* 表单 */
|
||
.form-group { margin-bottom: 12px; }
|
||
.form-group label { display: block; font-size: 12px; color: var(--text2); margin-bottom: 4px; font-weight: 500; }
|
||
.form-row { display: flex; gap: 10px; align-items: flex-start; }
|
||
.form-row > label { flex: 0 0 140px; padding-top: 6px; }
|
||
.form-row > :not(label) { flex: 1; }
|
||
.form-row .form-row-narrow { flex: 0 0 auto !important; }
|
||
input, select, textarea {
|
||
width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); color: var(--text); font-size: 13px; font-family: inherit;
|
||
transition: border-color .15s;
|
||
}
|
||
input:focus, select:focus, textarea:focus {
|
||
outline: none; border-color: var(--accent);
|
||
}
|
||
textarea { resize: vertical; min-height: 70px; }
|
||
input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||
|
||
/* 日志容器 */
|
||
.log-container {
|
||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||
height: 280px; overflow-y: auto; padding: 10px;
|
||
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.6;
|
||
}
|
||
.log-line { padding: 1px 0; word-break: break-all; }
|
||
.log-line .ts { color: var(--text3); margin-right: 6px; }
|
||
.log-line.system { color: var(--blue); }
|
||
.log-line.stderr { color: var(--red); }
|
||
.log-line.error { color: var(--red); font-weight: 600; }
|
||
|
||
/* 日志标签 */
|
||
.log-tabs { display: flex; gap: 0; margin-bottom: 8px; }
|
||
.log-tab {
|
||
padding: 5px 14px; cursor: pointer; font-size: 12px; font-weight: 500;
|
||
color: var(--text2); border-bottom: 2px solid transparent; transition: all .15s;
|
||
background: none; border-top: none; border-left: none; border-right: none;
|
||
font-family: inherit;
|
||
}
|
||
.log-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||
.log-tab:hover { color: var(--text); }
|
||
|
||
/* 可折叠 */
|
||
.collapsible-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
cursor: pointer; user-select: none;
|
||
}
|
||
.collapsible-header:hover { color: var(--text); }
|
||
.collapsible-body { display: none; margin-top: 12px; }
|
||
.collapsible-body.open { display: block; }
|
||
.collapse-arrow { transition: transform .2s; font-size: 12px; }
|
||
.collapse-arrow.open { transform: rotate(90deg); }
|
||
|
||
/* 图表 */
|
||
.chart-container { width: 100%; height: 140px; position: relative; }
|
||
.chart-svg { width: 100%; height: 100%; }
|
||
.chart-line { fill: none; stroke-width: 2; }
|
||
.chart-line.cpu { stroke: var(--blue); }
|
||
.chart-line.mem { stroke: var(--green); }
|
||
.chart-area { opacity: .15; }
|
||
.chart-area.cpu { fill: var(--blue); }
|
||
.chart-area.mem { fill: var(--green); }
|
||
.legend { display: flex; gap: 14px; font-size: 11px; color: var(--text2); }
|
||
|
||
/* 性能仪表盘进度条 */
|
||
.perf-dashboard { display: flex; flex-direction: column; gap: 14px; }
|
||
.perf-row { display: flex; align-items: center; gap: 12px; }
|
||
.perf-label { min-width: 60px; font-size: 12px; color: var(--text2); }
|
||
.perf-value { min-width: 52px; font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 600; text-align: right; }
|
||
.perf-bar-wrap { flex: 1; background: var(--bg); border-radius: 4px; height: 10px; overflow: hidden; }
|
||
.perf-bar { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
||
.perf-bar.cpu-low, .perf-bar.cpu-mid, .perf-bar.cpu-high, .perf-bar.mem-low, .perf-bar.mem-mid, .perf-bar.mem-high { background: var(--blue); }
|
||
.perf-bar.cpu-mid, .perf-bar.mem-mid { background: var(--yellow); }
|
||
.perf-bar.cpu-high, .perf-bar.mem-high { background: var(--red); }
|
||
.perf-bar.mem-low { background: var(--green); }
|
||
.perf-stat { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||
.perf-stat:last-child { border-bottom: none; }
|
||
.perf-stat-icon { font-size: 16px; width: 24px; text-align: center; }
|
||
.perf-stat-label { font-size: 12px; color: var(--text2); flex: 1; }
|
||
.perf-stat-value { font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 600; }
|
||
.legend-item { display: flex; align-items: center; gap: 5px; }
|
||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||
.legend-dot.cpu { background: var(--blue); }
|
||
.legend-dot.mem { background: var(--green); }
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
padding: 32px; color: var(--text2);
|
||
}
|
||
.empty-state .icon { font-size: 36px; margin-bottom: 8px; }
|
||
|
||
/* 概览统计条 */
|
||
.overview-stat {
|
||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||
min-width: 80px;
|
||
}
|
||
.overview-stat-label { font-size: 10px; color: var(--text3); text-transform: uppercase; letter-spacing: .5px; }
|
||
.overview-stat-value { font-size: 15px; font-weight: 700; }
|
||
|
||
/* 会话详情展开 */
|
||
.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,
|
||
#sidebar .nav-group > summary { display: none; }
|
||
#sidebar .nav-item { justify-content: center; padding: 10px 0; }
|
||
#sidebar .nav-group > .nav-item { padding-left: 0; }
|
||
#sidebar .nav-icon { margin-right: 0; }
|
||
}
|
||
|
||
/* 服务卡片内的指标 */
|
||
.metrics { display: flex; gap: 10px; }
|
||
.metric { flex: 1; text-align: center; padding: 6px; background: var(--bg3); border-radius: var(--radius-sm); }
|
||
.metric .value { font-size: 16px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
|
||
.metric .label { font-size: 10px; color: var(--text2); margin-top: 2px; }
|
||
|
||
/* 仪表盘快速操作 */
|
||
.quick-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||
|
||
/* 刷新按钮旋转 */
|
||
|
||
/* 页面加载进度条 */
|
||
@keyframes loadingBar { 0% { width: 0; } 30% { width: 40%; } 60% { width: 75%; } 100% { width: 95%; } }
|
||
.loading-overlay {
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
padding: 60px 20px; gap: 16px;
|
||
}
|
||
.loading-progress-bar {
|
||
width: 260px; height: 3px; background: var(--bg3); border-radius: 2px; overflow: hidden;
|
||
}
|
||
.loading-progress-fill {
|
||
height: 100%; background: linear-gradient(90deg, var(--accent), var(--blue));
|
||
border-radius: 2px; animation: loadingBar 3s ease-out forwards;
|
||
}
|
||
.loading-text { color: var(--text2); font-size: 13px; }
|
||
.loading-detail { color: var(--text3); font-size: 11px; }
|
||
.loading-icon { font-size: 36px; animation: loadingPulse 1.2s ease-in-out infinite; }
|
||
@keyframes loadingPulse {
|
||
0%, 100% { opacity: 0.35; } 50% { opacity: 1; }
|
||
}
|
||
|
||
/* 按钮加载状态 */
|
||
.btn.loading {
|
||
position: relative; pointer-events: none; opacity: 0.7;
|
||
}
|
||
.btn.loading::after {
|
||
content: ""; position: absolute;
|
||
width: 12px; height: 12px; top: 50%; left: 50%; margin: -6px 0 0 -6px;
|
||
border: 2px solid transparent; border-top-color: currentColor;
|
||
border-radius: 50%; animation: spin .6s linear infinite;
|
||
}
|
||
.btn.loading .btn-text { visibility: hidden; }
|
||
|
||
/* 操作反馈闪烁 */
|
||
@keyframes flashBorder {
|
||
0% { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||
100% { border-color: var(--border); box-shadow: none; }
|
||
}
|
||
.card.action-pending {
|
||
animation: flashBorder .6s ease-in-out 3;
|
||
border-color: var(--accent);
|
||
}
|
||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||
.spinning { animation: spin 1s linear infinite; }
|
||
|
||
/* 数据库监看 */
|
||
.db-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||
.db-port-card {
|
||
background: var(--bg3); border-radius: var(--radius-sm); padding: 12px;
|
||
display: flex; align-items: center; gap: 10px; transition: all .2s;
|
||
border: 1px solid transparent;
|
||
}
|
||
.db-port-card.alive { border-color: var(--green); background: var(--green-bg); }
|
||
.db-port-card.dead { border-color: var(--red); background: var(--red-bg); opacity: .7; }
|
||
.db-port-card .db-dot {
|
||
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
||
}
|
||
.db-port-card.alive .db-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||
.db-port-card.dead .db-dot { background: var(--red); }
|
||
.db-port-card .db-info { flex: 1; min-width: 0; }
|
||
.db-port-card .db-name { font-size: 12px; font-weight: 600; }
|
||
.db-port-card .db-port-label { font-size: 10px; color: var(--text2); font-family: 'JetBrains Mono', monospace; }
|
||
.db-summary { display: flex; gap: 20px; align-items: center; padding: 12px 0; }
|
||
.db-summary-stat { text-align: center; }
|
||
.db-summary-stat .val { font-size: 20px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
|
||
.db-summary-stat .lbl { font-size: 10px; color: var(--text2); }
|
||
|
||
.tunnel-log {
|
||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||
max-height: 200px; overflow-y: auto; padding: 8px; margin-top: 8px;
|
||
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.5;
|
||
white-space: pre-wrap; word-break: break-all; color: var(--text2);
|
||
}
|
||
|
||
/* IoT 设备控制面板 */
|
||
.iot-device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||
.iot-device-card {
|
||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
||
padding: 16px; transition: border-color .2s;
|
||
}
|
||
.iot-device-card:hover { border-color: var(--accent); }
|
||
.iot-device-card.on { border-color: var(--green); }
|
||
.iot-device-card.off { border-color: var(--border2); opacity: .85; }
|
||
.iot-device-header {
|
||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;
|
||
}
|
||
.iot-device-name { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||
.iot-device-type { font-size: 10px; color: var(--text2); text-transform: uppercase; }
|
||
.iot-device-status { display: flex; align-items: center; gap: 6px; }
|
||
.iot-status-dot {
|
||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||
}
|
||
.iot-status-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||
.iot-status-dot.off { background: var(--text3); }
|
||
.iot-device-props { margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
|
||
.iot-prop-row {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||
font-size: 12px; padding: 4px 0;
|
||
}
|
||
.iot-prop-label { color: var(--text2); min-width: 50px; }
|
||
.iot-prop-value {
|
||
font-family: 'JetBrains Mono', monospace; font-weight: 600; min-width: 45px; text-align: right;
|
||
font-size: 12px;
|
||
}
|
||
.iot-prop-control { display: flex; align-items: center; gap: 6px; flex: 1; justify-content: flex-end; }
|
||
.iot-prop-control input[type="range"] { width: 100px; accent-color: var(--accent); }
|
||
.iot-device-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||
.iot-toggle-btn {
|
||
padding: 5px 14px; border-radius: var(--radius-sm); border: 1px solid;
|
||
cursor: pointer; font-size: 12px; font-weight: 600; transition: all .15s;
|
||
font-family: inherit;
|
||
}
|
||
.iot-toggle-btn.on { background: var(--green-bg); color: var(--green); border-color: var(--green); }
|
||
.iot-toggle-btn.on:hover { background: var(--green); color: #000; }
|
||
.iot-toggle-btn.off { background: var(--red-bg); color: var(--red); border-color: var(--red); }
|
||
.iot-toggle-btn.off:hover { background: var(--red); color: #fff; }
|
||
.iot-mode-btn {
|
||
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||
cursor: pointer; font-size: 11px; background: var(--bg3); color: var(--text);
|
||
transition: all .15s; font-family: inherit;
|
||
}
|
||
.iot-mode-btn:hover { background: var(--bg4); border-color: var(--text2); }
|
||
.iot-mode-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
|
||
.iot-color-btn {
|
||
width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--border);
|
||
cursor: pointer; transition: all .15s; flex-shrink: 0;
|
||
}
|
||
.iot-color-btn:hover { border-color: var(--text2); transform: scale(1.15); }
|
||
.iot-color-btn.active { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||
.iot-history-panel {
|
||
margin-top: 10px; border-top: 1px solid var(--border); padding-top: 8px;
|
||
}
|
||
.iot-history-item {
|
||
font-size: 11px; color: var(--text2); padding: 3px 0; display: flex; gap: 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
.iot-history-item .iot-hist-time { color: var(--text3); min-width: 60px; }
|
||
.iot-history-item .iot-hist-action { color: var(--accent); }
|
||
.iot-history-item .iot-hist-detail { color: var(--text2); }
|
||
.iot-refresh-bar {
|
||
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||
}
|
||
.iot-last-update { font-size: 11px; color: var(--text3); }
|
||
|
||
/* ========== 记忆卡片样式 ========== */
|
||
.mem-card {
|
||
transition: all 0.2s ease;
|
||
}
|
||
.mem-card:hover {
|
||
box-shadow: 0 4px 20px rgba(0,0,0,.35);
|
||
transform: translateY(-2px);
|
||
border-color: var(--accent) !important;
|
||
}
|
||
.mem-card.mem-card-high:hover {
|
||
border-color: #f59e0b !important;
|
||
box-shadow: 0 4px 24px rgba(245,158,11,.25);
|
||
}
|
||
.mem-cat-tab.active {
|
||
background: var(--accent) !important;
|
||
color: #fff !important;
|
||
border-color: var(--accent) !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ========== 记忆时间线样式 ========== */
|
||
.timeline-container {
|
||
position: relative;
|
||
padding-left: 40px;
|
||
}
|
||
.timeline-container::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 16px;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 2px;
|
||
background: linear-gradient(180deg, var(--blue) 0%, var(--border2) 50%, #a855f7 100%);
|
||
border-radius: 1px;
|
||
}
|
||
.timeline-item {
|
||
position: relative;
|
||
margin-bottom: 20px;
|
||
transition: all .2s ease;
|
||
}
|
||
.timeline-item:hover {
|
||
transform: translateX(2px);
|
||
}
|
||
.timeline-dot {
|
||
position: absolute;
|
||
left: -28px;
|
||
top: 14px;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
z-index: 2;
|
||
border: 2px solid var(--border2);
|
||
background: var(--bg2);
|
||
transition: all .2s;
|
||
}
|
||
.timeline-dot.memory {
|
||
border-color: var(--blue);
|
||
background: var(--blue-bg);
|
||
box-shadow: 0 0 8px rgba(59,130,246,.3);
|
||
}
|
||
.timeline-dot.thinking {
|
||
border-color: #a855f7;
|
||
background: rgba(168,85,247,.12);
|
||
box-shadow: 0 0 8px rgba(168,85,247,.3);
|
||
}
|
||
.timeline-card {
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 14px 16px;
|
||
cursor: pointer;
|
||
transition: all .2s ease;
|
||
}
|
||
.timeline-card:hover {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 4px 16px rgba(0,0,0,.2);
|
||
}
|
||
.timeline-card.memory-card {
|
||
border-left: 3px solid var(--blue);
|
||
}
|
||
.timeline-card.thinking-card {
|
||
border-left: 3px solid #a855f7;
|
||
}
|
||
.timeline-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.timeline-card-title {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
flex: 1;
|
||
line-height: 1.4;
|
||
}
|
||
.timeline-card-title.memory { color: #60a5fa; }
|
||
.timeline-card-title.thinking { color: #c084fc; }
|
||
.timeline-card-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 11px;
|
||
color: var(--text3);
|
||
flex-shrink: 0;
|
||
}
|
||
.timeline-card-body {
|
||
font-size: 12px;
|
||
color: var(--text2);
|
||
line-height: 1.6;
|
||
margin-bottom: 8px;
|
||
}
|
||
.timeline-card-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
font-size: 10px;
|
||
color: var(--text3);
|
||
border-top: 1px solid var(--border);
|
||
padding-top: 8px;
|
||
}
|
||
.timeline-importance-stars {
|
||
color: #f59e0b;
|
||
font-size: 11px;
|
||
white-space: nowrap;
|
||
}
|
||
.timeline-trigger-badge {
|
||
display: inline-block;
|
||
padding: 1px 8px;
|
||
border-radius: 10px;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
}
|
||
.timeline-trigger-badge.scheduled { background: var(--blue-bg); color: var(--blue); }
|
||
.timeline-trigger-badge.manual { background: var(--orange-bg); color: var(--orange); }
|
||
.timeline-detail {
|
||
display: none;
|
||
margin-top: 10px;
|
||
padding: 12px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
}
|
||
.timeline-detail.open {
|
||
display: block;
|
||
}
|
||
.timeline-detail .detail-section {
|
||
margin-bottom: 10px;
|
||
}
|
||
.timeline-detail .detail-section:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
.timeline-detail .detail-label {
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
color: var(--text3);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.timeline-detail .detail-content {
|
||
color: var(--text);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.timeline-detail .tool-call-item {
|
||
padding: 6px 10px;
|
||
background: var(--bg3);
|
||
border-radius: var(--radius-sm);
|
||
margin-bottom: 4px;
|
||
font-size: 11px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
/* 筛选标签样式 */
|
||
.timeline-filter-tab {
|
||
padding: 4px 14px;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--border);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
background: var(--bg3);
|
||
color: var(--text2);
|
||
transition: all .15s;
|
||
font-family: inherit;
|
||
}
|
||
.timeline-filter-tab:hover { background: var(--bg4); color: var(--text); }
|
||
.timeline-filter-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
|
||
|
||
/* ========== 全链路追踪面板样式 ========== */
|
||
.trace-summary { display: flex; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; }
|
||
.trace-summary-item {
|
||
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||
background: var(--bg3); border-radius: var(--radius-sm); font-size: 12px;
|
||
}
|
||
.trace-summary-item .tsi-val { font-weight: 700; font-family: 'JetBrains Mono', monospace; }
|
||
|
||
.trace-timeline { position: relative; padding-left: 48px; }
|
||
.trace-timeline::before {
|
||
content: ''; position: absolute; left: 22px; top: 0; bottom: 0;
|
||
width: 2px; background: var(--border2); border-radius: 1px;
|
||
}
|
||
.trace-hop {
|
||
position: relative; margin-bottom: 4px; padding: 8px 12px;
|
||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||
display: flex; align-items: center; gap: 12px; cursor: pointer;
|
||
transition: all .15s; font-size: 12px;
|
||
}
|
||
.trace-hop:hover { border-color: var(--accent); background: var(--bg3); }
|
||
.trace-hop.error { border-left: 3px solid var(--red); }
|
||
.trace-hop.success { border-left: 3px solid var(--green); }
|
||
.trace-hop .hop-dot {
|
||
position: absolute; left: -32px; top: 50%; transform: translateY(-50%);
|
||
width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--border2);
|
||
background: var(--bg2); z-index: 2;
|
||
}
|
||
.trace-hop .hop-dot.gateway { border-color: var(--blue); background: var(--blue-bg); }
|
||
.trace-hop .hop-dot.ai-core { border-color: var(--accent); background: var(--accent-bg); }
|
||
.trace-hop .hop-dot.voice-service { border-color: var(--yellow); background: var(--yellow-bg); }
|
||
.trace-hop .hop-dot.memory-service { border-color: var(--orange); background: var(--orange-bg); }
|
||
.trace-hop .hop-dot.error { border-color: var(--red); background: var(--red-bg); }
|
||
|
||
.trace-hop .hop-time {
|
||
font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3);
|
||
min-width: 70px; flex-shrink: 0;
|
||
}
|
||
.trace-hop .hop-service {
|
||
font-size: 10px; font-weight: 600; padding: 1px 8px; border-radius: 10px;
|
||
flex-shrink: 0; text-transform: uppercase;
|
||
}
|
||
.trace-hop .hop-service.gateway { background: var(--blue-bg); color: var(--blue); }
|
||
.trace-hop .hop-service.ai-core { background: var(--accent-bg); color: var(--accent); }
|
||
.trace-hop .hop-service.voice-service { background: var(--yellow-bg); color: var(--yellow); }
|
||
.trace-hop .hop-service.memory-service { background: var(--orange-bg); color: var(--orange); }
|
||
.trace-hop .hop-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.trace-hop .hop-duration {
|
||
font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2);
|
||
flex-shrink: 0;
|
||
}
|
||
.trace-hop .hop-status {
|
||
font-size: 16px; flex-shrink: 0;
|
||
}
|
||
.trace-hop-detail {
|
||
display: none; margin: -2px 0 8px 12px; padding: 10px 14px;
|
||
background: var(--bg); border: 1px solid var(--border2); border-radius: var(--radius-sm);
|
||
font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2);
|
||
white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto;
|
||
}
|
||
.trace-hop-detail.open { display: block; }
|
||
|
||
.trace-control-bar {
|
||
display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap;
|
||
}
|
||
.trace-control-bar input {
|
||
width: 280px; flex-shrink: 0;
|
||
}
|
||
.trace-empty { text-align: center; padding: 40px; color: var(--text2); }
|
||
.trace-empty .icon { font-size: 48px; margin-bottom: 12px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ========== 侧边栏 ========== -->
|
||
<aside id="sidebar">
|
||
<div class="sidebar-header">
|
||
<span class="sidebar-title">ethend</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>
|
||
|
||
<details class="nav-group">
|
||
<summary>🧠<span style="flex:1">AI 认知</span><span class="group-arrow">▶</span></summary>
|
||
<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="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>
|
||
</details>
|
||
|
||
<details class="nav-group">
|
||
<summary>⚙️<span style="flex:1">系统运维</span><span class="group-arrow">▶</span></summary>
|
||
<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="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="vmMonitor">
|
||
<span class="nav-icon">🖥</span><span class="nav-label">VM 监控</span>
|
||
<span class="nav-badge" id="vm-badge" style="display:none">●</span>
|
||
</button>
|
||
<button class="nav-item" data-panel="trace">
|
||
<span class="nav-icon">🔗</span><span class="nav-label">链路追踪</span>
|
||
<span class="nav-badge" id="trace-badge" style="display:none">0</span>
|
||
</button>
|
||
</details>
|
||
|
||
<details class="nav-group">
|
||
<summary>🔧<span style="flex:1">工具 & 插件</span><span class="group-arrow">▶</span></summary>
|
||
<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="plugins">
|
||
<span class="nav-icon">🔌</span><span class="nav-label">插件管理</span>
|
||
</button>
|
||
</details>
|
||
|
||
<details class="nav-group">
|
||
<summary>💬<span style="flex:1">通信平台</span><span class="group-arrow">▶</span></summary>
|
||
<button class="nav-item" data-panel="chatPlatforms">
|
||
<span class="nav-icon">💬</span><span class="nav-label">第三方聊天</span>
|
||
</button>
|
||
<button class="nav-item" data-panel="clients">
|
||
<span class="nav-icon">📱</span><span class="nav-label">客户端管理</span>
|
||
<span class="nav-badge" id="clients-badge" style="display:none">0</span>
|
||
</button>
|
||
</details>
|
||
|
||
<details class="nav-group">
|
||
<summary>🤖<span style="flex:1">模型</span><span class="group-arrow">▶</span></summary>
|
||
<button class="nav-item" data-panel="modelConfig">
|
||
<span class="nav-icon">🤖</span><span class="nav-label">模型配置</span>
|
||
</button>
|
||
<button class="nav-item" data-panel="llmCalls">
|
||
<span class="nav-icon">📊</span><span class="nav-label">LLM 调用</span>
|
||
</button>
|
||
<button class="nav-item" data-panel="thinkingSchedule">
|
||
<span class="nav-icon">⏰</span><span class="nav-label">思考调度</span>
|
||
</button>
|
||
</details>
|
||
</nav>
|
||
<div class="sidebar-footer">
|
||
<span id="ws-dot" class="disconnected"></span>
|
||
<span class="sidebar-footer-text" id="ws-status-text">未连接</span>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ========== 主内容区 ========== -->
|
||
<div id="main">
|
||
<div class="main-header">
|
||
<h2 id="panel-title">🏠 仪表盘</h2>
|
||
<div class="main-header-actions" id="panel-actions"></div>
|
||
</div>
|
||
<div id="panel-container">
|
||
<!-- 仪表盘 -->
|
||
<div class="panel active" id="panel-dashboard"><div class="loading-overlay" id="dashboard-loading"><div class="loading-icon">🛠️</div><div class="loading-text">正在连接服务并加载数据...</div><div class="loading-progress-bar"><div class="loading-progress-fill"></div></div><div class="loading-detail" id="loading-detail">初始化中</div></div></div>
|
||
<!-- 记忆管理 -->
|
||
<div class="panel" id="panel-memory"></div>
|
||
<!-- 会话监看 -->
|
||
<div class="panel" id="panel-sessions"></div>
|
||
<!-- 服务管理 -->
|
||
<div class="panel" id="panel-services"></div>
|
||
<!-- IoT 设备控制 -->
|
||
<div class="panel" id="panel-iot"></div>
|
||
<!-- 性能监控 -->
|
||
<div class="panel" id="panel-performance"></div>
|
||
<!-- 数据库监看 -->
|
||
<div class="panel" id="panel-database"></div>
|
||
<!-- VM 监控 -->
|
||
<div class="panel" id="panel-vmMonitor"></div>
|
||
<!-- 工具调用记录 -->
|
||
<div class="panel" id="panel-toolCalls"></div>
|
||
<!-- 语音识别日志 -->
|
||
<div class="panel" id="panel-stt"></div>
|
||
<div class="panel" id="panel-plugins"></div>
|
||
<!-- 自主思考日志 -->
|
||
<div class="panel" id="panel-thinking"></div>
|
||
<!-- 记忆时间线 -->
|
||
<div class="panel" id="panel-timeline"></div>
|
||
<!-- 第三方聊天 -->
|
||
<div class="panel" id="panel-chatPlatforms"></div>
|
||
<!-- 客户端管理 -->
|
||
<div class="panel" id="panel-clients"></div>
|
||
<div class="panel" id="panel-modelConfig"></div>
|
||
<div class="panel" id="panel-thinkingSchedule"></div>
|
||
<div class="panel" id="panel-llmCalls"></div>
|
||
<div class="panel" id="panel-trace"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast -->
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
// ========== 全局状态 ==========
|
||
const STATE = {
|
||
activePanel: 'dashboard',
|
||
sidebarCollapsed: false,
|
||
// 仪表盘
|
||
dashboardData: null,
|
||
// 服务
|
||
serviceStatus: {},
|
||
// 日志
|
||
activeLogTab: 'ai-core',
|
||
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [], 'memory-service': [], 'voice-service': [] },
|
||
maxLogLines: 500,
|
||
logLayout: 'grid',
|
||
// 性能
|
||
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [], 'memory-service': [], 'voice-service': [] },
|
||
// 会话
|
||
sessionsData: [],
|
||
sessionsAutoRefresh: null,
|
||
// 计时器
|
||
dashboardInterval: null,
|
||
statusInterval: null,
|
||
dbInterval: null,
|
||
// 仪表盘增量刷新 (Bug 7)
|
||
dashboardRenderCount: 0,
|
||
// 资源使用 60s 滑动窗口历史 (Bug 6)
|
||
resourceHistory: {},
|
||
// 记忆面板状态
|
||
memoryCache: [],
|
||
memoryUserId: 'admin',
|
||
memoryFilterCategory: 'all',
|
||
memorySortBy: 'importance',
|
||
memorySortDir: 'desc',
|
||
memoryFilterImportance: 0,
|
||
memorySearchText: '',
|
||
memoryPanelInitialized: false,
|
||
memoryOffset: 0,
|
||
memoryLimit: 50,
|
||
memoryHasMore: true,
|
||
memoryLoadingMore: false,
|
||
// STT 语音识别日志面板状态
|
||
sttLogs: [],
|
||
sttAutoRefresh: null,
|
||
sttAutoRefreshInterval: null,
|
||
// 时间线面板状态
|
||
timelineData: [],
|
||
timelineUserId: 'admin',
|
||
timelineFilterType: 'all',
|
||
timelineAutoRefresh: null,
|
||
timelineLimit: 100,
|
||
timelineOffset: 0,
|
||
timelineHasMore: true,
|
||
timelineLoadingMore: false,
|
||
// 第三方聊天
|
||
chatConfigsAutoRefresh: null,
|
||
chatConfigs: [],
|
||
chatActivePlatform: null,
|
||
chatLogLimit: 100,
|
||
// 自主思考面板:记录展开的日志 ID
|
||
// 模型配置
|
||
modelConfigTab: 'providers',
|
||
modelConfigProviders: [],
|
||
modelConfigModels: [],
|
||
modelConfigRouting: [],
|
||
fetchedModels: [],
|
||
llmCallsData: [],
|
||
expandedThinkingLogs: {},
|
||
// DOM 增量更新优化
|
||
renderHashes: {},
|
||
expandedToolCalls: {},
|
||
expandedSTTRows: {},
|
||
expandedTimelineItems: {},
|
||
servicesCardsRendered: false,
|
||
sessionsFirstRender: true,
|
||
prevSessionIds: [],
|
||
};
|
||
|
||
// 简单哈希函数 (djb2),用于判断数据是否变化
|
||
function simpleHash(str) {
|
||
var hash = 5381;
|
||
for (var i = 0; i < str.length; i++) {
|
||
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
||
}
|
||
return hash;
|
||
}
|
||
|
||
// ========== WebSocket ==========
|
||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
let ws = null, wsRetryTimer = null;
|
||
|
||
function connectWS() {
|
||
if (ws && ws.readyState === WebSocket.OPEN) return;
|
||
try {
|
||
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||
ws.onopen = () => {
|
||
document.getElementById('ws-dot').className = 'connected';
|
||
document.getElementById('ws-status-text').textContent = '已连接';
|
||
};
|
||
ws.onclose = () => {
|
||
document.getElementById('ws-dot').className = 'disconnected';
|
||
document.getElementById('ws-status-text').textContent = '断开(重连中)';
|
||
wsRetryTimer = setTimeout(connectWS, 3000);
|
||
};
|
||
ws.onerror = () => { document.getElementById('ws-dot').className = 'disconnected'; };
|
||
ws.onmessage = (ev) => {
|
||
const msg = JSON.parse(ev.data);
|
||
if (msg.type === 'log') handleWSLog(msg.data);
|
||
if (msg.type === 'stt-log') handleSTTLog(msg);
|
||
if (msg.type === 'voice_transcript') handleVoiceTranscript(msg);
|
||
if (msg.type === 'chat-log') handleChatLog(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 handleSTTLog(msg) {
|
||
const entry = msg.data || msg;
|
||
if (!entry || !entry.id) return;
|
||
// 添加到本地状态缓存(去重)
|
||
const exists = STATE.sttLogs.find(function(e) { return e.id === entry.id; });
|
||
if (!exists) {
|
||
STATE.sttLogs.unshift(entry);
|
||
if (STATE.sttLogs.length > 200) STATE.sttLogs.length = 200;
|
||
}
|
||
// 更新 badge
|
||
updateSTTBadge();
|
||
// 如果当前正在查看 STT 面板,增量更新表格
|
||
if (STATE.activePanel === 'stt') {
|
||
prependSTTTableRow(entry);
|
||
}
|
||
}
|
||
|
||
function updateSTTBadge() {
|
||
const badge = document.getElementById('stt-badge');
|
||
if (!badge) return;
|
||
const count = STATE.sttLogs.length;
|
||
if (count > 0) {
|
||
badge.textContent = count > 99 ? '99+' : count;
|
||
badge.style.display = 'inline';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ========== 工具函数 ==========
|
||
function escHtml(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
function drawSparkline(canvas, data, color) {
|
||
if (!canvas || data.length < 2) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const w = canvas.width, h = canvas.height;
|
||
ctx.clearRect(0, 0, w, h);
|
||
const max = Math.max(...data, 1);
|
||
const min = Math.min(...data, 0);
|
||
const range = max - min || 1;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < data.length; i++) {
|
||
const x = (i / (data.length - 1)) * w;
|
||
const y = h - ((data[i] - min) / range) * (h - 4) - 2;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
function formatUptime(ms) {
|
||
if (!ms || ms < 0) return '—';
|
||
const s = Math.floor(ms / 1000);
|
||
const m = Math.floor(s / 60);
|
||
const h = Math.floor(m / 60);
|
||
if (h > 0) return `${h}h ${m%60}m`;
|
||
if (m > 0) return `${m}m ${s%60}s`;
|
||
return `${s}s`;
|
||
}
|
||
|
||
function formatTime(ts) {
|
||
if (!ts) return '—';
|
||
const d = new Date(ts);
|
||
return d.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
function timeAgo(ts) {
|
||
if (!ts) return '—';
|
||
const diff = Date.now() - new Date(ts).getTime();
|
||
const s = Math.floor(diff / 1000);
|
||
if (s < 60) return `${s}秒前`;
|
||
if (s < 3600) return `${Math.floor(s/60)}分钟前`;
|
||
if (s < 86400) return `${Math.floor(s/3600)}小时前`;
|
||
return `${Math.floor(s/86400)}天前`;
|
||
}
|
||
|
||
function statusBadge(status) {
|
||
const map = {
|
||
running: 'badge-running', idle: 'badge-idle',
|
||
stopped: 'badge-stopped', error: 'badge-error',
|
||
starting: 'badge-starting', building: 'badge-building',
|
||
thinking: 'badge-thinking', streaming: 'badge-streaming',
|
||
};
|
||
return map[status] || 'badge-stopped';
|
||
}
|
||
|
||
const ALL_SVC_IDS = ['ai-core', 'gateway', 'frontend', 'iot-debug-service', 'memory-service', 'voice-service'];
|
||
|
||
function escapeId(id) {
|
||
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug', 'memory-service': 'Memory', 'voice-service': 'Voice' };
|
||
return map[id] || id;
|
||
}
|
||
|
||
async function api(url, opts = {}) {
|
||
try {
|
||
const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
let parsed;
|
||
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
||
return {
|
||
error: parsed?.error || text,
|
||
errorType: parsed?.errorType || null,
|
||
hint: parsed?.hint || null,
|
||
status: res.status,
|
||
};
|
||
}
|
||
const body = await res.json();
|
||
// 即使 HTTP 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);
|
||
// 折叠侧边栏时强制展开所有分组,否则 icon-only 模式看不到菜单项
|
||
if (STATE.sidebarCollapsed) {
|
||
document.querySelectorAll('.nav-group').forEach(g => g.setAttribute('open', ''));
|
||
} else {
|
||
autoExpandGroups(); // 恢复高度判断
|
||
}
|
||
});
|
||
|
||
// 侧边栏分组自动展开/折叠 — 屏幕高度 >= 900px 时自动展开
|
||
function autoExpandGroups() {
|
||
const groups = document.querySelectorAll('.nav-group');
|
||
if (groups.length === 0) return;
|
||
const expand = window.innerHeight >= 900;
|
||
groups.forEach(g => {
|
||
if (expand) g.setAttribute('open', '');
|
||
else g.removeAttribute('open');
|
||
});
|
||
}
|
||
window.addEventListener('resize', autoExpandGroups);
|
||
autoExpandGroups();
|
||
|
||
function switchPanel(name) {
|
||
STATE.activePanel = name;
|
||
|
||
// Update URL hash (without triggering hashchange).
|
||
if (location.hash !== '#' + name) {
|
||
history.replaceState(null, '', '#' + name);
|
||
}
|
||
|
||
// 停止链路追踪 SSE
|
||
disconnectTraceStream();
|
||
|
||
// 更新侧边栏
|
||
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: '🎤 语音识别日志', plugins: '🔌 插件管理', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
|
||
chatPlatforms: '💬 第三方聊天配置与消息日志',
|
||
clients: '📱 客户端管理',
|
||
modelConfig: '🤖 模型配置管理',
|
||
thinkingSchedule: '⏰ 思考调度配置',
|
||
llmCalls: '📊 LLM 调用日志',
|
||
vmMonitor: '🖥 VM 监控',
|
||
trace: '🔗 全链路追踪',
|
||
};
|
||
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(); disconnectTraceStream(); break;
|
||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'vmMonitor': renderVMMonitorPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'plugins': renderPluginsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
|
||
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
|
||
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'thinkingSchedule': renderThinkingSchedulePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'llmCalls': renderLlmCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||
case 'trace': renderTracePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||
}
|
||
}
|
||
|
||
function stopDashboardAutoRefresh() {
|
||
if (STATE.dashboardInterval) { clearInterval(STATE.dashboardInterval); STATE.dashboardInterval = null; }
|
||
}
|
||
|
||
function stopSessionsAutoRefresh() {
|
||
if (STATE.sessionsAutoRefresh) { clearInterval(STATE.sessionsAutoRefresh); STATE.sessionsAutoRefresh = null; }
|
||
}
|
||
|
||
function startDashboardAutoRefresh() {
|
||
stopDashboardAutoRefresh();
|
||
STATE.dashboardInterval = setInterval(() => {
|
||
if (STATE.activePanel === 'dashboard') renderDashboard();
|
||
}, 5000);
|
||
}
|
||
|
||
function startSessionsAutoRefresh() {
|
||
stopSessionsAutoRefresh();
|
||
STATE.sessionsAutoRefresh = setInterval(() => {
|
||
if (STATE.activePanel === 'sessions') loadSessions();
|
||
}, 5000);
|
||
}
|
||
|
||
function startDbAutoRefresh() {
|
||
stopDbAutoRefresh();
|
||
STATE.dbInterval = setInterval(() => {
|
||
if (STATE.activePanel === 'database') renderDatabasePanel();
|
||
}, 5000);
|
||
}
|
||
|
||
function stopDbAutoRefresh() {
|
||
if (STATE.dbInterval) { clearInterval(STATE.dbInterval); STATE.dbInterval = null; }
|
||
}
|
||
|
||
// ========== 面板1: 仪表盘 ==========
|
||
async function renderDashboard() {
|
||
// 更新加载提示(如果加载覆盖层还在)
|
||
var loadDetail = document.getElementById("loading-detail");
|
||
if (loadDetail) loadDetail.textContent = "正在加载仪表盘数据...";
|
||
const data = await api('/api/dashboard');
|
||
if (data.error) {
|
||
document.getElementById('panel-dashboard').innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
|
||
STATE.dashboardRenderCount = 0;
|
||
return;
|
||
}
|
||
// 数据未变则跳过 DOM 更新
|
||
var dataHash = simpleHash(JSON.stringify(data));
|
||
if (STATE.renderHashes['dashboard'] === dataHash && STATE.dashboardRenderCount > 0) return;
|
||
STATE.renderHashes['dashboard'] = dataHash;
|
||
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">ethend 内存</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>' +
|
||
'<button class="btn btn-sm" onclick="restartEthend()" style="margin-left:8px;border-color:var(--accent);color:var(--accent)" title="重启 ethend 自身以应用更新">🔁 重启 ethend</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';
|
||
|
||
// 计算平均延迟 (从 API 获取实际请求耗时)
|
||
let avgLatency = '—';
|
||
|
||
// 获取趋势数据 (从性能仪表盘 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;
|
||
const entries = Object.entries(svcs);
|
||
const isFirstRender = STATE.dashboardRenderCount === 0;
|
||
|
||
if (isFirstRender) {
|
||
container.innerHTML = entries.map(([id, svc]) => {
|
||
const isDocker = svc.source === 'docker';
|
||
return `
|
||
<div class="card" style="margin:0" data-dash-svc="${id}">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||
<span style="font-weight:600">${svc.name}</span>
|
||
<span data-dash-svc-status="${id}">${isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : `<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>`}</span>
|
||
</div>
|
||
<div class="metrics">
|
||
<div class="metric"><div class="value" data-dash-svc-pid="${id}">${isDocker ? (svc.containerName || '—') : (svc.pid || '—')}</div><div class="label">${isDocker ? '容器' : 'PID'}</div></div>
|
||
<div class="metric"><div class="value" data-dash-svc-port="${id}">${svc.port}</div><div class="label">端口</div></div>
|
||
<div class="metric"><div class="value" data-dash-svc-uptime="${id}">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
|
||
</div>
|
||
<div class="btn-group" style="margin-top:10px" data-dash-svc-actions="${id}">
|
||
${isDocker
|
||
? '<span class="docker-hint">🐳 Docker 管理</span>'
|
||
: `${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>` : ''}`}
|
||
${svc.healthUrl ? `<button class="btn btn-xs" onclick="checkHealth('${id}')">❤️</button>` : ''}
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
} else {
|
||
entries.forEach(function([id, svc]) {
|
||
var isDocker = svc.source === 'docker';
|
||
// 更新状态 badge
|
||
var statusEl = document.querySelector('[data-dash-svc-status="' + id + '"]');
|
||
if (statusEl) {
|
||
statusEl.innerHTML = isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : '<span class="badge ' + statusBadge(svc.status) + '">' + svc.status + '</span>';
|
||
}
|
||
// 更新 PID/容器名
|
||
var pidEl = document.querySelector('[data-dash-svc-pid="' + id + '"]');
|
||
if (pidEl) pidEl.textContent = isDocker ? (svc.containerName || '—') : (svc.pid || '—');
|
||
// 更新端口
|
||
var portEl = document.querySelector('[data-dash-svc-port="' + id + '"]');
|
||
if (portEl) portEl.textContent = svc.port;
|
||
// 更新运行时间
|
||
var uptimeEl = document.querySelector('[data-dash-svc-uptime="' + id + '"]');
|
||
if (uptimeEl) uptimeEl.textContent = formatUptime(svc.uptime);
|
||
// 更新按钮组
|
||
var actionsEl = document.querySelector('[data-dash-svc-actions="' + id + '"]');
|
||
if (actionsEl) {
|
||
actionsEl.innerHTML = isDocker
|
||
? '<span class="docker-hint">🐳 Docker 管理</span>'
|
||
: (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>' : '') +
|
||
(svc.healthUrl ? '<button class="btn btn-xs" onclick="checkHealth(\'' + id + '\')">❤️</button>' : '');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// ========== 记忆分类颜色映射 ==========
|
||
const MEMORY_CAT_COLORS = {
|
||
'user_preference': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '用户偏好', icon: '💜' },
|
||
'personal_info': { bg: 'rgba(59,130,246,.15)', text: '#3b82f6', name: '个人信息', icon: '👤' },
|
||
'conversation': { bg: 'rgba(34,197,94,.15)', text: '#22c55e', name: '对话摘要', icon: '💬' },
|
||
'knowledge': { bg: 'rgba(249,115,22,.15)', text: '#f97316', name: '知识信息', icon: '📚' },
|
||
'event': { bg: 'rgba(239,68,68,.15)', text: '#ef4444', name: '事件记录', icon: '📅' },
|
||
'task': { bg: 'rgba(234,179,8,.15)', text: '#eab308', name: '任务计划', icon: '✅' },
|
||
'relationship': { bg: 'rgba(236,72,153,.15)', text: '#ec4899', name: '关系情感', icon: '💕' },
|
||
// 向后兼容旧分类
|
||
'preference': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '偏好', icon: '💜' },
|
||
'fact': { bg: 'rgba(59,130,246,.15)', text: '#3b82f6', name: '事实', icon: '👤' },
|
||
'experience': { bg: 'rgba(249,115,22,.15)', text: '#f97316', name: '经验', icon: '📚' },
|
||
'other': { bg: 'rgba(139,148,158,.15)', text: '#8b949e', name: '其他', icon: '📌' },
|
||
'habit': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '习惯', icon: '💜' },
|
||
};
|
||
|
||
function getCatColor(cat) {
|
||
return MEMORY_CAT_COLORS[cat] || MEMORY_CAT_COLORS['other'];
|
||
}
|
||
|
||
// ========== 面板2: 记忆管理 ==========
|
||
function renderMemoryPanel() {
|
||
const isFirst = !STATE.memoryPanelInitialized;
|
||
STATE.memoryPanelInitialized = true;
|
||
|
||
// 首次渲染完整 DOM 结构
|
||
if (isFirst) {
|
||
document.getElementById('panel-memory').innerHTML = `
|
||
<!-- 搜索 & 添加行 -->
|
||
<div class="cards-grid cards-2" style="margin-bottom:14px">
|
||
<div class="card" style="margin:0">
|
||
<div class="card-header"><span class="card-title">🔍 搜索与筛选</span></div>
|
||
<div class="form-row">
|
||
<div class="form-group" style="flex:1">
|
||
<label>用户ID</label>
|
||
<input type="text" id="mem-user-id" placeholder="admin" value="${escHtml(STATE.memoryUserId)}">
|
||
</div>
|
||
<div class="form-group" style="flex:2">
|
||
<label>全文搜索</label>
|
||
<input type="text" id="mem-search-text" placeholder="输入关键词搜索记忆内容..." value="${escHtml(STATE.memorySearchText)}"
|
||
oninput="STATE.memorySearchText=this.value;filterAndRenderMemories()">
|
||
</div>
|
||
</div>
|
||
<div class="form-row" style="margin-top:4px">
|
||
<div class="form-group" style="flex:1">
|
||
<label>最低重要性 ≥ <span id="mem-imp-val">${STATE.memoryFilterImportance}</span></label>
|
||
<input type="range" id="mem-filter-importance" min="0" max="10" value="${STATE.memoryFilterImportance}"
|
||
oninput="document.getElementById('mem-imp-val').textContent=this.value;STATE.memoryFilterImportance=parseInt(this.value);filterAndRenderMemories()">
|
||
</div>
|
||
<div class="form-group" style="flex:1;display:flex;align-items:flex-end">
|
||
<button class="btn btn-accent btn-sm" onclick="loadMemories()" style="width:100%">🔍 查询记忆</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin:0">
|
||
<div class="card-header"><span class="card-title">➕ 添加记忆</span></div>
|
||
<div class="form-group"><label>用户ID</label><input type="text" id="mem-add-user-id" placeholder="admin" value="admin"></div>
|
||
<div class="form-group"><label>内容</label><textarea id="mem-add-content" placeholder="输入记忆内容..." rows="2"></textarea></div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>分类</label>
|
||
<select id="mem-add-category">
|
||
<option value="user_preference">用户偏好</option>
|
||
<option value="personal_info">个人信息</option>
|
||
<option value="conversation">对话摘要</option>
|
||
<option value="knowledge">知识信息</option>
|
||
<option value="event">事件记录</option>
|
||
<option value="task">任务计划</option>
|
||
<option value="relationship">关系情感</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>重要程度: <span id="mem-priority-val">5</span></label>
|
||
<input type="range" id="mem-add-importance" min="1" max="10" value="5" oninput="document.getElementById('mem-priority-val').textContent=this.value">
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-accent btn-sm" onclick="addMemory()">➕ 添加</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计面板 -->
|
||
<div class="card" id="mem-stats-card" style="margin-bottom:14px">
|
||
<div class="card-header"><span class="card-title">📊 记忆统计</span></div>
|
||
<div id="mem-stats-content">
|
||
<div class="empty-state"><div class="icon">📊</div>加载记忆后显示统计</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分类筛选标签栏 -->
|
||
<div class="card" style="margin-bottom:10px;padding:12px 16px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
|
||
<div id="mem-cat-tabs" style="display:flex;gap:6px;flex-wrap:wrap">
|
||
<!-- 动态填充 -->
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<select id="mem-sort-by" onchange="STATE.memorySortBy=this.value;filterAndRenderMemories()" style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">
|
||
<option value="importance" ${STATE.memorySortBy==='importance'?'selected':''}>⭐ 重要性</option>
|
||
<option value="created_at" ${STATE.memorySortBy==='created_at'?'selected':''}>🕐 创建时间</option>
|
||
<option value="updated_at" ${STATE.memorySortBy==='updated_at'?'selected':''}>🕐 更新时间</option>
|
||
<option value="category" ${STATE.memorySortBy==='category'?'selected':''}>🏷️ 分类</option>
|
||
<option value="access_count" ${STATE.memorySortBy==='access_count'?'selected':''}>📊 访问次数</option>
|
||
</select>
|
||
<select id="mem-sort-dir" onchange="STATE.memorySortDir=this.value;filterAndRenderMemories()" style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">
|
||
<option value="desc" ${STATE.memorySortDir==='desc'?'selected':''}>↓ 降序</option>
|
||
<option value="asc" ${STATE.memorySortDir==='asc'?'selected':''}>↑ 升序</option>
|
||
</select>
|
||
<span id="mem-result-count" style="font-size:11px;color:var(--text2);white-space:nowrap"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 记忆卡片网格 -->
|
||
<div id="mem-cards-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px">
|
||
<div class="empty-state" style="grid-column:1/-1"><div class="icon">🧠</div>点击「查询记忆」加载记忆数据</div>
|
||
</div>
|
||
`;
|
||
|
||
// 初始化分类标签
|
||
renderCategoryTabs();
|
||
}
|
||
|
||
// 如果有缓存数据,直接渲染
|
||
if (STATE.memoryCache.length > 0) {
|
||
filterAndRenderMemories();
|
||
}
|
||
}
|
||
|
||
function renderCategoryTabs() {
|
||
const container = document.getElementById('mem-cat-tabs');
|
||
if (!container) return;
|
||
|
||
const categories = [
|
||
{ key: 'all', name: '全部', icon: '📋' },
|
||
{ key: 'user_preference', name: '用户偏好', icon: '💜' },
|
||
{ key: 'personal_info', name: '个人信息', icon: '👤' },
|
||
{ key: 'conversation', name: '对话摘要', icon: '💬' },
|
||
{ key: 'knowledge', name: '知识信息', icon: '📚' },
|
||
{ key: 'event', name: '事件记录', icon: '📅' },
|
||
{ key: 'task', name: '任务计划', icon: '✅' },
|
||
{ key: 'relationship', name: '关系情感', icon: '💕' },
|
||
];
|
||
|
||
container.innerHTML = categories.map(c => {
|
||
const active = STATE.memoryFilterCategory === c.key;
|
||
const style = active
|
||
? 'background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600'
|
||
: 'background:var(--bg3);color:var(--text2);border-color:var(--border)';
|
||
return `<button class="mem-cat-tab" data-cat="${c.key}"
|
||
style="padding:4px 12px;border-radius:16px;border:1px solid;cursor:pointer;font-size:11px;transition:all .15s;font-family:inherit;${style}"
|
||
onmouseenter="if(!this.classList.contains('active')){this.style.background='var(--bg4)';this.style.color='var(--text)'}"
|
||
onmouseleave="if(!this.classList.contains('active')){this.style.background='var(--bg3)';this.style.color='var(--text2)'}"
|
||
onclick="switchMemoryCategory('${c.key}')">${c.icon} ${c.name}</button>`;
|
||
}).join('');
|
||
}
|
||
|
||
function switchMemoryCategory(cat) {
|
||
STATE.memoryFilterCategory = cat;
|
||
renderCategoryTabs();
|
||
filterAndRenderMemories();
|
||
}
|
||
|
||
async function loadMemories() {
|
||
const userId = document.getElementById('mem-user-id').value.trim();
|
||
if (!userId) { showToast('请输入用户ID', 'error'); return; }
|
||
STATE.memoryUserId = userId;
|
||
STATE.memoryOffset = 0;
|
||
STATE.memoryHasMore = true;
|
||
|
||
const data = await api(`/api/memory/list?user_id=${encodeURIComponent(userId)}&limit=${STATE.memoryLimit}&offset=0`);
|
||
|
||
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;
|
||
STATE.memoryOffset = memories.length;
|
||
STATE.memoryHasMore = memories.length >= STATE.memoryLimit;
|
||
filterAndRenderMemories();
|
||
}
|
||
|
||
async function loadMoreMemories() {
|
||
if (STATE.memoryLoadingMore || !STATE.memoryHasMore) return;
|
||
STATE.memoryLoadingMore = true;
|
||
|
||
const userId = STATE.memoryUserId;
|
||
const data = await api(`/api/memory/list?user_id=${encodeURIComponent(userId)}&limit=${STATE.memoryLimit}&offset=${STATE.memoryOffset}`);
|
||
|
||
if (data.error) { STATE.memoryLoadingMore = false; return; }
|
||
|
||
let memories = [];
|
||
if (Array.isArray(data)) memories = data;
|
||
else if (data.memories) memories = data.memories;
|
||
else if (data.results) memories = data.results;
|
||
|
||
if (memories.length > 0) {
|
||
STATE.memoryCache = STATE.memoryCache.concat(memories);
|
||
STATE.memoryOffset += memories.length;
|
||
STATE.memoryHasMore = memories.length >= STATE.memoryLimit;
|
||
renderStatsPanel();
|
||
appendMemoryCards(memories);
|
||
const countEl = document.getElementById('mem-result-count');
|
||
if (countEl) countEl.textContent = '显示 ' + STATE.memoryCache.length + ' 条';
|
||
} else {
|
||
STATE.memoryHasMore = false;
|
||
}
|
||
STATE.memoryLoadingMore = false;
|
||
}
|
||
|
||
function appendMemoryCards(memories) {
|
||
var grid = document.getElementById('mem-cards-grid');
|
||
if (!grid) return;
|
||
var html = memories.map(function(m) { return renderMemoryCard(m); }).join('');
|
||
grid.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
|
||
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;
|
||
STATE.memoryOffset = memories.length;
|
||
STATE.memoryHasMore = false; // search doesn't support pagination
|
||
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');
|
||
|
||
// 数据未变则跳过 DOM 更新
|
||
var dataHash = simpleHash(JSON.stringify(data));
|
||
if (STATE.renderHashes['sessions'] === dataHash) return;
|
||
STATE.renderHashes['sessions'] = dataHash;
|
||
|
||
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 = [];
|
||
STATE.sessionsFirstRender = true;
|
||
return;
|
||
}
|
||
|
||
// 尝试增量更新: 如果 session_id 集合未变,仅更新状态和最近活动时间
|
||
if (!STATE.sessionsFirstRender && flatSessions.length > 0) {
|
||
var prevIds = (STATE.prevSessionIds || []).slice().sort().join(',');
|
||
var currIds = flatSessions.map(function(s) { return s.session_id; }).sort().join(',');
|
||
if (prevIds === currIds) {
|
||
// 仅更新状态 badge 和最近活动时间
|
||
flatSessions.forEach(function(s, i) {
|
||
var row = document.getElementById('session-row-' + i);
|
||
if (!row) return;
|
||
// 更新状态 badge
|
||
var badgeEl = row.querySelector('.badge');
|
||
if (badgeEl) {
|
||
badgeEl.className = 'badge ' + statusBadge(s.state || 'idle');
|
||
badgeEl.textContent = s.state || 'idle';
|
||
}
|
||
// 更新 last_activity
|
||
var lastActEl = row.querySelector('span:last-child');
|
||
if (lastActEl) {
|
||
lastActEl.textContent = '最近活动: ' + timeAgo(s.last_activity);
|
||
}
|
||
});
|
||
STATE.prevSessionIds = flatSessions.map(function(s) { return s.session_id; });
|
||
return;
|
||
}
|
||
}
|
||
STATE.prevSessionIds = flatSessions.map(function(s) { return s.session_id; });
|
||
STATE.sessionsFirstRender = false;
|
||
|
||
// Bug 5: 保存当前展开的 session ID 列表,以便重建 DOM 后恢复
|
||
const previouslyExpanded = [];
|
||
const existingDetails = container.querySelectorAll('[id^="session-detail-"]');
|
||
existingDetails.forEach(function(el) {
|
||
if (el.style.display !== 'none') {
|
||
const match = el.id.match(/^session-detail-(\d+)$/);
|
||
if (match) previouslyExpanded.push(parseInt(match[1]));
|
||
}
|
||
});
|
||
|
||
let html = '';
|
||
let globalIndex = 0;
|
||
const flatSessionMap = []; // 记录 index -> session 映射,用于恢复展开
|
||
for (const [userID, sessions] of Object.entries(users)) {
|
||
html += `<div style="margin-bottom:16px">`;
|
||
html += `<div style="font-weight:600;font-size:14px;padding:8px 0;border-bottom:1px solid var(--border);margin-bottom:8px;color:var(--accent)">👤 User: ${escHtml(userID)}</div>`;
|
||
|
||
for (const s of sessions) {
|
||
const idx = globalIndex++;
|
||
flatSessionMap.push({ index: idx, session: s, userID: userID });
|
||
html += `
|
||
<div style="padding:6px 0 6px 20px">
|
||
<div id="session-row-${idx}" class="session-row" data-index="${idx}" style="cursor:pointer;display:flex;align-items:center;gap:10px;padding:6px 10px;background:var(--bg3);border-radius:var(--radius-sm)" onclick="toggleSessionDetail(${idx})">
|
||
<span class="collapse-arrow" id="session-arrow-${idx}">▶</span>
|
||
<span style="flex:1">💬 <strong>Session:</strong> <code style="font-size:11px;color:var(--accent)">${escHtml((s.session_id || '').substring(0, 24))}${(s.session_id || '').length > 24 ? '...' : ''}</code></span>
|
||
<span class="badge ${statusBadge(s.state || 'idle')}">${s.state || 'idle'}</span>
|
||
<span style="font-size:11px;color:var(--text2)">最近活动: ${timeAgo(s.last_activity)}</span>
|
||
</div>
|
||
<div id="session-detail-${idx}" style="display:none;margin-top:4px;margin-left:20px">
|
||
<div class="session-detail" id="session-detail-content-${idx}">
|
||
<div style="text-align:center;color:var(--text2);padding:8px">加载详情中...</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
|
||
// Bug 5: 恢复之前展开的 session (通过 session_id 匹配新旧 index)
|
||
if (previouslyExpanded.length > 0) {
|
||
// 构建 session_id -> 旧 index 的映射
|
||
const oldSessionIdToIndex = {};
|
||
STATE.sessionsData.forEach(function(s, i) { oldSessionIdToIndex[s.session_id] = i; });
|
||
|
||
// 对每个之前展开的 index,找到对应的 session_id,再找到新的 index
|
||
const toExpandNewIndices = [];
|
||
previouslyExpanded.forEach(function(oldIdx) {
|
||
const oldSession = STATE.sessionsData[oldIdx];
|
||
if (oldSession) {
|
||
const sid = oldSession.session_id;
|
||
// 在新的 flatSessionMap 中按 session_id 查找
|
||
for (let j = 0; j < flatSessionMap.length; j++) {
|
||
if (flatSessionMap[j].session.session_id === sid) {
|
||
toExpandNewIndices.push(j);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 恢复展开
|
||
toExpandNewIndices.forEach(function(newIdx) {
|
||
restoreSessionExpand(newIdx);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Bug 5 helper: 恢复展开的 session UI 并自动加载详情
|
||
async function restoreSessionExpand(index) {
|
||
const detailRow = document.getElementById('session-detail-' + index);
|
||
const arrow = document.getElementById('session-arrow-' + index);
|
||
if (!detailRow || !arrow) return;
|
||
detailRow.style.display = '';
|
||
arrow.classList.add('open');
|
||
// 直接加载详情内容 (不调用 toggleSessionDetail 以避免 flip-flop)
|
||
const session = STATE.sessionsData[index];
|
||
if (!session) return;
|
||
const contentEl = document.getElementById('session-detail-content-' + index);
|
||
if (!contentEl) return;
|
||
await loadSessionDetailContent(session, contentEl);
|
||
}
|
||
|
||
// 提取 session 详情加载逻辑为独立函数 (Bug 5 复用)
|
||
async function loadSessionDetailContent(session, contentEl) {
|
||
const data = await api('/api/sessions/' + session.session_id);
|
||
if (data.error) {
|
||
let hint = '';
|
||
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
|
||
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
|
||
} else if (data.errorType === 'gateway_unreachable') {
|
||
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
|
||
} else if (data.status === 502) {
|
||
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请确认 Gateway 服务已启动</span>';
|
||
}
|
||
contentEl.innerHTML = '<div style="color:var(--red);text-align:center">' + escHtml(data.error) + hint + '</div>';
|
||
return;
|
||
}
|
||
const messages = data.recent_messages || [];
|
||
contentEl.innerHTML =
|
||
'<div class="detail-row">' +
|
||
'<span class="detail-label">会话ID:</span>' +
|
||
'<code style="font-size:11px">' + escHtml(data.session_id || session.session_id) + '</code>' +
|
||
'</div>' +
|
||
'<div class="detail-row">' +
|
||
'<span class="detail-label">用户ID:</span>' +
|
||
'<span>' + escHtml(data.user_id || session.user_id) + '</span>' +
|
||
'</div>' +
|
||
'<div class="detail-row">' +
|
||
'<span class="detail-label">状态:</span>' +
|
||
'<span class="badge ' + statusBadge(data.state || 'idle') + '">' + (data.state || 'idle') + '</span>' +
|
||
'</div>' +
|
||
'<div class="detail-row">' +
|
||
'<span class="detail-label">消息数:</span>' +
|
||
'<span>' + (data.message_count || 0) + '</span>' +
|
||
'</div>' +
|
||
'<div class="detail-row">' +
|
||
'<span class="detail-label">连接时间:</span>' +
|
||
'<span>' + formatTime(data.connected_at) + '</span>' +
|
||
'</div>' +
|
||
'<div class="detail-row">' +
|
||
'<span class="detail-label">最后活跃:</span>' +
|
||
'<span>' + formatTime(data.last_activity) + '</span>' +
|
||
'</div>' +
|
||
(messages.length > 0 ?
|
||
'<div style="margin-top:10px;font-weight:600;font-size:12px;color:var(--text2)">📝 最近消息 (' + messages.length + ')</div>' +
|
||
'<div class="msg-list">' +
|
||
messages.map(function(m) {
|
||
return '<div class="msg-item">' +
|
||
'<span class="role ' + m.role + '">' + m.role + '</span>' +
|
||
'<span style="color:var(--text2);font-size:10px;margin-right:6px">' + formatTime(m.timestamp) + '</span>' +
|
||
escHtml(m.content || '') +
|
||
'</div>';
|
||
}).join('') +
|
||
'</div>'
|
||
: '<div style="margin-top:8px;color:var(--text2);font-size:12px">暂无消息记录</div>');
|
||
}
|
||
|
||
// 保留旧 loadSessions 兼容其他调用
|
||
async function loadSessions() {
|
||
fetchActiveSessions();
|
||
}
|
||
|
||
async function toggleSessionDetail(index) {
|
||
const detailRow = document.getElementById('session-detail-' + index);
|
||
const arrow = document.getElementById('session-arrow-' + index);
|
||
const contentEl = document.getElementById('session-detail-content-' + index);
|
||
|
||
if (detailRow.style.display !== 'none') {
|
||
// 折叠
|
||
detailRow.style.display = 'none';
|
||
arrow.classList.remove('open');
|
||
return;
|
||
}
|
||
|
||
// 展开
|
||
detailRow.style.display = '';
|
||
arrow.classList.add('open');
|
||
|
||
const session = STATE.sessionsData[index];
|
||
if (!session) return;
|
||
|
||
// 委托给共用函数
|
||
await loadSessionDetailContent(session, contentEl);
|
||
}
|
||
|
||
// ========== 面板4: 服务管理 ==========
|
||
function renderServicesPanel() {
|
||
document.getElementById('panel-actions').innerHTML = `
|
||
<button class="btn btn-sm btn-accent" onclick="svcAction('start-all')">▶ 一键启动</button>
|
||
<button class="btn btn-sm" onclick="svcAction('start-all-fresh')">🔄 强制重启全部</button>
|
||
<button class="btn btn-sm btn-red" onclick="svcAction('stop-all')">⏹ 全部停止</button>
|
||
`;
|
||
|
||
document.getElementById('panel-services').innerHTML = `
|
||
<!-- 服务状态卡片 -->
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">📡 服务管理</span></div>
|
||
<div class="cards-grid cards-4" id="services-svc-cards"></div>
|
||
</div>
|
||
|
||
<!-- 实时日志 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">📋 实时日志</span>
|
||
<div class="btn-group">
|
||
<div class="log-tabs" id="services-log-tabs" style="margin:0"></div>
|
||
<button class="btn btn-xs" onclick="toggleSvcLogLayout()" id="btn-svc-log-layout">📋 标签页</button>
|
||
<button class="btn btn-xs" onclick="clearSvcLogs()">🗑 清空</button>
|
||
</div>
|
||
</div>
|
||
<div id="services-log-tabs-panel" style="display:none">
|
||
<div class="log-container" id="services-log-panel">
|
||
<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>
|
||
</div>
|
||
</div>
|
||
<div id="services-log-grid">
|
||
<div class="svc-log-grid" style="display:flex;gap:12px;overflow-x:auto;padding-bottom:8px">
|
||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-ai-core" style="height:300px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div></div>
|
||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-gateway" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div></div>
|
||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-iot-debug-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div></div>
|
||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-frontend" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div></div>
|
||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-memory-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Memory</div></div></div>
|
||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-voice-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Voice</div></div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
renderServiceCards();
|
||
initSvcLogTabs();
|
||
if (STATE.logLayout === 'grid') {
|
||
document.getElementById('services-log-tabs').style.display = 'none';
|
||
ALL_SVC_IDS.forEach(id => renderGridLog(id));
|
||
} else {
|
||
renderServiceLog();
|
||
}
|
||
}
|
||
|
||
function renderServiceCards() {
|
||
const container = document.getElementById('services-svc-cards');
|
||
if (!container) return;
|
||
const status = STATE.serviceStatus;
|
||
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ALL_SVC_IDS;
|
||
|
||
// 首次渲染使用 innerHTML 构建完整 DOM
|
||
if (!STATE.servicesCardsRendered) {
|
||
container.innerHTML = ids.map(id => {
|
||
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null, source: 'none' };
|
||
const isDocker = svc.source === 'docker';
|
||
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" data-svc="${id}">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||
<span style="font-weight:600" data-svc-name="${id}">${svc.name}</span>
|
||
<span class="svc-status-badge" data-svc-status="${id}">${isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : `<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>`}</span>
|
||
</div>
|
||
<div class="metrics">
|
||
<div class="metric"><div class="value" data-svc-pid="${id}">${isDocker ? (svc.containerName || '—') : (svc.pid || '—')}</div><div class="label">${isDocker ? '容器' : 'PID'}</div></div>
|
||
<div class="metric"><div class="value" data-svc-port="${id}">${svc.port}</div><div class="label">端口</div></div>
|
||
<div class="metric"><div class="value" data-svc-uptime="${id}">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
|
||
</div>
|
||
<div class="btn-group" style="margin-top:10px" data-svc-actions="${id}">
|
||
${isDocker
|
||
? '<span class="docker-hint">🐳 Docker 管理 — 请使用 docker compose</span>'
|
||
: `${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('');
|
||
STATE.servicesCardsRendered = true;
|
||
return;
|
||
}
|
||
|
||
// 后续调用: 增量更新每个卡片的属性值
|
||
ids.forEach(function(id) {
|
||
updateServiceCardInPlace(id, status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null, source: 'none' });
|
||
});
|
||
}
|
||
|
||
function updateServiceCardInPlace(id, svc) {
|
||
var isDocker = svc.source === 'docker';
|
||
var isRunning = svc.status === 'running';
|
||
var isStarting = svc.status === 'starting' || svc.status === 'building';
|
||
var isStopped = svc.status === 'stopped' || svc.status === 'error' || svc.status === 'unknown';
|
||
|
||
// 更新状态 badge
|
||
var statusEl = document.querySelector('[data-svc-status="' + id + '"]');
|
||
if (statusEl) {
|
||
statusEl.innerHTML = isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : '<span class="badge ' + statusBadge(svc.status) + '">' + svc.status + '</span>';
|
||
}
|
||
|
||
// 更新 PID/容器名
|
||
var pidEl = document.querySelector('[data-svc-pid="' + id + '"]');
|
||
if (pidEl) pidEl.textContent = isDocker ? (svc.containerName || '—') : (svc.pid || '—');
|
||
|
||
// 更新端口
|
||
var portEl = document.querySelector('[data-svc-port="' + id + '"]');
|
||
if (portEl) portEl.textContent = svc.port;
|
||
|
||
// 更新运行时间
|
||
var uptimeEl = document.querySelector('[data-svc-uptime="' + id + '"]');
|
||
if (uptimeEl) uptimeEl.textContent = formatUptime(svc.uptime);
|
||
|
||
// 更新按钮组
|
||
var actionsEl = document.querySelector('[data-svc-actions="' + id + '"]');
|
||
if (actionsEl) {
|
||
actionsEl.innerHTML = isDocker
|
||
? '<span class="docker-hint">🐳 Docker 管理 — 请使用 docker compose</span>'
|
||
: (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>' : '');
|
||
}
|
||
}
|
||
|
||
// ---- 日志功能 ----
|
||
function initSvcLogTabs() {
|
||
const tabs = document.getElementById('services-log-tabs');
|
||
if (!tabs) return;
|
||
tabs.innerHTML = ALL_SVC_IDS.map(id =>
|
||
`<button class="log-tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchSvcLogTab('${id}')">${escapeId(id)}</button>`
|
||
).join('');
|
||
}
|
||
|
||
function switchSvcLogTab(id) {
|
||
STATE.activeLogTab = id;
|
||
initSvcLogTabs();
|
||
renderServiceLog();
|
||
}
|
||
|
||
function renderServiceLog() {
|
||
const panel = document.getElementById('services-log-panel');
|
||
if (!panel) return;
|
||
const lines = STATE.logLines[STATE.activeLogTab] || [];
|
||
if (lines.length === 0) {
|
||
panel.innerHTML = '<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>';
|
||
return;
|
||
}
|
||
panel.innerHTML = lines.map(l =>
|
||
`<div class="log-line ${l.stream}"><span class="ts">${l.ts}</span>${escHtml(l.text)}</div>`
|
||
).join('');
|
||
panel.scrollTop = panel.scrollHeight;
|
||
}
|
||
|
||
function renderGridLog(service) {
|
||
const panel = document.getElementById(`log-panel-${service}`);
|
||
if (!panel) return;
|
||
const lines = STATE.logLines[service] || [];
|
||
if (lines.length === 0) {
|
||
panel.innerHTML = `<div class="empty-state"><div class="icon">📝</div>${escapeId(service)}</div>`;
|
||
return;
|
||
}
|
||
panel.innerHTML = lines.map(l =>
|
||
`<div class="log-line ${l.stream}"><span class="ts">${l.ts}</span>${escHtml(l.text)}</div>`
|
||
).join('');
|
||
panel.scrollTop = panel.scrollHeight;
|
||
}
|
||
|
||
function toggleSvcLogLayout() {
|
||
const btn = document.getElementById('btn-svc-log-layout');
|
||
const tabsPanel = document.getElementById('services-log-tabs-panel');
|
||
const gridPanel = document.getElementById('services-log-grid');
|
||
const logTabs = document.getElementById('services-log-tabs');
|
||
|
||
if (STATE.logLayout === 'grid') {
|
||
STATE.logLayout = 'tabs';
|
||
gridPanel.style.display = 'none';
|
||
tabsPanel.style.display = '';
|
||
logTabs.style.display = '';
|
||
btn.textContent = '📐 并列';
|
||
renderServiceLog();
|
||
} else {
|
||
STATE.logLayout = 'grid';
|
||
tabsPanel.style.display = 'none';
|
||
gridPanel.style.display = '';
|
||
logTabs.style.display = 'none';
|
||
btn.textContent = '📋 标签页';
|
||
ALL_SVC_IDS.forEach(id => renderGridLog(id));
|
||
}
|
||
}
|
||
|
||
async function clearSvcLogs() {
|
||
const id = STATE.activeLogTab;
|
||
await api(`/api/logs/${id}`, { method: 'DELETE' });
|
||
STATE.logLines[id] = [];
|
||
renderServiceLog();
|
||
}
|
||
|
||
// ---- ethend 自重启 ----
|
||
async function restartEthend() {
|
||
if (!confirm('确定要重启 ethend 吗?\n\n页面将在几秒后自动刷新。')) return;
|
||
try {
|
||
await api('/api/ethend/restart', { method: 'POST' });
|
||
} catch (e) {
|
||
// 请求可能在收到响应前就中断(因为服务已退出),这是正常的
|
||
}
|
||
// 等待新进程就绪后刷新
|
||
setTimeout(function() {
|
||
location.reload();
|
||
}, 2000);
|
||
}
|
||
|
||
// ---- 服务操作 ----
|
||
async function svcAction(cmd, serviceId) {
|
||
var actionLabels = { start: "启动", stop: "停止", restart: "重启", build: "编译", "start-all": "一键启动", "start-all-fresh": "强制重启全部", "stop-all": "全部停止" };
|
||
var label = actionLabels[cmd] || cmd;
|
||
var svcLabel = serviceId ? escapeId(serviceId) : "";
|
||
var msg = svcLabel ? "正在" + label + " " + svcLabel + "..." : "正在执行: " + label + "...";
|
||
showToast(msg, "info");
|
||
|
||
// 立即更新本地状态和 UI(乐观更新)
|
||
if (serviceId) {
|
||
var svc = STATE.serviceStatus[serviceId];
|
||
if (svc && svc.source === "docker") {
|
||
showToast(svc.name + " 由 Docker 管理,不支持此操作", "error");
|
||
return;
|
||
}
|
||
var newStatus;
|
||
if (cmd === "start") newStatus = "starting";
|
||
else if (cmd === "stop") newStatus = "stopped";
|
||
else if (cmd === "restart") newStatus = "starting";
|
||
else if (cmd === "build") newStatus = "building";
|
||
if (newStatus) {
|
||
if (!STATE.serviceStatus[serviceId]) {
|
||
STATE.serviceStatus[serviceId] = { name: escapeId(serviceId), status: newStatus, pid: null, port: "-", uptime: 0, source: "none" };
|
||
} else {
|
||
STATE.serviceStatus[serviceId].status = newStatus;
|
||
}
|
||
if (STATE.activePanel === "services") renderServiceCards();
|
||
if (STATE.activePanel === "dashboard") renderDashboardSvcCards(STATE.serviceStatus);
|
||
}
|
||
} else {
|
||
// 批量操作:跳过 Docker 服务,只更新本地服务状态
|
||
var dockerCount = 0;
|
||
var newStatus = (cmd === "start-all" || cmd === "start-all-fresh") ? "starting" : "stopped";
|
||
ALL_SVC_IDS.forEach(function(id) {
|
||
if (!STATE.serviceStatus[id]) {
|
||
STATE.serviceStatus[id] = { name: escapeId(id), status: newStatus, pid: null, port: "-", uptime: 0, source: "none" };
|
||
} else if (STATE.serviceStatus[id].source === "docker") {
|
||
dockerCount++;
|
||
} else {
|
||
STATE.serviceStatus[id].status = newStatus;
|
||
}
|
||
});
|
||
if (dockerCount > 0) {
|
||
showToast("跳过 " + dockerCount + " 个 Docker 管理服务的操作", "info");
|
||
}
|
||
if (STATE.activePanel === "services") renderServiceCards();
|
||
if (STATE.activePanel === "dashboard") renderDashboardSvcCards(STATE.serviceStatus);
|
||
}
|
||
|
||
// 发起 API 请求
|
||
let url;
|
||
if (cmd === "start-all") url = "/api/services/start-all";
|
||
else if (cmd === "start-all-fresh") url = "/api/services/start-all-fresh";
|
||
else if (cmd === "stop-all") url = "/api/services/stop-all";
|
||
else url = "/api/services/" + serviceId + "/" + cmd;
|
||
|
||
var method = ["start","stop","restart","build","start-all","start-all-fresh","stop-all"].indexOf(cmd) >= 0 ? "POST" : "GET";
|
||
var res = await api(url, { method: method });
|
||
showToast(res.message || res.error || (label + " 完成"), res.error ? "error" : "success");
|
||
refreshStatus();
|
||
}
|
||
|
||
async function checkHealth(id) {
|
||
const res = await api(`/api/proxy/${id}/health`);
|
||
if (res.error) {
|
||
showToast(`${id}: ${res.error}`, 'error');
|
||
} else {
|
||
showToast(`${id}: ${res.status} - ${JSON.stringify(res.data).substring(0, 100)}`, 'success');
|
||
}
|
||
}
|
||
|
||
async function refreshStatus() {
|
||
const status = await api('/api/services');
|
||
if (!status.error) {
|
||
STATE.serviceStatus = status;
|
||
if (STATE.activePanel === 'services') renderServiceCards();
|
||
if (STATE.activePanel === 'dashboard') renderDashboard();
|
||
}
|
||
if (STATE.activePanel === 'performance') refreshPerf();
|
||
}
|
||
|
||
// ========== 面板5: 性能监控 ==========
|
||
function renderPerformancePanel() {
|
||
document.getElementById('panel-performance').innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">📊 性能分析</span>
|
||
<div class="legend">
|
||
<span class="legend-item"><span class="legend-dot cpu"></span> CPU %</span>
|
||
<span class="legend-item"><span class="legend-dot mem"></span> 内存 MB</span>
|
||
</div>
|
||
</div>
|
||
<div class="cards-grid cards-4" id="perf-panels"></div>
|
||
</div>
|
||
`;
|
||
refreshPerf();
|
||
}
|
||
|
||
async function refreshPerf() {
|
||
const [snap, history] = await Promise.all([
|
||
api('/api/performance'),
|
||
api('/api/performance/history'),
|
||
]);
|
||
|
||
if (history && !history.error) {
|
||
for (const [id, h] of Object.entries(history)) {
|
||
if (h && h.length > 0) STATE.perfHistory[id] = h.slice(-60);
|
||
}
|
||
}
|
||
|
||
renderPerfPanels(snap);
|
||
}
|
||
|
||
function renderPerfPanels(snap) {
|
||
const container = document.getElementById('perf-panels');
|
||
if (!container) return;
|
||
|
||
// 数据未变则跳过更新
|
||
var dataHash = simpleHash(JSON.stringify(snap));
|
||
if (STATE.renderHashes['perfPanels'] === dataHash) return;
|
||
STATE.renderHashes['perfPanels'] = dataHash;
|
||
|
||
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ALL_SVC_IDS;
|
||
|
||
// Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值
|
||
const isFirstRender = !container.querySelector('.perf-card');
|
||
|
||
if (isFirstRender) {
|
||
container.innerHTML = ids.map(function(id) {
|
||
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
|
||
return '<div class="card perf-card" style="margin:0" data-svc="' + id + '">' +
|
||
'<div class="card-header">' +
|
||
'<span class="card-title">' + escapeId(id) + '</span>' +
|
||
'<span style="font-size:11px;color:var(--text2)" class="perf-snap-val">CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB</span>' +
|
||
'</div>' +
|
||
'<div class="chart-container">' +
|
||
'<svg viewBox="0 0 300 120" class="chart-svg" id="perf-chart-' + id + '">' +
|
||
drawChart(STATE.perfHistory[id] || []) +
|
||
'</svg>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
} else {
|
||
// 增量更新: 只更新 SVG 图表内容和快照数值
|
||
ids.forEach(function(id) {
|
||
var card = container.querySelector('.perf-card[data-svc="' + id + '"]');
|
||
if (!card) return;
|
||
var s = snap[id] || { pid: null, cpu: 0, mem: 0 };
|
||
var valEl = card.querySelector('.perf-snap-val');
|
||
if (valEl) valEl.textContent = 'CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB';
|
||
var svg = card.querySelector('.chart-svg');
|
||
if (svg) svg.innerHTML = drawChart(STATE.perfHistory[id] || []);
|
||
});
|
||
}
|
||
}
|
||
|
||
function drawChart(history) {
|
||
if (!history || history.length < 2) return '<text x="150" y="65" text-anchor="middle" fill="#8b949e" font-size="11">等待数据...</text>';
|
||
|
||
const w = 300, h = 120, pad = 10;
|
||
const maxCpu = Math.max(5, ...history.map(d => d.cpu));
|
||
const maxMem = Math.max(10, ...history.map(d => d.mem));
|
||
|
||
let cpuArea = '', cpuLine = '', memArea = '', memLine = '';
|
||
|
||
for (let i = 0; i < history.length; i++) {
|
||
const x = pad + (i / Math.max(1, history.length - 1)) * (w - 2*pad);
|
||
const cpuY = h - pad - (history[i].cpu / maxCpu) * (h - 2*pad);
|
||
const memY = h - pad - (history[i].mem / maxMem) * (h - 2*pad);
|
||
cpuLine += `${i===0?'M':'L'} ${x} ${cpuY} `;
|
||
memLine += `${i===0?'M':'L'} ${x} ${memY} `;
|
||
}
|
||
|
||
cpuArea = cpuLine + `L ${pad + (history.length-1)/(Math.max(1,history.length-1))*(w-2*pad)} ${h-pad} L ${pad} ${h-pad} Z`;
|
||
memArea = memLine + `L ${pad + (history.length-1)/(Math.max(1,history.length-1))*(w-2*pad)} ${h-pad} L ${pad} ${h-pad} Z`;
|
||
|
||
return `
|
||
<path d="${cpuArea}" class="chart-area cpu"/>
|
||
<path d="${cpuLine}" class="chart-line cpu"/>
|
||
<path d="${memArea}" class="chart-area mem"/>
|
||
<path d="${memLine}" class="chart-line mem"/>
|
||
`;
|
||
}
|
||
|
||
// ========== 面板: 数据库监看 ==========
|
||
async function fetchDatabaseStatus() {
|
||
return await api('/api/database/status');
|
||
}
|
||
|
||
async function renderDatabasePanel() {
|
||
const data = await fetchDatabaseStatus();
|
||
|
||
document.getElementById('panel-actions').innerHTML =
|
||
'<button class="btn btn-sm" onclick="refreshDatabasePanel()" id="db-refresh-btn">🔄 刷新</button>' +
|
||
'<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>';
|
||
|
||
const panel = document.getElementById('panel-database');
|
||
if (data.error) {
|
||
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
|
||
STATE.dbInitialized = false;
|
||
return;
|
||
}
|
||
|
||
// 数据未变则跳过 DOM 更新
|
||
var dataHash = simpleHash(JSON.stringify(data));
|
||
if (STATE.renderHashes['database'] === dataHash && STATE.dbInitialized) return;
|
||
STATE.renderHashes['database'] = dataHash;
|
||
|
||
const ports = data.ports || [];
|
||
const allAlive = data.allAlive;
|
||
const aliveCount = data.aliveCount;
|
||
const totalPorts = data.totalPorts;
|
||
const pg = data.pgDetails;
|
||
|
||
// 更新侧边栏数据库徽章
|
||
const badge = document.getElementById('db-badge');
|
||
if (badge) {
|
||
if (allAlive) {
|
||
badge.style.display = 'inline';
|
||
badge.style.color = 'var(--green)';
|
||
} else if (aliveCount > 0) {
|
||
badge.style.display = 'inline';
|
||
badge.style.color = 'var(--yellow)';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新动态元素
|
||
const isFirstRender = !STATE.dbInitialized;
|
||
if (isFirstRender) {
|
||
panel.innerHTML =
|
||
'<!-- 概览 -->' +
|
||
'<div class="card">' +
|
||
'<div class="card-header">' +
|
||
'<span class="card-title">🐳 数据库连接状态</span>' +
|
||
'<span class="badge ' + (allAlive ? 'badge-running' : (aliveCount > 0 ? 'badge-starting' : 'badge-error')) + '" id="db-tunnel-badge">' + (allAlive ? '全部在线' : (aliveCount > 0 ? '部分在线' : '离线')) + '</span>' +
|
||
'</div>' +
|
||
'<div class="db-summary">' +
|
||
'<div class="db-summary-stat">' +
|
||
'<div class="val" id="db-alive-count" style="color:' + (allAlive ? 'var(--green)' : 'var(--red)') + '">' + aliveCount + '/' + totalPorts + '</div>' +
|
||
'<div class="lbl">容器端口通联</div>' +
|
||
'</div>' +
|
||
(pg ?
|
||
'<div class="db-summary-stat">' +
|
||
'<div class="val" id="db-mem-count" style="color:var(--blue)">' + (pg.memories ?? '—') + '</div>' +
|
||
'<div class="lbl">记忆条目 (' + escHtml(pg.database || '') + ')</div>' +
|
||
'</div>'
|
||
: '<div class="db-summary-stat" style="display:none" id="db-mem-stat">' +
|
||
'<div class="val" id="db-mem-count" style="color:var(--blue)">—</div>' +
|
||
'<div class="lbl">记忆条目</div>' +
|
||
'</div>') +
|
||
'<div class="db-summary-stat">' +
|
||
'<div class="val" id="db-check-time" style="color:var(--text2)">' + formatTime(data.timestamp) + '</div>' +
|
||
'<div class="lbl">最后检查时间</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="db-grid" id="db-ports-grid">' +
|
||
ports.map(function(p) {
|
||
return '<div class="db-port-card ' + (p.alive ? 'alive' : 'dead') + '" data-port="' + p.port + '">' +
|
||
'<div class="db-dot"></div>' +
|
||
'<div class="db-info">' +
|
||
'<div class="db-name">' + escHtml(p.name) + '</div>' +
|
||
'<div class="db-port-label">:' + p.port + ' ' + (p.alive ? '✅' : '❌') + '</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('') +
|
||
'</div>' +
|
||
'</div>' +
|
||
|
||
'<!-- 数据库容器控制 -->' +
|
||
'<div class="card">' +
|
||
'<div class="card-header"><span class="card-title">🕹️ 数据库容器控制</span></div>' +
|
||
'<div class="btn-group" style="margin-bottom:8px">' +
|
||
'<button class="btn btn-green btn-sm" id="db-tunnel-start" onclick="dbContainerAction(\'start\')"' + (allAlive ? ' disabled' : '') + '>▶ 启动</button>' +
|
||
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="dbContainerAction(\'stop\')"' + (aliveCount === 0 ? ' disabled' : '') + '>⏹ 停止</button>' +
|
||
'<button class="btn btn-sm" onclick="dbContainerAction(\'restart\')">🔄 重启</button>' +
|
||
'</div>' +
|
||
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (aliveCount > 0 && !allAlive ? 'block' : 'none') + '">⚠️ 部分容器端口不通,请尝试重启 Docker 容器</div>' +
|
||
'<div id="tunnel-log-container" style="display:none">' +
|
||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
|
||
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
|
||
'<button class="btn btn-xs" onclick="document.getElementById(\'tunnel-log-container\').style.display=\'none\'">✕</button>' +
|
||
'</div>' +
|
||
'<div class="tunnel-log" id="tunnel-log"></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
|
||
'<!-- 连接信息 -->' +
|
||
'<div class="card">' +
|
||
'<div class="card-header"><span class="card-title">📋 连接说明</span></div>' +
|
||
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
|
||
'<div>🐳 Docker Compose: <code style="color:var(--text)">docker compose -f docker-compose.dev.db.yml up -d</code></div>' +
|
||
'<div>📁 配置文件: <code style="color:var(--text)">docker-compose.dev.db.yml</code></div>' +
|
||
'<div>💡 所有数据库服务运行在本地 Docker 容器中,端口映射至 <code style="color:var(--text)">localhost</code></div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
STATE.dbInitialized = true;
|
||
} else {
|
||
// Bug 7: 增量更新 — 只更新状态徽章、计数器、端口卡片、检查时间
|
||
var el;
|
||
|
||
// 数据库状态徽章
|
||
el = document.getElementById('db-tunnel-badge');
|
||
if (el) {
|
||
el.className = 'badge ' + (allAlive ? 'badge-running' : (aliveCount > 0 ? 'badge-starting' : 'badge-error'));
|
||
el.textContent = allAlive ? '全部在线' : (aliveCount > 0 ? '部分在线' : '离线');
|
||
}
|
||
|
||
// 端口通联计数
|
||
el = document.getElementById('db-alive-count');
|
||
if (el) {
|
||
el.textContent = aliveCount + '/' + totalPorts;
|
||
el.style.color = allAlive ? 'var(--green)' : 'var(--red)';
|
||
}
|
||
|
||
// 记忆条目
|
||
var memStat = document.getElementById('db-mem-stat');
|
||
el = document.getElementById('db-mem-count');
|
||
if (pg) {
|
||
if (memStat) memStat.style.display = '';
|
||
if (el) el.textContent = pg.memories ?? '—';
|
||
} else {
|
||
if (memStat) memStat.style.display = 'none';
|
||
}
|
||
|
||
// 检查时间
|
||
el = document.getElementById('db-check-time');
|
||
if (el) el.textContent = formatTime(data.timestamp);
|
||
|
||
// 更新端口卡片
|
||
var grid = document.getElementById('db-ports-grid');
|
||
if (grid) {
|
||
ports.forEach(function(p) {
|
||
var card = grid.querySelector('.db-port-card[data-port="' + p.port + '"]');
|
||
if (card) {
|
||
card.className = 'db-port-card ' + (p.alive ? 'alive' : 'dead');
|
||
var lbl = card.querySelector('.db-port-label');
|
||
if (lbl) lbl.textContent = ':' + p.port + ' ' + (p.alive ? '✅' : '❌');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 更新按钮 disable 状态
|
||
el = document.getElementById('db-tunnel-start');
|
||
if (el) el.disabled = !!allAlive;
|
||
el = document.getElementById('db-tunnel-stop');
|
||
if (el) el.disabled = !(aliveCount > 0);
|
||
|
||
// 部分在线警告
|
||
el = document.getElementById('db-zombie-warn');
|
||
if (el) el.style.display = (aliveCount > 0 && !allAlive) ? 'block' : 'none';
|
||
}
|
||
}
|
||
|
||
function refreshDatabasePanel() {
|
||
renderDatabasePanel();
|
||
}
|
||
|
||
async function dbContainerAction(action) {
|
||
var labelMap = { start: '启动', stop: '停止', restart: '重启' };
|
||
var label = labelMap[action] || action;
|
||
showToast('正在' + label + '数据库容器...', 'info');
|
||
const logContainer = document.getElementById('tunnel-log-container');
|
||
const logEl = document.getElementById('tunnel-log');
|
||
logEl.textContent = '执行中...';
|
||
logContainer.style.display = 'block';
|
||
|
||
const data = await api('/api/db/' + action, { method: 'POST' });
|
||
if (data.error && !data.output) {
|
||
logEl.textContent = '错误: ' + data.error;
|
||
showToast('操作失败: ' + data.error, 'error');
|
||
} else {
|
||
logEl.textContent = data.output || data.error || '(无输出)';
|
||
if (data.success) {
|
||
showToast(label + '数据库完成', 'success');
|
||
} else {
|
||
showToast(label + '完成 (查看日志)', 'info');
|
||
}
|
||
setTimeout(refreshDatabasePanel, 1500);
|
||
}
|
||
}
|
||
|
||
// ========== 数据库卡片控制 ==========
|
||
var DB_ACTION_LABELS = {
|
||
start: '启动', stop: '停止', restart: '重启'
|
||
};
|
||
|
||
async function renderDBCard() {
|
||
const data = await api('/api/db/status');
|
||
const badge = document.getElementById('db-status-badge');
|
||
const typeDisplay = document.getElementById('db-type-display');
|
||
const portDisplay = document.getElementById('db-port-display');
|
||
const uptimeDisplay = document.getElementById('db-uptime-display');
|
||
|
||
if (data.error) {
|
||
if (badge) { badge.textContent = '错误'; badge.className = 'badge badge-error'; }
|
||
if (uptimeDisplay) uptimeDisplay.textContent = '错误';
|
||
updateDBCardButtons(true, false);
|
||
return;
|
||
}
|
||
|
||
const online = data.online;
|
||
if (badge) {
|
||
badge.textContent = online ? '🟢 在线' : '🔴 离线';
|
||
badge.className = 'badge ' + (online ? 'badge-running' : 'badge-error');
|
||
}
|
||
if (typeDisplay) typeDisplay.textContent = 'PostgreSQL';
|
||
if (portDisplay) portDisplay.textContent = data.port || 5432;
|
||
if (uptimeDisplay) uptimeDisplay.textContent = online ? '已连接' : '未连接';
|
||
|
||
// 同步按钮 disabled 状态: 在线时禁用启动,离线时禁用停止
|
||
updateDBCardButtons(false, online);
|
||
}
|
||
|
||
function updateDBCardButtons(disabled, online) {
|
||
var startBtn = document.getElementById('db-card-start-btn');
|
||
var stopBtn = document.getElementById('db-card-stop-btn');
|
||
if (disabled) {
|
||
if (startBtn) startBtn.disabled = true;
|
||
if (stopBtn) stopBtn.disabled = true;
|
||
} else {
|
||
if (startBtn) startBtn.disabled = !!online;
|
||
if (stopBtn) stopBtn.disabled = !online;
|
||
}
|
||
}
|
||
|
||
async function controlDB(action) {
|
||
var label = DB_ACTION_LABELS[action] || action;
|
||
showToast('正在' + label + '数据库...', 'info');
|
||
|
||
// 禁用所有按钮防止重复点击
|
||
updateDBCardButtons(true);
|
||
|
||
// 显示日志区域
|
||
var logContainer = document.getElementById('db-card-log-container');
|
||
var logEl = document.getElementById('db-card-log');
|
||
if (!logContainer) {
|
||
// 首次使用时动态创建日志区域
|
||
var dbCard = document.getElementById('db-card');
|
||
if (dbCard) {
|
||
logContainer = document.createElement('div');
|
||
logContainer.id = 'db-card-log-container';
|
||
logContainer.style.cssText = 'margin-top:8px;display:none';
|
||
logContainer.innerHTML =
|
||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
|
||
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
|
||
'<button class="btn btn-xs" onclick="var c=document.getElementById(\'db-card-log-container\');if(c)c.style.display=\'none\'">✕</button>' +
|
||
'</div>' +
|
||
'<div class="tunnel-log" id="db-card-log" style="max-height:120px"></div>';
|
||
dbCard.appendChild(logContainer);
|
||
logEl = document.getElementById('db-card-log');
|
||
}
|
||
}
|
||
if (logContainer) logContainer.style.display = 'block';
|
||
if (logEl) logEl.textContent = '执行中...';
|
||
|
||
var data = await api('/api/db/' + action, { method: 'POST' });
|
||
if (data.error && !data.output) {
|
||
if (logEl) logEl.textContent = '错误: ' + data.error;
|
||
showToast('操作失败: ' + data.error, 'error');
|
||
// 恢复按钮状态 — 重新读取在线状态
|
||
setTimeout(renderDBCard, 1000);
|
||
} else {
|
||
if (logEl) logEl.textContent = data.output || data.error || '(无输出)';
|
||
if (data.success) {
|
||
showToast('数据库' + label + '完成', 'success');
|
||
} else {
|
||
showToast(label + '完成 (查看日志)', 'info');
|
||
}
|
||
// 等待2秒后刷新数据库卡片和仪表盘
|
||
setTimeout(function() {
|
||
renderDBCard();
|
||
// 如果当前在仪表盘面板,刷新仪表盘
|
||
if (STATE.activePanel === 'dashboard') renderDashboard();
|
||
}, 2000);
|
||
}
|
||
}
|
||
|
||
// ========== 面板8: 工具调用记录 ==========
|
||
async function renderToolCallsPanel() {
|
||
var container = document.getElementById('panel-toolCalls');
|
||
|
||
if (!STATE.toolCallsPage) STATE.toolCallsPage = 1;
|
||
if (!STATE.toolCallsFilter) STATE.toolCallsFilter = '';
|
||
if (!STATE.toolCallsAutoRefresh) STATE.toolCallsAutoRefresh = null;
|
||
if (!STATE.toolCallsLimit) STATE.toolCallsLimit = 20;
|
||
|
||
var actionsEl = document.getElementById('panel-actions');
|
||
var autoRefreshOn = STATE.toolCallsAutoRefresh !== null;
|
||
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
|
||
'<input type="checkbox" id="toolcalls-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleToolCallsAutoRefresh(this.checked)">' +
|
||
'自动刷新</label>';
|
||
|
||
var statsData = null;
|
||
try {
|
||
var statsResp = await fetch('/api/tool-calls/stats');
|
||
if (statsResp.ok) statsData = await statsResp.json();
|
||
} catch(e) {}
|
||
|
||
var callsData = null;
|
||
try {
|
||
var params = '?page=' + STATE.toolCallsPage + '&limit=' + STATE.toolCallsLimit;
|
||
if (STATE.toolCallsFilter) params += '&tool_name=' + encodeURIComponent(STATE.toolCallsFilter);
|
||
var callsResp = await fetch('/api/tool-calls' + params);
|
||
if (callsResp.ok) callsData = await callsResp.json();
|
||
} catch(e) {}
|
||
|
||
if (!callsData || callsData.error) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
|
||
(callsData && callsData.error ? escHtml(callsData.error) : '无法连接到 AI-Core 服务,请在「服务管理」中启动 AI-Core') +
|
||
(callsData && callsData.hint ? '<br><small>' + escHtml(callsData.hint) + '</small>' : '') +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
// 数据未变且页码/筛选未变则跳过
|
||
var toolCallsKey = STATE.toolCallsPage + '|' + STATE.toolCallsFilter + '|' + simpleHash(JSON.stringify(callsData));
|
||
if (STATE.renderHashes['toolCalls'] === toolCallsKey) return;
|
||
STATE.renderHashes['toolCalls'] = toolCallsKey;
|
||
|
||
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.timestamp ? new Date(call.timestamp).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.call_id || i);
|
||
|
||
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.timestamp ? new Date(call.timestamp).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;
|
||
|
||
// 恢复展开的调用详情
|
||
Object.keys(STATE.expandedToolCalls).forEach(function(callId) {
|
||
if (STATE.expandedToolCalls[callId]) {
|
||
var expandRow = document.getElementById(callId + '-expand');
|
||
if (expandRow) expandRow.style.display = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
function toggleToolCallExpand(callId) {
|
||
var expandRow = document.getElementById(callId + '-expand');
|
||
if (expandRow) {
|
||
var isExpanded = expandRow.style.display !== 'none';
|
||
if (isExpanded) {
|
||
expandRow.style.display = 'none';
|
||
STATE.expandedToolCalls[callId] = false;
|
||
} else {
|
||
expandRow.style.display = '';
|
||
STATE.expandedToolCalls[callId] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// 数据未变则跳过
|
||
var sttHash = simpleHash(JSON.stringify(data));
|
||
if (STATE.renderHashes['stt'] === sttHash) return;
|
||
STATE.renderHashes['stt'] = sttHash;
|
||
|
||
STATE.sttLogs = data.logs || [];
|
||
updateSTTBadge();
|
||
|
||
// 统计卡片
|
||
var totalLogs = data.total || STATE.sttLogs.length;
|
||
var successCount = 0, errorCount = 0, totalDurationMs = 0;
|
||
STATE.sttLogs.forEach(function(e) {
|
||
if (e.status === 'success') successCount++;
|
||
else errorCount++;
|
||
totalDurationMs += (e.durationMs || 0);
|
||
});
|
||
var avgDurationMs = STATE.sttLogs.length > 0 ? Math.round(totalDurationMs / STATE.sttLogs.length) : 0;
|
||
|
||
var statsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
|
||
'<div class="stat-card accent"><div class="stat-value">' + totalLogs + '</div><div class="stat-label">总请求数</div></div>' +
|
||
'<div class="stat-card green"><div class="stat-value">' + successCount + '</div><div class="stat-label">成功</div></div>' +
|
||
'<div class="stat-card red"><div class="stat-value">' + errorCount + '</div><div class="stat-label">失败</div></div>' +
|
||
'<div class="stat-card blue"><div class="stat-value">' + avgDurationMs + 'ms</div><div class="stat-label">平均处理时间</div></div>' +
|
||
'</div>';
|
||
|
||
// 构建表格
|
||
var tableHtml = '<div class="table-wrap"><table>' +
|
||
'<thead><tr>' +
|
||
'<th style="width:130px;">时间</th>' +
|
||
'<th style="width:60px;">状态</th>' +
|
||
'<th style="width:80px;">音频大小</th>' +
|
||
'<th style="width:70px;">预计时长</th>' +
|
||
'<th style="width:70px;">语言</th>' +
|
||
'<th style="width:80px;">处理时间</th>' +
|
||
'<th>识别结果</th>' +
|
||
'</tr></thead><tbody>';
|
||
|
||
if (STATE.sttLogs.length > 0) {
|
||
for (var i = 0; i < STATE.sttLogs.length; i++) {
|
||
var log = STATE.sttLogs[i];
|
||
var timeStr = log.timestamp ? new Date(log.timestamp).toLocaleString('zh-CN', {hour12: false}) : '—';
|
||
var statusBadgeHtml = log.status === 'success'
|
||
? '<span class="badge badge-running">✓ 成功</span>'
|
||
: '<span class="badge badge-error">✗ 失败</span>';
|
||
var audioSizeMB = log.audioSizeMB || '—';
|
||
var estDuration = log.estimatedDurationSec ? log.estimatedDurationSec + 's' : '—';
|
||
var lang = log.language || '—';
|
||
var durationMs = log.durationMs ? log.durationMs + 'ms' : '—';
|
||
var textDisplay = log.status === 'success'
|
||
? (log.text || '<span style="color:var(--text3);font-style:italic;">(空结果)</span>')
|
||
: ('<span style="color:var(--red);">' + escHtml(log.error || '未知错误') + '</span>');
|
||
var textLenLabel = log.textLength ? ' (' + log.textLength + '字)' : '';
|
||
var rowId = 'stt-row-' + i;
|
||
|
||
tableHtml += '<tr class="stt-row" data-rowid="' + rowId + '" style="cursor:pointer;" onclick="toggleSTTExpand(\'' + rowId + '\')">' +
|
||
'<td style="font-size:11px;color:var(--text2);white-space:nowrap;">' + escHtml(timeStr) + '</td>' +
|
||
'<td>' + statusBadgeHtml + '</td>' +
|
||
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;">' + escHtml(audioSizeMB) + ' MB</td>' +
|
||
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;">' + escHtml(estDuration) + '</td>' +
|
||
'<td style="text-align:center;">' + escHtml(lang) + '</td>' +
|
||
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;color:' + (log.durationMs > 5000 ? 'var(--orange)' : 'var(--text2)') + ';">' + escHtml(durationMs) + '</td>' +
|
||
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;">' + (log.status === 'success' ? escHtml((log.text || '').substring(0, 80)) + textLenLabel : textDisplay) + '</td>' +
|
||
'</tr>';
|
||
|
||
// 展开行: 完整内容
|
||
tableHtml += '<tr class="stt-expand" id="' + rowId + '-expand" style="display:none;">' +
|
||
'<td colspan="7" style="background:var(--bg);padding:12px 24px;">' +
|
||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
|
||
'<div><strong style="color:var(--text2);font-size:11px;">文件名:</strong> <span style="font-size:11px;">' + escHtml(log.filename || '—') + '</span></div>' +
|
||
'<div><strong style="color:var(--text2);font-size:11px;">日志 ID:</strong> <code style="font-size:10px;">' + escHtml(log.id || '—') + '</code></div>' +
|
||
'</div>';
|
||
if (log.status === 'success') {
|
||
tableHtml += '<div style="margin-top:8px;"><strong style="color:var(--text2);font-size:11px;">完整识别结果 (' + (log.textLength || 0) + ' 字):</strong>' +
|
||
'<pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:200px;overflow:auto;white-space:pre-wrap;">' + escHtml(log.text || '') + '</pre></div>';
|
||
} else {
|
||
tableHtml += '<div style="margin-top:8px;"><strong style="color:var(--red);font-size:11px;">错误详情:</strong>' +
|
||
'<pre style="background:var(--red-bg);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:120px;overflow:auto;white-space:pre-wrap;color:var(--red);">' + escHtml(log.error || '未知错误') + '</pre></div>';
|
||
}
|
||
tableHtml += '</td></tr>';
|
||
}
|
||
} else {
|
||
tableHtml += '<tr><td colspan="7" style="text-align:center;color:var(--text3);padding:30px;">暂无语音识别记录</td></tr>';
|
||
}
|
||
|
||
tableHtml += '</tbody></table></div>';
|
||
|
||
// Voice Service 状态提示
|
||
var voiceStatusHtml = '';
|
||
try {
|
||
var voiceStatusResp = await fetch('/api/voice/health');
|
||
if (!voiceStatusResp.ok) {
|
||
voiceStatusHtml = '<div style="margin-top:12px;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--yellow);">' +
|
||
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
|
||
}
|
||
} catch(e) {
|
||
voiceStatusHtml = '<div style="margin-top:12px;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--yellow);">' +
|
||
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
|
||
}
|
||
|
||
// 语音录制测试卡片
|
||
var recorderHtml = buildVoiceRecorderCard();
|
||
|
||
container.innerHTML = statsHtml + recorderHtml + tableHtml + voiceStatusHtml;
|
||
|
||
// 恢复展开的STT行
|
||
Object.keys(STATE.expandedSTTRows).forEach(function(rowId) {
|
||
if (STATE.expandedSTTRows[rowId]) {
|
||
var expandRow = document.getElementById(rowId + '-expand');
|
||
if (expandRow) expandRow.style.display = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- 语音录制测试 ----
|
||
var voiceMediaRecorder = null;
|
||
var voiceAudioChunks = [];
|
||
|
||
function buildVoiceRecorderCard() {
|
||
var isRecording = !!voiceMediaRecorder;
|
||
return '<div class="card" style="margin-bottom:14px">' +
|
||
'<div class="card-header"><span class="card-title">🎙️ 语音录制测试</span>' +
|
||
'<span style="font-size:11px;color:var(--text3)">录音后自动发送到 ASR 模型识别并传入对话</span></div>' +
|
||
'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
|
||
'<button class="btn btn-accent" id="btn-record-start" onclick="startVoiceRecord()" ' + (isRecording ? 'disabled' : '') + '>🎙️ 开始录音</button>' +
|
||
'<button class="btn btn-red" id="btn-record-stop" onclick="stopVoiceRecord()" ' + (isRecording ? '' : 'disabled') + '>⏹️ 停止录音</button>' +
|
||
'<span id="record-status" style="font-size:12px;color:var(--text2)">' + (isRecording ? '🔴 录音中...' : '点击按钮开始录音') + '</span>' +
|
||
'<span id="record-timer" style="font-size:12px;font-family:monospace;color:var(--accent)"></span>' +
|
||
'</div>' +
|
||
'<div id="voice-result" style="margin-top:10px;font-size:12px;color:var(--text2)"></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
var voiceRecordTimer = null;
|
||
var voiceRecordSeconds = 0;
|
||
|
||
async function startVoiceRecord() {
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||
alert('当前浏览器不支持录音功能');
|
||
return;
|
||
}
|
||
try {
|
||
var stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
voiceAudioChunks = [];
|
||
voiceMediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm' });
|
||
voiceMediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) voiceAudioChunks.push(e.data); };
|
||
voiceMediaRecorder.onstop = function() {
|
||
stream.getTracks().forEach(function(t) { t.stop(); });
|
||
processVoiceRecording();
|
||
};
|
||
voiceMediaRecorder.start();
|
||
voiceRecordSeconds = 0;
|
||
updateVoiceRecordUI(true);
|
||
voiceRecordTimer = setInterval(function() {
|
||
voiceRecordSeconds++;
|
||
var el = document.getElementById('record-timer');
|
||
if (el) el.textContent = voiceRecordSeconds + 's';
|
||
}, 1000);
|
||
} catch(e) {
|
||
alert('无法访问麦克风: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function stopVoiceRecord() {
|
||
if (voiceMediaRecorder && voiceMediaRecorder.state === 'recording') {
|
||
voiceMediaRecorder.stop();
|
||
}
|
||
if (voiceRecordTimer) { clearInterval(voiceRecordTimer); voiceRecordTimer = null; }
|
||
updateVoiceRecordUI(false);
|
||
}
|
||
|
||
function updateVoiceRecordUI(isRecording) {
|
||
var startBtn = document.getElementById('btn-record-start');
|
||
var stopBtn = document.getElementById('btn-record-stop');
|
||
var statusEl = document.getElementById('record-status');
|
||
var timerEl = document.getElementById('record-timer');
|
||
if (startBtn) startBtn.disabled = isRecording;
|
||
if (stopBtn) stopBtn.disabled = !isRecording;
|
||
if (statusEl) statusEl.innerHTML = isRecording ? '🔴 录音中...' : '点击按钮开始录音';
|
||
if (timerEl) timerEl.textContent = isRecording ? '0s' : '';
|
||
}
|
||
|
||
async function processVoiceRecording() {
|
||
var resultEl = document.getElementById('voice-result');
|
||
if (!resultEl) return;
|
||
if (voiceAudioChunks.length === 0) {
|
||
resultEl.innerHTML = '<span style="color:var(--orange)">录音数据为空</span>';
|
||
return;
|
||
}
|
||
var blob = new Blob(voiceAudioChunks, { type: 'audio/webm' });
|
||
resultEl.innerHTML = '<span style="color:var(--text3)">📤 发送音频到 ASR 引擎 (' + (blob.size / 1024).toFixed(1) + ' KB)...</span>';
|
||
|
||
var reader = new FileReader();
|
||
reader.onload = function() {
|
||
var base64 = reader.result.split(',')[1];
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({
|
||
type: 'voice_input',
|
||
mode: 'voice_msg',
|
||
audio_data: base64,
|
||
session_id: STATE.activeSession || 'default',
|
||
timestamp: Date.now()
|
||
}));
|
||
resultEl.innerHTML = '<span style="color:var(--text3)">⏳ 等待语音识别结果...</span>';
|
||
} else {
|
||
resultEl.innerHTML = '<span style="color:var(--red)">WebSocket 未连接,无法发送语音</span>';
|
||
}
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
}
|
||
|
||
function handleChatLog(entry) {
|
||
if (!entry || !entry.platform) return;
|
||
// Add to local log cache.
|
||
STATE.chatLogs = STATE.chatLogs || {};
|
||
STATE.chatLogs[entry.platform] = STATE.chatLogs[entry.platform] || [];
|
||
var logs = STATE.chatLogs[entry.platform];
|
||
// Dedup by timestamp+content.
|
||
var dup = logs.find(function(l) {
|
||
return l.timestamp === entry.timestamp && l.content === entry.content && l.direction === entry.direction;
|
||
});
|
||
if (dup) return;
|
||
logs.unshift(entry);
|
||
if (logs.length > 500) logs.length = 500;
|
||
// If viewing this platform's detail, prepend to the log container.
|
||
if (STATE.activePanel === 'chatPlatforms' && STATE.chatActivePlatform) {
|
||
// Check if the entry's platform matches the active config's platform type.
|
||
var activeCfg = (STATE.chatConfigs || []).find(function(c) { return c.name === STATE.chatActivePlatform; }) || null;
|
||
var activePtype = (activeCfg && activeCfg.platform) || STATE.chatActivePlatform;
|
||
if (entry.platform === activePtype) {
|
||
prependChatLogEntry(entry);
|
||
}
|
||
}
|
||
}
|
||
|
||
function prependChatLogEntry(l) {
|
||
var container = document.getElementById('chat-log-container');
|
||
if (!container) return;
|
||
// Check if the container is showing an empty state; if so, clear it first.
|
||
var emptyState = container.querySelector('.empty-state');
|
||
if (emptyState) container.innerHTML = '';
|
||
|
||
var filter = STATE.chatLogFilter || 'all';
|
||
if (filter === 'incoming' && l.direction !== 'incoming') return;
|
||
if (filter === 'outgoing' && l.direction !== 'outgoing') return;
|
||
if (filter === 'error' && !l.error && l.success !== false) return;
|
||
|
||
var arrow = l.direction === 'incoming' ? '← 收到' : '→ 发送';
|
||
var color = l.direction === 'incoming' ? 'var(--blue)' : 'var(--green)';
|
||
var time = new Date(l.timestamp).toLocaleString('zh-CN', { hour12: false });
|
||
var content = (l.content || '').length > 300 ? (l.content || '').substring(0, 297) + '...' : (l.content || '');
|
||
var errorTag = (l.error || l.success === false)
|
||
? ' <span style="color:var(--red);cursor:help" title="' + escHtml(l.error || '发送失败') + '">⚠</span>' : '';
|
||
var sender = escHtml(l.sender_name || l.sender_id || '-');
|
||
if (l.sender_name && l.sender_id && l.sender_name !== l.sender_id) {
|
||
sender = escHtml(l.sender_name) + ' <span style="color:var(--text3);font-size:10px">(' + escHtml(l.sender_id) + ')</span>';
|
||
}
|
||
var ctxTag = '';
|
||
if (l.channel_id && l.channel_id.indexOf('private_') !== 0 && l.direction === 'incoming') {
|
||
ctxTag = ' <span style="color:var(--text3);font-size:10px">[群:' + escHtml(l.channel_id) + ']</span> ';
|
||
}
|
||
var entryHTML = '<div style="padding:6px 10px;border-bottom:1px solid var(--border);font-size:12px">' +
|
||
'<span style="color:' + color + ';font-weight:600">' + arrow + '</span> ' +
|
||
'<span style="color:var(--text3)">' + time + '</span> ' +
|
||
ctxTag +
|
||
'<span style="color:var(--text2)">' + sender + '</span> ' +
|
||
'<span>' + escHtml(content) + '</span>' + errorTag +
|
||
'</div>';
|
||
container.insertAdjacentHTML('afterbegin', entryHTML);
|
||
// Keep max 200 entries in DOM.
|
||
while (container.children.length > 200) {
|
||
container.removeChild(container.lastChild);
|
||
}
|
||
}
|
||
|
||
function handleVoiceTranscript(msg) {
|
||
var resultEl = document.getElementById('voice-result');
|
||
if (!resultEl) return;
|
||
if (msg.text) {
|
||
resultEl.innerHTML = '<div style="padding:8px 12px;background:var(--green-bg);border-radius:var(--radius-sm);border:1px solid var(--green)">' +
|
||
'<strong style="color:var(--green)">✅ 识别结果:</strong> ' + escHtml(msg.text) + '</div>';
|
||
}
|
||
}
|
||
|
||
function prependSTTTableRow(entry) {
|
||
var container = document.getElementById('panel-stt');
|
||
var table = container.querySelector('table tbody');
|
||
if (!table) return;
|
||
// 简单刷新整个面板以保持统计准确
|
||
renderSTTPanel();
|
||
}
|
||
|
||
function toggleSTTExpand(rowId) {
|
||
var expandRow = document.getElementById(rowId + '-expand');
|
||
if (expandRow) {
|
||
var isExpanded = expandRow.style.display !== 'none';
|
||
if (isExpanded) {
|
||
expandRow.style.display = 'none';
|
||
STATE.expandedSTTRows[rowId] = false;
|
||
} else {
|
||
expandRow.style.display = '';
|
||
STATE.expandedSTTRows[rowId] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
function refreshSTTPanel() {
|
||
renderSTTPanel();
|
||
}
|
||
|
||
function clearSTTLogs() {
|
||
STATE.sttLogs = [];
|
||
updateSTTBadge();
|
||
if (STATE.activePanel === 'stt') renderSTTPanel();
|
||
}
|
||
|
||
function toggleSTTAutoRefresh(on) {
|
||
if (STATE.sttAutoRefresh) {
|
||
clearInterval(STATE.sttAutoRefresh);
|
||
STATE.sttAutoRefresh = null;
|
||
}
|
||
if (on) {
|
||
STATE.sttAutoRefresh = setInterval(function() {
|
||
if (STATE.activePanel === 'stt') renderSTTPanel();
|
||
}, 5000);
|
||
}
|
||
}
|
||
|
||
// ========== 面板9: 自主思考日志 ==========
|
||
async function renderThinkingPanel() {
|
||
var container = document.getElementById('panel-thinking');
|
||
|
||
if (!STATE.thinkingPage) STATE.thinkingPage = 1;
|
||
if (!STATE.thinkingAutoRefresh) STATE.thinkingAutoRefresh = null;
|
||
if (!STATE.thinkingLimit) STATE.thinkingLimit = 20;
|
||
if (!STATE.thinkingUserId) STATE.thinkingUserId = 'admin';
|
||
|
||
var actionsEl = document.getElementById('panel-actions');
|
||
var autoRefreshOn = STATE.thinkingAutoRefresh !== null;
|
||
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
|
||
'<input type="checkbox" id="thinking-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleThinkingAutoRefresh(this.checked)">' +
|
||
'自动刷新 (5s)</label>';
|
||
|
||
var statsData = null;
|
||
try {
|
||
var statsResp = await fetch('/api/v1/thinking/stats?user_id=' + encodeURIComponent(STATE.thinkingUserId));
|
||
if (statsResp.ok) {
|
||
statsData = await statsResp.json();
|
||
// 兼容旧格式 {stats: {...}} 和新格式 {total_logs: ..., ...}
|
||
if (statsData && statsData.stats) statsData = statsData.stats;
|
||
}
|
||
} catch(e) {}
|
||
|
||
var logsData = null;
|
||
try {
|
||
var params = '?user_id=' + encodeURIComponent(STATE.thinkingUserId) + '&limit=' + STATE.thinkingLimit + '&offset=' + ((STATE.thinkingPage - 1) * STATE.thinkingLimit);
|
||
var logsResp = await fetch('/api/v1/thinking' + params);
|
||
if (logsResp.ok) logsData = await logsResp.json();
|
||
} catch(e) {}
|
||
|
||
if (!logsData || logsData.error) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
|
||
(logsData && logsData.error ? escHtml(logsData.error) : '无法连接到 Memory-Service,请在「服务管理」中启动 Memory-Service') +
|
||
(logsData && logsData.hint ? '<br><small>' + escHtml(logsData.hint) + '</small>' : '') +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
// 数据未变且页码/用户未变则跳过
|
||
var thinkingKey = STATE.thinkingPage + '|' + STATE.thinkingUserId + '|' + simpleHash(JSON.stringify(logsData));
|
||
if (STATE.renderHashes['thinking'] === thinkingKey) return;
|
||
STATE.renderHashes['thinking'] = thinkingKey;
|
||
|
||
var totalLogs = statsData ? statsData.total_logs : (logsData.total || 0);
|
||
var totalToolCalls = statsData ? statsData.total_tool_calls : 0;
|
||
var avgContentLen = statsData ? (statsData.avg_content_length || 0).toFixed(0) : 0;
|
||
var latestAt = statsData ? (statsData.latest_at ? formatTime(statsData.latest_at) : '—') : '—';
|
||
|
||
var statsCardsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
|
||
'<div class="stat-card accent"><div class="stat-value">' + totalLogs + '</div><div class="stat-label">总思考次数</div></div>' +
|
||
'<div class="stat-card green"><div class="stat-value">' + totalToolCalls + '</div><div class="stat-label">总工具调用</div></div>' +
|
||
'<div class="stat-card blue"><div class="stat-value">' + avgContentLen + '</div><div class="stat-label">平均长度(字符)</div></div>' +
|
||
'<div class="stat-card orange"><div class="stat-value">' + latestAt + '</div><div class="stat-label">最近思考</div></div>' +
|
||
'</div>';
|
||
|
||
var filterHtml = '<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap;">' +
|
||
'<button class="btn btn-sm" onclick="refreshThinkingPanel()">🔄 刷新</button>' +
|
||
'<span style="font-size:11px;color:var(--text3);">用户: ' + escHtml(STATE.thinkingUserId) + '</span>' +
|
||
'</div>';
|
||
|
||
var tableHtml = '<div class="table-wrap"><table>' +
|
||
'<thead><tr><th style="width:130px;">时间</th><th style="width:80px;">工具调用</th><th style="width:80px;">内容长度</th><th>内容摘要</th></tr></thead><tbody>';
|
||
|
||
var logs = logsData.logs || [];
|
||
if (logs.length > 0) {
|
||
for (var i = 0; i < logs.length; i++) {
|
||
var log = logs[i];
|
||
var timeStr = log.created_at ? new Date(log.created_at).toLocaleString('zh-CN', {hour12: false}) : '—';
|
||
var toolCallCount = log.tool_call_count || 0;
|
||
var contentLen = log.content_length || 0;
|
||
var summary = log.content || '';
|
||
// Extract first line or first 120 chars as summary
|
||
var firstLine = summary.split('\n')[0];
|
||
if (firstLine.length > 120) firstLine = firstLine.slice(0, 117) + '...';
|
||
var logId = 'th-' + log.id;
|
||
|
||
tableHtml += '<tr class="thinking-row" data-logid="' + logId + '" onclick="toggleThinkingExpand(\'' + logId + '\')" style="cursor:pointer;">' +
|
||
'<td style="font-size:11px;color:var(--text2);">' + escHtml(timeStr) + '</td>' +
|
||
'<td style="text-align:center;"><span class="badge" style="background:' + (toolCallCount > 0 ? 'var(--accent)' : 'var(--bg3)') + ';">' + toolCallCount + '</span></td>' +
|
||
'<td style="text-align:right;font-size:11px;color:var(--text2);">' + contentLen + '</td>' +
|
||
'<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;">' + escHtml(firstLine) + '</td>' +
|
||
'</tr>';
|
||
|
||
// Expand row with full content and tool calls
|
||
var toolCallsDisplay = '无';
|
||
try {
|
||
var parsed = typeof log.tool_calls === 'string' ? JSON.parse(log.tool_calls) : log.tool_calls;
|
||
if (parsed && Array.isArray(parsed) && parsed.length > 0) {
|
||
toolCallsDisplay = '';
|
||
for (var j = 0; j < parsed.length; j++) {
|
||
var tc = parsed[j];
|
||
var tcName = tc.function ? (tc.function.name || '未知') : (tc.name || '未知');
|
||
var tcArgs = tc.function ? (tc.function.arguments || '') : (tc.arguments || '');
|
||
if (typeof tcArgs === 'object') tcArgs = JSON.stringify(tcArgs);
|
||
toolCallsDisplay += '<div style="margin-bottom:6px;padding:6px;background:var(--bg2);border-radius:4px;">' +
|
||
'<strong style="color:var(--accent2);">' + escHtml(tcName) + '</strong>' +
|
||
'<pre style="font-size:10px;margin:4px 0 0;white-space:pre-wrap;color:var(--text2);">' + escHtml(String(tcArgs).slice(0, 500)) + '</pre>' +
|
||
'</div>';
|
||
}
|
||
}
|
||
} catch(e) { toolCallsDisplay = '<span style="color:var(--text3);">解析失败</span>'; }
|
||
|
||
tableHtml += '<tr class="thinking-expand" id="' + logId + '-expand" style="display:none;"><td colspan="4" style="background:var(--bg);padding:12px 24px;">' +
|
||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
|
||
'<div><strong style="color:var(--text2);font-size:11px;">完整思考内容:</strong><pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:300px;overflow:auto;white-space:pre-wrap;">' + escHtml(log.content || '') + '</pre></div>' +
|
||
'<div><strong style="color:var(--text2);font-size:11px;">工具调用详情:</strong><div style="margin-top:4px;max-height:300px;overflow:auto;">' + toolCallsDisplay + '</div></div>' +
|
||
'</div>' +
|
||
'<div style="margin-top:8px;font-size:11px;color:var(--text3);">ID: ' + escHtml(log.id || '—') + ' | User: ' + escHtml(log.user_id || '—') + ' | 内容长度: ' + (log.content_length || 0) + ' | ' + (log.created_at ? new Date(log.created_at).toLocaleString('zh-CN', {hour12: false}) : '') + '</div>' +
|
||
'</td></tr>';
|
||
}
|
||
} else {
|
||
tableHtml += '<tr><td colspan="4" style="text-align:center;color:var(--text3);padding:30px;">暂无自主思考记录</td></tr>';
|
||
}
|
||
|
||
tableHtml += '</tbody></table></div>';
|
||
|
||
var totalPages = Math.max(1, Math.ceil((logsData.total || 0) / STATE.thinkingLimit));
|
||
var paginationHtml = '';
|
||
if (totalPages > 1) {
|
||
paginationHtml = '<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px;font-size:12px;">' +
|
||
'<button class="btn btn-sm" ' + (STATE.thinkingPage <= 1 ? 'disabled' : '') + ' onclick="goThinkingPage(' + (STATE.thinkingPage - 1) + ')">← 上一页</button>' +
|
||
'<span style="color:var(--text2);">第 ' + STATE.thinkingPage + '/' + totalPages + ' 页</span>' +
|
||
'<button class="btn btn-sm" ' + (STATE.thinkingPage >= totalPages ? 'disabled' : '') + ' onclick="goThinkingPage(' + (STATE.thinkingPage + 1) + ')">下一页 →</button>' +
|
||
'</div>';
|
||
}
|
||
|
||
container.innerHTML = statsCardsHtml + filterHtml + tableHtml + paginationHtml;
|
||
|
||
// 恢复之前展开的思考日志
|
||
Object.keys(STATE.expandedThinkingLogs).forEach(function(logId) {
|
||
if (STATE.expandedThinkingLogs[logId]) {
|
||
var expandRow = document.getElementById(logId + '-expand');
|
||
if (expandRow) expandRow.style.display = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
function toggleThinkingExpand(logId) {
|
||
var expandRow = document.getElementById(logId + '-expand');
|
||
if (expandRow) {
|
||
var isExpanded = expandRow.style.display !== 'none';
|
||
if (isExpanded) {
|
||
expandRow.style.display = 'none';
|
||
STATE.expandedThinkingLogs[logId] = false;
|
||
} else {
|
||
expandRow.style.display = '';
|
||
STATE.expandedThinkingLogs[logId] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
function goThinkingPage(page) {
|
||
STATE.thinkingPage = page;
|
||
renderThinkingPanel();
|
||
document.getElementById('panel-thinking').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
function refreshThinkingPanel() {
|
||
renderThinkingPanel();
|
||
}
|
||
|
||
function toggleThinkingAutoRefresh(on) {
|
||
if (STATE.thinkingAutoRefresh) {
|
||
clearInterval(STATE.thinkingAutoRefresh);
|
||
STATE.thinkingAutoRefresh = null;
|
||
}
|
||
if (on) {
|
||
STATE.thinkingAutoRefresh = setInterval(function() {
|
||
if (STATE.activePanel === 'thinking') renderThinkingPanel();
|
||
}, 5000);
|
||
}
|
||
}
|
||
|
||
// ========== 面板10: 记忆时间线 ==========
|
||
function stopTimelineAutoRefresh() {
|
||
if (STATE.timelineAutoRefresh) { clearInterval(STATE.timelineAutoRefresh); STATE.timelineAutoRefresh = null; }
|
||
}
|
||
|
||
function startTimelineAutoRefresh() {
|
||
stopTimelineAutoRefresh();
|
||
STATE.timelineAutoRefresh = setInterval(function() {
|
||
if (STATE.activePanel === 'timeline') renderTimelinePanel();
|
||
}, 30000);
|
||
}
|
||
|
||
function importanceToStarsTimeline(imp) {
|
||
var full = Math.round(imp / 2);
|
||
var empty = 5 - full;
|
||
return '<span style="color:#f59e0b">' + '★'.repeat(full) + '</span><span style="color:var(--text3)">' + '☆'.repeat(empty) + '</span>';
|
||
}
|
||
|
||
async function renderTimelinePanel() {
|
||
var container = document.getElementById('panel-timeline');
|
||
if (!container) return;
|
||
|
||
var actionsEl = document.getElementById('panel-actions');
|
||
var autoRefreshOn = STATE.timelineAutoRefresh !== null;
|
||
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
|
||
'<input type="checkbox" id="timeline-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleTimelineAutoRefresh(this.checked)">' +
|
||
'自动刷新 (30s)</label>' +
|
||
'<button class="btn btn-sm" onclick="renderTimelinePanel()" style="margin-left:8px">🔄 刷新</button>';
|
||
|
||
// 加载数据 (重置 offset)
|
||
STATE.timelineOffset = 0;
|
||
STATE.timelineHasMore = true;
|
||
var userId = STATE.timelineUserId || 'admin';
|
||
var data = await api('/api/memory-timeline?user_id=' + encodeURIComponent(userId) + '&limit=' + STATE.timelineLimit + '&offset=0');
|
||
|
||
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 tlKey = userId + '|' + STATE.timelineFilterType + '|' + simpleHash(JSON.stringify(data));
|
||
if (STATE.renderHashes['timeline'] === tlKey) return;
|
||
STATE.renderHashes['timeline'] = tlKey;
|
||
|
||
var timeline = data.timeline || [];
|
||
var stats = data.stats || {};
|
||
STATE.timelineData = timeline;
|
||
STATE.timelineOffset = timeline.length;
|
||
STATE.timelineHasMore = data.hasMore !== false;
|
||
|
||
// 筛选
|
||
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>';
|
||
}
|
||
|
||
var loadMoreHtml = STATE.timelineHasMore
|
||
? '<div id="timeline-load-more" style="text-align:center;padding:16px;color:var(--text2);cursor:pointer" onclick="loadMoreTimeline()">📜 滚动加载更多...</div>'
|
||
: (timeline.length > 0 ? '<div style="text-align:center;padding:16px;color:var(--text3)">已加载全部条目</div>' : '');
|
||
|
||
container.innerHTML = statsCardsHtml + filterHtml + timelineHtml + loadMoreHtml;
|
||
|
||
// 恢复展开的时间线详情
|
||
Object.keys(STATE.expandedTimelineItems).forEach(function(itemId) {
|
||
if (STATE.expandedTimelineItems[itemId]) {
|
||
var detail = document.getElementById(itemId + '-detail');
|
||
if (detail) detail.classList.add('open');
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadMoreTimeline() {
|
||
if (STATE.timelineLoadingMore || !STATE.timelineHasMore) return;
|
||
STATE.timelineLoadingMore = true;
|
||
|
||
var userId = STATE.timelineUserId || 'admin';
|
||
var data = await api('/api/memory-timeline?user_id=' + encodeURIComponent(userId) + '&limit=' + STATE.timelineLimit + '&offset=' + STATE.timelineOffset);
|
||
|
||
if (data.error) { STATE.timelineLoadingMore = false; return; }
|
||
|
||
var newItems = data.timeline || [];
|
||
if (newItems.length > 0) {
|
||
STATE.timelineData = STATE.timelineData.concat(newItems);
|
||
STATE.timelineOffset += newItems.length;
|
||
STATE.timelineHasMore = data.hasMore !== false;
|
||
appendTimelineItems(newItems);
|
||
} else {
|
||
STATE.timelineHasMore = false;
|
||
}
|
||
|
||
var loadMoreEl = document.getElementById('timeline-load-more');
|
||
if (loadMoreEl) {
|
||
loadMoreEl.outerHTML = STATE.timelineHasMore
|
||
? '<div id="timeline-load-more" style="text-align:center;padding:16px;color:var(--text2);cursor:pointer" onclick="loadMoreTimeline()">📜 滚动加载更多...</div>'
|
||
: '<div style="text-align:center;padding:16px;color:var(--text3)">已加载全部条目</div>';
|
||
}
|
||
|
||
STATE.timelineLoadingMore = false;
|
||
}
|
||
|
||
function appendTimelineItems(items) {
|
||
var container = document.querySelector('#panel-timeline .timeline-container');
|
||
if (!container) return;
|
||
|
||
var html = '';
|
||
var baseIdx = STATE.timelineData.length - items.length;
|
||
for (var i = 0; i < items.length; i++) {
|
||
var item = items[i];
|
||
var itemId = 'tl-' + (baseIdx + 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>';
|
||
}
|
||
html += '<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>';
|
||
}
|
||
container.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
|
||
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');
|
||
STATE.expandedTimelineItems[itemId] = detail.classList.contains('open');
|
||
}
|
||
}
|
||
|
||
function filterTimeline(type) {
|
||
STATE.timelineFilterType = type;
|
||
renderTimelinePanel();
|
||
}
|
||
|
||
function toggleTimelineAutoRefresh(on) {
|
||
if (STATE.timelineAutoRefresh) {
|
||
clearInterval(STATE.timelineAutoRefresh);
|
||
STATE.timelineAutoRefresh = null;
|
||
}
|
||
if (on) {
|
||
STATE.timelineAutoRefresh = setInterval(function() {
|
||
if (STATE.activePanel === 'timeline') renderTimelinePanel();
|
||
}, 30000);
|
||
}
|
||
}
|
||
|
||
// ========== 面板: 第三方聊天配置 ==========
|
||
|
||
var PLATFORM_FIELDS = {
|
||
qq: [
|
||
{ key: 'mode', label: '连接模式', type: 'select', options: [
|
||
{ value: 'client', label: '客户端模式 (主动连接 NapCat)' },
|
||
{ value: 'server', label: '服务端模式 (等待 NapCat 连接)' }
|
||
]},
|
||
{ key: 'remote_url', label: 'NapCat OneBot WS 地址', placeholder: '如: ws://127.0.0.1:10311', showIf: { key: 'mode', value: 'client' } },
|
||
{ key: 'access_token', label: 'Access Token (可选)', placeholder: '留空则不验证' },
|
||
{ key: 'bot_port', label: '本地监听端口', placeholder: '8096', showIf: { key: 'mode', value: 'server' } },
|
||
{ key: 'send_interval_ms', label: '消息发送间隔 (毫秒, 防封控)', placeholder: '2000' },
|
||
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
|
||
],
|
||
telegram: [
|
||
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
|
||
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://your-domain.com' },
|
||
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
|
||
],
|
||
webhook: [
|
||
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://hook.example.com/chat' },
|
||
{ key: 'secret', label: 'Secret Token', placeholder: '(可选)' },
|
||
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: user_abc,user_xyz' }
|
||
],
|
||
wechat: [
|
||
{ key: 'corp_id', label: '企业ID (Corp ID)', placeholder: 'ww...' },
|
||
{ key: 'corp_secret', label: '应用Secret', placeholder: '' },
|
||
{ key: 'agent_id', label: 'Agent ID', placeholder: '1000001' },
|
||
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' },
|
||
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: user_abc,user_xyz' }
|
||
],
|
||
feishu: [
|
||
{ key: 'app_id', label: 'App ID', placeholder: 'cli_...' },
|
||
{ key: 'app_secret', label: 'App Secret', placeholder: '' },
|
||
{ key: 'verification_token', label: 'Verification Token', placeholder: '' },
|
||
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' },
|
||
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: user_abc,user_xyz' }
|
||
],
|
||
discord: [
|
||
{ key: 'bot_token', label: 'Bot Token', placeholder: 'MT...' },
|
||
{ key: 'application_id', label: 'Application ID', placeholder: '123456789...' },
|
||
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
|
||
]
|
||
};
|
||
|
||
var PLATFORM_ICONS = { qq: '🐧', telegram: '✈️', webhook: '🪝', wechat: '💚', feishu: '🕊️', discord: '🎮' };
|
||
var PLATFORM_LABELS = { qq: 'QQ', telegram: 'Telegram', webhook: 'Webhook', wechat: 'WeChat', feishu: 'Feishu', discord: 'Discord' };
|
||
// 已完整实现的平台 (非桩代码)
|
||
var PLATFORM_REAL = { qq: true, telegram: true, webhook: true };
|
||
|
||
// 静态能力声明 (来自各适配器 Capabilities() 返回值,桥接离线时展示)
|
||
var PLATFORM_STATIC_CAPS = {
|
||
qq: { max_message_length: 4500, supports_markdown: false, supports_image: true, supports_voice: false, supports_emoji: true, supports_reaction: false, supports_typing_hint: false, recommend_burst_max: 3 },
|
||
telegram: { max_message_length: 4096, supports_markdown: true, supports_image: true, supports_voice: true, supports_emoji: true, supports_reaction: false, supports_typing_hint: true, recommend_burst_max: 5 },
|
||
webhook: { max_message_length: 4000, supports_markdown: true, supports_image: true, supports_voice: true, supports_emoji: true, supports_reaction: false, supports_typing_hint: false, recommend_burst_max: 3 },
|
||
wechat: { max_message_length: 2048, supports_markdown: false, supports_image: true, supports_voice: true, supports_emoji: false, supports_reaction: false, supports_typing_hint: false, recommend_burst_max: 3 },
|
||
feishu: { max_message_length: 30000, supports_markdown: true, supports_image: true, supports_voice: false, supports_emoji: true, supports_reaction: true, supports_typing_hint: false, recommend_burst_max: 5 },
|
||
discord: { max_message_length: 2000, supports_markdown: true, supports_image: true, supports_voice: false, supports_emoji: true, supports_reaction: true, supports_typing_hint: true, recommend_burst_max: 3 }
|
||
};
|
||
|
||
function startChatAutoRefresh() {
|
||
stopChatAutoRefresh();
|
||
// Periodic refresh for overview + connection status (logs are now real-time via WebSocket).
|
||
STATE.chatConfigsAutoRefresh = setInterval(function() {
|
||
if (STATE.activePanel === 'chatPlatforms') {
|
||
loadChatOverview();
|
||
if (STATE.chatActivePlatform) {
|
||
loadChatPlatformInfo(STATE.chatActivePlatform);
|
||
}
|
||
}
|
||
}, 10000);
|
||
}
|
||
|
||
function stopChatAutoRefresh() {
|
||
if (STATE.chatConfigsAutoRefresh) { clearInterval(STATE.chatConfigsAutoRefresh); STATE.chatConfigsAutoRefresh = null; }
|
||
}
|
||
|
||
// ---- 概览栏 ----
|
||
|
||
async function loadChatOverview() {
|
||
var bar = document.getElementById('chat-overview-bar');
|
||
if (!bar) return;
|
||
// 并行获取平台状态 + 配置列表
|
||
var [platResp, cfgResp] = await Promise.all([
|
||
api('/api/chat-platforms/platforms').catch(function() { return { error: true }; }),
|
||
api('/api/chat-platforms/configs').catch(function() { return { error: true }; })
|
||
]);
|
||
var platforms = platResp.platforms || [];
|
||
var configs = cfgResp.configs || [];
|
||
var bridgeDown = !!platResp.error;
|
||
STATE.chatPlatforms = platforms;
|
||
STATE.chatConfigs = configs;
|
||
|
||
var connected = 0, realCount = 0;
|
||
platforms.forEach(function(p) {
|
||
if (p.connected) connected++;
|
||
if (PLATFORM_REAL[p.name]) realCount++;
|
||
});
|
||
|
||
bar.innerHTML =
|
||
'<div class="overview-stat"><span class="overview-stat-label">桥接服务</span>' +
|
||
'<span class="overview-stat-value">' + (bridgeDown ? '<span style="color:var(--red)">离线</span>' : '<span style="color:var(--green)">运行中</span>') + '</span></div>' +
|
||
'<div class="overview-stat"><span class="overview-stat-label">已连接</span>' +
|
||
'<span class="overview-stat-value" style="color:' + (connected > 0 ? 'var(--green)' : 'var(--text2)') + '">' + connected + '/' + platforms.length + '</span></div>' +
|
||
'<div class="overview-stat"><span class="overview-stat-label">已实现</span>' +
|
||
'<span class="overview-stat-value">' + realCount + '/6 (3桩)</span></div>' +
|
||
'<div class="overview-stat"><span class="overview-stat-label">身份映射</span>' +
|
||
'<span class="overview-stat-value" id="ov-ident-count">—</span></div>';
|
||
// 异步加载身份数量
|
||
api('/api/chat-platforms/identities').then(function(d) {
|
||
var el = document.getElementById('ov-ident-count');
|
||
if (!el) return;
|
||
var total = 0;
|
||
if (d && !d.error) { for (var k in d) { if (d.hasOwnProperty(k)) total += d[k].length; } }
|
||
el.textContent = total;
|
||
}).catch(function() {});
|
||
}
|
||
|
||
// ---- 列表视图 ----
|
||
|
||
function renderChatPlatformsPanel() {
|
||
if (STATE.chatActivePlatform) { renderChatPlatformDetail(STATE.chatActivePlatform); return; }
|
||
STATE.chatLogFilter = 'all';
|
||
var panel = document.getElementById('panel-chatPlatforms');
|
||
panel.innerHTML =
|
||
'<div class="card" style="margin-bottom:14px"><div class="card-body" id="chat-overview-bar" style="display:flex;gap:24px;flex-wrap:wrap;padding:12px 16px">' +
|
||
'<div class="empty-state">加载中...</div></div></div>' +
|
||
'<div class="card"><div class="card-header"><span class="card-title">🔗 平台配置列表</span>' +
|
||
'<button class="btn btn-sm btn-accent" onclick="showChatConfigForm()">+ 添加配置</button></div>' +
|
||
'<div class="table-wrap"><table id="chat-configs-table"><thead><tr>' +
|
||
'<th>平台</th><th>状态</th><th>能力</th><th>关键配置</th><th>更新时间</th><th>操作</th>' +
|
||
'</tr></thead><tbody id="chat-configs-tbody">' +
|
||
'<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>加载中...</div></td></tr></tbody></table></div></div>';
|
||
document.getElementById('panel-actions').innerHTML =
|
||
'<button class="btn btn-sm" onclick="refreshChatAll()">🔄 刷新全部</button>' +
|
||
'<button class="btn btn-sm" onclick="loadChatIdentities()">👤 身份映射</button>' +
|
||
'<button class="btn btn-sm" onclick="showBlocklistSettings()">🚫 黑白名单</button>';
|
||
loadChatOverview();
|
||
renderChatConfigsTable();
|
||
}
|
||
|
||
function refreshChatAll() {
|
||
loadChatOverview();
|
||
renderChatConfigsTable();
|
||
}
|
||
|
||
function renderChatConfigsTable() {
|
||
var tbody = document.getElementById('chat-configs-tbody');
|
||
if (!tbody) return;
|
||
// 优先使用刚从 loadChatOverview 拉到的数据
|
||
var configs = STATE.chatConfigs;
|
||
if (!configs || configs.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>暂无配置,点击「添加配置」创建</div></td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = configs.map(function(c) {
|
||
var ptype = c.platform || c.name;
|
||
var icon = PLATFORM_ICONS[ptype] || '🔗';
|
||
var pfx = (c.platform && c.platform !== c.name) ? ('<span style="color:var(--text3);font-size:10px">' + escHtml(c.platform) + '/</span>') : '';
|
||
var label = c.label || (PLATFORM_LABELS[ptype] ? (PLATFORM_LABELS[ptype] + ' (' + escHtml(c.name) + ')') : c.name);
|
||
var isReal = PLATFORM_REAL[ptype];
|
||
var implBadge = isReal ? '<span class="badge badge-running" title="完整实现">✅</span>'
|
||
: '<span class="badge badge-stopped" title="桩代码 (待开发)" style="opacity:.7">🔧 桩</span>';
|
||
var connBadge = c.connected
|
||
? '<span class="badge badge-running">● 已连接</span>'
|
||
: '<span class="badge badge-stopped">○ 未连接</span>';
|
||
var enabledBadge = c.enabled !== false
|
||
? '<span class="badge badge-running">启用</span>'
|
||
: '<span class="badge badge-stopped">禁用</span>';
|
||
var keys = (c.fields && Object.keys(c.fields).length > 0)
|
||
? Object.keys(c.fields).map(function(k) { return k + '=' + (c.fields[k] ? '***' : '(空)'); }).join(', ')
|
||
: '—';
|
||
var capsHTML = buildCapsHTML(ptype);
|
||
if (!capsHTML) capsHTML = '<span style="color:var(--text3)">—</span>';
|
||
var updated = c.updated_at ? timeAgo(c.updated_at) : '—';
|
||
return '<tr style="cursor:pointer" onclick="editChatConfig(\'' + escHtml(c.name) + '\')">' +
|
||
'<td><strong>' + pfx + icon + ' ' + escHtml(label) + '</strong> ' + implBadge + '</td>' +
|
||
'<td>' + enabledBadge + ' ' + connBadge + '</td>' +
|
||
'<td style="font-size:11px">' + capsHTML + '</td>' +
|
||
'<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(keys) + '</td>' +
|
||
'<td>' + updated + '</td>' +
|
||
'<td><div class="btn-group" onclick="event.stopPropagation()">' +
|
||
'<button class="btn btn-xs" onclick="editChatConfig(\'' + escHtml(c.name) + '\')">✏️ 编辑</button>' +
|
||
'<button class="btn btn-xs btn-red" onclick="deleteChatConfig(\'' + escHtml(c.name) + '\')">🗑</button>' +
|
||
'</div></td></tr>';
|
||
}).join('');
|
||
}
|
||
|
||
function buildCapsHTML(name) {
|
||
var caps = (STATE.chatCaps && STATE.chatCaps[name]) || PLATFORM_STATIC_CAPS[name];
|
||
if (!caps) return '';
|
||
var items = [];
|
||
if (caps.supports_markdown) items.push('<span title="支持 Markdown">📝</span>');
|
||
if (caps.supports_image) items.push('<span title="支持图片">🖼️</span>');
|
||
if (caps.supports_voice) items.push('<span title="支持语音">🎤</span>');
|
||
if (caps.supports_emoji) items.push('<span title="支持表情">😊</span>');
|
||
if (caps.supports_reaction) items.push('<span title="支持回应">👍</span>');
|
||
if (caps.supports_typing_hint) items.push('<span title="支持输入状态">⌨️</span>');
|
||
items.push('<span style="color:var(--text3)">' + caps.max_message_length + '字</span>');
|
||
return items.join(' ');
|
||
}
|
||
function refreshChatConfigs() { loadChatOverview(); renderChatConfigsTable(); }
|
||
|
||
function showChatConfigForm() {
|
||
var panel = document.getElementById('panel-chatPlatforms');
|
||
var options = ['qq', 'telegram', 'webhook', 'wechat', 'feishu', 'discord'];
|
||
panel.innerHTML = '<div class="card"><div class="card-header"><span class="card-title">➕ 选择要配置的平台</span>' +
|
||
'<button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 取消</button></div>' +
|
||
'<div class="cards-grid cards-3">' +
|
||
options.map(function(p) {
|
||
var isReal = PLATFORM_REAL[p];
|
||
return '<div class="card" style="cursor:pointer;text-align:center;padding:20px" onclick="startNewConfig(\'' + p + '\')">' +
|
||
'<div style="font-size:32px;margin-bottom:8px">' + (PLATFORM_ICONS[p] || '🔗') + '</div>' +
|
||
'<div style="font-weight:600">' + (PLATFORM_LABELS[p] || p) + '</div>' +
|
||
'<div style="font-size:10px;color:var(--text3);margin-top:4px">' + (isReal ? '完整实现' : '桩代码') + '</div></div>';
|
||
}).join('') + '</div></div>';
|
||
document.getElementById('panel-actions').innerHTML = '';
|
||
}
|
||
|
||
function startNewConfig(name) {
|
||
if (name === 'qq') { showNewQQConfigDialog(); return; }
|
||
STATE.chatActivePlatform = name;
|
||
renderChatPlatformsPanel();
|
||
}
|
||
|
||
function showNewQQConfigDialog() {
|
||
var defaultName = 'qq-' + Date.now().toString(36);
|
||
var panel = document.getElementById('panel-chatPlatforms');
|
||
panel.innerHTML =
|
||
'<div class="card"><div class="card-header"><span class="card-title">🐧 新建 QQ 配置</span>' +
|
||
'<button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 取消</button></div>' +
|
||
'<div class="card-body">' +
|
||
'<div class="form-group"><label>配置名称</label>' +
|
||
'<input type="text" id="new-qq-name" value="' + defaultName + '" placeholder="如: qq-home, qq-work"></div>' +
|
||
'<div class="form-group"><label>连接模式</label>' +
|
||
'<select id="new-qq-mode" style="width:100%;padding:8px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
|
||
'<option value="client">客户端模式 (主动连接 NapCat)</option>' +
|
||
'<option value="server">服务端模式 (等待 NapCat 连接)</option></select></div>' +
|
||
'<div class="btn-group" style="margin-top:12px">' +
|
||
'<button class="btn btn-accent" onclick="createQQConfig()">创建配置</button></div></div></div>';
|
||
document.getElementById('panel-actions').innerHTML = '';
|
||
}
|
||
|
||
function createQQConfig() {
|
||
var nameEl = document.getElementById('new-qq-name');
|
||
var modeEl = document.getElementById('new-qq-mode');
|
||
var name = (nameEl && nameEl.value.trim()) || ('qq-' + Date.now().toString(36));
|
||
var mode = modeEl ? modeEl.value : 'client';
|
||
var newCfg = { name: name, platform: 'qq', enabled: true, label: '', fields: { mode: mode }, connected: false };
|
||
var configs = STATE.chatConfigs || [];
|
||
configs.push(newCfg);
|
||
STATE.chatConfigs = configs;
|
||
STATE.chatActivePlatform = name;
|
||
renderChatPlatformsPanel();
|
||
}
|
||
|
||
function editChatConfig(name) {
|
||
STATE.chatActivePlatform = name;
|
||
STATE.chatLogFilter = 'all';
|
||
renderChatPlatformsPanel();
|
||
}
|
||
|
||
// ---- 平台详情页 ----
|
||
|
||
async function loadChatPlatformInfo(name) {
|
||
var data = await api('/api/chat-platforms/platforms/' + encodeURIComponent(name)).catch(function() { return { error: true }; });
|
||
STATE.chatBridgeDown = !!data.error;
|
||
// 桥接服务离线提示
|
||
var banner = document.getElementById('chat-bridge-banner');
|
||
if (banner) {
|
||
banner.innerHTML = data.error
|
||
? '<div class="card" style="border-color:var(--yellow);background:var(--yellow-bg);padding:12px 16px">' +
|
||
'<strong>⚠️ 平台桥接服务未运行</strong> — 以下配置和日志依赖 platform-bridge 服务。' +
|
||
'请在 <a href="javascript:switchPanel(\'services\')" style="color:var(--accent);text-decoration:underline">服务管理</a> 中启动 platform-bridge 或将其加入 docker-compose.yml。</div>'
|
||
: '';
|
||
}
|
||
// 更新能力缓存
|
||
STATE.chatCaps = STATE.chatCaps || {};
|
||
if (!data.error) {
|
||
STATE.chatCaps[name] = data.capabilities;
|
||
STATE.chatPlatformInfo = STATE.chatPlatformInfo || {};
|
||
STATE.chatPlatformInfo[name] = data;
|
||
}
|
||
// 更新页面中的状态指示
|
||
var statusEl = document.getElementById('platform-status-indicator');
|
||
if (statusEl && !data.error) {
|
||
var conn = data.connected;
|
||
statusEl.innerHTML = conn
|
||
? '<span class="badge badge-running">● 已连接</span>'
|
||
: '<span class="badge badge-stopped">○ 未连接</span>';
|
||
}
|
||
var cfgForCaps = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
|
||
var ptypeForCaps = (cfgForCaps && cfgForCaps.platform) || name;
|
||
var capsHTML = buildCapsHTML(ptypeForCaps);
|
||
var capsEl = document.getElementById('platform-caps');
|
||
if (capsEl) capsEl.innerHTML = capsHTML || '<span style="color:var(--text3)">—</span>';
|
||
}
|
||
|
||
function renderChatPlatformDetail(name) {
|
||
var cfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
|
||
STATE.chatLogFilter = STATE.chatLogFilter || 'all';
|
||
var ptype = (cfg && cfg.platform) || name;
|
||
var icon = PLATFORM_ICONS[ptype] || '🔗';
|
||
var isReal = PLATFORM_REAL[ptype];
|
||
var panel = document.getElementById('panel-chatPlatforms');
|
||
var logLimit = STATE.chatLogLimit || 100;
|
||
var filterOpts = '<option value="all"' + (STATE.chatLogFilter === 'all' ? ' selected' : '') + '>全部</option>' +
|
||
'<option value="incoming"' + (STATE.chatLogFilter === 'incoming' ? ' selected' : '') + '>← 收到</option>' +
|
||
'<option value="outgoing"' + (STATE.chatLogFilter === 'outgoing' ? ' selected' : '') + '>→ 发送</option>' +
|
||
'<option value="error"' + (STATE.chatLogFilter === 'error' ? ' selected' : '') + '>⚠ 错误</option>';
|
||
panel.innerHTML =
|
||
'<div style="margin-bottom:14px"><button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 返回列表</button></div>' +
|
||
'<div id="chat-bridge-banner" style="margin-bottom:14px"></div>' +
|
||
// 状态卡片
|
||
'<div class="card" style="margin-bottom:14px"><div class="card-header"><span class="card-title">' + icon + ' ' + (ptype !== name ? escHtml(ptype) + '/' : '') + escHtml(name) + ' 状态</span></div>' +
|
||
'<div class="card-body" style="display:flex;gap:24px;flex-wrap:wrap;align-items:center">' +
|
||
'<div><span style="color:var(--text3)">连接: </span><span id="platform-status-indicator">' +
|
||
(cfg && cfg.connected ? '<span class="badge badge-running">● 已连接</span>' : '<span class="badge badge-stopped">○ 未连接</span>') + '</span></div>' +
|
||
'<div><span style="color:var(--text3)">实现: </span>' + (isReal ? '<span class="badge badge-running">完整实现</span>' : '<span class="badge badge-stopped" style="opacity:.7">🔧 桩代码 (待开发)</span>') + '</div>' +
|
||
'<div><span style="color:var(--text3)">能力: </span><span id="platform-caps">' + (buildCapsHTML(ptype) || '<span style="color:var(--text3)">—</span>') + '</span></div>' +
|
||
'<div style="margin-left:auto"><button class="btn btn-xs" onclick="loadChatPlatformInfo(\'' + escHtml(name) + '\')">🔄 刷新状态</button></div>' +
|
||
'</div></div>' +
|
||
// 配置 + 身份映射 并排
|
||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px">' +
|
||
'<div class="card"><div class="card-header"><span class="card-title">⚙️ 配置</span><span id="cfg-save-status"></span></div>' +
|
||
'<div class="card-body" id="chat-config-form"></div></div>' +
|
||
'<div class="card" id="chat-identity-card"><div class="card-header"><span class="card-title">👤 身份映射</span></div>' +
|
||
'<div class="card-body" id="chat-identity-body"><div class="empty-state">加载中...</div></div></div>' +
|
||
'</div>' +
|
||
// 消息日志
|
||
'<div class="card"><div class="card-header"><span class="card-title">📋 消息日志 (最近 ' + logLimit + ' 条)</span>' +
|
||
'<div class="btn-group">' +
|
||
'<select id="chat-log-filter" onchange="STATE.chatLogFilter=this.value;refreshChatLogs(\'' + escHtml(name) + '\')" ' +
|
||
'style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' + filterOpts + '</select>' +
|
||
'<select id="chat-log-limit" onchange="STATE.chatLogLimit=parseInt(this.value);refreshChatLogs(\'' + escHtml(name) + '\')" ' +
|
||
'style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
|
||
'<option value="50"' + (logLimit === 50 ? ' selected' : '') + '>50条</option><option value="100"' + (logLimit === 100 ? ' selected' : '') + '>100条</option><option value="200"' + (logLimit === 200 ? ' selected' : '') + '>200条</option><option value="500"' + (logLimit === 500 ? ' selected' : '') + '>500条</option></select>' +
|
||
'<button class="btn btn-xs" onclick="refreshChatLogs(\'' + escHtml(name) + '\')">🔄 刷新</button></div></div>' +
|
||
'<div id="chat-log-container" style="max-height:400px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px">' +
|
||
'<div class="empty-state"><div class="icon">📝</div>加载中...</div></div></div>';
|
||
document.getElementById('panel-actions').innerHTML = '';
|
||
renderChatConfigForm(name, cfg);
|
||
loadChatPlatformInfo(name);
|
||
loadChatIdentitiesForPlatform(name);
|
||
refreshChatLogs(name);
|
||
}
|
||
|
||
function renderChatConfigForm(name, cfg) {
|
||
var platformType = (cfg && cfg.platform) || name;
|
||
var fields = PLATFORM_FIELDS[platformType] || [];
|
||
var container = document.getElementById('chat-config-form');
|
||
if (!container) return;
|
||
var currentFields = (cfg && cfg.fields) || {};
|
||
var enabled = cfg ? (cfg.enabled !== false) : true;
|
||
var fieldsHTML = fields.map(function(f) {
|
||
var val = currentFields[f.key] || '';
|
||
var display = '';
|
||
if (f.showIf) {
|
||
var condVal = currentFields[f.showIf.key] || '';
|
||
if (condVal !== f.showIf.value) display = 'display:none';
|
||
}
|
||
if (f.type === 'select') {
|
||
return '<div class="form-group" style="' + display + '" data-cond="' + escHtml(f.showIf ? f.showIf.key + ':' + f.showIf.value : '') + '"><label>' + escHtml(f.label) + '</label>' +
|
||
'<select id="cfg-field-' + escHtml(f.key) + '" onchange="onCfgFieldChange(\'' + escHtml(name) + '\')" style="width:100%;padding:8px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
|
||
(f.options || []).map(function(o) {
|
||
return '<option value="' + escHtml(o.value) + '"' + (val === o.value ? ' selected' : '') + '>' + escHtml(o.label) + '</option>';
|
||
}).join('') + '</select></div>';
|
||
}
|
||
return '<div class="form-group" style="' + display + '" data-cond="' + escHtml(f.showIf ? f.showIf.key + ':' + f.showIf.value : '') + '"><label>' + escHtml(f.label) + '</label>' +
|
||
'<input type="text" id="cfg-field-' + escHtml(f.key) + '" value="' + escHtml(val) + '" placeholder="' + escHtml(f.placeholder || '') + '"></div>';
|
||
}).join('');
|
||
container.innerHTML =
|
||
'<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
|
||
'<input type="checkbox" id="cfg-field-enabled" ' + (enabled ? 'checked' : '') + ' style="width:auto"><span>启用此平台</span></label></div>' +
|
||
fieldsHTML +
|
||
'<div class="form-group"><label>显示名称</label>' +
|
||
'<input type="text" id="cfg-field-label" value="' + escHtml((cfg && cfg.label) || '') + '" placeholder="' + escHtml(name) + '"></div>' +
|
||
'<div class="btn-group" style="margin-top:12px"><button class="btn btn-sm btn-accent" onclick="saveChatConfig(\'' + escHtml(name) + '\')">💾 保存配置</button></div>';
|
||
}
|
||
|
||
function onCfgFieldChange(name) {
|
||
var cfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
|
||
var platformType = (cfg && cfg.platform) || name;
|
||
var fieldDefs = PLATFORM_FIELDS[platformType] || [];
|
||
var tempFields = {};
|
||
fieldDefs.forEach(function(f) {
|
||
var el = document.getElementById('cfg-field-' + f.key);
|
||
if (el) tempFields[f.key] = el.value;
|
||
});
|
||
var enabledEl = document.getElementById('cfg-field-enabled');
|
||
var labelEl = document.getElementById('cfg-field-label');
|
||
var tempCfg = { name: name, platform: (cfg && cfg.platform) || name, fields: tempFields, enabled: enabledEl ? enabledEl.checked : true, label: labelEl ? labelEl.value : '' };
|
||
renderChatConfigForm(name, tempCfg);
|
||
}
|
||
|
||
async function saveChatConfig(name) {
|
||
var cfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
|
||
var platformType = (cfg && cfg.platform) || name;
|
||
var fields = {};
|
||
var fieldDefs = PLATFORM_FIELDS[platformType] || [];
|
||
fieldDefs.forEach(function(f) {
|
||
var el = document.getElementById('cfg-field-' + f.key);
|
||
if (el) fields[f.key] = el.value;
|
||
});
|
||
var enabledEl = document.getElementById('cfg-field-enabled');
|
||
var enabled = enabledEl ? enabledEl.checked : true;
|
||
var labelEl = document.getElementById('cfg-field-label');
|
||
var label = labelEl ? labelEl.value : '';
|
||
var data = await api('/api/chat-platforms/configs/' + encodeURIComponent(name), {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name: name, platform: platformType, enabled: enabled, label: label, fields: fields })
|
||
});
|
||
if (data.error) { showToast('保存失败: ' + data.error, 'error'); return; }
|
||
showToast('配置已保存 (实时生效)', 'success');
|
||
// Update STATE with saved config so re-render preserves form inputs.
|
||
var oldCfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
|
||
var savedCfg = { name: name, platform: platformType, enabled: enabled, label: label, fields: fields, connected: (oldCfg && oldCfg.connected) || false };
|
||
var configs = STATE.chatConfigs || [];
|
||
var idx = configs.findIndex(function(c) { return c.name === name; });
|
||
if (idx >= 0) { configs[idx] = savedCfg; } else { configs.push(savedCfg); }
|
||
STATE.chatConfigs = configs;
|
||
renderChatPlatformDetail(name);
|
||
}
|
||
|
||
async function deleteChatConfig(name) {
|
||
if (!confirm('确认删除 ' + name + ' 的配置?')) return;
|
||
var data = await api('/api/chat-platforms/configs/' + encodeURIComponent(name), { method: 'DELETE' });
|
||
if (data.error) { showToast('删除失败: ' + data.error, 'error'); return; }
|
||
showToast('配置已删除', 'success');
|
||
STATE.chatActivePlatform = null;
|
||
loadChatOverview();
|
||
renderChatPlatformsPanel();
|
||
}
|
||
|
||
// ---- 身份映射 ----
|
||
|
||
async function loadChatIdentities() {
|
||
var data = await api('/api/chat-platforms/identities').catch(function() { return { error: true }; });
|
||
STATE.chatIdentities = data;
|
||
// 以弹窗形式展示
|
||
var html = '<div class="card"><div class="card-header"><span class="card-title">👤 身份映射列表</span>' +
|
||
'<button class="btn btn-sm" onclick="this.closest(\'.card\').remove()">✕ 关闭</button></div>' +
|
||
'<div class="card-body">';
|
||
if (data.error) {
|
||
html += '<div class="empty-state"><div class="icon">⚠️</div>桥接服务不可达</div>';
|
||
} else {
|
||
var total = 0;
|
||
for (var plat in data) { if (data.hasOwnProperty(plat)) total += data[plat].length; }
|
||
if (total === 0) {
|
||
html += '<div class="empty-state"><div class="icon">👤</div>暂无身份映射 (在 platform-bridge 启动时通过环境变量 QQ_ADMIN_UID / TELEGRAM_ADMIN_UID 预设)</div>';
|
||
} else {
|
||
html += '<div class="table-wrap"><table><thead><tr><th>平台</th><th>平台UID</th><th>Cyrene用户</th><th>昵称</th><th>权限级别</th></tr></thead><tbody>';
|
||
for (var plat in data) {
|
||
if (!data.hasOwnProperty(plat)) continue;
|
||
(data[plat] || []).forEach(function(id) {
|
||
var permBadge = id.permission_level === 'admin'
|
||
? '<span class="badge badge-running">管理员</span>'
|
||
: '<span class="badge">' + escHtml(id.permission_level || '—') + '</span>';
|
||
html += '<tr><td>' + (PLATFORM_ICONS[plat] || '') + ' ' + escHtml(plat) + '</td>' +
|
||
'<td><code>' + escHtml(id.platform_uid) + '</code></td>' +
|
||
'<td>' + escHtml(id.cyrene_user_id) + '</td>' +
|
||
'<td>' + escHtml(id.nickname || '—') + '</td>' +
|
||
'<td>' + permBadge + '</td></tr>';
|
||
});
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
}
|
||
html += '</div></div>';
|
||
// 插入到列表上方
|
||
var panel = document.getElementById('panel-chatPlatforms');
|
||
var existing = panel.querySelector('.card:first-child');
|
||
var div = document.createElement('div');
|
||
div.style.cssText = 'margin-bottom:14px';
|
||
div.innerHTML = html;
|
||
panel.insertBefore(div, existing);
|
||
}
|
||
|
||
async function loadChatIdentitiesForPlatform(name) {
|
||
var bodyEl = document.getElementById('chat-identity-body');
|
||
if (!bodyEl) return;
|
||
var cfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
|
||
var ptype = (cfg && cfg.platform) || name;
|
||
var data = await api('/api/chat-platforms/identities').catch(function() { return { error: true }; });
|
||
if (data.error) {
|
||
bodyEl.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>桥接服务不可达</div>';
|
||
return;
|
||
}
|
||
var identities = data[ptype] || [];
|
||
STATE.chatIdentities = data;
|
||
if (identities.length === 0) {
|
||
bodyEl.innerHTML = '<div class="empty-state" style="padding:20px"><div class="icon">👤</div>暂无此平台的身份映射' +
|
||
'<div style="font-size:11px;color:var(--text3);margin-top:4px">通过环境变量 ' + ptype.toUpperCase() + '_ADMIN_UID 预设管理员身份</div></div>';
|
||
return;
|
||
}
|
||
bodyEl.innerHTML = '<div style="max-height:200px;overflow-y:auto">' +
|
||
identities.map(function(id) {
|
||
var permBadge = id.permission_level === 'admin'
|
||
? '<span class="badge badge-running">管理员</span>'
|
||
: '<span class="badge">' + escHtml(id.permission_level || '—') + '</span>';
|
||
return '<div style="padding:8px;border-bottom:1px solid var(--border);font-size:12px;display:flex;justify-content:space-between;align-items:center">' +
|
||
'<div><code>' + escHtml(id.platform_uid) + '</code> → <strong>' + escHtml(id.cyrene_user_id) + '</strong>' +
|
||
(id.nickname ? ' (' + escHtml(id.nickname) + ')' : '') + '</div>' +
|
||
'<div>' + permBadge + '</div></div>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
// ---- 黑名单/白名单设置 ----
|
||
|
||
async function showBlocklistSettings() {
|
||
var panel = document.getElementById('panel-chatPlatforms');
|
||
var data = await api('/api/chat-platforms/settings/blocklist').catch(function() { return null; });
|
||
var settings = (data && !data.error) ? data : { mode: 'blacklist', group_ids: [], user_ids: [] };
|
||
STATE._blocklistSettings = settings;
|
||
var groupIDs = (settings.group_ids || []).join('\n');
|
||
var userIDs = (settings.user_ids || []).join('\n');
|
||
var div = document.createElement('div');
|
||
div.id = 'blocklist-settings-card';
|
||
div.style.cssText = 'margin-bottom:14px';
|
||
div.innerHTML =
|
||
'<div class="card"><div class="card-header"><span class="card-title">🚫 黑名单/白名单设置</span>' +
|
||
'<button class="btn btn-sm" onclick="hideBlocklistSettings()">✕ 关闭</button></div>' +
|
||
'<div class="card-body">' +
|
||
'<div class="form-group"><label>模式</label>' +
|
||
'<select id="blocklist-mode" style="width:100%;padding:8px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
|
||
'<option value="blacklist"' + (settings.mode === 'blacklist' ? ' selected' : '') + '>黑名单模式 (屏蔽列表中的群号/用户)</option>' +
|
||
'<option value="whitelist"' + (settings.mode === 'whitelist' ? ' selected' : '') + '>白名单模式 (仅回复列表中的群号/用户)</option>' +
|
||
'</select></div>' +
|
||
'<div style="color:var(--text3);font-size:11px;margin-bottom:12px">' +
|
||
(settings.mode === 'blacklist'
|
||
? '黑名单模式: 不对名单内的群号或私聊用户进行回复,但消息仍会显示在日志中'
|
||
: '白名单模式: 只对白名单内的群号和私聊用户进行回复,消息仍会显示在日志中') +
|
||
'</div>' +
|
||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">' +
|
||
'<div class="form-group"><label>群号列表 (每行一个)</label>' +
|
||
'<textarea id="blocklist-group-ids" rows="6" style="width:100%;padding:8px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px">' + escHtml(groupIDs) + '</textarea></div>' +
|
||
'<div class="form-group"><label>私聊用户ID列表 (每行一个)</label>' +
|
||
'<textarea id="blocklist-user-ids" rows="6" style="width:100%;padding:8px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px">' + escHtml(userIDs) + '</textarea></div>' +
|
||
'</div>' +
|
||
'<div class="btn-group" style="margin-top:12px">' +
|
||
'<button class="btn btn-sm btn-accent" onclick="saveBlocklistSettings()">💾 保存设置</button>' +
|
||
'<span id="blocklist-save-status" style="margin-left:8px"></span></div>' +
|
||
'</div></div>';
|
||
var existing = panel.querySelector('#blocklist-settings-card');
|
||
if (existing) existing.remove();
|
||
var firstChild = panel.firstChild;
|
||
if (firstChild) { panel.insertBefore(div, firstChild); } else { panel.appendChild(div); }
|
||
// 监听模式切换更新提示文字
|
||
var modeEl = document.getElementById('blocklist-mode');
|
||
if (modeEl) {
|
||
modeEl.addEventListener('change', function() {
|
||
var hint = this.value === 'blacklist'
|
||
? '黑名单模式: 不对名单内的群号或私聊用户进行回复,但消息仍会显示在日志中'
|
||
: '白名单模式: 只对白名单内的群号和私聊用户进行回复,消息仍会显示在日志中';
|
||
var next = this.parentElement.parentElement.querySelector('div[style]');
|
||
if (next) next.textContent = hint;
|
||
});
|
||
}
|
||
}
|
||
|
||
function hideBlocklistSettings() {
|
||
var card = document.getElementById('blocklist-settings-card');
|
||
if (card) card.remove();
|
||
}
|
||
|
||
async function saveBlocklistSettings() {
|
||
var modeEl = document.getElementById('blocklist-mode');
|
||
var groupEl = document.getElementById('blocklist-group-ids');
|
||
var userEl = document.getElementById('blocklist-user-ids');
|
||
var statusEl = document.getElementById('blocklist-save-status');
|
||
var mode = modeEl ? modeEl.value : 'blacklist';
|
||
var groupIDs = (groupEl ? groupEl.value : '').split('\n').map(function(s) { return s.trim(); }).filter(function(s) { return s !== ''; });
|
||
var userIDs = (userEl ? userEl.value : '').split('\n').map(function(s) { return s.trim(); }).filter(function(s) { return s !== ''; });
|
||
var data = await api('/api/chat-platforms/settings/blocklist', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ mode: mode, group_ids: groupIDs, user_ids: userIDs })
|
||
});
|
||
if (data.error) {
|
||
if (statusEl) { statusEl.innerHTML = '<span style="color:var(--red)">保存失败: ' + escHtml(data.error) + '</span>'; }
|
||
return;
|
||
}
|
||
if (statusEl) { statusEl.innerHTML = '<span style="color:var(--green)">已保存</span>'; }
|
||
STATE._blocklistSettings = data.settings || { mode: mode, group_ids: groupIDs, user_ids: userIDs };
|
||
showToast('黑名单/白名单设置已保存', 'success');
|
||
}
|
||
|
||
// ---- 消息日志 ----
|
||
|
||
async function refreshChatLogs(name) {
|
||
var limit = STATE.chatLogLimit || 100;
|
||
var filter = STATE.chatLogFilter || 'all';
|
||
var data = await api('/api/chat-platforms/logs/' + encodeURIComponent(name) + '?limit=' + limit);
|
||
var container = document.getElementById('chat-log-container');
|
||
if (!container) return;
|
||
if (data.error) { container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>'; return; }
|
||
var logs = data.logs || [];
|
||
// 应用过滤
|
||
if (filter === 'incoming') logs = logs.filter(function(l) { return l.direction === 'incoming'; });
|
||
else if (filter === 'outgoing') logs = logs.filter(function(l) { return l.direction === 'outgoing'; });
|
||
else if (filter === 'error') logs = logs.filter(function(l) { return l.error || l.success === false; });
|
||
STATE.chatLogs = STATE.chatLogs || {};
|
||
STATE.chatLogs[name] = logs;
|
||
if (logs.length === 0) { container.innerHTML = '<div class="empty-state"><div class="icon">📝</div>暂无匹配的消息日志</div>'; return; }
|
||
container.innerHTML = logs.map(function(l) {
|
||
var arrow = l.direction === 'incoming' ? '← 收到' : '→ 发送';
|
||
var color = l.direction === 'incoming' ? 'var(--blue)' : 'var(--green)';
|
||
var time = new Date(l.timestamp).toLocaleString('zh-CN', { hour12: false });
|
||
var content = (l.content || '').length > 300 ? (l.content || '').substring(0, 297) + '...' : (l.content || '');
|
||
var errorTag = (l.error || l.success === false)
|
||
? ' <span style="color:var(--red);cursor:help" title="' + escHtml(l.error || '发送失败') + '">⚠</span>' : '';
|
||
// Build sender info: name (id).
|
||
var sender = escHtml(l.sender_name || l.sender_id || '-');
|
||
if (l.sender_name && l.sender_id && l.sender_name !== l.sender_id) {
|
||
sender = escHtml(l.sender_name) + ' <span style="color:var(--text3);font-size:10px">(' + escHtml(l.sender_id) + ')</span>';
|
||
}
|
||
// Build channel context tag for group messages.
|
||
var ctxTag = '';
|
||
if (l.channel_id && l.channel_id.indexOf('private_') !== 0 && l.direction === 'incoming') {
|
||
ctxTag = ' <span style="color:var(--text3);font-size:10px">[群:' + escHtml(l.channel_id) + ']</span> ';
|
||
}
|
||
return '<div style="padding:6px 10px;border-bottom:1px solid var(--border);font-size:12px">' +
|
||
'<span style="color:' + color + ';font-weight:600">' + arrow + '</span> ' +
|
||
'<span style="color:var(--text3)">' + time + '</span> ' +
|
||
ctxTag +
|
||
'<span style="color:var(--text2)">' + sender + '</span> ' +
|
||
'<span>' + escHtml(content) + '</span>' + errorTag +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
// ========== 模型配置管理面板 ==========
|
||
|
||
function renderModelConfigPanel() {
|
||
var panel = document.getElementById('panel-modelConfig');
|
||
var activeTab = STATE.modelConfigTab || 'providers';
|
||
|
||
var tabs = [
|
||
{ id: 'providers', label: '🔌 模型提供商' },
|
||
{ id: 'models', label: '🧠 模型定义' },
|
||
{ id: 'routing', label: '🔀 用途路由' },
|
||
];
|
||
|
||
var tabBar = '<div class="tab-bar" style="margin-bottom:14px;display:flex;gap:6px;flex-wrap:wrap">' +
|
||
tabs.map(function(t) {
|
||
return '<button class="btn btn-sm' + (activeTab === t.id ? ' btn-accent' : '') +
|
||
'" onclick="STATE.modelConfigTab=\'' + t.id + '\';renderModelConfigPanel();">' + t.label + '</button>';
|
||
}).join('') + '</div>';
|
||
|
||
panel.innerHTML = tabBar + '<div id="model-config-content" class="card"><div class="card-body">加载中...</div></div>';
|
||
document.getElementById('panel-actions').innerHTML = '';
|
||
|
||
switch (activeTab) {
|
||
case 'providers': renderProvidersTab(); break;
|
||
case 'models': renderModelsTab(); break;
|
||
case 'routing': renderRoutingTab(); break;
|
||
}
|
||
}
|
||
|
||
// ---- Providers tab ----
|
||
|
||
async function renderProvidersTab() {
|
||
var container = document.getElementById('model-config-content');
|
||
var data = await api('/api/model-config/providers');
|
||
STATE.modelConfigProviders = data.providers || [];
|
||
|
||
var rows = STATE.modelConfigProviders.length === 0
|
||
? '<tr><td colspan="5"><div class="empty-state"><div class="icon">🔌</div>暂无模型提供商,请添加</div></td></tr>'
|
||
: STATE.modelConfigProviders.map(function(p) {
|
||
var updated = p.updated_at ? timeAgo(p.updated_at) : '—';
|
||
return '<tr>' +
|
||
'<td><strong>' + escHtml(p.name) + '</strong></td>' +
|
||
'<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(p.base_url) + '</td>' +
|
||
'<td>' + (p.timeout_sec || '—') + 's</td>' +
|
||
'<td>' + updated + '</td>' +
|
||
'<td><div class="btn-group">' +
|
||
'<button class="btn btn-xs" onclick="showProviderForm(\'' + escHtml(p.name) + '\')">✏️</button>' +
|
||
'<button class="btn btn-xs btn-red" onclick="deleteModelProvider(\'' + escHtml(p.name) + '\')">🗑</button>' +
|
||
'</div></td></tr>';
|
||
}).join('');
|
||
|
||
container.innerHTML =
|
||
'<div class="card-header"><span class="card-title">🔌 模型提供商</span>' +
|
||
'<button class="btn btn-sm btn-accent" onclick="showProviderForm()">+ 添加</button></div>' +
|
||
'<div class="table-wrap"><table><thead><tr>' +
|
||
'<th>名称</th><th>Base URL</th><th>超时</th><th>更新时间</th><th>操作</th>' +
|
||
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||
}
|
||
|
||
var PROVIDER_TEMPLATES = [
|
||
{ name: 'deepseek', label: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.deepseek.com/models' },
|
||
{ name: 'dashscope', label: '阿里百炼 (DashScope)', base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/models' },
|
||
{ name: 'zhipu', label: '智谱 AI (GLM)', base_url: 'https://open.bigmodel.cn/api/paas/v4', timeout_sec: 120, max_retries: 3, models_url: 'https://open.bigmodel.cn/api/paas/v4/models' },
|
||
{ name: 'moonshot', label: 'Moonshot (Kimi)', base_url: 'https://api.moonshot.cn/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.moonshot.cn/v1/models' },
|
||
{ name: 'siliconflow', label: '硅基流动 (SiliconFlow)', base_url: 'https://api.siliconflow.cn/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.siliconflow.cn/v1/models' },
|
||
{ name: 'lingyi', label: '零一万物', base_url: 'https://api.lingyiwanwu.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.lingyiwanwu.com/v1/models' },
|
||
{ name: 'qianfan', label: '百度千帆', base_url: 'https://qianfan.baidubce.com/v2', timeout_sec: 120, max_retries: 3, models_url: 'https://qianfan.baidubce.com/v2/models' },
|
||
{ name: 'xfyun', label: '讯飞星火', base_url: 'https://spark-api-open.xf-yun.com/v1', timeout_sec: 120, max_retries: 3, models_url: '' },
|
||
{ name: 'minimax', label: 'MiniMax', base_url: 'https://api.minimax.chat/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.minimax.chat/v1/models' },
|
||
{ name: 'openai', label: 'OpenAI', base_url: 'https://api.openai.com/v1', timeout_sec: 120, max_retries: 3, models_url: 'https://api.openai.com/v1/models' },
|
||
{ name: 'custom', label: '💡 自定义...', base_url: '', timeout_sec: 120, max_retries: 3, models_url: '' },
|
||
];
|
||
|
||
var MODEL_TEMPLATES = {
|
||
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
||
dashscope: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-max-longcontext', 'qwen-vl-plus', 'qwen-coder-turbo'],
|
||
zhipu: ['glm-4-flash', 'glm-4-plus', 'glm-4-long', 'glm-4v-flash', 'glm-4-air'],
|
||
moonshot: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
|
||
siliconflow: ['Qwen/Qwen3-235B-A22B', 'Qwen/Qwen2.5-72B-Instruct', 'Qwen/Qwen2.5-7B-Instruct', 'deepseek-ai/DeepSeek-V3', 'Pro/THUDM/glm-4-9b-chat'],
|
||
lingyi: ['yi-large', 'yi-medium', 'yi-lightning', 'yi-vision'],
|
||
qianfan: ['ernie-speed-128k', 'ernie-4.0-8k', 'ernie-3.5-8k', 'ernie-speed-pro-128k'],
|
||
xfyun: ['spark-lite', 'spark-pro-128k', 'spark-max', 'spark-4.0-ultra'],
|
||
minimax: ['abab6.5s-chat', 'abab6.5-chat'],
|
||
openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini'],
|
||
};
|
||
|
||
function showProviderForm(name) {
|
||
var existing = null;
|
||
if (name) {
|
||
for (var i = 0; i < STATE.modelConfigProviders.length; i++) {
|
||
if (STATE.modelConfigProviders[i].name === name) { existing = STATE.modelConfigProviders[i]; break; }
|
||
}
|
||
}
|
||
var isEdit = !!existing;
|
||
var formTitle = isEdit ? '✏️ 编辑 ' + escHtml(name) : '➕ 添加模型提供商';
|
||
var defaults = existing || { name: '', base_url: 'https://api.deepseek.com/v1', api_key: '', timeout_sec: 120, max_retries: 3 };
|
||
|
||
var templateOptions = PROVIDER_TEMPLATES.map(function(t) {
|
||
return '<option value="' + t.name + '" data-url="' + escHtml(t.base_url) + '" data-timeout="' + t.timeout_sec + '" data-retries="' + t.max_retries + '">' + escHtml(t.label) + '</option>';
|
||
}).join('');
|
||
|
||
var container = document.getElementById('model-config-content');
|
||
container.innerHTML =
|
||
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
|
||
'<button class="btn btn-sm" onclick="renderProvidersTab()">← 返回</button></div>' +
|
||
'<div class="card-body"><form onsubmit="event.preventDefault();saveProviderForm(\'' + escHtml(name || '') + '\');">' +
|
||
(isEdit ? '' :
|
||
'<div class="form-row"><label>📋 快速模板</label>' +
|
||
'<select id="prov-template" class="input" onchange="applyProviderTemplate(this.value)" style="background:var(--bg3)">' +
|
||
'<option value="">-- 选择提供商模板自动填充 --</option>' + templateOptions + '</select></div>') +
|
||
'<div class="form-row"><label>Provider 名称 ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
|
||
'<input id="prov-name" class="input" value="' + escHtml(defaults.name) + '" ' + (isEdit ? 'readonly' : 'placeholder="如 deepseek, openai"') + ' required></div>' +
|
||
'<div class="form-row"><label>Base URL <span style="color:var(--red)">*</span></label>' +
|
||
'<input id="prov-url" class="input" value="' + escHtml(defaults.base_url) + '" placeholder="https://api.deepseek.com/v1" required></div>' +
|
||
'<div class="form-row"><label>API Key</label>' +
|
||
'<input id="prov-key" class="input" type="password" value="' + escHtml(defaults.api_key || '') + '" placeholder="sk-xxx"></div>' +
|
||
'<div class="form-row" style="display:flex;gap:12px"><div style="flex:1"><label>超时 (秒)</label>' +
|
||
'<input id="prov-timeout" class="input" type="number" value="' + (defaults.timeout_sec || 120) + '"></div>' +
|
||
'<div style="flex:1"><label>最大重试</label>' +
|
||
'<input id="prov-retries" class="input" type="number" value="' + (defaults.max_retries || 3) + '"></div></div>' +
|
||
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
|
||
'</form></div>';
|
||
}
|
||
|
||
function applyProviderTemplate(templateName) {
|
||
if (!templateName || templateName === 'custom') return;
|
||
var sel = document.getElementById('prov-template');
|
||
var opt = sel.options[sel.selectedIndex];
|
||
document.getElementById('prov-name').value = opt.value;
|
||
document.getElementById('prov-url').value = opt.getAttribute('data-url') || '';
|
||
var timeout = parseInt(opt.getAttribute('data-timeout')) || 120;
|
||
var retries = parseInt(opt.getAttribute('data-retries')) || 3;
|
||
var timeoutEl = document.getElementById('prov-timeout');
|
||
var retriesEl = document.getElementById('prov-retries');
|
||
if (timeoutEl && !timeoutEl.value) timeoutEl.value = timeout;
|
||
if (retriesEl && !retriesEl.value) retriesEl.value = retries;
|
||
}
|
||
|
||
function updateModelTemplateOptions() {
|
||
var provider = document.getElementById('model-provider').value;
|
||
var area = document.getElementById('model-template-area');
|
||
if (!area) return;
|
||
STATE.fetchedModels = [];
|
||
var models = MODEL_TEMPLATES[provider] || [];
|
||
area.innerHTML =
|
||
'<select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
|
||
'<option value="">-- 选择模型模板 / 查询获取 --</option>' +
|
||
models.map(function(m) { return '<option value="' + escHtml(m) + '">' + escHtml(m) + '</option>'; }).join('') +
|
||
'</select>';
|
||
var btn = document.getElementById('btn-fetch-models');
|
||
if (!btn) return;
|
||
var tmpl = null;
|
||
for (var i = 0; i < PROVIDER_TEMPLATES.length; i++) {
|
||
if (PROVIDER_TEMPLATES[i].name === provider) { tmpl = PROVIDER_TEMPLATES[i]; break; }
|
||
}
|
||
btn.disabled = !(tmpl && tmpl.models_url);
|
||
}
|
||
|
||
|
||
function applyModelTemplate(modelName) {
|
||
if (!modelName) return;
|
||
document.getElementById('model-name').value = modelName;
|
||
var idEl = document.getElementById('model-id');
|
||
if (idEl && !idEl.value) idEl.value = modelName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
|
||
}
|
||
|
||
async function fetchProviderModels() {
|
||
var provider = document.getElementById('model-provider').value;
|
||
if (!provider) { alert('请先选择 Provider'); return; }
|
||
var tmpl = null;
|
||
for (var i = 0; i < PROVIDER_TEMPLATES.length; i++) {
|
||
if (PROVIDER_TEMPLATES[i].name === provider) { tmpl = PROVIDER_TEMPLATES[i]; break; }
|
||
}
|
||
if (!tmpl || !tmpl.models_url) { alert('该 Provider 不支持在线查询模型列表(讯飞星火等使用非标准接口)'); return; }
|
||
var btn = document.getElementById('btn-fetch-models');
|
||
if (btn) { btn.disabled = true; btn.textContent = '⏳ 查询中...'; }
|
||
try {
|
||
var result = await api('/api/model-config/fetch-models/' + encodeURIComponent(provider) + '?url=' + encodeURIComponent(tmpl.models_url));
|
||
if (result.error) { alert('查询失败: ' + result.error + (result.body ? '\n' + result.body.substring(0, 200) : '')); return; }
|
||
var models = result.models || [];
|
||
if (models.length === 0) { alert('该 Provider 未返回任何模型'); return; }
|
||
STATE.fetchedModels = models;
|
||
renderFetchedModelList(models, '');
|
||
} catch(e) {
|
||
alert('查询模型列表出错: ' + e.message);
|
||
} finally {
|
||
if (btn) { btn.disabled = false; btn.textContent = '🔍 查询'; }
|
||
}
|
||
}
|
||
|
||
function renderFetchedModelList(models, filter) {
|
||
var area = document.getElementById('model-template-area');
|
||
if (!area) return;
|
||
var filterLower = (filter || '').toLowerCase();
|
||
var filtered = filterLower ? models.filter(function(m) { return m.toLowerCase().indexOf(filterLower) >= 0; }) : models;
|
||
var countInfo = filterLower ? '\uff08' + filtered.length + '/' + models.length + '\uff09' : '\uff08共 ' + models.length + ' \u4e2a\uff09';
|
||
// 搜索栏 + 结果列表分离:搜索框保持在 DOM 中,oninput 只更新结果列表
|
||
var html = '<div id="model-search-bar" style="display:flex;gap:8px;margin-bottom:8px">' +
|
||
'<input id="model-search-input" class="input" type="text" placeholder="\U0001f50d \u641c索模型名称...' + countInfo + '" value="' + escHtml(filter) + '"' +
|
||
' oninput="filterFetchedModels()" style="flex:1;background:var(--bg);font-size:12px">' +
|
||
'<button type="button" class="btn btn-xs" onclick="clearModelSearch()" title="\u6e05\u9664\u641c索">\u2715</button></div>' +
|
||
'<div id="model-search-results" style="max-height:220px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg)">';
|
||
if (filtered.length === 0) {
|
||
html += '<div class="empty-state" style="padding:12px"><div class="icon">\U0001f50d</div>\u65e0\u5339\u914d\u6a21\u578b</div>';
|
||
} else {
|
||
html += filtered.map(function(m) {
|
||
return '<div class="fetched-model-item"' +
|
||
' data-model="' + escHtml(m) + '"' +
|
||
' onclick="var mn=this.getAttribute(\'data-model\');selectFetchedModel(mn);"' +
|
||
' style="padding:6px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border);transition:background .12s"' +
|
||
' onmouseenter="this.style.background=\'var(--bg3)\'"' +
|
||
' onmouseleave="this.style.background=\'\'">' + escHtml(m) + '</div>';
|
||
}).join('');
|
||
}
|
||
html += '</div>';
|
||
area.innerHTML = html;
|
||
}
|
||
|
||
// filterFetchedModels 仅更新结果列表,不重建搜索框,解决输入时焦点丢失问题
|
||
function filterFetchedModels() {
|
||
var input = document.getElementById('model-search-input');
|
||
var results = document.getElementById('model-search-results');
|
||
if (!input || !results) return;
|
||
var filter = input.value;
|
||
var models = STATE.fetchedModels;
|
||
var filterLower = filter.toLowerCase();
|
||
var filtered = filterLower ? models.filter(function(m) { return m.toLowerCase().indexOf(filterLower) >= 0; }) : models;
|
||
var countInfo = filterLower ? '\uff08' + filtered.length + '/' + models.length + '\uff09' : '\uff08共 ' + models.length + ' \u4e2a\uff09';
|
||
input.placeholder = '\U0001f50d \u641c索模型名称...' + countInfo;
|
||
if (filtered.length === 0) {
|
||
results.innerHTML = '<div class="empty-state" style="padding:12px"><div class="icon">\U0001f50d</div>\u65e0\u5339\u914d\u6a21型</div>';
|
||
} else {
|
||
results.innerHTML = filtered.map(function(m) {
|
||
return '<div class="fetched-model-item" data-model="' + escHtml(m) + '"' +
|
||
' onclick="var mn=this.getAttribute(\'data-model\');selectFetchedModel(mn);"' +
|
||
' style="padding:6px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border);transition:background .12s"' +
|
||
' onmouseenter="this.style.background=\'var(--bg3)\'"' +
|
||
' onmouseleave="this.style.background=\'\'">' + escHtml(m) + '</div>';
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
function clearModelSearch() {
|
||
var input = document.getElementById('model-search-input');
|
||
if (input) { input.value = ''; input.focus(); }
|
||
filterFetchedModels();
|
||
}
|
||
|
||
|
||
function selectFetchedModel(modelName) {
|
||
document.getElementById('model-name').value = modelName;
|
||
var idEl = document.getElementById('model-id');
|
||
if (idEl && !idEl.value) idEl.value = modelName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
|
||
var items = document.querySelectorAll('.fetched-model-item');
|
||
for (var i = 0; i < items.length; i++) {
|
||
items[i].style.background = (items[i].textContent === modelName) ? 'var(--accent-bg)' : '';
|
||
}
|
||
}
|
||
|
||
function resetModelTemplateArea() {
|
||
var area = document.getElementById('model-template-area');
|
||
if (!area) return;
|
||
STATE.fetchedModels = [];
|
||
var provider = document.getElementById('model-provider').value;
|
||
var models = MODEL_TEMPLATES[provider] || [];
|
||
area.innerHTML =
|
||
'<select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
|
||
'<option value="">-- 选择模型模板 / 查询获取 --</option>' +
|
||
models.map(function(m) { return '<option value="' + escHtml(m) + '">' + escHtml(m) + '</option>'; }).join('') +
|
||
'</select>';
|
||
}
|
||
|
||
async function saveProviderForm(name) {
|
||
var data = {
|
||
name: document.getElementById('prov-name').value.trim(),
|
||
base_url: document.getElementById('prov-url').value.trim(),
|
||
api_key: document.getElementById('prov-key').value,
|
||
timeout_sec: parseInt(document.getElementById('prov-timeout').value) || 120,
|
||
max_retries: parseInt(document.getElementById('prov-retries').value) || 3,
|
||
};
|
||
var saveName = name || data.name;
|
||
if (!saveName || !data.base_url) { alert('名称和 Base URL 为必填项'); return; }
|
||
var result = await api('/api/model-config/providers/' + encodeURIComponent(saveName), { method: 'POST', body: JSON.stringify(data) });
|
||
if (result.error) { alert('保存失败: ' + result.error); return; }
|
||
STATE.modelConfigTab = 'providers';
|
||
renderModelConfigPanel();
|
||
}
|
||
|
||
async function deleteModelProvider(name) {
|
||
if (!confirm('确定删除 Provider "' + name + '"?\n注意:关联的模型和路由也会受影响。')) return;
|
||
var result = await api('/api/model-config/providers/' + encodeURIComponent(name), { method: 'DELETE' });
|
||
if (result.error) { alert('删除失败: ' + result.error); return; }
|
||
renderProvidersTab();
|
||
}
|
||
|
||
// ---- Models tab ----
|
||
|
||
async function renderModelsTab() {
|
||
var container = document.getElementById('model-config-content');
|
||
var data = await api('/api/model-config/models');
|
||
STATE.modelConfigModels = data.models || [];
|
||
|
||
var rows = STATE.modelConfigModels.length === 0
|
||
? '<tr><td colspan="6"><div class="empty-state"><div class="icon">🧠</div>暂无模型定义,请添加</div></td></tr>'
|
||
: STATE.modelConfigModels.map(function(m) {
|
||
var enabledBadge = m.enabled !== false ? '<span class="badge badge-running">启用</span>' : '<span class="badge badge-stopped">禁用</span>';
|
||
var tags = (m.tags && m.tags.length > 0) ? m.tags.join(', ') : '—';
|
||
var updated = m.updated_at ? timeAgo(m.updated_at) : '—';
|
||
return '<tr>' +
|
||
'<td><strong>' + escHtml(m.id) + '</strong></td>' +
|
||
'<td>' + escHtml(m.name) + '</td>' +
|
||
'<td>' + escHtml(m.provider) + '</td>' +
|
||
'<td>' + enabledBadge + '</td>' +
|
||
'<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(tags) + '</td>' +
|
||
'<td><div class="btn-group">' +
|
||
'<button class="btn btn-xs" onclick="showModelForm(\'' + escHtml(m.id) + '\')">✏️</button>' +
|
||
'<button class="btn btn-xs btn-red" onclick="deleteModelConfig(\'' + escHtml(m.id) + '\')">🗑</button>' +
|
||
'</div></td></tr>';
|
||
}).join('');
|
||
|
||
container.innerHTML =
|
||
'<div class="card-header"><span class="card-title">🧠 模型定义</span>' +
|
||
'<button class="btn btn-sm btn-accent" onclick="showModelForm()">+ 添加</button></div>' +
|
||
'<div class="table-wrap"><table><thead><tr>' +
|
||
'<th>ID</th><th>模型名</th><th>Provider</th><th>状态</th><th>标签</th><th>操作</th>' +
|
||
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||
}
|
||
|
||
function showModelForm(id) {
|
||
var existing = null;
|
||
if (id) {
|
||
for (var i = 0; i < STATE.modelConfigModels.length; i++) {
|
||
if (STATE.modelConfigModels[i].id === id) { existing = STATE.modelConfigModels[i]; break; }
|
||
}
|
||
}
|
||
var isEdit = !!existing;
|
||
var formTitle = isEdit ? '✏️ 编辑模型 ' + escHtml(id) : '➕ 添加模型';
|
||
var defaults = existing || { id: '', name: '', provider: '', description: '', priority: 0, tags: [], params: {}, enabled: true };
|
||
var tagsStr = (defaults.tags && defaults.tags.length > 0) ? defaults.tags.join(', ') : '';
|
||
var paramsStr = JSON.stringify(defaults.params || {}, null, 2);
|
||
|
||
var container = document.getElementById('model-config-content');
|
||
container.innerHTML =
|
||
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
|
||
'<button class="btn btn-sm" onclick="renderModelsTab()">← 返回</button></div>' +
|
||
'<div class="card-body"><form onsubmit="event.preventDefault();saveModelForm(\'' + escHtml(id || '') + '\');">' +
|
||
'<div class="form-row"><label>模型 ID ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
|
||
'<input id="model-id" class="input" value="' + escHtml(defaults.id) + '" ' + (isEdit ? 'readonly' : 'placeholder="如 primary_chat"') + ' required></div>' +
|
||
'<div class="form-row"><label>模型名称 <span style="color:var(--red)">*</span></label>' +
|
||
'<input id="model-name" class="input" value="' + escHtml(defaults.name) + '" placeholder="deepseek-v4-flash" required></div>' +
|
||
'<div class="form-row"><label>Provider <span style="color:var(--red)">*</span></label>' +
|
||
'<select id="model-provider" class="input" required onchange="updateModelTemplateOptions()"><option value="">-- 选择 Provider --</option>' +
|
||
STATE.modelConfigProviders.map(function(p) {
|
||
return '<option value="' + escHtml(p.name) + '"' + (defaults.provider === p.name ? ' selected' : '') + '>' + escHtml(p.name) + '</option>';
|
||
}).join('') + '</select></div>' +
|
||
(isEdit ? '' :
|
||
'<div class="form-row"><label>📋 快速模板</label>' +
|
||
'<div>' +
|
||
'<div style="display:flex;gap:8px">' +
|
||
'<div id="model-template-area" style="flex:1"><select id="model-template" class="input" onchange="applyModelTemplate(this.value)" style="background:var(--bg3)">' +
|
||
'<option value="">-- 选择模型模板 / 查询获取 --</option></select></div>' +
|
||
'<button type="button" class="btn btn-sm" id="btn-fetch-models" onclick="fetchProviderModels()" style="white-space:nowrap" disabled>🔍 查询</button></div>' +
|
||
'<div style="font-size:11px;color:var(--text3);margin-top:4px">选择 Provider 后可用模板或点击查询在线获取模型列表</div>' +
|
||
'</div></div>') +
|
||
'<div class="form-row"><label>描述</label>' +
|
||
'<input id="model-desc" class="input" value="' + escHtml(defaults.description || '') + '" placeholder="用于日常对话的模型"></div>' +
|
||
'<div class="form-row" style="display:flex;gap:12px"><div style="flex:1"><label>优先级</label>' +
|
||
'<input id="model-priority" class="input" type="number" value="' + (defaults.priority || 0) + '"></div>' +
|
||
'<div style="flex:1;display:flex;align-items:flex-end;padding-bottom:4px"><label style="display:flex;align-items:center;gap:6px;cursor:pointer">' +
|
||
'<input type="checkbox" id="model-enabled"' + (defaults.enabled !== false ? ' checked' : '') + '> 启用</label></div></div>' +
|
||
'<div class="form-row"><label>标签 (逗号分隔)</label>' +
|
||
'<input id="model-tags" class="input" value="' + escHtml(tagsStr) + '" placeholder="chat, fast"></div>' +
|
||
'<div class="form-row"><label>模型参数 (JSON)</label>' +
|
||
'<textarea id="model-params" class="input" rows="3" style="font-family:monospace;font-size:12px">' + escHtml(paramsStr) + '</textarea></div>' +
|
||
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
|
||
'</form></div>';
|
||
}
|
||
|
||
async function saveModelForm(id) {
|
||
var tagsStr = document.getElementById('model-tags').value.trim();
|
||
var tags = tagsStr ? tagsStr.split(',').map(function(t) { return t.trim(); }).filter(Boolean) : [];
|
||
var paramsStr = document.getElementById('model-params').value.trim();
|
||
var params = {};
|
||
try { if (paramsStr) params = JSON.parse(paramsStr); } catch(e) { alert('模型参数 JSON 格式错误: ' + e.message); return; }
|
||
|
||
var data = {
|
||
id: document.getElementById('model-id').value.trim(),
|
||
name: document.getElementById('model-name').value.trim(),
|
||
provider: document.getElementById('model-provider').value,
|
||
description: document.getElementById('model-desc').value.trim(),
|
||
priority: parseInt(document.getElementById('model-priority').value) || 0,
|
||
enabled: document.getElementById('model-enabled').checked,
|
||
tags: tags,
|
||
params: params,
|
||
};
|
||
var saveId = id || data.id;
|
||
if (!saveId || !data.name || !data.provider) { alert('模型 ID、名称和 Provider 为必填项'); return; }
|
||
var result = await api('/api/model-config/models/' + encodeURIComponent(saveId), { method: 'POST', body: JSON.stringify(data) });
|
||
if (result.error) { alert('保存失败: ' + result.error); return; }
|
||
STATE.modelConfigTab = 'models';
|
||
renderModelConfigPanel();
|
||
}
|
||
|
||
async function deleteModelConfig(id) {
|
||
if (!confirm('确定删除模型 "' + id + '"?')) return;
|
||
var result = await api('/api/model-config/models/' + encodeURIComponent(id), { method: 'DELETE' });
|
||
if (result.error) { alert('删除失败: ' + result.error); return; }
|
||
renderModelsTab();
|
||
}
|
||
|
||
// ---- Routing tab ----
|
||
|
||
async function renderRoutingTab() {
|
||
var container = document.getElementById('model-config-content');
|
||
var data = await api('/api/model-config/routing');
|
||
STATE.modelConfigRouting = data.routing || [];
|
||
|
||
var rows = STATE.modelConfigRouting.length === 0
|
||
? '<tr><td colspan="4"><div class="empty-state"><div class="icon">🔀</div>暂无路由规则,请添加</div></td></tr>'
|
||
: STATE.modelConfigRouting.map(function(r) {
|
||
var chain = (r.fallback_chain && r.fallback_chain.length > 0) ? r.fallback_chain.join(' → ') : '—';
|
||
var requiredBadge = r.required ? '<span class="badge badge-running">必需</span>' : '<span class="badge badge-stopped">可选</span>';
|
||
return '<tr>' +
|
||
'<td><strong>' + escHtml(r.purpose) + '</strong></td>' +
|
||
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(chain) + '</td>' +
|
||
'<td>' + requiredBadge + '</td>' +
|
||
'<td><div class="btn-group">' +
|
||
'<button class="btn btn-xs" onclick="showRoutingForm(\'' + escHtml(r.purpose) + '\')">✏️</button>' +
|
||
'<button class="btn btn-xs btn-red" onclick="deleteRoutingRule(\'' + escHtml(r.purpose) + '\')">🗑</button>' +
|
||
'</div></td></tr>';
|
||
}).join('');
|
||
|
||
container.innerHTML =
|
||
'<div class="card-header"><span class="card-title">🔀 用途路由</span>' +
|
||
'<button class="btn btn-sm btn-accent" onclick="showRoutingForm()">+ 添加</button></div>' +
|
||
'<div class="table-wrap"><table><thead><tr>' +
|
||
'<th>用途</th><th>回退链</th><th>必需性</th><th>操作</th>' +
|
||
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||
}
|
||
|
||
function showRoutingForm(purpose) {
|
||
var existing = null;
|
||
if (purpose) {
|
||
for (var i = 0; i < STATE.modelConfigRouting.length; i++) {
|
||
if (STATE.modelConfigRouting[i].purpose === purpose) { existing = STATE.modelConfigRouting[i]; break; }
|
||
}
|
||
}
|
||
var isEdit = !!existing;
|
||
var formTitle = isEdit ? '✏️ 编辑路由 ' + escHtml(purpose) : '➕ 添加路由';
|
||
var defaults = existing || { purpose: '', fallback_chain: [], required: false };
|
||
var existingChain = defaults.fallback_chain || [];
|
||
|
||
var models = STATE.modelConfigModels;
|
||
var modelCheckboxes = '';
|
||
if (models.length === 0) {
|
||
modelCheckboxes = '<div class="empty-state" style="padding:16px"><div class="icon">🧠</div>暂无模型定义,请先在「模型定义」标签中添加模型</div>';
|
||
} else {
|
||
modelCheckboxes = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:6px;max-height:260px;overflow-y:auto;padding:4px 0">' +
|
||
models.map(function(m) {
|
||
var checked = existingChain.indexOf(m.id) >= 0 ? ' checked' : '';
|
||
var providerLabel = m.provider ? ' <span style="color:var(--text3);font-size:11px">(' + escHtml(m.provider) + ')</span>' : '';
|
||
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg3);border-radius:var(--radius-sm);cursor:pointer;font-size:12px;transition:background .15s" onmouseenter="this.style.background=\'var(--bg4)\'" onmouseleave="this.style.background=\'var(--bg3)\'">' +
|
||
'<input type="checkbox" name="routing-model" value="' + escHtml(m.id) + '"' + checked + ' style="accent-color:var(--accent)">' +
|
||
'<span style="flex:1"><strong>' + escHtml(m.name || m.id) + '</strong>' + providerLabel + '</span>' +
|
||
'</label>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
var container = document.getElementById('model-config-content');
|
||
container.innerHTML =
|
||
'<div class="card-header"><span class="card-title">' + formTitle + '</span>' +
|
||
'<button class="btn btn-sm" onclick="renderRoutingTab()">← 返回</button></div>' +
|
||
'<div class="card-body"><form onsubmit="event.preventDefault();saveRoutingForm(\'' + escHtml(purpose || '') + '\');">' +
|
||
'<div class="form-row"><label>用途 ID ' + (isEdit ? '' : '<span style="color:var(--red)">*</span>') + '</label>' +
|
||
'<select id="routing-purpose" class="input" ' + (isEdit ? 'disabled' : 'required') + '>' +
|
||
'<option value="">-- 选择用途 --</option>' +
|
||
'<option value="chat"'+ (defaults.purpose === 'chat' ? ' selected' : '') +'>chat (日常对话)</option>' +
|
||
'<option value="deep_thinking"'+ (defaults.purpose === 'deep_thinking' ? ' selected' : '') +'>deep_thinking (深度思考/复杂推理)</option>' +
|
||
'<option value="code"'+ (defaults.purpose === 'code' ? ' selected' : '') +'>code (代码生成)</option>' +
|
||
'<option value="vision"'+ (defaults.purpose === 'vision' ? ' selected' : '') +'>vision (视觉理解)</option>' +
|
||
'<option value="ocr"'+ (defaults.purpose === 'ocr' ? ' selected' : '') +'>ocr (文字识别/OCR)</option>' +
|
||
'<option value="math"'+ (defaults.purpose === 'math' ? ' selected' : '') +'>math (数学推理)</option>' +
|
||
'<option value="translation"'+ (defaults.purpose === 'translation' ? ' selected' : '') +'>translation (翻译)</option>' +
|
||
'<option value="intent_analysis"'+ (defaults.purpose === 'intent_analysis' ? ' selected' : '') +'>intent_analysis (意图分析)</option>' +
|
||
'<option value="tool_calling"'+ (defaults.purpose === 'tool_calling' ? ' selected' : '') +'>tool_calling (工具调用/Function Calling)</option>' +
|
||
'<option value="memory_extraction"'+ (defaults.purpose === 'memory_extraction' ? ' selected' : '') +'>memory_extraction (记忆提取)</option>' +
|
||
'<option value="roleplay"'+ (defaults.purpose === 'roleplay' ? ' selected' : '') +'>roleplay (角色扮演)</option>' +
|
||
'<option value="long_context"'+ (defaults.purpose === 'long_context' ? ' selected' : '') +'>long_context (长文档处理)</option>' +
|
||
'<option value="speech_recognition"'+ (defaults.purpose === 'speech_recognition' ? ' selected' : '') +'>speech_recognition (实时语音识别)</option>' +
|
||
'<option value="speech_recognition_offline"'+ (defaults.purpose === 'speech_recognition_offline' ? ' selected' : '') +'>speech_recognition_offline (非实时语音识别)</option>' +
|
||
'</select></div>' +
|
||
'<div style="margin-bottom:10px">' +
|
||
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">' +
|
||
'<label style="flex:0 0 140px;padding-top:6px">回退模型链 <span style="color:var(--text2);font-weight:400">(勾选即加入,顺序=表格显示顺序)</span></label>' +
|
||
(models.length > 0 ? '<div class="btn-group">' +
|
||
'<button type="button" class="btn btn-xs" onclick="var cbs=document.querySelectorAll(\'input[name=routing-model]\');cbs.forEach(function(c){c.checked=true})">全选</button>' +
|
||
'<button type="button" class="btn btn-xs" onclick="var cbs=document.querySelectorAll(\'input[name=routing-model]\');cbs.forEach(function(c){c.checked=false})">取消全选</button>' +
|
||
'</div>' : '') +
|
||
'</div>' +
|
||
modelCheckboxes + '</div>' +
|
||
'<div class="form-row"><label style="display:flex;align-items:center;gap:6px;cursor:pointer">' +
|
||
'<input type="checkbox" id="routing-required"' + (defaults.required ? ' checked' : '') + '> 必需 (所有模型不可用时返回错误,而非回退到 .env)</label></div>' +
|
||
'<div style="margin-top:14px"><button type="submit" class="btn btn-accent">💾 保存</button></div>' +
|
||
'</form></div>';
|
||
}
|
||
|
||
async function saveRoutingForm(purpose) {
|
||
// 收集所有勾选的模型 (按 DOM 顺序 = 表格显示顺序)
|
||
var checkedCbs = document.querySelectorAll('input[name="routing-model"]:checked');
|
||
var chain = [];
|
||
for (var i = 0; i < checkedCbs.length; i++) {
|
||
chain.push(checkedCbs[i].value);
|
||
}
|
||
|
||
var data = {
|
||
purpose: purpose || document.getElementById('routing-purpose').value,
|
||
fallback_chain: chain,
|
||
required: document.getElementById('routing-required').checked,
|
||
};
|
||
if (!data.purpose) { alert('请选择用途'); return; }
|
||
if (chain.length === 0) { alert('回退模型链不能为空'); return; }
|
||
var result = await api('/api/model-config/routing/' + encodeURIComponent(data.purpose), { method: 'POST', body: JSON.stringify(data) });
|
||
if (result.error) { alert('保存失败: ' + result.error); return; }
|
||
STATE.modelConfigTab = 'routing';
|
||
renderModelConfigPanel();
|
||
}
|
||
|
||
async function deleteRoutingRule(purpose) {
|
||
if (!confirm('确定删除路由 "' + purpose + '"?')) return;
|
||
var result = await api('/api/model-config/routing/' + encodeURIComponent(purpose), { method: 'DELETE' });
|
||
if (result.error) { alert('删除失败: ' + result.error); return; }
|
||
renderRoutingTab();
|
||
}
|
||
|
||
// ========== 思考调度配置面板 ==========
|
||
|
||
const ALL_DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||
const DAY_LABELS = { monday: '一', tuesday: '二', wednesday: '三', thursday: '四', friday: '五', saturday: '六', sunday: '日' };
|
||
|
||
function computeCurrentInterval(cfg) {
|
||
var now = new Date();
|
||
var wd = ALL_DAYS[(now.getDay() + 6) % 7];
|
||
var mins = now.getHours() * 60 + now.getMinutes();
|
||
for (var i = 0; i < cfg.rules.length; i++) {
|
||
var rule = cfg.rules[i];
|
||
if (!rule.days || rule.days.indexOf(wd) < 0) continue;
|
||
var tr = parseTimeRange(rule.time_range);
|
||
if (!tr) continue;
|
||
var inRange = tr.start <= tr.end ? (mins >= tr.start && mins < tr.end) : (mins >= tr.start || mins < tr.end);
|
||
if (!inRange) continue;
|
||
var excepted = false;
|
||
for (var e = 0; e < (rule.except || []).length; e++) {
|
||
var er = parseTimeRange(rule.except[e]);
|
||
if (er) {
|
||
var eIn = er.start <= er.end ? (mins >= er.start && mins < er.end) : (mins >= er.start || mins < er.end);
|
||
if (eIn) { excepted = true; break; }
|
||
}
|
||
}
|
||
if (!excepted) return rule.interval_minutes;
|
||
}
|
||
return cfg.default_interval_minutes || 5;
|
||
}
|
||
|
||
function parseTimeRange(r) {
|
||
var parts = r.split('-');
|
||
if (parts.length !== 2) return null;
|
||
var start = parseHM(parts[0].trim());
|
||
var end = parseHM(parts[1].trim());
|
||
if (start === null || end === null) return null;
|
||
return { start: start, end: end };
|
||
}
|
||
|
||
function parseHM(s) {
|
||
var parts = s.split(':');
|
||
if (parts.length !== 2) return null;
|
||
var h = parseInt(parts[0], 10), m = parseInt(parts[1], 10);
|
||
if (isNaN(h) || isNaN(m) || h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||
return h * 60 + m;
|
||
}
|
||
|
||
function renderThinkingSchedulePanel() {
|
||
var panel = document.getElementById('panel-thinkingSchedule');
|
||
panel.innerHTML = '<div class="card"><div class="card-body">加载中...</div></div>';
|
||
|
||
api('/api/thinking-schedule').then(function(cfg) {
|
||
if (cfg.error) {
|
||
panel.innerHTML = '<div class="card"><div class="card-body"><div class="empty-state">⚠ 加载失败: ' + escHtml(cfg.error) + '</div></div></div>';
|
||
return;
|
||
}
|
||
drawScheduleForm(panel, cfg);
|
||
scheduleAutoSave(cfg);
|
||
});
|
||
}
|
||
|
||
var _scheduleAutoSaveTimer = null;
|
||
function scheduleAutoSave(cfg) {
|
||
if (_scheduleAutoSaveTimer) clearInterval(_scheduleAutoSaveTimer);
|
||
_scheduleAutoSaveTimer = setInterval(function() {
|
||
var cur = computeCurrentInterval(cfg);
|
||
var el = document.getElementById('schedule-current-interval');
|
||
if (el) el.textContent = '当前间隔: ' + cur + ' 分钟';
|
||
}, 30000);
|
||
}
|
||
|
||
function drawScheduleForm(panel, cfg) {
|
||
var curInterval = computeCurrentInterval(cfg);
|
||
|
||
var rulesHtml = '';
|
||
for (var i = 0; i < cfg.rules.length; i++) {
|
||
rulesHtml += buildRuleRow(cfg.rules[i], i);
|
||
}
|
||
|
||
panel.innerHTML =
|
||
'<div class="card" style="margin-bottom:12px">' +
|
||
'<div class="card-header">' +
|
||
'<span class="card-title">⏰ 思考调度配置</span>' +
|
||
'<div class="btn-group">' +
|
||
'<span id="schedule-current-interval" style="font-size:13px;color:var(--accent);margin-right:12px">当前间隔: ' + curInterval + ' 分钟</span>' +
|
||
'<button class="btn btn-sm btn-accent" onclick="saveSchedule()">💾 保存</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="card-body">' +
|
||
'<div style="margin-bottom:12px;display:flex;align-items:center;gap:10px">' +
|
||
'<label style="white-space:nowrap;font-weight:600">默认间隔 (分钟):</label>' +
|
||
'<input type="number" id="sched-default-interval" value="' + (cfg.default_interval_minutes || 5) + '" min="1" max="120" style="width:80px" class="form-input"/>' +
|
||
'<span style="color:var(--text2);font-size:12px">无规则匹配时使用此间隔</span>' +
|
||
'</div>' +
|
||
|
||
'<div class="table-wrap"><table><thead><tr>' +
|
||
'<th>规则名称</th><th>适用日期</th><th>时间段</th><th>排除时段</th><th>间隔(分)</th><th>操作</th>' +
|
||
'</tr></thead><tbody id="sched-rules-tbody">' +
|
||
rulesHtml +
|
||
'</tbody></table></div>' +
|
||
|
||
'<button class="btn btn-sm" onclick="addScheduleRule()" style="margin-top:8px">➕ 添加规则</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function buildRuleRow(rule, idx) {
|
||
var daysHtml = '';
|
||
for (var d = 0; d < ALL_DAYS.length; d++) {
|
||
var day = ALL_DAYS[d];
|
||
var checked = (rule.days || []).indexOf(day) >= 0 ? ' checked' : '';
|
||
daysHtml += '<label style="display:inline-flex;align-items:center;gap:2px;margin-right:4px;font-size:12px;cursor:pointer">' +
|
||
'<input type="checkbox" data-sched-days="' + idx + '" value="' + day + '"' + checked + ' style="margin:0"/>' +
|
||
DAY_LABELS[day] + '</label>';
|
||
}
|
||
var exceptVal = (rule.except || []).join(', ');
|
||
return '<tr>' +
|
||
'<td><input type="text" id="sched-name-' + idx + '" value="' + escHtml(rule.name || '') + '" class="form-input" style="width:120px"/></td>' +
|
||
'<td>' + daysHtml + '</td>' +
|
||
'<td><input type="text" id="sched-range-' + idx + '" value="' + escHtml(rule.time_range || '') + '" class="form-input" placeholder="HH:MM-HH:MM" style="width:100px"/></td>' +
|
||
'<td><input type="text" id="sched-except-' + idx + '" value="' + escHtml(exceptVal) + '" class="form-input" placeholder="HH:MM-HH:MM, ..." style="width:140px"/></td>' +
|
||
'<td><input type="number" id="sched-interval-' + idx + '" value="' + (rule.interval_minutes || 5) + '" min="1" max="120" class="form-input" style="width:60px"/></td>' +
|
||
'<td><button class="btn btn-xs btn-red" onclick="deleteScheduleRule(' + idx + ')">🗑</button></td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
function collectScheduleConfig() {
|
||
var cfg = {
|
||
version: '1.0',
|
||
default_interval_minutes: parseInt(document.getElementById('sched-default-interval').value, 10) || 5,
|
||
rules: []
|
||
};
|
||
var tbody = document.getElementById('sched-rules-tbody');
|
||
var rows = tbody.querySelectorAll('tr');
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var nameEl = document.getElementById('sched-name-' + i);
|
||
if (!nameEl) continue;
|
||
var days = [];
|
||
var checks = document.querySelectorAll('[data-sched-days="' + i + '"]:checked');
|
||
for (var c = 0; c < checks.length; c++) {
|
||
days.push(checks[c].value);
|
||
}
|
||
var exceptRaw = document.getElementById('sched-except-' + i).value.trim();
|
||
var exceptList = [];
|
||
if (exceptRaw) {
|
||
exceptList = exceptRaw.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
|
||
}
|
||
cfg.rules.push({
|
||
name: nameEl.value.trim(),
|
||
days: days,
|
||
time_range: document.getElementById('sched-range-' + i).value.trim(),
|
||
except: exceptList,
|
||
interval_minutes: parseInt(document.getElementById('sched-interval-' + i).value, 10) || 5
|
||
});
|
||
}
|
||
return cfg;
|
||
}
|
||
|
||
function addScheduleRule() {
|
||
var cfg = collectScheduleConfig();
|
||
cfg.rules.push({ name: '', days: [], time_range: '09:00-17:00', except: [], interval_minutes: 5 });
|
||
var panel = document.getElementById('panel-thinkingSchedule');
|
||
drawScheduleForm(panel, cfg);
|
||
}
|
||
|
||
function deleteScheduleRule(idx) {
|
||
var cfg = collectScheduleConfig();
|
||
cfg.rules.splice(idx, 1);
|
||
var panel = document.getElementById('panel-thinkingSchedule');
|
||
drawScheduleForm(panel, cfg);
|
||
}
|
||
|
||
async function saveSchedule() {
|
||
var cfg = collectScheduleConfig();
|
||
var result = await api('/api/thinking-schedule', { method: 'PUT', body: JSON.stringify(cfg) });
|
||
if (result.error) {
|
||
alert('保存失败: ' + result.error);
|
||
return;
|
||
}
|
||
// Re-render to refresh the current interval preview
|
||
api('/api/thinking-schedule').then(function(cfg) {
|
||
var panel = document.getElementById('panel-thinkingSchedule');
|
||
drawScheduleForm(panel, cfg);
|
||
});
|
||
}
|
||
|
||
// ========== 插件管理面板 ==========
|
||
|
||
var pluginsTab = 'list'; // 'list' or 'tools'
|
||
var pluginListData = [];
|
||
var toolListData = [];
|
||
|
||
function renderPluginsPanel() {
|
||
var panel = document.getElementById('panel-plugins');
|
||
panel.innerHTML =
|
||
'<div class="card">' +
|
||
'<div class="card-header">' +
|
||
'<span class="card-title">🔌 插件管理</span>' +
|
||
'<div class="btn-group">' +
|
||
'<button class="btn btn-sm' + (pluginsTab === 'list' ? ' btn-accent' : '') + '" onclick="switchPluginsTab(\'list\')">📦 插件列表</button>' +
|
||
'<button class="btn btn-sm' + (pluginsTab === 'tools' ? ' btn-accent' : '') + '" onclick="switchPluginsTab(\'tools\')">🔧 工具注册表</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="card-body" id="plugins-tab-content">' +
|
||
'<div class="empty-state">加载中...</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
if (pluginsTab === 'list') {
|
||
loadPluginList();
|
||
} else {
|
||
loadToolList();
|
||
}
|
||
}
|
||
|
||
function switchPluginsTab(tab) {
|
||
pluginsTab = tab;
|
||
renderPluginsPanel();
|
||
}
|
||
|
||
async function loadPluginList() {
|
||
var content = document.getElementById('plugins-tab-content');
|
||
try {
|
||
var result = await api('/api/plugins');
|
||
if (result.error) {
|
||
content.innerHTML = '<div class="empty-state">⚠ 加载失败: ' + escHtml(result.error) + '</div>';
|
||
return;
|
||
}
|
||
pluginListData = result.plugins || [];
|
||
var html = '';
|
||
if (pluginListData.length === 0) {
|
||
html = '<div class="empty-state">📦 暂无已安装的插件</div>';
|
||
} else {
|
||
html = '<div style="margin-bottom:8px;color:var(--text2);font-size:13px">共 ' + pluginListData.length + ' 个插件</div>';
|
||
html += '<div class="table-wrap"><table><thead><tr>' +
|
||
'<th>名称</th><th>版本</th><th>作者</th><th>分类</th><th>状态</th><th>工具</th><th>操作</th>' +
|
||
'</tr></thead><tbody>';
|
||
for (var i = 0; i < pluginListData.length; i++) {
|
||
var p = pluginListData[i];
|
||
var m = p.metadata || {};
|
||
var statusBadge = getStatusBadge(p.status, p.enabled);
|
||
var catIcon = getCategoryIcon(m.category);
|
||
var toolCount = (p.tools || []).length;
|
||
html += '<tr>' +
|
||
'<td><strong>' + escHtml(m.displayName || m.name) + '</strong><br><span style="font-size:11px;color:var(--text2)">' + escHtml(m.name) + '</span></td>' +
|
||
'<td>' + escHtml(m.version || '-') + '</td>' +
|
||
'<td>' + escHtml((m.author && m.author.name) || '-') + '</td>' +
|
||
'<td>' + catIcon + ' ' + escHtml(m.category || '-') + '</td>' +
|
||
'<td>' + statusBadge + '</td>' +
|
||
'<td><span class="badge">' + toolCount + '</span></td>' +
|
||
'<td>' + buildPluginActions(p) + '</td>' +
|
||
'</tr>';
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
content.innerHTML = html;
|
||
} catch (e) {
|
||
content.innerHTML = '<div class="empty-state">⚠ 请求失败: ' + escHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function loadToolList(filterText) {
|
||
var content = document.getElementById('plugins-tab-content');
|
||
try {
|
||
var result = await api('/api/tools');
|
||
if (result.error) {
|
||
content.innerHTML = '<div class="empty-state">⚠ 加载失败: ' + escHtml(result.error) + '</div>';
|
||
return;
|
||
}
|
||
toolListData = result.tools || [];
|
||
var filtered = toolListData;
|
||
if (filterText) {
|
||
var q = filterText.toLowerCase();
|
||
filtered = toolListData.filter(function(t) {
|
||
return (t.id || '').toLowerCase().indexOf(q) >= 0 ||
|
||
(t.name || '').toLowerCase().indexOf(q) >= 0 ||
|
||
(t.category || '').toLowerCase().indexOf(q) >= 0;
|
||
});
|
||
}
|
||
|
||
var html = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">' +
|
||
'<input type="text" class="form-input" id="tool-search" placeholder="搜索工具名称/ID/Category..." style="width:260px" oninput="loadToolList(this.value)"/>' +
|
||
'<span style="color:var(--text2);font-size:13px">共 ' + toolListData.length + ' 个工具' + (filtered.length !== toolListData.length ? ',显示 ' + filtered.length + ' 个' : '') + '</span>' +
|
||
'</div>';
|
||
|
||
if (filtered.length === 0) {
|
||
html += '<div class="empty-state">🔧 ' + (filterText ? '无匹配工具' : '暂无已注册的工具') + '</div>';
|
||
} else {
|
||
html += '<div class="table-wrap"><table><thead><tr>' +
|
||
'<th>Tool ID</th><th>Category</th><th>Complexity</th><th>DangerLevel</th><th>参数</th>' +
|
||
'</tr></thead><tbody>';
|
||
for (var i = 0; i < filtered.length; i++) {
|
||
var t = filtered[i];
|
||
var paramCount = 0;
|
||
var paramNames = [];
|
||
if (t.parameters && t.parameters.properties) {
|
||
paramNames = Object.keys(t.parameters.properties);
|
||
paramCount = paramNames.length;
|
||
}
|
||
var dangerBadge = '';
|
||
if (t.danger_level && t.danger_level !== 'low') {
|
||
var dc = t.danger_level === 'high' ? 'var(--red)' : 'var(--orange)';
|
||
dangerBadge = '<span class="badge" style="background:' + dc + '">' + escHtml(t.danger_level) + '</span>';
|
||
}
|
||
html += '<tr style="cursor:pointer" onclick="toggleToolParams(\'' + escHtml(t.id) + '\')">' +
|
||
'<td><strong>' + escHtml(t.name || t.id) + '</strong><br><span style="font-size:11px;color:var(--text2)">' + escHtml(t.id) + '</span></td>' +
|
||
'<td>' + escHtml(t.category || '-') + '</td>' +
|
||
'<td><span class="badge">' + escHtml(t.complexity || 'simple') + '</span></td>' +
|
||
'<td>' + (dangerBadge || escHtml(t.danger_level || 'low')) + '</td>' +
|
||
'<td><span style="color:var(--accent)">' + paramCount + ' 参数</span></td>' +
|
||
'</tr>' +
|
||
'<tr id="tool-params-' + escHtml(t.id) + '" style="display:none">' +
|
||
'<td colspan="5">' +
|
||
'<div class="card" style="margin:4px 0">' +
|
||
'<div class="card-body" style="font-size:12px">' +
|
||
'<div style="margin-bottom:4px"><strong>描述:</strong> ' + escHtml(t.description || '-') + '</div>' +
|
||
'<pre style="background:var(--bg);padding:8px;border-radius:4px;white-space:pre-wrap;max-height:200px;overflow-y:auto">' + escHtml(JSON.stringify(t.parameters, null, 2)) + '</pre>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</td>' +
|
||
'</tr>';
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
content.innerHTML = html;
|
||
} catch (e) {
|
||
content.innerHTML = '<div class="empty-state">⚠ 请求失败: ' + escHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
function toggleToolParams(toolId) {
|
||
var row = document.getElementById('tool-params-' + toolId);
|
||
if (row) {
|
||
row.style.display = row.style.display === 'none' ? '' : 'none';
|
||
}
|
||
}
|
||
|
||
function getStatusBadge(status, enabled) {
|
||
if (status === 'error') {
|
||
return '<span class="badge" style="background:var(--red)">错误</span>';
|
||
}
|
||
if (!enabled || status === 'disabled') {
|
||
return '<span class="badge" style="background:var(--text2)">已禁用</span>';
|
||
}
|
||
if (status === 'running') {
|
||
return '<span class="badge" style="background:var(--green)">运行中</span>';
|
||
}
|
||
return '<span class="badge" style="background:var(--green)">' + escHtml(status) + '</span>';
|
||
}
|
||
|
||
function getCategoryIcon(cat) {
|
||
var icons = {
|
||
utility: '🔧', text: '📝', security: '🔒', data: '📊', filesystem: '📁',
|
||
network: '🌐', web: '🌍', iot: '🏠',
|
||
};
|
||
return icons[cat] || '📦';
|
||
}
|
||
|
||
function buildPluginActions(p) {
|
||
var name = escHtml((p.metadata && p.metadata.name) || '');
|
||
var enabled = p.enabled;
|
||
var canEnable = !enabled;
|
||
var canDisable = enabled && (p.metadata && p.metadata.name !== '');
|
||
var html = '';
|
||
if (canEnable) {
|
||
html += '<button class="btn btn-xs btn-accent" onclick="pluginAction(\'' + name + '\', \'enable\')" title="启用">▶</button> ';
|
||
}
|
||
if (canDisable) {
|
||
html += '<button class="btn btn-xs" onclick="pluginAction(\'' + name + '\', \'disable\')" title="禁用">⏸</button> ';
|
||
}
|
||
html += '<button class="btn btn-xs" onclick="pluginAction(\'' + name + '\', \'reload\')" title="重载">🔄</button>';
|
||
return html;
|
||
}
|
||
|
||
async function pluginAction(id, action) {
|
||
try {
|
||
var result = await api('/api/plugins/' + encodeURIComponent(id) + '/' + action, { method: 'POST' });
|
||
if (result.error) {
|
||
alert('操作失败 (' + action + '): ' + result.error);
|
||
return;
|
||
}
|
||
loadPluginList();
|
||
} catch (e) {
|
||
alert('请求异常: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ========== 客户端管理面板 ==========
|
||
|
||
function renderClientsPanel() {
|
||
var panel = document.getElementById('panel-clients');
|
||
panel.innerHTML =
|
||
'<div class="card">' +
|
||
'<div class="card-header">' +
|
||
'<span class="card-title">📱 已连接设备</span>' +
|
||
'<div class="btn-group">' +
|
||
'<button class="btn btn-sm" onclick="loadClients()">🔄 刷新</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div id="clients-online"></div>' +
|
||
'</div>' +
|
||
'<div class="card">' +
|
||
'<div class="card-header"><span class="card-title">📋 历史设备</span></div>' +
|
||
'<div id="clients-offline"></div>' +
|
||
'</div>' +
|
||
'<div class="card">' +
|
||
'<div class="card-header"><span class="card-title">💡 跨端说明</span></div>' +
|
||
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
|
||
'<p>每个浏览器/设备首次连接时会分配唯一的 <b>Client ID</b>(存储在浏览器 localStorage)。</p>' +
|
||
'<p>后续所有消息都会携带此 ID,昔涟可据此判断用户当前使用的设备。</p>' +
|
||
'<p><b>设备名称</b> 自动从 User-Agent 推断,你也可以在下方为设备添加备注。</p>' +
|
||
'<p>在线设备 = 当前 WebSocket 连接已建立;离线设备 = 曾连接过但当前断开。</p>' +
|
||
'</div>' +
|
||
'</div>';
|
||
loadClients();
|
||
}
|
||
|
||
async function loadClients() {
|
||
var data = await api('/api/clients');
|
||
var onlineDiv = document.getElementById('clients-online');
|
||
var offlineDiv = document.getElementById('clients-offline');
|
||
if (!onlineDiv || !offlineDiv) return;
|
||
|
||
if (data.error) {
|
||
onlineDiv.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '<br><span style="font-size:11px">Gateway 服务可能未启动</span></div>';
|
||
offlineDiv.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
var clients = data.clients || [];
|
||
var onlineClients = clients.filter(function(c) { return c.online; });
|
||
var offlineClients = clients.filter(function(c) { return !c.online; });
|
||
|
||
// Update badge
|
||
var badge = document.getElementById('clients-badge');
|
||
if (badge) {
|
||
var onlineCount = onlineClients.length;
|
||
if (onlineCount > 0) {
|
||
badge.textContent = onlineCount;
|
||
badge.style.display = 'inline';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
if (clients.length === 0) {
|
||
onlineDiv.innerHTML = '<div class="empty-state"><div class="icon">📱</div>暂无已连接的客户端</div>';
|
||
offlineDiv.innerHTML = '<div class="empty-state"><div class="icon">📋</div>暂无历史设备记录</div>';
|
||
return;
|
||
}
|
||
|
||
onlineDiv.innerHTML = onlineClients.length > 0
|
||
? '<div class="table-wrap"><table>' +
|
||
'<thead><tr><th>状态</th><th>设备名</th><th>Client ID</th><th>备注</th><th>首次连接</th><th>最后活跃</th><th>操作</th></tr></thead>' +
|
||
'<tbody>' + onlineClients.map(function(c) { return clientRowHTML(c); }).join('') + '</tbody>' +
|
||
'</table></div>'
|
||
: '<div class="empty-state"><div class="icon">📱</div>暂无在线客户端</div>';
|
||
|
||
offlineDiv.innerHTML = offlineClients.length > 0
|
||
? '<div class="table-wrap"><table>' +
|
||
'<thead><tr><th>状态</th><th>设备名</th><th>Client ID</th><th>备注</th><th>首次连接</th><th>最后活跃</th><th>操作</th></tr></thead>' +
|
||
'<tbody>' + offlineClients.map(function(c) { return clientRowHTML(c); }).join('') + '</tbody>' +
|
||
'</table></div>'
|
||
: '<div class="empty-state"><div class="icon">📋</div>暂无历史离线设备</div>';
|
||
}
|
||
|
||
function clientRowHTML(c) {
|
||
var statusClass = c.online ? 'badge-running' : 'badge-stopped';
|
||
var statusText = c.online ? '在线' : '离线';
|
||
return '<tr>' +
|
||
'<td><span class="badge ' + statusClass + '">' + statusText + '</span></td>' +
|
||
'<td><strong>' + escHtml(c.device_name || '未知设备') + '</strong></td>' +
|
||
'<td style="font-family:\'JetBrains Mono\',monospace;font-size:11px;color:var(--text2)">' + escHtml(c.client_id || '') + '</td>' +
|
||
'<td>' +
|
||
'<span id="client-note-' + escHtml(c.client_id || '') + '" style="cursor:pointer;color:var(--accent)" title="点击编辑备注" onclick="editClientNote(\'' + escHtml(c.client_id || '') + '\')">' +
|
||
(c.note ? escHtml(c.note) : '<span style="color:var(--text3);font-style:italic">点击添加备注</span>') +
|
||
'</span>' +
|
||
'</td>' +
|
||
'<td style="font-size:11px;color:var(--text3)">' + formatTime(c.first_seen_at) + '</td>' +
|
||
'<td style="font-size:11px;color:var(--text3)">' + formatTime(c.last_seen_at) + '</td>' +
|
||
'<td><button class="btn btn-xs" onclick="editClientNote(\'' + escHtml(c.client_id || '') + '\')">✏️ 备注</button></td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
async function editClientNote(clientID) {
|
||
var currentNote = document.getElementById('client-note-' + clientID);
|
||
var oldNote = (currentNote && currentNote.textContent !== '点击添加备注') ? currentNote.textContent : '';
|
||
var note = prompt('为设备 ' + clientID + ' 输入备注:', oldNote);
|
||
if (note === null) return; // cancelled
|
||
|
||
var resp = await api('/api/clients/' + encodeURIComponent(clientID) + '/note', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ note: note }),
|
||
});
|
||
if (resp.error) {
|
||
showToast('更新备注失败: ' + resp.error, 'error');
|
||
} else {
|
||
showToast('备注已更新', 'success');
|
||
loadClients();
|
||
}
|
||
}
|
||
|
||
// ========== LLM Calls Panel ==========
|
||
async function renderLlmCallsPanel() {
|
||
var panel = document.getElementById('panel-llmCalls');
|
||
panel.innerHTML = '<div class="loading">🔄 加载中...</div>';
|
||
|
||
try {
|
||
var resp = await api('/api/llm-calls?limit=100');
|
||
var calls = resp.calls || [];
|
||
STATE.llmCallsData = calls;
|
||
|
||
// 数据未变则跳过
|
||
var llmHash = simpleHash(JSON.stringify(calls));
|
||
if (STATE.renderHashes['llmCalls'] === llmHash) return;
|
||
STATE.renderHashes['llmCalls'] = llmHash;
|
||
|
||
if (calls.length === 0) {
|
||
panel.innerHTML = '<div class="empty-state">暂无 LLM 调用记录<br><small>发送一条消息后刷新查看</small></div>';
|
||
return;
|
||
}
|
||
|
||
var totalTokens = calls.reduce(function(s, c) { return s + (c.total_tokens || 0); }, 0);
|
||
var successCount = calls.filter(function(c) { return c.success; }).length;
|
||
|
||
var html = '<div class="stats-row" style="margin-bottom:16px">' +
|
||
'<div class="stat-card"><div class="stat-value">' + calls.length + '</div><div class="stat-label">总调用</div></div>' +
|
||
'<div class="stat-card"><div class="stat-value">' + successCount + '/' + calls.length + '</div><div class="stat-label">成功</div></div>' +
|
||
'<div class="stat-card"><div class="stat-value">' + formatTokens(totalTokens) + '</div><div class="stat-label">总 Token</div></div>' +
|
||
'</div>';
|
||
|
||
html += '<div class="table-wrap"><table class="data-table">' +
|
||
'<thead><tr>' +
|
||
'<th>时间</th><th>模型</th><th>耗时</th><th>Prompt</th><th>Completion</th><th>Total</th><th>状态</th>' +
|
||
'</tr></thead><tbody>';
|
||
|
||
calls.forEach(function(c) {
|
||
var statusClass = c.success ? 'status-ok' : 'status-err';
|
||
var statusText = c.success ? '✓' : '✗ ' + escHtml(c.error || '');
|
||
var durMs = (c.duration_ms || 0) / 1000000;
|
||
html += '<tr>' +
|
||
'<td style="font-size:11px;white-space:nowrap">' + formatTime(c.time) + '</td>' +
|
||
'<td style="font-size:12px;font-family:monospace">' + escHtml(c.model) + '</td>' +
|
||
'<td style="font-size:11px">' + (durMs > 0 ? (durMs / 1000).toFixed(2) + 's' : '-') + '</td>' +
|
||
'<td style="font-size:11px">' + (c.prompt_tokens || 0).toLocaleString() + '</td>' +
|
||
'<td style="font-size:11px">' + (c.completion_tokens || 0).toLocaleString() + '</td>' +
|
||
'<td style="font-size:11px;font-weight:600">' + (c.total_tokens || 0).toLocaleString() + '</td>' +
|
||
'<td><span class="' + statusClass + '">' + statusText + '</span></td>' +
|
||
'</tr>';
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
panel.innerHTML = html;
|
||
} catch (err) {
|
||
panel.innerHTML = '<div class="error-state">加载失败: ' + escHtml(err.message) + '<br><small>确认 AI-Core 服务已启动</small></div>';
|
||
}
|
||
}
|
||
|
||
function formatTokens(n) {
|
||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||
return String(n);
|
||
}
|
||
|
||
</script>
|
||
<script src="iot-panel.js"></script>
|
||
<script>
|
||
// ========== 初始化 ==========
|
||
|
||
// Listen for browser back/forward navigation.
|
||
window.addEventListener('hashchange', function() {
|
||
var hash = location.hash.replace('#', '');
|
||
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig', 'llmCalls', 'thinkingSchedule', 'plugins', 'trace'];
|
||
if (hash && validPanels.indexOf(hash) >= 0 && hash !== STATE.activePanel) {
|
||
switchPanel(hash);
|
||
}
|
||
});
|
||
|
||
connectWS();
|
||
refreshStatus();
|
||
|
||
// Restore last panel from URL hash, or default to dashboard.
|
||
var initHash = location.hash.replace('#', '');
|
||
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig', 'llmCalls', 'thinkingSchedule', 'plugins', 'trace'];
|
||
if (initHash && validPanels.indexOf(initHash) >= 0) {
|
||
switchPanel(initHash);
|
||
} else {
|
||
switchPanel('dashboard');
|
||
location.hash = '#dashboard';
|
||
}
|
||
|
||
// 全局状态定时刷新
|
||
STATE.statusInterval = setInterval(refreshStatus, 5000);
|
||
|
||
// 无限滚动: 监听面板容器滚动,触发热加载
|
||
var panelContainer = document.getElementById('panel-container');
|
||
if (panelContainer) {
|
||
panelContainer.addEventListener('scroll', function() {
|
||
var nearBottom = panelContainer.scrollTop + panelContainer.clientHeight >= panelContainer.scrollHeight - 200;
|
||
if (!nearBottom) return;
|
||
if (STATE.activePanel === 'memory' && STATE.memoryHasMore && !STATE.memoryLoadingMore) {
|
||
loadMoreMemories();
|
||
} else if (STATE.activePanel === 'timeline' && STATE.timelineHasMore && !STATE.timelineLoadingMore) {
|
||
loadMoreTimeline();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== VM 监控面板 ==========
|
||
async function renderVMMonitorPanel() {
|
||
var container = document.getElementById('panel-vmMonitor');
|
||
if (!container) return;
|
||
|
||
document.getElementById('panel-actions').innerHTML =
|
||
'<button class="btn btn-sm" onclick="renderVMMonitorPanel()">🔄 刷新</button>';
|
||
|
||
container.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-muted)">加载中...</div>';
|
||
|
||
var statusData, logData;
|
||
try {
|
||
var r1 = await api('/api/vm-monitor/status');
|
||
statusData = r1;
|
||
var r2 = await api('/api/tool-calls?tool_name=os_exec&limit=10');
|
||
logData = r2;
|
||
var r3 = await api('/api/tool-calls?tool_name=os_file&limit=10');
|
||
var fileLogs = r3.calls || [];
|
||
var r4 = await api('/api/tool-calls?tool_name=os_system&limit=5');
|
||
var sysLogs = r4.calls || [];
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>请求失败: ' + escHtml(String(e)) + '</div>';
|
||
return;
|
||
}
|
||
|
||
|
||
// 数据未变则跳过
|
||
var vmHash = simpleHash(JSON.stringify(statusData));
|
||
if (STATE.renderHashes['vmMonitor'] === vmHash) return;
|
||
STATE.renderHashes['vmMonitor'] = vmHash;
|
||
if (statusData.error) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(statusData.error) + '</div>';
|
||
return;
|
||
}
|
||
|
||
var osEnabled = statusData.os_enabled;
|
||
var sys = statusData.system || {};
|
||
var backend = statusData.backend || '—';
|
||
var host = statusData.host || {};
|
||
var disk = statusData.disk || {};
|
||
|
||
// Merge all OS tool logs
|
||
var allCalls = (logData.calls || []).concat(fileLogs).concat(sysLogs);
|
||
allCalls.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||
allCalls = allCalls.slice(0, 20);
|
||
|
||
var badgeColor = osEnabled ? 'var(--green)' : 'var(--text-muted)';
|
||
var badgeText = osEnabled ? '已启用 (' + backend + ')' : '未配置';
|
||
|
||
var uname = sys.uname || '—';
|
||
var hostname = sys.hostname || '—';
|
||
var memory = sys.memory || '—';
|
||
var diskInfo = sys.disk || disk.stat || '—';
|
||
var cpu = host.system ? (host.system.num_cpu || '—') : '—';
|
||
var hostOS = host.system ? (host.system.os || '—') : '—';
|
||
|
||
// Build HTML
|
||
var html =
|
||
'<!-- 状态概览 -->' +
|
||
'<div class="cards-grid cards-4" style="margin-bottom:16px">' +
|
||
'<div class="stat-card blue"><div class="stat-value">' + escHtml(backend) + '</div><div class="stat-label">OS 后端</div></div>' +
|
||
'<div class="stat-card green"><div class="stat-value" style="font-size:14px">' + escHtml(hostname) + '</div><div class="stat-label">主机名</div></div>' +
|
||
'<div class="stat-card accent"><div class="stat-value">' + cpu + '</div><div class="stat-label">CPU 核数</div></div>' +
|
||
'<div class="stat-card"><div class="stat-value" style="font-size:13px;color:' + badgeColor + '">' + escHtml(badgeText) + '</div><div class="stat-label">状态</div></div>' +
|
||
'</div>';
|
||
|
||
// System info card
|
||
html +=
|
||
'<div class="cards-grid cards-2" style="margin-bottom:16px">' +
|
||
'<div class="card">' +
|
||
'<div class="card-header"><span class="card-title">🐧 系统详情</span></div>' +
|
||
'<div style="padding:12px;font-size:12px;line-height:1.8;font-family:var(--mono)">' +
|
||
'<div style="color:var(--text-muted);margin-bottom:4px">uname -a</div>' +
|
||
'<pre style="margin:0 0 12px;white-space:pre-wrap;font-size:11px">' + escHtml(uname) + '</pre>' +
|
||
'<div style="color:var(--text-muted);margin-bottom:4px">内存</div>' +
|
||
'<pre style="margin:0 0 12px;white-space:pre-wrap;font-size:11px">' + escHtml(memory) + '</pre>' +
|
||
'<div style="color:var(--text-muted);margin-bottom:4px">磁盘</div>' +
|
||
'<pre style="margin:0;white-space:pre-wrap;font-size:11px">' + escHtml(String(diskInfo)) + '</pre>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="card">' +
|
||
'<div class="card-header"><span class="card-title">🖥 宿主机信息</span></div>' +
|
||
'<div style="padding:12px;font-size:12px;line-height:1.8">' +
|
||
'<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--border)"><span style="color:var(--text-muted)">OS</span><span>' + escHtml(hostOS) + '</span></div>' +
|
||
'<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--border)"><span style="color:var(--text-muted)">架构</span><span>' + escHtml(String(host.system ? host.system.arch : '—')) + '</span></div>' +
|
||
'<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--border)"><span style="color:var(--text-muted)">Go 版本</span><span>' + escHtml(String(host.system ? host.system.go_version : '—')) + '</span></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
// Recent OS tool calls
|
||
html +=
|
||
'<div class="card">' +
|
||
'<div class="card-header"><span class="card-title">📋 近期 OS 工具调用</span><span style="font-size:11px;color:var(--text-muted)">最近 20 条 (os_exec / os_file / os_system)</span></div>';
|
||
|
||
if (allCalls.length === 0) {
|
||
html += '<div style="text-align:center;padding:30px;color:var(--text-muted)">暂无 OS 工具调用记录</div>';
|
||
} else {
|
||
html +=
|
||
'<div style="overflow-x:auto">' +
|
||
'<table class="data-table" style="font-size:11px">' +
|
||
'<thead><tr>' +
|
||
'<th>时间</th><th>工具</th><th>状态</th><th>耗时</th><th>命令/操作</th>' +
|
||
'</tr></thead><tbody>';
|
||
|
||
for (var i = 0; i < allCalls.length; i++) {
|
||
var call = allCalls[i];
|
||
var ts = new Date(call.timestamp / 1e6).toISOString().replace('T', ' ').slice(0, 19);
|
||
var successIcon = call.success ? '✅' : '❌';
|
||
var args = {};
|
||
try { args = JSON.parse(call.arguments || '{}'); } catch(e) {}
|
||
var summary = '';
|
||
if (call.tool_name === 'os_exec') {
|
||
summary = escHtml(args.command || call.tool_name).slice(0, 80);
|
||
} else if (call.tool_name === 'os_file') {
|
||
summary = escHtml((args.action || '') + ' ' + (args.path || '')).slice(0, 60);
|
||
} else {
|
||
summary = escHtml(call.tool_name);
|
||
}
|
||
var dur = call.duration_ms ? (call.duration_ms / 1000).toFixed(2) + 's' : '—';
|
||
|
||
html +=
|
||
'<tr>' +
|
||
'<td style="white-space:nowrap">' + escHtml(ts) + '</td>' +
|
||
'<td><span class="badge badge-' + (call.tool_name === 'os_exec' ? 'running' : call.tool_name === 'os_file' ? 'starting' : 'info') + '">' + escHtml(call.tool_name) + '</span></td>' +
|
||
'<td>' + successIcon + '</td>' +
|
||
'<td>' + dur + '</td>' +
|
||
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(args.command || args.path || '') + '">' + summary + '</td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// ========== 全链路追踪面板 ==========
|
||
var traceMode = 'recent'; // 'recent' | 'session'
|
||
var traceSessionId = '';
|
||
var traceStreamSource = null;
|
||
var traceSeenIds = new Set();
|
||
var traceMaxDomEntries = 200;
|
||
|
||
function renderTracePanel() {
|
||
document.getElementById('panel-actions').innerHTML =
|
||
'<button class="btn btn-sm" onclick="refreshTrace()" id="trace-refresh-btn">🔄 刷新</button>' +
|
||
'<span id="trace-live-status" style="font-size:11px;color:var(--green);display:flex;align-items:center;gap:4px">' +
|
||
'<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite"></span> 实时</span>';
|
||
|
||
document.getElementById('panel-trace').innerHTML =
|
||
'<div class="card">' +
|
||
'<div class="card-header">' +
|
||
'<span class="card-title">🔗 全链路消息追踪</span>' +
|
||
'<span style="font-size:11px;color:var(--text2)">追踪 Client → Gateway → AI-Core → LLM 全链路 (实时推送)</span>' +
|
||
'</div>' +
|
||
'<div class="trace-control-bar">' +
|
||
'<button class="btn btn-sm ' + (traceMode === 'recent' ? 'btn-accent' : '') + '" onclick="switchTraceMode(\'recent\')">📋 最近活动</button>' +
|
||
'<button class="btn btn-sm ' + (traceMode === 'session' ? 'btn-accent' : '') + '" onclick="switchTraceMode(\'session\')">🔍 会话追踪</button>' +
|
||
'<span id="trace-session-input" style="display:' + (traceMode === 'session' ? 'inline' : 'none') + '">' +
|
||
'<input type="text" id="trace-session-id" placeholder="输入 Session ID..." value="' + escHtml(traceSessionId) + '" style="width:260px" onkeydown="if(event.key===\'Enter\')refreshTrace()">' +
|
||
'<button class="btn btn-sm btn-accent" onclick="refreshTrace()">追踪</button>' +
|
||
'</span>' +
|
||
'</div>' +
|
||
'<div id="trace-stats" class="trace-summary"></div>' +
|
||
'<div id="trace-content">' +
|
||
'<div style="text-align:center;padding:20px;color:var(--text2);font-size:12px">加载中...</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
connectTraceStream();
|
||
refreshTrace();
|
||
}
|
||
|
||
function switchTraceMode(mode) {
|
||
traceMode = mode;
|
||
traceSeenIds.clear();
|
||
renderTracePanel();
|
||
}
|
||
|
||
// --- SSE 实时推送 ---
|
||
|
||
function connectTraceStream() {
|
||
disconnectTraceStream();
|
||
var url = getAICoreBaseUrl() + '/api/v1/llm-calls/stream';
|
||
traceStreamSource = new EventSource(url);
|
||
traceStreamSource.onmessage = function(e) {
|
||
try {
|
||
var call = JSON.parse(e.data);
|
||
var trace = llmCallToTrace(call);
|
||
if (trace && !traceSeenIds.has(trace.id)) {
|
||
traceSeenIds.add(trace.id);
|
||
addTraceToTimeline(trace, true);
|
||
}
|
||
} catch(ignore) {}
|
||
};
|
||
traceStreamSource.onerror = function() {
|
||
updateTraceLiveStatus(false);
|
||
traceStreamSource.close();
|
||
setTimeout(function() {
|
||
if (STATE.activePanel === 'trace') connectTraceStream();
|
||
}, 3000);
|
||
};
|
||
traceStreamSource.onopen = function() {
|
||
updateTraceLiveStatus(true);
|
||
};
|
||
}
|
||
|
||
function disconnectTraceStream() {
|
||
if (traceStreamSource) {
|
||
traceStreamSource.close();
|
||
traceStreamSource = null;
|
||
}
|
||
updateTraceLiveStatus(false);
|
||
}
|
||
|
||
function updateTraceLiveStatus(connected) {
|
||
var el = document.getElementById('trace-live-status');
|
||
if (!el) return;
|
||
if (connected) {
|
||
el.innerHTML = '<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite"></span> 实时';
|
||
el.style.color = 'var(--green)';
|
||
} else {
|
||
el.innerHTML = '⚫ 离线';
|
||
el.style.color = 'var(--text3)';
|
||
}
|
||
}
|
||
|
||
function getAICoreBaseUrl() {
|
||
return 'http://localhost:8081';
|
||
}
|
||
|
||
// --- 初始加载 & 手动刷新 ---
|
||
|
||
async function refreshTrace() {
|
||
var contentEl = document.getElementById('trace-content');
|
||
var statsEl = document.getElementById('trace-stats');
|
||
var btn = document.getElementById('trace-refresh-btn');
|
||
if (btn) btn.classList.add('spinning');
|
||
|
||
var url, data;
|
||
if (traceMode === 'session') {
|
||
var sid = document.getElementById('trace-session-id').value.trim();
|
||
if (!sid) {
|
||
contentEl.innerHTML = '<div class="trace-empty"><div class="icon">⚠️</div>请输入 Session ID 以追踪会话链路</div>';
|
||
statsEl.innerHTML = '';
|
||
if (btn) btn.classList.remove('spinning');
|
||
return;
|
||
}
|
||
traceSessionId = sid;
|
||
url = '/api/trace/session/' + encodeURIComponent(sid);
|
||
} else {
|
||
url = '/api/trace/recent?limit=50';
|
||
}
|
||
|
||
try {
|
||
data = await api(url);
|
||
} catch(e) {
|
||
data = { error: e.message };
|
||
}
|
||
|
||
if (btn) btn.classList.remove('spinning');
|
||
|
||
if (!data) {
|
||
contentEl.innerHTML = '<div class="trace-empty"><div class="icon">⚠️</div>无法获取链路数据 (服务未响应)</div>';
|
||
statsEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
if (data.error) {
|
||
contentEl.innerHTML = '<div class="trace-empty"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
|
||
statsEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
var traces = data.traces || [];
|
||
var stats = data.stats || {};
|
||
|
||
// 筛选出新的追踪节点
|
||
var newTraces = [];
|
||
for (var i = 0; i < traces.length; i++) {
|
||
if (!traceSeenIds.has(traces[i].id)) {
|
||
traceSeenIds.add(traces[i].id);
|
||
newTraces.push(traces[i]);
|
||
}
|
||
}
|
||
|
||
// 如果是首次加载(content 只有 loading 提示),全量渲染
|
||
var timeline = contentEl.querySelector('.trace-timeline');
|
||
if (!timeline) {
|
||
if (traces.length === 0) {
|
||
contentEl.innerHTML = '<div class="trace-empty"><div class="icon">📭</div>暂无链路追踪数据<br><span style="font-size:11px;color:var(--text3)">请确保服务正在运行且有消息活动</span></div>';
|
||
statsEl.innerHTML = '';
|
||
return;
|
||
}
|
||
// 全量渲染
|
||
var html = '<div class="trace-timeline">';
|
||
for (var i = 0; i < traces.length; i++) {
|
||
html += traceHopHtml(traces[i], i > 0 ? traces[i-1] : null);
|
||
}
|
||
html += '</div>';
|
||
contentEl.innerHTML = html;
|
||
} else if (newTraces.length > 0) {
|
||
// 增量添加
|
||
for (var j = newTraces.length - 1; j >= 0; j--) {
|
||
addTraceToTimeline(newTraces[j], false);
|
||
}
|
||
pruneTimeline();
|
||
}
|
||
|
||
// 更新统计
|
||
updateTraceStatsEl(statsEl, traces, stats, data.session);
|
||
|
||
// 更新徽章
|
||
var badge = document.getElementById('trace-badge');
|
||
if (badge) {
|
||
badge.textContent = traces.length;
|
||
badge.style.display = traces.length > 0 ? 'inline-block' : 'none';
|
||
}
|
||
}
|
||
|
||
// --- 增量 DOM 操作 ---
|
||
|
||
function addTraceToTimeline(trace, isLive) {
|
||
var contentEl = document.getElementById('trace-content');
|
||
var timeline = contentEl.querySelector('.trace-timeline');
|
||
if (!timeline) return;
|
||
|
||
var gapIndicator = timeline.querySelector('div[data-gap]');
|
||
var insertPoint = gapIndicator || timeline.firstChild;
|
||
|
||
var html = traceHopHtml(trace, null);
|
||
var temp = document.createElement('div');
|
||
temp.innerHTML = html;
|
||
|
||
while (temp.firstChild) {
|
||
timeline.insertBefore(temp.firstChild, insertPoint);
|
||
}
|
||
|
||
if (isLive) {
|
||
var newHop = timeline.querySelector('.trace-hop');
|
||
if (newHop) {
|
||
newHop.style.transition = 'background 0.3s';
|
||
newHop.style.background = 'var(--accent-bg)';
|
||
setTimeout(function() { newHop.style.background = ''; }, 2000);
|
||
}
|
||
}
|
||
|
||
pruneTimeline();
|
||
}
|
||
|
||
function pruneTimeline() {
|
||
var timeline = document.querySelector('.trace-timeline');
|
||
if (!timeline) return;
|
||
var hops = timeline.querySelectorAll('.trace-hop');
|
||
while (hops.length > traceMaxDomEntries) {
|
||
var last = hops[hops.length - 1];
|
||
var detail = last.nextElementSibling;
|
||
if (detail && detail.classList.contains('trace-hop-detail')) {
|
||
detail.remove();
|
||
}
|
||
last.remove();
|
||
hops = timeline.querySelectorAll('.trace-hop');
|
||
}
|
||
}
|
||
|
||
function traceHopHtml(t, prevT) {
|
||
var time = new Date(t.timestamp).toISOString().replace('T', ' ').slice(0, 19);
|
||
var isError = t.status === 'error';
|
||
var gapHtml = '';
|
||
|
||
if (prevT) {
|
||
var gap = t.ts - prevT.ts;
|
||
if (gap > 50) {
|
||
gapHtml = '<div data-gap style="text-align:center;padding:2px 0;font-size:10px;color:var(--text3);margin-left:48px">↓ ' + (gap > 1000 ? (gap / 1000).toFixed(2) + 's' : gap + 'ms') + '</div>';
|
||
}
|
||
}
|
||
|
||
return gapHtml +
|
||
'<div class="trace-hop ' + (isError ? 'error' : 'success') + '" data-id="' + escHtml(t.id) + '" onclick="toggleTraceHop(this)" title="点击展开详情">' +
|
||
'<div class="hop-dot ' + (isError ? 'error' : escHtml(t.service)) + '"></div>' +
|
||
'<span class="hop-time">' + escHtml(time) + '</span>' +
|
||
'<span class="hop-service ' + escHtml(t.service) + '">' + escHtml(t.service) + '</span>' +
|
||
'<span class="hop-label">' + escHtml(t.label) + '</span>' +
|
||
(t.durationMs > 0 ? '<span class="hop-duration">' + (t.durationMs / 1000).toFixed(3) + 's</span>' : '') +
|
||
'<span class="hop-status">' + (isError ? '❌' : '✅') + '</span>' +
|
||
'</div>' +
|
||
'<div class="trace-hop-detail">' +
|
||
'<div><strong>时间:</strong> ' + escHtml(t.timestamp) + '</div>' +
|
||
'<div><strong>服务:</strong> ' + escHtml(t.service) + '</div>' +
|
||
'<div><strong>节点:</strong> ' + escHtml(t.hop) + '</div>' +
|
||
'<div><strong>标签:</strong> ' + escHtml(t.label) + '</div>' +
|
||
(t.durationMs > 0 ? '<div><strong>耗时:</strong> ' + (t.durationMs / 1000).toFixed(3) + 's</div>' : '') +
|
||
'<div><strong>状态:</strong> ' + (isError ? '❌ 失败' : '✅ 成功') + '</div>' +
|
||
(t.detail ? '<div style="margin-top:6px"><strong>详情:</strong><br>' + escHtml(String(t.detail)) + '</div>' : '') +
|
||
'</div>';
|
||
}
|
||
|
||
function updateTraceStatsEl(statsEl, traces, stats, session) {
|
||
var servicesCount = (stats.services || []).length;
|
||
var html =
|
||
'<div class="trace-summary-item">📊 <span class="tsi-val">' + traces.length + '</span> 个追踪节点</div>' +
|
||
'<div class="trace-summary-item">🖥 <span class="tsi-val">' + servicesCount + '</span> 个服务</div>' +
|
||
(stats.errors > 0 ? '<div class="trace-summary-item" style="color:var(--red)">❌ <span class="tsi-val">' + stats.errors + '</span> 个错误</div>' : '<div class="trace-summary-item" style="color:var(--green)">✅ 全部成功</div>') +
|
||
(stats.totalSpanMs > 0 ? '<div class="trace-summary-item">⏱ 总跨度 <span class="tsi-val">' + (stats.totalSpanMs / 1000).toFixed(2) + 's</span></div>' : '') +
|
||
(stats.totalDurationMs > 0 ? '<div class="trace-summary-item">⏳ 总耗时 <span class="tsi-val">' + (stats.totalDurationMs / 1000).toFixed(2) + 's</span></div>' : '');
|
||
if (session) {
|
||
var s = session;
|
||
html +=
|
||
'<div class="trace-summary-item">💬 Session: <code style="font-size:10px">' + escHtml((s.session_id || '').substring(0, 20)) + '...</code></div>' +
|
||
'<div class="trace-summary-item">👤 User: ' + escHtml(s.user_id || '—') + '</div>';
|
||
}
|
||
statsEl.innerHTML = html;
|
||
}
|
||
|
||
// 转换 SSE 推送的 LLM CallRecord 为 trace 格式
|
||
function llmCallToTrace(call) {
|
||
var ts = call.time ? new Date(call.time).getTime() : Date.now();
|
||
return {
|
||
id: 'llm-' + ts + '-' + Math.random().toString(36).slice(2, 6),
|
||
timestamp: new Date(ts).toISOString(),
|
||
ts: ts,
|
||
service: 'ai-core',
|
||
hop: 'llm_call',
|
||
label: 'LLM 调用: ' + (call.model || 'unknown'),
|
||
status: call.success ? 'success' : 'error',
|
||
durationMs: call.duration_ms || (call.Duration ? Math.round(call.Duration / 1e6) : 0),
|
||
detail: call.error || (call.prompt_tokens || 0) + '+' + (call.completion_tokens || 0) + ' tokens',
|
||
data: call
|
||
};
|
||
}
|
||
|
||
function toggleTraceHop(el) {
|
||
var detail = el.nextElementSibling;
|
||
if (detail && detail.classList.contains('trace-hop-detail')) {
|
||
detail.classList.toggle('open');
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|