fix: 消息日志增强 + 历史消息抑制 + SSE实时追踪 + 群聊上下文优化

- 日志:收/发消息均显示群名称,管理员显示真实QQ昵称而非"开拓者"
- 历史消息:服务重启后NapCat回放的历史消息不再触发回复,静默注入上下文
- 消息时间戳:转发给AI时附带【消息时间: HH:MM:SS (XmXs前)】标记
- ♪ 分割符:QQ消息支持♪作为句子断点
- AI-Core SSE端点:全链路追踪实时推送,ethend不再5秒轮询
- 群聊上下文:AI-Core明确被告知消息来自群聊,以实际发送者为主语

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 11:49:36 +08:00
parent 677385ec17
commit 3ad728406e
13 changed files with 587 additions and 156 deletions
+206 -108
View File
@@ -1200,8 +1200,8 @@ function switchPanel(name) {
history.replaceState(null, '', '#' + name);
}
// 停止链路追踪定时器
stopTraceAutoRefresh();
// 停止链路追踪 SSE
disconnectTraceStream();
// 更新侧边栏
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
@@ -1232,24 +1232,24 @@ function switchPanel(name) {
// 渲染面板
switch (name) {
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
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;
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
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(); break;
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 '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;
}
}
@@ -6135,22 +6135,23 @@ async function renderVMMonitorPanel() {
}
// ========== 全链路追踪面板 ==========
var traceAutoRefreshId = null;
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>' +
'<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>';
'<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>' +
'<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>' +
@@ -6166,35 +6167,69 @@ function renderTracePanel() {
'</div>' +
'</div>';
connectTraceStream();
refreshTrace();
}
function switchTraceMode(mode) {
traceMode = mode;
stopTraceAutoRefresh();
document.getElementById('trace-auto-refresh').checked = false;
traceSeenIds.clear();
renderTracePanel();
}
function toggleTraceAutoRefresh() {
var checked = document.getElementById('trace-auto-refresh').checked;
if (checked) {
startTraceAutoRefresh();
// --- 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 {
stopTraceAutoRefresh();
el.innerHTML = '⚫ 离线';
el.style.color = 'var(--text3)';
}
}
function startTraceAutoRefresh() {
stopTraceAutoRefresh();
traceAutoRefreshId = setInterval(function() {
if (STATE.activePanel === 'trace') refreshTrace();
}, 5000);
function getAICoreBaseUrl() {
return 'http://localhost:8081';
}
function stopTraceAutoRefresh() {
if (traceAutoRefreshId) { clearInterval(traceAutoRefreshId); traceAutoRefreshId = null; }
}
// --- 初始加载 & 手动刷新 ---
async function refreshTrace() {
var contentEl = document.getElementById('trace-content');
@@ -6202,8 +6237,6 @@ async function refreshTrace() {
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();
@@ -6242,93 +6275,158 @@ async function refreshTrace() {
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>';
// 筛选出新的追踪节点
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]);
}
}
statsEl.innerHTML = statsHtml;
// 更新侧边栏徽章
// 如果是首次加载(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';
}
}
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 操作 ---
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);
}
// 保存当前展开状态,重建 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);
}
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);
}
}
// 渲染时间线
var html = '<div class="trace-timeline">';
pruneTimeline();
}
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>';
}
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');
}
}
html += gapHtml;
function traceHopHtml(t, prevT) {
var time = new Date(t.timestamp).toISOString().replace('T', ' ').slice(11, 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 ? (t.durationMs / 1000).toFixed(2) + 's' : t.durationMs + 'ms') + '</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 ? (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>';
}
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-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>';
'<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;
}
html += '</div>';
contentEl.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 || 0,
detail: call.error || (call.prompt_tokens || 0) + '+' + (call.completion_tokens || 0) + ' tokens',
data: call
};
}
function toggleTraceHop(el) {