diff --git a/app/api/usage/route.ts b/app/api/usage/route.ts new file mode 100644 index 00000000..2d1b4c02 --- /dev/null +++ b/app/api/usage/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { aggregateUsage, scanUsage, type UsageRange } from "@/lib/usage-store"; + +const RANGES: UsageRange[] = ["today", "7d", "30d", "all"]; + +export async function GET(req: Request) { + try { + const param = new URL(req.url).searchParams.get("range"); + const range: UsageRange = RANGES.includes(param as UsageRange) ? (param as UsageRange) : "7d"; + await scanUsage(); + return NextResponse.json(aggregateUsage(range)); + } catch (error) { + return NextResponse.json( + { error: String(error) }, + { status: 500 } + ); + } +} diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index 0514b55e..36f16354 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -5,6 +5,7 @@ import type { SessionInfo } from "@/lib/types"; import { useI18n } from "@/hooks/useI18n"; import { DirectoryPicker } from "./DirectoryPicker"; import { FileExplorer, type FileExplorerHandle } from "./FileExplorer"; +import { UsageStats } from "./UsageStats"; declare global { interface Window { @@ -394,6 +395,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio const [projectFilter, setProjectFilter] = useState(""); const [wtFilter, setWtFilter] = useState(""); const [customPathOpen, setCustomPathOpen] = useState(false); + const [usageOpen, setUsageOpen] = useState(false); const [customPathValue, setCustomPathValue] = useState(""); const [customPathError, setCustomPathError] = useState(null); const [customPathValidating, setCustomPathValidating] = useState(false); @@ -872,6 +874,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio onSelect={(path) => void commitCustomPath(path)} /> )} + {usageOpen && setUsageOpen(false)} />} {/* Header */}
+ +
+ + {/* Range tabs */} +
+ {RANGES.map((r) => { + const active = r === range; + return ( + + ); + })} +
+ + {/* Body */} +
+ {loading && !report ? ( +
{t("usage.loading")}
+ ) : error ? ( +
{t("usage.error")}
+ ) : !report || report.models.length === 0 ? ( +
+ {t("usage.empty", { date: installedDate })} +
+ ) : ( + <> + {/* Summary cards */} +
+ {card(t("usage.totalCost"), formatCost(totals!.cost))} + {card(t("usage.totalTokens"), formatTokens(totalTokens))} + {card(t("usage.messages"), String(totals!.messages))} + {card(t("usage.sessions"), String(totals!.sessions))} +
+ + {/* Daily cost bars */} + {report.daily.length > 1 && ( +
+
+ {t("usage.daily")} +
+
+ {report.daily.map((d) => ( +
0 ? Math.max((d.cost / maxDailyCost) * 100, 4) : 4}%`, + background: d.cost > 0 ? "var(--accent)" : "var(--border)", + opacity: d.cost > 0 ? 0.85 : 0.5, + }} + /> + ))} +
+
+ )} + + {/* Per-model table */} +
+
+ {t("usage.model")} + {t("session.input")} + {t("session.output")} + {t("session.cacheRead")} + {t("session.cacheWrite")} + {t("session.cost")} + {t("usage.share")} +
+ {report.models.map((m) => { + const share = totals!.cost > 0 ? m.cost / totals!.cost : 0; + return ( +
+ + {m.provider}/{m.model} + + {formatTokens(m.input)} + {formatTokens(m.output)} + {formatTokens(m.cacheRead)} + {formatTokens(m.cacheWrite)} + {formatCost(m.cost)} + + + + + + {(share * 100).toFixed(0)}% + + +
+ ); + })} +
+ +
{t("usage.disclaimer")}
+ + )} +
+
+
+ ); +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index a5dedf8f..16566c2f 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -53,6 +53,24 @@ export const enLocale: LocalePlugin = { "session.tokens": "Tokens", "session.copyFile": "Copy file path", "session.copyId": "Copy session ID", + "usage.title": "Usage", + "usage.buttonTitle": "Usage & cost stats", + "usage.since": "tracking since {date}", + "usage.range.today": "Today", + "usage.range.7d": "7 days", + "usage.range.30d": "30 days", + "usage.range.all": "All", + "usage.totalCost": "Total cost", + "usage.totalTokens": "Total tokens", + "usage.messages": "Messages", + "usage.sessions": "Sessions", + "usage.daily": "Daily cost", + "usage.model": "Model", + "usage.share": "Share", + "usage.loading": "Loading...", + "usage.error": "Failed to load usage stats", + "usage.empty": "No usage recorded yet — tracking started on {date}", + "usage.disclaimer": "Costs are estimates recorded by pi for each message. Only usage after tracking started is counted.", "workspace.opening": "Opening workspace...", "workspace.unable": "Unable to open workspace", "workspace.selectSession": "Select a session from the sidebar", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 878d9603..5fda3c6f 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -53,6 +53,24 @@ export const zhCNLocale: LocalePlugin = { "session.tokens": "Token", "session.copyFile": "复制文件路径", "session.copyId": "复制会话 ID", + "usage.title": "用量", + "usage.buttonTitle": "用量与费用统计", + "usage.since": "自 {date} 起记录", + "usage.range.today": "今天", + "usage.range.7d": "近 7 天", + "usage.range.30d": "近 30 天", + "usage.range.all": "全部", + "usage.totalCost": "总费用", + "usage.totalTokens": "总 Token", + "usage.messages": "消息", + "usage.sessions": "会话", + "usage.daily": "每日费用", + "usage.model": "模型", + "usage.share": "占比", + "usage.loading": "加载中...", + "usage.error": "用量统计加载失败", + "usage.empty": "还没有记录 —— 自 {date} 起产生的用量才会被统计", + "usage.disclaimer": "费用为 pi 按每条消息记录的估算值,仅统计开始记录之后产生的用量。", "workspace.opening": "正在打开工作区...", "workspace.unable": "无法打开工作区", "workspace.selectSession": "从侧边栏选择一个会话", diff --git a/lib/usage-store.ts b/lib/usage-store.ts new file mode 100644 index 00000000..aaf9aef4 --- /dev/null +++ b/lib/usage-store.ts @@ -0,0 +1,269 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs"; +import { join } from "path"; +import { getAgentDir, getSessionEntries, listAllSessions } from "./session-reader"; +import type { AssistantMessage, SessionMessageEntry } from "./types"; + +// ============================================================================ +// Persistent usage store. +// +// Records per-message token/cost usage for assistant messages *created after +// the feature was installed* — historical session content is intentionally +// ignored. The store lives next to pi's own config as +// ~/.pi/agent/pi-web-usage.json and is updated incrementally: each session +// file is only re-parsed when its mtime changes, and per-file `seen` entry +// ids make re-scans idempotent (entries are never double-counted, even if a +// session file is fully rewritten by pi). +// ============================================================================ + +export interface UsageBucket { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + messages: number; + /** Unique session ids that contributed to this bucket */ + sessions: string[]; +} + +interface SessionFileState { + mtimeMs: number; + /** Entry ids already folded into buckets — guards against double counting */ + seen: string[]; +} + +interface UsageStoreData { + version: 1; + /** ISO timestamp; entries older than this are never recorded */ + installedAt: string; + /** Keyed by session file path */ + files: Record; + /** days[localDay]["provider/model"] = bucket */ + days: Record>; +} + +export type UsageRange = "today" | "7d" | "30d" | "all"; + +export interface UsageModelStats { + provider: string; + model: string; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + messages: number; + sessions: number; +} + +export interface UsageReport { + range: UsageRange; + installedAt: string; + models: UsageModelStats[]; + totals: Omit; + /** Per-day totals within the range, ascending by day */ + daily: { day: string; cost: number; tokens: number; messages: number }[]; +} + +declare global { + var __piUsageScanPromise: Promise | undefined; +} + +function usageStorePath(): string { + return join(getAgentDir(), "pi-web-usage.json"); +} + +function emptyStore(): UsageStoreData { + return { version: 1, installedAt: new Date().toISOString(), files: {}, days: {} }; +} + +function loadStore(): UsageStoreData { + try { + const raw = readFileSync(usageStorePath(), "utf8"); + const data = JSON.parse(raw) as UsageStoreData; + if (data && data.version === 1 && typeof data.installedAt === "string" && data.days && data.files) { + return data; + } + } catch { + // Missing or corrupt file — start fresh. + } + return emptyStore(); +} + +function saveStore(data: UsageStoreData): void { + const dir = getAgentDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const target = usageStorePath(); + const tmp = `${target}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify(data), "utf8"); + renameSync(tmp, target); +} + +/** Local (server timezone) YYYY-MM-DD day key. */ +function dayKey(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function emptyBucket(): UsageBucket { + return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, messages: 0, sessions: [] }; +} + +function recordEntry(store: UsageStoreData, sessionId: string, entry: SessionMessageEntry, message: AssistantMessage): void { + const usage = message.usage; + if (!usage) return; + const ts = Date.parse(entry.timestamp); + if (Number.isNaN(ts)) return; + + const key = dayKey(new Date(ts)); + const modelKey = `${message.provider}/${message.model}`; + const dayBucket = (store.days[key] ??= {}); + const bucket = (dayBucket[modelKey] ??= emptyBucket()); + + bucket.input += usage.input || 0; + bucket.output += usage.output || 0; + bucket.cacheRead += usage.cacheRead || 0; + bucket.cacheWrite += usage.cacheWrite || 0; + bucket.cost += usage.cost?.total || 0; + bucket.messages += 1; + if (!bucket.sessions.includes(sessionId)) bucket.sessions.push(sessionId); +} + +/** + * Incrementally fold new assistant usage entries into the store. + * Only entries at/after `installedAt` are considered; files that have not + * changed since the last scan are skipped via mtime. Coalesced so concurrent + * API requests share a single scan. + */ +export async function scanUsage(): Promise { + if (globalThis.__piUsageScanPromise) return globalThis.__piUsageScanPromise; + + const promise = (async () => { + const sessions = await listAllSessions(); + const store = loadStore(); + const cutoff = Date.parse(store.installedAt); + let dirty = false; + + for (const session of sessions) { + let mtimeMs: number; + try { + mtimeMs = statSync(session.path).mtimeMs; + } catch { + continue; // File vanished between listing and stat. + } + + const state = store.files[session.path]; + if (state && state.mtimeMs === mtimeMs) continue; + + let entries; + try { + entries = getSessionEntries(session.path); + } catch { + continue; // Unreadable/partially-written file; retry next scan. + } + + const seen = new Set(state?.seen ?? []); + for (const entry of entries) { + if (entry.type !== "message" || seen.has(entry.id)) continue; + const message = (entry as SessionMessageEntry).message; + if (message.role !== "assistant") continue; + const ts = Date.parse(entry.timestamp); + if (Number.isNaN(ts) || ts < cutoff) continue; + const assistant = message as AssistantMessage; + if (!assistant.usage) continue; + recordEntry(store, session.id, entry as SessionMessageEntry, assistant); + seen.add(entry.id); + dirty = true; + } + + store.files[session.path] = { mtimeMs, seen: [...seen] }; + } + + if (dirty || !existsSync(usageStorePath())) saveStore(store); + })(); + + globalThis.__piUsageScanPromise = promise; + try { + await promise; + } finally { + if (globalThis.__piUsageScanPromise === promise) { + globalThis.__piUsageScanPromise = undefined; + } + } +} + +function rangeStartDay(range: UsageRange, today: string): string | null { + if (range === "all") return null; + if (range === "today") return today; + const days = range === "7d" ? 6 : 29; // inclusive of today + const d = new Date(); + d.setDate(d.getDate() - days); + return dayKey(d); +} + +/** Aggregate stored buckets into a report for the requested range. */ +export function aggregateUsage(range: UsageRange): UsageReport { + const store = loadStore(); + const today = dayKey(new Date()); + const startDay = rangeStartDay(range, today); + + const byModel = new Map(); + const sessionsByModel = new Map>(); + const dailyMap = new Map(); + + for (const [day, dayModels] of Object.entries(store.days)) { + if (startDay && day < startDay) continue; + for (const [modelKey, bucket] of Object.entries(dayModels)) { + const slash = modelKey.indexOf("/"); + const provider = slash === -1 ? "" : modelKey.slice(0, slash); + const model = slash === -1 ? modelKey : modelKey.slice(slash + 1); + + const stats = byModel.get(modelKey) ?? { + provider, model, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, messages: 0, sessions: 0, + }; + stats.input += bucket.input; + stats.output += bucket.output; + stats.cacheRead += bucket.cacheRead; + stats.cacheWrite += bucket.cacheWrite; + stats.cost += bucket.cost; + stats.messages += bucket.messages; + byModel.set(modelKey, stats); + + const sessionSet = sessionsByModel.get(modelKey) ?? new Set(); + for (const id of bucket.sessions) sessionSet.add(id); + sessionsByModel.set(modelKey, sessionSet); + + const d = dailyMap.get(day) ?? { day, cost: 0, tokens: 0, messages: 0 }; + d.cost += bucket.cost; + d.tokens += bucket.input + bucket.output + bucket.cacheRead + bucket.cacheWrite; + d.messages += bucket.messages; + dailyMap.set(day, d); + } + } + + const models = [...byModel.values()] + .map((m) => ({ ...m, sessions: sessionsByModel.get(m.provider + "/" + m.model)?.size ?? 0 })) + .sort((a, b) => b.cost - a.cost || b.input + b.output - (a.input + a.output)); + + const totals = models.reduce( + (acc, m) => ({ + input: acc.input + m.input, + output: acc.output + m.output, + cacheRead: acc.cacheRead + m.cacheRead, + cacheWrite: acc.cacheWrite + m.cacheWrite, + cost: acc.cost + m.cost, + messages: acc.messages + m.messages, + sessions: 0, + }), + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, messages: 0, sessions: 0 }, + ); + const allSessions = new Set(); + for (const set of sessionsByModel.values()) for (const id of set) allSessions.add(id); + totals.sessions = allSessions.size; + + const daily = [...dailyMap.values()].sort((a, b) => (a.day < b.day ? -1 : 1)); + + return { range, installedAt: store.installedAt, models, totals, daily }; +}