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:
1 parent
edd55a9b0f
commit
52f5bb5f86
10 files changed
+821
-31
No files matched your search
Generated
+185
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '权限',
|
||||
|
||||
@@ -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
|
||||
// ==========================================
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ChatSession {
|
||||
status: 'active' | 'archived';
|
||||
mode?: 'code' | 'plan' | 'ask';
|
||||
needs_approval?: boolean;
|
||||
provider_name: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
Reference in new issue
Block a user