diff --git a/src/client/UsageIndicator.tsx b/src/client/UsageIndicator.tsx index d84f8e2..d049c62 100644 --- a/src/client/UsageIndicator.tsx +++ b/src/client/UsageIndicator.tsx @@ -10,8 +10,10 @@ import { currencySymbol, useBalance } from './balance.ts' import { useDisplayCurrency } from './currency.ts' import { getUiCopy, useUiLocale } from './i18n.ts' import { resolveCost, usePricing } from './pricing-api.ts' +import { useHistoryRounds } from './rounds/history.ts' import { useObservedRounds } from './rounds/observed.ts' -import { useSessionNodes, type ChatNodesHook, type ConversationNode, type ConversationSnapshot } from './snapshot.ts' +import { sumRoundCosts, type ChartRound } from './rounds/types.ts' +import { snapshotNodes, type ConversationNode, type ConversationSnapshot } from './snapshot.ts' import type { ContextBreakdownData } from './diagnose/context.ts' import { UsagePanel } from './UsagePanel.tsx' @@ -27,6 +29,15 @@ export interface DockUsageProps { input: unknown } +/** 历史折叠的模型归因:0.1.2+ 的快照节点不带 provenance/requestConfig,用最后一轮校准(面板同口径)。 */ +function lastRoundModel(rounds: readonly ChartRound[]): string | undefined { + for (let i = rounds.length - 1; i >= 0; i--) { + const model = rounds[i].model + if (model !== null && model !== '') return model + } + return undefined +} + function deriveModel(nodes: readonly ConversationNode[]): string | undefined { for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i] @@ -83,14 +94,24 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { const totals = useProjection('tokenUsage') as TokenUsageBuckets | undefined const pressure = useProjection('contextPressure') as { pressureTokens?: number; projectedTokens?: number; contextWindow?: number } | undefined const breakdown = useProjection('contextBreakdown') as ContextBreakdownData | undefined - const nodes = useSessionNodes(useChat, useSession) - const model = useMemo(() => deriveModel(nodes), [nodes]) + const nodes = useSession((s) => snapshotNodes(s)) + const history = useHistoryRounds(sessionId) + const model = useMemo(() => deriveModel(nodes) ?? lastRoundModel(history.rounds), [nodes, history.rounds]) const pricing = usePricing() const observedRounds = useObservedRounds(totals, nodes, pricing.table, currency) const { status: balanceStatus, data: balanceData, load: loadBalance } = useBalance(true) const hasTokens = totals !== undefined && (billedInputTokens(totals) > 0 || totals.outputTokens > 0) - const cost = totals !== undefined && pricing.table !== null ? resolveCost(pricing.table, totals, model, Date.now(), currency) : undefined + // 会话总计:优先「Σ 各轮成本」(每轮各自的时段与模型,与每轮徽章天然自洽); + // 历史不可用时才退回「当前时段 × 会话总量」的估算。 + const historyCost = useMemo( + () => (history.status === 'ok' ? sumRoundCosts(history.rounds, currency) : null), + [history.status, history.rounds, currency], + ) + const estimate = totals !== undefined && pricing.table !== null ? resolveCost(pricing.table, totals, model, Date.now(), currency) : undefined + const cost = historyCost !== null + ? { total: historyCost.total, estimated: historyCost.estimated } + : estimate === undefined ? undefined : { total: estimate.split.total, estimated: estimate.estimated } const cacheHit = totals !== undefined ? cacheHitPercent(totals) : null const pressurePct = pressurePercent(pressure) const breakdownTotal = (breakdown?.systemTokens ?? 0) + (breakdown?.toolsTokens ?? 0) + (breakdown?.messageTokens ?? 0) @@ -161,7 +182,7 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { parts.push({ key: 'output', text: `${copy.output} ${formatTokens(totals.outputTokens)}` }) if (cacheHit !== null) parts.push({ key: 'cache', text: `${copy.cache} ${cacheHit}%` }) } - if (cost !== undefined) parts.push({ key: 'cost', text: `${copy.cost} ${cost.estimated ? '≈' : ''}${formatMoney(cost.split.total, currency)}`, estimated: cost.estimated }) + if (cost !== undefined) parts.push({ key: 'cost', text: `${copy.cost} ${cost.estimated ? '≈' : ''}${formatMoney(cost.total, currency)}`, estimated: cost.estimated }) if (model !== undefined) parts.push({ key: 'model', text: model.replace(/^deepseek-/, '') }) const balanceLabel = balanceStatus === 'loading' || (balanceStatus === 'ok' && balance === undefined) diff --git a/src/client/UsagePanel.tsx b/src/client/UsagePanel.tsx index bf0267d..44916b4 100644 --- a/src/client/UsagePanel.tsx +++ b/src/client/UsagePanel.tsx @@ -18,7 +18,7 @@ import { analyzeContext, type ContextBreakdownData } from './diagnose/context.ts import { getUiCopy, type UiLocale } from './i18n.ts' import { resolvePricing, usePricing } from './pricing-api.ts' import { useHistoryRounds } from './rounds/history.ts' -import type { ChartRound } from './rounds/types.ts' +import { sumRoundCosts, type ChartRound } from './rounds/types.ts' export interface ContextPressureView { pressureTokens?: number projectedTokens?: number @@ -99,9 +99,11 @@ export function UsagePanel(props: UsagePanelProps): JSX.Element { if (pricing.table === null) return null return resolvePricing(pricing.table, effectiveModel) }, [pricing.table, effectiveModel]) - // 面板汇总成本:按所选币种的官方刊例价、按高峰/空闲时段计费。时段取 - // 「最近一轮历史的开始时刻」(更贴近实际发生时段;无历史时用当前时刻)。 + // 面板汇总成本:优先「Σ 各轮成本」(每轮各自的时段与模型,与每轮徽章自洽); + // 历史不可用时退回「会话总量 × 刊例价」,时段取最近一轮开始时刻(无历史用当前时刻)。 const costSplitTotal = useMemo(() => { + const summed = historyRounds !== null ? sumRoundCosts(historyRounds, currency) : null + if (summed !== null) return summed if (costView === null) return null const last = historyRounds !== null && historyRounds.length > 0 ? historyRounds[historyRounds.length - 1] : null return costSplitAt(totals, costView.pricing, last?.startedAt ?? Date.now(), currency) diff --git a/src/client/rounds/types.ts b/src/client/rounds/types.ts index 37b06c7..97751b0 100644 --- a/src/client/rounds/types.ts +++ b/src/client/rounds/types.ts @@ -2,7 +2,7 @@ * client 侧轮次数据形状:host `/usage` 的 wire 格式(权威历史) * 与本页观测增量共用同一 ChartRound 形状,图表只认这一个类型。 */ -import type { CostSplit, TokenUsageBuckets } from '../../pricing/calc.ts' +import type { CostCurrency, CostSplit, TokenUsageBuckets } from '../../pricing/calc.ts' import type { CompactionRecord } from '../../usage/compactions.ts' /** 每轮成本(来自 host 折叠,官方 CNY/USD 双币种各一份;观测增量路径为 null)。 */ @@ -45,3 +45,24 @@ export interface UsageResponse { } export type UsageStatus = 'idle' | 'loading' | 'ok' | 'error' + +/** + * Σ 各轮成本(权威历史口径:每轮各自的时段与模型),用于会话汇总—— + * 与每轮徽章天然自洽。任一轮缺成本(观测增量路径)或无轮次时返回 null,调用方回退估算。 + */ +export function sumRoundCosts(rounds: readonly ChartRound[], currency: CostCurrency): (CostSplit & { estimated: boolean }) | null { + if (rounds.length === 0) return null + const sum: CostSplit = { input: 0, cacheRead: 0, output: 0, total: 0 } + let estimated = false + for (const round of rounds) { + const cost = round.cost + if (cost === null) return null + const split = cost[currency] + sum.input += split.input + sum.cacheRead += split.cacheRead + sum.output += split.output + sum.total += split.total + estimated ||= cost.estimated + } + return { ...sum, estimated } +}