From 39700b95b74e7c060b58a88a036a5edcabea5e29 Mon Sep 17 00:00:00 2001 From: digua Date: Wed, 10 Jun 2026 21:26:00 +0800 Subject: [PATCH] feat(ai): display cache hit/miss token usage in chat status bar Extend TokenUsage types across the full data pipeline to include cacheReadTokens and cacheWriteTokens. The ChatStatusBar tooltip now shows cache hit information when available. This is a generic implementation that works with any API provider reporting cache metrics, not limited to DeepSeek. Co-authored-by: Cursor --- apps/cli/src/ai/chat-command.test.ts | 16 ++++++++-------- apps/desktop/main/ai/agent-stream-runner.ts | 2 +- apps/desktop/shared/types.ts | 2 ++ .../http-routes/src/routes/web/ai-chats.ts | 16 ++++++++++++++-- .../ai/agent/__tests__/event-handler.test.ts | 18 ++++++++++++++++-- packages/node-runtime/src/ai/agent/core.ts | 10 +++++++++- .../node-runtime/src/ai/agent/event-handler.ts | 12 +++++++++++- packages/node-runtime/src/ai/agent/types.ts | 2 ++ packages/node-runtime/src/ai/chats.ts | 12 +++++++++++- src/components/AIChat/chat/ChatStatusBar.vue | 8 +++++++- src/i18n/locales/en-US/ai.json | 1 + src/i18n/locales/ja-JP/ai.json | 1 + src/i18n/locales/zh-CN/ai.json | 1 + src/i18n/locales/zh-TW/ai.json | 1 + src/services/ai-stream/types.ts | 2 ++ src/services/ai/types.ts | 2 ++ src/stores/aiChat.ts | 18 +++++++++++++++--- 17 files changed, 104 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/ai/chat-command.test.ts b/apps/cli/src/ai/chat-command.test.ts index e7610025..965b8ad2 100644 --- a/apps/cli/src/ai/chat-command.test.ts +++ b/apps/cli/src/ai/chat-command.test.ts @@ -156,7 +156,7 @@ describe('runChatTurn', () => { onEvent({ type: 'done', isFinished: true, - usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }, }) }, } @@ -174,7 +174,7 @@ describe('runChatTurn', () => { aiChatId: 'ai_chat_1', role: 'assistant', content: 'hi', - tokenUsage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + tokenUsage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }, }, ]) }) @@ -222,7 +222,7 @@ describe('runChatTurn', () => { onEvent({ type: 'done', isFinished: true, - usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 }, + usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, }) }, } @@ -248,7 +248,7 @@ describe('runChatTurn', () => { role: 'assistant', content: '年度趋势如下。', contentBlocks: result.contentBlocks, - tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 }, + tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, }, ]) }) @@ -316,7 +316,7 @@ describe('runChatTurn', () => { onEvent({ type: 'done', isFinished: true, - usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 }, + usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, }) }, } @@ -347,7 +347,7 @@ describe('runChatTurn', () => { role: 'assistant', content: '年度趋势如下。', contentBlocks: result.contentBlocks, - tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 }, + tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, }, ]) }) @@ -411,7 +411,7 @@ describe('runChatTurn', () => { onEvent({ type: 'done', isFinished: true, - usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 }, + usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, }) }, } @@ -429,7 +429,7 @@ describe('runChatTurn', () => { role: 'assistant', content: '2026-Q1 是峰值。', contentBlocks: result.contentBlocks, - tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 }, + tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, }) }) diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 69d834ea..2c4202d1 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -125,7 +125,7 @@ export function createElectronRunAgentStream(): ( round: 0, toolsUsed: 0, contextTokens: 0, - totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, updatedAt: Date.now(), } satisfies AgentRuntimeStatus, }) diff --git a/apps/desktop/shared/types.ts b/apps/desktop/shared/types.ts index b48676c1..a4e38e70 100644 --- a/apps/desktop/shared/types.ts +++ b/apps/desktop/shared/types.ts @@ -28,6 +28,8 @@ export interface TokenUsage { promptTokens: number completionTokens: number totalTokens: number + cacheReadTokens: number + cacheWriteTokens: number } export interface AgentRuntimeStatus { diff --git a/packages/http-routes/src/routes/web/ai-chats.ts b/packages/http-routes/src/routes/web/ai-chats.ts index ed75bcb4..4c8ffcce 100644 --- a/packages/http-routes/src/routes/web/ai-chats.ts +++ b/packages/http-routes/src/routes/web/ai-chats.ts @@ -50,7 +50,13 @@ export function registerAiChatRoutes(server: FastifyInstance, ctx: HttpRouteCont dataKeywords?: string[] dataMessageCount?: number contentBlocks?: unknown[] - tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number } + tokenUsage?: { + promptTokens: number + completionTokens: number + totalTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + } } }>('/_web/ai/chats/:id/messages', async (request) => { const { role, content, dataKeywords, dataMessageCount, contentBlocks, tokenUsage } = request.body @@ -121,7 +127,13 @@ export function registerAiChatRoutes(server: FastifyInstance, ctx: HttpRouteCont role: 'user' | 'assistant' | 'summary' content: string contentBlocks?: unknown[] - tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number } + tokenUsage?: { + promptTokens: number + completionTokens: number + totalTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + } } }>('/_web/ai/chats/:id/messages/insert-after', async (request, reply) => { const { afterMessageId, role, content, contentBlocks, tokenUsage } = request.body diff --git a/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts b/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts index b27c4bba..05b81bc5 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts @@ -64,15 +64,29 @@ describe('AgentEventHandler', () => { }) handler.handleCoreEvent( - { type: 'usage_update', usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 } }, + { + type: 'usage_update', + usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150, cacheReadTokens: 30, cacheWriteTokens: 10 }, + }, [] ) const usage = handler.cloneUsage() assert.equal(usage.totalTokens, 150) + assert.equal(usage.cacheReadTokens, 30) + assert.equal(usage.cacheWriteTokens, 10) handler.handleCoreEvent( - { type: 'usage_update', usage: { promptTokens: 200, completionTokens: 100, totalTokens: 300 } }, + { + type: 'usage_update', + usage: { + promptTokens: 200, + completionTokens: 100, + totalTokens: 300, + cacheReadTokens: 60, + cacheWriteTokens: 20, + }, + }, [] ) assert.equal(usage.totalTokens, 150, 'clone should be independent') diff --git a/packages/node-runtime/src/ai/agent/core.ts b/packages/node-runtime/src/ai/agent/core.ts index b5610212..087815f6 100644 --- a/packages/node-runtime/src/ai/agent/core.ts +++ b/packages/node-runtime/src/ai/agent/core.ts @@ -45,7 +45,13 @@ export async function runAgentCore(options: AgentCoreOptions): Promise void private readonly context: EventHandlerContext @@ -205,6 +213,8 @@ export class AgentEventHandler { promptTokens: this.totalUsage.promptTokens, completionTokens: this.totalUsage.completionTokens, totalTokens: this.totalUsage.totalTokens, + cacheReadTokens: this.totalUsage.cacheReadTokens, + cacheWriteTokens: this.totalUsage.cacheWriteTokens, } } diff --git a/packages/node-runtime/src/ai/agent/types.ts b/packages/node-runtime/src/ai/agent/types.ts index 81963d7e..a4aeae35 100644 --- a/packages/node-runtime/src/ai/agent/types.ts +++ b/packages/node-runtime/src/ai/agent/types.ts @@ -14,6 +14,8 @@ export interface AgentTokenUsage { promptTokens: number completionTokens: number totalTokens: number + cacheReadTokens: number + cacheWriteTokens: number } export interface SimpleHistoryMessage { diff --git a/packages/node-runtime/src/ai/chats.ts b/packages/node-runtime/src/ai/chats.ts index ca8b0993..4520514e 100644 --- a/packages/node-runtime/src/ai/chats.ts +++ b/packages/node-runtime/src/ai/chats.ts @@ -59,6 +59,8 @@ export interface TokenUsageData { promptTokens: number completionTokens: number totalTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number } export interface AIMessage { @@ -831,13 +833,21 @@ export class AIChatManager { getAIChatTokenUsage(aiChatId: string): TokenUsageData { const rows = this.getActivePathRows(aiChatId) - const result: TokenUsageData = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + const result: TokenUsageData = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } for (const row of rows) { if (row.tokenUsage) { const usage = JSON.parse(row.tokenUsage) as TokenUsageData result.promptTokens += usage.promptTokens result.completionTokens += usage.completionTokens result.totalTokens += usage.totalTokens + result.cacheReadTokens! += usage.cacheReadTokens || 0 + result.cacheWriteTokens! += usage.cacheWriteTokens || 0 } } return result diff --git a/src/components/AIChat/chat/ChatStatusBar.vue b/src/components/AIChat/chat/ChatStatusBar.vue index 078b4e6e..cce0e5f8 100644 --- a/src/components/AIChat/chat/ChatStatusBar.vue +++ b/src/components/AIChat/chat/ChatStatusBar.vue @@ -18,7 +18,7 @@ const layoutStore = useLayoutStore() // Props const props = defineProps<{ - sessionTokenUsage: { totalTokens: number } + sessionTokenUsage: { totalTokens: number; cacheReadTokens: number; cacheWriteTokens: number } agentStatus?: AgentRuntimeStatus | null currentAIChatId?: string | null estimatedContextTokens?: number @@ -80,6 +80,9 @@ function formatCompactNumber(value: number): string { const totalTokenUsageText = computed(() => formatNumber(props.sessionTokenUsage.totalTokens)) const totalTokenUsageCompactText = computed(() => formatCompactNumber(props.sessionTokenUsage.totalTokens)) +const hasCacheData = computed(() => props.sessionTokenUsage.cacheReadTokens > 0) +const cacheReadText = computed(() => formatCompactNumber(props.sessionTokenUsage.cacheReadTokens)) + const contextTokens = computed(() => { if (props.agentStatus?.contextTokens) return props.agentStatus.contextTokens if (props.estimatedContextTokens && props.estimatedContextTokens > 0) return props.estimatedContextTokens @@ -338,6 +341,9 @@ const thinkingLevelLabel = computed(() => { {{ formatCompactNumber(modelContextWindow) }}
{{ t('ai.chat.statusBar.tokenUsageTitle') }}: {{ totalTokenUsageCompactText }}
+
+ {{ t('ai.chat.statusBar.cacheHit') }}: {{ cacheReadText }} +
diff --git a/src/i18n/locales/en-US/ai.json b/src/i18n/locales/en-US/ai.json index e08b7fdd..4614587e 100644 --- a/src/i18n/locales/en-US/ai.json +++ b/src/i18n/locales/en-US/ai.json @@ -215,6 +215,7 @@ } }, "tokenUsageTitle": "Total token usage", + "cacheHit": "Cache hit", "export": { "label": "Export", "title": "Export current conversation" diff --git a/src/i18n/locales/ja-JP/ai.json b/src/i18n/locales/ja-JP/ai.json index 6f6ae543..a794f33c 100644 --- a/src/i18n/locales/ja-JP/ai.json +++ b/src/i18n/locales/ja-JP/ai.json @@ -215,6 +215,7 @@ } }, "tokenUsageTitle": "累計 Token 使用量", + "cacheHit": "キャッシュヒット", "export": { "label": "エクスポート", "title": "現在のチャットをエクスポート" diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json index d203f23f..0ad13cfe 100644 --- a/src/i18n/locales/zh-CN/ai.json +++ b/src/i18n/locales/zh-CN/ai.json @@ -215,6 +215,7 @@ } }, "tokenUsageTitle": "累计 Token 使用量", + "cacheHit": "缓存命中", "export": { "label": "导出", "title": "导出当前对话" diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json index 2cf0a76f..df8eee9e 100644 --- a/src/i18n/locales/zh-TW/ai.json +++ b/src/i18n/locales/zh-TW/ai.json @@ -215,6 +215,7 @@ } }, "tokenUsageTitle": "累計 Token 用量", + "cacheHit": "快取命中", "export": { "label": "匯出", "title": "匯出當前對話" diff --git a/src/services/ai-stream/types.ts b/src/services/ai-stream/types.ts index ab2fa8c1..c2fe2fe5 100644 --- a/src/services/ai-stream/types.ts +++ b/src/services/ai-stream/types.ts @@ -66,6 +66,8 @@ export interface TokenUsage { promptTokens: number completionTokens: number totalTokens: number + cacheReadTokens: number + cacheWriteTokens: number } export interface AgentRuntimeStatus { diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts index 67810998..9b792a93 100644 --- a/src/services/ai/types.ts +++ b/src/services/ai/types.ts @@ -36,6 +36,8 @@ export interface TokenUsageData { promptTokens: number completionTokens: number totalTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number } export interface AIMessage { diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts index 568b86c3..b7d708f9 100644 --- a/src/stores/aiChat.ts +++ b/src/stores/aiChat.ts @@ -211,7 +211,17 @@ export function buildAIChatKey(params: { } function createEmptyTokenUsage(): TokenUsage { - return { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + return { promptTokens: 0, completionTokens: 0, totalTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } +} + +function toTokenUsage(data: { [K in keyof TokenUsage]?: number }): TokenUsage { + return { + promptTokens: data.promptTokens ?? 0, + completionTokens: data.completionTokens ?? 0, + totalTokens: data.totalTokens ?? 0, + cacheReadTokens: data.cacheReadTokens ?? 0, + cacheWriteTokens: data.cacheWriteTokens ?? 0, + } } function normalizeSerializedError(error: unknown): SerializedErrorInfo { @@ -523,7 +533,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { buffer.messages.splice(0, buffer.messages.length, ...history.map((msg) => toRuntimeMessage(msg))) buffer.sourceMessages.splice(0, buffer.sourceMessages.length) buffer.currentKeywords.splice(0, buffer.currentKeywords.length) - buffer.sessionTokenUsage = tokenUsage + buffer.sessionTokenUsage = toTokenUsage(tokenUsage) buffer.loaded = true } @@ -1083,6 +1093,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { promptTokens: state.sessionTokenUsage.promptTokens + chunk.usage.promptTokens, completionTokens: state.sessionTokenUsage.completionTokens + chunk.usage.completionTokens, totalTokens: state.sessionTokenUsage.totalTokens + chunk.usage.totalTokens, + cacheReadTokens: state.sessionTokenUsage.cacheReadTokens + chunk.usage.cacheReadTokens, + cacheWriteTokens: state.sessionTokenUsage.cacheWriteTokens + chunk.usage.cacheWriteTokens, } } setAgentPhase(state, 'completed', chunk.usage ? { totalUsage: chunk.usage } : undefined) @@ -1708,7 +1720,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { } } - targetBuffer.sessionTokenUsage = await useAIService().getAIChatTokenUsage(state.currentAIChatId!) + targetBuffer.sessionTokenUsage = toTokenUsage(await useAIService().getAIChatTokenUsage(state.currentAIChatId!)) state.sessionTokenUsage = { ...targetBuffer.sessionTokenUsage } return { success: true } } catch (error) {