diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui index fab325ee..4e428df7 160000 --- a/web/frontend/cyber-ui +++ b/web/frontend/cyber-ui @@ -1 +1 @@ -Subproject commit fab325ee56fff0c8d2d275b2599278967dbca046 +Subproject commit 4e428df7a7968eb7a2040362c6bbef93deefab10 diff --git a/web/frontend/e2e/jev-decision-ui.spec.ts b/web/frontend/e2e/jev-decision-ui.spec.ts index ebd779f7..80ded768 100644 --- a/web/frontend/e2e/jev-decision-ui.spec.ts +++ b/web/frontend/e2e/jev-decision-ui.spec.ts @@ -33,14 +33,16 @@ test('live judgments show choice distribution, native score and noul before actu await expect(page.locator('[data-selected=true]')).toHaveCount(0) await expect(page.getByTestId('jev-question').filter({ hasText: '正在判断' })).toHaveCount(3) const call = { id: 'read', name: 'read-evidence', arguments: { data: new TextEncoder().encode('{"target":"public"}') } } - await render(page, [runtime(4, { case: 'decisionResult', value: { requestId: 'live', elapsedMs: 275, usage: {inputTokens:120n,outputTokens:15n}, evaluations: { + await render(page, [runtime(4, { case: 'decisionResult', value: { requestId: 'live', elapsedMs: 275, + usage: { inputTokens: 120n, outputTokens: 15n, detail: { cache_read: 60n, cache_write: 10n } }, evaluations: { next: { value: { case: 'choice', value: 'picked' }, confidence: .7, probabilities: { winner: .7, picked: .3 } }, risk: { value: { case: 'score', value: 1.4 }, confidence: .8 }, enough: { value: { case: 'noul', value: .73 }, confidence: .6 }, } } }), runtime(5, { case: 'takeover', value: { definition } }), runtime(6, { case: 'dispatch', value: { call, candidateId: 'picked', read: true } })], true) await expect(page.getByTestId('jev-check')).toHaveCount(0) await expect(page.getByTestId('agent-workflow')).toHaveAttribute('open', '') await page.locator('[data-record-id="event-3"]').click() - await expect(page.getByTestId('jev-token-usage')).toContainText('JEV · 输入 120 · 输出 15 token') + await expect(page.getByTestId('token-usage')).toHaveText('↓120↑1550%') + await expect(page.getByTestId('token-usage')).toHaveAccessibleName('输入 120 token · 输出 15 token · 合计 135 token · 缓存读取 60 token · 缓存写入 10 token · 缓存 50%') const choice = page.locator('[data-question-id=next]') await expect(choice.locator('[data-option-id]')).toHaveCount(3) expect(await choice.locator('[data-option-id]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-option-id')))).toEqual(['winner', 'picked', 'unknown']) @@ -87,5 +89,5 @@ test('compilation keeps generation, review, publication and a failed retry in ch await expect(page.locator('[data-control-current=true]')).toContainText('生成失败') await expect(page.locator('[data-control-current=true]')).toContainText('Invalid binding') await expect(page.locator('[data-generation-request-id="reflex-draft-2"]')).toContainText('第 2 次生成 · 错误阶段:reader') - await expect(page.getByTestId('jev-token-usage').filter({hasText:'Reflex LLM'})).toContainText('token 用量未知') + await expect(page.locator('[data-generation-request-id="reflex-draft-2"]').locator('..').getByTestId('token-usage')).toHaveCount(0) }) diff --git a/web/frontend/e2e/jev-mechanism.spec.ts b/web/frontend/e2e/jev-mechanism.spec.ts index e27a76de..b3da085f 100644 --- a/web/frontend/e2e/jev-mechanism.spec.ts +++ b/web/frontend/e2e/jev-mechanism.spec.ts @@ -67,6 +67,9 @@ test('compiler rounds and mechanism diagnostics retain separate usage', async ({ event(8, { case: 'usage', value: { inputTokens: 50n, outputTokens: 5n } }), runtime(9, { case: 'decisionResult', value: { requestId: 'missing-usage' } })] await render(page, [...events, ...events]) + await expect(page.locator('[data-record-id="mechanism-2"]').getByTestId('token-usage')).toHaveText('↓100↑10') + await expect(page.locator('[data-record-id="mechanism-4"]').getByTestId('token-usage')).toHaveCount(0) + await expect(page.locator('[data-record-id="mechanism-9"]').getByTestId('token-usage')).toHaveCount(0) await page.locator('[data-record-id="mechanism-3"]').click() const detail = page.locator('[data-control-current=true]') await expect(detail).toContainText('native read flag contradicts trusted contract') diff --git a/web/frontend/e2e/jev-robustness.spec.ts b/web/frontend/e2e/jev-robustness.spec.ts index 3ad64668..16cccc75 100644 --- a/web/frontend/e2e/jev-robustness.spec.ts +++ b/web/frontend/e2e/jev-robustness.spec.ts @@ -141,7 +141,7 @@ test('foreground arguments and tool-free generated results stay in the execution const segment = page.getByTestId('agent-workflow') await page.locator('[data-record-id="deep-2"]').click() await expect(segment).toContainText('当前任务参数') - await expect(segment.getByTestId('jev-token-usage')).toContainText('前台 LLM') + await expect(segment.getByTestId('token-usage')).toHaveText('↓20↑8') await page.locator('[data-record-id="deep-4"]').click() await expect(segment).toContainText('answer') await expect(segment).toContainText('12') diff --git a/web/frontend/e2e/workflow.spec.ts b/web/frontend/e2e/workflow.spec.ts index 7eb9be07..2f3a0467 100644 --- a/web/frontend/e2e/workflow.spec.ts +++ b/web/frontend/e2e/workflow.spec.ts @@ -85,6 +85,77 @@ test('delegated execution branches once and background events stay in their orig expect(workflow.nodes.find(node => node.background)?.state).toBe('pending') }) +test('card usage accumulates calls and excludes the turn aggregate after a response boundary', () => { + const usage = (seq: number, input: bigint, output: bigint) => event(seq, { case: 'usage', value: { + inputTokens: input, outputTokens: output, totalTokens: input + output, detail: { cache_read: input / 2n, cache_write: 0n }, + } }) + const events = [event(1, { case: 'message', value: { id: 'first', role: 'assistant', content: [{ value: { case: 'text', value: { text: 'First step' } } }] } }), + usage(2, 100n, 20n), usage(3, 50n, 10n), event(4, { case: 'status', value: { state: 'eval_start' } }), + event(5, { case: 'message', value: { id: 'last', role: 'assistant', content: [{ value: { case: 'text', value: { text: 'Final answer' } } }] } }), + usage(6, 40n, 5n), event(7, { case: 'turnEnded', value: { usage: { inputTokens: 190n, outputTokens: 35n, totalTokens: 225n } } })] + const items = reduceAOPToTimeline([...events, ...events], { responseBoundary: e => e.payload.case === 'status' }) + expect(items.filter(item => item.kind === 'assistant_response').map(item => item.response?.metadata?.usage)).toEqual([ + { input_tokens: 150, output_tokens: 30, total_tokens: 180, cache_read: 75, cache_write: 0, model: '' }, + { input_tokens: 40, output_tokens: 5, total_tokens: 45, cache_read: 20, cache_write: 0, model: '' }, + ]) +}) + +for (const width of [390, 1280]) test(`card token usage stays compact and appears once through live updates and replay ${width}`, async ({ page }, info) => { + await page.setViewportSize({ width, height: 900 }) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.route('**/cyber.rpc.artifact.ArtifactService/SyncArtifacts', route => route.fulfill({ + contentType: 'application/proto', body: Buffer.from(toBinary(SyncArtifactsResponseSchema, create(SyncArtifactsResponseSchema))), + })) + await mount(page) + const first = [event(1, { case: 'turnStarted', value: {} }), + event(2, { case: 'message', value: { id: 'thought', role: 'assistant', content: [{ value: { case: 'reasoning', value: { text: 'Inspect the recorded evidence.' } } }] } }), + event(3, { case: 'toolCall', value: call }), event(4, { case: 'toolResult', value: { callId: call.id, name: call.name, + output: [{ value: { case: 'text', value: { text: 'Current evidence retrieved.' } } }] } }), + event(5, { case: 'usage', value: { inputTokens: 1200n, outputTokens: 240n, totalTokens: 1440n, detail: { cache_read: 960n, cache_write: 0n } } })] + await render(page, first) + const badges = page.getByTestId('token-usage') + await expect(badges).toHaveCount(1) + await expect(badges.first()).toHaveText('↓1.2k↑24080%') + const rest = [event(6, { case: 'status', value: { state: 'eval_start' } }), + event(7, { case: 'message', value: { id: 'reply', role: 'assistant', content: [{ value: { case: 'text', value: { text: 'Recorded evidence verified.' } } }] } }), + event(8, { case: 'usage', value: { inputTokens: 300n, outputTokens: 30n, totalTokens: 330n, detail: { cache_read: 0n } } }), + event(9, { case: 'turnEnded', value: { usage: { inputTokens: 1500n, outputTokens: 270n, totalTokens: 1770n } } })] + await render(page, rest, true) + await expect(badges).toHaveCount(2) + await expect(page.locator('[data-kind=tool]').getByTestId('token-usage')).toHaveCount(0) + await expect(page.locator('[data-kind=response]').getByTestId('token-usage')).toHaveCount(0) + await expect(page.getByTestId('assistant-response-footer').getByTestId('token-usage')).toHaveText('↓300↑30') + await badges.first().focus() + await expect(page.getByRole('tooltip')).toContainText('缓存读取 960 token') + await expect(page.getByRole('tooltip')).toContainText('缓存写入 0 token') + await page.keyboard.press('Escape') + const content = await badges.allTextContents() + await render(page, [...first, ...rest, ...first, ...rest]) + await expect(badges).toHaveCount(2) + expect(await badges.allTextContents()).toEqual(content) + for (const badge of await badges.all()) { + const bounds = await badge.boundingBox() + expect(bounds!.height).toBeLessThan(24) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width) + } + await badges.first().evaluate(element => (element as HTMLElement).blur()) + await expect(page.getByRole('tooltip')).toHaveCount(0) + await page.screenshot({ path: info.outputPath(`card-token-usage-${width}.png`), fullPage: true }) +}) + +test('total-only usage stays visible and unreported usage adds no placeholder', async ({ page }) => { + await mount(page) + const answer = event(1, { case: 'message', value: { id: 'answer', role: 'assistant', + content: [{ value: { case: 'text', value: { text: 'Verified current evidence.' } } }] } }) + await render(page, [answer]) + await expect(page.getByTestId('token-usage')).toHaveCount(0) + await render(page, [event(2, { case: 'turnEnded', value: { usage: { totalTokens: 11n } } })], true) + await expect(page.getByTestId('token-usage')).toHaveText('11 tok') + await expect(page.getByTestId('token-usage')).toHaveAccessibleName('合计 11 token') + await render(page, [answer, event(2, { case: 'usage', value: { inputTokens: 100n, detail: { usage_missing: 1n } } })]) + await expect(page.getByTestId('token-usage')).toHaveCount(0) +}) + test('live decisions and tool receipts update inline without duplicating their content', async ({ page }, info) => { await mount(page) const initial = [event(1, { case: 'turnStarted', value: {} }), trace(2, { case: 'takeover', value: { definition } }), diff --git a/web/frontend/src/components/ChatPanel.tsx b/web/frontend/src/components/ChatPanel.tsx index 607d20c8..84bed257 100644 --- a/web/frontend/src/components/ChatPanel.tsx +++ b/web/frontend/src/components/ChatPanel.tsx @@ -62,6 +62,7 @@ import { withRecaps } from '../lib/recap-view' import { isJEVBoundary, jevTimelineEvents, projectJEV, runtimeEvents, withJEV } from '../lib/jev-view' import { withWorkflows, type WorkflowTurn } from '../lib/workflow-view' import { Workflow } from './chat/Workflow' +import { TokenUsageBadge } from './chat/TokenUsageBadge' import { emptyExtensionResolver, type ExtensionResolver } from '../runtime/conversation' import { ContributionBoundary } from '../runtime/contribution-boundary' import { ReviewState, type Review } from '../cyber-proto' @@ -911,6 +912,7 @@ function timelineContent( } {node.response?.content.trim() &&
} {typeof node.response?.metadata?.recap === 'string' &&
{node.response.metadata.recap}
} + : node.kind === 'extension' && node.extensionType === 'guardrail' ? ) : undefined} response={hasResponse ? : undefined} - footer={!embedded && typeof message?.metadata?.recap === 'string' - ? {message.metadata.recap} : undefined} + footer={message?.metadata?.usage || !embedded && typeof message?.metadata?.recap === 'string' + ?
+ {!embedded && typeof message?.metadata?.recap === 'string' && {message.metadata.recap}} + +
: undefined} labels={{ tools: toolsLabel, thinking: t('thinkingLabel'), response: t('responseLabel') }} headerClassName="xl:hidden" timeLabel={formatRailTime(response)} diff --git a/web/frontend/src/components/chat/JEVDecision.tsx b/web/frontend/src/components/chat/JEVDecision.tsx index 6f043bec..31570551 100644 --- a/web/frontend/src/components/chat/JEVDecision.tsx +++ b/web/frontend/src/components/chat/JEVDecision.tsx @@ -2,18 +2,12 @@ import { ClaimType, type Claim, type Evaluation } from '../../gen/decision/claim import { Check, CircuitBoard, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { DecisionRequest, DecisionResult } from '../../gen/types/jev_pb' -import type { TokenUsage } from '../../../cyber-ui/packages/aop/src/gen/aop/event_pb' +import { TokenUsageBadge } from './TokenUsageBadge' import { decisionOptions, decisionQuestions, evaluationNumber, evaluationChoice } from '../../lib/jev-decisions' import './JEVTimeline.css' const percent = (value: number) => `${(value * 100).toFixed(1)}%` -export function TokenUsageLine({ source, usage }: { source: string; usage?: TokenUsage }) { - const { t } = useTranslation('jev') - return

{source} · {usage && !usage.detail.usage_missing - ? t('tokenUsage', { input: usage.inputTokens.toString(), output: usage.outputTokens.toString() }) : t('usageUnknown')}

-} - export function ChoiceBranches({ question, answer, definition = false }: { question: Claim; answer?: Evaluation; definition?: boolean }) { const { t } = useTranslation('jev') const options = decisionOptions(question, answer) @@ -65,13 +59,13 @@ export function DecisionBatch({ request, result, bodyOnly = false }: { request: const header =
JEV{t('judgment')} {questions.length > 1 ? t('parallelQuestions', { count: questions.length }) : t('singleQuestion')} - {result ? {Number(result.elapsedMs)} ms : } + {result ? {Number(result.elapsedMs)} ms : }
- const body = <>{result && }{result?.error &&

{result.error}

} + const body = <>{result?.error &&

{result.error}

}
{questions.map(([id, question]) => )}
const attributes = { 'data-testid': 'jev-decision', 'data-request-id': request.requestId, 'data-runtime-request': request.requestId, 'data-state': result ? result.error ? 'failed' : 'answered' : 'judging' } return
{bodyOnly ?
{questions.length > 1 ? t('parallelQuestions', { count: questions.length }) : t('singleQuestion')} - {result ? {Number(result.elapsedMs)} ms : } + {result ? {Number(result.elapsedMs)} ms : }
: header}{body}
} diff --git a/web/frontend/src/components/chat/JEVTimeline.tsx b/web/frontend/src/components/chat/JEVTimeline.tsx index 67297436..cba455a7 100644 --- a/web/frontend/src/components/chat/JEVTimeline.tsx +++ b/web/frontend/src/components/chat/JEVTimeline.tsx @@ -9,7 +9,8 @@ import { DecisionRequestSchema } from '../../gen/types/jev_pb' import { ClaimSchema } from '../../gen/decision/claim_pb' import { claimDefinitions, parseJEVJSON, evaluationType } from '../../lib/jev-decisions' import type { JEVSegment, JEVCompilation, JEVCheck } from '../../lib/jev-view' -import { DecisionBatch, TokenUsageLine } from './JEVDecision' +import { DecisionBatch } from './JEVDecision' +import { TokenUsageBadge } from './TokenUsageBadge' import { JEVDefinition } from './JEVDefinition' import { JEVReference } from './JEVReference' import { recordWorkflows, type WorkflowNode } from '../../lib/workflow-view' @@ -103,10 +104,15 @@ export function WorkflowNodeContent({ node, nodes, stage }: { node: WorkflowNode })) const published = generation.state === 'finished' && !generation.error && drafts.length > 0 && targets.every(Boolean) const publications = [...new Map(targets.filter((target): target is WorkflowNode => !!target).map(target => [target.id, target])).values()] + // Compiler rounds own their individual counters; the parent includes + // those same calls and already contributes to the panel's aggregate. + const hasRounds = generation.kind === 'reflex_llm' && generation.requestId && nodes.some(other => other.sessionId === node.sessionId + && other.turnId === node.turnId && other.related?.some(record => record.value.payload.case === 'generation' + && record.value.payload.value.parentRequestId === generation.requestId && record.value.payload.value.usage)) return
-

{t(generation.state === 'started' ? 'generationStarted' : generation.error ? 'generationFailed' : 'generationFinished')}

+
{t(generation.state === 'started' ? 'generationStarted' : generation.error ? 'generationFailed' : 'generationFinished')} + {generation.state === 'finished' && generation.kind !== 'reflex_validation' && !hasRounds && }
{(generation.attempt > 0 || generation.errorStage) &&

{t('generationAttempt', { count: generation.attempt })}{generation.errorStage && ` · ${t('errorStage', { stage: generation.errorStage })}`}

} - {generation.state === 'finished' && generation.kind !== 'reflex_validation' && } {generation.error && generation.error !== diagnosticMessage && } {generation.kind === 'reflex_validation' && generation.output && } {published ?

{t('workflow.artifactAtPublication')}

diff --git a/web/frontend/src/components/chat/TokenUsageBadge.tsx b/web/frontend/src/components/chat/TokenUsageBadge.tsx new file mode 100644 index 00000000..f41da7d0 --- /dev/null +++ b/web/frontend/src/components/chat/TokenUsageBadge.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next' +import { Database } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@cyber/ui' +import { formatTokenCount, normalizeTokenUsage } from '../../../cyber-ui/packages/viewer/src/lib/token-usage' + +export function TokenUsageBadge({ usage }: { usage?: unknown }) { + const { t } = useTranslation('chat') + const value = normalizeTokenUsage(usage) + if (!value || value.totalTokens <= 0) return null + const breakdown = value.inputTokens > 0 || value.outputTokens > 0 + const read = value.cacheReadInputTokens ?? value.cachedInputTokens + const hit = read !== undefined && value.inputTokens > 0 ? Math.min(100, read / value.inputTokens * 100) : undefined + const details = [ + ...(breakdown ? [t('tokenInput', { count: value.inputTokens }), t('tokenOutput', { count: value.outputTokens })] : []), + t('tokenTotal', { count: value.totalTokens }), + ...(read !== undefined ? [t('tokenCacheRead', { count: read })] : []), + ...(value.cacheCreationInputTokens !== undefined ? [t('tokenCacheWrite', { count: value.cacheCreationInputTokens })] : []), + ...(hit !== undefined ? [t('tokenCacheHit', { percent: hit.toFixed(0) })] : []), + ] + return + + {breakdown ? <>↓{formatTokenCount(value.inputTokens)}↑{formatTokenCount(value.outputTokens)} + : {formatTokenCount(value.totalTokens)} tok} + {hit !== undefined && hit > 0 && } + + {details.map(detail =>

{detail}

)}
+} diff --git a/web/frontend/src/i18n/locales/en/chat.ts b/web/frontend/src/i18n/locales/en/chat.ts index 18f047e7..4dccba56 100644 --- a/web/frontend/src/i18n/locales/en/chat.ts +++ b/web/frontend/src/i18n/locales/en/chat.ts @@ -1,4 +1,10 @@ export default { + tokenInput: '{{count}} input tokens', + tokenOutput: '{{count}} output tokens', + tokenTotal: '{{count}} total tokens', + tokenCacheRead: '{{count}} cache read tokens', + tokenCacheWrite: '{{count}} cache write tokens', + tokenCacheHit: '{{percent}}% cached', connectNodeHint: 'Enter a target to begin. Connect an execution node in the node panel first.', chat: 'Chat', terminal: 'Terminal', diff --git a/web/frontend/src/i18n/locales/zh/chat.ts b/web/frontend/src/i18n/locales/zh/chat.ts index b371d950..f2b06de3 100644 --- a/web/frontend/src/i18n/locales/zh/chat.ts +++ b/web/frontend/src/i18n/locales/zh/chat.ts @@ -1,4 +1,10 @@ export default { + tokenInput: '输入 {{count}} token', + tokenOutput: '输出 {{count}} token', + tokenTotal: '合计 {{count}} token', + tokenCacheRead: '缓存读取 {{count}} token', + tokenCacheWrite: '缓存写入 {{count}} token', + tokenCacheHit: '缓存 {{percent}}%', connectNodeHint: '输入目标即可开始;请先在节点面板连接执行节点。', chat: '对话', terminal: '终端', diff --git a/web/frontend/src/lib/workflow-view.ts b/web/frontend/src/lib/workflow-view.ts index edc9320e..edfc350c 100644 --- a/web/frontend/src/lib/workflow-view.ts +++ b/web/frontend/src/lib/workflow-view.ts @@ -193,7 +193,8 @@ export function withWorkflows(items: ViewerTimelineItem[], source: readonly AOPE && eventTime(event) >= item.timestamp && (!next || eventTime(event) < next.timestamp) && (event.payload.case === 'messageDelta' || event.payload.case === 'message' && event.payload.value.role === 'assistant')) if (item.thinking?.trim()) add({ ...base, id: scope(sid, tid, item.id, 'thinking'), kind: 'reasoning', label: 'workflow.reasoning', - state: item.streaming ? 'pending' : 'completed', item: { ...item, tools: [], response: undefined, steps: undefined } }) + state: item.streaming ? 'pending' : 'completed', item: { ...item, tools: [], steps: undefined, + response: item.response?.content.trim() || item.response?.metadata?.recap ? undefined : item.response } }) for (const tool of item.tools) addTool(tool, base) if (item.response?.content.trim() || item.response?.metadata?.recap) { const timestamp = textEvents.length ? eventTime(textEvents[textEvents.length - 1]) : base.timestamp