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:
+601
-6
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user