Merge remote-tracking branch 'origin/main' into codex/fix-fofa-region

This commit is contained in:
Ed1s0nZ committed 2026-10-16 16:17:04 +08:00
commit ee5e4dd7c7
110 files changed
+7762 -409

No files matched your search

+115 -6
View File
@@ -174,7 +174,9 @@
3. 顶部应用栏
-------------------------------------------------------------------------- */
@media (max-width: 767.98px) {
header {
/* Only the application bar has a fixed height and overlays page content.
Nested headers (e.g. project details/settings) must grow with their content. */
.container > header {
height: var(--m-appbar-h);
min-height: var(--m-appbar-h);
padding: 0 var(--m-gutter);
@@ -876,6 +878,15 @@
border-radius: 10px;
}
/* Preserve the fact search icon gutter after the generic mobile input padding. */
.projects-fact-filter-field--search input {
padding-left: 34px;
}
.projects-fact-search-icon {
bottom: calc((var(--m-touch) - 16px) / 2);
}
textarea {
min-height: 96px;
resize: vertical;
@@ -2110,6 +2121,104 @@ body.m-panel-open .chat-input-container {
overflow: visible !important;
}
/* Projects: one vertical scroller must fit between the page heading and
bottom navigation. Desktop viewport minimums and nested flex scrollers
otherwise put the final rows outside the clipped mobile page. */
#page-projects .page-content.projects-page-layout {
display: block;
min-height: 0;
overflow-y: auto;
}
#page-projects .projects-sidebar-card {
height: auto;
min-height: 0;
max-height: none;
margin-bottom: 12px;
}
#page-projects .projects-list {
max-height: 220px;
}
#page-projects .projects-detail,
#page-projects .projects-detail-inner,
#page-projects .projects-panel,
#page-projects .projects-table-wrap,
#page-projects .projects-settings-layout {
flex: none;
height: auto;
min-height: 0;
max-height: none;
}
#page-projects .projects-panel,
#page-projects .projects-settings-layout {
overflow: visible;
}
#page-projects .projects-table-wrap {
overflow-x: auto;
overscroll-behavior-y: auto;
}
/* The project wrapper already owns scrolling; the generic mobile wrapper
must not add a second scrollbar or negative gutters inside the card. */
#page-projects .projects-table-wrap > .m-table-scroll {
overflow: visible;
margin: 0;
padding: 0;
background: none;
}
/* Eight fact columns need readable widths. Keep one horizontal scroll area
and let actions scroll with their row instead of covering other cells. */
#page-projects #project-panel-facts .data-table--projects {
width: 1440px;
min-width: 1440px;
}
#page-projects #project-panel-facts th:nth-child(1) { width: 160px; }
#page-projects #project-panel-facts th:nth-child(2) { width: 120px; }
#page-projects #project-panel-facts th:nth-child(3) { width: 280px; }
#page-projects #project-panel-facts th:nth-child(4) { width: 100px; }
#page-projects #project-panel-facts th:nth-child(5) { width: 120px; }
#page-projects #project-panel-facts th:nth-child(6) { width: 140px; }
#page-projects #project-panel-facts th:nth-child(7) { width: 160px; }
#page-projects #project-panel-facts th:nth-child(8) { width: 360px; }
#page-projects #project-panel-facts .data-table--projects .col-actions {
position: static;
max-width: none;
box-shadow: none;
}
#page-projects #project-panel-facts .col-actions .projects-table-actions {
flex-wrap: wrap;
}
#page-projects #project-panel-facts :is(.projects-category, .projects-fact-badge, .projects-confidence) {
max-width: 100%;
box-sizing: border-box;
white-space: normal;
overflow-wrap: anywhere;
}
#page-projects #project-panel-assets .data-table--projects {
height: auto;
}
#page-projects #project-panel-assets .data-table--projects tbody {
flex: none;
overflow: visible;
}
#page-projects .project-fact-graph-layout {
flex: none;
height: 360px;
max-height: none;
}
/* 21.5 C2 会话「重置」按钮实测 24x44,横向点不中 */
#page-c2-sessions button,
.c2-sessions-page button {
@@ -3047,10 +3156,10 @@ html[data-theme="dark"] {
@media (max-width: 767.98px) {
/* 25.1 顶栏 / 底栏:透出材质 + 发丝分隔线。
顶栏必须写成 html[data-theme] header:style.css 用同一条特异性给暗色塞了
顶栏必须带 html[data-theme]:style.css 用主题选择器给暗色塞了
不透明渐变,只写 header 会在暗色下丢掉材质(实测 background 被压成渐变)。 */
header,
html[data-theme] header {
.container > header,
html[data-theme] .container > header {
background: var(--m-material-solid);
border-bottom: 1px solid var(--m-hairline);
box-shadow: none;
@@ -3063,8 +3172,8 @@ html[data-theme="dark"] {
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
header,
html[data-theme] header {
.container > header,
html[data-theme] .container > header {
background: var(--m-material);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
+149
View File
@@ -16689,6 +16689,63 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
background: rgba(15, 23, 42, 0.3);
}
.attack-chain-context {
margin: 14px 14px 0;
padding: 12px;
flex-shrink: 0;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.8125rem;
}
.attack-chain-context[hidden] {
display: none;
}
.attack-chain-context summary {
cursor: pointer;
font-weight: 600;
overflow-wrap: anywhere;
}
.attack-chain-context-notice {
color: var(--text-secondary);
font-size: 0.75rem;
line-height: 1.6;
margin: 10px 0;
}
.attack-chain-context-entry {
border-top: 1px solid var(--border-color);
margin-top: 10px;
padding-top: 10px;
}
.attack-chain-context-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 360px;
overflow: auto;
font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
line-height: 1.6;
margin: 10px 0;
}
.attack-chain-context-copy {
font-size: 0.75rem;
padding: 5px 8px;
}
.attack-chain-context-copy-status {
display: block;
color: var(--text-secondary);
font-size: 0.75rem;
margin-top: 6px;
}
.attack-chain-toolbar {
padding: 14px 18px;
border-bottom: 1px solid rgba(15, 23, 42, 0.07);
@@ -17051,6 +17108,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
display: none !important;
}
/* 激活态:详情独占 sidebar 时一并收起压缩摘要卡,两者会争夺同一份高度 */
.attack-chain-sidebar.details-active .attack-chain-context {
display: none !important;
}
/* 激活态:让 legend 容器成为可撑满的 flex 列,详情占满剩余空间 */
.attack-chain-sidebar.details-active .attack-chain-legend {
flex: 1 1 auto;
@@ -28051,6 +28113,16 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
border-top: 1px solid rgba(0, 0, 0, 0.06);
order: 2;
}
/* 侧栏整体只有 200px,摘要正文再留 360px 会变成嵌套滚动 */
.attack-chain-context {
margin: 8px 8px 0;
padding: 10px;
}
.attack-chain-context-body {
max-height: 140px;
}
.attack-chain-visualization-area {
order: 1;
@@ -47219,3 +47291,80 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
.update-adopt-confirm-btn {
align-self: flex-start;
}
/* Provider-reported token analytics in System Settings. */
.token-usage-panel { --token-cache-color: #0d9488; max-width: 1200px; margin: 0 auto 24px; }
html[data-theme="dark"] .token-usage-panel { --token-cache-color: #2dd4bf; }
.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.token-usage-subtitle { font-size: 13px; color: var(--text-secondary); margin: 8px 0 0; }
.token-usage-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.token-usage-scope { font-size: 11px; padding: 4px 8px; border-radius: 6px; color: var(--text-secondary); background: var(--bg-secondary); }
.token-usage-toolbar label { white-space: nowrap; font-size: 13px; }
.token-usage-toolbar select { padding: 7px 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); }
.token-usage-note { color: var(--text-secondary); font-size: 12px; line-height: 1.7; margin: 16px 0 20px; }
.token-usage-note summary { cursor: pointer; width: fit-content; }
.token-usage-note p { margin: 8px 0 0; max-width: 960px; }
.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.token-usage-metric { min-width: 0; padding: 16px; border: 1px solid var(--border-color); border-radius: 12px; }
.token-usage-metric span { display: block; color: var(--text-secondary); font-size: 12px; }
.token-usage-metric strong { display: block; margin-top: 10px; font-size: clamp(20px, 1.65vw, 28px); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -.025em; }
.token-usage-primary [data-metric="usageTotal"] { background: rgba(0, 102, 255, .05); border-color: rgba(0, 102, 255, .18); }
.token-usage-primary [data-metric="usageTotal"] strong { color: var(--accent-color); }
.token-usage-primary [data-metric="usageCacheRate"] strong { color: var(--token-cache-color); }
.token-usage-secondary { margin: 12px 0 24px; gap: 0; padding: 14px 0; border: 1px solid var(--border-color); border-radius: 12px; }
.token-usage-secondary .token-usage-metric { border: 0; border-radius: 0; padding: 0 16px; }
.token-usage-secondary .token-usage-metric + .token-usage-metric { border-left: 1px solid var(--border-color); }
.token-usage-secondary strong { margin-top: 6px; font-size: 18px; }
.token-usage-charts { display: grid; grid-template-columns: minmax(0, 2fr) minmax(235px, 1fr); gap: 16px; margin-bottom: 24px; }
.token-usage-chart-card, .token-usage-data-card { min-width: 0; border: 1px solid var(--border-color); border-radius: 12px; padding: 18px; }
.token-usage-panel h4 { margin: 0 0 16px; font-size: 14px; }
.token-usage-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.token-usage-chart-head h4 { margin: 0; }
.token-usage-chart-tabs { display: flex; flex-wrap: wrap; gap: 4px; }
.token-usage-chart-tabs button { cursor: pointer; border: 0; border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 11px; color: var(--text-secondary); background: transparent; }
.token-usage-chart-tabs button[aria-pressed="true"] { color: var(--accent-color); background: rgba(0, 102, 255, .08); }
.token-usage-chart-tabs button:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }
.token-usage-trend-svg { display: block; width: 100%; height: auto; overflow: visible; }
.token-usage-svg-wrap { overflow-x: auto; }
.token-usage-chart-grid { stroke: var(--border-color); stroke-dasharray: 3 4; }
.token-usage-chart-label { fill: var(--text-secondary); font-size: 11px; }
.token-usage-chart-tooltip { font-size: 11px; color: var(--text-secondary); line-height: 1.6; min-height: 35px; margin: 8px 0 0; }
.token-usage-chart-bar { fill: var(--accent-color); opacity: .8; }
.token-usage-chart-bar:hover, .token-usage-chart-bar:focus { opacity: 1; outline: none; stroke: var(--text-primary); stroke-width: 1; }
.token-usage-cache-ring { --cache-ratio: 0%; position: relative; width: 148px; height: 148px; margin: 8px auto 18px; border-radius: 50%; background: conic-gradient(var(--token-cache-color) var(--cache-ratio), var(--border-color) 0); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
.token-usage-cache-ring::before { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: var(--bg-primary); }
.token-usage-cache-ring strong, .token-usage-cache-ring span { position: relative; }
.token-usage-cache-ring strong { font-size: 28px; font-variant-numeric: tabular-nums; }
.token-usage-cache-ring span { color: var(--text-secondary); font-size: 12px; }
.token-usage-cache-legend { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; margin: 10px 0; }
.token-usage-cache-legend > span::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 2px; background: var(--border-color); margin-right: 7px; }
.token-usage-cache-legend.is-cached > span::before { background: var(--token-cache-color); }
.token-usage-cache-legend strong { font-variant-numeric: tabular-nums; }
.token-usage-chart-note { font-size: 11px; line-height: 1.6; color: var(--text-secondary); margin: 14px 0 0; }
.token-usage-data-card { margin-bottom: 16px; }
.token-usage-data-card > summary { cursor: pointer; font-size: 14px; font-weight: 600; }
.token-usage-data-card[open] > summary { margin-bottom: 16px; }
.token-usage-table-wrap { overflow-x: auto; }
.token-usage-table-wrap table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
.token-usage-table-wrap th, .token-usage-table-wrap td { padding: 12px 10px; text-align: right; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
.token-usage-table-wrap th { font-weight: 500; color: var(--text-secondary); background: var(--bg-secondary); }
.token-usage-table-wrap th:first-child, .token-usage-table-wrap td:first-child { text-align: left; }
.token-usage-table-wrap td:first-child { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
.token-usage-table-wrap tbody tr:last-child td { border-bottom: 0; }
.token-usage-share { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.token-usage-share-track { width: 48px; height: 4px; border-radius: 4px; background: var(--border-color); overflow: hidden; }
.token-usage-share-track > span { display: block; height: 100%; background: var(--accent-color); }
.token-usage-status { display: inline-block; padding: 3px 7px; border-radius: 5px; background: var(--bg-secondary); color: var(--text-secondary); }
.token-usage-status.is-final { background: rgba(13, 148, 136, .1); color: var(--token-cache-color); }
.token-usage-status.is-partial { background: rgba(217, 119, 6, .1); color: #d97706; }
.token-usage-empty { text-align: center; padding: 36px 16px; color: var(--text-secondary); font-size: 13px; }
@media (max-width: 1000px) { .token-usage-charts { grid-template-columns: 1fr; } }
@media (max-width: 640px) {
.token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.token-usage-metric { padding: 14px; }
.token-usage-secondary { gap: 14px 0; }
.token-usage-secondary .token-usage-metric:nth-child(3) { border-left: 0; }
.token-usage-chart-card, .token-usage-data-card { padding: 14px; }
.token-usage-subtitle { max-width: 220px; line-height: 1.6; }
.token-usage-trend-svg { min-width: 480px; }
}
+54 -6
View File
@@ -277,6 +277,19 @@
"capabilities": "Capabilities",
"administration": "Administration"
},
"tokenUsageUI": {
"chartHint": "Hover or focus a bar for exact usage",
"scopeProject": "Current project",
"scopeAccessible": "Accessible conversations",
"subtitle": "Explore model consumption, caching and usage trends",
"scopeNote": "Scope and counting rules",
"trendMetric": "Trend metric",
"noDailyData": "No daily usage records",
"cacheComposition": "Input cache breakdown",
"cacheHit": "Cache hit rate",
"uncachedInput": "Input without cache reads",
"cacheNote": "Cache reads are part of input, not added to the total again. Cache writes are listed separately."
},
"dashboard": {
"title": "Dashboard",
"refresh": "Refresh",
@@ -287,7 +300,8 @@
"toolCalls": "Tool invocations",
"successRate": "Tool success rate",
"tokenUsage": "Token usage",
"tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}",
"usageCacheHitRate": "Cache hit {{rate}}",
"tokenUsageSub": "Cache hit {{rate}}",
"noTokenUsageYet": "No usage yet",
"clickToViewTasks": "Click to view tasks",
"clickToViewChat": "Click to view conversations",
@@ -433,7 +447,33 @@
"mcpPartialDown_other": "{{count}} stopped",
"mcpAllDown": "All stopped",
"noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat",
"startScanBtn": "Go to chat to scan"
"startScanBtn": "Go to chat to scan",
"usageView": "View token usage",
"usagePeriod": "Period",
"usage7Days": "Last 7 days",
"usage30Days": "Last 30 days",
"usage90Days": "Last 90 days",
"usageTotal": "Total tokens",
"usageInput": "Input tokens",
"usageOutput": "Output tokens",
"usageCacheRead": "Cache read",
"usageCacheWrite": "Cache write",
"usageReasoning": "Reasoning tokens",
"usageCalls": "Model calls",
"usageCacheRate": "Cache read / input",
"usageByModel": "Usage by model (up to 20)",
"usageByDay": "Daily usage",
"usageRecent": "Recent runs (up to 20)",
"usageDate": "Date",
"usageTime": "Time",
"usageModel": "Model",
"usageStatus": "End reason",
"usageUnavailable": "Token usage is unavailable",
"usageScope": "Provider-reported usage from recorded Agent runs, including sub-agents. Cache reads and writes are part of input; reasoning is part of output. These details are not added to totals again. Unreported or uncollected usage is excluded. This is not a provider bill. Models are grouped by run configuration; runs with model failover may contain mixed usage.",
"usageShare": "Token share",
"usageTrend": "Daily token trend",
"usageFinal": "Completed",
"usagePartial": "Partial usage"
},
"projects": {
"title": "Projects",
@@ -795,7 +835,7 @@
"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)",
"turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls) · Cache write {{cacheWrite}}",
"turnNumber": "Turn {{number}}",
"turnPending": "Processing…",
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
@@ -979,7 +1019,8 @@
"hitlTimeoutUnlimited": "No limit",
"hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.",
"hitlStatusOff": "Human-in-the-loop: Off",
"rolePanelTitle": "Select role"
"rolePanelTitle": "Select role",
"einoUsageCacheWriteTokens": "Cache write tokens"
},
"toolGuard": {
"invalidTestResponse": "The server returned an invalid test result",
@@ -1942,6 +1983,7 @@
"settings": {
"title": "System settings",
"nav": {
"tokenUsage": "Token usage",
"basic": "Basic",
"hitl": "Human-in-the-loop",
"knowledge": "Knowledge base",
@@ -3628,7 +3670,14 @@
"redLine": "Red: enables/contributes",
"grayLine": "Gray: logical order",
"nodeDetails": "Node details",
"closeDetails": "Close details"
"closeDetails": "Close details",
"contextTitle": "Context summaries and evidence clues ({{count}})",
"contextNotice": "System-generated summaries from the current conversation trace may be newer than this graph. They support recall and retrieval, and do not grant new user authorization or verify tool evidence. Use snapshot paths in the summaries to locate original evidence.",
"contextSummary": "Context summary {{number}}",
"contextCopy": "Copy full summary",
"contextCopied": "Copied",
"contextCopyFailed": "Copy failed. Select the text to copy it manually.",
"regenerateFailed": "Regeneration failed: {{message}}"
},
"externalMcpModal": {
"configJson": "Config JSON",
@@ -3722,7 +3771,6 @@
"hitlPolicy": "Tool approval",
"hitlHint": "Applies to all tasks in this queue, including tasks added later. Uses the global whitelist, audit strategy and timeout.",
"hitlSubtaskHint": "New tasks use the queue’s tool approval setting. Change it in queue details.",
"title": "New task",
"queueTitle": "Queue title",
"queueTitlePlaceholder": "Enter queue title (optional, for identification and filtering)",
+54 -5
View File
@@ -277,6 +277,19 @@
"capabilities": "Возможности",
"administration": "Администрирование"
},
"tokenUsageUI": {
"chartHint": "Наведите или выберите столбец для точных значений",
"scopeProject": "Текущий проект",
"scopeAccessible": "Доступные диалоги",
"subtitle": "Расход моделей, использование кэша и динамика",
"scopeNote": "Область и правила подсчёта",
"trendMetric": "Показатель графика",
"noDailyData": "Нет ежедневных данных",
"cacheComposition": "Кэш входных токенов",
"cacheHit": "Доля попаданий в кэш",
"uncachedInput": "Вход без чтения кэша",
"cacheNote": "Чтение кэша входит во входные токены и не прибавляется к итогу. Запись кэша указана отдельно."
},
"dashboard": {
"title": "Обзор",
"refresh": "Обновить",
@@ -287,7 +300,8 @@
"toolCalls": "Вызовы инструментов",
"successRate": "Успешность инструментов",
"tokenUsage": "Расход токенов",
"tokenUsageSub": "За 7 дней {{calls}} вызовов · сегодня {{today}}",
"usageCacheHitRate": "Попадания в кэш {{rate}}",
"tokenUsageSub": "Кэш {{rate}}",
"noTokenUsageYet": "Расхода пока нет",
"clickToViewTasks": "Открыть задачи",
"clickToViewChat": "Открыть диалоги",
@@ -445,7 +459,33 @@
"recoApproveHitl": "Одобрить запросы HITL: {{count}}",
"recoRestartMcp": "Проверить не запущенные внешние MCP: {{count}}",
"recoCheckMonitor": "Разобрать неудачные вызовы инструментов: {{count}}",
"mcpPartialDown": "Не запущено: {{count}}"
"mcpPartialDown": "Не запущено: {{count}}",
"usageView": "Расход токенов",
"usagePeriod": "Период",
"usage7Days": "За 7 дней",
"usage30Days": "За 30 дней",
"usage90Days": "За 90 дней",
"usageTotal": "Всего токенов",
"usageInput": "Входные токены",
"usageOutput": "Выходные токены",
"usageCacheRead": "Чтение кэша",
"usageCacheWrite": "Запись кэша",
"usageReasoning": "Токены рассуждения",
"usageCalls": "Вызовы модели",
"usageCacheRate": "Чтение кэша / ввод",
"usageByModel": "По моделям (до 20)",
"usageByDay": "По дням",
"usageRecent": "Последние запуски (до 20)",
"usageDate": "Дата",
"usageTime": "Время",
"usageModel": "Модель",
"usageStatus": "Причина завершения",
"usageUnavailable": "Статистика недоступна",
"usageShare": "Доля токенов",
"usageTrend": "Расход по дням",
"usageScope": "Данные провайдера из сохранённых запусков Agent, включая субагентов. Чтение и запись кэша входят во ввод, рассуждение — в вывод; они не прибавляются к сумме повторно. Неполученные данные исключены. Это не счёт провайдера. Группировка по модели в конфигурации запуска; при переключении модели расход может быть смешан.",
"usageFinal": "Завершено",
"usagePartial": "Частичный расход"
},
"projects": {
"title": "Проекты",
@@ -807,7 +847,7 @@
"turnDurationHours": "{{hours}} ч {{minutes}} мин",
"turnProcessAria": "{{state}}; развернуть или свернуть подробности выполнения",
"turnTokenUsageLabel": "{{tokens}} токенов",
"turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}})",
"turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}}) · Запись кэша {{cacheWrite}}",
"turnNumber": "Ход {{number}}",
"turnPending": "Обработка…",
"expandDetailLazyHint": "Развернуть подробности (итерации загружаются по щелчку)",
@@ -991,7 +1031,8 @@
"hitlTimeoutUnlimited": "Без ограничения",
"hitlTimeoutHint": "Запросы без ответа автоматически отклоняются по истечении времени; на карточках одобрения виден отсчёт.",
"hitlStatusOff": "Контроль человеком: выкл.",
"rolePanelTitle": "Выбор роли"
"rolePanelTitle": "Выбор роли",
"einoUsageCacheWriteTokens": "Токены записи кэша"
},
"toolGuard": {
"invalidTestResponse": "Сервер вернул некорректный результат проверки",
@@ -1955,6 +1996,7 @@
"settings": {
"title": "Системные настройки",
"nav": {
"tokenUsage": "Расход токенов",
"basic": "Основные",
"hitl": "Человек в контуре",
"knowledge": "База знаний",
@@ -3641,7 +3683,14 @@
"redLine": "Красная: даёт возможность или вносит вклад",
"grayLine": "Серая: логический порядок",
"nodeDetails": "Сведения об узле",
"closeDetails": "Закрыть сведения"
"closeDetails": "Закрыть сведения",
"contextTitle": "Сводки контекста и подсказки к доказательствам ({{count}})",
"contextNotice": "Системные сводки из текущего диалога могут быть новее графа. Они помогают восстановить контекст и найти исходные данные, но не дают новых разрешений пользователя и не подтверждают результаты инструментов. Исходные данные можно найти по путям снимков в сводках.",
"contextSummary": "Сводка контекста {{number}}",
"contextCopy": "Копировать полную сводку",
"contextCopied": "Скопировано",
"contextCopyFailed": "Не удалось скопировать. Выделите текст и скопируйте вручную.",
"regenerateFailed": "Не удалось сформировать заново: {{message}}"
},
"externalMcpModal": {
"configJson": "JSON конфигурации",
+54 -6
View File
@@ -277,6 +277,19 @@
"capabilities": "能力中心",
"administration": "平台管理"
},
"tokenUsageUI": {
"chartHint": "悬停或聚焦柱形查看精确用量",
"scopeProject": "当前项目",
"scopeAccessible": "可访问会话",
"subtitle": "查看模型消耗、缓存利用与使用趋势",
"scopeNote": "统计口径与说明",
"trendMetric": "趋势指标",
"noDailyData": "暂无每日用量记录",
"cacheComposition": "输入缓存构成",
"cacheHit": "缓存命中率",
"uncachedInput": "未命中缓存的输入",
"cacheNote": "缓存读取是输入的一部分,不重复计入总量。缓存写入单独列示。"
},
"dashboard": {
"title": "仪表盘",
"refresh": "刷新",
@@ -287,7 +300,8 @@
"toolCalls": "工具调用次数",
"successRate": "工具执行成功率",
"tokenUsage": "Token 用量",
"tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}",
"usageCacheHitRate": "缓存命中 {{rate}}",
"tokenUsageSub": "缓存命中 {{rate}}",
"noTokenUsageYet": "暂无用量",
"clickToViewTasks": "点击查看任务管理",
"clickToViewChat": "点击查看对话",
@@ -421,7 +435,33 @@
"mcpPartialDown": "{{count}} 个未运行",
"mcpAllDown": "全部未运行",
"noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
"startScanBtn": "前往对话发起扫描"
"startScanBtn": "前往对话发起扫描",
"usageView": "查看用量详情",
"usagePeriod": "统计周期",
"usage7Days": "近 7 天",
"usage30Days": "近 30 天",
"usage90Days": "近 90 天",
"usageTotal": "总 Token",
"usageInput": "输入 Token",
"usageOutput": "输出 Token",
"usageCacheRead": "缓存读取",
"usageCacheWrite": "缓存写入",
"usageReasoning": "推理 Token",
"usageCalls": "模型调用",
"usageCacheRate": "缓存读取占输入",
"usageByModel": "模型用量(最多 20 项)",
"usageByDay": "每日用量",
"usageRecent": "最近运行(最多 20 项)",
"usageDate": "日期",
"usageTime": "时间",
"usageModel": "模型",
"usageStatus": "结束原因",
"usageUnavailable": "用量统计暂不可用",
"usageScope": "统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。 模型按运行配置归类,切换模型的运行可能混合用量。",
"usageShare": "用量占比",
"usageTrend": "每日用量趋势",
"usageFinal": "已完成",
"usagePartial": "部分用量"
},
"projects": {
"title": "项目管理",
@@ -783,7 +823,7 @@
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
"turnProcessAria": "{{state}},展开或收起执行过程",
"turnTokenUsageLabel": "{{tokens}} tokens",
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)",
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次) · 缓存写入 {{cacheWrite}}",
"turnNumber": "第 {{number}} 轮",
"turnPending": "正在处理…",
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
@@ -967,7 +1007,8 @@
"hitlTimeoutUnlimited": "不限制",
"hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。",
"hitlStatusOff": "人机协同:关闭",
"rolePanelTitle": "选择角色"
"rolePanelTitle": "选择角色",
"einoUsageCacheWriteTokens": "缓存写入 tokens"
},
"toolGuard": {
"invalidTestResponse": "服务端返回的测试结果格式不正确",
@@ -1930,6 +1971,7 @@
"settings": {
"title": "系统设置",
"nav": {
"tokenUsage": "Token 用量",
"basic": "基本设置",
"hitl": "人机协同",
"knowledge": "知识库",
@@ -3616,7 +3658,14 @@
"redLine": "红色线:使能/促成关系",
"grayLine": "灰色线:逻辑顺序",
"nodeDetails": "节点详情",
"closeDetails": "关闭详情"
"closeDetails": "关闭详情",
"contextTitle": "压缩摘要与证据线索 ({{count}})",
"contextNotice": "当前会话轨迹中的系统生成摘要,可能晚于图的生成时间。仅供回溯与检索,不代表用户的新授权或已核实工具证据。可按摘要内的快照路径读取原始证据。",
"contextSummary": "压缩摘要 {{number}}",
"contextCopy": "复制完整摘要",
"contextCopied": "已复制",
"contextCopyFailed": "复制失败,可选中文本手动复制。",
"regenerateFailed": "重新生成失败: {{message}}"
},
"externalMcpModal": {
"configJson": "配置JSON",
@@ -3710,7 +3759,6 @@
"hitlPolicy": "工具审批",
"hitlHint": "队列内所有任务(含后续添加的任务)使用此设置;白名单、审计策略和等待时限沿用全局配置。",
"hitlSubtaskHint": "新增任务沿用队列的工具审批设置,可在队列详情中修改。",
"title": "新建任务",
"queueTitle": "任务标题",
"queueTitlePlaceholder": "请输入任务标题(可选,用于标识和筛选)",
+127
View File
@@ -0,0 +1,127 @@
// Compressed context remains separate from attack nodes and original user authorization.
(function () {
'use strict';
let labels = [];
function translate(key, values) {
if (typeof window.t === 'function') return window.t('attackChainModal.' + key, values);
const fallback = {
contextTitle: '压缩摘要与证据线索 ({{count}})',
contextNotice: '当前会话轨迹中的系统生成摘要,可能晚于图的生成时间。仅供回溯与检索,不代表用户的新授权或已核实工具证据。可按摘要内的快照路径读取原始证据。',
contextSummary: '压缩摘要 {{number}}',
contextCopy: '复制完整摘要',
contextCopied: '已复制',
contextCopyFailed: '复制失败,可选中文本手动复制。'
};
let text = fallback[key] || key;
Object.entries(values || {}).forEach(([name, value]) => {
text = text.replace('{{' + name + '}}', String(value));
});
return text;
}
function label(element, key, values) {
const refresh = () => { element.textContent = translate(key, values); };
labels.push(refresh);
refresh();
}
function clear() {
labels = [];
const card = document.getElementById('attack-chain-context');
const contents = document.getElementById('attack-chain-context-content');
if (contents) contents.replaceChildren();
if (card) {
card.hidden = true;
card.open = false;
}
}
async function copyText(text) {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return;
} catch (_) {
// Plain HTTP deployments may deny clipboard access; retain manual-copy fallback.
}
}
const previousFocus = document.activeElement;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.readOnly = true;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
try {
textarea.focus();
textarea.select();
if (!document.execCommand('copy')) throw new Error('copy failed');
} finally {
textarea.remove();
if (previousFocus && typeof previousFocus.focus === 'function') {
previousFocus.focus({ preventScroll: true });
}
}
}
function render(chainData) {
const card = document.getElementById('attack-chain-context');
// A 409 refresh re-renders the same conversation; keep the card's open state.
const wasOpen = !!card && card.open;
clear();
const contents = document.getElementById('attack-chain-context-content');
if (!card || !contents) return;
// Only explicit server provenance qualifies; summary-looking user text stays user text.
const summaries = Array.isArray(chainData && chainData.context_summaries)
? chainData.context_summaries.filter(item => item &&
item.provenance === 'system_generated_summary' &&
typeof item.content === 'string' && item.content.trim())
: [];
if (!summaries.length) return;
label(document.getElementById('attack-chain-context-title'), 'contextTitle', { count: summaries.length });
label(document.getElementById('attack-chain-context-notice'), 'contextNotice');
summaries.forEach((item, index) => {
const entry = document.createElement('details');
entry.className = 'attack-chain-context-entry';
const heading = document.createElement('summary');
label(heading, 'contextSummary', { number: index + 1 });
const body = document.createElement('pre');
body.className = 'attack-chain-context-body';
// Do not render Markdown/HTML or turn evidence paths into clickable URLs.
body.textContent = item.content;
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn-secondary attack-chain-context-copy';
label(button, 'contextCopy');
const status = document.createElement('span');
status.className = 'attack-chain-context-copy-status';
status.setAttribute('role', 'status');
let statusKey = '';
labels.push(() => { status.textContent = statusKey ? translate(statusKey) : ''; });
button.addEventListener('click', async () => {
button.disabled = true;
status.textContent = '';
try {
await copyText(item.content);
statusKey = 'contextCopied';
} catch (_) {
statusKey = 'contextCopyFailed';
} finally {
status.textContent = translate(statusKey);
button.disabled = false;
}
});
entry.append(heading, body, button, status);
contents.appendChild(entry);
});
card.hidden = false;
card.open = wasOpen;
}
document.addEventListener('languagechange', () => labels.forEach(refresh => refresh()));
window.CyberStrikeAttackChainContext = { render, clear };
})();
+292
View File
@@ -0,0 +1,292 @@
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const contextSource = fs.readFileSync('web/static/js/attack-chain-context.js', 'utf8');
const chatSource = fs.readFileSync('web/static/js/chat.js', 'utf8');
class Element {
constructor(tagName, sinks) {
Object.assign(this, { tagName, children: [], listeners: new Map(), attributes: {}, style: {}, textContent: '', hidden: false, open: false, sinks });
this.classList = { remove() {}, add() {} };
}
append(...children) { children.forEach(child => this.appendChild(child)); }
appendChild(child) { this.children.push(child); child.parent = this; return child; }
replaceChildren(...children) { this.children.forEach(child => { child.parent = null; }); this.children = []; this.append(...children); }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this); this.parent = null; }
addEventListener(type, listener) { this.listeners.set(type, listener); }
setAttribute(name, value) { this.attributes[name] = value; }
focus() {}
select() {}
set innerHTML(value) { this.sinks.push(String(value)); this.replaceChildren(); this._html = value; }
get innerHTML() { return this._html || ''; }
}
function deferred() {
let resolve, reject;
const promise = new Promise((r, j) => { resolve = r; reject = j; });
return { promise, resolve, reject };
}
function harness(options = {}) {
const sinks = [], copied = [], timers = [], listeners = new Map(), ids = new Map();
for (const id of ['attack-chain-context', 'attack-chain-context-content', 'attack-chain-context-title', 'attack-chain-context-notice', 'attack-chain-container', 'attack-chain-stats', 'attack-chain-modal', 'attack-chain-details']) ids.set(id, new Element('div', sinks));
ids.get('attack-chain-context').hidden = true;
const button = new Element('button', sinks), sidebar = new Element('div', sinks), body = new Element('body', sinks);
const document = {
body, getElementById: id => ids.get(id), createElement: tag => new Element(tag, sinks),
addEventListener(type, callback) { const callbacks = listeners.get(type) || []; callbacks.push(callback); listeners.set(type, callbacks); },
querySelector: selector => selector === '.attack-chain-sidebar' ? sidebar : button,
execCommand: () => { copied.push(body.children.find(child => child.tagName === 'textarea').value); return options.copyWorks !== false; }
};
let language = 'zh-CN';
const dictionaries = Object.fromEntries(['zh-CN', 'en-US', 'ru-RU'].map(lang => [lang, JSON.parse(fs.readFileSync(`web/static/i18n/${lang}.json`, 'utf8'))]));
const window = {
t(key, values) {
let translated = key.split('.').reduce((value, part) => value && value[part], dictionaries[language]) || key;
// Mirror real i18next: interpolated values are escaped unless the caller opts out.
const escape = !(values && values.interpolation && values.interpolation.escapeValue === false);
Object.entries(values || {}).forEach(([name, value]) => {
if (name === 'interpolation') return;
const text = escape
? String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\//g, '&#x2F;')
: String(value);
translated = translated.replace('{{' + name + '}}', text);
});
return translated;
}, attackChainOriginalData: null
};
const runtime = vm.createContext({
document, window, navigator: { clipboard: options.clipboard === false ? undefined : { writeText: async text => { if (options.clipboardRejects) throw Error('denied'); copied.push(text); } } },
setTimeout: callback => { timers.push(callback); }, console: { error() {}, log() {} },
closeAppModal() {}, openAppModal() {}, isAppModalOpen: () => false, closeNodeDetails() {},
alert() {},
currentAttackChainConversationId: 'A', attackChainCytoscape: null,
apiFetch: options.apiFetch || (() => { throw Error('unexpected API request'); })
});
vm.runInContext(contextSource, runtime);
vm.runInContext('let attackChainRequestSequence = 0; const attackChainRequestOwners = new Map(); const attackChainLoadingMap = new Map();', runtime);
for (const [name, nextName] of [
['beginAttackChainRequest', '_acBuildNodeIconDataUrl'], ['isAttackChainLoading', 'addAttackChainButton'],
['resetAttackChainContext', 'showAttackChain'], ['showAttackChain', 'loadAttackChain'],
['loadAttackChain', 'renderAttackChain'], ['renderAttackChain', 'getEdgeNodes'],
['updateAttackChainStats', 'closeNodeDetails'], ['closeAttackChainModal', 'refreshAttackChain'],
['refreshAttackChain', 'regenerateAttackChain'], ['regenerateAttackChain', '_acEscapeXml'],
['exportAttackChain', 'projectFilterT']
]) {
const pattern = name => new RegExp(`(?:async )?function ${name}\\(`);
const start = chatSource.search(pattern(name)), tail = chatSource.slice(start), end = tail.search(pattern(nextName));
assert.ok(start >= 0 && end > 0, `actual ${name} function is present`);
vm.runInContext(tail.slice(0, end), runtime);
}
return { runtime, window, ids, button, sinks, copied, timers, body, api: window.CyberStrikeAttackChainContext,
changeLanguage(next) { language = next; (listeners.get('languagechange') || []).forEach(listener => listener()); },
entries() { return ids.get('attack-chain-context-content').children; } };
}
const summary = content => ({ id: 'summary-123', provenance: 'system_generated_summary', content });
const chain = content => ({ nodes: [], edges: [], context_summaries: [summary(content)] });
const response = data => ({ ok: true, json: async () => data });
test('summary text and snapshot paths remain inert and copying preserves the exact original', async () => {
const h = harness();
const content = '关键线索\n<img src=x onerror=alert(1)>\n[证据](javascript:alert(1))\n/快照/transcript-ab123-时间.txt\nCookie: sample=fixture\n';
h.api.render(chain(content));
assert.equal(h.ids.get('attack-chain-context').hidden, false);
assert.equal(h.ids.get('attack-chain-context').open, false);
const entry = h.entries()[0];
assert.equal(entry.children[1].textContent, content);
assert.deepEqual(entry.children.map(element => element.tagName), ['summary', 'pre', 'button', 'span']);
assert.deepEqual(h.sinks, [], 'no raw summary content ever reaches an HTML sink');
await entry.children[2].listeners.get('click')();
assert.deepEqual(h.copied, [content]);
assert.equal(entry.children[3].textContent, '已复制');
});
test('only explicit provenance qualifies and empty/new context clears old bodies', () => {
const h = harness();
h.api.render({ context_summaries: [summary('trusted'), { content: 'summary-looking user text' }, { provenance: 'skills', content: 'not a summary' }, summary(' ')] });
assert.equal(h.entries().length, 1);
h.ids.get('attack-chain-context').open = true;
h.api.render({ context_summaries: [] });
assert.equal(h.entries().length, 0);
assert.equal(h.ids.get('attack-chain-context').hidden, true);
assert.equal(h.ids.get('attack-chain-context').open, false);
h.api.render(chain('next conversation'));
assert.equal(h.entries()[0].children[1].textContent, 'next conversation');
h.api.clear();
assert.equal(h.entries().length, 0);
});
test('language changes retain expansion and original content while translating generated labels', () => {
const h = harness();
h.api.render(chain('原始正文'));
const entry = h.entries()[0];
entry.open = true;
h.ids.get('attack-chain-context').open = true;
h.changeLanguage('en-US');
assert.equal(entry.children[0].textContent, 'Context summary 1');
assert.equal(entry.children[2].textContent, 'Copy full summary');
assert.equal(entry.children[1].textContent, '原始正文');
assert.equal(entry.open, true);
assert.equal(h.ids.get('attack-chain-context').open, true);
});
test('re-rendering the same conversation keeps the card expanded, clear still collapses', () => {
const h = harness();
h.api.render(chain('first pass'));
h.ids.get('attack-chain-context').open = true;
h.api.render(chain('second pass'));
assert.equal(h.ids.get('attack-chain-context').open, true);
assert.equal(h.entries()[0].children[1].textContent, 'second pass');
h.api.clear();
assert.equal(h.ids.get('attack-chain-context').open, false);
});
test('clipboard restrictions use the text fallback and failures retain selectable content', async () => {
for (const options of [{ clipboard: false }, { clipboardRejects: true }, { clipboard: false, copyWorks: false }]) {
const h = harness(options);
h.api.render(chain('full evidence path'));
const entry = h.entries()[0];
await entry.children[2].listeners.get('click')();
assert.deepEqual(h.copied, ['full evidence path']);
assert.equal(h.body.children.length, 0);
assert.equal(entry.children[2].disabled, false);
assert.equal(entry.children[1].textContent, 'full evidence path');
assert.equal(entry.children[3].textContent, options.copyWorks === false ? '复制失败,可选中文本手动复制。' : '已复制');
}
});
test('an empty graph still displays its context without changing nodes or statistics', () => {
const h = harness(), data = chain('summary without graph nodes');
let destroyed = 0;
h.runtime.attackChainCytoscape = { destroy: () => { destroyed += 1; } };
h.runtime.renderAttackChain(data);
h.runtime.updateAttackChainStats(data);
assert.equal(h.entries()[0].children[1].textContent, 'summary without graph nodes');
assert.equal(h.ids.get('attack-chain-stats').textContent, '节点: 0 | 边: 0');
assert.equal(data.nodes.length, 0);
assert.equal(h.window.attackChainOriginalData, data);
assert.equal(destroyed, 1);
assert.equal(h.runtime.attackChainCytoscape, null, 'old graph is unavailable to filters and exports');
const alerts = [];
h.runtime.alert = message => alerts.push(message);
h.runtime.exportAttackChain('svg');
assert.equal(alerts.length, 1);
assert.equal(h.timers.length, 0, 'export cannot schedule an old graph download');
});
test('late 409/errors cannot replace another conversation or unlock its request', async () => {
for (const operation of ['loadAttackChain', 'regenerateAttackChain']) for (const result of ['409', 'error', 'json-error']) {
const request = deferred(), h = harness({ apiFetch: () => request.promise });
const pending = operation === 'loadAttackChain' ? h.runtime[operation]('A') : h.runtime[operation]();
h.runtime.currentAttackChainConversationId = 'B';
h.api.render(chain('B evidence'));
h.button.disabled = true;
const marker = h.ids.get('attack-chain-container').innerHTML;
if (result === 'error') request.reject(Error('<img onerror=alert(1)>'));
else if (result === 'json-error') request.resolve({ ok: false, status: 500, json: async () => ({ error: 'old error' }) });
else request.resolve({ ok: false, status: 409 });
await pending;
assert.equal(h.entries()[0].children[1].textContent, 'B evidence', `${operation}/${result}`);
assert.equal(h.ids.get('attack-chain-container').innerHTML, marker);
assert.equal(h.button.disabled, true);
assert.equal(h.timers.length, 0);
}
});
test('same-conversation requests keep the latest response and old completion cannot clear loading', async () => {
const requests = [deferred(), deferred()];
let index = 0;
const h = harness({ apiFetch: () => requests[index++].promise });
const older = h.runtime.loadAttackChain('A');
h.runtime.setAttackChainLoading('A', false);
const newer = h.runtime.loadAttackChain('A');
requests[0].resolve(response(chain('old evidence')));
await older;
assert.equal(h.runtime.isAttackChainLoading('A'), true);
requests[1].resolve(response(chain('new evidence')));
await newer;
assert.equal(h.entries()[0].children[1].textContent, 'new evidence');
assert.equal(h.runtime.isAttackChainLoading('A'), false);
const reverse = [deferred(), deferred()];
index = 0;
h.runtime.apiFetch = () => reverse[index++].promise;
const slow = h.runtime.loadAttackChain('A');
h.runtime.setAttackChainLoading('A', false);
const fast = h.runtime.loadAttackChain('A');
reverse[1].resolve(response(chain('newest evidence')));
await fast;
reverse[0].resolve(response(chain('stale evidence')));
await slow;
assert.equal(h.entries()[0].children[1].textContent, 'newest evidence');
});
test('current errors clear context and display server text without HTML execution', async () => {
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: '<img src=x onerror=alert(1)>' }) }) });
h.api.render(chain('old evidence'));
let destroyed = 0;
h.runtime.attackChainCytoscape = { destroy: () => { destroyed += 1; } };
await h.runtime.loadAttackChain('A');
assert.equal(h.entries().length, 0);
assert.equal(h.ids.get('attack-chain-container').children[0].textContent.includes('<img src=x onerror=alert(1)>'), true);
assert.equal(h.sinks.some(html => html.includes('onerror')), false);
assert.equal(destroyed, 1);
assert.equal(h.runtime.attackChainCytoscape, null);
const alerts = [];
h.runtime.alert = message => alerts.push(message);
h.runtime.exportAttackChain('svg');
assert.equal(alerts.length, 1);
assert.equal(h.timers.length, 0);
});
test('failure text stays readable instead of showing i18next escape entities', async () => {
const upstream = '生成攻击链失败: Post "https://api.example.com/v1/chat/completions": context deadline exceeded';
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: upstream }) }) });
await h.runtime.loadAttackChain('A');
const shown = h.ids.get('attack-chain-container').children[0].textContent;
assert.equal(shown.includes(upstream), true);
assert.equal(/&#x2F;|&quot;|&amp;|&#39;/.test(shown), false);
assert.equal(h.sinks.some(html => html.includes('api.example.com')), false);
});
test('regenerate failure reports its own message key unescaped', async () => {
const upstream = 'AI生成失败: Post "https://api.example.com/v1/chat/completions": EOF';
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: upstream }) }) });
await h.runtime.regenerateAttackChain();
const shown = h.ids.get('attack-chain-container').children[0].textContent;
assert.equal(shown.startsWith('重新生成失败: '), true);
assert.equal(shown.includes(upstream), true);
assert.equal(h.sinks.some(html => html.includes('api.example.com')), false);
});
test('regenerate 409 refreshes after completion while superseded timers stay inert', async () => {
const h = harness({ apiFetch: async () => ({ ok: false, status: 409 }) });
let refreshes = 0;
h.runtime.refreshAttackChain = () => { refreshes += 1; };
await h.runtime.regenerateAttackChain();
assert.equal(h.runtime.isAttackChainLoading('A'), false);
h.timers[0]();
assert.equal(refreshes, 1);
h.runtime.beginAttackChainRequest('A');
h.timers[0]();
assert.equal(refreshes, 1);
});
test('closing/reopening pending context clears old bodies and invalidates old responses', async () => {
const requests = [deferred(), deferred()];
let index = 0;
const h = harness({ apiFetch: () => requests[index++].promise });
const oldRequest = h.runtime.loadAttackChain('A');
h.api.render(chain('old context'));
h.runtime.closeAttackChainModal();
assert.equal(h.entries().length, 0);
assert.equal(h.window.attackChainOriginalData, null);
const reopened = h.runtime.showAttackChain('A');
assert.equal(index, 2);
requests[0].resolve(response(chain('stale closed context')));
await oldRequest;
assert.equal(h.entries().length, 0);
assert.equal(h.runtime.isAttackChainLoading('A'), true);
requests[1].resolve(response(chain('reopened context')));
await reopened;
assert.equal(h.entries()[0].children[1].textContent, 'reopened context');
});
+1 -1
View File
@@ -341,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260907-blocked-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20261011-token-usage');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
+104 -30
View File
@@ -4945,6 +4945,7 @@ function normalizeAssistantTurnTokenUsage(data) {
completionTokens: assistantTurnUsageNumber(source.completionTokens),
totalTokens: assistantTurnUsageNumber(source.totalTokens),
cachedTokens: assistantTurnUsageNumber(source.cachedTokens),
cacheWriteTokens: assistantTurnUsageNumber(source.cacheWriteTokens),
reasoningTokens: assistantTurnUsageNumber(source.reasoningTokens),
model: source.model != null ? String(source.model).trim() : ''
};
@@ -4962,6 +4963,7 @@ function mergeAssistantTurnTokenUsage(target, usage) {
completionTokens: 0,
totalTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
model: ''
};
@@ -4970,6 +4972,7 @@ function mergeAssistantTurnTokenUsage(target, usage) {
out.completionTokens += usage.completionTokens || 0;
out.totalTokens += usage.totalTokens || 0;
out.cachedTokens += usage.cachedTokens || 0;
out.cacheWriteTokens += usage.cacheWriteTokens || 0;
out.reasoningTokens += usage.reasoningTokens || 0;
if (!out.model && usage.model) out.model = usage.model;
return out.totalTokens > 0 ? out : null;
@@ -4998,6 +5001,7 @@ function setAssistantTurnTokenUsage(messageElementOrId, usage) {
delete messageElement.dataset.turnCompletionTokens;
delete messageElement.dataset.turnTotalTokens;
delete messageElement.dataset.turnCachedTokens;
delete messageElement.dataset.turnCacheWriteTokens;
delete messageElement.dataset.turnReasoningTokens;
delete messageElement.dataset.turnModel;
syncAssistantTurnSummary(messageElement);
@@ -5008,6 +5012,7 @@ function setAssistantTurnTokenUsage(messageElementOrId, usage) {
messageElement.dataset.turnCompletionTokens = String(normalized.completionTokens || 0);
messageElement.dataset.turnTotalTokens = String(normalized.totalTokens || 0);
messageElement.dataset.turnCachedTokens = String(normalized.cachedTokens || 0);
messageElement.dataset.turnCacheWriteTokens = String(normalized.cacheWriteTokens || 0);
messageElement.dataset.turnReasoningTokens = String(normalized.reasoningTokens || 0);
if (normalized.model) {
messageElement.dataset.turnModel = normalized.model;
@@ -5025,6 +5030,7 @@ function getAssistantTurnTokenUsage(messageElement) {
completionTokens: messageElement.dataset.turnCompletionTokens,
totalTokens: messageElement.dataset.turnTotalTokens,
cachedTokens: messageElement.dataset.turnCachedTokens,
cacheWriteTokens: messageElement.dataset.turnCacheWriteTokens,
reasoningTokens: messageElement.dataset.turnReasoningTokens,
model: messageElement.dataset.turnModel
});
@@ -5051,6 +5057,7 @@ function formatAssistantTurnTokenUsageTitle(usage) {
prompt: formatAssistantTurnTokenCount(safeUsage.promptTokens),
completion: formatAssistantTurnTokenCount(safeUsage.completionTokens),
cached: formatAssistantTurnTokenCount(safeUsage.cachedTokens),
cacheWrite: formatAssistantTurnTokenCount(safeUsage.cacheWriteTokens),
reasoning: formatAssistantTurnTokenCount(safeUsage.reasoningTokens),
calls: formatAssistantTurnTokenCount(safeUsage.modelCalls),
model: safeUsage.model || ''
@@ -6853,6 +6860,26 @@ function updateActiveConversation() {
// ==================== 攻击链可视化功能 ====================
let attackChainRequestSequence = 0;
const attackChainRequestOwners = new Map();
function beginAttackChainRequest(conversationId) {
const requestSequence = ++attackChainRequestSequence;
attackChainRequestOwners.set(conversationId, requestSequence);
return requestSequence;
}
function isCurrentAttackChainRequest(conversationId, requestSequence) {
return currentAttackChainConversationId === conversationId && attackChainRequestSequence === requestSequence;
}
function finishAttackChainRequest(conversationId, requestSequence) {
if (attackChainRequestOwners.get(conversationId) === requestSequence) {
attackChainRequestOwners.delete(conversationId);
setAttackChainLoading(conversationId, false);
}
}
// 生成节点图标的 data URL(用于 Cytoscape background-image)
// 返回一个精美的渐变色方块 + 白色矢量图标
function _acBuildNodeIconDataUrl(iconType, color, colorDark) {
@@ -6919,6 +6946,29 @@ function updateAttackChainAvailability() {
addAttackChainButton(currentConversationId);
}
function resetAttackChainContext() {
if (attackChainCytoscape) {
attackChainCytoscape.destroy();
attackChainCytoscape = null;
}
window.attackChainOriginalData = null;
if (window.CyberStrikeAttackChainContext) window.CyberStrikeAttackChainContext.clear();
}
function renderAttackChainError(container, message) {
if (!container) return;
const errorElement = document.createElement('div');
errorElement.className = 'error-message';
errorElement.textContent = message;
container.replaceChildren(errorElement);
}
// window.t escapes interpolation for innerHTML; these go to textContent, so opt out.
function attackChainFailureText(key, fallbackPrefix, message) {
if (typeof window.t !== 'function') return fallbackPrefix + message;
return window.t(key, { message, interpolation: { escapeValue: false } });
}
// 显示攻击链模态框
async function showAttackChain(conversationId) {
// 如果当前显示的对话ID不同,或者没有在加载,允许打开
@@ -6933,6 +6983,11 @@ async function showAttackChain(conversationId) {
}
currentAttackChainConversationId = conversationId;
// Reopening or returning to a conversation starts a fresh view, even if an old request is pending.
attackChainRequestSequence += 1;
attackChainRequestOwners.delete(conversationId);
setAttackChainLoading(conversationId, false);
resetAttackChainContext();
const modal = document.getElementById('attack-chain-modal');
if (!modal) {
console.error('攻击链模态框未找到');
@@ -6953,6 +7008,8 @@ async function showAttackChain(conversationId) {
if (detailsPanel) {
detailsPanel.style.display = 'none';
}
const sidebar = document.querySelector('.attack-chain-sidebar');
if (sidebar) sidebar.classList.remove('details-active');
// 禁用重新生成按钮
const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]');
@@ -6973,14 +7030,26 @@ async function loadAttackChain(conversationId) {
}
setAttackChainLoading(conversationId, true);
const requestSequence = beginAttackChainRequest(conversationId);
if (currentAttackChainConversationId === conversationId) {
resetAttackChainContext();
const container = document.getElementById('attack-chain-container');
if (container) {
container.innerHTML = '<div class="loading-spinner">' + (typeof window.t === 'function' ? window.t('chat.loading') : '加载中...') + '</div>';
}
}
try {
const response = await apiFetch(`/api/attack-chain/${conversationId}`);
if (!isCurrentAttackChainRequest(conversationId, requestSequence)) {
finishAttackChainRequest(conversationId, requestSequence);
return;
}
if (!response.ok) {
// 处理 409 Conflict(正在生成中)
if (response.status === 409) {
const error = await response.json();
const container = document.getElementById('attack-chain-container');
if (container) {
container.innerHTML = `
@@ -6999,7 +7068,7 @@ async function loadAttackChain(conversationId) {
// 使用闭包保存 conversationId,防止串台
setTimeout(() => {
// 检查当前显示的对话ID是否匹配
if (currentAttackChainConversationId === conversationId) {
if (isCurrentAttackChainRequest(conversationId, requestSequence)) {
refreshAttackChain();
}
}, 5000);
@@ -7023,12 +7092,12 @@ async function loadAttackChain(conversationId) {
const chainData = await response.json();
// 检查当前显示的对话ID是否匹配,防止串台
if (currentAttackChainConversationId !== conversationId) {
if (!isCurrentAttackChainRequest(conversationId, requestSequence)) {
console.log('攻击链数据已返回,但当前显示的对话已切换,忽略此次渲染', {
returned: conversationId,
current: currentAttackChainConversationId
});
setAttackChainLoading(conversationId, false);
finishAttackChainRequest(conversationId, requestSequence);
return;
}
@@ -7039,20 +7108,20 @@ async function loadAttackChain(conversationId) {
updateAttackChainStats(chainData);
// 成功加载后,重置加载状态
setAttackChainLoading(conversationId, false);
finishAttackChainRequest(conversationId, requestSequence);
} catch (error) {
console.error('加载攻击链失败:', error);
const container = document.getElementById('attack-chain-container');
if (container) {
container.innerHTML = '<div class="error-message">' + (typeof window.t === 'function' ? window.t('chat.loadFailed', { message: error.message }) : '加载失败: ' + error.message) + '</div>';
}
// 错误时也重置加载状态
setAttackChainLoading(conversationId, false);
finishAttackChainRequest(conversationId, requestSequence);
if (!isCurrentAttackChainRequest(conversationId, requestSequence)) return;
resetAttackChainContext();
renderAttackChainError(document.getElementById('attack-chain-container'),
attackChainFailureText('chat.loadFailed', '加载失败: ', error.message));
} finally {
// 恢复重新生成按钮
const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]');
if (regenerateBtn) {
if (regenerateBtn && isCurrentAttackChainRequest(conversationId, requestSequence)) {
regenerateBtn.disabled = false;
regenerateBtn.style.opacity = '1';
regenerateBtn.style.cursor = 'pointer';
@@ -7062,6 +7131,12 @@ async function loadAttackChain(conversationId) {
// 渲染攻击链
function renderAttackChain(chainData) {
if (attackChainCytoscape) {
attackChainCytoscape.destroy();
attackChainCytoscape = null;
}
window.attackChainOriginalData = chainData;
if (window.CyberStrikeAttackChainContext) window.CyberStrikeAttackChainContext.render(chainData);
const container = document.getElementById('attack-chain-container');
if (!container) {
return;
@@ -7674,8 +7749,6 @@ function renderAttackChain(chainData) {
attackChainCytoscape.edges().style({ 'opacity': 0.88, 'width': '' });
});
// 保存原始数据用于过滤
window.attackChainOriginalData = chainData;
}
// 安全地获取边的源节点和目标节点
@@ -8099,16 +8172,14 @@ function closeNodeDetails() {
// 关闭攻击链模态框
function closeAttackChainModal() {
closeAppModal('attack-chain-modal');
attackChainRequestSequence += 1;
attackChainRequestOwners.delete(currentAttackChainConversationId);
setAttackChainLoading(currentAttackChainConversationId, false);
resetAttackChainContext();
// 关闭节点详情
closeNodeDetails();
// 清理Cytoscape实例
if (attackChainCytoscape) {
attackChainCytoscape.destroy();
attackChainCytoscape = null;
}
currentAttackChainConversationId = null;
}
@@ -8146,6 +8217,8 @@ async function regenerateAttackChain() {
// 保存请求时的对话ID,防止串台
const savedConversationId = currentAttackChainConversationId;
setAttackChainLoading(savedConversationId, true);
const requestSequence = beginAttackChainRequest(savedConversationId);
resetAttackChainContext();
const container = document.getElementById('attack-chain-container');
if (container) {
@@ -8166,10 +8239,11 @@ async function regenerateAttackChain() {
method: 'POST'
});
if (!isCurrentAttackChainRequest(savedConversationId, requestSequence)) return;
if (!response.ok) {
// 处理 409 Conflict(正在生成中)
if (response.status === 409) {
const error = await response.json();
if (container) {
container.innerHTML = `
<div class="loading-spinner" style="text-align: center; padding: 40px;">
@@ -8186,9 +8260,8 @@ async function regenerateAttackChain() {
// 5秒后自动刷新
// savedConversationId 已在函数开始处定义
setTimeout(() => {
// 检查当前显示的对话ID是否匹配,且仍在加载中
if (currentAttackChainConversationId === savedConversationId &&
isAttackChainLoading(savedConversationId)) {
// Only this view's latest request may start another refresh.
if (isCurrentAttackChainRequest(savedConversationId, requestSequence)) {
refreshAttackChain();
}
}, 5000);
@@ -8202,12 +8275,12 @@ async function regenerateAttackChain() {
const chainData = await response.json();
// 检查当前显示的对话ID是否匹配,防止串台
if (currentAttackChainConversationId !== savedConversationId) {
if (!isCurrentAttackChainRequest(savedConversationId, requestSequence)) {
console.log('攻击链数据已返回,但当前显示的对话已切换,忽略此次渲染', {
returned: savedConversationId,
current: currentAttackChainConversationId
});
setAttackChainLoading(savedConversationId, false);
finishAttackChainRequest(savedConversationId, requestSequence);
return;
}
@@ -8219,14 +8292,15 @@ async function regenerateAttackChain() {
} catch (error) {
console.error('重新生成攻击链失败:', error);
if (container) {
container.innerHTML = `<div class="error-message">重新生成失败: ${error.message}</div>`;
}
if (!isCurrentAttackChainRequest(savedConversationId, requestSequence)) return;
resetAttackChainContext();
renderAttackChainError(container,
attackChainFailureText('attackChainModal.regenerateFailed', '重新生成失败: ', error.message));
} finally {
setAttackChainLoading(savedConversationId, false);
finishAttackChainRequest(savedConversationId, requestSequence);
// 恢复重新生成按钮
if (regenerateBtn) {
if (regenerateBtn && isCurrentAttackChainRequest(savedConversationId, requestSequence)) {
regenerateBtn.disabled = false;
regenerateBtn.style.opacity = '1';
regenerateBtn.style.cursor = 'pointer';
+5 -6
View File
@@ -162,7 +162,7 @@ async function refreshDashboard() {
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),
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=5'))
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=20'))
]);
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
@@ -441,6 +441,7 @@ async function refreshDashboard() {
setEl('dashboard-kpi-success-rate', '-');
setEl('dashboard-kpi-tools-calls', '-');
setEl('dashboard-kpi-token-usage', '-');
setKpiSubText('dashboard-kpi-token-sub-text', '-');
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
@@ -714,9 +715,8 @@ function renderDashboardTokenUsage(res) {
setKpiSubText('dashboard-kpi-token-sub-text', '-');
return;
}
const cacheRate = tokenUsageCacheRate(summary);
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, '暂无用量'));
@@ -725,9 +725,8 @@ function renderDashboardTokenUsage(res) {
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)));
rate: cacheRate
}, '缓存命中 ' + cacheRate));
}
function formatTokenUsageCompact(num) {
+2 -2
View File
@@ -107,7 +107,7 @@ test('Agent 审查不进入人工审批弹窗、倒计时和项目计数', () =>
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
assert.match(handler, /CreatePendingInterrupt\([\s\S]{0,260}reviewer string/);
assert.match(handler, /reviewer != "audit_agent"[\s\S]{0,120}m\.pending\[id\] = p/);
assert.match(logsHandler, /ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'/);
assert.match(fs.readFileSync('internal/database/schemas/application.sql', 'utf8'), /reviewer TEXT NOT NULL DEFAULT 'human'/);
assert.match(logsHandler, /status = 'pending' AND COALESCE\(reviewer,'human'\) = 'human'/);
assert.match(monitor, /function isAgentReviewedHitl\(data\)/);
assert.match(monitor, /if \(!data\.resolved && !isAgentReviewedHitl\(data\)\)/);
@@ -293,7 +293,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260907-blocked-1/);
assert.match(template, /monitor\.js\?v=20261011-token-usage/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=[A-Za-z0-9._-]+/);
assert.match(template, /style\.css\?v=[A-Za-z0-9._-]+/);
+5 -1
View File
@@ -2889,6 +2889,9 @@ function formatEinoUsageSummaryMessage(data) {
if (Number(d.cachedTokens || 0) > 0) {
rows.push([label('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]);
}
if (Number(d.cacheWriteTokens || 0) > 0) {
rows.push([label('chat.einoUsageCacheWriteTokens', '缓存写入 tokens'), d.cacheWriteTokens]);
}
if (Number(d.reasoningTokens || 0) > 0) {
rows.push([label('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]);
}
@@ -6734,7 +6737,7 @@ function addTimelineItem(timeline, type, options) {
}
if (type === 'eino_usage_summary' && options.data) {
const d = options.data;
['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'reasoningTokens'].forEach(function (key) {
['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'cacheWriteTokens', 'reasoningTokens'].forEach(function (key) {
if (d[key] != null) {
item.dataset[key] = String(d[key]);
}
@@ -9651,6 +9654,7 @@ function refreshProgressAndTimelineI18n() {
completionTokens: item.dataset.completionTokens,
totalTokens: item.dataset.totalTokens,
cachedTokens: item.dataset.cachedTokens,
cacheWriteTokens: item.dataset.cacheWriteTokens,
reasoningTokens: item.dataset.reasoningTokens
};
titleSpan.textContent = formatEinoUsageSummaryTitle(usageData);
+13 -4
View File
@@ -1,9 +1,9 @@
// 页面路由管理
let currentPage = null;
/** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id=) */
/** 对话、漏洞管理和设置页切换时保留查询串(会话 ID 或设置分区)。 */
function buildHashForPage(pageId) {
if (pageId !== 'chat' && pageId !== 'vulnerabilities') {
if (pageId !== 'chat' && pageId !== 'vulnerabilities' && pageId !== 'settings') {
return pageId;
}
const full = window.location.hash.slice(1);
@@ -111,7 +111,7 @@ function initRouter() {
let pageId = hashParts[0].replace(/^#?\//, '');
if (pageId === 'c2') pageId = 'c2-listeners';
// 一键更新已并进系统设置页的分区菜单:#system-update 这条老深链落到设置页的那一区。
const settingsSection = pageId === 'system-update' ? 'update' : '';
const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : '';
if (settingsSection) pageId = 'settings';
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
@@ -541,6 +541,12 @@ async function initPage(pageId) {
if (updateSection && updateSection.classList.contains('active') && typeof loadUpdateConsole === 'function') {
loadUpdateConsole();
}
// Returning through the regular settings nav reloads a retained usage panel.
// Deep links are loaded by switchSettingsSection after switchPage returns.
const tokenSection = document.getElementById('settings-section-token-usage');
if (tokenSection && tokenSection.classList.contains('active') && window.location.hash !== '#settings?section=token-usage' && typeof refreshTokenUsage === 'function') {
refreshTokenUsage();
}
break;
case 'roles-management':
// 初始化角色管理页面
@@ -622,6 +628,9 @@ async function initPage(pageId) {
if (pageId !== 'settings' && typeof stopUpdatePolling === 'function') {
stopUpdatePolling();
}
if (pageId !== 'settings' && typeof stopTokenUsageRequests === 'function') {
stopTokenUsageRequests();
}
}
// 页面加载完成后初始化路由
@@ -639,7 +648,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (pageId === 'c2') pageId = 'c2-listeners';
// 与首屏解析同一套别名:#system-update 落到系统设置页的「一键更新」分区。
const settingsSection = pageId === 'system-update' ? 'update' : '';
const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : '';
if (settingsSection) pageId = 'settings';
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
+12
View File
@@ -661,6 +661,13 @@ function switchSettingsSection(section) {
return;
}
// Unknown hash sections must not hide every settings panel.
const requestedContent = document.getElementById(`settings-section-${section}`);
if (!requestedContent) return;
if (window.location.hash.split('?')[0] === '#settings') {
history.replaceState(null, '', section === 'token-usage' ? '#settings?section=token-usage' : '#settings');
}
// 更新导航项状态
document.querySelectorAll('.settings-nav-item').forEach(item => {
item.classList.remove('active');
@@ -678,6 +685,11 @@ function switchSettingsSection(section) {
if (activeContent) {
activeContent.classList.add('active');
initSettingsCustomSelects(activeContent);
}
if (section === 'token-usage' && typeof refreshTokenUsage === 'function') {
refreshTokenUsage();
} else if (typeof stopTokenUsageRequests === 'function') {
stopTokenUsageRequests();
}
if (section === 'robots' && typeof window.loadVulnerabilityAlertSubscription === 'function') {
window.loadVulnerabilityAlertSubscription();
@@ -0,0 +1,93 @@
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync('web/static/js/token-usage.js', 'utf8');
const harness = () => {
const context = { document: { addEventListener() {} }, window: {}, AbortController };
vm.createContext(context);
vm.runInContext(source, context);
return context;
};
test('cache and reasoning details do not inflate total or double-count input', () => {
const ctx = harness();
const metrics = ctx.tokenUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 });
const values = Object.fromEntries(metrics);
assert.equal(values.usageTotal, 120);
assert.equal(values.usageCacheWrite, 30);
assert.equal(values.usageCacheRate, '40.0%');
assert.equal(Object.fromEntries(ctx.tokenUsageMetrics({})).usageCacheRate, '—');
assert.equal(ctx.tokenUsageCount(Infinity), 0);
assert.equal(ctx.tokenUsageCount(-12), 0);
});
test('period selection refreshes once and rejects arbitrary periods', () => {
const ctx = harness();
let calls = 0;
ctx.refreshTokenUsage = () => { calls++; };
ctx.document.getElementById = () => ({ textContent: '' });
ctx.changeTokenUsagePeriod('30');
ctx.changeTokenUsagePeriod('999');
assert.equal(ctx.tokenUsageState.days, 30);
assert.equal(calls, 1);
});
test('cache hit ratio uses input tokens, clamps invalid detail and handles missing input', () => {
const ctx = harness();
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 930, totalTokens: 1100 }), '93.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 0 }), '0.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 10, cachedTokens: 30 }), '100.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 0, cachedTokens: 30 }), '—');
assert.equal(ctx.tokenUsageCacheRate(null), '—');
});
test('daily trends retain server day keys and fill only gaps within returned history', () => {
const ctx = harness();
const rows = ctx.tokenUsageTimeline([{ key: '2026-10-12', totalTokens: 12 }, { key: '2026-10-10', totalTokens: 10 }, { key: 'invalid', totalTokens: 20 }]);
assert.deepEqual(Array.from(rows, row => row.key), ['2026-10-10', '2026-10-11', '2026-10-12']);
assert.equal(rows[1].totalTokens, 0);
assert.equal(rows[1].events, 0);
assert.equal(rows[2].totalTokens, 12);
assert.equal(ctx.tokenUsageTimeline([]).length, 0);
assert.equal(ctx.tokenUsageTimeline([{ key: '2026-10-10', totalTokens: 10 }]).length, 1);
});
test('detail requests stay project scoped and discard late results after a period change', async () => {
const ctx = harness();
const host = { setAttribute() {}, removeAttribute() {}, textContent: '' };
ctx.document.getElementById = id => id === 'settings-token-analysis' ? host : null;
ctx.getActiveProjectId = () => 'project / sample';
const requests = [];
ctx.apiFetch = (url, options) => new Promise(resolve => requests.push({ url, options, resolve }));
const rendered = [];
ctx.renderTokenUsageAnalysis = value => rendered.push(value);
const first = ctx.refreshTokenUsage();
ctx.tokenUsageState.days = 30;
const second = ctx.refreshTokenUsage();
assert.ok(requests[0].options.signal.aborted);
assert.match(requests[1].url, /days=30.*project_id=project%20%2F%20sample/);
requests[1].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 30 } }) });
await second;
requests[0].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 7 } }) });
await first;
assert.equal(rendered.length, 1);
assert.equal(rendered[0].summary.totalTokens, 30);
const pending = ctx.refreshTokenUsage();
ctx.stopTokenUsageRequests();
requests[2].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 90 } }) });
await pending;
assert.equal(rendered.length, 1, 'leaving the section must invalidate outstanding results');
});
test('all supported languages expose usage labels and interpolation placeholders', () => {
for (const locale of ['zh-CN', 'en-US', 'ru-RU']) {
const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8'));
for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]);
assert.ok(data.settings.nav.tokenUsage);
for (const key of ['subtitle', 'scopeNote', 'trendMetric', 'cacheComposition', 'cacheHit', 'uncachedInput', 'cacheNote', 'scopeProject', 'scopeAccessible', 'noDailyData']) assert.ok(data.tokenUsageUI[key]);
assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/);
assert.match(data.dashboard.tokenUsageSub, /\{\{rate\}\}/);
assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/);
}
});
+291
View File
@@ -0,0 +1,291 @@
// Token detail data belongs to the settings section; the dashboard keeps a
// separate fixed seven-day summary.
var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null, trendMetric: 'totalTokens' };
function tokenUsageText(key, opts, fallback) {
if (typeof window.t === 'function') {
const text = window.t(key, opts);
if (text && text !== key) return text;
}
return fallback == null ? key : fallback;
}
function openDashboardTokenUsage() {
if (window.location.hash === '#settings?section=token-usage') {
switchPage('settings');
switchSettingsSection('token-usage');
} else {
window.location.hash = 'settings?section=token-usage';
}
}
function stopTokenUsageRequests() {
tokenUsageState.requestSequence++;
if (tokenUsageState.controller) tokenUsageState.controller.abort();
tokenUsageState.controller = null;
}
async function refreshTokenUsage() {
const host = document.getElementById('settings-token-analysis');
if (!host) return;
stopTokenUsageRequests();
const sequence = tokenUsageState.requestSequence;
const controller = new AbortController();
tokenUsageState.controller = controller;
host.setAttribute('aria-busy', 'true');
host.textContent = tokenUsageText('common.loading', null, '加载中…');
const select = document.getElementById('settings-token-days');
if (select) select.value = String(tokenUsageState.days);
let url = '/api/usage/tokens?days=' + tokenUsageState.days + '&limit=20';
const projectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() : '';
const scope = document.getElementById('settings-token-scope');
if (scope) scope.textContent = tokenUsageText('tokenUsageUI.' + (projectId ? 'scopeProject' : 'scopeAccessible'), null, projectId ? '当前项目' : '可访问会话');
if (projectId) url += '&project_id=' + encodeURIComponent(projectId);
try {
const response = await apiFetch(url, { signal: controller.signal });
const result = response && response.ok ? await response.json() : null;
if (sequence !== tokenUsageState.requestSequence) return;
tokenUsageState.result = result;
renderTokenUsageAnalysis(result);
} catch (_) {
if (sequence !== tokenUsageState.requestSequence) return;
tokenUsageState.result = null;
renderTokenUsageAnalysis(null);
} finally {
if (sequence === tokenUsageState.requestSequence) {
tokenUsageState.controller = null;
host.removeAttribute('aria-busy');
}
}
}
function changeTokenUsagePeriod(value) {
const days = Number(value);
if (![7, 30, 90].includes(days)) return;
tokenUsageState.days = days;
refreshTokenUsage();
}
function tokenUsageCacheRate(summary) {
const input = tokenUsageCount(summary && summary.promptTokens);
const cached = tokenUsageCount(summary && summary.cachedTokens);
return input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—';
}
function tokenUsageCount(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
}
function tokenUsageMetrics(summary) {
const input = tokenUsageCount(summary.promptTokens);
const cached = tokenUsageCount(summary.cachedTokens);
return [
['usageTotal', summary.totalTokens],
['usageInput', input],
['usageOutput', summary.completionTokens],
['usageCacheRead', cached],
['usageCacheWrite', summary.cacheWriteTokens],
['usageReasoning', summary.reasoningTokens],
['usageCalls', summary.modelCalls],
['usageCacheRate', tokenUsageCacheRate(summary)]
];
}
document.addEventListener('languagechange', function () {
if (tokenUsageState.controller) return;
const page = document.getElementById('page-settings');
const section = document.getElementById('settings-section-token-usage');
if (page && page.classList.contains('active') && section && section.classList.contains('active')) {
renderTokenUsageAnalysis(tokenUsageState.result);
}
});
function tokenUsageNode(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
// Fill only gaps between returned dates. The API's server-local day keys are
// authoritative; do not invent a browser-timezone endpoint for the period.
function tokenUsageTimeline(rows) {
const dated = (rows || []).filter(row => /^\d{4}-\d{2}-\d{2}$/.test(row.key || '')).sort((a, b) => a.key.localeCompare(b.key));
if (!dated.length) return [];
const byDay = new Map(dated.map(row => [row.key, row]));
const start = Date.parse(dated[0].key + 'T00:00:00Z');
const end = Date.parse(dated[dated.length - 1].key + 'T00:00:00Z');
if (!Number.isFinite(start) || !Number.isFinite(end)) return dated;
const days = [];
for (let time = start; time <= end && days.length < 365; time += 86400000) {
const key = new Date(time).toISOString().slice(0, 10);
days.push(byDay.get(key) || { key, events: 0, totalTokens: 0, modelCalls: 0, promptTokens: 0 });
}
return days;
}
function setTokenUsageTrend(metric) {
if (!['totalTokens', 'modelCalls', 'cacheRate'].includes(metric)) return;
tokenUsageState.trendMetric = metric;
renderTokenUsageAnalysis(tokenUsageState.result);
const button = document.querySelector('[data-token-trend="' + metric + '"]');
if (button) button.focus();
}
function renderTokenUsageAnalysis(res) {
const host = document.getElementById('settings-token-analysis');
if (!host) return;
host.replaceChildren();
const label = key => tokenUsageText('dashboard.' + key, null, key);
const ui = key => tokenUsageText('tokenUsageUI.' + key, null, key);
const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN');
if (!res || !res.summary) {
const empty = tokenUsageNode('div', 'token-usage-empty', label('usageUnavailable'));
host.append(empty);
return;
}
const summary = res.summary;
const metrics = new Map(tokenUsageMetrics(summary));
const primary = tokenUsageNode('div', 'token-usage-metrics token-usage-primary');
const secondary = tokenUsageNode('div', 'token-usage-metrics token-usage-secondary');
const primaryKeys = ['usageTotal', 'usageInput', 'usageOutput', 'usageCacheRate'];
[...primaryKeys, 'usageCalls', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning'].forEach(key => {
const value = metrics.get(key);
const card = tokenUsageNode('div', 'token-usage-metric');
card.dataset.metric = key;
card.append(tokenUsageNode('span', '', label(key)), tokenUsageNode('strong', '', typeof value === 'string' ? value : exact(value)));
(primaryKeys.includes(key) ? primary : secondary).append(card);
});
host.append(primary, secondary);
if (!tokenUsageCount(summary.events)) {
host.append(tokenUsageNode('div', 'token-usage-empty', label('noTokenUsageYet')));
return;
}
const chartLayout = tokenUsageNode('div', 'token-usage-charts');
const chart = tokenUsageNode('section', 'token-usage-chart-card');
const chartHead = tokenUsageNode('div', 'token-usage-chart-head');
chartHead.append(tokenUsageNode('h4', '', label('usageTrend')));
const tabs = tokenUsageNode('div', 'token-usage-chart-tabs');
tabs.setAttribute('role', 'group');
tabs.setAttribute('aria-label', ui('trendMetric'));
for (const [metric, key] of [['totalTokens', 'usageTotal'], ['modelCalls', 'usageCalls'], ['cacheRate', 'usageCacheRate']]) {
const button = tokenUsageNode('button', '', label(key));
button.type = 'button';
button.dataset.tokenTrend = metric;
button.setAttribute('aria-pressed', String(tokenUsageState.trendMetric === metric));
button.addEventListener('click', () => setTokenUsageTrend(metric));
tabs.append(button);
}
chartHead.append(tabs);
chart.append(chartHead);
const tooltip = tokenUsageNode('p', 'token-usage-chart-tooltip', ui('chartHint'));
tooltip.setAttribute('aria-live', 'polite');
const days = tokenUsageTimeline(res.byDay);
const metric = tokenUsageState.trendMetric;
const valueOf = row => metric === 'cacheRate' ? (tokenUsageCount(row.promptTokens) ? Math.min(tokenUsageCount(row.cachedTokens), tokenUsageCount(row.promptTokens)) / tokenUsageCount(row.promptTokens) * 100 : 0) : tokenUsageCount(row[metric]);
const peak = metric === 'cacheRate' ? 100 : Math.max(1, ...days.map(valueOf));
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 640 230');
svg.setAttribute('role', 'group');
svg.setAttribute('aria-label', label('usageTrend'));
svg.classList.add('token-usage-trend-svg');
const element = (tag, attrs, text) => {
const node = document.createElementNS(svgNS, tag);
Object.entries(attrs).forEach(([key, value]) => node.setAttribute(key, String(value)));
if (text != null) node.textContent = text;
return node;
};
for (let tick = 0; tick <= 4; tick++) {
const y = 18 + tick * 44;
svg.append(element('line', { x1: 54, x2: 624, y1: y, y2: y, class: 'token-usage-chart-grid' }));
svg.append(element('text', { x: 46, y: y + 4, 'text-anchor': 'end', class: 'token-usage-chart-label' }, metric === 'cacheRate' ? (100 - tick * 25) + '%' : formatTokenUsageCompact(peak * (4 - tick) / 4)));
}
const slot = 570 / Math.max(days.length, 1);
const every = Math.max(1, Math.ceil(days.length / 7));
days.forEach((row, index) => {
const x = 54 + slot * (index + .5);
const height = valueOf(row) / peak * 176;
const detail = row.key + ' · ' + label('usageTotal') + ' ' + exact(row.totalTokens) + ' · ' + label('usageCalls') + ' ' + exact(row.modelCalls) + ' · ' + label('usageCacheRate') + ' ' + tokenUsageCacheRate(row);
const bar = element('rect', { x: x - Math.min(24, slot * .55) / 2, y: 194 - height, width: Math.min(24, slot * .55), height: Math.max(height, 1), rx: 3, class: 'token-usage-chart-bar', tabindex: 0, 'aria-label': detail });
bar.append(element('title', {}, detail));
for (const event of ['mouseenter', 'focus']) bar.addEventListener(event, () => { tooltip.textContent = detail; });
for (const event of ['mouseleave', 'blur']) bar.addEventListener(event, () => { tooltip.textContent = ui('chartHint'); });
svg.append(bar);
if (index % every === 0 || index === days.length - 1) {
svg.append(element('text', { x, y: 218, 'text-anchor': 'middle', class: 'token-usage-chart-label' }, row.key.slice(5)));
}
});
const plot = tokenUsageNode('div', 'token-usage-svg-wrap');
plot.append(svg);
chart.append(plot);
chart.append(tooltip);
if (!days.length) chart.append(tokenUsageNode('p', 'token-usage-empty', ui('noDailyData')));
chartLayout.append(chart);
const cache = tokenUsageNode('section', 'token-usage-chart-card token-usage-cache-card');
cache.append(tokenUsageNode('h4', '', ui('cacheComposition')));
const input = tokenUsageCount(summary.promptTokens);
const cached = Math.min(input, tokenUsageCount(summary.cachedTokens));
const ratio = input ? cached / input * 100 : 0;
const ring = tokenUsageNode('div', 'token-usage-cache-ring');
ring.style.setProperty('--cache-ratio', ratio + '%');
ring.append(tokenUsageNode('strong', '', tokenUsageCacheRate(summary)), tokenUsageNode('span', '', ui('cacheHit')));
cache.append(ring);
for (const [key, value, className] of [['usageCacheRead', cached, 'is-cached'], ['uncachedInput', input - cached, 'is-uncached']]) {
const line = tokenUsageNode('div', 'token-usage-cache-legend ' + className);
line.append(tokenUsageNode('span', '', key === 'uncachedInput' ? ui(key) : label(key)), tokenUsageNode('strong', '', exact(value)));
cache.append(line);
}
cache.append(tokenUsageNode('p', 'token-usage-chart-note', ui('cacheNote')));
chartLayout.append(cache);
host.append(chartLayout);
// Provider labels are untrusted: render all values as text or constructed
// nodes, never as HTML. Full precision is retained in every table.
const table = (titleKey, headings, rows, collapsed = false) => {
const section = tokenUsageNode(collapsed ? 'details' : 'section', 'token-usage-data-card');
const title = tokenUsageNode(collapsed ? 'summary' : 'h4', '', label(titleKey));
const wrap = tokenUsageNode('div', 'token-usage-table-wrap');
const grid = tokenUsageNode('table');
const caption = tokenUsageNode('caption', 'sr-only', label(titleKey));
const head = tokenUsageNode('thead');
const hr = tokenUsageNode('tr');
headings.forEach(key => { const th = tokenUsageNode('th', '', label(key)); th.scope = 'col'; hr.append(th); });
head.append(hr);
const body = tokenUsageNode('tbody');
rows.forEach(values => {
const tr = tokenUsageNode('tr');
values.forEach(value => {
const td = tokenUsageNode('td');
if (value && value.nodeType) td.append(value);
else td.textContent = String(value == null ? '' : value);
tr.append(td);
});
body.append(tr);
});
grid.append(caption, head, body);
wrap.append(grid);
section.append(title, wrap);
host.append(section);
};
const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)];
const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls'];
table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => {
const ratio = tokenUsageCount(summary.totalTokens) ? tokenUsageCount(row.totalTokens) / tokenUsageCount(summary.totalTokens) * 100 : 0;
const share = tokenUsageNode('div', 'token-usage-share');
share.append(tokenUsageNode('span', '', ratio.toFixed(1) + '%'));
const track = tokenUsageNode('span', 'token-usage-share-track');
const fill = tokenUsageNode('span'); fill.style.width = Math.min(100, ratio) + '%'; track.append(fill); share.append(track);
return [row.label || row.key, share, ...counts(row)];
}));
table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)]), true);
table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => {
const time = new Date(row.createdAt);
const status = tokenUsageNode('span', 'token-usage-status', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'));
if (row.reason === 'final' || row.reason === 'partial') status.classList.add('is-' + row.reason);
return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', row.model || '—', status, exact(row.totalTokens), exact(row.modelCalls)];
}));
}
@@ -117,12 +117,14 @@ test('助手轮次摘要会显示持久化 token 用量', () => {
promptTokens: 1200,
completionTokens: 34,
cachedTokens: 200,
cacheWriteTokens: 100,
reasoningTokens: 12,
modelCalls: 1,
model: 'deepseek-v3',
});
assert.equal(message.dataset.turnTotalTokens, '1234');
assert.equal(message.dataset.turnCacheWriteTokens, '100');
assert.match(message.label.innerHTML, /1\.2K tokens/);
assert.match(message.label.innerHTML, /turn-process-token-chip/);
});
+1 -1
View File
@@ -435,7 +435,7 @@ test('the console lives inside the settings page as its own section', () => {
// Both hash entry points carry the alias, and the retired page id is gone from the whitelists:
// #system-update has to land on 系统设置 with its update section selected.
const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : '';/g)];
const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : /g)];
assert.equal(aliases.length, 2, 'both hash parsers must normalize #system-update into the settings section');
const selects = [...router.matchAll(/if \(settingsSection && typeof switchSettingsSection === 'function'\) \{[\s\S]{0,80}?switchSettingsSection\(settingsSection\);/g)];
assert.equal(selects.length, 2, 'both hash paths must open the update section after switching the page');
+3
View File
@@ -2203,6 +2203,9 @@ function formatWebshellEinoUsageSummaryMessage(data) {
if (Number(d.cachedTokens || 0) > 0) {
rows.push([wsTOr('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]);
}
if (Number(d.cacheWriteTokens || 0) > 0) {
rows.push([wsTOr('chat.einoUsageCacheWriteTokens', '缓存写入 tokens'), d.cacheWriteTokens]);
}
if (Number(d.reasoningTokens || 0) > 0) {
rows.push([wsTOr('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]);
}
+37 -9
View File
@@ -39,14 +39,14 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20261010-layout-overflow-1">
<link rel="stylesheet" href="/static/css/style.css?v=20261016-context-memory-token-analytics">
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<!-- 移动端样式层:全部规则限定在 767.98px 以下,桌面端零影响 -->
<link rel="stylesheet" href="/static/css/mobile.css?v=20261007-glass-1">
<script src="/static/js/router.js?v=20261009-retest-1"></script>
<link rel="stylesheet" href="/static/css/mobile.css?v=20261015-project-table-3">
<script src="/static/js/router.js?v=20261011-token-settings"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -556,7 +556,7 @@
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-rate-sub-text" data-i18n="dashboard.healthyStatus">运行平稳</span>
</div>
</div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('chat')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('chat'); }" data-i18n="dashboard.clickToViewChat" data-i18n-attr="title" title="点击查看对话">
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="openDashboardTokenUsage()" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); openDashboardTokenUsage(); }" data-i18n="dashboard.usageView" data-i18n-attr="title" title="查看用量详情">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.tokenUsage">Token 用量</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-tokens" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/><path d="M8 3 6 21"/><path d="m18 3-2 18"/></svg></span>
@@ -3602,6 +3602,9 @@
<div class="settings-nav-item active" data-section="basic" onclick="switchSettingsSection('basic')">
<span data-i18n="settings.nav.basic">基本设置</span>
</div>
<div class="settings-nav-item" data-section="token-usage" role="button" tabindex="0" onclick="switchSettingsSection('token-usage')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchSettingsSection('token-usage'); }">
<span data-i18n="settings.nav.tokenUsage">Token 用量</span>
</div>
<div class="settings-nav-item" data-section="hitl" onclick="switchSettingsSection('hitl')">
<span data-i18n="settings.nav.hitl">人机协同</span>
</div>
@@ -3637,6 +3640,24 @@
<!-- 右侧内容区域 -->
<div class="settings-content">
<div id="settings-section-token-usage" class="settings-section-content token-usage-panel">
<div class="settings-section-header token-usage-header">
<div><h3 data-i18n="settings.nav.tokenUsage">Token 用量</h3><p class="token-usage-subtitle" data-i18n="tokenUsageUI.subtitle">查看模型消耗、缓存利用与使用趋势</p></div>
<button id="settings-token-refresh" type="button" class="btn-secondary" onclick="refreshTokenUsage()" data-i18n="common.refresh">刷新</button>
</div>
<div class="token-usage-toolbar">
<label for="settings-token-days" data-i18n="dashboard.usagePeriod">统计周期</label>
<select id="settings-token-days" onchange="changeTokenUsagePeriod(this.value)">
<option value="7" data-i18n="dashboard.usage7Days">近 7 天</option>
<option value="30" data-i18n="dashboard.usage30Days">近 30 天</option>
<option value="90" data-i18n="dashboard.usage90Days">近 90 天</option>
</select>
<span id="settings-token-scope" class="token-usage-scope"></span>
</div>
<details class="token-usage-note"><summary data-i18n="tokenUsageUI.scopeNote">统计口径与说明</summary><p data-i18n="dashboard.usageScope">统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。</p></details>
<div id="settings-token-analysis" aria-live="polite"></div>
</div>
<!-- 基本设置 -->
<div id="settings-section-basic" class="settings-section-content active">
<div class="settings-section-header">
@@ -5697,6 +5718,11 @@
</div>
<div class="attack-chain-sidebar">
<div class="attack-chain-sidebar-content">
<details id="attack-chain-context" class="attack-chain-context" hidden>
<summary><span id="attack-chain-context-title"></span></summary>
<p id="attack-chain-context-notice" class="attack-chain-context-notice"></p>
<div id="attack-chain-context-content"></div>
</details>
<div class="attack-chain-legend">
<div class="legend-section">
<div class="legend-title" data-i18n="attackChainModal.riskLevel">风险等级</div>
@@ -6996,14 +7022,16 @@
<script src="/static/js/info-collect.js?v=20260717-1"></script>
<script src="/static/js/assets.js?v=20260717-7"></script>
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/dashboard.js?v=20261011-token-settings-cache-only"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat.js?v=20261009-retest-1"></script>
<script src="/static/js/monitor.js?v=20261011-token-usage"></script>
<script src="/static/js/attack-chain-context.js?v=20261012-summary-2"></script>
<script src="/static/js/chat.js?v=20261016-context-memory-token-usage"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/tool-guard.js?v=20261010-conflict"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/token-usage.js?v=20261012-token-analytics"></script>
<script src="/static/js/settings.js?v=20261011-token-settings"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
<script src="/static/js/storage.js"></script>
@@ -7016,7 +7044,7 @@
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js?v=20260819-1"></script>
<script src="/static/js/vulnerability.js?v=20261010-retest-2"></script>
<script src="/static/js/webshell.js?v=20260907-blocked-1"></script>
<script src="/static/js/webshell.js?v=20261011-token-usage"></script>
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/workflow-package-client.js"></script>