fix: 修复 AI 回复无法送达发送者 + 重复消息 + action角色泄露 + OS环境支持

广播逻辑重构:
- AI 回复 (stream_start/response/stream_segments/multi_message/stream_end) 改用 broadcastToUser 发送给所有客户端
- 用户消息回显保持 broadcastToUserExcept 排除发送者

消息去重与角色修复:
- CacheMessage(user) 移至回复生成后,避免本轮 LLM 调用出现重复用户消息
- action 角色消息在 DB 存储时映射为 assistant,DeepSeek 等模型不支持自定义角色
- stream_end defer 机制确保错误路径也会终止客户端思考指示器

OS 完整环境支持:
- host 包重构为 HostBackend 接口 + Direct/WSL/Docker 三种后端
- 新增 os_exec/os_file/os_system 工具供 AI 在完整 Linux 环境中自由操作

其他:
- 视觉模型注入 + 图片预处理后清空 Images 避免传给 Chat 模型
- 图片 URL 相对路径→绝对 URL 转换
- DevTools 链路追踪页面 + 重启修复
- 记忆搜索模糊匹配增强
- 后台思考定时调度支持
- 管理后台页面 (模型配置/用户管理等)
- docs/api 更新广播机制说明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:46:17 +08:00
parent aac64ed8b7
commit 91c9ee4b2d
49 changed files with 5032 additions and 299 deletions
+601 -6
View File
@@ -670,6 +670,76 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
}
.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>
@@ -714,6 +784,14 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<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">
@@ -785,6 +863,8 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<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>
<!-- 语音识别日志 -->
@@ -801,6 +881,7 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<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>
@@ -843,6 +924,10 @@ const STATE = {
memoryFilterImportance: 0,
memorySearchText: '',
memoryPanelInitialized: false,
memoryOffset: 0,
memoryLimit: 50,
memoryHasMore: true,
memoryLoadingMore: false,
// STT 语音识别日志面板状态
sttLogs: [],
sttAutoRefresh: null,
@@ -853,6 +938,9 @@ const STATE = {
timelineFilterType: 'all',
timelineAutoRefresh: null,
timelineLimit: 100,
timelineOffset: 0,
timelineHasMore: true,
timelineLoadingMore: false,
// 第三方聊天
chatConfigsAutoRefresh: null,
chatConfigs: [],
@@ -1086,6 +1174,9 @@ function switchPanel(name) {
history.replaceState(null, '', '#' + name);
}
// 停止链路追踪定时器
stopTraceAutoRefresh();
// 更新侧边栏
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-item[data-panel="${name}"]`);
@@ -1101,6 +1192,8 @@ function switchPanel(name) {
modelConfig: '🤖 模型配置管理',
thinkingSchedule: '⏰ 思考调度配置',
llmCalls: '📊 LLM 调用日志',
vmMonitor: '🖥 VM 监控',
trace: '🔗 全链路追踪',
};
document.getElementById('panel-title').textContent = titles[name] || name;
@@ -1120,6 +1213,7 @@ function switchPanel(name) {
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'vmMonitor': renderVMMonitorPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'plugins': renderPluginsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
@@ -1130,6 +1224,7 @@ function switchPanel(name) {
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'thinkingSchedule': renderThinkingSchedulePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'llmCalls': renderLlmCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'trace': renderTracePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
}
}
@@ -1652,8 +1747,10 @@ 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)}`);
const data = await api(`/api/memory/list?user_id=${encodeURIComponent(userId)}&limit=${STATE.memoryLimit}&offset=0`);
if (data.error) {
let hint = '';
@@ -1678,9 +1775,46 @@ async function loadMemories() {
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() || '';
@@ -1714,6 +1848,8 @@ async function searchMemory() {
else if (data.results) memories = data.results;
STATE.memoryCache = memories;
STATE.memoryOffset = memories.length;
STATE.memoryHasMore = false; // search doesn't support pagination
filterAndRenderMemories();
}
@@ -3480,9 +3616,11 @@ async function renderTimelinePanel() {
'自动刷新 (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);
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) +
@@ -3493,6 +3631,8 @@ async function renderTimelinePanel() {
var timeline = data.timeline || [];
var stats = data.stats || {};
STATE.timelineData = timeline;
STATE.timelineOffset = timeline.length;
STATE.timelineHasMore = data.hasMore !== false;
// 筛选
var filtered = timeline;
@@ -3617,7 +3757,110 @@ async function renderTimelinePanel() {
timelineHtml += '</div>';
}
container.innerHTML = statsCardsHtml + filterHtml + timelineHtml;
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;
}
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) {
@@ -5029,7 +5272,7 @@ function formatTokens(n) {
// 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'];
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);
}
@@ -5040,7 +5283,7 @@ 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'];
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 {
@@ -5050,6 +5293,358 @@ if (initHash && validPanels.indexOf(initHash) >= 0) {
// 全局状态定时刷新
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;
}
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 traceAutoRefreshId = null;
var traceMode = 'recent'; // 'recent' | 'session'
var traceSessionId = '';
function renderTracePanel() {
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="refreshTrace()" id="trace-refresh-btn">🔄 刷新</button>' +
'<label style="font-size:11px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer">' +
'<input type="checkbox" id="trace-auto-refresh" onchange="toggleTraceAutoRefresh()"> 自动刷新 (5s)' +
'</label>';
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>';
refreshTrace();
}
function switchTraceMode(mode) {
traceMode = mode;
stopTraceAutoRefresh();
document.getElementById('trace-auto-refresh').checked = false;
renderTracePanel();
}
function toggleTraceAutoRefresh() {
var checked = document.getElementById('trace-auto-refresh').checked;
if (checked) {
startTraceAutoRefresh();
} else {
stopTraceAutoRefresh();
}
}
function startTraceAutoRefresh() {
stopTraceAutoRefresh();
traceAutoRefreshId = setInterval(function() {
if (STATE.activePanel === 'trace') refreshTrace();
}, 5000);
}
function stopTraceAutoRefresh() {
if (traceAutoRefreshId) { clearInterval(traceAutoRefreshId); traceAutoRefreshId = null; }
}
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 servicesCount = (stats.services || []).length;
var statsHtml =
'<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>' : '');
// Session info
if (data.session) {
var s = data.session;
statsHtml +=
'<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 = statsHtml;
// 更新侧边栏徽章
var badge = document.getElementById('trace-badge');
if (badge) {
badge.textContent = traces.length;
badge.style.display = traces.length > 0 ? 'inline-block' : 'none';
}
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>';
return;
}
// 保存当前展开状态,重建 DOM 后恢复
var expandedHops = [];
var existingHops = contentEl.querySelectorAll('.trace-hop-detail.open');
for (var ei = 0; ei < existingHops.length; ei++) {
// 用 hop 的 label+timestamp 做 key 来恢复
var prevHop = existingHops[ei].previousElementSibling;
if (prevHop) {
var hopLabel = prevHop.querySelector('.hop-label');
var hopTime = prevHop.querySelector('.hop-time');
if (hopLabel && hopTime) {
expandedHops.push(hopTime.textContent + '|' + hopLabel.textContent);
}
}
}
// 渲染时间线
var html = '<div class="trace-timeline">';
for (var i = 0; i < traces.length; i++) {
var t = traces[i];
var time = new Date(t.timestamp).toISOString().replace('T', ' ').slice(11, 19);
var isError = t.status === 'error';
var hopKey = time + '|' + t.label;
var wasExpanded = expandedHops.indexOf(hopKey) >= 0;
var gapHtml = '';
// 显示与上一跳的时间间隔
if (i > 0) {
var gap = t.ts - traces[i - 1].ts;
if (gap > 50) {
gapHtml = '<div 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>';
}
}
html += gapHtml;
html +=
'<div class="trace-hop ' + (isError ? 'error' : 'success') + '" 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 ? (t.durationMs / 1000).toFixed(2) + 's' : t.durationMs + 'ms') + '</span>' : '') +
'<span class="hop-status">' + (isError ? '❌' : '✅') + '</span>' +
'</div>' +
'<div class="trace-hop-detail' + (wasExpanded ? ' open' : '') + '">' +
'<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 ? (t.durationMs / 1000).toFixed(2) + 's' : t.durationMs + 'ms') + '</div>' : '') +
'<div><strong>状态:</strong> ' + (isError ? '❌ 失败' : '✅ 成功') + '</div>' +
(t.detail ? '<div style="margin-top:6px"><strong>详情:</strong><br>' + escHtml(String(t.detail)) + '</div>' : '') +
'</div>';
}
html += '</div>';
contentEl.innerHTML = html;
}
function toggleTraceHop(el) {
var detail = el.nextElementSibling;
if (detail && detail.classList.contains('trace-hop-detail')) {
detail.classList.toggle('open');
}
}
</script>
</body>
</html>