-
{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}`;