Files
Cyrene/ethend/public/index.html
T
AskaEth a9c79d7887 feat: ASR语音转写管线 + 群聊身份混淆修复
- 新增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>
2026-05-31 16:46:47 +08:00

6441 lines
302 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cyrene 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 200body 中也可能包含 error 字段(如 Gateway 代理失败返回的 502
if (body && body.error) {
return { ...body, status: res.status };
}
return body;
} catch (err) {
return { error: err.message, status: 0 };
}
}
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
clearTimeout(t._timeout);
t._timeout = setTimeout(() => { t.className = ''; }, 3000);
}
// ========== 侧边栏导航 ==========
const panels = {};
document.querySelectorAll('.panel').forEach(p => { panels[p.id.replace('panel-','')] = p; });
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => switchPanel(btn.dataset.panel));
});
document.getElementById('toggle-sidebar').addEventListener('click', () => {
STATE.sidebarCollapsed = !STATE.sidebarCollapsed;
document.getElementById('sidebar').classList.toggle('collapsed', STATE.sidebarCollapsed);
// 折叠侧边栏时强制展开所有分组,否则 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>