diff --git a/package-lock.json b/package-lock.json index 5ed0103..62cc5ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 96bc070..ba2b46e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 83dfc5c..172b46f 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -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, 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 } diff --git a/src/app/api/usage/stats/route.ts b/src/app/api/usage/stats/route.ts new file mode 100644 index 0000000..4ad7231 --- /dev/null +++ b/src/app/api/usage/stats/route.ts @@ -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 }); + } +} diff --git a/src/components/settings/SettingsLayout.tsx b/src/components/settings/SettingsLayout.tsx index 2f2defb..eb88b66 100644 --- a/src/components/settings/SettingsLayout.tsx +++ b/src/components/settings/SettingsLayout.tsx @@ -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
(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
(null); + const activeSection = overrideSection ?? hashSection; + const { t } = useTranslation(); const settingsLabelKeys: Record = { '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 (
@@ -100,6 +104,7 @@ export function SettingsLayout() { {activeSection === "general" && } {activeSection === "providers" && } {activeSection === "cli" && } + {activeSection === "usage" && }
diff --git a/src/components/settings/UsageStatsSection.tsx b/src/components/settings/UsageStatsSection.tsx new file mode 100644 index 0000000..d1cf549 --- /dev/null +++ b/src/components/settings/UsageStatsSection.tsx @@ -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; +} + +// --------------------------------------------------------------------------- +// 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; + 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 ( +
+

{label}

+ {items.map((p, i) => { + const displayName = String(p.name ?? p.dataKey ?? "unknown"); + const displayColor = p.color || p.fill || "var(--color-chart-1)"; + return ( +
+ + {displayName} + {formatTokens(p.value ?? 0)} +
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const abortRef = useRef(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 ( +
+ {/* Day range selector */} +
+ {RANGE_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Data cards */} +
+ + + + 0 + ? `${formatTokens(summary.cache_read_tokens)} ${t('usage.cached')}` + : undefined + } + /> +
+ + {/* Bar chart */} +
+

{t('usage.dailyChart')}

+ + {loading && ( +
+ {t('usage.loading')} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && chartData.length === 0 && ( +
+ + + + + +

{t('usage.noData')}

+

{t('usage.noDataHint')}

+
+ )} + + {!loading && !error && chartData.length > 0 && ( + + + + + + } + cursor={{ fill: "var(--color-accent)", opacity: 0.3 }} + /> + + {models.map((model, idx) => ( + + ))} + + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function StatCard({ + label, + value, + sub, +}: { + label: string; + value: string; + sub?: string; +}) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +// --------------------------------------------------------------------------- +// Data transform: pivot daily array into chart-friendly format +// --------------------------------------------------------------------------- + +function deriveChartData(daily: UsageStatsResponse["daily"], days: number): { + chartData: Array>; + 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(); + for (const row of normalised) { + modelSet.add(row.model); + } + const models = Array.from(modelSet).sort(); + + // Group by date + const byDate = new Map>(); + 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 = { date: shortDate(date) }; + for (const m of models) { + row[m] = modelTokens[m] || 0; + } + return row; + }); + + return { chartData, models }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index caa85b7..a994ff0 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 339b23a..094fcd2 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -77,6 +77,21 @@ const zh: Record = { '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': '权限', diff --git a/src/lib/db.ts b/src/lib/db.ts index 7e4af0a..049345c 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 // ========================================== diff --git a/src/types/index.ts b/src/types/index.ts index 48fea69..36c6766 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,6 +15,7 @@ export interface ChatSession { status: 'active' | 'archived'; mode?: 'code' | 'plan' | 'ask'; needs_approval?: boolean; + provider_name: string; } // ==========================================