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() &&
{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 ={result.error}
} + const body = <>{result?.error &&{result.error}
}{t(generation.state === 'started' ? 'generationStarted' : generation.error ? 'generationFailed' : 'generationFinished')}
+{t('generationAttempt', { count: generation.attempt })}{generation.errorStage && ` · ${t('errorStage', { stage: generation.errorStage })}`}
} - {generation.state === 'finished' && generation.kind !== 'reflex_validation' &&{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{detail}
)}