Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions src/client/UsageIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
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'

Expand All @@ -19,7 +21,7 @@
/** 会话快照选择器(framework 标准套件)。 */
useSession: <S>(selector: (s: ConversationSnapshot) => S) => S
/** chat 快照选择器(framework 标准套件;0.1.2 起节点列表在这里)。 */
useChat?: ChatNodesHook

Check failure on line 24 in src/client/UsageIndicator.tsx

View workflow job for this annotation

GitHub Actions / verify (20)

Cannot find name 'ChatNodesHook'.

Check failure on line 24 in src/client/UsageIndicator.tsx

View workflow job for this annotation

GitHub Actions / verify (22)

Cannot find name 'ChatNodesHook'.
/** 投影读取钩子(framework 标准套件)。 */
useProjection: (key: 'tokenUsage' | 'contextPressure' | 'contextBreakdown') => unknown
sessionId: string
Expand All @@ -27,6 +29,15 @@
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]
Expand Down Expand Up @@ -83,14 +94,24 @@
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)
Expand Down Expand Up @@ -161,7 +182,7 @@
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)
Expand Down
8 changes: 5 additions & 3 deletions src/client/UsagePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 22 additions & 1 deletion src/client/rounds/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)。 */
Expand Down Expand Up @@ -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 }
}
Loading