feat: add token usage statistics page in Settings

New "Usage" tab in Settings showing token consumption analytics:

- **Data cards**: Total Tokens, Total Cost, Sessions, Cache Hit Rate
- **Bar chart**: Daily token usage with model/provider color grouping
  using recharts v3, with custom tooltip and continuous date x-axis
- **Provider tracking**: Each session now records the active provider
  name (e.g. "Kimi Coding Plan") so the chart distinguishes usage
  across different providers, not just model names

Backend changes (db.ts):
- Added `provider_name` column to chat_sessions with auto-migration
- Added `getTokenUsageStats(days)` with summary + daily aggregation
- SQL uses `json_valid()` guard to prevent malformed JSON crashes
- Fixed off-by-one: 7D now shows exactly 7 calendar days, not 8
- Provider-qualified grouping: shows provider name when set,
  plain model name when using direct Anthropic

API changes (route.ts):
- Persists effectiveModel and activeProvider name on every message
- Captures model from SDK init event for accurate tracking
- New GET /api/usage/stats endpoint

Frontend fixes:
- SettingsLayout: replaced useEffect+setState with useSyncExternalStore
  to fix react-hooks/set-state-in-effect lint error
- UsageStatsSection: AbortController prevents request race on rapid
  range switching; cache hit rate formula corrected to
  cache_read/(cache_read+input); date gaps filled with zeros for
  continuous x-axis
- Full i18n support (en + zh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-25 00:07:41 +08:00
1 parent edd55a9b0f
commit 52f5bb5f86
10 files changed
+821 -31

No files matched your search

+185 -3
View File
@@ -1,12 +1,12 @@
{
"name": "codepilot",
"version": "0.13.2",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codepilot",
"version": "0.13.2",
"version": "0.14.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
"@hugeicons/core-free-icons": "^3.1.1",
@@ -32,6 +32,7 @@
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"recharts": "^3.7.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.22.0",
@@ -4517,6 +4518,42 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -4610,6 +4647,12 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@streamdown/cjk": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@streamdown/cjk/-/cjk-1.0.1.tgz",
@@ -5483,6 +5526,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -8227,6 +8276,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -9036,6 +9091,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -9549,6 +9614,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
@@ -10846,6 +10917,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -14789,7 +14870,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-markdown": {
@@ -14819,6 +14899,29 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -14935,6 +15038,51 @@
"node": ">= 6"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -15234,6 +15382,12 @@
"url": "https://github.com/sponsors/jet2jet"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -16560,6 +16714,12 @@
"semver": "bin/semver"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -17319,6 +17479,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.13.2",
"version": "0.14.0",
"private": true,
"author": {
"name": "op7418",
@@ -45,6 +45,7 @@
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"recharts": "^3.7.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.22.0",
+16 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
import { streamClaude } from '@/lib/claude-client';
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, getSetting } from '@/lib/db';
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, getSetting, getActiveProvider } from '@/lib/db';
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
import fs from 'fs';
import path from 'path';
@@ -57,6 +57,17 @@ export async function POST(request: NextRequest) {
// Determine model: request override > session model > default setting
const effectiveModel = model || session.model || getSetting('default_model') || undefined;
// Persist model and provider to session so usage stats can group by model+provider.
// This runs on every message but the DB writes are cheap (single UPDATE by PK).
if (effectiveModel && effectiveModel !== session.model) {
updateSessionModel(session_id, effectiveModel);
}
const activeProvider = getActiveProvider();
const providerName = activeProvider?.name || '';
if (providerName !== (session.provider_name || '')) {
updateSessionProvider(session_id, providerName);
}
// Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
const effectiveMode = mode || session.mode || 'code';
let permissionMode: string;
@@ -185,12 +196,15 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
// skip malformed tool_result data
}
} else if (event.type === 'status') {
// Capture SDK session_id from init event and persist it
// Capture SDK session_id and model from init event and persist them
try {
const statusData = JSON.parse(event.data);
if (statusData.session_id) {
updateSdkSessionId(sessionId, statusData.session_id);
}
if (statusData.model) {
updateSessionModel(sessionId, statusData.model);
}
} catch {
// skip malformed status data
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest } from 'next/server';
import { getTokenUsageStats } from '@/lib/db';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const daysParam = searchParams.get('days');
const days = daysParam ? Math.min(Math.max(parseInt(daysParam, 10) || 30, 1), 365) : 30;
const stats = getTokenUsageStats(days);
return Response.json(stats);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch usage stats';
return Response.json({ error: message }, { status: 500 });
}
}
+30 -25
View File
@@ -1,21 +1,22 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useCallback, useSyncExternalStore } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
Settings02Icon,
CodeIcon,
} from "@hugeicons/core-free-icons";
import { Plug01Icon } from "@hugeicons/core-free-icons";
import { Plug01Icon, Analytics02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/lib/utils";
import { GeneralSection } from "./GeneralSection";
import { ProviderManager } from "./ProviderManager";
import { CliSettingsSection } from "./CliSettingsSection";
import { UsageStatsSection } from "./UsageStatsSection";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
type Section = "general" | "providers" | "cli";
type Section = "general" | "providers" | "cli" | "usage";
interface SidebarItem {
id: Section;
@@ -27,44 +28,47 @@ const sidebarItems: SidebarItem[] = [
{ id: "general", label: "General", icon: Settings02Icon },
{ id: "providers", label: "Providers", icon: Plug01Icon },
{ id: "cli", label: "Claude CLI", icon: CodeIcon },
{ id: "usage", label: "Usage", icon: Analytics02Icon },
];
function getInitialSection(): Section {
if (typeof window !== "undefined") {
const hash = window.location.hash.replace("#", "");
if (sidebarItems.some((item) => item.id === hash)) {
return hash as Section;
}
function getSectionFromHash(): Section {
if (typeof window === "undefined") return "general";
const hash = window.location.hash.replace("#", "");
if (sidebarItems.some((item) => item.id === hash)) {
return hash as Section;
}
return "general";
}
function subscribeToHash(callback: () => void) {
window.addEventListener("hashchange", callback);
return () => window.removeEventListener("hashchange", callback);
}
export function SettingsLayout() {
const [activeSection, setActiveSection] = useState<Section>(getInitialSection);
// useSyncExternalStore subscribes to hash changes without triggering
// the react-hooks/set-state-in-effect lint rule.
const hashSection = useSyncExternalStore(subscribeToHash, getSectionFromHash, () => "general" as Section);
// Local state allows immediate UI update on click before the hash updates.
const [overrideSection, setOverrideSection] = useState<Section | null>(null);
const activeSection = overrideSection ?? hashSection;
const { t } = useTranslation();
const settingsLabelKeys: Record<string, TranslationKey> = {
'General': 'settings.general',
'Providers': 'settings.providers',
'Claude CLI': 'settings.claudeCli',
'Usage': 'settings.usage',
};
// Sync hash on mount and on popstate
useEffect(() => {
const onHashChange = () => {
const hash = window.location.hash.replace("#", "");
if (sidebarItems.some((item) => item.id === hash)) {
setActiveSection(hash as Section);
}
};
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const handleSectionChange = (section: Section) => {
setActiveSection(section);
const handleSectionChange = useCallback((section: Section) => {
setOverrideSection(section);
window.history.replaceState(null, "", `/settings#${section}`);
};
// Clear override so subsequent hash changes take effect
queueMicrotask(() => setOverrideSection(null));
}, []);
return (
<div className="flex h-full flex-col">
@@ -100,6 +104,7 @@ export function SettingsLayout() {
{activeSection === "general" && <GeneralSection />}
{activeSection === "providers" && <ProviderManager />}
{activeSection === "cli" && <CliSettingsSection />}
{activeSection === "usage" && <UsageStatsSection />}
</div>
</div>
</div>
@@ -0,0 +1,447 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { useTranslation } from "@/hooks/useTranslation";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface UsageStatsResponse {
summary: {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
total_sessions: number;
cache_read_tokens: number;
cache_creation_tokens: number;
};
daily: Array<{
date: string;
model: string;
input_tokens: number;
output_tokens: number;
cost: number;
}>;
}
// Recharts v3 Payload — only the fields we actually read
interface RechartsPayloadItem {
name?: string | number;
dataKey?: string | number;
value?: number;
color?: string;
fill?: string;
payload?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Number formatting helpers
// ---------------------------------------------------------------------------
/**
* Format a token count for display.
* 0 → "0"
* 999 → "999"
* 1_234 → "1,234"
* 9_999 → "9,999"
* 10_000 → "10.0K"
* 1_234_567 → "1.23M"
* 1_200_000_000 → "1.20B"
*/
function formatTokens(n: number): string {
if (n === 0) return "0";
if (n < 0) return "-" + formatTokens(-n);
if (n < 10_000) return n.toLocaleString("en-US");
if (n < 1_000_000) return (n / 1_000).toFixed(1) + "K";
if (n < 1_000_000_000) return (n / 1_000_000).toFixed(2) + "M";
return (n / 1_000_000_000).toFixed(2) + "B";
}
/**
* Format a USD cost for display.
* 0 → "$0.00"
* 0.00015 → "$0.0002"
* 0.0052 → "$0.0052"
* 0.12 → "$0.12"
* 1.5 → "$1.50"
* 1234.5 → "$1,234.50"
*/
function formatCost(n: number): string {
if (n === 0) return "$0.00";
if (n < 0.01) return "$" + n.toFixed(4);
if (n >= 1000) return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return "$" + n.toFixed(2);
}
/**
* Format a percentage.
* NaN / undefined → "N/A"
* 0 → "0%"
* 0.456 → "0.5%"
* 12.345 → "12.3%"
* 100 → "100%"
*/
function formatPercent(n: number | undefined): string {
if (n === undefined || isNaN(n)) return "N/A";
if (n === 0) return "0%";
if (n === 100) return "100%";
return n.toFixed(1) + "%";
}
/** Short date for chart x-axis: "2/24" */
function shortDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return `${d.getMonth() + 1}/${d.getDate()}`;
}
// ---------------------------------------------------------------------------
// Stable model → color mapping
// ---------------------------------------------------------------------------
const COLOR_PALETTE = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
/**
* Assign a stable color to each unique model key (e.g. "sonnet", "Kimi/sonnet").
* Uses the index in the sorted model list so each distinct key gets its own color.
*/
function getModelColor(_model: string, idx: number): string {
return COLOR_PALETTE[idx % COLOR_PALETTE.length];
}
// ---------------------------------------------------------------------------
// Chart tooltip (recharts v3 compatible)
// ---------------------------------------------------------------------------
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: ReadonlyArray<RechartsPayloadItem>;
label?: string | number;
}) {
if (!active || !payload?.length) return null;
// Filter out entries with zero or missing values
const items = payload.filter((p) => typeof p.value === "number" && p.value > 0);
if (items.length === 0) return null;
return (
<div className="rounded-lg border border-border/50 bg-popover px-3 py-2 text-xs shadow-md">
<p className="mb-1 font-medium text-popover-foreground">{label}</p>
{items.map((p, i) => {
const displayName = String(p.name ?? p.dataKey ?? "unknown");
const displayColor = p.color || p.fill || "var(--color-chart-1)";
return (
<div key={displayName + i} className="flex items-center gap-2 text-popover-foreground/80">
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: displayColor }}
/>
<span>{displayName}</span>
<span className="ml-auto font-mono">{formatTokens(p.value ?? 0)}</span>
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Day range selector
// ---------------------------------------------------------------------------
const RANGE_OPTIONS = [
{ label: "7D", days: 7 },
{ label: "30D", days: 30 },
{ label: "90D", days: 90 },
] as const;
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function UsageStatsSection() {
const { t } = useTranslation();
const [days, setDays] = useState(30);
const [data, setData] = useState<UsageStatsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchStats = useCallback(async (d: number) => {
// Abort any in-flight request to avoid stale data overwriting fresh data
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/usage/stats?days=${d}`, { signal: controller.signal });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
setData(await res.json());
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : String(t('usage.loadError')));
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [t]);
useEffect(() => {
fetchStats(days);
return () => abortRef.current?.abort();
}, [days, fetchStats]);
// Derive chart data: pivot daily rows into { date, model1: N, model2: N, ... }
const { chartData, models } = deriveChartData(data?.daily ?? [], days);
const summary = data?.summary;
const totalTokens = summary
? summary.total_input_tokens + summary.total_output_tokens
: 0;
const cacheTotal = summary
? summary.cache_read_tokens + summary.total_input_tokens
: 0;
const cacheRate = summary && cacheTotal > 0
? (summary.cache_read_tokens / cacheTotal) * 100
: undefined;
return (
<div className="max-w-3xl space-y-6">
{/* Day range selector */}
<div className="flex items-center gap-2">
{RANGE_OPTIONS.map((opt) => (
<button
key={opt.days}
onClick={() => setDays(opt.days)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
days === opt.days
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{opt.label}
</button>
))}
</div>
{/* Data cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('usage.totalTokens')}
value={loading ? "–" : formatTokens(totalTokens)}
sub={
summary
? `${t('usage.input')} ${formatTokens(summary.total_input_tokens)} · ${t('usage.output')} ${formatTokens(summary.total_output_tokens)}`
: undefined
}
/>
<StatCard
label={t('usage.totalCost')}
value={loading ? "–" : formatCost(summary?.total_cost ?? 0)}
/>
<StatCard
label={t('usage.sessions')}
value={loading ? "–" : String(summary?.total_sessions ?? 0)}
/>
<StatCard
label={t('usage.cacheHitRate')}
value={loading ? "–" : formatPercent(cacheRate)}
sub={
summary && summary.cache_read_tokens > 0
? `${formatTokens(summary.cache_read_tokens)} ${t('usage.cached')}`
: undefined
}
/>
</div>
{/* Bar chart */}
<div className="rounded-lg border border-border/50 p-4">
<h3 className="mb-4 text-sm font-medium">{t('usage.dailyChart')}</h3>
{loading && (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
{t('usage.loading')}
</div>
)}
{error && (
<div className="flex h-64 items-center justify-center text-sm text-red-500">
{error}
</div>
)}
{!loading && !error && chartData.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-40"
>
<path d="M18 20V10" />
<path d="M12 20V4" />
<path d="M6 20v-6" />
</svg>
<p className="text-sm">{t('usage.noData')}</p>
<p className="text-xs">{t('usage.noDataHint')}</p>
</div>
)}
{!loading && !error && chartData.length > 0 && (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 4, right: 4, left: -8, bottom: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="var(--color-border)"
opacity={0.5}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={formatTokens}
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
tickLine={false}
axisLine={false}
width={54}
/>
<Tooltip
content={(props) => <ChartTooltip {...props} />}
cursor={{ fill: "var(--color-accent)", opacity: 0.3 }}
/>
<Legend
wrapperStyle={{ fontSize: 11, paddingTop: 8 }}
iconType="circle"
iconSize={8}
/>
{models.map((model, idx) => (
<Bar
key={model}
name={model}
dataKey={model}
stackId="tokens"
fill={getModelColor(model, idx)}
radius={idx === models.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]}
maxBarSize={40}
/>
))}
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function StatCard({
label,
value,
sub,
}: {
label: string;
value: string;
sub?: string;
}) {
return (
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
{sub && <p className="mt-0.5 text-[11px] text-muted-foreground">{sub}</p>}
</div>
);
}
// ---------------------------------------------------------------------------
// Data transform: pivot daily array into chart-friendly format
// ---------------------------------------------------------------------------
function deriveChartData(daily: UsageStatsResponse["daily"], days: number): {
chartData: Array<Record<string, string | number>>;
models: string[];
} {
if (daily.length === 0) return { chartData: [], models: [] };
// Normalise model names: empty string → "unknown"
const normalised = daily.map((row) => ({
...row,
model: row.model || "unknown",
}));
// Collect all unique models
const modelSet = new Set<string>();
for (const row of normalised) {
modelSet.add(row.model);
}
const models = Array.from(modelSet).sort();
// Group by date
const byDate = new Map<string, Record<string, number>>();
for (const row of normalised) {
const total = row.input_tokens + row.output_tokens;
if (!byDate.has(row.date)) {
byDate.set(row.date, {});
}
const entry = byDate.get(row.date)!;
entry[row.model] = (entry[row.model] || 0) + total;
}
// Build a continuous date range covering the full window so x-axis has no gaps
const allDates: string[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
allDates.push(d.toISOString().slice(0, 10));
}
// Format for recharts, filling missing dates with zeros
const chartData = allDates.map((date) => {
const modelTokens = byDate.get(date) || {};
const row: Record<string, string | number> = { date: shortDate(date) };
for (const m of models) {
row[m] = modelTokens[m] || 0;
}
return row;
});
return { chartData, models };
}
+15
View File
@@ -80,6 +80,21 @@ const en = {
'settings.enableAutoApprove': 'Enable Auto-approve',
'settings.language': 'Language',
'settings.languageDesc': 'Choose the display language for the interface',
'settings.usage': 'Usage',
// ── Settings: Usage Stats ───────────────────────────────────
'usage.totalTokens': 'Total Tokens',
'usage.totalCost': 'Total Cost',
'usage.sessions': 'Sessions',
'usage.cacheHitRate': 'Cache Hit Rate',
'usage.input': 'In',
'usage.output': 'Out',
'usage.cached': 'cached',
'usage.dailyChart': 'Daily Token Usage',
'usage.loading': 'Loading...',
'usage.loadError': 'Failed to load usage stats',
'usage.noData': 'No usage data yet',
'usage.noDataHint': 'Start a conversation to see statistics here.',
// ── Settings: CLI ───────────────────────────────────────────
'cli.permissions': 'Permissions',
+15
View File
@@ -77,6 +77,21 @@ const zh: Record<TranslationKey, string> = {
'settings.enableAutoApprove': '启用自动批准',
'settings.language': '语言',
'settings.languageDesc': '选择界面显示语言',
'settings.usage': '用量统计',
// ── Settings: Usage Stats ───────────────────────────────────
'usage.totalTokens': '总 Token',
'usage.totalCost': '总费用',
'usage.sessions': '会话数',
'usage.cacheHitRate': '缓存命中率',
'usage.input': '输入',
'usage.output': '输出',
'usage.cached': '已缓存',
'usage.dailyChart': '每日 Token 用量',
'usage.loading': '加载中...',
'usage.loadError': '加载用量数据失败',
'usage.noData': '暂无用量数据',
'usage.noDataHint': '开始对话后即可在此查看统计信息。',
// ── Settings: CLI ───────────────────────────────────────────
'cli.permissions': '权限',
+91
View File
@@ -150,6 +150,9 @@ function migrateDb(db: Database.Database): void {
if (!colNames.includes('mode')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'code'");
}
if (!colNames.includes('provider_name')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN provider_name TEXT NOT NULL DEFAULT ''");
}
const msgColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
const msgColNames = msgColumns.map(c => c.name);
@@ -261,6 +264,16 @@ export function updateSdkSessionId(id: string, sdkSessionId: string): void {
db.prepare('UPDATE chat_sessions SET sdk_session_id = ? WHERE id = ?').run(sdkSessionId, id);
}
export function updateSessionModel(id: string, model: string): void {
const db = getDb();
db.prepare('UPDATE chat_sessions SET model = ? WHERE id = ?').run(model, id);
}
export function updateSessionProvider(id: string, providerName: string): void {
const db = getDb();
db.prepare('UPDATE chat_sessions SET provider_name = ? WHERE id = ?').run(providerName, id);
}
export function updateSessionWorkingDirectory(id: string, workingDirectory: string): void {
const db = getDb();
const projectName = path.basename(workingDirectory);
@@ -510,6 +523,84 @@ export function deactivateAllProviders(): void {
db.prepare('UPDATE api_providers SET is_active = 0').run();
}
// ==========================================
// Token Usage Statistics
// ==========================================
export function getTokenUsageStats(days: number = 30): {
summary: {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
total_sessions: number;
cache_read_tokens: number;
cache_creation_tokens: number;
};
daily: Array<{
date: string;
model: string;
input_tokens: number;
output_tokens: number;
cost: number;
}>;
} {
const db = getDb();
const summary = db.prepare(`
SELECT
COALESCE(SUM(json_extract(m.token_usage, '$.input_tokens')), 0) AS total_input_tokens,
COALESCE(SUM(json_extract(m.token_usage, '$.output_tokens')), 0) AS total_output_tokens,
COALESCE(SUM(json_extract(m.token_usage, '$.cost_usd')), 0) AS total_cost,
COUNT(DISTINCT m.session_id) AS total_sessions,
COALESCE(SUM(json_extract(m.token_usage, '$.cache_read_input_tokens')), 0) AS cache_read_tokens,
COALESCE(SUM(json_extract(m.token_usage, '$.cache_creation_input_tokens')), 0) AS cache_creation_tokens
FROM messages m
WHERE m.token_usage IS NOT NULL
AND json_valid(m.token_usage) = 1
AND m.created_at >= date('now', '-' || (? - 1) || ' days')
`).get(days) as {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
total_sessions: number;
cache_read_tokens: number;
cache_creation_tokens: number;
};
const daily = db.prepare(`
SELECT
DATE(m.created_at) AS date,
CASE
WHEN COALESCE(NULLIF(s.provider_name, ''), '') != ''
THEN s.provider_name
ELSE COALESCE(NULLIF(s.model, ''), 'unknown')
END AS model,
COALESCE(SUM(json_extract(m.token_usage, '$.input_tokens')), 0) AS input_tokens,
COALESCE(SUM(json_extract(m.token_usage, '$.output_tokens')), 0) AS output_tokens,
COALESCE(SUM(json_extract(m.token_usage, '$.cost_usd')), 0) AS cost
FROM messages m
LEFT JOIN chat_sessions s ON m.session_id = s.id
WHERE m.token_usage IS NOT NULL
AND json_valid(m.token_usage) = 1
AND m.created_at >= date('now', '-' || (? - 1) || ' days')
GROUP BY DATE(m.created_at),
CASE
WHEN COALESCE(NULLIF(s.provider_name, ''), '') != ''
THEN s.provider_name
ELSE COALESCE(NULLIF(s.model, ''), 'unknown')
END
ORDER BY date ASC
`).all(days) as Array<{
date: string;
model: string;
input_tokens: number;
output_tokens: number;
cost: number;
}>;
return { summary, daily };
}
// ==========================================
// Graceful Shutdown
// ==========================================
+1
View File
@@ -15,6 +15,7 @@ export interface ChatSession {
status: 'active' | 'archived';
mode?: 'code' | 'plan' | 'ask';
needs_approval?: boolean;
provider_name: string;
}
// ==========================================