Add token usage tracking and UI refinements

This commit is contained in:
temp
2026-08-24 00:05:05 +08:00
parent a34cab431a
commit e218316c55
23 changed files with 1285 additions and 93 deletions
+128 -56
View File
@@ -4107,62 +4107,50 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
position: relative;
}
/* 消息复制按钮 - 位于消息气泡右下角 */
/* 消息复制按钮 - 与时间戳同一行 */
.message-copy-btn {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
position: static;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 20px;
color: #666;
font-size: 0.8125rem;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary, #888);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
z-index: 10;
opacity: 0;
transform: translateY(4px);
font-weight: 500;
}
.message-bubble:hover .message-copy-btn {
opacity: 1;
transform: translateY(0);
opacity: 0.72;
flex-shrink: 0;
transition: opacity 0.2s ease, color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.message-copy-btn:hover {
background: rgba(255, 255, 255, 1);
border-color: rgba(0, 102, 255, 0.2);
color: #0066ff;
box-shadow: 0 4px 12px rgba(0, 102, 255, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
color: var(--accent-color, #0066ff);
background: rgba(0, 102, 255, 0.07);
border-color: rgba(0, 102, 255, 0.14);
opacity: 1;
}
.message-copy-btn:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 2px 6px rgba(0, 102, 255, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06);
background: rgba(0, 102, 255, 0.11);
}
.message-copy-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.message-copy-btn:hover svg {
transform: scale(1.1);
.message-copy-btn:focus-visible {
opacity: 1;
outline: 2px solid var(--accent-color, #0066ff);
outline-offset: 1px;
}
.message-copy-btn span {
font-weight: 500;
letter-spacing: 0.01em;
display: none;
}
/* 时间戳 + 删除本轮(与气泡分离,和「展开详情」同一视觉层级) */
@@ -24302,11 +24290,15 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
/* 第一行:核心 KPI(视觉层次 + 色块强调) */
.dashboard-kpi-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 1200px) {
.dashboard-kpi-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 900px) {
.dashboard-kpi-row { grid-template-columns: repeat(2, 1fr); }
}
@@ -24466,6 +24458,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.dashboard-kpi-card:nth-child(2) { background: linear-gradient(145deg, #fff 0%, #fef2f2 100%); }
.dashboard-kpi-card:nth-child(3) { background: linear-gradient(145deg, #fff 0%, #f0fdf4 100%); }
.dashboard-kpi-card:nth-child(4) { background: linear-gradient(145deg, #fff 0%, #f0fdfa 100%); }
.dashboard-kpi-card:nth-child(5) { background: linear-gradient(145deg, #fff 0%, #f8fafc 100%); }
.dashboard-kpi-card:hover {
transform: translateY(-3px);
@@ -24494,6 +24487,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.dashboard-kpi-icon-vuln { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
.dashboard-kpi-icon-calls { background: rgba(34, 197, 94, 0.1); color: #22c55e; }
.dashboard-kpi-icon-rate { background: rgba(20, 184, 166, 0.1); color: #14b8a6; }
.dashboard-kpi-icon-tokens { background: rgba(99, 102, 241, 0.1); color: #6366f1; }
.dashboard-kpi-value {
font-size: 1.875rem;
@@ -28734,12 +28728,54 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
}
.role-selector-icon {
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
display: flex;
}
.agent-mode-logo {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--agent-logo-a);
box-shadow: none;
overflow: visible;
flex: 0 0 auto;
}
.agent-mode-logo--deep {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
}
.agent-mode-logo--plan {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
}
.agent-mode-logo--supervisor {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
}
.agent-mode-logo--default {
--agent-logo-a: #858d98;
--agent-logo-b: #858d98;
}
.agent-mode-logo__svg {
width: 100%;
height: 100%;
display: block;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.role-selector-text {
@@ -29049,6 +29085,16 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
border-color: rgba(138, 43, 226, 0.3);
box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2);
}
.role-selection-item-main .role-selection-item-icon-main.agent-mode-logo,
.role-selection-item-main:hover .role-selection-item-icon-main.agent-mode-logo,
.role-selection-item-main.selected .role-selection-item-icon-main.agent-mode-logo {
width: 28px;
height: 28px;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.role-selection-item-content-main {
display: flex;
flex-direction: column;
@@ -36309,7 +36355,8 @@ html[data-theme="dark"] .conversation-reasoning-card .hitl-reviewer-toggle-btn.i
html[data-theme="dark"] .dashboard-kpi-card:nth-child(1),
html[data-theme="dark"] .dashboard-kpi-card:nth-child(2),
html[data-theme="dark"] .dashboard-kpi-card:nth-child(3),
html[data-theme="dark"] .dashboard-kpi-card:nth-child(4) {
html[data-theme="dark"] .dashboard-kpi-card:nth-child(4),
html[data-theme="dark"] .dashboard-kpi-card:nth-child(5) {
background: linear-gradient(145deg, #111827 0%, #172033 100%);
}
@@ -36797,21 +36844,17 @@ html[data-theme="dark"] .webshell-ai-msg.assistant.webshell-ai-candidate-output
}
html[data-theme="dark"] .message-copy-btn {
background: #1f2937;
border-color: #334155;
color: var(--text-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
}
html[data-theme="dark"] .message-copy-btn:hover {
background: #263244;
background: rgba(96, 165, 250, 0.12);
border-color: rgba(96, 165, 250, 0.45);
color: var(--accent-hover);
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.18);
}
html[data-theme="dark"] .message-copy-btn:active {
box-shadow: 0 2px 6px rgba(96, 165, 250, 0.14);
background: rgba(96, 165, 250, 0.18);
}
html[data-theme="dark"] .message.user .message-bubble {
@@ -37364,6 +37407,16 @@ html[data-theme="dark"] .role-selection-item-icon-main {
border-color: rgba(148, 163, 184, 0.12) !important;
}
html[data-theme="dark"] .role-selection-item-icon-main.agent-mode-logo,
html[data-theme="dark"] .project-conversation-preview-mode-icon.agent-mode-logo,
html[data-theme="dark"] .role-selector-icon.agent-mode-logo {
--agent-logo-a: #94a3b8;
--agent-logo-b: #94a3b8;
border-color: transparent !important;
background: transparent !important;
box-shadow: none !important;
}
html[data-theme="dark"] .sidebar-list-pagination,
html[data-theme="dark"] .sidebar-list-pagination-inner,
html[data-theme="dark"] .conversation-sidebar-pagination,
@@ -45062,6 +45115,13 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
color: #858d98;
}
.project-conversation-preview-mode-icon.agent-mode-logo {
width: 16px;
height: 16px;
margin-right: 0;
color: #858d98;
}
.project-conversation-preview-project,
.project-conversation-preview-mode {
min-width: 0;
@@ -46350,15 +46410,6 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
margin-bottom: 0;
}
.message.assistant-turn-with-process .message-copy-btn {
right: 0;
bottom: -34px;
padding: 5px 9px;
border: 0;
background: transparent;
box-shadow: none;
}
.message.assistant-turn-with-process .mcp-call-section {
width: 100%;
margin: 0;
@@ -46408,8 +46459,9 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
.turn-process-leading {
display: inline-flex;
align-items: center;
gap: 9px;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.turn-process-status-dot {
@@ -46426,6 +46478,26 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
animation: codex-turn-pulse 1.55s ease-in-out infinite;
}
.turn-process-token-chip {
display: inline;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
opacity: 0.72;
white-space: nowrap;
}
.turn-process-token-chip::before {
content: "· ";
color: inherit;
font-weight: inherit;
}
@keyframes codex-turn-pulse {
0%, 100% { opacity: 0.55; transform: scale(0.88); }
50% { opacity: 1; transform: scale(1); }
+5
View File
@@ -127,6 +127,9 @@
"vulnTotal": "Total vulnerabilities",
"toolCalls": "Tool invocations",
"successRate": "Tool success rate",
"tokenUsage": "Token usage",
"tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}",
"noTokenUsageYet": "No usage yet",
"clickToViewTasks": "Click to view tasks",
"clickToViewChat": "Click to view conversations",
"clickToViewVuln": "Click to view vulnerabilities",
@@ -633,6 +636,8 @@
"turnDurationMinutes": "{{minutes}} min {{seconds}} sec",
"turnDurationHours": "{{hours}} hr {{minutes}} min",
"turnProcessAria": "{{state}}; expand or collapse execution details",
"turnTokenUsageLabel": "{{tokens}} tokens",
"turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls)",
"turnNumber": "Turn {{number}}",
"turnPending": "Processing…",
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
+5
View File
@@ -127,6 +127,9 @@
"vulnTotal": "漏洞总数",
"toolCalls": "工具调用次数",
"successRate": "工具执行成功率",
"tokenUsage": "Token 用量",
"tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}",
"noTokenUsageYet": "暂无用量",
"clickToViewTasks": "点击查看任务管理",
"clickToViewChat": "点击查看对话",
"clickToViewVuln": "点击查看漏洞管理",
@@ -621,6 +624,8 @@
"turnDurationMinutes": "{{minutes}} 分钟 {{seconds}} 秒",
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
"turnProcessAria": "{{state}},展开或收起执行过程",
"turnTokenUsageLabel": "{{tokens}} tokens",
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)",
"turnNumber": "第 {{number}} 轮",
"turnPending": "正在处理…",
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
+35
View File
@@ -0,0 +1,35 @@
const fs = require('node:fs');
const test = require('node:test');
const assert = require('node:assert/strict');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
function functionSource(source, name, nextName) {
const start = source.indexOf(`function ${name}(`);
const end = source.indexOf(`function ${nextName}(`, start);
assert.notEqual(start, -1, `${name} should exist`);
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
return source.slice(start, end);
}
test('用户和助手消息使用同一复制按钮入口', () => {
const helperSource = functionSource(chat, 'appendMessageCopyButton', 'addMessage');
const addMessageSource = functionSource(chat, 'addMessage', 'copyMessageToClipboard');
assert.match(helperSource, /classList\.contains\('assistant'\)[\s\S]*classList\.contains\('user'\)/);
assert.match(helperSource, /const footer = ensureMessageMetaFooter\(content\)/);
assert.match(helperSource, /message-bubble \.message-copy-btn/);
assert.match(helperSource, /copyMessageToClipboard\(messageDiv, this\)/);
assert.match(addMessageSource, /role === 'assistant' \|\| role === 'user'[\s\S]*messageDiv\.dataset\.originalContent = content/);
assert.match(addMessageSource, /metaFooter\.appendChild\(timeDiv\)/);
assert.match(addMessageSource, /role === 'assistant' \|\| role === 'user'[\s\S]*appendMessageCopyButton\(messageDiv\)/);
});
test('刷新消息内容时会保留或补回复制按钮', () => {
const refreshSource = functionSource(chat, 'refreshSystemReadyMessageBubbles', 'appendMessageCopyButton');
const updateSource = functionSource(monitor, 'updateAssistantBubbleContent', 'isConversationTaskRunning');
assert.match(refreshSource, /appendMessageCopyButton\(messageDiv\)/);
assert.match(updateSource, /window\.appendMessageCopyButton\(assistantElement\)/);
});
+258 -22
View File
@@ -1001,23 +1001,30 @@ function getAgentModeLabelForValue(mode) {
}
}
function getAgentModeIconForValue(mode) {
function getAgentModeIconClassForValue(mode) {
switch (mode) {
case CHAT_AGENT_MODE_EINO_SINGLE: return '';
case 'deep': return '🧩';
case 'plan_execute': return '📋';
case 'supervisor': return '🎯';
default: return '🤖';
case CHAT_AGENT_MODE_EINO_SINGLE: return 'eino';
case 'deep': return 'deep';
case 'plan_execute': return 'plan';
case 'supervisor': return 'supervisor';
default: return 'default';
}
}
function renderAgentModeLogoMarkup() {
return '<svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg>';
}
function syncAgentModeFromValue(value) {
const hid = document.getElementById('agent-mode-select');
const label = document.getElementById('agent-mode-text');
const icon = document.getElementById('agent-mode-icon');
if (hid) hid.value = value;
if (label) label.textContent = getAgentModeLabelForValue(value);
if (icon) icon.textContent = getAgentModeIconForValue(value);
if (icon) {
icon.className = 'role-selector-icon agent-mode-logo agent-mode-logo--' + getAgentModeIconClassForValue(value);
icon.innerHTML = renderAgentModeLogoMarkup();
}
document.querySelectorAll('.agent-mode-option').forEach(function (el) {
const v = el.getAttribute('data-value');
el.classList.toggle('selected', v === value);
@@ -3507,9 +3514,60 @@ function refreshSystemReadyMessageBubbles() {
bubble.innerHTML = formattedContent;
if (typeof wrapTablesInBubble === 'function') wrapTablesInBubble(bubble);
messageDiv.dataset.originalContent = text;
appendMessageCopyButton(messageDiv);
});
}
function ensureMessageMetaFooter(content) {
if (!content) return null;
let footer = content.querySelector('.message-meta-footer');
if (footer) return footer;
const timeDiv = content.querySelector('.message-time');
footer = document.createElement('div');
footer.className = 'message-meta-footer';
if (timeDiv && timeDiv.parentNode === content) {
timeDiv.parentNode.insertBefore(footer, timeDiv);
footer.appendChild(timeDiv);
} else {
content.appendChild(footer);
}
return footer;
}
function appendMessageCopyButton(messageDiv) {
if (!messageDiv) return null;
if (!messageDiv.classList || (!messageDiv.classList.contains('assistant') && !messageDiv.classList.contains('user'))) {
return null;
}
const content = messageDiv.querySelector('.message-content');
const footer = ensureMessageMetaFooter(content);
if (!footer) return null;
messageDiv.querySelectorAll('.message-bubble .message-copy-btn').forEach((btn) => btn.remove());
let copyBtn = footer.querySelector('.message-copy-btn');
if (copyBtn) return copyBtn;
copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'message-copy-btn';
copyBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg><span>' + (typeof window.t === 'function' ? window.t('common.copy') : '复制') + '</span>';
copyBtn.title = typeof window.t === 'function' ? window.t('chat.copyMessageTitle') : '复制消息内容';
copyBtn.setAttribute('aria-label', copyBtn.title);
copyBtn.onclick = function(e) {
e.stopPropagation();
copyMessageToClipboard(messageDiv, this);
};
const deleteBtn = footer.querySelector('.message-delete-turn-btn');
if (deleteBtn) {
footer.insertBefore(copyBtn, deleteBtn);
} else {
footer.appendChild(copyBtn);
}
return copyBtn;
}
window.appendMessageCopyButton = appendMessageCopyButton;
window.ensureMessageMetaFooter = ensureMessageMetaFooter;
// 添加消息(options.systemReadyMessage 为 true 时,语言切换会刷新该条文案)
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) {
const messagesDiv = document.getElementById('chat-messages');
@@ -3581,23 +3639,10 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
contentWrapper.appendChild(bubble);
// 保存原始内容到消息元素,用于复制功能
if (role === 'assistant') {
if (role === 'assistant' || role === 'user') {
messageDiv.dataset.originalContent = content;
}
// 为助手消息添加复制按钮(复制整个回复内容)- 放在消息气泡右下角
if (role === 'assistant') {
const copyBtn = document.createElement('button');
copyBtn.className = 'message-copy-btn';
copyBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg><span>' + (typeof window.t === 'function' ? window.t('common.copy') : '复制') + '</span>';
copyBtn.title = typeof window.t === 'function' ? window.t('chat.copyMessageTitle') : '复制消息内容';
copyBtn.onclick = function(e) {
e.stopPropagation();
copyMessageToClipboard(messageDiv, this);
};
bubble.appendChild(copyBtn);
}
// 添加时间戳
const timeDiv = document.createElement('div');
timeDiv.className = 'message-time';
@@ -3626,8 +3671,16 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
try {
timeDiv.dataset.messageTime = messageTime.toISOString();
} catch (e) { /* ignore */ }
contentWrapper.appendChild(timeDiv);
const metaFooter = document.createElement('div');
metaFooter.className = 'message-meta-footer';
metaFooter.appendChild(timeDiv);
contentWrapper.appendChild(metaFooter);
messageDiv.appendChild(contentWrapper);
// 为用户和助手消息添加复制按钮(复制整条消息内容)
if (role === 'assistant' || role === 'user') {
appendMessageCopyButton(messageDiv);
}
// 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建)
if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) {
@@ -4132,6 +4185,10 @@ function renderProcessDetails(messageId, processDetails, options) {
detailsContainer.dataset.lazyNotLoaded = '0';
detailsContainer.dataset.loaded = '1';
}
const turnUsageFromDetails = extractAssistantTurnTokenUsage(processDetails);
if (turnUsageFromDetails) {
setAssistantTurnTokenUsage(messageElement, turnUsageFromDetails);
}
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
processDetails = filterNoiseProcessDetails(processDetails);
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
@@ -4887,6 +4944,134 @@ function formatAssistantTurnDuration(durationMs) {
: seconds + ' 秒';
}
function assistantTurnUsageNumber(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
function normalizeAssistantTurnTokenUsage(data) {
const source = data && typeof data === 'object' ? data : {};
const usage = {
modelCalls: assistantTurnUsageNumber(source.modelCalls),
promptTokens: assistantTurnUsageNumber(source.promptTokens),
completionTokens: assistantTurnUsageNumber(source.completionTokens),
totalTokens: assistantTurnUsageNumber(source.totalTokens),
cachedTokens: assistantTurnUsageNumber(source.cachedTokens),
reasoningTokens: assistantTurnUsageNumber(source.reasoningTokens),
model: source.model != null ? String(source.model).trim() : ''
};
if (usage.totalTokens <= 0 && (usage.promptTokens > 0 || usage.completionTokens > 0)) {
usage.totalTokens = usage.promptTokens + usage.completionTokens;
}
return usage.totalTokens > 0 ? usage : null;
}
function mergeAssistantTurnTokenUsage(target, usage) {
if (!usage) return target || null;
const out = target || {
modelCalls: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cachedTokens: 0,
reasoningTokens: 0,
model: ''
};
out.modelCalls += usage.modelCalls || 0;
out.promptTokens += usage.promptTokens || 0;
out.completionTokens += usage.completionTokens || 0;
out.totalTokens += usage.totalTokens || 0;
out.cachedTokens += usage.cachedTokens || 0;
out.reasoningTokens += usage.reasoningTokens || 0;
if (!out.model && usage.model) out.model = usage.model;
return out.totalTokens > 0 ? out : null;
}
function extractAssistantTurnTokenUsage(processDetails) {
if (!Array.isArray(processDetails)) return null;
let total = null;
processDetails.forEach((detail) => {
if (!detail || String(detail.eventType || '').trim() !== 'eino_usage_summary') return;
const usage = normalizeAssistantTurnTokenUsage(detail.data);
total = mergeAssistantTurnTokenUsage(total, usage);
});
return total;
}
function setAssistantTurnTokenUsage(messageElementOrId, usage) {
const messageElement = typeof messageElementOrId === 'string'
? document.getElementById(messageElementOrId)
: messageElementOrId;
if (!messageElement || !messageElement.dataset) return;
const normalized = normalizeAssistantTurnTokenUsage(usage);
if (!normalized) {
delete messageElement.dataset.turnModelCalls;
delete messageElement.dataset.turnPromptTokens;
delete messageElement.dataset.turnCompletionTokens;
delete messageElement.dataset.turnTotalTokens;
delete messageElement.dataset.turnCachedTokens;
delete messageElement.dataset.turnReasoningTokens;
delete messageElement.dataset.turnModel;
syncAssistantTurnSummary(messageElement);
return;
}
messageElement.dataset.turnModelCalls = String(normalized.modelCalls || 0);
messageElement.dataset.turnPromptTokens = String(normalized.promptTokens || 0);
messageElement.dataset.turnCompletionTokens = String(normalized.completionTokens || 0);
messageElement.dataset.turnTotalTokens = String(normalized.totalTokens || 0);
messageElement.dataset.turnCachedTokens = String(normalized.cachedTokens || 0);
messageElement.dataset.turnReasoningTokens = String(normalized.reasoningTokens || 0);
if (normalized.model) {
messageElement.dataset.turnModel = normalized.model;
} else {
delete messageElement.dataset.turnModel;
}
syncAssistantTurnSummary(messageElement);
}
function getAssistantTurnTokenUsage(messageElement) {
if (!messageElement || !messageElement.dataset) return null;
return normalizeAssistantTurnTokenUsage({
modelCalls: messageElement.dataset.turnModelCalls,
promptTokens: messageElement.dataset.turnPromptTokens,
completionTokens: messageElement.dataset.turnCompletionTokens,
totalTokens: messageElement.dataset.turnTotalTokens,
cachedTokens: messageElement.dataset.turnCachedTokens,
reasoningTokens: messageElement.dataset.turnReasoningTokens,
model: messageElement.dataset.turnModel
});
}
function formatAssistantTurnTokenCount(value) {
const n = assistantTurnUsageNumber(value);
if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1).replace(/\.0$/, '') + 'K';
return String(n);
}
function formatAssistantTurnTokenUsageLabel(usage) {
const tokens = formatAssistantTurnTokenCount(usage && usage.totalTokens);
return typeof window.t === 'function'
? window.t('chat.turnTokenUsageLabel', { tokens: tokens })
: tokens + ' tokens';
}
function formatAssistantTurnTokenUsageTitle(usage) {
const safeUsage = usage || {};
const values = {
total: formatAssistantTurnTokenCount(safeUsage.totalTokens),
prompt: formatAssistantTurnTokenCount(safeUsage.promptTokens),
completion: formatAssistantTurnTokenCount(safeUsage.completionTokens),
cached: formatAssistantTurnTokenCount(safeUsage.cachedTokens),
reasoning: formatAssistantTurnTokenCount(safeUsage.reasoningTokens),
calls: formatAssistantTurnTokenCount(safeUsage.modelCalls),
model: safeUsage.model || ''
};
return typeof window.t === 'function'
? window.t('chat.turnTokenUsageTitle', values)
: 'Token usage: ' + values.total + ' (input ' + values.prompt + ', output ' + values.completion + ')';
}
function assistantTurnTimestamp(value) {
if (value == null || value === '') return NaN;
const n = new Date(value).getTime();
@@ -4985,6 +5170,12 @@ function syncAssistantTurnSummary(messageElementOrId) {
: 0;
}
const duration = formatAssistantTurnDuration(durationMs);
const tokenUsage = getAssistantTurnTokenUsage(messageElement);
const tokenUsageHtml = tokenUsage
? '<span class="turn-process-token-chip" title="' + escapeHtml(formatAssistantTurnTokenUsageTitle(tokenUsage)) + '">' +
escapeHtml(formatAssistantTurnTokenUsageLabel(tokenUsage)) +
'</span>'
: '';
let text;
if (status === 'running') {
text = typeof window.t === 'function' ? window.t('chat.turnElapsedRunning', { duration: duration }) : '已处理 ' + duration;
@@ -5001,6 +5192,7 @@ function syncAssistantTurnSummary(messageElementOrId) {
<span class="turn-process-leading">
<span class="turn-process-status-dot${status === 'running' ? ' is-running' : ''}" aria-hidden="true"></span>
<span class="turn-process-summary-text">${escapeHtml(text)}</span>
${tokenUsageHtml}
</span>
<svg class="turn-process-chevron" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M7.5 5.5L12 10l-4.5 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
`;
@@ -5012,8 +5204,10 @@ function syncAssistantTurnSummary(messageElementOrId) {
}
window.setAssistantTurnTiming = setAssistantTurnTiming;
window.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage;
window.syncAssistantTurnSummary = syncAssistantTurnSummary;
window.formatAssistantTurnDuration = formatAssistantTurnDuration;
window.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;
/** 渗透测试区:工具栏(展开详情 | N次工具执行)+ 独立工具列表 + 迭代时间线 */
function ensureMcpCallSectionChrome(messageElement, messageId) {
@@ -6156,6 +6350,43 @@ async function prefetchLastAssistantProcessDetails() {
}
}
async function hydrateConversationTokenUsage(conversationId, expectedSeq, signal) {
const id = String(conversationId || '').trim();
if (!id || typeof apiFetch !== 'function' || typeof window.setAssistantTurnTokenUsage !== 'function') return;
if (signal && signal.aborted) return;
const params = new URLSearchParams();
params.set('since', '1970-01-01');
params.set('limit', '500');
const res = await apiFetch(
'/api/conversations/' + encodeURIComponent(id) + '/token-usage?' + params.toString(),
signal ? { signal: signal } : undefined
);
const payload = await res.json().catch(() => ({}));
if (!res.ok || (signal && signal.aborted)) return;
if (expectedSeq != null && expectedSeq !== loadConversationRequestSeq) return;
if (currentConversationId !== id) return;
const rows = Array.isArray(payload && payload.recent) ? payload.recent : [];
if (rows.length === 0) return;
const byMessage = new Map();
rows.forEach((row) => {
const messageId = row && row.messageId != null ? String(row.messageId).trim() : '';
if (!messageId) return;
const usage = normalizeAssistantTurnTokenUsage(row);
if (!usage) return;
byMessage.set(messageId, mergeAssistantTurnTokenUsage(byMessage.get(messageId) || null, usage));
});
if (byMessage.size === 0) return;
document.querySelectorAll('#chat-messages .message.assistant[data-backend-message-id]').forEach((messageElement) => {
const backendMessageId = messageElement && messageElement.dataset
? String(messageElement.dataset.backendMessageId || '').trim()
: '';
const usage = backendMessageId ? byMessage.get(backendMessageId) : null;
if (usage) {
window.setAssistantTurnTokenUsage(messageElement, usage);
}
});
}
async function loadConversation(conversationId) {
conversationId = String(conversationId || '').trim();
if (!conversationId) return;
@@ -6458,6 +6689,11 @@ async function loadConversation(conversationId) {
if (seq !== loadConversationRequestSeq) {
return;
}
hydrateConversationTokenUsage(conversationId, seq, conversationLoadController.signal).catch((e) => {
if (!e || e.name !== 'AbortError') {
console.warn('hydrateConversationTokenUsage failed', e);
}
});
if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') {
await window.restoreHitlInlineForConversation(conversationId);
}
+44 -2
View File
@@ -66,10 +66,12 @@ async function refreshDashboard() {
setDashboardOverviewPlaceholder('…');
setEl('dashboard-kpi-tools-calls', '…');
setEl('dashboard-kpi-success-rate', '…');
setEl('dashboard-kpi-token-usage', '…');
setKpiSubText('dashboard-kpi-tasks-sub-text', '…');
setKpiSubText('dashboard-kpi-vuln-sub-text', '…');
setKpiSubText('dashboard-kpi-tools-sub-text', '…');
setKpiSubText('dashboard-kpi-rate-sub-text', '…');
setKpiSubText('dashboard-kpi-token-sub-text', '…');
hideEl('dashboard-kpi-vuln-critical-badge');
hideEl('dashboard-alert-banner');
setRecentVulnsLoading();
@@ -127,7 +129,7 @@ async function refreshDashboard() {
hitlPendingRes, notificationsRes, externalMcpStatsRes,
webshellRes,
c2ListenersRes, c2SessionsRes, c2TasksRes,
projectSummaryRes, severityFilteredStatsRes
projectSummaryRes, severityFilteredStatsRes, tokenUsageRes
] = await Promise.all([
fetchJson('/api/agent-loop/tasks'),
fetchJson('/api/vulnerabilities/stats'),
@@ -159,7 +161,8 @@ async function refreshDashboard() {
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null)
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null),
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=5'))
]);
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
@@ -330,6 +333,8 @@ async function refreshDashboard() {
renderDashboardToolsBar(null);
}
renderDashboardTokenUsage(tokenUsageRes);
// 「能力总览 → MCP 工具」用配置总数(包含未被调用过的工具);专项接口失败时回落到 monitor 的 names.length
if (toolsConfigRes && typeof toolsConfigRes.total === 'number') {
setEl('dashboard-resource-tools', formatNumber(toolsConfigRes.total));
@@ -435,10 +440,12 @@ async function refreshDashboard() {
setDashboardOverviewPlaceholder('-');
setEl('dashboard-kpi-success-rate', '-');
setEl('dashboard-kpi-tools-calls', '-');
setEl('dashboard-kpi-token-usage', '-');
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
setKpiSubText('dashboard-kpi-rate-sub-text', '-');
setKpiSubText('dashboard-kpi-token-sub-text', '-');
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
setEl('dashboard-resource-' + k, '-');
});
@@ -700,6 +707,41 @@ function setKpiRateBadge(id, rate, failedCount) {
}
}
function renderDashboardTokenUsage(res) {
const summary = res && res.summary ? res.summary : null;
if (!summary) {
setEl('dashboard-kpi-token-usage', '-');
setKpiSubText('dashboard-kpi-token-sub-text', '-');
return;
}
const total = Number(summary.totalTokens || 0);
const calls = Number(summary.modelCalls || 0);
const today = res && res.today ? Number(res.today.totalTokens || 0) : 0;
if (!Number.isFinite(total) || total <= 0) {
setEl('dashboard-kpi-token-usage', '0');
setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.noTokenUsageYet', null, '暂无用量'));
return;
}
setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total));
setKpiSubText('dashboard-kpi-token-sub-text',
dt('dashboard.tokenUsageSub', {
today: formatTokenUsageCompact(today),
calls: Number.isFinite(calls) ? calls : 0
}, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
}
function formatTokenUsageCompact(num) {
const n = Number(num || 0);
if (!Number.isFinite(n) || n <= 0) return '0';
if (n >= 1000000) {
return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
}
if (n >= 1000) {
return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'K';
}
return String(Math.trunc(n));
}
// sessionStorage:告警条「×」忽略记录 + 最近一次**实际展示过**的 reason 片段(不含 level),
// 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。
var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert';
+4 -2
View File
@@ -1044,7 +1044,7 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
const bubble = assistantElement.querySelector('.message-bubble');
if (!bubble) return;
// 保留复制按钮:addMessage 会把按钮 append 在 message-bubble 里
// 清理旧版本可能残留在气泡内的复制按钮;新版按钮统一在时间行。
const copyBtn = bubble.querySelector('.message-copy-btn');
if (copyBtn) copyBtn.remove();
@@ -1066,7 +1066,9 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
if (typeof wrapTablesInBubble === 'function') {
wrapTablesInBubble(bubble);
}
if (copyBtn) bubble.appendChild(copyBtn);
if (typeof window.appendMessageCopyButton === 'function') {
window.appendMessageCopyButton(assistantElement);
}
if (typeof window.csMarkdownSanitize !== 'undefined') {
window.csMarkdownSanitize.stripSuspiciousImages(bubble);
@@ -138,3 +138,17 @@ test('对话悬浮预览标题与时间分行显示并保留更多标题内容',
assert.match(titleStyles, /-webkit-line-clamp: 2;/);
assert.doesNotMatch(titleStyles, /white-space: nowrap;/);
});
test('对话悬浮预览使用美化后的代理模式徽标', () => {
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
assert.match(projects, /function getProjectConversationModeIconClass\(conversation\)/);
assert.match(projects, /project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--default/);
assert.match(projects, /agent-mode-logo__svg/);
assert.match(projects, /<rect x="3" y="11" width="18" height="10" rx="2"/);
assert.match(projects, /agent-mode-logo--' \+ getProjectConversationModeIconClass\(conversation\)/);
assert.match(styles, /\.agent-mode-logo\s*\{[\s\S]*?background: transparent;/);
assert.match(styles, /\.agent-mode-logo__svg\s*\{[\s\S]*?stroke: currentColor;[\s\S]*?stroke-width: 1\.9;/);
assert.match(styles, /\.project-conversation-preview-mode-icon\.agent-mode-logo\s*\{[\s\S]*?width: 16px;/);
assert.doesNotMatch(cssBlock(styles, '.agent-mode-logo'), /linear-gradient|box-shadow: 0 5px/);
});
+14 -1
View File
@@ -3084,6 +3084,15 @@ function getProjectConversationModeLabel(conversation) {
);
}
function getProjectConversationModeIconClass(conversation) {
const mode = String(conversation?.agentMode || conversation?.agent_mode || '').trim().toLowerCase();
if (mode === 'eino_single') return 'eino';
if (mode === 'deep') return 'deep';
if (mode === 'plan_execute') return 'plan';
if (mode === 'supervisor') return 'supervisor';
return 'default';
}
function ensureProjectConversationPreview() {
let preview = document.getElementById('project-conversation-preview');
if (preview) return preview;
@@ -3102,7 +3111,7 @@ function ensureProjectConversationPreview() {
<span class="project-conversation-preview-project"></span>
</div>
<div class="project-conversation-preview-meta">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="6" cy="5" r="2" stroke="currentColor" stroke-width="1.7"/><circle cx="6" cy="19" r="2" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="9" r="2" stroke="currentColor" stroke-width="1.7"/><path d="M6 7v10M8 15c5 0 3-6 8-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<span class="project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--default" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></span>
<span class="project-conversation-preview-mode"></span>
<span class="project-conversation-preview-separator" aria-hidden="true">·</span>
<span class="project-conversation-preview-status"></span>
@@ -3165,6 +3174,10 @@ function showProjectConversationPreview(conversation, project, row) {
ageEl.hidden = !ageEl.textContent;
preview.querySelector('.project-conversation-preview-project').textContent = project?.name
|| pickerMessage(tp, 'chat.conversationPreviewNoProject', '未绑定项目');
const modeIcon = preview.querySelector('.project-conversation-preview-mode-icon');
if (modeIcon) {
modeIcon.className = 'project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--' + getProjectConversationModeIconClass(conversation);
}
preview.querySelector('.project-conversation-preview-mode').textContent = getProjectConversationModeLabel(conversation);
statusEl.textContent = status;
statusEl.className = 'project-conversation-preview-status'
+40 -1
View File
@@ -66,7 +66,7 @@ function createHarness(nowMs) {
clearInterval() {},
};
vm.runInNewContext(
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming;`,
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming; this.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage; this.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;`,
context
);
return context;
@@ -103,6 +103,45 @@ test('已完成任务仍优先使用持久化耗时', () => {
assert.match(message.label.innerHTML, /耗时 1 分钟 5 秒/);
});
test('助手轮次摘要会显示持久化 token 用量', () => {
const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z'));
const message = createMessage();
context.setAssistantTurnTiming(message, {
startedAt: '2026-08-12T02:00:00.000Z',
completedAt: '2026-08-12T02:00:03.000Z',
durationMs: 3000,
status: 'completed',
});
context.setAssistantTurnTokenUsage(message, {
promptTokens: 1200,
completionTokens: 34,
cachedTokens: 200,
reasoningTokens: 12,
modelCalls: 1,
model: 'deepseek-v3',
});
assert.equal(message.dataset.turnTotalTokens, '1234');
assert.match(message.label.innerHTML, /1\.2K tokens/);
assert.match(message.label.innerHTML, /turn-process-token-chip/);
});
test('助手轮次可从 Eino usage summary 过程详情提取 token 用量', () => {
const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z'));
const usage = context.extractAssistantTurnTokenUsage([
{ eventType: 'progress', data: { totalTokens: 9999 } },
{ eventType: 'eino_usage_summary', data: { promptTokens: 400, completionTokens: 100, modelCalls: 1 } },
{ eventType: 'eino_usage_summary', data: { totalTokens: 25, modelCalls: 1 } },
]);
assert.equal(usage.totalTokens, 525);
assert.equal(usage.promptTokens, 400);
assert.equal(usage.completionTokens, 100);
assert.equal(usage.modelCalls, 2);
});
test('已中断任务使用固定终态耗时且不再按当前时间增长', () => {
const context = createHarness(Date.parse('2026-08-13T12:00:00.000Z'));
const message = createMessage();