From 1636acce8fd4ff25ccaeca2deb0a727e8a044cd5 Mon Sep 17 00:00:00 2001 From: SpaceHunterInf Date: Sat, 6 Jun 2026 23:50:18 +0100 Subject: [PATCH] feat: add cost dashboard (Usage tab) to Settings - New UsageTab component showing total spend, daily average, API calls, and token usage with an interactive bar chart - Time range selector (7d / 14d / 30d) for adjustable cost windows - Token breakdown visualization (input vs output) with proportional bars - Consumes existing backend routes: /config/cost/global, /config/cost/daily - Added fetchGlobalCost and fetchDailyCost API client functions - Full i18n support (English + Simplified Chinese) - Dollar-sign icon in settings sidebar between Data and Appearance tabs --- .../src/components/settings/SettingsModal.tsx | 14 +- frontend/src/components/settings/UsageTab.tsx | 272 ++++++++++++++++++ frontend/src/i18n/messages/en.ts | 20 ++ frontend/src/i18n/messages/zh-CN.ts | 20 ++ frontend/src/lib/api.ts | 32 +++ 5 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/settings/UsageTab.tsx diff --git a/frontend/src/components/settings/SettingsModal.tsx b/frontend/src/components/settings/SettingsModal.tsx index 7e17f839..ccd33a85 100644 --- a/frontend/src/components/settings/SettingsModal.tsx +++ b/frontend/src/components/settings/SettingsModal.tsx @@ -10,6 +10,7 @@ import { MemoryTab } from './MemoryTab'; import { ModelRolesTab } from './ModelRolesTab'; import { ProvidersTab } from './ProvidersTab'; import { SocialTab } from './SocialTab'; +import { UsageTab } from './UsageTab'; import { useI18n, type Locale } from '../../i18n'; import { BrutalSelect } from '../BrutalSelect'; @@ -37,7 +38,7 @@ interface SettingsModalProps { } type ProviderTab = 'credentials' | 'models'; -type CategoryType = 'providers' | 'roles' | 'memory' | 'social' | 'mcp' | 'automation' | 'data' | 'appearance'; +type CategoryType = 'providers' | 'roles' | 'memory' | 'social' | 'mcp' | 'automation' | 'data' | 'usage' | 'appearance'; export function SettingsModal({ isOpen, onClose }: SettingsModalProps): React.ReactElement | null { const { refreshBackendConfig, backendConfig } = useChatStore(); @@ -408,6 +409,13 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps): React.Re ) }, + { + id: 'usage', label: t('settings.categories.usage'), icon: ( + + + + ) + }, { id: 'appearance', label: t('settings.categories.appearance'), icon: ( @@ -564,6 +572,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps): React.Re )} + {activeCategory === 'usage' && ( + + )} + {activeCategory === 'appearance' && ( )} diff --git a/frontend/src/components/settings/UsageTab.tsx b/frontend/src/components/settings/UsageTab.tsx new file mode 100644 index 00000000..1efef995 --- /dev/null +++ b/frontend/src/components/settings/UsageTab.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { fetchGlobalCost, fetchDailyCost } from '../../lib/api'; +import type { CostGlobal, CostDaily } from '../../lib/api'; +import { useI18n } from '../../i18n'; + +type TimeRange = 7 | 14 | 30; + +function formatCost(usd: number): string { + if (usd < 0.01 && usd > 0) return `$${usd.toFixed(4)}`; + if (usd < 1) return `$${usd.toFixed(3)}`; + return `$${usd.toFixed(2)}`; +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +/** Pure-CSS bar chart rendered as a flex row of bar columns. */ +function DailyChart({ data, range }: { data: CostDaily[]; range: TimeRange }) { + const { t } = useI18n(); + + const filled = useMemo(() => { + const today = new Date(); + const map = new Map(data.map(d => [d.date, d])); + const result: CostDaily[] = []; + for (let i = range - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + result.push(map.get(key) ?? { date: key, cost_usd: 0, input_tokens: 0, output_tokens: 0, calls: 0 }); + } + return result; + }, [data, range]); + + const maxCost = useMemo(() => Math.max(...filled.map(d => d.cost_usd), 0.001), [filled]); + + const [hoveredIdx, setHoveredIdx] = useState(null); + + return ( +
+
+ {t('settings.usage.dailySpend')} +
+ + {/* Chart */} +
+ {filled.map((d, i) => { + const pct = maxCost > 0 ? (d.cost_usd / maxCost) * 100 : 0; + const barHeight = Math.max(pct, d.cost_usd > 0 ? 3 : 0); + const isToday = i === filled.length - 1; + const isHovered = hoveredIdx === i; + + return ( +
setHoveredIdx(i)} + onMouseLeave={() => setHoveredIdx(null)} + > + {/* Tooltip */} + {isHovered && ( +
+
{d.date}
+
{formatCost(d.cost_usd)}
+
{t('settings.usage.tooltipCalls', { count: String(d.calls) })}
+
+ )} + + {/* Bar */} +
0 ? '2px' : '0' }} + /> +
+ ); + })} +
+ + {/* X-axis labels */} +
+ {filled[0]?.date.slice(5)} + {t('settings.usage.today')} +
+
+ ); +} + +function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+ + {label} + + + {value} + + {sub && ( + + {sub} + + )} +
+ ); +} + +export function UsageTab(): React.ReactElement { + const { t } = useI18n(); + const [range, setRange] = useState(30); + const [global, setGlobal] = useState(null); + const [daily, setDaily] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + Promise.all([fetchGlobalCost(range), fetchDailyCost(range)]) + .then(([g, d]) => { + if (cancelled) return; + setGlobal(g); + setDaily(d); + }) + .catch(e => { + if (cancelled) return; + setError(String(e)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [range]); + + const avgDaily = useMemo(() => { + if (!global || !range) return 0; + return global.total_cost_usd / range; + }, [global, range]); + + return ( +
+ {/* Header */} +
+

{t('settings.usage.title')}

+

{t('settings.usage.subtitle')}

+
+ + {/* Time range selector */} +
+ {([7, 14, 30] as TimeRange[]).map(r => ( + + ))} +
+ + {loading ? ( +
+
+
+ ) : error ? ( +
+

{error}

+
+ ) : global ? ( + <> + {/* Summary cards */} +
+ + + + +
+ + {/* Daily chart */} +
+ +
+ + {/* Token breakdown */} +
+
+ {t('settings.usage.tokenBreakdown')} +
+ +
+ {/* Input tokens bar */} +
+
+ {t('settings.usage.inputTokens')} + + {formatTokens(global.total_input_tokens)} + +
+
+
+
+
+ + {/* Output tokens bar */} +
+
+ {t('settings.usage.outputTokens')} + + {formatTokens(global.total_output_tokens)} + +
+
+
+
+
+
+
+ + {/* Empty state */} + {global.total_calls === 0 && ( +
+

+ {t('settings.usage.noUsageYet')} +

+
+ )} + + ) : null} +
+ ); +} diff --git a/frontend/src/i18n/messages/en.ts b/frontend/src/i18n/messages/en.ts index 5366d2ef..8e1df427 100644 --- a/frontend/src/i18n/messages/en.ts +++ b/frontend/src/i18n/messages/en.ts @@ -198,6 +198,7 @@ export const en = { mcp: 'MCP Servers', automation: 'Automation', data: 'Data', + usage: 'Usage', appearance: 'Appearance', }, data: { @@ -309,6 +310,25 @@ export const en = { colorScheme: 'Color Scheme', schemes: { warm: 'Warm', cold: 'Cold', green: 'Green' }, }, + usage: { + title: 'Usage', + subtitle: 'Track your LLM API spending and token usage', + totalSpend: 'Total Spend', + avgDaily: 'Avg. Daily', + totalCalls: 'API Calls', + totalTokens: 'Tokens', + lastNDays: 'Last {count} days', + perDay: 'per day', + apiCalls: 'API calls', + days: '{count}d', + dailySpend: 'Daily Spend', + today: 'Today', + tooltipCalls: '{count} calls', + tokenBreakdown: 'Token Breakdown', + inputTokens: 'Input Tokens', + outputTokens: 'Output Tokens', + noUsageYet: 'No usage data yet. Start chatting to see your spending here.', + }, switchToLight: 'Switch to light mode', switchToDark: 'Switch to dark mode', saveChanges: 'Save Changes', diff --git a/frontend/src/i18n/messages/zh-CN.ts b/frontend/src/i18n/messages/zh-CN.ts index cbd27c0d..9e3c0172 100644 --- a/frontend/src/i18n/messages/zh-CN.ts +++ b/frontend/src/i18n/messages/zh-CN.ts @@ -198,6 +198,7 @@ export const zhCN = { mcp: 'MCP 服务器', automation: '自动化', data: '数据', + usage: '用量', appearance: '外观', }, data: { @@ -309,6 +310,25 @@ export const zhCN = { colorScheme: '颜色方案', schemes: { warm: '暖色', cold: '冷色', green: '绿色' }, }, + usage: { + title: '用量', + subtitle: '跟踪 LLM API 花费和 Token 使用情况', + totalSpend: '总花费', + avgDaily: '日均花费', + totalCalls: 'API 调用', + totalTokens: 'Token 数', + lastNDays: '最近 {count} 天', + perDay: '每天', + apiCalls: '次 API 调用', + days: '{count}天', + dailySpend: '每日花费', + today: '今天', + tooltipCalls: '{count} 次调用', + tokenBreakdown: 'Token 分布', + inputTokens: '输入 Token', + outputTokens: '输出 Token', + noUsageYet: '暂无用量数据。开始对话后即可在此查看花费。', + }, switchToLight: '切换到浅色模式', switchToDark: '切换到深色模式', saveChanges: '保存更改', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ed6d0da2..349cca17 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -748,6 +748,38 @@ export async function saveHeartbeatGlobalConfig(cfg: HeartbeatGlobalConfig): Pro }); } +// ----------------------------------------------------------------------------- +// Cost Tracking +// ----------------------------------------------------------------------------- + +export interface CostGlobal { + total_cost_usd: number; + total_input_tokens: number; + total_output_tokens: number; + total_calls: number; + days: number; +} + +export interface CostDaily { + date: string; + cost_usd: number; + input_tokens: number; + output_tokens: number; + calls: number; +} + +export async function fetchGlobalCost(days = 30): Promise { + const res = await fetch(`${getApiBase()}/config/cost/global?days=${days}`); + if (!res.ok) throw new Error('Failed to fetch global cost'); + return res.json(); +} + +export async function fetchDailyCost(days = 30): Promise { + const res = await fetch(`${getApiBase()}/config/cost/daily?days=${days}`); + if (!res.ok) throw new Error('Failed to fetch daily cost'); + return res.json(); +} + export async function saveSocialConfig(config: SocialConfig): Promise { try { const res = await fetch(`${getApiBase()}/config/social`, {