diff --git a/apps/desktop/src/main/__tests__/turnCostBroadcaster.test.ts b/apps/desktop/src/main/__tests__/turnCostBroadcaster.test.ts index 752eb0f62ec..bdbed5d7058 100644 --- a/apps/desktop/src/main/__tests__/turnCostBroadcaster.test.ts +++ b/apps/desktop/src/main/__tests__/turnCostBroadcaster.test.ts @@ -4,6 +4,8 @@ * per-turn 费用挂载(MessageActionBar"本轮消耗")的 main 侧业务体: * - recordTurnCostOnMessage:patch 成功才广播;patch false(行不存在)不广播; * costUsd 非法 / 极小直接跳过(绝不写 $0);patch 抛错只吞不传播。 + * - recordTurnUsageOnMessage:算不出报价的轮次只落 turnUsageDetails,不碰任何 + * 金额字段与 scheduler 账本(UI 据此退回显示本轮 token)。 * - codexUsageToTokens:done.data.usage → computeGatewayTurnCost 入参映射 * (reasoning 算 output,与 daily_model_usage 口径一致)。 * @@ -37,6 +39,7 @@ vi.mock('../messagePersistBroadcaster.js', () => ({ import { recordTurnCostOnMessage, recordSchedulerTurnCost, + recordTurnUsageOnMessage, codexUsageToTokens, type TurnCostDeps, type MessageTurnCostPayload, @@ -331,6 +334,137 @@ describe('recordTurnCostOnMessage', () => { }); }); +describe('recordTurnUsageOnMessage', () => { + const USAGE_ARGS = { sessionId: 's1', clientId: 'm1', turnUsageDetails: DETAILS }; + + it('只落 token 明细,不写任何金额字段', async () => { + const { deps, broadcasts, patchCalls, runCostCalls } = makeDeps(true); + await expect(recordTurnUsageOnMessage(USAGE_ARGS, deps)).resolves.toBe(true); + // 账本口径:没有钱就不碰钱。patch 里只能有 turnUsageDetails 一个键。 + expect(patchCalls).toEqual([ + { + sessionId: 's1', + clientId: 'm1', + patch: { turnUsageDetails: DETAILS }, + }, + ]); + // scheduler 费用账本不参与(它只接受真实计费)。 + expect(runCostCalls).toHaveLength(0); + expect(broadcasts).toEqual([ + { sessionId: 's1', clientId: 'm1', turnUsageDetails: DETAILS }, + ]); + }); + + it('广播 payload 不带金额字段 —— 消费方据此走 token 回退', async () => { + const { deps, broadcasts } = makeDeps(true); + await recordTurnUsageOnMessage(USAGE_ARGS, deps); + const [payload] = broadcasts; + expect(payload.turnMoney).toBeUndefined(); + expect(payload.turnCostUsd).toBeUndefined(); + expect(payload.userTurnMoney).toBeUndefined(); + expect(payload.userTurnCostUsd).toBeUndefined(); + }); + + it('明细缺省(整轮 0 token)→ 不落库不广播,绝不写空对象', async () => { + const { deps, broadcasts, patchCalls } = makeDeps(true); + await expect( + recordTurnUsageOnMessage({ sessionId: 's1', clientId: 'm1' }, deps), + ).resolves.toBe(false); + expect(patchCalls).toHaveLength(0); + expect(broadcasts).toHaveLength(0); + }); + + it('sessionId / clientId 缺失 → 直接跳过', async () => { + const { deps, patchCalls } = makeDeps(true); + await expect( + recordTurnUsageOnMessage({ sessionId: '', clientId: 'm1', turnUsageDetails: DETAILS }, deps), + ).resolves.toBe(false); + await expect( + recordTurnUsageOnMessage({ sessionId: 's1', clientId: '', turnUsageDetails: DETAILS }, deps), + ).resolves.toBe(false); + expect(patchCalls).toHaveLength(0); + }); + + it('patch 返回 null(行已被 rewind 删)→ 不广播', async () => { + const { deps, broadcasts } = makeDeps(false); + await expect(recordTurnUsageOnMessage(USAGE_ARGS, deps)).resolves.toBe(false); + expect(broadcasts).toHaveLength(0); + }); + + it('patch 抛错 → 只吞不传播(调用方 fire-and-forget)', async () => { + const { deps, broadcasts } = makeDeps(new Error('db locked')); + await expect(recordTurnUsageOnMessage(USAGE_ARGS, deps)).resolves.toBe(false); + expect(broadcasts).toHaveLength(0); + }); + + // 一次用户请求可能含多个 SDK segment(自动续跑):前面的有真实费用、最后一个缺报价 + // 走 usage-only 时,若只写明细,收尾消息会退回显示 token,把这一轮已经花掉的钱藏起来。 + it('本轮此前已有费用 → 收尾消息带上用户轮累计,而不是退回 token', async () => { + const { deps, broadcasts, patchCalls, runCostCalls } = makeDeps(true, { + money: usdMoney(0.31), + costUsd: 0.31, + hasEstimatedValue: false, + }); + await expect(recordTurnUsageOnMessage(USAGE_ARGS, deps)).resolves.toBe(true); + + expect(patchCalls[0].patch).toEqual({ + turnUsageDetails: DETAILS, + userTurnCost: usdMoney(0.31), + userTurnCostUsd: 0.31, + userTurnCostIsEstimate: false, + }); + // 当前这个无价 segment 依然不记账:没有 turnCost / turnCostUsd,也不碰 scheduler 账本。 + expect(patchCalls[0].patch).not.toHaveProperty('turnCost'); + expect(patchCalls[0].patch).not.toHaveProperty('turnCostUsd'); + expect(runCostCalls).toHaveLength(0); + + expect(broadcasts[0]).toMatchObject({ + userTurnMoney: usdMoney(0.31), + userTurnCostUsd: 0.31, + userTurnCostIsEstimate: false, + turnUsageDetails: DETAILS, + }); + expect(broadcasts[0].turnMoney).toBeUndefined(); + }); + + it('本轮此前的费用是订阅价值估算 → 估算标记一并带上', async () => { + const { deps, patchCalls, broadcasts } = makeDeps(true, { + money: usdMoney(0.2, 'value-estimate'), + costUsd: 0.2, + hasEstimatedValue: true, + }); + await recordTurnUsageOnMessage(USAGE_ARGS, deps); + expect(patchCalls[0].patch).toMatchObject({ userTurnCostIsEstimate: true }); + expect(broadcasts[0]).toMatchObject({ userTurnCostIsEstimate: true }); + }); + + it('本轮此前没有费用 → 只写明细(不写 0 值累计)', async () => { + const { deps, patchCalls, broadcasts } = makeDeps(true); + await recordTurnUsageOnMessage(USAGE_ARGS, deps); + expect(patchCalls[0].patch).toEqual({ turnUsageDetails: DETAILS }); + expect(broadcasts[0].userTurnMoney).toBeUndefined(); + }); + + it('CNY 累计不写 userTurnCostUsd 字段', async () => { + const { deps, patchCalls, broadcasts } = makeDeps(true, { + money: cnyMoney(2.5), + costUsd: 0, + hasEstimatedValue: false, + }); + await recordTurnUsageOnMessage(USAGE_ARGS, deps); + expect(patchCalls[0].patch).not.toHaveProperty('userTurnCostUsd'); + expect(broadcasts[0].userTurnCostUsd).toBeUndefined(); + expect(broadcasts[0].userTurnMoney).toEqual(cnyMoney(2.5)); + }); + + it('读取往轮累计失败 → 不落库不广播(不猜金额)', async () => { + const { deps, patchCalls, broadcasts } = makeDeps(true, new Error('db locked')); + await expect(recordTurnUsageOnMessage(USAGE_ARGS, deps)).resolves.toBe(false); + expect(patchCalls).toHaveLength(0); + expect(broadcasts).toHaveLength(0); + }); +}); + describe('recordSchedulerTurnCost', () => { const schedulerOrigin = { kind: 'scheduler', diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 87ba6a52c42..e8e06adad29 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -290,6 +290,7 @@ import { codexUsageToTokens, recordSchedulerTurnCost, recordTurnCostOnMessage, + recordTurnUsageOnMessage, } from '../turnCostBroadcaster.js'; import { recordModelMismatchOnMessage } from '../modelMismatchBroadcaster.js'; import { detectClaudeModelMismatch } from '../../shared/modelMismatch.js'; @@ -3297,8 +3298,8 @@ export function wireSessionToIpc(session: ReturnType): void estimatedValues.length > 0 ? addRegionalMoney(estimatedValues) : null; + const turnUsageDetails = buildClaudeTurnUsageDetails(doneData?.usage, deltas, 'unknown', perModel); if (turnEstimatedValue && turnEstimatedValue.amount > 0) { - const turnUsageDetails = buildClaudeTurnUsageDetails(doneData?.usage, deltas, 'unknown', perModel); const changedScheduleId = await recordSchedulerTurnCost({ sessionId: session.id, clientId: turnAssistantPersistId, @@ -3307,6 +3308,14 @@ export function wireSessionToIpc(session: ReturnType): void turnOrigin: event.turnOrigin, }); if (changedScheduleId) broadcastSchedulerChanged(changedScheduleId); + } else { + // 真实计费与订阅估值都拿不到(典型:网关目录整体不下发价格、模型不在价表) + // —— 钱没有,但 token 明细是算好的,落下来让 UI 退回显示本轮 token。 + await recordTurnUsageOnMessage({ + sessionId: session.id, + clientId: turnAssistantPersistId, + turnUsageDetails, + }); } } })(); @@ -3314,46 +3323,65 @@ export function wireSessionToIpc(session: ReturnType): void // 窄兜底: 罕见地 done 只带 total_cost_usd、没 modelUsage —— 拆不了 daily_model_usage, // 但至少用累计差把总额 / session / message 记上, 别漏整轮 (review #4)。 const rawDelta = Math.max(0, cumulative - prevReportedCost); - if (rawDelta > 0) { - void (async () => { - let resolvedModel = 'unknown'; - try { - const model = await modelPromise; - resolvedModel = model; - } catch { /* non-fatal: 保留 SDK 原始 cost */ } - // 订阅直连轮(chatgpt/ / xai/)走窄兜底时: 真实计费恒 0, 不写 daily_spend / - // sessions.total_cost_usd(与主路径 resolveTurnCost 的 subscription gate 同口径, - // 避免把订阅 SDK 自报 cost 误记进计费)。 - if (isSubscriptionDirectModel(resolvedModel)) return; - const providerId = getSessionProvider(session.id); - const observedRoute = - providerId == null ? readClaudeSessionRoute(session.id) : null; - const route: BillingRoute = session.remoteHostId - ? 'unknown' - : providerId === 'anthropic' || observedRoute === 'subscription' - ? 'subscription' - : providerId === 'xd' || observedRoute === 'gateway' - ? 'xd-gateway' - : providerId - ? 'provider-api' - : 'unknown'; - if (route === 'subscription' || route === 'xd-gateway') return; - const ledgerCurrency = - (await getGatewayAccountCurrency()) ?? currentLedgerCurrency(); - const money = usdToLedgerCurrency(rawDelta, ledgerCurrency); - const turnUsageDetails = buildClaudeTurnUsageDetails(doneData?.usage, undefined, resolvedModel); - recordTurnSpend(money); - recordSessionTurnSpend(session.id, money); - const changedScheduleId = await recordSchedulerTurnCost({ + void (async () => { + let resolvedModel = 'unknown'; + try { + const model = await modelPromise; + resolvedModel = model; + } catch { /* non-fatal: 保留 SDK 原始 cost */ } + const turnUsageDetails = buildClaudeTurnUsageDetails(doneData?.usage, undefined, resolvedModel); + // 本分支有三个"记不了钱"的出口(本轮 cost 未增长 / 订阅直连 / 订阅与网关路由), + // 账本口径一个字不改,但都把本轮 token 明细落下来 —— 钱算不出来不代表用量 + // 算不出来,UI 那一格据此退回显示 token 而不是空着。 + const recordUsageOnly = async () => { + if (!turnAssistantPersistId) return; + await recordTurnUsageOnMessage({ sessionId: session.id, clientId: turnAssistantPersistId, - money, turnUsageDetails, - turnOrigin: event.turnOrigin, }); - if (changedScheduleId) broadcastSchedulerChanged(changedScheduleId); - })(); - } + }; + if (rawDelta <= 0) { + await recordUsageOnly(); + return; + } + // 订阅直连轮(chatgpt/ / xai/)走窄兜底时: 真实计费恒 0, 不写 daily_spend / + // sessions.total_cost_usd(与主路径 resolveTurnCost 的 subscription gate 同口径, + // 避免把订阅 SDK 自报 cost 误记进计费)。 + if (isSubscriptionDirectModel(resolvedModel)) { + await recordUsageOnly(); + return; + } + const providerId = getSessionProvider(session.id); + const observedRoute = + providerId == null ? readClaudeSessionRoute(session.id) : null; + const route: BillingRoute = session.remoteHostId + ? 'unknown' + : providerId === 'anthropic' || observedRoute === 'subscription' + ? 'subscription' + : providerId === 'xd' || observedRoute === 'gateway' + ? 'xd-gateway' + : providerId + ? 'provider-api' + : 'unknown'; + if (route === 'subscription' || route === 'xd-gateway') { + await recordUsageOnly(); + return; + } + const ledgerCurrency = + (await getGatewayAccountCurrency()) ?? currentLedgerCurrency(); + const money = usdToLedgerCurrency(rawDelta, ledgerCurrency); + recordTurnSpend(money); + recordSessionTurnSpend(session.id, money); + const changedScheduleId = await recordSchedulerTurnCost({ + sessionId: session.id, + clientId: turnAssistantPersistId, + money, + turnUsageDetails, + turnOrigin: event.turnOrigin, + }); + if (changedScheduleId) broadcastSchedulerChanged(changedScheduleId); + })(); } // 与 spend 记账并列的另一个 turn-done side-effect: 刷新 Claude 账号月度配额 // (LiteLLM /v2/user/info)。fire-and-forget, 模块内 2s 超时 + 10s 节流。 @@ -3456,7 +3484,23 @@ export function wireSessionToIpc(session: ReturnType): void // 避免 scheduler 的 Cost 汇总混入订阅价值或远端账号消耗。 // fire-and-forget 不阻塞事件循环;价格表走 main 端内存 + 磁盘缓存, // stale 快返并后台刷新, - // 拉不到 / 模型无条目 → 本轮不显示。 + // 拉不到 / 模型无条目 → 只落 token 明细,UI 退回显示本轮 token。 + // 明细在 try 外构造:它只依赖上面已拿到的 token 数,价格请求抛错时也要能落。 + const turnUsageDetails = buildTurnUsageDetails({ + inputTokens: promptTokens, + outputTokens: completionTokens, + cacheReadTokens: cachedTokens, + cacheCreateTokens: 0, + model: turnModel, + }); + const recordCodexUsageOnly = async () => { + if (!turnAssistantPersistId) return; + await recordTurnUsageOnMessage({ + sessionId: session.id, + clientId: turnAssistantPersistId, + turnUsageDetails, + }); + }; try { const pricing = isSubscriptionValue && !isCodexXaiProviderRoute ? await getModelPricing() @@ -3486,13 +3530,6 @@ export function wireSessionToIpc(session: ReturnType): void cacheCreateTokensDelta: 0, }); } - const turnUsageDetails = buildTurnUsageDetails({ - inputTokens: promptTokens, - outputTokens: completionTokens, - cacheReadTokens: cachedTokens, - cacheCreateTokens: 0, - model: turnModel, - }); if (money && money.amount > 0) { if (!isSubscriptionValue) { void recordTurnSpend(money); @@ -3506,9 +3543,14 @@ export function wireSessionToIpc(session: ReturnType): void turnOrigin: event.turnOrigin, }); if (changedScheduleId) broadcastSchedulerChanged(changedScheduleId); + } else { + await recordCodexUsageOnly(); } } catch { // token row 已在价格请求前落库;价格失败只影响 API cost / message cost。 + // 消息那一格仍要有事实可看:补落一次 token 明细。patch 是 agent_meta merge、 + // 写的又是同一份明细,所以与上面成功分支重复执行也是幂等的(自身失败只 warn)。 + await recordCodexUsageOnly(); } })(); } diff --git a/apps/desktop/src/main/turnCostBroadcaster.ts b/apps/desktop/src/main/turnCostBroadcaster.ts index a2e41f209e0..b067d03edd1 100644 --- a/apps/desktop/src/main/turnCostBroadcaster.ts +++ b/apps/desktop/src/main/turnCostBroadcaster.ts @@ -7,6 +7,13 @@ * { turnCostUsd, turnCostIsEstimate } patch 进 messages.agent_meta(免 migration, * 历史会话重开也能显示),落库成功后广播给所有窗口刷新 MessageActionBar。 * + * 算不出金额的轮次走 recordTurnUsageOnMessage:只落 turnUsageDetails,让 UI 退回 + * 显示本轮 token。没有报价的成因很多(网关目录整体不下发价格、模型不在价表、 + * 订阅轮估值也 miss),但对用户来说"这一格空着"都是同一种坏体验 —— 钱算不出来 + * 不代表用量算不出来,token 明细在这些路径上本就已经算好,只是此前被丢掉了。 + * 该路径不碰任何账本字段(daily_spend / sessions.total_cost_usd / turnCost), + * 没有钱就不记账。 + * * 写库经 messagePersistBroadcaster 的 enqueueDurableWrite 串行 FIFO:该消息的 * createMessage 在 done 同步路径已入队,patch 后入队 → 顺序天然正确(Codex 异步 * 折算晚到也成立)。先落库后广播,保证多窗口 / 后开窗口(走历史加载读 agent_meta) @@ -16,6 +23,7 @@ import { BrowserWindow } from 'electron'; import type { SendOrigin } from '@cindy/maker-core'; +import type { MessageTurnCostPayload } from '../shared/turnCostPayload.js'; import type { TurnUsageDetails } from '../shared/turnUsageDetails.js'; import { patchMessageAgentMetaWithResult, @@ -40,21 +48,10 @@ const log = createLogger('turnCostBroadcaster'); /** IPC channel: main → renderer 推单条消息的 per-turn 费用。 */ export const MESSAGE_TURN_COST_CHANGED = 'usage:message-turn-cost'; -export interface MessageTurnCostPayload { - sessionId: string; - /** 该轮最后一条 assistant 的 messages.client_id。 */ - clientId: string; - turnMoney: RegionalMoney; - turnCostUsd?: number; - turnCostIsEstimate: boolean; - /** User-visible cumulative cost from the latest real user prompt through this message. */ - userTurnMoney: RegionalMoney; - userTurnCostUsd?: number; - /** True when any segment in userTurnCostUsd is a subscription-value estimate. */ - userTurnCostIsEstimate: boolean; - /** 本轮 token/cache 明细;旧消息或取不到 usage 时缺省。 */ - turnUsageDetails?: TurnUsageDetails; -} +// payload 契约的正本在 shared(跨进程协议不放 main —— 否则 renderer 的类型图会反向 +// 依赖 main 实现模块,把 Electron / DB / 调度器副作用拖进 renderer 工具链)。这里再导出 +// 一次,既有 import 路径不变。 +export type { MessageTurnCostPayload } from '../shared/turnCostPayload.js'; /** 测试注入用依赖(patch / broadcast 可替换,免 Electron / sqlite)。 */ export interface TurnCostDeps { @@ -186,6 +183,93 @@ export async function recordTurnCostOnMessage( } } +/** + * 无金额路径:落库 + 广播,外加读一次本用户轮的既有累计(见 + * recordTurnUsageOnMessage 对 userTurnMoney 的处理)。不动 scheduler 账本。 + */ +export type TurnUsageDeps = Pick< + TurnCostDeps, + 'patchAgentMeta' | 'enqueue' | 'broadcast' | 'readPriorUserRoundCost' +>; + +/** + * 算不出金额时,把本轮 token 明细挂到该轮最后一条 assistant 上。 + * + * 与 recordTurnCostOnMessage 的区别是**不为当前 segment 记账**:不写 turnCost / + * turnCostUsd,也不调 applyScheduleRunCostChange —— 账本只接受真实计费,这里提供的是 + * 用量事实。 + * + * 但「本用户轮此前已经产生的费用」必须继续显示。一次用户请求可能含多个 SDK segment + * (自动续跑):前面的 segment 有真实费用、最后一个 segment 缺报价走本函数时,若只写 + * turnUsageDetails,收尾那条消息的操作栏会退回显示 token,把这一轮已经花掉的钱藏起来 + * —— 而 readPriorUserRoundCost 的契约本来就是「让收尾消息承载整轮总额」。所以这里读一次 + * 往轮累计,有则连同 userTurnCost 一起投影到消息与 payload(仍不含当前无价 segment)。 + * + * turnUsageDetails 为空(整轮 0 token)时直接跳过:没有可展示的事实,不写空对象。 + */ +export async function recordTurnUsageOnMessage( + args: { + sessionId: string; + clientId: string; + turnUsageDetails?: TurnUsageDetails | null; + }, + deps: TurnUsageDeps = defaultDeps, +): Promise { + const { sessionId, clientId, turnUsageDetails } = args; + if (!sessionId || !clientId || !turnUsageDetails) return false; + try { + const outcome = await deps.enqueue(`turn-usage:${sessionId}:${clientId}`, async () => { + // 与 recordTurnCostOnMessage 同一条 durable FIFO,所以这里看得到本用户轮此前 + // 每个 segment 已落库的费用。 + const prior = await deps.readPriorUserRoundCost(sessionId, clientId); + const userTurnMoney = + prior.money && prior.money.amount > 0 ? prior.money : null; + const patch: Record = { turnUsageDetails }; + if (userTurnMoney) { + patch.userTurnCost = userTurnMoney; + patch.userTurnCostIsEstimate = prior.hasEstimatedValue; + if (userTurnMoney.currency === 'USD') { + patch.userTurnCostUsd = userTurnMoney.amount; + } + } + const patched = await deps.patchAgentMeta(sessionId, clientId, patch); + if (!patched) return null; + return { userTurnMoney, userTurnCostIsEstimate: prior.hasEstimatedValue }; + }); + if (!outcome) return false; + try { + const { userTurnMoney, userTurnCostIsEstimate } = outcome; + deps.broadcast({ + sessionId, + clientId, + turnUsageDetails, + ...(userTurnMoney + ? { + userTurnMoney, + ...(userTurnMoney.currency === 'USD' + ? { userTurnCostUsd: userTurnMoney.amount } + : {}), + userTurnCostIsEstimate, + } + : {}), + }); + } catch (err) { + // 明细已落库,后开窗口走历史加载仍能读到;广播失败不影响持久事实。 + log.warn( + 'recordTurnUsageOnMessage broadcast failed:', + err instanceof Error ? err.message : String(err), + ); + } + return true; + } catch (err) { + log.warn( + 'recordTurnUsageOnMessage failed:', + err instanceof Error ? err.message : String(err), + ); + return false; + } +} + export interface SchedulerTurnCostFallbackDeps { recordOnMessage: typeof recordTurnCostOnMessage; recordDirect: typeof recordScheduleRunCostDirect; diff --git a/apps/desktop/src/renderer/__tests__/messageTurnCost.test.ts b/apps/desktop/src/renderer/__tests__/messageTurnCost.test.ts index f2695cf9e67..a5c70e4a484 100644 --- a/apps/desktop/src/renderer/__tests__/messageTurnCost.test.ts +++ b/apps/desktop/src/renderer/__tests__/messageTurnCost.test.ts @@ -308,6 +308,64 @@ describe('makerChatStore per-turn 费用', () => { expect(makerChatStore.getSnapshot(SID)).toBe(snap); }); + // 自动续跑的收尾 segment 缺报价时,payload / agent_meta 只有「整轮累计 + token 明细」, + // 没有当前 segment 金额。三个字段互不蕴含(见 shared/turnCostPayload.ts 的不变量), + // 累计金额的读取一旦嵌进 turnMoney 分支,这一轮已经花掉的钱就会被 token 顶掉。 + it('实时推送:无当前分段金额但有用户轮累计时,累计金额照样写入', async () => { + vi.mocked(messageService.list).mockResolvedValueOnce([ + serverMessage({ clientId: 'a-usage-only' }), + ]); + makerChatStore.ensureInitialMessages(SID); + await flush(); + await flush(); + + const cb = getTurnCostCb(); + cb!({ + sessionId: SID, + clientId: 'a-usage-only', + userTurnMoney: legacyMoney(1.25), + userTurnCostUsd: 1.25, + userTurnCostIsEstimate: true, + turnUsageDetails: DETAILS, + }); + + const msg = makerChatStore + .getSnapshot(SID) + .messages.find((m) => m.clientId === 'a-usage-only'); + expect(msg?.turnMoney).toBeUndefined(); + expect(msg?.userTurnMoney).toEqual(legacyMoney(1.25)); + expect(msg?.userTurnCostUsd).toBe(1.25); + expect(msg?.userTurnCostIsEstimate).toBe(true); + expect(msg?.turnUsageDetails).toEqual(DETAILS); + }); + + it('历史加载:只落了用户轮累计的无价收尾轮,重开会话仍恢复累计金额', async () => { + vi.mocked(messageService.list).mockResolvedValueOnce([ + serverMessage({ + clientId: 'a-usage-only-history', + agentMeta: { + userTurnCost: legacyMoney(3.5), + userTurnCostUsd: 3.5, + userTurnCostIsEstimate: false, + turnUsageDetails: DETAILS ?? undefined, + }, + }), + ]); + makerChatStore.ensureInitialMessages(SID); + await flush(); + await flush(); + + const msg = makerChatStore + .getSnapshot(SID) + .messages.find((m) => m.clientId === 'a-usage-only-history'); + expect(msg?.turnMoney).toBeUndefined(); + expect(msg?.userTurnMoney).toEqual(legacyMoney(3.5)); + expect(msg?.userTurnCostUsd).toBe(3.5); + expect(msg?.userTurnCostIsEstimate).toBe(false); + // 明细落库即等价 turn 收尾,action bar 才挂得出来。 + expect(msg?.turnCompleted).toBe(true); + }); + it('实时推送:订阅估算值有 GPT token 明细时按 cache 口径重算', async () => { vi.mocked(messageService.list).mockResolvedValueOnce([ serverMessage({ clientId: 'a-live-estimate' }), diff --git a/apps/desktop/src/renderer/components/chat/MessageActionBar.tsx b/apps/desktop/src/renderer/components/chat/MessageActionBar.tsx index 76652b95cd9..badea008d99 100644 --- a/apps/desktop/src/renderer/components/chat/MessageActionBar.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageActionBar.tsx @@ -51,7 +51,7 @@ import { } from '@/components/ui/dropdown-menu'; import { toast } from '@/lib/toast'; import { formatAbsolute, useRelativeTime } from '@/hooks/useRelativeTime'; -import { formatTurnCostMoney } from '@/lib/usageFormat'; +import { formatCompactTokens, formatTurnCostMoney } from '@/lib/usageFormat'; import { buildTurnUsageTooltipLines } from '@/lib/turnUsageTooltip'; import type { TurnUsageDetails } from '../../../shared/turnUsageDetails'; import { @@ -348,19 +348,20 @@ export function MessageActionBar({ ) ); + // 时间戳之后那一格的排版口径:与 timeText 同为 12px 次要文本 + 0.5px 光学修正; + // bar 的 gap-0.5(2px)对两段相邻文本太挤,额外 6px 左距(共 8px)让「时间 | 用量」 + // 读成两个独立信息组。金额与 token 回退共用同一套 —— 换的是内容,不是位置。 + const metaTextClassName = cn( + 'inline-flex h-[24px] items-center text-[12px] font-normal whitespace-nowrap', + 'relative top-[0.5px]', + 'ml-1.5', + 'text-[var(--settings-section-desc)] cursor-default', + ); + const costText = displayedMoney && displayedMoney.amount > 0 && ( - + {displayedCostIsEstimate ? t('chat.messageActionBar.turnCostEstimatedValue', { cost: formatTurnCostMoney(displayedMoney), @@ -374,6 +375,28 @@ export function MessageActionBar({ ); + // 金额缺席时的回退:显示本轮 token 总量。成因可能是网关目录整体不下发价格、 + // 模型不在价表、或订阅估值也 miss —— 对用户都一样,这一格不该因此空着。 + // 钱算不出来不代表用量算不出来:明细由 main 在 turn 结束时落库,与金额同源。 + const tokensText = !(displayedMoney && displayedMoney.amount > 0) && + turnUsageDetails && + turnUsageDetails.totalTokens > 0 && ( + + + + {t('chat.messageActionBar.turnTokens', { + tokens: formatCompactTokens(turnUsageDetails.totalTokens), + })} + + + + + {buildTurnUsageTooltipLines({ details: turnUsageDetails, t }).join('\n')} + + + + ); + // Edit (Pencil) button — last user message only. Enters the inline edit // state owned by UserMessage; no in-flight state here (entering edit is // instant and side-effect free — commit happens on the edit box's Send). @@ -547,7 +570,7 @@ export function MessageActionBar({ // align='right' → [time][copy][edit][more] (user) const items = align === 'left' - ? [copyBtn, moreMenu, timeText, costText] + ? [copyBtn, moreMenu, timeText, costText || tokensText] : [timeText, copyBtn, editBtn, moreMenu]; return ( diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index ab5808fbe28..0842d921c97 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -588,7 +588,10 @@ export function shouldBlockAssistantFork( function isCompletedAssistantMessage(message: ChatMessage): boolean { return message.turnCompleted === true || (message.turnMoney?.amount ?? 0) > 0 || - (typeof message.turnCostUsd === 'number' && message.turnCostUsd > 0); + (typeof message.turnCostUsd === 'number' && message.turnCostUsd > 0) || + // turnUsageDetails 也只在 turn 结束时 patch(算不出报价的轮次只落它), + // 与费用字段一样是等价的收尾信号 —— 少这一条,无金额轮就挂不出 action bar。 + message.turnUsageDetails !== undefined; } export function collectTurnFinalAssistantClientIds(messages: readonly ChatMessage[]): Set { diff --git a/apps/desktop/src/renderer/components/chat/__tests__/MessageActionBar.test.tsx b/apps/desktop/src/renderer/components/chat/__tests__/MessageActionBar.test.tsx index ac3b760a30f..4fbd58d21b4 100644 --- a/apps/desktop/src/renderer/components/chat/__tests__/MessageActionBar.test.tsx +++ b/apps/desktop/src/renderer/components/chat/__tests__/MessageActionBar.test.tsx @@ -222,4 +222,111 @@ describe('MessageActionBar', () => { ].join('\n'), ); }); + + // 金额缺席时的回退:那一格显示本轮 token,而不是空着(2026-07-30 起网关目录整体 + // 不下发价格,消息底部只剩时间戳)。t 桩忽略插值,所以断言到 key 粒度; + // 数值形态由 maker-shared 的 formatCompactTokens 单测覆盖。 + describe('token fallback when no money is available', () => { + const details = { + inputTokens: 12_400, + outputTokens: 8_900, + cacheReadTokens: 2_000_000, + cacheCreateTokens: 86_400, + totalTokens: 2_107_700, + cacheHitRate: 0.95, + }; + + it('无金额 + 有 token 明细 → 显示 token,tooltip 说明取不到报价', () => { + render( + , + ); + + expect(screen.getByText('chat.messageActionBar.turnTokens')).toBeTruthy(); + const tooltip = screen.getByText( + (_, element) => + element?.classList.contains('whitespace-pre-line') === true && + element.textContent?.includes('usageDetails.tokenLine') === true, + ); + expect(tooltip.textContent).toBe( + [ + 'usageDetails.tokenLine', + 'usageDetails.cacheLine', + 'usageDetails.noBilledCost', + ].join('\n'), + ); + // 没有钱就不出现任何费用文案。 + expect(tooltip.textContent).not.toContain('usageDetails.costLine'); + }); + + it('有金额 → 仍显示金额,不出现 token 回退', () => { + render( + , + ); + + expect(screen.queryByText('chat.messageActionBar.turnTokens')).toBeNull(); + expect(screen.getByText('¥1.50')).toBeTruthy(); + }); + + it('金额为 0 → 视同无金额,走 token 回退(绝不显示 ¥0.00)', () => { + render( + , + ); + + expect(screen.getByText('chat.messageActionBar.turnTokens')).toBeTruthy(); + expect(screen.queryByText('¥0.00')).toBeNull(); + }); + + it('既无金额也无明细 → 那一格不渲染', () => { + render(); + expect(screen.queryByText('chat.messageActionBar.turnTokens')).toBeNull(); + }); + + it('明细存在但 totalTokens 为 0 → 不渲染(没有可展示的事实)', () => { + render( + , + ); + expect(screen.queryByText('chat.messageActionBar.turnTokens')).toBeNull(); + }); + + it('user 侧(align=right)不出现 token 格', () => { + render( + , + ); + expect(screen.queryByText('chat.messageActionBar.turnTokens')).toBeNull(); + }); + }); }); diff --git a/apps/desktop/src/renderer/hooks/useSessionEstimatedValue.ts b/apps/desktop/src/renderer/hooks/useSessionEstimatedValue.ts index b2a4ec8c019..807eb05d4a3 100644 --- a/apps/desktop/src/renderer/hooks/useSessionEstimatedValue.ts +++ b/apps/desktop/src/renderer/hooks/useSessionEstimatedValue.ts @@ -38,7 +38,12 @@ interface EstimatedValueTurnCostPayload { clientId: string; turnMoney?: unknown; turnCostUsd?: number; - turnCostIsEstimate: boolean; + /** + * 无报价轮(main 的 recordTurnUsageOnMessage)只推 turnUsageDetails,整组金额字段 + * 缺省 —— 与 MessageTurnCostPayload 保持一致的可选性。下面的 + * `turnCostIsEstimate !== true` 早退本就把这类轮次挡在估值汇总之外。 + */ + turnCostIsEstimate?: boolean; turnUsageDetails?: unknown; } diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 45f9534a935..8e9e28a7c8a 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -3784,6 +3784,7 @@ "costBreakdownHeader": "By model:", "modelCostLine": "· {{model}} {{cost}}", "suggestionLine": "Suggestion: {{suggestion}}", + "noBilledCost": "Cost unavailable for this turn; showing usage only", "suggestion": { "lowCache": "Cache hit rate is low; more context was billed again this turn" } @@ -5109,6 +5110,7 @@ "turnCost": "Cost this turn", "turnCostEstimated": "Token value this turn (API-price equivalent)", "turnCostEstimatedValue": "Worth {{cost}}", + "turnTokens": "{{tokens}} tokens", "userTurnCostTotalLine": "User-turn total: {{cost}}", "userTurnCostDetailsTitle": "Final SDK segment" }, diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index e40f46bdf2d..322d4964524 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -3782,6 +3782,7 @@ "costBreakdownHeader": "モデル別:", "modelCostLine": "· {{model}} {{cost}}", "suggestionLine": "提案: {{suggestion}}", + "noBilledCost": "このターンの料金は現在取得できないため、使用量のみ表示しています", "suggestion": { "lowCache": "キャッシュヒット率が低く、このターンでは多くのコンテキストが再課金されています" } @@ -5107,6 +5108,7 @@ "turnCost": "このターンの消費", "turnCostEstimated": "このターンの token 価値(API 単価換算)", "turnCostEstimatedValue": "価値 {{cost}}", + "turnTokens": "{{tokens}} tokens", "userTurnCostTotalLine": "このユーザーターンの合計: {{cost}}", "userTurnCostDetailsTitle": "最終 SDK セグメント" }, diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index f02f62154de..4372273fa4b 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -3782,6 +3782,7 @@ "costBreakdownHeader": "모델별:", "modelCostLine": "· {{model}} {{cost}}", "suggestionLine": "제안: {{suggestion}}", + "noBilledCost": "이번 턴의 비용을 현재 확인할 수 없어 사용량만 표시합니다", "suggestion": { "lowCache": "캐시 적중률이 낮아 이번 턴에서 더 많은 컨텍스트가 다시 과금되었습니다" } @@ -5107,6 +5108,7 @@ "turnCost": "이번 턴 비용", "turnCostEstimated": "이번 턴 토큰 가치(API 단가 환산)", "turnCostEstimatedValue": "가치 {{cost}}", + "turnTokens": "{{tokens}} tokens", "userTurnCostTotalLine": "이번 사용자 턴 합계: {{cost}}", "userTurnCostDetailsTitle": "마지막 SDK 세그먼트" }, diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 64f23587b3d..ea27c5956d9 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -3782,6 +3782,7 @@ "costBreakdownHeader": "按模型拆分:", "modelCostLine": "· {{model}} {{cost}}", "suggestionLine": "建议:{{suggestion}}", + "noBilledCost": "本轮费用暂不可用,仅显示用量", "suggestion": { "lowCache": "缓存命中率偏低,本轮较多上下文重新计费" } @@ -5107,6 +5108,7 @@ "turnCost": "本轮消耗", "turnCostEstimated": "本轮 token 价值(按 API 单价折算)", "turnCostEstimatedValue": "价值 {{cost}}", + "turnTokens": "{{tokens}} tokens", "userTurnCostTotalLine": "本轮累计:{{cost}}", "userTurnCostDetailsTitle": "最后一个 SDK 分段" }, diff --git a/apps/desktop/src/renderer/lib/__tests__/turnUsageTooltip.test.ts b/apps/desktop/src/renderer/lib/__tests__/turnUsageTooltip.test.ts index eb4dcdff1fe..788238d62ca 100644 --- a/apps/desktop/src/renderer/lib/__tests__/turnUsageTooltip.test.ts +++ b/apps/desktop/src/renderer/lib/__tests__/turnUsageTooltip.test.ts @@ -136,6 +136,58 @@ describe('buildTurnUsageTooltipLines — 建议行 (只在真正有价值时出 }); }); +describe('buildTurnUsageTooltipLines — 无金额 (token 回退) tooltip', () => { + const details = buildTurnUsageDetails({ + inputTokens: 12_400, + outputTokens: 8_900, + cacheReadTokens: 2_000_000, + cacheCreateTokens: 86_400, + model: 'claude-opus-5', + })!; + + it('不传 money/costUsd → 跳过费用行, 保留 token / 缓存 / 模型行', () => { + const out = buildTurnUsageTooltipLines({ details, t }); + expect(out.some((l) => l.startsWith('usageDetails.costLine'))).toBe(false); + expect(out.some((l) => l.startsWith('usageDetails.valueLine'))).toBe(false); + expect(out.some((l) => l.startsWith('usageDetails.tokenLine'))).toBe(true); + expect(out.some((l) => l.startsWith('usageDetails.cacheLine'))).toBe(true); + expect(out.some((l) => l.startsWith('usageDetails.modelLine'))).toBe(true); + }); + + it('无金额 → 末尾追加「取不到报价」说明, 避免被读成"这轮不花钱"', () => { + const out = buildTurnUsageTooltipLines({ details, t }); + expect(out[out.length - 1]).toBe('usageDetails.noBilledCost'); + }); + + it('有金额 → 不出现该说明行', () => { + expect( + buildTurnUsageTooltipLines({ details, t, costUsd: 0.42 }), + ).not.toContain('usageDetails.noBilledCost'); + expect( + buildTurnUsageTooltipLines({ + details, + t, + money: { amount: 3.5, currency: 'CNY', approximate: false, kind: 'actual-cost' }, + }), + ).not.toContain('usageDetails.noBilledCost'); + }); + + it('金额为 0 / 负 → 视同无金额, 仍给说明行 (绝不显示 $0.00 当事实)', () => { + const zero = buildTurnUsageTooltipLines({ details, t, costUsd: 0 }); + expect(zero).toContain('usageDetails.noBilledCost'); + const negative = buildTurnUsageTooltipLines({ details, t, costUsd: -1 }); + expect(negative).toContain('usageDetails.noBilledCost'); + }); + + // 说明行刻意**不断言原因**:这一层分不清「价格字段缺失」与「显式全 0 的免费模型」, + // 断言"取不到报价"会对后者给出错误解释。 + it('说明行不断言原因 (免费模型与缺价轮共用同一句)', () => { + const out = buildTurnUsageTooltipLines({ details, t }); + expect(out).toContain('usageDetails.noBilledCost'); + expect(out.some((l) => l.includes('priceUnavailable'))).toBe(false); + }); +}); + describe('normalizeTurnUsageDetails — perModelCost 往返 / 清洗', () => { it('合法数组往返, 过滤空 model / cost<=0', () => { const d = normalizeTurnUsageDetails({ diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index 02ce733f318..c699dd8cf89 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -4169,8 +4169,11 @@ function initGlobalListeners(): void { (legacyTurnCostUsd !== undefined ? legacyUsdMoney(legacyTurnCostUsd) : undefined); - if (!turnMoney || !(turnMoney.amount > 0)) return; const { sessionId, clientId } = p; + // 用户轮累计与当前 segment 金额是**两个独立事实**,必须各自判定 —— 一次用户请求 + // 含多个 SDK segment 时,前面的 segment 有真实费用、收尾 segment 缺报价,payload + // 就只有 userTurnMoney + turnUsageDetails。把累计金额的解析放在 turnMoney 分支 + // 之内,这一轮已经花掉的钱会被 token 顶掉(不变量正本见 shared/turnCostPayload.ts)。 const userTurnMoney = normalizeRegionalMoney(p.userTurnMoney) ?? (typeof p.userTurnCostUsd === 'number' && p.userTurnCostUsd > 0 @@ -4180,6 +4183,31 @@ function initGlobalListeners(): void { typeof p.userTurnCostUsd === 'number' && p.userTurnCostUsd > 0 ? p.userTurnCostUsd : undefined; + const userTurnCostPatch = userTurnMoney + ? { + userTurnMoney, + ...(userTurnCostUsd ? { userTurnCostUsd } : {}), + userTurnCostIsEstimate: p.userTurnCostIsEstimate === true, + } + : {}; + // 无当前 segment 金额 = main 的 recordTurnUsageOnMessage(算不出报价的轮次): + // 更新明细 + 可能存在的整轮累计,让 action bar 优先显示已花的钱、否则退回 token。 + // 三者都没有才是真的无事可做。 + if (!turnMoney || !(turnMoney.amount > 0)) { + if (!turnUsageDetails && !userTurnMoney) return; + setState(sessionId, (s) => { + const idx = s.messages.findIndex((m) => m.clientId === clientId); + if (idx < 0) return s; + const msgs = s.messages.slice(); + msgs[idx] = { + ...msgs[idx], + ...userTurnCostPatch, + ...(turnUsageDetails ? { turnUsageDetails } : {}), + }; + return { ...s, messages: msgs }; + }); + return; + } const resolvedTurnCostUsd = turnMoney.currency === 'USD' ? turnMoney.amount @@ -4195,13 +4223,7 @@ function initGlobalListeners(): void { ? { turnCostUsd: resolvedTurnCostUsd } : {}), turnCostIsEstimate, - ...(userTurnMoney - ? { - userTurnMoney, - ...(userTurnCostUsd ? { userTurnCostUsd } : {}), - userTurnCostIsEstimate: p.userTurnCostIsEstimate === true, - } - : {}), + ...userTurnCostPatch, ...(turnUsageDetails ? { turnUsageDetails } : {}), }; return { ...s, messages: msgs }; @@ -10018,6 +10040,26 @@ function mapServerMessages(serverMsgs: Message[]): ChatMessage[] { ? legacyUsdMoney(legacyTurnCostUsd) : undefined) : undefined; + // 用户轮累计与当前 segment 费用各自独立读(不变量正本见 shared/turnCostPayload.ts): + // 收尾 segment 缺报价的轮次只落了 userTurnCost + turnUsageDetails,若把它嵌在 + // persistedTurnMoney > 0 的分支里,重开会话后整轮已花的钱会被 token 顶掉。 + const persistedUserTurnMoney = + m.role === 'assistant' && agentMeta + ? normalizeRegionalMoney(agentMeta.userTurnCost) ?? + (typeof agentMeta.userTurnCostUsd === 'number' && agentMeta.userTurnCostUsd > 0 + ? legacyUsdMoney(agentMeta.userTurnCostUsd) + : undefined) + : undefined; + const persistedUserTurnCostPatch = + agentMeta && persistedUserTurnMoney + ? { + userTurnMoney: persistedUserTurnMoney, + ...(typeof agentMeta.userTurnCostUsd === 'number' && agentMeta.userTurnCostUsd > 0 + ? { userTurnCostUsd: agentMeta.userTurnCostUsd } + : {}), + userTurnCostIsEstimate: agentMeta.userTurnCostIsEstimate === true, + } + : {}; return { clientId: m.clientId, role: m.role, @@ -10028,12 +10070,19 @@ function mapServerMessages(serverMsgs: Message[]): ChatMessage[] { : {}), // SDK done turn seal:新数据用 turnCompleted;存量会话已有 turnCostUsd 的收尾 // assistant 等价可推导,直接补投影让本修复对历史复现会话立即生效。 + // turnUsageDetails 同样是 turn 结束才 patch 的字段(无报价轮只落它),因此 + // 也是等价的收尾信号 —— 少了它,无金额轮重开会话就挂不出 action bar。 ...(m.role === 'assistant' && ( agentMeta?.turnCompleted === true || - (persistedTurnMoney?.amount ?? 0) > 0 + (persistedTurnMoney?.amount ?? 0) > 0 || + turnUsageDetails !== undefined ) ? { turnCompleted: true } : {}), + // 本轮 token 明细独立于金额挂载:算不出报价的轮次只有它,UI 据此退回显示 token。 + ...(m.role === 'assistant' && turnUsageDetails ? { turnUsageDetails } : {}), + // 整轮累计费用同样独立挂载:无价收尾轮只有它,没有 turnCost。 + ...persistedUserTurnCostPatch, // assistant 上挂的 per-turn 费用(main turn 结束时 patch 进 agent_meta) ...(m.role === 'assistant' && agentMeta && @@ -10044,27 +10093,10 @@ function mapServerMessages(serverMsgs: Message[]): ChatMessage[] { persistedTurnMoney.currency === 'USD' ? persistedTurnMoney.amount : undefined; - const persistedUserTurnMoney = - normalizeRegionalMoney(agentMeta.userTurnCost) ?? - (typeof agentMeta.userTurnCostUsd === 'number' && - agentMeta.userTurnCostUsd > 0 - ? legacyUsdMoney(agentMeta.userTurnCostUsd) - : undefined); return { turnMoney: persistedTurnMoney, ...(turnCostUsd !== undefined ? { turnCostUsd } : {}), turnCostIsEstimate: agentMeta.turnCostIsEstimate === true, - ...(persistedUserTurnMoney - ? { - userTurnMoney: persistedUserTurnMoney, - ...(typeof agentMeta.userTurnCostUsd === 'number' && - agentMeta.userTurnCostUsd > 0 - ? { userTurnCostUsd: agentMeta.userTurnCostUsd } - : {}), - userTurnCostIsEstimate: agentMeta.userTurnCostIsEstimate === true, - } - : {}), - ...(turnUsageDetails ? { turnUsageDetails } : {}), }; })() : {}), diff --git a/apps/desktop/src/renderer/lib/turnUsageTooltip.ts b/apps/desktop/src/renderer/lib/turnUsageTooltip.ts index 264e8183b40..16d3c77add9 100644 --- a/apps/desktop/src/renderer/lib/turnUsageTooltip.ts +++ b/apps/desktop/src/renderer/lib/turnUsageTooltip.ts @@ -112,5 +112,9 @@ export function buildTurnUsageTooltipLines({ if (suggestionText) { lines.push(t('usageDetails.suggestionLine', { suggestion: suggestionText })); } + // 无金额时交代一句,避免「只有 token、没有钱」被读成事实缺失;这一层不猜测具体原因。 + if (!formattedCost) { + lines.push(t('usageDetails.noBilledCost')); + } return lines; } diff --git a/apps/desktop/src/renderer/lib/usageFormat.ts b/apps/desktop/src/renderer/lib/usageFormat.ts index f9bc0266f7c..d09ecf861a9 100644 --- a/apps/desktop/src/renderer/lib/usageFormat.ts +++ b/apps/desktop/src/renderer/lib/usageFormat.ts @@ -4,8 +4,14 @@ * 保证两处金额口径 / 文案形态一致。 */ +import { formatCompactTokens } from '@cindy/maker-shared/usage-format'; + import type { RegionalMoney } from '../../shared/regionalMoney'; +// token 紧凑口径由 maker-shared 提供(mobile 消息动作行同源复用),这里只再导出, +// 现有 import 路径不变。 +export { formatCompactTokens }; + /** * 软日限额系数: 月度配额 / 30 * 4.5 = 月度配额 * 0.15。 * 给重度使用的"忙日"留 buffer (即把月配额视作可在 1/4.5 ≈ 6.67 个高强度日内集中消耗)。 @@ -87,11 +93,3 @@ export function formatModelShort(id: string): string { if (/^gpt-/i.test(s)) return s.toUpperCase(); return s || id; } - -/** 紧凑 token 数: ≥1B 用 X.XB, ≥1M 用 X.XM, ≥1k 用 X.Xk, 否则原值。 */ -export function formatCompactTokens(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; - return String(n); -} diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 773bca69522..8d13eff81ad 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -3359,18 +3359,13 @@ interface ElectronAPI { ) => () => void; // per-message 维度: turn 结束后 main 推该轮费用(挂在最后一条 assistant 上)。 + // 直接复用 main 侧的 payload 正本 —— 金额字段整组可选(无报价轮只带 + // turnUsageDetails),两侧各写一份必然漂移:曾出现 main 已放宽为可选、这里仍声明 + // 必填,消费方在 typecheck 通过的情况下解引用 undefined。 onUsageMessageTurnCost: ( - cb: (data: { - sessionId: string; - clientId: string; - turnMoney: import('../shared/regionalMoney').RegionalMoney; - turnCostUsd?: number; - turnCostIsEstimate: boolean; - userTurnMoney: import('../shared/regionalMoney').RegionalMoney; - userTurnCostUsd?: number; - userTurnCostIsEstimate: boolean; - turnUsageDetails?: import('../shared/turnUsageDetails').TurnUsageDetails; - }) => void, + cb: ( + data: import('../shared/turnCostPayload').MessageTurnCostPayload, + ) => void, ) => () => void; // per-message 维度: turn 结束检测到模型被上游降级 / 替换时 main 推标记 diff --git a/apps/desktop/src/shared/turnCostPayload.ts b/apps/desktop/src/shared/turnCostPayload.ts new file mode 100644 index 00000000000..0977e1e1c0c --- /dev/null +++ b/apps/desktop/src/shared/turnCostPayload.ts @@ -0,0 +1,53 @@ +/** + * turnCostPayload — `usage:message-turn-cost` 的跨进程 payload 契约。 + * + * 放在 shared 而不是 main:它是 main → renderer 的协议,两侧都要引用。曾经 main 与 + * preload 声明各写一份,漂移出「main 已放宽为可选、renderer 仍声明必填」的状态,消费方 + * 能在 typecheck 通过的情况下解引用 undefined;而让 renderer 的类型图反向 import main + * 实现模块又会把 Electron / 数据库 / 调度器副作用拖进 renderer 工具链,违反 + * electron-security-and-process-boundaries.md §2 的分层(shared 只存跨进程协议、类型、 + * 常量和纯函数)。收在这里两个问题一起解决。 + */ + +import type { RegionalMoney } from './regionalMoney.js'; +import type { TurnUsageDetails } from './turnUsageDetails.js'; + +/** + * 金额字段整组可选:无报价轮(main 的 recordTurnUsageOnMessage)只带 turnUsageDetails, + * 消费方据此退回 token 展示;若本用户轮此前已产生费用,则额外带 userTurnMoney 累计 + * (当前无价 segment 不入账,但已花的钱要继续可见)。有金额的轮次这些字段成组出现。 + * + * ── 不变量:三个事实各自独立 ──────────────────────────────────────────────── + * `turnMoney`(当前 segment 费用)、`userTurnMoney`(本用户轮累计)、 + * `turnUsageDetails`(本轮 token 用量)是**三个互不蕴含的事实**。合法组合包括 + * 「只有 token」「token + 累计金额」「三者齐全」——「有累计金额但当前 segment 无价」 + * 正是自动续跑的收尾轮:前面的 segment 记了账,收尾那个缺报价。 + * + * 因此每一条消费路径都必须**分别判定**这三个字段,不得把其中一个的解析嵌进另一个 + * 的条件分支里。此前反复踩的就是这一条:把 userTurnMoney 放在 `turnMoney > 0` 之内, + * 于是收尾轮的操作栏用 token 顶掉了整轮已经花掉的钱。 + * + * 对称路径共 5 条(改动其中任意一条时,请逐条核对另外四条): + * 1. Desktop 实时 — renderer/lib/makerChatStore.ts handleUsageMessageTurnCostRaw + * 2. Desktop 历史 — renderer/lib/makerChatStore.ts buildChatMessages + * 3. Mobile 实时 — apps/mobile/src/session/remoteSessionStore.ts usage:message-turn-cost + * 4. Mobile 历史 — apps/mobile/src/session/messageNormalize.ts readTurnCost + * 5. 展示取值 — MessageActionBar(desktop) / MessageRenderer(mobile): + * 金额 = userTurnMoney ?? turnMoney,两者皆无才回退 token + * ───────────────────────────────────────────────────────────────────────── + */ +export interface MessageTurnCostPayload { + sessionId: string; + /** 该轮最后一条 assistant 的 messages.client_id。 */ + clientId: string; + turnMoney?: RegionalMoney; + turnCostUsd?: number; + turnCostIsEstimate?: boolean; + /** User-visible cumulative cost from the latest real user prompt through this message. */ + userTurnMoney?: RegionalMoney; + userTurnCostUsd?: number; + /** True when any segment in userTurnCostUsd is a subscription-value estimate. */ + userTurnCostIsEstimate?: boolean; + /** 本轮 token/cache 明细;旧消息或取不到 usage 时缺省。 */ + turnUsageDetails?: TurnUsageDetails; +} diff --git a/apps/mobile/src/__tests__/messageActions.test.ts b/apps/mobile/src/__tests__/messageActions.test.ts index 66b2b82ea2c..7eb1b838d13 100644 --- a/apps/mobile/src/__tests__/messageActions.test.ts +++ b/apps/mobile/src/__tests__/messageActions.test.ts @@ -7,6 +7,7 @@ import { formatMessageAbsoluteTime, formatMessageRelativeTime, formatMessageTurnCost, + formatMessageTurnTokens, mobileMessageShowsActionBar, } from '@/session/messageActions'; import type { NormalizedRemoteMessage } from '@/session/messageNormalize'; @@ -157,6 +158,18 @@ describe('messageActions', () => { expect(formatMessageTurnCost(money(0.034, 'CNY'))).toBe('¥0.03'); expect(formatMessageTurnCost(money(0))).toBe(''); }); + + // 金额缺席时(桌面算不出模型报价)操作行退回显示本轮 token,数字口径与桌面同源。 + it('formats the per-turn token fallback like the desktop action bar', () => { + expect(formatMessageTurnTokens(2_107_700)).toBe('2.1M tokens'); + expect(formatMessageTurnTokens(12_400)).toBe('12.4k tokens'); + expect(formatMessageTurnTokens(842)).toBe('842 tokens'); + // 没有可展示的事实时给空串,由调用方决定不渲染那一格。 + expect(formatMessageTurnTokens(0)).toBe(''); + expect(formatMessageTurnTokens(-1)).toBe(''); + expect(formatMessageTurnTokens(undefined)).toBe(''); + expect(formatMessageTurnTokens(Number.NaN)).toBe(''); + }); }); function normalizedMessage(overrides: Partial): NormalizedRemoteMessage { diff --git a/apps/mobile/src/__tests__/messageNormalize.test.ts b/apps/mobile/src/__tests__/messageNormalize.test.ts index 3ae5708c3f4..94522e80b68 100644 --- a/apps/mobile/src/__tests__/messageNormalize.test.ts +++ b/apps/mobile/src/__tests__/messageNormalize.test.ts @@ -79,7 +79,10 @@ describe('normalizeRemoteMessages', () => { expect(items[0]).toMatchObject({ kind: 'assistant', turnCostUsd: 0.05, - turnCostIsEstimate: true, + turnMoney: { + approximate: true, + kind: 'value-estimate', + }, }); expect(items[1].turnCostUsd).toBeUndefined(); expect(items[2].turnCostUsd).toBeUndefined(); @@ -87,13 +90,113 @@ describe('normalizeRemoteMessages', () => { turnMoney: { amount: 0.29, currency: 'CNY', + approximate: false, kind: 'actual-cost', }, - turnCostIsEstimate: false, }); expect(items[3].turnCostUsd).toBeUndefined(); }); + // 桌面算不出模型报价的轮次只落 turnUsageDetails:操作行据此退回显示本轮 token, + // 且它同样是 turn 收尾信号(否则那条消息挂不出操作行)。 + it('extracts assistant turn tokens when desktop could not price the turn', () => { + const items = normalizeRemoteMessages([ + message({ + id: 'usage-only', + role: 'assistant', + content: 'answer', + agentMeta: { + turnUsageDetails: { + inputTokens: 12_400, + outputTokens: 8_900, + cacheReadTokens: 2_000_000, + cacheCreateTokens: 86_400, + totalTokens: 2_107_700, + cacheHitRate: 0.95, + }, + }, + }), + message({ + id: 'usage-and-cost', + role: 'assistant', + content: 'answer', + agentMeta: { + turnCostUsd: 0.42, + turnUsageDetails: { totalTokens: 1_000 }, + }, + }), + message({ + id: 'zero-tokens', + role: 'assistant', + content: 'answer', + agentMeta: { turnUsageDetails: { totalTokens: 0 } }, + }), + message({ + id: 'malformed-usage', + role: 'assistant', + content: 'answer', + agentMeta: { turnUsageDetails: 'not-an-object' }, + }), + ]); + + expect(items[0]).toMatchObject({ + kind: 'assistant', + turnTotalTokens: 2_107_700, + // 无报价轮也必须被认成收尾,否则操作行整条不出现。 + turnCompleted: true, + }); + expect(items[0].turnMoney).toBeUndefined(); + // 有钱的轮次两者并存:金额优先展示,token 明细留给 tooltip / 回退判定。 + expect(items[1]).toMatchObject({ turnCostUsd: 0.42, turnTotalTokens: 1_000 }); + expect(items[2].turnTotalTokens).toBeUndefined(); + expect(items[3].turnTotalTokens).toBeUndefined(); + }); + + // 整轮累计与当前 segment 是两个独立事实(不变量正本见 + // apps/desktop/src/shared/turnCostPayload.ts):操作行只挂在收尾正文上,它要承载整轮 + // 总额;收尾 segment 缺报价的轮次更是只有 userTurnCost —— 不读它就会用 token 把已经 + // 花掉的钱顶掉。 + it('prefers the user-round total over the trailing segment cost', () => { + const items = normalizeRemoteMessages([ + message({ + id: 'usage-only-with-total', + role: 'assistant', + content: 'answer', + agentMeta: { + userTurnCost: { amount: 1.25, currency: 'USD', approximate: false, kind: 'actual-cost' }, + userTurnCostUsd: 1.25, + userTurnCostIsEstimate: true, + turnUsageDetails: { totalTokens: 2_100_000 }, + }, + }), + message({ + id: 'total-wins-over-segment', + role: 'assistant', + content: 'answer', + agentMeta: { + turnCostUsd: 0.3, + userTurnCostUsd: 1.8, + userTurnCostIsEstimate: false, + }, + }), + ]); + + // 无当前分段金额,但整轮已经花过钱 → 显示金额,不回退 token。 + expect(items[0]).toMatchObject({ + turnCostUsd: 1.25, + turnTotalTokens: 2_100_000, + turnCompleted: true, + }); + expect(items[0].turnMoney).toMatchObject({ + amount: 1.25, + currency: 'USD', + approximate: true, + kind: 'value-estimate', + }); + // 两者都有时取整轮累计(与桌面 displayedMoney 同口径)。 + expect(items[1].turnCostUsd).toBe(1.8); + }); + it('preserves assistant streaming state from desktop message metadata and content', () => { const items = normalizeRemoteMessages([ message({ diff --git a/apps/mobile/src/__tests__/remoteSessionStore.test.ts b/apps/mobile/src/__tests__/remoteSessionStore.test.ts index 0d3363d6a83..195f4c1d8f9 100644 --- a/apps/mobile/src/__tests__/remoteSessionStore.test.ts +++ b/apps/mobile/src/__tests__/remoteSessionStore.test.ts @@ -2304,6 +2304,38 @@ describe('remoteSessionStore', () => { }); }); + // 无当前 segment 金额、只有整轮累计 + token 明细 = 桌面自动续跑的无价收尾轮。 + // 累计金额必须一起落进 agentMeta,否则操作行会用 token 顶掉这一轮已经花掉的钱 + // (不变量正本见 apps/desktop/src/shared/turnCostPayload.ts)。 + it('keeps the user-round total from usage-only turn cost pushes', () => { + remoteSessionStore.setMessages('s1', [message('m1', 's1')]); + + remoteSessionStore.applyRemotePush('dev-1', 'usage:message-turn-cost', { + sessionId: 's1', + clientId: 'm1', + userTurnMoney: { + amount: 1.25, + currency: 'USD', + approximate: false, + kind: 'actual-cost', + }, + userTurnCostUsd: 1.25, + userTurnCostIsEstimate: true, + turnUsageDetails: { totalTokens: 2_100_000 }, + }); + + const meta = remoteSessionStore.getMessages('s1')[0].agentMeta; + expect(meta).toMatchObject({ + userTurnCost: { amount: 1.25, currency: 'USD' }, + userTurnCostUsd: 1.25, + userTurnCostIsEstimate: true, + turnUsageDetails: { totalTokens: 2_100_000 }, + }); + // 当前 segment 没有报价 → 不写任何 segment 金额字段(不记账)。 + expect(meta?.turnCost).toBeUndefined(); + expect(meta?.turnCostUsd).toBeUndefined(); + }); + it('patches existing messages from realtime model mismatch pushes', () => { remoteSessionStore.setMessages('s1', [message('m1', 's1')]); diff --git a/apps/mobile/src/__tests__/sharedFixtureParity.test.ts b/apps/mobile/src/__tests__/sharedFixtureParity.test.ts index fee075571f1..5658e793adb 100644 --- a/apps/mobile/src/__tests__/sharedFixtureParity.test.ts +++ b/apps/mobile/src/__tests__/sharedFixtureParity.test.ts @@ -42,7 +42,10 @@ describe('shared remote-control fixture parity', () => { .toBe(JSON.stringify({ ok: true, message: 'Lead replied: continue with UI parity.' })); expect(normalized.find((message) => message.key === 'raw-final')).toMatchObject({ turnCostUsd: 0.042, - turnCostIsEstimate: true, + turnMoney: { + approximate: true, + kind: 'value-estimate', + }, }); expect(normalized.find((message) => message.key === 'raw-ask-answered')?.body) .toBe('Q: Deploy?\nA: yes\n\nQ: Notify?\nA: (skipped)'); diff --git a/apps/mobile/src/i18n/locales/en/message.json b/apps/mobile/src/i18n/locales/en/message.json index cdb80cadd14..6dfce92a5bc 100644 --- a/apps/mobile/src/i18n/locales/en/message.json +++ b/apps/mobile/src/i18n/locales/en/message.json @@ -12,6 +12,7 @@ "sentTime": "Sent at {{time}}", "linkCopied": "Link copied", "turnCostEstimate": "This turn {{cost}}", + "turnTokens": "This turn used {{tokens}}", "turnCost": "This turn cost {{cost}}", "messageGenerating": "Message is generating", "generating": "Generating", @@ -121,7 +122,8 @@ "justNow": "Just now", "minutesAgo": "{{n}} min ago", "hoursAgo": "{{n}} h ago", - "turnCostValue": "Est. {{value}}" + "turnCostValue": "Est. {{value}}", + "turnTokens": "{{tokens}} tokens" }, "lightbox": { "cancelAnnotation": "Cancel annotation", diff --git a/apps/mobile/src/i18n/locales/ja/message.json b/apps/mobile/src/i18n/locales/ja/message.json index a71138d1b8b..42ca4701edd 100644 --- a/apps/mobile/src/i18n/locales/ja/message.json +++ b/apps/mobile/src/i18n/locales/ja/message.json @@ -12,6 +12,7 @@ "sentTime": "送信時刻 {{time}}", "linkCopied": "リンクをコピーしました", "turnCostEstimate": "今回 {{cost}}", + "turnTokens": "今回の消費 {{tokens}}", "turnCost": "今回の消費 {{cost}}", "messageGenerating": "メッセージを生成中", "generating": "生成中", @@ -121,7 +122,8 @@ "justNow": "たった今", "minutesAgo": "{{n}} 分前", "hoursAgo": "{{n}} 時間前", - "turnCostValue": "概算 {{value}}" + "turnCostValue": "概算 {{value}}", + "turnTokens": "{{tokens}} tokens" }, "lightbox": { "cancelAnnotation": "注釈をキャンセル", diff --git a/apps/mobile/src/i18n/locales/ko/message.json b/apps/mobile/src/i18n/locales/ko/message.json index aabe3ec70df..ea653124639 100644 --- a/apps/mobile/src/i18n/locales/ko/message.json +++ b/apps/mobile/src/i18n/locales/ko/message.json @@ -12,6 +12,7 @@ "sentTime": "보낸 시간 {{time}}", "linkCopied": "링크를 복사했습니다", "turnCostEstimate": "이번 턴 {{cost}}", + "turnTokens": "이번 턴 소비 {{tokens}}", "turnCost": "이번 턴 소비 {{cost}}", "messageGenerating": "메시지 생성 중", "generating": "생성 중", @@ -121,7 +122,8 @@ "justNow": "방금", "minutesAgo": "{{n}}분 전", "hoursAgo": "{{n}}시간 전", - "turnCostValue": "예상 {{value}}" + "turnCostValue": "예상 {{value}}", + "turnTokens": "{{tokens}} tokens" }, "lightbox": { "cancelAnnotation": "주석 취소", diff --git a/apps/mobile/src/i18n/locales/zh-CN/message.json b/apps/mobile/src/i18n/locales/zh-CN/message.json index f23a50e7bab..0f789ebbdbe 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/message.json +++ b/apps/mobile/src/i18n/locales/zh-CN/message.json @@ -12,6 +12,7 @@ "sentTime": "发送时间 {{time}}", "linkCopied": "链接已复制", "turnCostEstimate": "本轮{{cost}}", + "turnTokens": "本轮消耗 {{tokens}}", "turnCost": "本轮消耗 {{cost}}", "messageGenerating": "消息正在生成", "generating": "生成中", @@ -121,7 +122,8 @@ "justNow": "刚刚", "minutesAgo": "{{n}} 分钟前", "hoursAgo": "{{n}} 小时前", - "turnCostValue": "价值 {{value}}" + "turnCostValue": "价值 {{value}}", + "turnTokens": "{{tokens}} tokens" }, "lightbox": { "cancelAnnotation": "取消标注", diff --git a/apps/mobile/src/session/MessageRenderer.tsx b/apps/mobile/src/session/MessageRenderer.tsx index 17999478716..5fef0f44ac5 100644 --- a/apps/mobile/src/session/MessageRenderer.tsx +++ b/apps/mobile/src/session/MessageRenderer.tsx @@ -140,6 +140,7 @@ import { formatMessageAbsoluteTime, formatMessageRelativeTime, formatMessageTurnCost, + formatMessageTurnTokens, formatModelShortLabel, mobileMessageShowsActionBar, writeClipboardText, @@ -1580,6 +1581,10 @@ function MessageBubble({ const turnCost = showCompletedActionBar && item.message.kind === 'assistant' ? formatMessageTurnCost(item.message.turnMoney) : ''; + // 金额缺席时退回显示本轮 token(桌面算不出模型报价的轮次):这一格不留空。 + const turnTokens = !turnCost && showCompletedActionBar && item.message.kind === 'assistant' + ? formatMessageTurnTokens(item.message.turnTotalTokens) + : ''; const canFork = !!( showCompletedActionBar && clientId @@ -1663,9 +1668,10 @@ function MessageBubble({ canCopy, hasMoreActions: messageMenu.length > 0, hasTime: !!relativeTime, - hasTurnCost: !!turnCost, + // 金额与 token 回退占同一格,任一有值就保留该位置。 + hasTurnCost: !!turnCost || !!turnTokens, isStreaming: isStreamingAssistant, - }), [canCopy, isStreamingAssistant, isUser, messageMenu.length, relativeTime, turnCost]); + }), [canCopy, isStreamingAssistant, isUser, messageMenu.length, relativeTime, turnCost, turnTokens]); const hasActions = actionBar.items.length > 0; const actionBusy = !!clientId && actions.busyClientId === clientId; const disabled = !!actions.busyClientId; @@ -1744,13 +1750,24 @@ function MessageBubble({ ) : null; const costText = turnCost ? ( {turnCost} + ) : turnTokens ? ( + + {turnTokens} + ) : null; const streamingStatus = isStreamingAssistant ? ( 0 + (turnCost.turnMoney?.amount ?? 0) > 0 || + // 无报价轮只落 turnUsageDetails,它同样只在 turn 结束时写入,等价收尾信号。 + turnCost.turnTotalTokens !== undefined ) ? { turnCompleted: true } : {}), @@ -697,33 +703,71 @@ function readTimestamp(value: unknown): number | null { return Number.isFinite(timestamp) ? timestamp : null; } -function readTurnCost( - message: RemoteMessage, -): Pick { - if (message.role !== 'assistant') return {}; - const money = normalizeRemoteMoney(message.agentMeta?.turnCost); - if (money && money.amount > 0) { +/** + * agentMeta 里一对「金额 + 旧版 USD 数字 + 估算标记」→ 操作行可显示的金额投影。 + * 用户轮累计与当前 segment 走同一个实现,免得两处各写一份判据后漂移。 + */ +function projectTurnMoney( + money: unknown, + legacyUsd: unknown, + isEstimateFlag: boolean, +): Pick | null { + const normalized = normalizeRemoteMoney(money); + if (normalized && normalized.amount > 0) { + const isEstimate = isEstimateFlag || normalized.kind === 'value-estimate'; + // NormalizedRemoteMessage is a display projection, not the accounting record. Fold the + // separate wire flag into turnMoney so visible text and accessibility cannot choose + // different estimate semantics for mixed actual-cost + value-estimate user-turn totals. + const displayMoney: RemoteMoney = isEstimate + ? { ...normalized, approximate: true, kind: 'value-estimate' } + : normalized; return { - turnMoney: money, - ...(money.currency === 'USD' ? { turnCostUsd: money.amount } : {}), - turnCostIsEstimate: money.kind === 'value-estimate', + turnMoney: displayMoney, + ...(displayMoney.currency === 'USD' ? { turnCostUsd: displayMoney.amount } : {}), }; } - const cost = readNumber(message.agentMeta?.turnCostUsd); - if (cost === null || cost <= 0) return {}; - const isEstimate = message.agentMeta?.turnCostIsEstimate === true; + const cost = readNumber(legacyUsd); + if (cost === null || cost <= 0) return null; return { turnMoney: { amount: cost, currency: 'USD', - approximate: isEstimate, - kind: isEstimate ? 'value-estimate' : 'actual-cost', + approximate: isEstimateFlag, + kind: isEstimateFlag ? 'value-estimate' : 'actual-cost', }, turnCostUsd: cost, - turnCostIsEstimate: isEstimate, }; } +function readTurnCost( + message: RemoteMessage, +): Pick< + NormalizedRemoteMessage, + 'turnMoney' | 'turnCostUsd' | 'turnTotalTokens' +> { + if (message.role !== 'assistant') return {}; + // 用量与金额分开读:桌面算不出报价的轮次只落 turnUsageDetails,操作行退回显示 token。 + const totalTokens = readNumber(readRecord(message.agentMeta?.turnUsageDetails)?.totalTokens); + const usage: Pick = + totalTokens !== null && totalTokens > 0 ? { turnTotalTokens: totalTokens } : {}; + // 整轮累计优先于当前 segment(与桌面 MessageActionBar 的 displayedMoney 同口径): + // 一次用户请求含多个自动续跑 segment 时,操作行只挂在收尾正文上,而它要承载整轮总额; + // 收尾 segment 缺报价的轮次更是只有 userTurnCost。两者独立判定,不互为前提 + // (不变量正本见 apps/desktop/src/shared/turnCostPayload.ts)。 + const projected = + projectTurnMoney( + message.agentMeta?.userTurnCost, + message.agentMeta?.userTurnCostUsd, + message.agentMeta?.userTurnCostIsEstimate === true, + ) ?? + projectTurnMoney( + message.agentMeta?.turnCost, + message.agentMeta?.turnCostUsd, + message.agentMeta?.turnCostIsEstimate === true, + ); + return projected ? { ...usage, ...projected } : usage; +} + // 桌面 main 在 turn 结束检测到模型被上游降级时写 agentMeta.modelMismatch = // { selected, actual }(modelMismatchBroadcaster);字段不全的一律忽略。 function readModelMismatch(message: RemoteMessage): Pick { diff --git a/apps/mobile/src/session/messageRenderReconcile.ts b/apps/mobile/src/session/messageRenderReconcile.ts index a6bedbc4bad..f5fe3b815f5 100644 --- a/apps/mobile/src/session/messageRenderReconcile.ts +++ b/apps/mobile/src/session/messageRenderReconcile.ts @@ -188,7 +188,6 @@ function normalizedMessageEqual( && previous.createdAt === next.createdAt && previous.isStreaming === next.isStreaming && previous.turnCostUsd === next.turnCostUsd - && previous.turnCostIsEstimate === next.turnCostIsEstimate && previous.toolSettled === next.toolSettled && previous.isTurnFinalAssistant === next.isTurnFinalAssistant && previous.isSyntheticTrigger === next.isSyntheticTrigger diff --git a/apps/mobile/src/session/remoteSessionStore.ts b/apps/mobile/src/session/remoteSessionStore.ts index f771baab08d..b96b067d4d2 100644 --- a/apps/mobile/src/session/remoteSessionStore.ts +++ b/apps/mobile/src/session/remoteSessionStore.ts @@ -2052,17 +2052,47 @@ export const remoteSessionStore = { const clientId = readString(payload, 'clientId'); const turnMoney = normalizeRemoteMoney(payload.turnMoney); const turnCostUsd = readNumber(payload, 'turnCostUsd'); + // 用量明细与金额各自独立:桌面算不出报价时只推 turnUsageDetails,操作行据此 + // 退回显示本轮 token(与 messageNormalize.readTurnCost 同口径)。 + const turnUsageDetails = isRecord(payload.turnUsageDetails) + ? payload.turnUsageDetails + : undefined; + // 用户轮累计与当前 segment 金额是两个独立事实:自动续跑的收尾轮只带 userTurnMoney + // (前面的 segment 记了账、收尾这个缺报价)。所以独立投影,由 + // messageNormalize.readTurnCost 单点决定操作行显示哪一个 + // (不变量正本见 apps/desktop/src/shared/turnCostPayload.ts)。 + const userTurnMoney = normalizeRemoteMoney(payload.userTurnMoney); + const userTurnCostUsd = readNumber(payload, 'userTurnCostUsd'); + const userTurnCostIsEstimate = payload.userTurnCostIsEstimate === true; + const basePatch: Record = { + ...(turnUsageDetails ? { turnUsageDetails } : {}), + }; + if (userTurnMoney && userTurnMoney.amount > 0) { + basePatch.userTurnCost = userTurnMoney; + if (userTurnMoney.currency === 'USD') { + basePatch.userTurnCostUsd = userTurnMoney.amount; + } + basePatch.userTurnCostIsEstimate = + userTurnCostIsEstimate || userTurnMoney.kind === 'value-estimate'; + } else if (userTurnCostUsd !== null && userTurnCostUsd > 0) { + basePatch.userTurnCostUsd = userTurnCostUsd; + basePatch.userTurnCostIsEstimate = userTurnCostIsEstimate; + } if (sessionId && clientId && turnMoney && turnMoney.amount > 0) { this.patchMessageAgentMeta(sessionId, clientId, { + ...basePatch, turnCost: turnMoney, ...(turnMoney.currency === 'USD' ? { turnCostUsd: turnMoney.amount } : {}), turnCostIsEstimate: turnMoney.kind === 'value-estimate', }); } else if (sessionId && clientId && turnCostUsd !== null && turnCostUsd > 0) { this.patchMessageAgentMeta(sessionId, clientId, { + ...basePatch, turnCostUsd, turnCostIsEstimate: payload.turnCostIsEstimate === true, }); + } else if (sessionId && clientId && Object.keys(basePatch).length > 0) { + this.patchMessageAgentMeta(sessionId, clientId, basePatch); } return; } diff --git a/packages/maker-shared/package.json b/packages/maker-shared/package.json index a9c929e661b..77130b5f491 100644 --- a/packages/maker-shared/package.json +++ b/packages/maker-shared/package.json @@ -57,6 +57,7 @@ "./system-card": "./src/systemCard.ts", "./thinking-text": "./src/thinkingText.ts", "./tool-use-descriptor": "./src/toolUseDescriptor.ts", + "./usage-format": "./src/usageFormat.ts", "./work-activity-projection": "./src/workActivityProjection.ts", "./worktree-paths": "./src/worktreePaths.ts", "./codex-usage-buckets": "./src/codexUsageBuckets.ts" diff --git a/packages/maker-shared/src/__tests__/usageFormat.test.ts b/packages/maker-shared/src/__tests__/usageFormat.test.ts new file mode 100644 index 00000000000..f9860755e42 --- /dev/null +++ b/packages/maker-shared/src/__tests__/usageFormat.test.ts @@ -0,0 +1,47 @@ +/** + * usageFormat.test.ts + * --------------------------------------------------------------------------- + * token 紧凑口径 —— desktop 消息动作行与 mobile 操作行共用这一份, + * 同一轮在两端必须读到同一个数字。 + */ + +import { describe, expect, it } from 'vitest'; + +import { formatCompactTokens } from '../usageFormat'; + +describe('formatCompactTokens', () => { + it('小于 1k 原样输出', () => { + expect(formatCompactTokens(0)).toBe('0'); + expect(formatCompactTokens(1)).toBe('1'); + expect(formatCompactTokens(999)).toBe('999'); + }); + + it('千位档一位小数', () => { + expect(formatCompactTokens(1000)).toBe('1.0k'); + expect(formatCompactTokens(12_400)).toBe('12.4k'); + expect(formatCompactTokens(999_949)).toBe('999.9k'); + }); + + it('百万档一位小数', () => { + expect(formatCompactTokens(1_000_000)).toBe('1.0M'); + expect(formatCompactTokens(2_107_700)).toBe('2.1M'); + }); + + it('十亿档一位小数(重度会话的 cache read 会到这个量级)', () => { + expect(formatCompactTokens(1_000_000_000)).toBe('1.0B'); + expect(formatCompactTokens(9_290_698_420)).toBe('9.3B'); + }); + + // 舍入不得跨档:999_999 曾输出 "1000.0k"(量级已是 M、单位还停在 k),自相矛盾。 + it('舍入达到 1000.0 时进到上一档', () => { + expect(formatCompactTokens(999_999)).toBe('1.0M'); + expect(formatCompactTokens(999_950)).toBe('1.0M'); + expect(formatCompactTokens(999_999_999)).toBe('1.0B'); + // 恰好在阈值下方仍留在本档。 + expect(formatCompactTokens(999_949_999)).toBe('999.9M'); + }); + + it('超出最大档继续用 B 表达', () => { + expect(formatCompactTokens(1_000_000_000_000)).toBe('1000.0B'); + }); +}); diff --git a/packages/maker-shared/src/usageFormat.ts b/packages/maker-shared/src/usageFormat.ts new file mode 100644 index 00000000000..c41345ba4fa --- /dev/null +++ b/packages/maker-shared/src/usageFormat.ts @@ -0,0 +1,31 @@ +/** + * usageFormat — 用量数字展示的共享口径(desktop 与 mobile 共用一份)。 + * + * 消息底部那一格在拿不到金额时退回显示本轮 token,两端必须给出同一个数字形态 + * —— 各写一份必然漂移(同一轮在桌面读作 2.1M、在手机读作 2,097k 就无从核对)。 + */ + +/** 从小到大:命中第一个「四舍五入后仍不满 1000」的单位。 */ +const TOKEN_UNITS: ReadonlyArray<{ divisor: number; suffix: string }> = [ + { divisor: 1_000, suffix: 'k' }, + { divisor: 1_000_000, suffix: 'M' }, + { divisor: 1_000_000_000, suffix: 'B' }, +]; + +/** + * toFixed(1) 对 ≥999.95 会舍入成 "1000.0" —— 那已经是上一档的量级, + * 单位却还停在小档(999_999 → "1000.0k"),读起来自相矛盾。命中该阈值就进档。 + */ +const UNIT_CARRY_THRESHOLD = 999.95; + +/** 紧凑 token 数: ≥1B 用 X.XB, ≥1M 用 X.XM, ≥1k 用 X.Xk, 否则原值。 */ +export function formatCompactTokens(n: number): string { + if (n < TOKEN_UNITS[0].divisor) return String(n); + for (const { divisor, suffix } of TOKEN_UNITS) { + const scaled = n / divisor; + if (scaled < UNIT_CARRY_THRESHOLD) return `${scaled.toFixed(1)}${suffix}`; + } + // 超出最大单位(≥ ~1000B)只能继续用 B 表达。 + const largest = TOKEN_UNITS[TOKEN_UNITS.length - 1]; + return `${(n / largest.divisor).toFixed(1)}${largest.suffix}`; +}