diff --git a/src/main/agent/image-generation-service.ts b/src/main/agent/image-generation-service.ts index bbbecb17..cde81885 100644 --- a/src/main/agent/image-generation-service.ts +++ b/src/main/agent/image-generation-service.ts @@ -115,7 +115,10 @@ export class ImageGenerationService { regenerate(context: ImageRequestContext, operationId: string, currentWorkMode?: () => 'ask' | 'execute'): Promise { const previous = this.getOperation(context.conversationId, operationId) - return this.bind(context, currentWorkMode).call({ ...previous.input, modelProfileId: previous.modelProfileId }, randomUUID()) + // Keep the originating request so usage stays attached to its task row; + // the fresh call ID keeps the regenerated operation distinct. + return this.bind({ ...context, requestId: previous.requestId }, currentWorkMode) + .call({ ...previous.input, modelProfileId: previous.modelProfileId }, randomUUID()) } cancelConversation(conversationId: string): void { diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 8ea1c164..861ea332 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -6900,16 +6900,19 @@ describe('AssistantDatabase', () => { cacheWrite: 12 }) + const usageTotals = { + callCount: 1, + input: 125, + output: 25, + cacheRead: 40, + cacheWrite: 12, + cacheInput: 177, + totalTokens: 150 + } expect(database.getTokenUsageSummary()).toEqual({ - totals: { - callCount: 1, - input: 125, - output: 25, - cacheRead: 40, - cacheWrite: 12, - cacheInput: 177, - totalTokens: 150 - }, + totals: usageTotals, + conversationTotals: usageTotals, + systemTotals: expect.objectContaining({ callCount: 0, totalTokens: 0 }), records: [ expect.objectContaining({ requestId: taskId, @@ -7525,18 +7528,54 @@ describe('AssistantDatabase', () => { expect(database.listTasks()).toEqual([]) expect(database.listArtifacts(project.id)).toEqual([]) expect(database.listMagicNotes()).toEqual([]) + const emptyTotals = { + callCount: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cacheInput: 0, + totalTokens: 0 + } expect(database.getTokenUsageSummary()).toEqual({ - totals: { - callCount: 0, - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - cacheInput: 0, - totalTokens: 0 - }, + totals: emptyTotals, + conversationTotals: emptyTotals, + systemTotals: emptyTotals, records: [] }) database.close() }) + + it('separates system task usage from conversation usage', async () => { + const database = await createDatabase() + const usage = { runtime: 'model', provider: 'openai', model: 'gpt', input: 10, output: 5, cacheRead: 0, cacheWrite: 0 } + const conversationTask = '00000000-0000-4000-8000-000000000401' + database.createTask({ id: conversationTask, conversationId: 'chat-1', title: 'Chat', instructions: 'Reply', workMode: 'ask' }) + database.upsertModelUsageCall({ ...usage, requestId: conversationTask, callId: 'chat' }) + const systemTasks = [ + ['00000000-0000-4000-8000-000000000402', 'heartbeat:a', 'heartbeat'], + ['00000000-0000-4000-8000-000000000403', 'supervision:b', 'supervision'], + ['00000000-0000-4000-8000-000000000404', undefined, 'magic-notes'] + ] as const + for (const [id, conversationId] of systemTasks) { + database.createTask({ id, conversationId, title: 'System', instructions: 'Run', workMode: 'ask', origin: 'assistant', visible: false }) + database.upsertModelUsageCall({ ...usage, requestId: id, callId: 'system' }) + } + database.recordSystemModelUsage({ ...usage, runtime: 'embedding', source: 'knowledge', bucket: 'embedding', output: 0 }) + database.recordSystemModelUsage({ ...usage, runtime: 'embedding', source: 'knowledge', bucket: 'embedding', output: 0 }) + + const summary = database.getTokenUsageSummary() + expect(summary.conversationTotals).toMatchObject({ callCount: 1, input: 10, output: 5 }) + expect(summary.systemTotals).toMatchObject({ callCount: 5, input: 50, output: 15 }) + expect(summary.totals).toMatchObject({ callCount: 6, input: 60, output: 20 }) + expect(summary.records.find(record => record.requestId === conversationTask)?.systemSource).toBeUndefined() + for (const [id, , source] of systemTasks) { + expect(summary.records.find(record => record.requestId === id)?.systemSource).toBe(source) + } + const knowledge = summary.records.filter(record => record.systemSource === 'knowledge') + expect(knowledge).toHaveLength(1) + expect(knowledge[0]).toMatchObject({ callCount: 2, input: 20, output: 0 }) + expect(database.listTasks().map(task => task.id)).toEqual([conversationTask]) + database.close() + }) }) diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 8e1a02f9..eae6f2c8 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -58,9 +58,13 @@ import type { ProjectChannel, ProjectCreateInput, ScheduleCreateInput, + SystemModelUsageInput, TokenUsageRecord, - TokenUsageSummary + TokenUsageSummary, + TokenUsageSystemSource, + TokenUsageTotals } from '../../shared/assistant-contracts' +import { tokenUsageSystemSources } from '../../shared/assistant-contracts' import type { StoredSupervisionResult, SupervisionCandidate } from './supervisor-service' import { supervisionEntitySchema, supervisionResultViewSchema, type SupervisionTarget, type SupervisionGraphRequest, type SupervisionRunRequest, type SupervisionActivity } from '../../shared/supervision-contracts' import type { AgentEvent } from '../../shared/contracts' @@ -466,6 +470,7 @@ type TokenUsageRecordRow = { project_name: string | null conversation_id: string | null conversation_title: string | null + system_source: string | null runtime: string provider: string model: string @@ -1824,6 +1829,7 @@ export class AssistantDatabase { private subagentProgress?: SubagentProgressStorage private channelEventWrites = 0 private channelOutboxWrites = 0 + private modelUsageNotificationQueued = false private readonly executionStatsCache = new Map< string, { value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number } @@ -1834,6 +1840,7 @@ export class AssistantDatabase { private readonly options: { onMagicNotesChanged?: () => void onMagicTodosChanged?: () => void + onModelUsageChanged?: () => void } = {} ) { this.noteStorage = new MagicNoteStorage(databasePath) @@ -5577,53 +5584,121 @@ export class AssistantDatabase { now, now ) + this.notifyModelUsageChanged() + } + + /** + * Records usage for background work that has no request of its own, such as + * knowledge graph extraction or remote embeddings. Calls are grouped into one + * hidden task per source, bucket and UTC day so the foreign key to tasks holds. + */ + recordSystemModelUsage(input: SystemModelUsageInput): void { + const day = new Date().toISOString().slice(0, 10) + const taskId = `${input.source}:${input.bucket}:${day}` + const now = new Date().toISOString() + this.requireDatabase() + .prepare( + `INSERT OR IGNORE INTO tasks + (id, project_id, conversation_id, title, instructions, origin, + status, priority, work_mode, created_at, started_at, completed_at, + visible) + VALUES (?, NULL, ?, ?, ?, 'assistant', 'completed', 0, 'ask', ?, ?, ?, 0)` + ) + .run( + taskId, + taskId, + input.bucket === 'embedding' ? '知识库向量化' : '知识图谱抽取', + '后台知识库模型用量汇总', + now, + now, + now + ) + this.upsertModelUsageCall({ + ...input, + requestId: taskId, + callId: `system-call:${randomUUID()}` + }) + } + + private notifyModelUsageChanged(): void { + if (this.modelUsageNotificationQueued || !this.options.onModelUsageChanged) { + return + } + this.modelUsageNotificationQueued = true + // Coalesce bursts of per-call upserts into one renderer refresh. + const timer = setTimeout(() => { + this.modelUsageNotificationQueued = false + this.options.onModelUsageChanged?.() + }, 500) + timer.unref?.() } getTokenUsageSummary(): TokenUsageSummary { - const rows = this.requireDatabase() - .prepare( - `SELECT - usage.request_id, - tasks.project_id, - projects.name AS project_name, - tasks.conversation_id, - conversations.title AS conversation_title, - usage.runtime, - usage.provider, - usage.model, - COUNT(*) AS call_count, - SUM(usage.input_tokens) AS input_tokens, - SUM(usage.output_tokens) AS output_tokens, - SUM(usage.cache_read_tokens) AS cache_read_tokens, - SUM(usage.cache_write_tokens) AS cache_write_tokens, - SUM( - usage.input_tokens + - CASE - WHEN LOWER(usage.provider) LIKE '%anthropic%' - THEN usage.cache_read_tokens + usage.cache_write_tokens - ELSE 0 - END - ) AS cache_input_tokens - FROM model_usage_calls usage - JOIN tasks ON tasks.id = usage.request_id - LEFT JOIN projects ON projects.id = tasks.project_id - LEFT JOIN conversations - ON conversations.id = tasks.conversation_id - GROUP BY - usage.request_id, - tasks.project_id, - projects.name, - tasks.conversation_id, - conversations.title, - usage.runtime, - usage.provider, - usage.model - ORDER BY MAX(usage.updated_at) DESC - LIMIT 500` - ) - .all() as TokenUsageRecordRow[] - const records: TokenUsageRecord[] = rows.map((row) => ({ + const systemSourceSql = `CASE + WHEN tasks.conversation_id LIKE 'heartbeat:%' THEN 'heartbeat' + WHEN tasks.conversation_id LIKE 'supervision:%' THEN 'supervision' + WHEN tasks.conversation_id LIKE 'knowledge:%' THEN 'knowledge' + WHEN tasks.conversation_id LIKE 'delegation:%' THEN 'delegation' + WHEN tasks.origin = 'assistant' AND tasks.conversation_id IS NULL + THEN 'magic-notes' + END` + const database = this.requireDatabase() + // Conversation and system rows are limited separately so frequent chat + // usage cannot hide background usage (or the reverse). + const selectRows = (system: boolean): TokenUsageRecordRow[] => + database + .prepare( + `SELECT + usage.request_id, + tasks.project_id, + projects.name AS project_name, + tasks.conversation_id, + conversations.title AS conversation_title, + ${systemSourceSql} AS system_source, + usage.runtime, + usage.provider, + usage.model, + COUNT(*) AS call_count, + SUM(usage.input_tokens) AS input_tokens, + SUM(usage.output_tokens) AS output_tokens, + SUM(usage.cache_read_tokens) AS cache_read_tokens, + SUM(usage.cache_write_tokens) AS cache_write_tokens, + SUM( + usage.input_tokens + + CASE + WHEN LOWER(usage.provider) LIKE '%anthropic%' + THEN usage.cache_read_tokens + usage.cache_write_tokens + ELSE 0 + END + ) AS cache_input_tokens + FROM model_usage_calls usage + JOIN tasks ON tasks.id = usage.request_id + LEFT JOIN projects ON projects.id = tasks.project_id + LEFT JOIN conversations + ON conversations.id = tasks.conversation_id + WHERE (${systemSourceSql}) IS ${system ? 'NOT ' : ''}NULL + GROUP BY + usage.request_id, + tasks.project_id, + projects.name, + tasks.conversation_id, + conversations.title, + usage.runtime, + usage.provider, + usage.model + ORDER BY MAX(usage.updated_at) DESC + LIMIT 500` + ) + .all() as TokenUsageRecordRow[] + const knownSources = new Set(tokenUsageSystemSources) + const records: TokenUsageRecord[] = [ + ...selectRows(false), + ...selectRows(true) + ].map((row) => ({ requestId: row.request_id, + ...(row.system_source && knownSources.has(row.system_source) + ? { systemSource: row.system_source as TokenUsageSystemSource } + : {}), projectId: row.project_id ?? undefined, projectName: row.project_name ?? undefined, conversationId: row.conversation_id ?? undefined, @@ -5639,28 +5714,8 @@ export class AssistantDatabase { cacheInput: row.cache_input_tokens, totalTokens: row.input_tokens + row.output_tokens })) - const totalRow = this.requireDatabase() - .prepare( - `SELECT - COUNT(*) AS call_count, - COALESCE(SUM(input_tokens), 0) AS input_tokens, - COALESCE(SUM(output_tokens), 0) AS output_tokens, - COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, - COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, - COALESCE( - SUM( - input_tokens + - CASE - WHEN LOWER(provider) LIKE '%anthropic%' - THEN cache_read_tokens + cache_write_tokens - ELSE 0 - END - ), - 0 - ) AS cache_input_tokens - FROM model_usage_calls` - ) - .get() as { + type TotalRow = { + is_system: number | null call_count: number input_tokens: number output_tokens: number @@ -5668,16 +5723,51 @@ export class AssistantDatabase { cache_write_tokens: number cache_input_tokens: number } - const totals = { - callCount: totalRow.call_count, - input: totalRow.input_tokens, - output: totalRow.output_tokens, - cacheRead: totalRow.cache_read_tokens, - cacheWrite: totalRow.cache_write_tokens, - cacheInput: totalRow.cache_input_tokens, - totalTokens: totalRow.input_tokens + totalRow.output_tokens + // Usage rows always reference a task (foreign key), so the join is lossless. + const totalRows = database + .prepare( + `SELECT + (${systemSourceSql}) IS NOT NULL AS is_system, + COUNT(*) AS call_count, + COALESCE(SUM(usage.input_tokens), 0) AS input_tokens, + COALESCE(SUM(usage.output_tokens), 0) AS output_tokens, + COALESCE(SUM(usage.cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(usage.cache_write_tokens), 0) AS cache_write_tokens, + COALESCE( + SUM( + usage.input_tokens + + CASE + WHEN LOWER(usage.provider) LIKE '%anthropic%' + THEN usage.cache_read_tokens + usage.cache_write_tokens + ELSE 0 + END + ), + 0 + ) AS cache_input_tokens + FROM model_usage_calls usage + LEFT JOIN tasks ON tasks.id = usage.request_id + GROUP BY is_system` + ) + .all() as TotalRow[] + const toTotals = (rows: TotalRow[]): TokenUsageTotals => { + const sum = (key: Exclude): number => + rows.reduce((total, row) => total + row[key], 0) + return { + callCount: sum('call_count'), + input: sum('input_tokens'), + output: sum('output_tokens'), + cacheRead: sum('cache_read_tokens'), + cacheWrite: sum('cache_write_tokens'), + cacheInput: sum('cache_input_tokens'), + totalTokens: sum('input_tokens') + sum('output_tokens') + } + } + return { + totals: toTotals(totalRows), + conversationTotals: toTotals(totalRows.filter((row) => !row.is_system)), + systemTotals: toTotals(totalRows.filter((row) => row.is_system)), + records } - return { totals, records } } updateTaskStatus( diff --git a/src/main/assistant/supervision-production.test.ts b/src/main/assistant/supervision-production.test.ts index d1af6f75..7f2b53f5 100644 --- a/src/main/assistant/supervision-production.test.ts +++ b/src/main/assistant/supervision-production.test.ts @@ -131,6 +131,24 @@ function fixture(content = 'Atlas and Beacon are separate projects.') { return { db, request, respond, service } } +it('records supervisor model usage under a hidden supervision task', async () => { + const f = fixture() + const run: AgentRuntime['run'] = async function* (input) { + yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(f.respond(input.prompt)) } + yield { type: 'model-usage', requestId: input.requestId, callId: 'call-1', runtime: 'model', provider: 'openai', + model: 'gpt-review', inputTokens: 40, outputTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 } + yield { type: 'done', requestId: input.requestId } + } + const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelConcurrency: 1, + supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, responseKiB: 1024 } }), + async () => ({ runtimeId: 'model', capability: 'chat', run } as AgentRuntime), new SupervisionModelPool()) + await service.run(f.request) + const summary = f.db.getTokenUsageSummary() + expect(summary.records).toEqual([expect.objectContaining({ systemSource: 'supervision', model: 'gpt-review', input: 40, output: 8 })]) + expect(summary.systemTotals).toMatchObject({ callCount: 1, input: 40 }) + expect(f.db.listTasks()).toEqual([]) +}) + it('production factory normalizes two absent identities, then reuses exact candidate UUIDs without extra calls', async () => { const f = fixture() const first = await f.service().run(f.request) diff --git a/src/main/assistant/supervision-production.ts b/src/main/assistant/supervision-production.ts index 1d1d3e87..4430dc47 100644 --- a/src/main/assistant/supervision-production.ts +++ b/src/main/assistant/supervision-production.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import type { ApplicationSettings } from '../../shared/application-settings-contracts' import { defaultSupervisionTimeoutSeconds, defaultSupervisorModelConcurrency } from '../../shared/application-settings-contracts' import { supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts' -import type { AgentRuntime } from '../agent/runtime' +import type { AgentRuntime, RuntimeModelUsageEvent } from '../agent/runtime' import type { AssistantDatabase } from './assistant-database' import type { SupervisionModelPool } from './supervision-model-pool' import { SupervisorService } from './supervisor-service' @@ -11,7 +11,11 @@ export function createProductionSupervisorService( database: AssistantDatabase, getSettings: () => Promise | undefined>, resolveRuntime: () => Promise, - pool: SupervisionModelPool + pool: SupervisionModelPool, + persistUsage: (event: RuntimeModelUsageEvent) => void = event => database.upsertModelUsageCall({ + requestId: event.requestId, callId: event.callId, runtime: event.runtime, provider: event.provider, model: event.model, + input: event.inputTokens, output: event.outputTokens, cacheRead: event.cacheReadTokens, cacheWrite: event.cacheWriteTokens + }) ): SupervisorService { return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, { summarize: async request => { @@ -30,9 +34,13 @@ export function createProductionSupervisorService( }, timeoutSeconds * 1000) const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024 let output = '', completed = false - const conversationId = `supervision:${randomUUID()}` + const requestId = randomUUID() + const conversationId = `supervision:${requestId}` + // Hidden task row so model usage can be attributed to supervision. + database.createTask({ id: requestId, conversationId, title: '监督者回顾', instructions: '根据有界证据整理监督者回顾', + workMode: 'ask', origin: 'assistant', visible: false }) try { - for await (const event of runtime.run({ requestId: randomUUID(), conversationId, workMode: 'ask', + for await (const event of runtime.run({ requestId, conversationId, workMode: 'ask', prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract, 'REVIEW TIME RANGE:', JSON.stringify(request.request.timeRange), 'KNOWN ENTITIES:', JSON.stringify(request.candidates.map((candidate, index) => ({ candidateRef: `known_${index + 1}`, label: candidate.label, description: candidate.description @@ -48,14 +56,23 @@ export function createProductionSupervisorService( modelSignal.throwIfAborted() } } + if (event.type === 'model-usage') persistUsage(event) if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用') if (event.type === 'error') throw new Error(event.message) if (event.type === 'done') completed = true } modelSignal.throwIfAborted() if (!completed) throw new Error('监督者模型未报告完成') + database.updateTaskStatus(requestId, 'completed') return output - } catch (error) { throw timeoutError ?? error } + } catch (error) { + const failure = timeoutError ?? error + try { + database.updateTaskStatus(requestId, modelSignal.aborted ? 'cancelled' : 'failed', + failure instanceof Error ? failure.message.slice(0, 2_000) : '监督者模型调用失败') + } catch { /* status bookkeeping must not mask the model failure */ } + throw failure + } finally { clearTimeout(timeout); await runtime.releaseConversation?.(conversationId) } }, request.signal) } diff --git a/src/main/index.ts b/src/main/index.ts index 7acdc226..8b1e6e74 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -86,6 +86,7 @@ import { import { FeedbackService } from './feedback/feedback-service' import { registerFeedbackIpcHandler } from './feedback/feedback-ipc' import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts' +import type { SystemModelUsageInput } from '../shared/assistant-contracts' import { runCleanupBeforeDeadline, settleCleanupPhases @@ -237,6 +238,28 @@ let managedRemoteExecutionServices: let directModelSubagentScheduler: SubagentScheduler | undefined let localToolEnvironmentService: LocalToolEnvironmentService | undefined +function recordKnowledgeUsage( + bucket: SystemModelUsageInput['bucket'], + usage: Omit +): void { + try { + assistantDatabase?.recordSystemModelUsage({ + ...usage, + source: 'knowledge', + bucket + }) + } catch (error) { + void desktopDiagnostics + .recordFailure({ + component: 'desktop', + stage: 'knowledge', + code: 'knowledge.usage.persist-failed', + error + }) + .catch(() => undefined) + } +} + type ManagedEmbeddingProvider = EmbeddingProvider & { dispose?: () => void | Promise } @@ -259,7 +282,17 @@ async function createEmbeddingProvider( return new OpenAIEmbeddingClient({ endpoint: connection.baseUrl, model: connection.modelName, - apiKey: connection.apiKey + apiKey: connection.apiKey, + onUsage: ({ model, inputTokens }) => + recordKnowledgeUsage('embedding', { + runtime: 'embedding', + provider: 'openai-compatible', + model, + input: inputTokens, + output: 0, + cacheRead: 0, + cacheWrite: 0 + }) }) } const snapshot = await manager.getSnapshot() @@ -899,7 +932,16 @@ if (hasSingleInstanceLock) { credentialCipher: secureCipher, databasePath: join(app.getPath('userData'), 'knowledge.sqlite'), managedRoot: join(app.getPath('userData'), 'knowledge'), - extractStructured: createModelGraphExtractor(settingsStore), + extractStructured: createModelGraphExtractor( + settingsStore, + fetch, + undefined, + (usage) => + recordKnowledgeUsage('graph-extraction', { + runtime: 'model', + ...usage + }) + ), parseDocument: documentParsingService.parse }) knowledgeService = startupKnowledgeService @@ -935,6 +977,15 @@ if (hasSingleInstanceLock) { ) } }) + }, + onModelUsageChanged: () => { + if ( + mainWindow && + !mainWindow.isDestroyed() && + !mainWindow.webContents.isDestroyed() + ) { + mainWindow.webContents.send(ipcChannels.tokenUsageChanged) + } } } ) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 2359b05f..7534495b 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -5796,6 +5796,9 @@ describe('registerIpcHandlers agent terminal state', () => { const regenerated = await regenerate(event, { conversationId, operationId: original.id }) as typeof original expect(regenerated.id).not.toBe(original.id) expect(regenerated.messageId).toBe(messageId) + // Regeneration keeps the originating task so its usage satisfies the task foreign key. + expect(regenerated.requestId).toBe(original.requestId) + expect(regenerated.callId).not.toBe(original.callId) expect(fetcher).toHaveBeenCalledTimes(2) database.saveLocalConversations([{ header: { ...header, workMode: 'ask' }, messages: [] }]) await expect(regenerate(event, { conversationId, operationId: original.id })).rejects.toThrow('Execute') @@ -11358,6 +11361,47 @@ describe('registerIpcHandlers agent terminal state', () => { } ) + it('sends the work-mode instruction only once to runtimes that consume trusted instructions', async () => { + let receivedRequest: + | { requestId: string; prompt: string; trustedInstructions?: string } + | undefined + const runtime = { + runtimeId: 'opencode', + capability: 'chat', + requiresToolApproval: false, + supportsToolExecution: true, + consumesTrustedInstructions: true, + getStatus: vi.fn(), + dispose: vi.fn(), + async *run(request: { + requestId: string + prompt: string + trustedInstructions?: string + }) { + receivedRequest = request + yield { requestId: request.requestId, type: 'done' } + } + } + const harness = createHarness(runtime) + const requestId = '5c3e1f7a-2b8d-4e6f-9a1c-7d2b4e6f8a10' + + harness.handler?.(trustedEvent(harness.webContents), { + requestId, + conversationId: 'conversation-1', + prompt: 'fix the bug', + workMode: 'execute' + }) + + await vi.waitFor(() => + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith(requestId, 'completed') + ) + expect(receivedRequest?.prompt).toBe('fix the bug') + expect(receivedRequest?.trustedInstructions).toContain('Work mode: Execute.') + await harness.dispose() + }) + it('routes eligible Ask requests through the persisted smart expert service and publishes child events', async () => { const runtime = { capability: 'chat', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d2f7195e..1076d4ae 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2396,7 +2396,9 @@ export function registerIpcHandlers( conversationId: runtimeConversationId, projectId: schedule.projectId, workMode: schedule.workMode, - prompt: `${trustedInstructions}\n\n${schedule.prompt}`, + prompt: requestRuntime.consumesTrustedInstructions === true + ? schedule.prompt + : `${trustedInstructions}\n\n${schedule.prompt}`, knowledgeLibraryIds: [], ...(remoteContext?.contextIds?.length ? { contextIds: remoteContext.contextIds } @@ -4317,7 +4319,7 @@ export function registerIpcHandlers( markManagedSshAccepted() } return selectedRuntime.run( - modeInstruction + modeInstruction && selectedRuntime.consumesTrustedInstructions !== true ? { ...executionRequest, prompt: `${modeInstruction}\n\n${executionRequest.prompt}` @@ -6413,7 +6415,8 @@ export function registerIpcHandlers( } ) const supervisorService = createProductionSupervisorService(assistantDatabase, - async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool) + async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool, + persistModelUsage) registerHandler( ipcChannels.conversationsBranchLocal, @@ -6478,7 +6481,7 @@ export function registerIpcHandlers( const currentWorkMode = (): 'ask' | 'execute' => normalizeInteractiveWorkMode(assistantDatabase.getConversation(target.conversationId).workMode) return imageGenerationService.regenerate({ conversationId: target.conversationId, messageId: previous.messageId, - requestId: randomUUID(), workMode: currentWorkMode() + requestId: previous.requestId, workMode: currentWorkMode() }, target.operationId, currentWorkMode) }) diff --git a/src/main/knowledge/model-extractor.test.ts b/src/main/knowledge/model-extractor.test.ts index 9cecd220..6c4dc9b2 100644 --- a/src/main/knowledge/model-extractor.test.ts +++ b/src/main/knowledge/model-extractor.test.ts @@ -96,6 +96,25 @@ describe('createModelGraphExtractor', () => { } ) + it.each([ + ['anthropic-messages', { content: [{ type: 'text', text: '{}' }], model: 'claude-x', + usage: { input_tokens: 11, output_tokens: 3, cache_read_input_tokens: 2, cache_creation_input_tokens: 1 } }, + { provider: 'anthropic', model: 'claude-x', input: 11, output: 3, cacheRead: 2, cacheWrite: 1 }], + ['openai-chat-completions', { choices: [{ message: { content: '{}' } }], + usage: { prompt_tokens: 9, completion_tokens: 4, prompt_tokens_details: { cached_tokens: 5 } } }, + { provider: 'openai', model: 'intranet-model', input: 9, output: 4, cacheRead: 5, cacheWrite: 0 }] + ] as const)('reports %s token usage', async (protocol, payload, expected) => { + const onUsage = vi.fn() + const extract = createModelGraphExtractor( + store({ modelProtocol: protocol }), + vi.fn(async () => jsonResponse(payload)), + undefined, + onUsage + ) + await expect(extract('extract this')).resolves.toEqual({}) + expect(onUsage).toHaveBeenCalledWith(expected) + }) + it('uses an unauthenticated Anthropic endpoint with its path and query', async () => { const fetcher = vi.fn(async () => jsonResponse({ diff --git a/src/main/knowledge/model-extractor.ts b/src/main/knowledge/model-extractor.ts index a448295f..73c8ab53 100644 --- a/src/main/knowledge/model-extractor.ts +++ b/src/main/knowledge/model-extractor.ts @@ -158,10 +158,64 @@ function completionDetail(payload: unknown): string | undefined { : undefined } +export type GraphExtractionUsage = { + provider: 'anthropic' | 'openai' + model: string + input: number + output: number + cacheRead: number + cacheWrite: number +} + +function tokenCount(value: unknown): number { + return Number.isSafeInteger(value) && (value as number) >= 0 + ? value as number + : 0 +} + +/** Reads provider-reported token usage from a non-streaming response. */ +export function readGraphExtractionUsage( + payload: unknown, + protocol: string, + fallbackModel: string +): GraphExtractionUsage | undefined { + const response = record(payload) + const usage = record(response?.usage) + if (!usage) { + return undefined + } + const model = + typeof response?.model === 'string' && response.model.length > 0 + ? response.model.slice(0, 500) + : fallbackModel.slice(0, 500) + if (protocol === 'anthropic-messages') { + return { + provider: 'anthropic', + model, + input: tokenCount(usage.input_tokens), + output: tokenCount(usage.output_tokens), + cacheRead: tokenCount(usage.cache_read_input_tokens), + cacheWrite: tokenCount(usage.cache_creation_input_tokens) + } + } + const details = record( + usage.prompt_tokens_details ?? usage.input_tokens_details + ) + return { + provider: 'openai', + model, + input: tokenCount(usage.prompt_tokens ?? usage.input_tokens), + output: tokenCount(usage.completion_tokens ?? usage.output_tokens), + cacheRead: tokenCount(details?.cached_tokens), + cacheWrite: 0 + } +} + export function createModelGraphExtractor( settingsStore: RuntimeSettingsStore, fetcher: typeof fetch = fetch, - requestTimeoutMilliseconds = defaultGraphRequestTimeoutMilliseconds + requestTimeoutMilliseconds = defaultGraphRequestTimeoutMilliseconds, + onUsage?: (usage: GraphExtractionUsage) => void ): ExtractStructured { return async (prompt, signal) => { const settings = await settingsStore.getResolvedSettings() @@ -289,6 +343,18 @@ export function createModelGraphExtractor( { cause: error } ) } + const usage = readGraphExtractionUsage( + payload, + protocol, + settings.modelName + ) + if (usage && onUsage) { + try { + onUsage(usage) + } catch { + // Usage accounting must never fail a successful extraction. + } + } const text = protocol === 'anthropic-messages' ? anthropicText(payload) diff --git a/src/main/knowledge/openai-embedding-client.test.ts b/src/main/knowledge/openai-embedding-client.test.ts index 13b8b70b..f400f373 100644 --- a/src/main/knowledge/openai-embedding-client.test.ts +++ b/src/main/knowledge/openai-embedding-client.test.ts @@ -42,6 +42,16 @@ describe('OpenAIEmbeddingClient', () => { }) }) + it('reports provider token usage per batch without failing on observer errors', async () => { + const transport = vi.fn(async () => + new Response(JSON.stringify({ data: [{ index: 0, embedding: [1, 2] }], usage: { prompt_tokens: 7, total_tokens: 7 } })) + ) + const onUsage = vi.fn(() => { throw new Error('observer failed') }) + const client = new OpenAIEmbeddingClient({ endpoint: 'http://127.0.0.1/v1/embeddings', model: 'embed', fetch: transport, onUsage }) + await expect(client.embed(['alpha'])).resolves.toEqual([[1, 2]]) + expect(onUsage).toHaveBeenCalledWith({ model: 'embed', inputTokens: 7 }) + }) + it('accepts unauthenticated endpoints and restores response index order', async () => { const transport = vi.fn(async () => new Response( diff --git a/src/main/knowledge/openai-embedding-client.ts b/src/main/knowledge/openai-embedding-client.ts index c7f40e95..b00c2a8f 100644 --- a/src/main/knowledge/openai-embedding-client.ts +++ b/src/main/knowledge/openai-embedding-client.ts @@ -22,6 +22,8 @@ export interface OpenAIEmbeddingClientOptions { batchSize?: number timeoutMs?: number fetch?: typeof fetch + /** Receives provider-reported input tokens for each successful batch. */ + onUsage?: (usage: { model: string; inputTokens: number }) => void } export type EmbeddingInputRole = 'query' | 'document' @@ -268,8 +270,10 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider { private readonly batchSize: number private readonly timeoutMs: number private readonly transport: typeof fetch + private readonly onUsage?: OpenAIEmbeddingClientOptions['onUsage'] constructor(options: OpenAIEmbeddingClientOptions) { + this.onUsage = options.onUsage this.endpoint = normalizedEndpoint(options.endpoint) this.model = requiredString(options.model, 'model', MAX_MODEL_LENGTH) this.apiKey = options.apiKey?.trim() || undefined @@ -395,6 +399,26 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider { return embeddings } + private reportUsage(payload: unknown): void { + if (!this.onUsage || typeof payload !== 'object' || payload === null) { + return + } + const usage = (payload as { usage?: unknown }).usage + if (typeof usage !== 'object' || usage === null) { + return + } + const values = usage as Record + const tokens = values.prompt_tokens ?? values.total_tokens + if (!Number.isSafeInteger(tokens) || (tokens as number) <= 0) { + return + } + try { + this.onUsage({ model: this.model, inputTokens: tokens as number }) + } catch { + // Usage accounting must never fail an embedding request. + } + } + private async embedBatch( input: readonly string[], signal?: AbortSignal @@ -439,10 +463,9 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider { `Embedding request failed with HTTP ${response.status}` ) } - const vectors = validateEmbeddings( - await readBoundedJson(response), - input.length - ) + const payload = await readBoundedJson(response) + const vectors = validateEmbeddings(payload, input.length) + this.reportUsage(payload) if ( this.dimensions !== undefined && vectors.some((vector) => vector.length !== this.dimensions) diff --git a/src/preload/index.ts b/src/preload/index.ts index 877bb87c..979f9b10 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1283,7 +1283,13 @@ const desktopApi: DesktopApi = { getTokenSummary: () => ipcRenderer.invoke( ipcChannels.tokenUsageSummary - ) as Promise + ) as Promise, + onChanged: (listener) => { + const handler = (): void => listener() + ipcRenderer.on(ipcChannels.tokenUsageChanged, handler) + return () => + ipcRenderer.removeListener(ipcChannels.tokenUsageChanged, handler) + } }, artifacts: { list: (projectId?: string) => diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index 9a4d2c18..6bfee2ea 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -932,4 +932,53 @@ describe('ActivityPanel', () => { ).toBeInTheDocument() expect(screen.queryByText('goodbuddy')).not.toBeInTheDocument() }) + + it('moves system task usage into its own tab with separate totals', () => { + const usage = makeTokenUsage() + const systemRecord = (requestId: string, systemSource: 'heartbeat' | 'supervision', input: number) => ({ + requestId, systemSource, conversationId: `${systemSource}:x`, runtime: 'model', provider: 'openai', model: 'gpt-sys', + callCount: 1, input, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: input + 1 + }) + usage.records.push(systemRecord('sys-1', 'heartbeat', 7), systemRecord('sys-2', 'supervision', 9)) + usage.conversationTotals = usage.totals + usage.systemTotals = { callCount: 2, input: 16, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 18 } + + render() + fireEvent.click(screen.getByRole('tab', { name: '用量统计' })) + fireEvent.click(screen.getByRole('button', { name: '按会话' })) + expect(screen.queryByText('heartbeat:x')).not.toBeInTheDocument() + expect(within(screen.getByLabelText('Token 用量统计')).getByText('150')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('tab', { name: '系统任务' })) + const stats = screen.getByLabelText('系统任务用量统计') + expect(within(stats).getByText('18')).toBeInTheDocument() + expect(screen.getByRole('row', { name: '自动监督 7 1 0 0 0% 8' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '监督者' })) + expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 9 1 0 0 0% 10' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '按模型' })) + expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 16 2 0 0 0% 18' })).toBeInTheDocument() + }) + + it('shows an empty system tab and refreshes usage on demand', async () => { + let resolveRefresh!: () => void + const onRefreshTokenUsage = vi.fn(() => new Promise((resolve) => { resolveRefresh = resolve })) + render() + fireEvent.click(screen.getByRole('tab', { name: '系统任务' })) + expect(screen.getByText('暂无系统任务 Token 用量')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '刷新' })) + expect(onRefreshTokenUsage).toHaveBeenCalledOnce() + const busy = screen.getByRole('button', { name: '正在刷新' }) + expect(busy).toBeDisabled() + fireEvent.click(busy) + expect(onRefreshTokenUsage).toHaveBeenCalledOnce() + resolveRefresh() + expect(await screen.findByRole('button', { name: '刷新' })).toBeEnabled() + }) + + it('hides the refresh button when no refresh handler is provided', () => { + render() + fireEvent.click(screen.getByRole('tab', { name: '用量统计' })) + expect(screen.queryByRole('button', { name: '刷新' })).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index eaa5bdbf..2e561c6b 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -1,4 +1,4 @@ -import { Activity, ChevronRight, Trash2 } from 'lucide-react' +import { Activity, ChevronRight, RefreshCw, Trash2 } from 'lucide-react' import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { InlineHelp } from './InlineHelp' @@ -11,7 +11,9 @@ import { type ActivityRecord } from './activity-store' import { getTokenUsageTotals, groupTokenUsage, - type TokenUsageGroup + splitTokenUsage, + type TokenUsageGroup, + type TokenUsageGroupRow } from './token-usage' import { DestructiveConfirmActions, @@ -25,7 +27,9 @@ import { import { getProjectDisplayText } from './project-display' type ActivityFilter = 'all' | 'active' | 'failed' -type ActivityView = 'tasks' | 'timeline' | 'usage' +type ActivityView = 'tasks' | 'timeline' | 'usage' | 'system' +type SystemTokenUsageGroup = Extract +type TokenUsageView = Extract type ActivityActorKind = | 'user' | 'assistant' @@ -41,6 +45,8 @@ export type ActivityPanelProps = { tokenUsage: TokenUsageSummary onClear: () => void onOpenConversation: (conversationId: string) => void + /** Re-reads token usage from storage; the button is hidden when omitted. */ + onRefreshTokenUsage?: () => Promise } type ConversationActivityGroup = { @@ -244,7 +250,8 @@ export const ActivityPanel = memo(function ActivityPanel({ records, tokenUsage, onClear, - onOpenConversation + onOpenConversation, + onRefreshTokenUsage }: ActivityPanelProps): React.JSX.Element { const { t, i18n } = useTranslation('activity') const { t: tWorkspace } = useTranslation('workspace') @@ -252,7 +259,10 @@ export const ActivityPanel = memo(function ActivityPanel({ useState('tasks') const [filter, setFilter] = useState('all') const [tokenGroup, setTokenGroup] = - useState('project') + useState>('project') + const [systemTokenGroup, setSystemTokenGroup] = + useState('source') + const [refreshingTokenUsage, setRefreshingTokenUsage] = useState(false) const [expandedTokenRows, setExpandedTokenRows] = useState>( () => new Set() ) @@ -311,10 +321,27 @@ export const ActivityPanel = memo(function ActivityPanel({ }> = [ { id: 'tasks', label: t('tabs.tasks') }, { id: 'timeline', label: t('tabs.timeline') }, - { id: 'usage', label: t('tabs.usage') } + { id: 'usage', label: t('tabs.usage') }, + { id: 'system', label: t('tabs.system') } + ] + const systemTokenGroups: ReadonlyArray<{ + value: SystemTokenUsageGroup + label: string + columnLabel: string + }> = [ + { + value: 'source', + label: t('systemUsage.groups.source'), + columnLabel: t('systemUsage.columns.source') + }, + { + value: 'model', + label: t('tokenUsage.groups.model'), + columnLabel: t('tokenUsage.columns.model') + } ] const tokenGroups: ReadonlyArray<{ - value: TokenUsageGroup + value: Exclude label: string columnLabel: string }> = [ @@ -452,17 +479,26 @@ export const ActivityPanel = memo(function ActivityPanel({ }) } ] + const splitUsage = useMemo( + () => splitTokenUsage(displayTokenUsage), + [displayTokenUsage] + ) const tokenTotals = useMemo( - () => getTokenUsageTotals(tokenUsage), - [tokenUsage] + () => getTokenUsageTotals(splitUsage.conversation), + [splitUsage] ) const tokenRows = useMemo( - () => groupTokenUsage(displayTokenUsage, tokenGroup), - [displayTokenUsage, tokenGroup] + () => groupTokenUsage(splitUsage.conversation, tokenGroup), + [splitUsage, tokenGroup] + ) + const systemTokenTotals = useMemo( + () => getTokenUsageTotals(splitUsage.system), + [splitUsage] + ) + const systemTokenRows = useMemo( + () => groupTokenUsage(splitUsage.system, systemTokenGroup), + [splitUsage, systemTokenGroup] ) - const tokenGroupLabel = - tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ?? - t('tokenUsage.columns.project') const emptyDescription = filter === 'active' ? t('empty.active') @@ -530,8 +566,25 @@ export const ActivityPanel = memo(function ActivityPanel({ { kind: 'tool', label: t('timeline.actors.tool') }, { kind: 'approval', label: t('timeline.actors.approval') } ] + const systemSourceLabel = (key: string): string => { + switch (key) { + case 'source:heartbeat': + return t('systemUsage.sources.heartbeat') + case 'source:supervision': + return t('systemUsage.sources.supervision') + case 'source:magic-notes': + return t('systemUsage.sources.magicNotes') + case 'source:knowledge': + return t('systemUsage.sources.knowledge') + case 'source:delegation': + return t('systemUsage.sources.delegation') + default: + return t('systemUsage.sources.unknown') + } + } const localizeTokenRow = ( - row: (typeof tokenRows)[number] + row: TokenUsageGroupRow, + group: TokenUsageGroup ): { label: string } => { const modelLabel = row.model || t('tokenUsage.fallbacks.unknownModel') @@ -546,17 +599,30 @@ export const ActivityPanel = memo(function ActivityPanel({ ? t('tokenUsage.runtimes.deepseekHarness') : row.runtime || t('tokenUsage.fallbacks.unknownRuntime') const label = - tokenGroup === 'project' && - row.key === 'project:unassigned' - ? t('tokenUsage.fallbacks.unassignedProject') - : tokenGroup === 'conversation' && - row.key === 'conversation:deleted' - ? t('tokenUsage.fallbacks.deletedConversation') - : !row.children - ? `${runtimeLabel} · ${modelLabel}` - : row.label + group === 'source' && row.children + ? systemSourceLabel(row.key) + : group === 'project' && + row.key === 'project:unassigned' + ? t('tokenUsage.fallbacks.unassignedProject') + : group === 'conversation' && + row.key === 'conversation:deleted' + ? t('tokenUsage.fallbacks.deletedConversation') + : !row.children + ? `${runtimeLabel} · ${modelLabel}` + : row.label return { label } } + const refreshTokenUsage = async (): Promise => { + if (!onRefreshTokenUsage || refreshingTokenUsage) { + return + } + setRefreshingTokenUsage(true) + try { + await onRefreshTokenUsage() + } finally { + setRefreshingTokenUsage(false) + } + } const renderRecordCard = ( record: ActivityRecord, showContext: boolean @@ -984,135 +1050,195 @@ export const ActivityPanel = memo(function ActivityPanel({ )} - {activeView === 'usage' && ( -
-
-
-

{t('tokenUsage.title')}

- -
+ {activeView === 'usage' && + renderTokenUsagePanel({ + view: 'usage', + title: t('tokenUsage.title'), + description: undefined, + group: tokenGroup, + groups: tokenGroups, + onGroupChange: setTokenGroup, + totals: tokenTotals, + rows: tokenRows, + empty: t('tokenUsage.empty') + })} -
-
-
{t('tokenUsage.columns.input')}
-
- {tokenCountFormatter.format(tokenTotals.inputTokens)} -
-
-
-
{t('tokenUsage.columns.output')}
-
- {tokenCountFormatter.format(tokenTotals.outputTokens)} -
-
-
-
{t('tokenUsage.columns.cacheWrite')}
-
- {tokenCountFormatter.format( - tokenTotals.cacheWriteTokens - )} -
-
-
-
{t('tokenUsage.columns.cacheRead')}
-
- {tokenCountFormatter.format( - tokenTotals.cacheReadTokens - )} -
-
-
-
{t('tokenUsage.columns.cacheHitRate')}
-
- {formatCacheHitRate(tokenTotals.cacheHitRate)} -
-
-
-
{t('tokenUsage.columns.total')}
-
- {tokenCountFormatter.format(tokenTotals.totalTokens)} -
-
-
+ {activeView === 'system' && + renderTokenUsagePanel({ + view: 'system', + title: t('systemUsage.title'), + description: t('systemUsage.description'), + group: systemTokenGroup, + groups: systemTokenGroups, + onGroupChange: setSystemTokenGroup, + totals: systemTokenTotals, + rows: systemTokenRows, + empty: t('systemUsage.empty') + })} +
+ ) -
- ({ + view, + title, + description, + group, + groups, + onGroupChange, + totals, + rows, + empty + }: { + view: TokenUsageView + title: string + description: string | undefined + group: Group + groups: ReadonlyArray<{ value: Group; label: string; columnLabel: string }> + onGroupChange: (group: Group) => void + totals: ReturnType + rows: TokenUsageGroupRow[] + empty: string + }): React.JSX.Element { + const titleId = view === 'usage' ? 'token-usage-title' : 'system-usage-title' + const groupLabel = + groups.find((item) => item.value === group)?.columnLabel ?? + groups[0]!.columnLabel + const expansionPrefix = `${view}:` + return ( +
+
+
+

{title}

+ + {onRefreshTokenUsage && ( +
+
+ + + + + + + + + + + + + {rows.length === 0 ? ( - - - - - - - + - - - {tokenRows.length === 0 ? ( - - - - ) : ( - tokenRows.flatMap((parent) => [ + ) : ( + rows + .flatMap((parent) => [ { row: parent, isChild: false }, - ...(expandedTokenRows.has(parent.key) + ...(expandedTokenRows.has(expansionPrefix + parent.key) ? (parent.children ?? []).map((row) => ({ row: { ...row, key: `${parent.key}:${row.key}` }, isChild: true })) : []) - ]).map(({ row, isChild }) => { - const localizedRow = localizeTokenRow(row) + ]) + .map(({ row, isChild }) => { + const localizedRow = localizeTokenRow(row, group) + const expansionKey = expansionPrefix + row.key return ( + + - - - - + + ) }) - )} - -
{groupLabel}{t('tokenUsage.columns.input')}{t('tokenUsage.columns.output')}{t('tokenUsage.columns.cacheWrite')}{t('tokenUsage.columns.cacheRead')}{t('tokenUsage.columns.cacheHitRate')}{t('tokenUsage.columns.total')}
{tokenGroupLabel}{t('tokenUsage.columns.input')}{t('tokenUsage.columns.output')} - {t('tokenUsage.columns.cacheWrite')} - - {t('tokenUsage.columns.cacheRead')} - - {t('tokenUsage.columns.cacheHitRate')} - - {t('tokenUsage.columns.total')} - + {empty} +
- {t('tokenUsage.empty')} -
{row.children ? ( {tokenCountFormatter.format(row.inputTokens)}{tokenCountFormatter.format(row.outputTokens)} - {tokenCountFormatter.format( - row.inputTokens - )} + {tokenCountFormatter.format(row.cacheWriteTokens)} - {tokenCountFormatter.format( - row.outputTokens - )} - - {tokenCountFormatter.format( - row.cacheWriteTokens - )} - - {tokenCountFormatter.format( - row.cacheReadTokens - )} - - {formatCacheHitRate(row.cacheHitRate)} - - {tokenCountFormatter.format( - row.totalTokens - )} + {tokenCountFormatter.format(row.cacheReadTokens)} {formatCacheHitRate(row.cacheHitRate)}{tokenCountFormatter.format(row.totalTokens)}
-
- -
- )} - - ) + )} + + + + + + ) + } }) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index ed7fdfb8..6ee44e8a 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -640,6 +640,7 @@ const api: DesktopApi & RuntimeNativeClientApi = { }, records: [], })), + onChanged: vi.fn(() => () => undefined), }, artifacts: { list: vi.fn(async () => []), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 962e2a69..6ff87949 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -6096,9 +6096,28 @@ function App(): React.JSX.Element { }), ); }, 0); - return () => clearTimeout(timeout); + // Background work (supervisor, heartbeat, knowledge, notes) records usage + // without a renderer request; main notifies so the view stays current. + const removeUsageListener = window.goodbuddy.usage.onChanged?.(() => { + void refreshTokenUsage().catch(() => undefined); + }); + return () => { + clearTimeout(timeout); + removeUsageListener?.(); + }; }, [refreshTokenUsage, view]); + const manualRefreshTokenUsage = useCallback(async (): Promise => { + try { + await refreshTokenUsage(); + } catch { + notify({ + tone: "error", + message: tRef.current("notices.tokenUsageReadFailed"), + }); + } + }, [notify, refreshTokenUsage]); + useEffect(() => { void window.goodbuddy.artifacts .list() @@ -11406,6 +11425,7 @@ function App(): React.JSX.Element { !isSystemTokenUsageRecord(record) + ) + const systemRecords = tokenUsage.records.filter(isSystemTokenUsageRecord) + const emptyTotals = { + callCount: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cacheInput: 0, + totalTokens: 0 + } + return { + // Legacy summaries without split totals keep their overall totals. + conversation: { + totals: tokenUsage.conversationTotals ?? tokenUsage.totals, + records: conversationRecords + }, + system: { + totals: tokenUsage.systemTotals ?? emptyTotals, + records: systemRecords + } + } +} + export function groupTokenUsage( tokenUsage: TokenUsageSummary, group: TokenUsageGroup diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 16c7431a..51ca65ed 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -781,8 +781,38 @@ export type ModelUsageCallInput = { cacheWrite: number } +export const tokenUsageSystemSources = [ + 'heartbeat', + 'supervision', + 'magic-notes', + 'knowledge', + 'delegation' +] as const + +export type TokenUsageSystemSource = (typeof tokenUsageSystemSources)[number] + +export type SystemModelUsageInput = Omit< + ModelUsageCallInput, + 'requestId' | 'callId' +> & { + source: Extract + bucket: 'graph-extraction' | 'embedding' +} + +export type TokenUsageTotals = { + callCount: number + input: number + output: number + cacheRead: number + cacheWrite: number + cacheInput?: number + totalTokens: number +} + export type TokenUsageRecord = { requestId: string + /** Set when the usage came from background system work instead of a conversation. */ + systemSource?: TokenUsageSystemSource projectId?: string projectName?: string conversationId?: string @@ -800,15 +830,12 @@ export type TokenUsageRecord = { } export type TokenUsageSummary = { - totals: { - callCount: number - input: number - output: number - cacheRead: number - cacheWrite: number - cacheInput?: number - totalTokens: number - } + /** Totals across all recorded usage. */ + totals: TokenUsageTotals + /** Totals for conversation work only; absent in legacy summaries. */ + conversationTotals?: TokenUsageTotals + /** Totals for background system work only; absent in legacy summaries. */ + systemTotals?: TokenUsageTotals records: TokenUsageRecord[] } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index b6adcd2a..4e078bd0 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -2159,6 +2159,8 @@ export type DesktopApi = { } usage: { getTokenSummary: () => Promise + /** Fires (debounced) after any model usage is recorded. */ + onChanged: (listener: () => void) => () => void } artifacts: { list: (projectId?: string) => Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 816e9c4f..e58fe6c1 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -249,6 +249,7 @@ export const ipcChannels = { activityHistoryGet: 'activity-history:get', activityHistoryReplace: 'activity-history:replace', tokenUsageSummary: 'usage:token-summary', + tokenUsageChanged: 'usage:token-changed', artifactsList: 'artifacts:list', artifactsGet: 'artifacts:get', artifactsImportFiles: 'artifacts:import-files',