diff --git a/src/lib/OverviewPanel.svelte b/src/lib/OverviewPanel.svelte index b2ef751..fce7cb6 100644 --- a/src/lib/OverviewPanel.svelte +++ b/src/lib/OverviewPanel.svelte @@ -1,7 +1,14 @@
@@ -23,21 +59,50 @@
-
{t('settings.overview.detail')}
+
+
{t('settings.overview.detail')}
+ (dim = v as UsageDimension)} + /> +
{#if days.length === 0}

{t('settings.overview.noData')}

{:else} + {#if windowRows.length > 0} +
+
{t('settings.overview.windowTotal', { n: DETAIL_DAYS })}
+ {#each windowRows as [k, v] (k)} +
+ {keyName(k)} + + + + ↑{fmtTokens(v.in)} ↓{fmtTokens(v.out)} · {fmtTokens(v.in + v.out)} +
+ {/each} +
+ {/if} {#each days as [date, d] (date)} + {@const rows = sumDimension([d], dim)}
{date} ↑{fmtTokens(d.in)} ↓{fmtTokens(d.out)} · {t('settings.overview.total')} {fmtTokens(d.in + d.out)} - {#if d.prov && Object.keys(d.prov).length > 0} - {#each provRows(d) as [p, v] (p)} - {provName(p)} ↑{fmtTokens(v.in)} ↓{fmtTokens(v.out)} + {#if rows.length > 0} + {#each rows as [k, v] (k)} + {keyName(k)} ↑{fmtTokens(v.in)} ↓{fmtTokens(v.out)} {/each} {:else} - {t('settings.overview.noProvDetail')} + {t(emptyKey)} {/if}
@@ -69,6 +134,61 @@ .detail { margin-top: 20px; } + .dhead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + } + .dhead .glabel { + margin-bottom: 0; + } + .win { + margin-bottom: 14px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + } + .wtitle { + font-size: 11px; + font-weight: 600; + color: var(--dim2); + margin-bottom: 8px; + } + .wrow { + display: grid; + grid-template-columns: minmax(60px, 160px) 1fr auto; + gap: 10px; + align-items: center; + padding: 3px 0; + } + .wname { + font-size: 12px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .wbar { + height: 6px; + border-radius: 3px; + background: var(--surface2); + box-shadow: inset 0 0 0 1px var(--border); + overflow: hidden; + } + .wfill { + display: block; + height: 100%; + border-radius: 3px; + background: var(--accent); + } + .wval { + font-size: 11px; + color: var(--dim); + white-space: nowrap; + } .drow { display: grid; grid-template-columns: 1fr auto; diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index 098a7ee..c2ccc98 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -776,7 +776,19 @@ export class ChatState { this.totalOut += out; // Estimate cost from tokens when the engine doesn't report it itself. if (!this.#engineCost) this.cost += costUsd(this.model, inn, out); - recordUsage(inn, out, this.provider); + recordUsage(inn, out, { + provider: this.provider, + model: this.model, + // Stable agent keys: native backend ids as-is; acp sessions keyed + // by registry id so renames don't split history. + agent: + this.backendId === 'acp' + ? this.acpAgentId + ? `acp:${this.acpAgentId}` + : 'acp' + : this.backendId, + agentLabel: this.backendId === 'acp' ? this.acpAgentName : undefined + }); // Prefer the active assistant message (jucode reports usage per // message, mid-turn). When it's already reset — e.g. claude reports // one usage at the end of the turn, after the assistant finished — diff --git a/src/lib/i18n/messages/settings.ts b/src/lib/i18n/messages/settings.ts index 5c61890..b523972 100644 --- a/src/lib/i18n/messages/settings.ts +++ b/src/lib/i18n/messages/settings.ts @@ -282,6 +282,12 @@ const settings = { total: '共', other: '其他', noProvDetail: '无 Provider 明细', + noModelDetail: '无模型明细', + noAgentDetail: '无智能体明细', + dimProvider: '渠道', + dimModel: '模型', + dimAgent: '智能体', + windowTotal: '近 {n} 天合计', heatmap: { month: '{n}月', ariaLabel: '每日 Token 用量热力图', @@ -580,6 +586,12 @@ const settings = { total: 'total', other: 'Other', noProvDetail: 'No provider breakdown', + noModelDetail: 'No model breakdown', + noAgentDetail: 'No agent breakdown', + dimProvider: 'Provider', + dimModel: 'Model', + dimAgent: 'Agent', + windowTotal: 'Last {n} days total', heatmap: { month: 'M{n}', ariaLabel: 'Daily token usage heatmap', diff --git a/src/lib/usageStats.test.ts b/src/lib/usageStats.test.ts new file mode 100644 index 0000000..91feb1d --- /dev/null +++ b/src/lib/usageStats.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { DayUsage } from './usageStats'; + +// usageStats 持有模块级内存缓存,因此每个用例都通过 resetModules + 动态导入 +// 拿到一份全新的模块;localStorage 在 node 环境下不存在,这里注入内存实现。 +function makeStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + key: (i: number) => [...store.keys()][i] ?? null, + get length() { + return store.size; + } + } as Storage; +} + +async function fresh(initial?: Record) { + vi.resetModules(); + (globalThis as { localStorage: Storage }).localStorage = makeStorage(initial); + return import('./usageStats'); +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('recordUsage', () => { + it('increments day totals and all three dimension maps', async () => { + const m = await fresh(); + m.recordUsage(100, 20, { provider: 'anthropic', model: 'opus-4.8', agent: 'claude' }); + m.recordUsage(50, 5, { provider: 'anthropic', model: 'opus-4.8', agent: 'claude' }); + const day = m.getDailyUsage()[m.dayKey(new Date())]; + expect(day).toEqual({ + in: 150, + out: 25, + prov: { anthropic: { in: 150, out: 25 } }, + models: { 'opus-4.8': { in: 150, out: 25 } }, + agents: { claude: { in: 150, out: 25 } } + }); + }); + + it('falls back to the other bucket when meta is missing or blank', async () => { + const m = await fresh(); + m.recordUsage(10, 1); + m.recordUsage(5, 2, { provider: ' ', model: '', agent: undefined }); + const day = m.getDailyUsage()[m.dayKey(new Date())]; + expect(day.prov).toEqual({ other: { in: 15, out: 3 } }); + expect(day.models).toEqual({ other: { in: 15, out: 3 } }); + expect(day.agents).toEqual({ other: { in: 15, out: 3 } }); + }); + + it('ignores zero-token events', async () => { + const m = await fresh(); + m.recordUsage(0, 0, { provider: 'openai' }); + expect(m.getDailyUsage()).toEqual({}); + }); + + it('ignores invalid token counts without poisoning valid totals', async () => { + const m = await fresh(); + m.recordUsage(Number.NaN, 4, { provider: 'openai', model: 'gpt-6', agent: 'codex' }); + m.recordUsage(Number.POSITIVE_INFINITY, -2, { + provider: 'invalid', + model: 'invalid', + agent: 'invalid' + }); + const day = m.getDailyUsage()[m.dayKey(new Date())]; + expect(day).toEqual({ + in: 0, + out: 4, + prov: { openai: { in: 0, out: 4 } }, + models: { 'gpt-6': { in: 0, out: 4 } }, + agents: { codex: { in: 0, out: 4 } } + }); + }); + + it('records keys that match object prototype properties', async () => { + const m = await fresh(); + m.recordUsage(10, 1, { provider: 'constructor', model: '__proto__', agent: 'toString' }); + const day = m.getDailyUsage()[m.dayKey(new Date())]; + expect(Object.entries(day.prov!)).toEqual([['constructor', { in: 10, out: 1 }]]); + expect(Object.entries(day.models!)).toEqual([['__proto__', { in: 10, out: 1 }]]); + expect(Object.entries(day.agents!)).toEqual([['toString', { in: 10, out: 1 }]]); + }); + + it('stores agent display labels for stable keys', async () => { + const m = await fresh(); + m.recordUsage(10, 1, { agent: 'acp:gemini-cli', agentLabel: 'Gemini CLI' }); + m.recordUsage(10, 1, { agent: 'jucode' }); + const day = m.getDailyUsage()[m.dayKey(new Date())]; + expect(day.agentLabels).toEqual({ 'acp:gemini-cli': 'Gemini CLI' }); + expect(Object.keys(day.agents!).sort()).toEqual(['acp:gemini-cli', 'jucode']); + }); + + it('persists to localStorage after the debounce window', async () => { + const m = await fresh(); + m.recordUsage(7, 3, { provider: 'openai', model: 'gpt-6', agent: 'codex' }); + vi.advanceTimersByTime(1000); + const raw = localStorage.getItem('jucode-usage-daily'); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw!) as Record; + expect(parsed[m.dayKey(new Date())]).toEqual({ + in: 7, + out: 3, + prov: { openai: { in: 7, out: 3 } }, + models: { 'gpt-6': { in: 7, out: 3 } }, + agents: { codex: { in: 7, out: 3 } } + }); + }); +}); + +describe('load compatibility', () => { + it('parses old v1 days without models or agents', async () => { + const m = await fresh({ + 'jucode-usage-daily': JSON.stringify({ + '2026-01-02': { in: 10, out: 5, prov: { openai: { in: 10, out: 5 } } }, + '2026-01-03': { in: 4, out: 2 } + }) + }); + const usage = m.getDailyUsage(); + expect(usage['2026-01-02']).toEqual({ + in: 10, + out: 5, + prov: { openai: { in: 10, out: 5 } } + }); + expect(usage['2026-01-03']).toEqual({ in: 4, out: 2 }); + }); + + it('ignores malformed day keys and coerces bad values', async () => { + const m = await fresh({ + 'jucode-usage-daily': JSON.stringify({ + 'not-a-day': { in: 99, out: 99 }, + '2026-1-2': { in: 99, out: 99 }, + '2026-01-02': { + in: '1e400', + out: 5, + models: { + m1: { in: 'y', out: 1 }, + ' ': { in: 20, out: 2 }, + invalid: { in: 'Infinity', out: -1 } + }, + agentLabels: { ' acp:x ': ' New name ', '': 'junk', 'acp:y': ' ' } + } + }) + }); + const usage = m.getDailyUsage(); + expect(Object.keys(usage)).toEqual(['2026-01-02']); + expect(usage['2026-01-02']).toEqual({ + in: 0, + out: 5, + models: { m1: { in: 0, out: 1 } }, + agentLabels: { 'acp:x': 'New name' } + }); + }); + + it('starts empty when the stored JSON is corrupt', async () => { + const m = await fresh({ 'jucode-usage-daily': '{oops' }); + expect(m.getDailyUsage()).toEqual({}); + }); +}); + +describe('sumDimension', () => { + it('aggregates a dimension across days sorted by total desc', async () => { + const m = await fresh(); + const days: DayUsage[] = [ + { in: 0, out: 0, models: { a: { in: 10, out: 1 }, b: { in: 1, out: 1 } } }, + { in: 0, out: 0, models: { b: { in: 100, out: 1 } } }, + { in: 0, out: 0 } // 旧数据无该维度:跳过 + ]; + expect(m.sumDimension(days, 'models')).toEqual([ + ['b', { in: 101, out: 2 }], + ['a', { in: 10, out: 1 }] + ]); + }); + + it('returns empty for days without the dimension', async () => { + const m = await fresh(); + expect(m.sumDimension([{ in: 5, out: 5 }], 'agents')).toEqual([]); + }); +}); + +describe('collectAgentLabels', () => { + it('merges labels across days with later days winning', async () => { + const m = await fresh(); + const days: DayUsage[] = [ + { in: 0, out: 0, agentLabels: { 'acp:x': 'Old name' } }, + { in: 0, out: 0 }, + { in: 0, out: 0, agentLabels: { 'acp:x': 'New name', 'acp:y': 'Why' } } + ]; + expect(m.collectAgentLabels(days)).toEqual({ 'acp:x': 'New name', 'acp:y': 'Why' }); + }); +}); diff --git a/src/lib/usageStats.ts b/src/lib/usageStats.ts index 4b9473f..5835479 100644 --- a/src/lib/usageStats.ts +++ b/src/lib/usageStats.ts @@ -3,27 +3,76 @@ const KEY = 'jucode-usage-daily'; const RETENTION_DAYS = 400; -export interface ProviderUsage { +export interface DimUsage { in: number; out: number; } +/** 可拆分的统计维度:渠道(provider)、模型、智能体。 */ +export type UsageDimension = 'prov' | 'models' | 'agents'; + export interface DayUsage { in: number; out: number; - /** 按 provider 拆分的明细;顶层 in/out 恒为各 provider 之和(含旧版无明细的数据)。 */ - prov?: Record; + /** 按渠道(provider)拆分的明细;顶层 in/out 恒为总和(含旧版无明细的数据)。 */ + prov?: Record; + /** 按模型拆分的明细(旧数据没有,缺失属正常)。 */ + models?: Record; + /** 按智能体拆分的明细,key 为 jucode | claude | codex | acp | acp:。 */ + agents?: Record; + /** agent key → 展示名(如 acp: 对应的 ACP agent 名称),仅供 UI 使用。 */ + agentLabels?: Record; +} + +export interface UsageMeta { + provider?: string; + model?: string; + agent?: string; + /** agent key 的展示名;仅在 key 稳定但不适合直接展示时(acp:)需要。 */ + agentLabel?: string; } let cache: Record | null = null; let saveTimer: ReturnType | null = null; +function tokenCount(v: unknown): number { + const n = typeof v === 'number' || typeof v === 'string' ? Number(v) : 0; + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +function ownUsage(map: Record, key: string): DimUsage { + if (Object.prototype.hasOwnProperty.call(map, key)) return map[key]; + const usage = { in: 0, out: 0 }; + Object.defineProperty(map, key, { value: usage, enumerable: true, configurable: true, writable: true }); + return usage; +} + +function setOwnLabel(map: Record, key: string, label: string) { + Object.defineProperty(map, key, { value: label, enumerable: true, configurable: true, writable: true }); +} + export function dayKey(d: Date): string { const m = `${d.getMonth() + 1}`.padStart(2, '0'); const day = `${d.getDate()}`.padStart(2, '0'); return `${d.getFullYear()}-${m}-${day}`; } +function readDim(v: unknown): Record | undefined { + if (!v || typeof v !== 'object') return undefined; + const out: Record = {}; + for (const [rawKey, e] of Object.entries(v as Record | null>)) { + const key = rawKey.trim(); + if (!key) continue; + const inTokens = tokenCount(e?.in); + const outTokens = tokenCount(e?.out); + if (!inTokens && !outTokens) continue; + const usage = ownUsage(out, key); + usage.in += inTokens; + usage.out += outTokens; + } + return Object.keys(out).length ? out : undefined; +} + function load(): Record { if (cache) return cache; cache = {}; @@ -34,11 +83,21 @@ function load(): Record { for (const [k, v] of Object.entries(parsed)) { const d = v as Partial | null; if (!/^\d{4}-\d{2}-\d{2}$/.test(k)) continue; - const day: DayUsage = { in: Number(d?.in) || 0, out: Number(d?.out) || 0 }; - if (d?.prov && typeof d.prov === 'object') { - day.prov = {}; - for (const [p, pv] of Object.entries(d.prov)) - day.prov[p] = { in: Number(pv?.in) || 0, out: Number(pv?.out) || 0 }; + const day: DayUsage = { in: tokenCount(d?.in), out: tokenCount(d?.out) }; + const prov = readDim(d?.prov); + if (prov) day.prov = prov; + const models = readDim(d?.models); + if (models) day.models = models; + const agents = readDim(d?.agents); + if (agents) day.agents = agents; + if (d?.agentLabels && typeof d.agentLabels === 'object') { + const labels: Record = {}; + for (const [rawKey, rawLabel] of Object.entries(d.agentLabels)) { + const key = rawKey.trim(); + const label = typeof rawLabel === 'string' ? rawLabel.trim() : ''; + if (key && label) setOwnLabel(labels, key, label); + } + if (Object.keys(labels).length) day.agentLabels = labels; } cache[k] = day; } @@ -64,17 +123,29 @@ function persist() { }, 800); } -export function recordUsage(inTokens: number, outTokens: number, provider?: string) { - if (!inTokens && !outTokens) return; +/** 空白/缺失的 key 归入 'other' 桶。 */ +function bump(map: Record, key: string | undefined, inT: number, outT: number): string { + const k = key?.trim() || 'other'; + const e = ownUsage(map, k); + e.in += inT; + e.out += outT; + return k; +} + +export function recordUsage(inTokens: number, outTokens: number, meta?: UsageMeta) { + const inT = tokenCount(inTokens); + const outT = tokenCount(outTokens); + if (!inT && !outT) return; const map = load(); const k = dayKey(new Date()); const d = (map[k] ??= { in: 0, out: 0 }); - d.in += inTokens; - d.out += outTokens; - const pkey = provider?.trim() || 'other'; - const p = ((d.prov ??= {})[pkey] ??= { in: 0, out: 0 }); - p.in += inTokens; - p.out += outTokens; + d.in += inT; + d.out += outT; + bump((d.prov ??= {}), meta?.provider, inT, outT); + bump((d.models ??= {}), meta?.model, inT, outT); + const agentKey = bump((d.agents ??= {}), meta?.agent, inT, outT); + const label = meta?.agentLabel?.trim(); + if (label && agentKey !== 'other') setOwnLabel((d.agentLabels ??= {}), agentKey, label); persist(); } @@ -82,5 +153,30 @@ export function getDailyUsage(): Record { return load(); } +/** 将若干天在某一维度上的明细求和,按总量降序返回条目。 */ +export function sumDimension(days: DayUsage[], dim: UsageDimension): [string, DimUsage][] { + const acc: Record = {}; + for (const d of days) { + const m = d[dim]; + if (!m) continue; + for (const [k, v] of Object.entries(m)) { + const e = ownUsage(acc, k); + e.in += v.in; + e.out += v.out; + } + } + return Object.entries(acc).sort((a, b) => b[1].in + b[1].out - (a[1].in + a[1].out)); +} + +/** 汇总各天记录到的 agent 展示名(后出现的覆盖先出现的)。 */ +export function collectAgentLabels(days: DayUsage[]): Record { + const out: Record = {}; + for (const d of days) { + if (!d.agentLabels) continue; + for (const [key, label] of Object.entries(d.agentLabels)) setOwnLabel(out, key, label); + } + return out; +} + export const fmtTokens = (n: number) => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`;