diff --git a/locales/en-US/opStatusTray.json b/locales/en-US/opStatusTray.json new file mode 100644 index 0000000000..1a54ac8588 --- /dev/null +++ b/locales/en-US/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Working", + "Drafting", + "Thinking", + "Computing", + "Brewing", + "Synthesizing", + "Crunching", + "Architecting", + "Composing", + "Orchestrating", + "Sketching", + "Noodling", + "Pondering", + "Crafting", + "Flambéing", + "Simmering", + "Whirring", + "Wrangling", + "Polishing", + "Preparing the answer", + "Baking", + "Channeling", + "Coalescing", + "Deciphering", + "Forging", + "Harmonizing", + "Improvising", + "Inferring", + "Tinkering", + "Zigzagging" + ] +} diff --git a/locales/zh-CN/opStatusTray.json b/locales/zh-CN/opStatusTray.json new file mode 100644 index 0000000000..954cd78af3 --- /dev/null +++ b/locales/zh-CN/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "处理中", + "思考中", + "推理中", + "分析中", + "检索线索中", + "梳理上下文中", + "整理思路中", + "生成回复中", + "准备答案中", + "撰写内容中", + "组织语言中", + "完善表达中", + "核对细节中", + "提炼重点中", + "串联信息中", + "构建方案中", + "拆解问题中", + "验证思路中", + "整合结果中", + "润色答案中", + "继续推进中", + "认真执行中", + "快速处理中", + "稳步推进中", + "灵感生成中", + "校准方向中", + "收束结论中", + "补全细节中", + "输出结果中", + "收尾处理中" + ] +} diff --git a/packages/locales/src/create.ts b/packages/locales/src/create.ts index 3c4f0def8a..3656edd723 100644 --- a/packages/locales/src/create.ts +++ b/packages/locales/src/create.ts @@ -21,12 +21,11 @@ import defaultHome from '@/locales/default/home'; import { normalizeLocale } from '@/locales/resources'; import { isOnServerSide } from '@/utils/env'; import { unwrapESMModule } from '@/utils/esm/unwrapESMModule'; - import { loadI18nNamespaceModule } from '@/utils/i18n/loadI18nNamespaceModule'; const mergeNamespace = ( - fallbackResources: Record, - localeResources: Record, + fallbackResources: Record, + localeResources: Record, ) => ({ ...fallbackResources, ...localeResources, diff --git a/packages/locales/src/default/index.ts b/packages/locales/src/default/index.ts index 98e785faa7..f3da0f332e 100644 --- a/packages/locales/src/default/index.ts +++ b/packages/locales/src/default/index.ts @@ -32,6 +32,7 @@ import notification from './notification'; import oauth from './oauth'; import onboarding from './onboarding'; import openInApp from './openInApp'; +import opStatusTray from './opStatusTray'; import pageShare from './pageShare'; import plugin from './plugin'; import portal from './portal'; @@ -84,6 +85,7 @@ const resources = { notification, oauth, onboarding, + opStatusTray, openInApp, pageShare, plugin, diff --git a/packages/locales/src/default/opStatusTray.ts b/packages/locales/src/default/opStatusTray.ts new file mode 100644 index 0000000000..846a387a3f --- /dev/null +++ b/packages/locales/src/default/opStatusTray.ts @@ -0,0 +1,34 @@ +export default { + generatingPhrases: [ + 'Working', + 'Drafting', + 'Thinking', + 'Computing', + 'Brewing', + 'Synthesizing', + 'Crunching', + 'Architecting', + 'Composing', + 'Orchestrating', + 'Sketching', + 'Noodling', + 'Pondering', + 'Crafting', + 'Flambéing', + 'Simmering', + 'Whirring', + 'Wrangling', + 'Polishing', + 'Preparing the answer', + 'Baking', + 'Channeling', + 'Coalescing', + 'Deciphering', + 'Forging', + 'Harmonizing', + 'Improvising', + 'Inferring', + 'Tinkering', + 'Zigzagging', + ], +}; diff --git a/src/features/Conversation/ChatInput/OpStatusTray.tsx b/src/features/Conversation/ChatInput/OpStatusTray.tsx index 866249dd7a..7540b8f25d 100644 --- a/src/features/Conversation/ChatInput/OpStatusTray.tsx +++ b/src/features/Conversation/ChatInput/OpStatusTray.tsx @@ -14,8 +14,15 @@ import { type OperationType, } from '@/store/chat/slices/operation/types'; import { shinyTextStyles } from '@/styles'; +import { + calculateOperationUsageMetrics, + hasOperationUsageMetrics, + mergeOperationUsageMetrics, + type OperationUsageMetrics, +} from '@/utils/operationUsageMetrics'; import { contextSelectors, dataSelectors, useConversationStore } from '../store'; +import { parseStatusPhrases, pickStableStatusPhrase } from './OpStatusTray/logic'; const styles = createStaticStyles(({ css, cssVar }) => ({ container: css` @@ -240,108 +247,109 @@ interface MetricItem { } const OpStatusTray = memo(({ topAttached }) => { - const { t } = useTranslation('chat'); + const { t } = useTranslation(['chat', 'opStatusTray']); const context = useConversationStore(contextSelectors.context); const dbMessages = useConversationStore(dataSelectors.dbMessages); - // Detect any running AI-runtime op (excludes sub-ops like callLLM/toolCalling) - // and capture the earliest start time as the op's anchor. - const startTime = useChatStore((s) => { + const operationState = useChatStore((s) => { const ops = operationSelectors.getOperationsByContext(context)(s); - let earliest: number | undefined; - for (const op of ops) { - if ( - op.status !== 'running' || - op.metadata.isAborting || - !AI_RUNTIME_OPERATION_TYPES.includes(op.type) - ) { - continue; - } - if (earliest === undefined || op.metadata.startTime < earliest) { - earliest = op.metadata.startTime; - } - } - return earliest; - }); + let activity: ActivityKey | undefined; + let earliestStart: number | undefined; + let latestActivityStart = -1; + let statusSeed: string | undefined; + let stepCount = 0; + let usageMetrics: OperationUsageMetrics | undefined; + const runtimeOperationIds: string[] = []; - // The most recently started running sub-op decides the streaming phase. - // Server-side runtimes surface no sub-ops on the client, so fall back to - // 'generating' — the dominant phase for plain server-streamed chat. - const activity = useChatStore((s): ActivityKey => { - const ops = operationSelectors.getOperationsByContext(context)(s); - let current: ActivityKey | undefined; - let latest = -1; for (const op of ops) { if (op.status !== 'running' || op.metadata.isAborting) continue; + const mapped = resolveActivity(op.type); - if (!mapped) continue; - if (op.metadata.startTime > latest) { - latest = op.metadata.startTime; - current = mapped; + if (mapped && op.metadata.startTime > latestActivityStart) { + latestActivityStart = op.metadata.startTime; + activity = mapped; } - } - return current ?? 'generating'; - }); - const steps = useChatStore((s) => { - const ops = operationSelectors.getOperationsByContext(context)(s); - let stepCount = 0; - - for (const op of ops) { - if ( - op.status !== 'running' || - op.metadata.isAborting || - !AI_RUNTIME_OPERATION_TYPES.includes(op.type) - ) { + if (!AI_RUNTIME_OPERATION_TYPES.includes(op.type)) { continue; } + runtimeOperationIds.push(op.id); stepCount = Math.max(stepCount, normalizeStepCount(op.metadata.stepCount)); - } + if (hasOperationUsageMetrics(op.metadata.usageMetrics)) { + usageMetrics = mergeOperationUsageMetrics(usageMetrics, op.metadata.usageMetrics); + } - return stepCount; + if (earliestStart === undefined || op.metadata.startTime < earliestStart) { + earliestStart = op.metadata.startTime; + statusSeed = op.id; + } + } + return { + activity: activity ?? 'generating', + operationIdsKey: runtimeOperationIds.join('|'), + startTime: earliestStart, + statusSeed, + steps: stepCount, + usageMetrics, + }; }); + const operationsByMessage = useChatStore((s) => s.operationsByMessage); const [now, setNow] = useState(() => Date.now()); useEffect(() => { - if (!startTime) return; + if (!operationState.startTime) return; setNow(Date.now()); const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); - }, [startTime]); + }, [operationState.startTime]); - // Aggregate tokens / cost across the current conversation. - // New code reads usage only from the top-level message field. - const { totalCost, totalTokens } = useMemo(() => { - let tokens = 0; - let cost = 0; - for (const m of dbMessages) { - if (m.role !== 'assistant') continue; - const usage = m.usage; - if (!usage) continue; - tokens += usage.totalTokens ?? 0; - cost += usage.cost ?? 0; - } - return { totalCost: cost, totalTokens: tokens }; - }, [dbMessages]); + const operationIds = useMemo( + () => new Set(operationState.operationIdsKey.split('|').filter(Boolean)), + [operationState.operationIdsKey], + ); - if (!startTime) return null; + // Fallback for older / reloaded operation state: derive usage from messages + // produced by this operation when live operation metadata is unavailable. + const fallbackMetrics = useMemo(() => { + return calculateOperationUsageMetrics(dbMessages, operationIds, operationsByMessage); + }, [dbMessages, operationIds, operationsByMessage]); - const elapsed = now - startTime; - const costLabel = t('opStatusTray.cost'); - const stepLabel = t('opStatusTray.steps'); - const tokenLabel = t('opStatusTray.tokens', { defaultValue: 'tokens' }); + if (!operationState.startTime) return null; + + const { totalCost, totalTokens } = hasOperationUsageMetrics(operationState.usageMetrics) + ? operationState.usageMetrics + : fallbackMetrics; + const elapsed = now - operationState.startTime; + const costLabel = t('chat:opStatusTray.cost'); + const stepLabel = t('chat:opStatusTray.steps'); + const tokenLabel = t('chat:opStatusTray.tokens', { defaultValue: 'tokens' }); + const generatingPhrases = parseStatusPhrases( + t('opStatusTray:generatingPhrases', { + defaultValue: [], + returnObjects: true, + }), + ); + const randomGeneratingStatus = + pickStableStatusPhrase( + generatingPhrases, + operationState.statusSeed ?? String(operationState.startTime), + ) ?? t('chat:opStatusTray.status.generating'); + const statusText = + operationState.activity === 'generating' + ? randomGeneratingStatus + : t(`chat:opStatusTray.status.${operationState.activity}`); // Zero-valued metrics render nothing; steps only matter for long-running // multi-step tasks, so a single step stays hidden too. const metrics = [ - steps > 1 + operationState.steps > 1 ? { icon: FootprintsIcon, key: 'steps', label: stepLabel, - title: `${steps} ${stepLabel}`, - value: String(steps), + title: `${operationState.steps} ${stepLabel}`, + value: String(operationState.steps), } : undefined, totalTokens > 0 @@ -404,9 +412,7 @@ const OpStatusTray = memo(({ topAttached }) => { > - - {t(`opStatusTray.status.${activity}`)}... - + {statusText}... {formatDuration(elapsed)} diff --git a/src/features/Conversation/ChatInput/OpStatusTray/logic.test.ts b/src/features/Conversation/ChatInput/OpStatusTray/logic.test.ts new file mode 100644 index 0000000000..efc1cc62f5 --- /dev/null +++ b/src/features/Conversation/ChatInput/OpStatusTray/logic.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { parseStatusPhrases, pickStableStatusPhrase } from './logic'; + +describe('OpStatusTray logic', () => { + describe('status phrases', () => { + it('parses a pipe-delimited localized phrase list', () => { + expect(parseStatusPhrases('Working | Flambéing | 努力干活中 | ')).toEqual([ + 'Working', + 'Flambéing', + '努力干活中', + ]); + }); + + it('parses a localized phrase array', () => { + expect(parseStatusPhrases(['Working', 'Flambéing', '', 1])).toEqual(['Working', 'Flambéing']); + }); + + it('picks a stable phrase for the same operation seed', () => { + const phrases = ['Working', 'Thinking', 'Flambéing']; + + expect(pickStableStatusPhrase(phrases, 'op-123')).toBe( + pickStableStatusPhrase(phrases, 'op-123'), + ); + expect(phrases).toContain(pickStableStatusPhrase(phrases, 'op-123')); + }); + }); +}); diff --git a/src/features/Conversation/ChatInput/OpStatusTray/logic.ts b/src/features/Conversation/ChatInput/OpStatusTray/logic.ts new file mode 100644 index 0000000000..9fbb266aee --- /dev/null +++ b/src/features/Conversation/ChatInput/OpStatusTray/logic.ts @@ -0,0 +1,29 @@ +export const parseStatusPhrases = (raw: unknown): string[] => { + if (Array.isArray(raw)) { + return raw + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); + } + + if (typeof raw !== 'string') return []; + + return raw + .split('|') + .map((item) => item.trim()) + .filter(Boolean); +}; + +const hashString = (input: string): number => { + let hash = 0x81_1c_9d_c5; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash; +}; + +export const pickStableStatusPhrase = (phrases: string[], seed: string): string | undefined => { + if (phrases.length === 0) return undefined; + return phrases[hashString(seed) % phrases.length]; +}; diff --git a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts index 8e030b1ddf..d4eadeb1c6 100644 --- a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts +++ b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts @@ -37,7 +37,10 @@ function createMockStore() { internal_toggleToolCallingStreaming: vi.fn(), markUnreadCompleted: vi.fn(), operations: { - 'op-1': { context: { agentId: 'agent-1', scope: 'session', topicId: 'topic-1' } }, + 'op-1': { + context: { agentId: 'agent-1', scope: 'session', topicId: 'topic-1' }, + metadata: { startTime: 0, usageMetrics: { totalTokens: 100 } }, + }, } as Record, replaceMessages: vi.fn(), startOperation: vi.fn(() => { @@ -622,6 +625,29 @@ describe('createGatewayEventHandler', () => { expect(store.replaceMessages).not.toHaveBeenCalled(); }); + + it('should accumulate turn metadata usage onto the operation', async () => { + const store = createMockStore(); + const handler = createHandler(store); + + handler( + makeEvent('step_complete', { + phase: 'turn_metadata', + usage: { cost: 0.2, totalInputTokens: 40, totalOutputTokens: 10, totalTokens: 50 }, + }), + ); + await flush(); + + expect(store.updateOperationMetadata).toHaveBeenCalledWith('op-1', { + usageMetrics: { + totalCost: 0.2, + totalInputTokens: 40, + totalOutputTokens: 10, + totalTokens: 150, + }, + }); + expect(store.replaceMessages).not.toHaveBeenCalled(); + }); }); describe('agent_runtime_end', () => { diff --git a/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts index 869fab8416..8ec7e8230f 100644 --- a/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts +++ b/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts @@ -159,14 +159,19 @@ function createMockStore(overrides: Record = {}) { // for each subagent run, mirroring `startOperation`'s contract just // enough that the executor can build dispatchers + completion calls. let subOpCounter = 0; - return { + const store = { associateMessageWithOperation: vi.fn(), completeOperation: vi.fn(), drainQueuedMessages: vi.fn(() => []), internal_dispatchMessage: vi.fn(), internal_toggleToolCallingStreaming: vi.fn(), markUnreadCompleted: vi.fn(), - operations: {} as Record, + operations: { + 'op-1': { + context: { agentId: 'agent-1', scope: 'main', topicId: 'topic-1' }, + metadata: { startTime: 0 }, + }, + } as Record, refreshMessages: vi.fn(async () => {}), refreshThreads: vi.fn(async () => {}), replaceMessages: vi.fn(), @@ -181,6 +186,19 @@ function createMockStore(overrides: Record = {}) { updateTopicMetadata: vi.fn().mockResolvedValue(undefined), ...overrides, } as any; + + if (!store.updateOperationMetadata) { + store.updateOperationMetadata = vi.fn((operationId: string, metadata: Record) => { + const operation = store.operations[operationId]; + if (!operation) return; + operation.metadata = { + ...operation.metadata, + ...metadata, + }; + }); + } + + return store; } const defaultContext = { @@ -726,7 +744,7 @@ describe('heterogeneousAgentExecutor DB persistence', () => { // Realistic CC partial-messages flow: message_start primes the turn, // assistant events echo a stale usage, message_delta carries the final. - await runWithEvents([ + const { store } = await runWithEvents([ ccInit(), ccMessageStart('msg_01'), ccAssistant('msg_01', [{ text: 'a', type: 'text' }]), @@ -772,6 +790,13 @@ describe('heterogeneousAgentExecutor DB persistence', () => { // No cache tokens for this turn — these fields should be absent expect(u2.inputCachedTokens).toBeUndefined(); expect(u2.inputWriteCacheTokens).toBeUndefined(); + + expect(store.operations['op-1'].metadata.usageMetrics).toEqual({ + totalCost: 0, + totalInputTokens: 650, + totalOutputTokens: 130, + totalTokens: 780, + }); }); it('should ignore stale usage on assistant events (from message_start echo)', async () => { diff --git a/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts b/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts index ef49cadb0d..ddf5d4c0e0 100644 --- a/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts +++ b/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts @@ -19,6 +19,7 @@ import { messageService } from '@/services/message'; import { emitClientAgentSignalSourceEvent } from '@/store/chat/slices/aiChat/actions/agentSignalBridge'; import type { ChatStore } from '@/store/chat/store'; import { notifyDesktopHumanApprovalRequired } from '@/store/chat/utils/desktopNotification'; +import { addUsageToOperationMetrics, type OperationUsageLike } from '@/utils/operationUsageMetrics'; // Lazy-loaded to break the import cycle: // gateway.ts → gatewayEventHandler.ts → executors/index.ts (which pulls in @@ -55,6 +56,10 @@ interface ToolPayloadIdentity { toolCallId?: string; } +type StepCompleteDataWithUsage = StepCompleteData & { + usage?: OperationUsageLike | null; +}; + /** * Extract `{ identifier, apiName, params, toolCallId }` from a stream event's * tool payload. Returns `undefined` when the payload is malformed so the @@ -500,7 +505,14 @@ export const createGatewayEventHandler = ( } case 'step_complete': { - const data = event.data as StepCompleteData | undefined; + const data = event.data as StepCompleteDataWithUsage | undefined; + + if (data?.phase === 'turn_metadata' && data.usage) { + const operation = get().operations[operationId]; + get().updateOperationMetadata(operationId, { + usageMetrics: addUsageToOperationMetrics(operation?.metadata?.usageMetrics, data.usage), + }); + } // Refresh on execution_complete to ensure final step state is consistent if (data?.phase === 'execution_complete') { diff --git a/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts b/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts index 1db326e373..6bde040a24 100644 --- a/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts +++ b/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts @@ -39,6 +39,7 @@ import { threadService } from '@/services/thread'; import { type ChatStore, useChatStore } from '@/store/chat/store'; import { resolveNotificationNavigatePath } from '@/store/chat/utils/desktopNotification'; import { markdownToTxt } from '@/utils/markdownToTxt'; +import { addUsageToOperationMetrics } from '@/utils/operationUsageMetrics'; import { messageMapKey } from '../../../utils/messageMapKey'; import { mergeQueuedMessages } from '../../operation/types'; @@ -1227,6 +1228,13 @@ export const executeHeterogeneousAgent = async ( return; } + if (turnUsage) { + const operation = get().operations[operationId]; + get().updateOperationMetadata(operationId, { + usageMetrics: addUsageToOperationMetrics(operation?.metadata?.usageMetrics, turnUsage), + }); + } + if (event.data.model) lastModel = event.data.model; if (event.data.provider) lastProvider = event.data.provider; const updateValue: Record = {}; diff --git a/src/utils/i18n/loadI18nNamespaceModule.desktop.ts b/src/utils/i18n/loadI18nNamespaceModule.desktop.ts index da0ca8fa61..c61d6e6f33 100644 --- a/src/utils/i18n/loadI18nNamespaceModule.desktop.ts +++ b/src/utils/i18n/loadI18nNamespaceModule.desktop.ts @@ -4,11 +4,11 @@ import type { } from './loadI18nNamespaceModule'; // eager: true — all locale JSON inlined at build time, synchronous access at runtime -const defaultModules = import.meta.glob<{ default: Record }>( +const defaultModules = import.meta.glob<{ default: Record }>( '/packages/locales/src/default/*.ts', { eager: true }, ); -const localeModules = import.meta.glob<{ default: Record }>('/locales/*/*.json', { +const localeModules = import.meta.glob<{ default: Record }>('/locales/*/*.json', { eager: true, }); @@ -17,7 +17,7 @@ const getLocaleKey = (lng: string, ns: string) => `/locales/${lng}/${ns}.json`; export const loadI18nNamespaceModule = async ( params: LoadI18nNamespaceModuleParams, -): Promise<{ default: Record }> => { +): Promise<{ default: Record }> => { const { defaultLang, normalizeLocale, lng, ns } = params; if (lng === defaultLang) { @@ -42,7 +42,7 @@ export type { export const loadI18nNamespaceModuleWithFallback = async ( params: LoadI18nNamespaceModuleWithFallbackParams, -): Promise<{ default: Record }> => { +): Promise<{ default: Record }> => { const { onFallback, ...rest } = params; try { return await loadI18nNamespaceModule(rest); diff --git a/src/utils/i18n/loadI18nNamespaceModule.vite.ts b/src/utils/i18n/loadI18nNamespaceModule.vite.ts index b212b43785..448d5d0b39 100644 --- a/src/utils/i18n/loadI18nNamespaceModule.vite.ts +++ b/src/utils/i18n/loadI18nNamespaceModule.vite.ts @@ -4,36 +4,36 @@ import type { } from './loadI18nNamespaceModule'; // Use import.meta.glob so Vite can statically analyze and avoid CJS/dynamic import issues -const defaultLoaders = import.meta.glob<{ default: Record }>( +const defaultLoaders = import.meta.glob<{ default: Record }>( '/packages/locales/src/default/*.ts', ); -const localeLoaders = import.meta.glob<{ default: Record }>('/locales/*/*.json'); +const localeLoaders = import.meta.glob<{ default: Record }>('/locales/*/*.json'); const getDefaultKey = (ns: string) => `/packages/locales/src/default/${ns}.ts`; const getLocaleKey = (lng: string, ns: string) => `/locales/${lng}/${ns}.json`; export const loadI18nNamespaceModule = async ( params: LoadI18nNamespaceModuleParams, -): Promise<{ default: Record }> => { +): Promise<{ default: Record }> => { const { defaultLang, normalizeLocale, lng, ns } = params; if (lng === defaultLang) { const key = getDefaultKey(ns); const load = defaultLoaders[key]; if (!load) throw new Error(`Missing default namespace: ${ns}`); - return load() as Promise<{ default: Record }>; + return load() as Promise<{ default: Record }>; } const normalizedLng = normalizeLocale(lng); const localeKey = getLocaleKey(normalizedLng, ns); const loadLocale = localeLoaders[localeKey]; if (loadLocale) { - return loadLocale() as Promise<{ default: Record }>; + return loadLocale() as Promise<{ default: Record }>; } const loadDefault = defaultLoaders[getDefaultKey(ns)]; if (!loadDefault) throw new Error(`Missing default namespace: ${ns}`); - return loadDefault() as Promise<{ default: Record }>; + return loadDefault() as Promise<{ default: Record }>; }; export type { @@ -43,7 +43,7 @@ export type { export const loadI18nNamespaceModuleWithFallback = async ( params: LoadI18nNamespaceModuleWithFallbackParams, -): Promise<{ default: Record }> => { +): Promise<{ default: Record }> => { const { onFallback, ...rest } = params; try { return await loadI18nNamespaceModule(rest); @@ -51,6 +51,6 @@ export const loadI18nNamespaceModuleWithFallback = async ( onFallback?.({ error, lng: rest.lng, ns: rest.ns }); const loadDefault = defaultLoaders[getDefaultKey(rest.ns)]; if (!loadDefault) throw error; - return loadDefault() as Promise<{ default: Record }>; + return loadDefault() as Promise<{ default: Record }>; } }; diff --git a/src/utils/operationUsageMetrics.test.ts b/src/utils/operationUsageMetrics.test.ts new file mode 100644 index 0000000000..3e5dffada3 --- /dev/null +++ b/src/utils/operationUsageMetrics.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { + addUsageToOperationMetrics, + calculateOperationUsageMetrics, + hasOperationUsageMetrics, +} from './operationUsageMetrics'; + +describe('operationUsageMetrics', () => { + describe('calculateOperationUsageMetrics', () => { + it('sums only assistant usage associated with the current operation', () => { + const metrics = calculateOperationUsageMetrics( + [ + { id: 'old-step', role: 'assistant', usage: { cost: 9, totalTokens: 9_000_000 } }, + { + id: 'current-step-1', + role: 'assistant', + usage: { cost: 1, totalInputTokens: 900, totalOutputTokens: 300, totalTokens: 1200 }, + }, + { + id: 'current-step-2', + role: 'assistant', + usage: { cost: 2, totalInputTokens: 700, totalOutputTokens: 100, totalTokens: 800 }, + }, + { id: 'current-tool', role: 'tool', usage: { cost: 9, totalTokens: 999_999 } }, + { id: 'unmapped', role: 'assistant', usage: { cost: 0.5, totalTokens: 5000 } }, + ], + new Set(['op-current']), + { + 'current-step-1': ['op-current'], + 'current-step-2': ['op-current', 'reasoning-op'], + 'current-tool': ['op-current'], + 'old-step': ['op-old'], + }, + ); + + expect(metrics).toEqual({ + totalCost: 3, + totalInputTokens: 1600, + totalOutputTokens: 400, + totalTokens: 2000, + }); + }); + + it('returns zero metrics when no runtime operation is active', () => { + const metrics = calculateOperationUsageMetrics( + [{ id: 'message', role: 'assistant', usage: { cost: 0.01, totalTokens: 100 } }], + new Set(), + { message: ['op-1'] }, + ); + + expect(metrics).toEqual({ + totalCost: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalTokens: 0, + }); + }); + }); + + describe('addUsageToOperationMetrics', () => { + it('adds a per-step usage delta onto existing operation metrics', () => { + const metrics = addUsageToOperationMetrics( + { totalCost: 1, totalInputTokens: 100, totalOutputTokens: 50, totalTokens: 150 }, + { cost: 2, totalInputTokens: 300, totalOutputTokens: 80, totalTokens: 380 }, + ); + + expect(metrics).toEqual({ + totalCost: 3, + totalInputTokens: 400, + totalOutputTokens: 130, + totalTokens: 530, + }); + expect(hasOperationUsageMetrics(metrics)).toBe(true); + }); + }); +}); diff --git a/src/utils/operationUsageMetrics.ts b/src/utils/operationUsageMetrics.ts new file mode 100644 index 0000000000..5f23dfcd1b --- /dev/null +++ b/src/utils/operationUsageMetrics.ts @@ -0,0 +1,86 @@ +export interface OperationUsageLike { + cost?: number | null; + totalInputTokens?: number | null; + totalOutputTokens?: number | null; + totalTokens?: number | null; +} + +export interface OperationUsageMetrics { + totalCost: number; + totalInputTokens: number; + totalOutputTokens: number; + totalTokens: number; +} + +interface OperationUsageMessage { + id: string; + role?: string; + usage?: OperationUsageLike | null; +} + +const normalizeNumber = (value: unknown): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0; + return value; +}; + +export const EMPTY_OPERATION_USAGE_METRICS: OperationUsageMetrics = { + totalCost: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalTokens: 0, +}; + +export const mergeOperationUsageMetrics = ( + left?: Partial | null, + right?: Partial | null, +): OperationUsageMetrics => ({ + totalCost: normalizeNumber(left?.totalCost) + normalizeNumber(right?.totalCost), + totalInputTokens: + normalizeNumber(left?.totalInputTokens) + normalizeNumber(right?.totalInputTokens), + totalOutputTokens: + normalizeNumber(left?.totalOutputTokens) + normalizeNumber(right?.totalOutputTokens), + totalTokens: normalizeNumber(left?.totalTokens) + normalizeNumber(right?.totalTokens), +}); + +export const usageToOperationMetrics = ( + usage?: OperationUsageLike | null, +): OperationUsageMetrics => ({ + totalCost: normalizeNumber(usage?.cost), + totalInputTokens: normalizeNumber(usage?.totalInputTokens), + totalOutputTokens: normalizeNumber(usage?.totalOutputTokens), + totalTokens: normalizeNumber(usage?.totalTokens), +}); + +export const addUsageToOperationMetrics = ( + metrics?: Partial | null, + usage?: OperationUsageLike | null, +): OperationUsageMetrics => mergeOperationUsageMetrics(metrics, usageToOperationMetrics(usage)); + +export const hasOperationUsageMetrics = ( + metrics?: Partial | null, +): metrics is OperationUsageMetrics => + normalizeNumber(metrics?.totalCost) > 0 || + normalizeNumber(metrics?.totalInputTokens) > 0 || + normalizeNumber(metrics?.totalOutputTokens) > 0 || + normalizeNumber(metrics?.totalTokens) > 0; + +export const calculateOperationUsageMetrics = ( + messages: OperationUsageMessage[], + operationIds: Set, + operationsByMessage: Record, +): OperationUsageMetrics => { + if (operationIds.size === 0) return EMPTY_OPERATION_USAGE_METRICS; + + let metrics: OperationUsageMetrics = EMPTY_OPERATION_USAGE_METRICS; + + for (const message of messages) { + if (message.role !== 'assistant') continue; + + const messageOperationIds = operationsByMessage[message.id]; + if (!messageOperationIds?.some((id) => operationIds.has(id))) continue; + + metrics = addUsageToOperationMetrics(metrics, message.usage); + } + + return metrics; +};