mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 fix(chat): track operation usage in status tray (#15736)
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"处理中",
|
||||
"思考中",
|
||||
"推理中",
|
||||
"分析中",
|
||||
"检索线索中",
|
||||
"梳理上下文中",
|
||||
"整理思路中",
|
||||
"生成回复中",
|
||||
"准备答案中",
|
||||
"撰写内容中",
|
||||
"组织语言中",
|
||||
"完善表达中",
|
||||
"核对细节中",
|
||||
"提炼重点中",
|
||||
"串联信息中",
|
||||
"构建方案中",
|
||||
"拆解问题中",
|
||||
"验证思路中",
|
||||
"整合结果中",
|
||||
"润色答案中",
|
||||
"继续推进中",
|
||||
"认真执行中",
|
||||
"快速处理中",
|
||||
"稳步推进中",
|
||||
"灵感生成中",
|
||||
"校准方向中",
|
||||
"收束结论中",
|
||||
"补全细节中",
|
||||
"输出结果中",
|
||||
"收尾处理中"
|
||||
]
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
localeResources: Record<string, string>,
|
||||
fallbackResources: Record<string, unknown>,
|
||||
localeResources: Record<string, unknown>,
|
||||
) => ({
|
||||
...fallbackResources,
|
||||
...localeResources,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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<OpStatusTrayProps>(({ 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<OpStatusTrayProps>(({ topAttached }) => {
|
||||
>
|
||||
<span className={cx(styles.metric, styles.statusMetric)}>
|
||||
<ActivityGlyph />
|
||||
<span className={cx(styles.statusText, shinyTextStyles.shinyText)}>
|
||||
{t(`opStatusTray.status.${activity}`)}...
|
||||
</span>
|
||||
<span className={cx(styles.statusText, shinyTextStyles.shinyText)}>{statusText}...</span>
|
||||
<span className={styles.timerValue}>{formatDuration(elapsed)}</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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<string, any>,
|
||||
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', () => {
|
||||
|
||||
@@ -159,14 +159,19 @@ function createMockStore(overrides: Record<string, any> = {}) {
|
||||
// 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<string, any>,
|
||||
operations: {
|
||||
'op-1': {
|
||||
context: { agentId: 'agent-1', scope: 'main', topicId: 'topic-1' },
|
||||
metadata: { startTime: 0 },
|
||||
},
|
||||
} as Record<string, any>,
|
||||
refreshMessages: vi.fn(async () => {}),
|
||||
refreshThreads: vi.fn(async () => {}),
|
||||
replaceMessages: vi.fn(),
|
||||
@@ -181,6 +186,19 @@ function createMockStore(overrides: Record<string, any> = {}) {
|
||||
updateTopicMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as any;
|
||||
|
||||
if (!store.updateOperationMetadata) {
|
||||
store.updateOperationMetadata = vi.fn((operationId: string, metadata: Record<string, any>) => {
|
||||
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 () => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
|
||||
@@ -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<string, string> }>(
|
||||
const defaultModules = import.meta.glob<{ default: Record<string, unknown> }>(
|
||||
'/packages/locales/src/default/*.ts',
|
||||
{ eager: true },
|
||||
);
|
||||
const localeModules = import.meta.glob<{ default: Record<string, string> }>('/locales/*/*.json', {
|
||||
const localeModules = import.meta.glob<{ default: Record<string, unknown> }>('/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<string, string> }> => {
|
||||
): Promise<{ default: Record<string, unknown> }> => {
|
||||
const { defaultLang, normalizeLocale, lng, ns } = params;
|
||||
|
||||
if (lng === defaultLang) {
|
||||
@@ -42,7 +42,7 @@ export type {
|
||||
|
||||
export const loadI18nNamespaceModuleWithFallback = async (
|
||||
params: LoadI18nNamespaceModuleWithFallbackParams,
|
||||
): Promise<{ default: Record<string, string> }> => {
|
||||
): Promise<{ default: Record<string, unknown> }> => {
|
||||
const { onFallback, ...rest } = params;
|
||||
try {
|
||||
return await loadI18nNamespaceModule(rest);
|
||||
|
||||
@@ -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<string, string> }>(
|
||||
const defaultLoaders = import.meta.glob<{ default: Record<string, unknown> }>(
|
||||
'/packages/locales/src/default/*.ts',
|
||||
);
|
||||
const localeLoaders = import.meta.glob<{ default: Record<string, string> }>('/locales/*/*.json');
|
||||
const localeLoaders = import.meta.glob<{ default: Record<string, unknown> }>('/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<string, string> }> => {
|
||||
): Promise<{ default: Record<string, unknown> }> => {
|
||||
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<string, string> }>;
|
||||
return load() as Promise<{ default: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
const normalizedLng = normalizeLocale(lng);
|
||||
const localeKey = getLocaleKey(normalizedLng, ns);
|
||||
const loadLocale = localeLoaders[localeKey];
|
||||
if (loadLocale) {
|
||||
return loadLocale() as Promise<{ default: Record<string, string> }>;
|
||||
return loadLocale() as Promise<{ default: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
const loadDefault = defaultLoaders[getDefaultKey(ns)];
|
||||
if (!loadDefault) throw new Error(`Missing default namespace: ${ns}`);
|
||||
return loadDefault() as Promise<{ default: Record<string, string> }>;
|
||||
return loadDefault() as Promise<{ default: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
export type {
|
||||
@@ -43,7 +43,7 @@ export type {
|
||||
|
||||
export const loadI18nNamespaceModuleWithFallback = async (
|
||||
params: LoadI18nNamespaceModuleWithFallbackParams,
|
||||
): Promise<{ default: Record<string, string> }> => {
|
||||
): Promise<{ default: Record<string, unknown> }> => {
|
||||
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<string, string> }>;
|
||||
return loadDefault() as Promise<{ default: Record<string, unknown> }>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<OperationUsageMetrics> | null,
|
||||
right?: Partial<OperationUsageMetrics> | 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<OperationUsageMetrics> | null,
|
||||
usage?: OperationUsageLike | null,
|
||||
): OperationUsageMetrics => mergeOperationUsageMetrics(metrics, usageToOperationMetrics(usage));
|
||||
|
||||
export const hasOperationUsageMetrics = (
|
||||
metrics?: Partial<OperationUsageMetrics> | 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<string>,
|
||||
operationsByMessage: Record<string, string[]>,
|
||||
): 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;
|
||||
};
|
||||
Reference in New Issue
Block a user