diff --git a/README.md b/README.md index aa2c48ec..8bc396a8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Hermes Web UI -Web dashboard for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — chat interaction, session management, scheduled jobs, platform channel configuration, and log viewing. +Web dashboard for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — chat interaction, session management, scheduled jobs, usage statistics, platform channel configuration, and log viewing. -![Hermes Web UI Demo](https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/src/assets/output1.gif) +![Hermes Web UI Demo](https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/src/assets/output.gif) ## Tech Stack @@ -19,6 +19,8 @@ Web dashboard for [Hermes Agent](https://github.com/NousResearch/hermes-agent) ## Install and Run +### Quick Install + ```bash npm install -g hermes-web-ui hermes-web-ui start @@ -26,6 +28,26 @@ hermes-web-ui start Open http://localhost:8648 +### WSL (Windows Subsystem for Linux) + +```bash +# 1. Auto-setup: install Node.js + hermes-web-ui +bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh) + +# 2. Start +hermes-web-ui start +``` + +> WSL will auto-detect and use `hermes gateway run` for background startup (no launchd/systemd). + +### One-line Setup (Auto-detect OS) + +```bash +bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh) +``` + +Automatically installs Node.js (if missing) and hermes-web-ui on Debian/Ubuntu/macOS. + ### CLI Commands | Command | Description | @@ -35,11 +57,20 @@ Open http://localhost:8648 | `hermes-web-ui stop` | Stop background process | | `hermes-web-ui restart` | Restart background process | | `hermes-web-ui status` | Check if running | +| `hermes-web-ui update` | Update to latest version & restart| +| `hermes-web-ui -v` | Show version number | +| `hermes-web-ui -h` | Show help message | | `hermes-web-ui` | Run in foreground (for debugging) | ### Auto Configuration -On startup, the BFF server automatically checks `~/.hermes/config.yaml` and ensures `platforms.api_server.enabled` is set to `true`. If modified, it backs up the original to `config.yaml.bak` and restarts the gateway. +On startup, the BFF server automatically: + +- Checks `~/.hermes/config.yaml` and ensures `platforms.api_server` has all required fields (`enabled`, `host`, `port`, `key`, `cors_origins`) +- If any field is missing, backs up the original to `config.yaml.bak`, fills in defaults, and restarts the gateway +- Detects if the gateway is running and starts it if needed +- Kills any process occupying the target port before starting +- Opens the browser automatically after successful startup ## Development @@ -68,7 +99,7 @@ Outputs to `dist/` (frontend + compiled BFF server). ``` hermes-web-ui/ ├── bin/ -│ └── hermes-web-ui.mjs # CLI entry (start/stop/restart/status) +│ └── hermes-web-ui.mjs # CLI entry (start/stop/restart/status/update/version/help) ├── server/src/ │ ├── index.ts # BFF entry (Koa app bootstrap) │ ├── config.ts # Configuration (port, upstream, etc.) @@ -102,6 +133,7 @@ hermes-web-ui/ │ │ ├── settings/ # Settings components │ │ │ ├── PlatformCard.vue # Platform card with config status │ │ │ └── PlatformSettings.vue # Platform channel configuration +│ │ ├── usage/ # Usage statistics components │ │ └── skills/ # Skill components │ ├── views/ │ │ ├── ChatView.vue # Chat page @@ -111,6 +143,7 @@ hermes-web-ui/ │ │ ├── ChannelsView.vue # Platform channels page │ │ ├── SkillsView.vue # Skills page │ │ ├── MemoryView.vue # Memory page +│ │ ├── UsageView.vue # Usage statistics page │ │ └── SettingsView.vue # Settings page │ └── router/index.ts # Router configuration └── dist/ # Build output (published to npm) @@ -134,6 +167,17 @@ hermes-web-ui/ - Model selector — automatically discovers available models from `~/.hermes/auth.json` credential pool - Global model switching (updates `~/.hermes/config.yaml`) - Per-session model display (badge in chat header and session list) +- Context token usage display (used / total) + +### Usage Statistics + +- Total token usage breakdown (input / output) +- Session count with daily average +- Estimated cost tracking +- Cache hit rate +- Model usage distribution (horizontal bar chart) +- 30-day daily trend (bar chart + data table) +- Hover tooltips on chart bars ### Platform Channels @@ -186,8 +230,10 @@ hermes-web-ui/ - Internationalization — auto-detect browser language, manual toggle between Chinese and English - Real-time connection status monitoring - Hermes version display in sidebar -- Auto config check on startup -- Minimalist dark theme +- Auto config check on startup with field-level validation +- Port conflict auto-resolution (kills stale processes) +- Auto browser open on startup +- Minimalist "Pure Ink" theme - Session group collapse state persisted across navigation ## Architecture @@ -207,7 +253,7 @@ The BFF layer handles: - API proxy to Hermes (with header forwarding) - SSE streaming passthrough - File upload to temp directory -- Session CRUD via Hermes CLI +- Session CRUD via Hermes CLI (with cache/cost token passthrough) - Config & credential management (config.yaml + .env) - WeChat QR code login flow (fetch QR, poll status, save credentials) - Auto gateway restart on platform config changes diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index 71b2480e..bb841912 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawn } from 'child_process' +import { spawn, execSync } from 'child_process' import { resolve, dirname, join } from 'path' import { fileURLToPath } from 'url' import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync } from 'fs' @@ -7,6 +7,8 @@ import { homedir } from 'os' const __dirname = dirname(fileURLToPath(import.meta.url)) const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js') +const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')) +const VERSION = pkg.version const PID_DIR = resolve(homedir(), '.hermes-web-ui') const PID_FILE = join(PID_DIR, 'server.pid') const LOG_FILE = join(PID_DIR, 'server.log') @@ -51,6 +53,32 @@ function startDaemon(port) { process.exit(1) } removePid() + + // Check if port is already in use + try { + const isWin = process.platform === 'win32' + let pids = '' + if (isWin) { + const out = execSync(`netstat -aon | findstr :${port}`, { encoding: 'utf-8' }).trim() + const lines = out.split('\n').filter(l => l.includes('LISTENING')) + pids = [...new Set(lines.map(l => l.trim().split(/\s+/).pop()).filter(Boolean))].join(' ') + } else { + pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8' }).trim() + } + if (pids) { + console.log(` ⚠ Port ${port} is in use by PID(s): ${pids}, killing...`) + if (isWin) { + execSync(`taskkill /F /PID ${pids.split(' ').join(' /PID ')}`, { encoding: 'utf-8' }) + } else { + execSync(`kill -9 ${pids}`, { encoding: 'utf-8' }) + } + // Brief wait for port to be released + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500) + } + } catch { + // Port is free + } + mkdirSync(PID_DIR, { recursive: true }) const logStream = openSync(LOG_FILE, 'a') @@ -74,6 +102,11 @@ function startDaemon(port) { console.log(` ✓ hermes-web-ui started (PID: ${child.pid}, port: ${port})`) console.log(` http://localhost:${port}`) console.log(` Log: ${LOG_FILE}`) + // Open browser + const url = `http://localhost:${port}` + const isWin = process.platform === 'win32' + const cmd = isWin ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}` + try { execSync(cmd, { stdio: 'ignore' }) } catch {} } else { console.log(' ✗ Failed to start hermes-web-ui') removePid() @@ -129,6 +162,56 @@ function showStatus() { const command = process.argv[2] || 'start' +if (['-v', '--version', 'version'].includes(command)) { + console.log(`hermes-web-ui v${VERSION}`) + process.exit(0) +} + +if (['-h', '--help', 'help'].includes(command)) { + console.log(` +hermes-web-ui v${VERSION} + +Usage: hermes-web-ui [options] + +Commands: + start [port] Start the server (default port: ${DEFAULT_PORT}) + stop Stop the server + restart [port] Restart the server + status Show server status + update Update to latest version and restart + version Show version number + +Options: + -v, --version Show version number + -h, --help Show this help message + --port Specify port (used with start/restart) +`) + process.exit(0) +} + +function doUpdate() { + const isWin = process.platform === 'win32' + const cmd = isWin + ? 'cmd /c npm install -g hermes-web-ui@latest' + : 'npm install -g hermes-web-ui@latest' + + console.log(' ⬆ Updating hermes-web-ui...') + + const child = spawn(isWin ? 'cmd' : 'sh', isWin ? ['/c', ...cmd.split(' ')] : ['-c', cmd], { + stdio: 'inherit', + }) + + child.on('exit', (code) => { + if (code === 0) { + console.log(' ✓ Update complete, restarting...') + stopDaemon() + setTimeout(() => startDaemon(getPort()), 500) + } else { + console.log(' ✗ Update failed') + } + }) +} + switch (command) { case 'start': startDaemon(getPort()) @@ -143,6 +226,10 @@ switch (command) { case 'status': showStatus() break + case 'update': + case 'upgrade': + doUpdate() + break default: const port = !isNaN(command) ? parseInt(command) : DEFAULT_PORT const child = spawn(process.execPath, [serverEntry], { diff --git a/package.json b/package.json index 6bba1b2f..888c185b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.2.0", + "version": "0.2.2", "description": "Hermes Agent Web UI - Chat and Job Management Dashboard", "repository": { "type": "git", diff --git a/server/src/index.ts b/server/src/index.ts index d0801c58..7d28763a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -84,7 +84,7 @@ async function ensureApiServerConfig() { const yaml = (await import('js-yaml')).default const configPath = resolve(homedir(), '.hermes/config.yaml') - const apiServerConfig = { + const apiServerDefaults: Record = { enabled: true, host: '127.0.0.1', port: 8642, @@ -101,8 +101,20 @@ async function ensureApiServerConfig() { const content = readFileSync(configPath, 'utf-8') const config = yaml.load(content) as any || {} - // Check if api_server is already correct - if (config.platforms?.api_server?.enabled === true) { + if (!config.platforms) config.platforms = {} + if (!config.platforms.api_server) config.platforms.api_server = {} + + const api = config.platforms.api_server + let needsUpdate = false + + for (const [key, value] of Object.entries(apiServerDefaults)) { + if (api[key] === undefined || api[key] === null) { + api[key] = value + needsUpdate = true + } + } + + if (!needsUpdate) { console.log(' ✓ api_server config is correct') return } @@ -110,10 +122,6 @@ async function ensureApiServerConfig() { // Backup before modifying copyFileSync(configPath, configPath + '.bak') - // Ensure platforms.api_server with correct values - if (!config.platforms) config.platforms = {} - config.platforms.api_server = apiServerConfig - const updated = yaml.dump(config, { lineWidth: -1, noRefs: true, quotingType: '"' }) writeFileSync(configPath, updated, 'utf-8') console.log(' ✓ api_server config ensured (backup saved to config.yaml.bak)') diff --git a/server/src/services/hermes-cli.ts b/server/src/services/hermes-cli.ts index cc41283c..17b5dabf 100644 --- a/server/src/services/hermes-cli.ts +++ b/server/src/services/hermes-cli.ts @@ -16,19 +16,39 @@ export interface HermesSession { tool_call_count: number input_tokens: number output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number billing_provider: string | null estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string messages?: any[] } -interface HermesSessionFull extends HermesSession { - system_prompt?: string - model_config?: string +interface HermesSessionFull { + id: string + source: string + user_id: string | null + model: string + title: string | null + started_at: number + ended_at: number | null + end_reason: string | null + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number cache_read_tokens?: number cache_write_tokens?: number reasoning_tokens?: number + billing_provider: string | null + estimated_cost_usd: number actual_cost_usd?: number | null cost_status?: string + messages?: any[] + system_prompt?: string + model_config?: string cost_source?: string pricing_version?: string | null [key: string]: any @@ -74,8 +94,13 @@ export async function listSessions(source?: string, limit?: number): Promise { tool_call_count: raw.tool_call_count, input_tokens: raw.input_tokens, output_tokens: raw.output_tokens, + cache_read_tokens: raw.cache_read_tokens || 0, + cache_write_tokens: raw.cache_write_tokens || 0, + reasoning_tokens: raw.reasoning_tokens || 0, billing_provider: raw.billing_provider, estimated_cost_usd: raw.estimated_cost_usd, + actual_cost_usd: raw.actual_cost_usd ?? null, + cost_status: raw.cost_status || '', messages: raw.messages, } } catch (err: any) { diff --git a/src/App.vue b/src/App.vue index f978ebfc..7c0531c6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -49,7 +49,7 @@ useKeyboard() .app-main { flex: 1; - overflow: hidden; + overflow-y: auto; background-color: $bg-primary; } diff --git a/src/api/sessions.ts b/src/api/sessions.ts index 2051aea7..a5453d4f 100644 --- a/src/api/sessions.ts +++ b/src/api/sessions.ts @@ -11,8 +11,13 @@ export interface SessionSummary { tool_call_count: number input_tokens: number output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number billing_provider: string | null estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string } export interface SessionDetail extends SessionSummary { diff --git a/src/assets/dance.mp4 b/src/assets/dance.mp4 new file mode 100644 index 00000000..34144c82 Binary files /dev/null and b/src/assets/dance.mp4 differ diff --git a/src/assets/output.gif b/src/assets/output.gif new file mode 100644 index 00000000..95e8bac1 Binary files /dev/null and b/src/assets/output.gif differ diff --git a/src/assets/output1.gif b/src/assets/output1.gif deleted file mode 100644 index 3362a2b6..00000000 Binary files a/src/assets/output1.gif and /dev/null differ diff --git a/src/assets/thinking.mp4 b/src/assets/thinking.mp4 new file mode 100644 index 00000000..5523eb44 Binary files /dev/null and b/src/assets/thinking.mp4 differ diff --git a/src/components/chat/ChatPanel.vue b/src/components/chat/ChatPanel.vue index ebd2385a..66c908c3 100644 --- a/src/components/chat/ChatPanel.vue +++ b/src/components/chat/ChatPanel.vue @@ -109,6 +109,57 @@ const activeSessionTitle = computed(() => chatStore.activeSession?.title || t('chat.newChat'), ) +const totalTokens = computed(() => { + const input = chatStore.activeSession?.inputTokens ?? 0 + const output = chatStore.activeSession?.outputTokens ?? 0 + return input + output +}) + +const MODEL_CONTEXT: Record = { + 'claude-opus-4': 200000, + 'claude-sonnet-4': 200000, + 'claude-haiku-4': 200000, + 'claude-3.5-sonnet': 200000, + 'claude-3.5-haiku': 200000, + 'claude-3-opus': 200000, + 'claude-3-sonnet': 200000, + 'claude-3-haiku': 200000, + 'gpt-4o': 128000, + 'gpt-4o-mini': 128000, + 'gpt-4-turbo': 128000, + 'gpt-4': 8192, + 'gpt-3.5-turbo': 16385, + 'o1': 200000, + 'o1-mini': 128000, + 'o3': 200000, + 'o3-mini': 200000, + 'o4-mini': 200000, + 'deepseek-chat': 65536, + 'deepseek-reasoner': 65536, + 'gemini-2.5-pro': 1000000, + 'gemini-2.5-flash': 1000000, + 'gemini-2.0-flash': 1000000, + 'glm-4-plus': 128000, + 'glm-4': 128000, + 'qwen-max': 128000, + 'qwen-plus': 128000, + 'qwen-turbo': 128000, +} + +const contextWindow = computed(() => { + const model = chatStore.activeSession?.model || '' + for (const [key, val] of Object.entries(MODEL_CONTEXT)) { + if (model.includes(key)) return val + } + return null +}) + +function formatTokens(n: number): string { + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M' + if (n >= 1000) return (n / 1000).toFixed(1) + 'k' + return String(n) +} + const activeSessionSource = computed(() => chatStore.activeSession?.source || '', ) @@ -310,6 +361,9 @@ async function handleRenameConfirm() { +
+ {{ formatTokens(totalTokens) }} / {{ formatTokens(contextWindow) }} +
@@ -542,4 +596,11 @@ async function handleRenameConfirm() { gap: 4px; flex-shrink: 0; } + +.context-info { + padding: 0 20px 4px; + font-size: 11px; + color: $text-muted; + flex-shrink: 0; +} diff --git a/src/components/chat/MessageItem.vue b/src/components/chat/MessageItem.vue index cc512c30..5dc043eb 100644 --- a/src/components/chat/MessageItem.vue +++ b/src/components/chat/MessageItem.vue @@ -1,84 +1,133 @@ diff --git a/src/components/usage/DailyTrend.vue b/src/components/usage/DailyTrend.vue new file mode 100644 index 00000000..c8be0677 --- /dev/null +++ b/src/components/usage/DailyTrend.vue @@ -0,0 +1,228 @@ + + + + + + + diff --git a/src/components/usage/ModelBreakdown.vue b/src/components/usage/ModelBreakdown.vue new file mode 100644 index 00000000..5d61bd30 --- /dev/null +++ b/src/components/usage/ModelBreakdown.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/components/usage/StatCards.vue b/src/components/usage/StatCards.vue new file mode 100644 index 00000000..f1784480 --- /dev/null +++ b/src/components/usage/StatCards.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 43194bc1..b64b8646 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -30,6 +30,7 @@ export default { skills: 'Skills', memory: 'Memory', logs: 'Logs', + usage: 'Usage', channels: 'Channels', settings: 'Settings', connected: 'Connected', @@ -43,6 +44,7 @@ export default { attachFiles: 'Attach files', stop: 'Stop', send: 'Send', + contextUsed: 'Context used:', sessions: 'Sessions', noSessions: 'No sessions', newChat: 'New Chat', @@ -337,4 +339,25 @@ export default { zh: '中文', en: 'English', }, + + // Usage + usage: { + title: 'Usage Statistics', + refresh: 'Refresh', + totalTokens: 'Total Tokens', + inputTokens: 'Input', + outputTokens: 'Output', + totalSessions: 'Total Sessions', + avgPerDay: '~{n}/day avg', + estimatedCost: 'Est. Cost', + cacheHitRate: 'Cache Hit Rate', + modelBreakdown: 'Model Breakdown', + dailyTrend: 'Daily Usage (Last 30 Days)', + date: 'Date', + tokens: 'Tokens', + cache: 'Cache', + sessions: 'Sessions', + cost: 'Cost', + noData: 'No usage data', + }, } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 583c9dec..14c8f4e0 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -30,6 +30,7 @@ export default { skills: '技能', memory: '记忆', logs: '日志', + usage: '用量', channels: '频道', settings: '设置', connected: '已连接', @@ -43,6 +44,7 @@ export default { attachFiles: '添加附件', stop: '停止', send: '发送', + contextUsed: '上下文已用:', sessions: '会话', noSessions: '暂无会话', newChat: '新建对话', @@ -337,4 +339,25 @@ export default { zh: '中文', en: 'English', }, + + // 用量统计 + usage: { + title: '用量统计', + refresh: '刷新', + totalTokens: '总 Token 数', + inputTokens: '输入', + outputTokens: '输出', + totalSessions: '总会话数', + avgPerDay: '日均 ~{n}', + estimatedCost: '预估费用', + cacheHitRate: '缓存命中率', + modelBreakdown: '模型分布', + dailyTrend: '每日用量(近 30 天)', + date: '日期', + tokens: 'Token', + cache: '缓存', + sessions: '会话', + cost: '费用', + noData: '暂无用量数据', + }, } diff --git a/src/router/index.ts b/src/router/index.ts index 56bb1740..fe0855db 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -23,6 +23,11 @@ const router = createRouter({ name: 'logs', component: () => import('@/views/LogsView.vue'), }, + { + path: '/usage', + name: 'usage', + component: () => import('@/views/UsageView.vue'), + }, { path: '/skills', name: 'skills', diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 53e2459e..ae67c5b7 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -37,6 +37,8 @@ export interface Session { model?: string provider?: string messageCount?: number + inputTokens?: number + outputTokens?: number } function uid(): string { @@ -147,6 +149,8 @@ function mapHermesSession(s: SessionSummary): Session { model: s.model, provider: (s as any).billing_provider || '', messageCount: s.message_count, + inputTokens: s.input_tokens, + outputTokens: s.output_tokens, } } @@ -203,6 +207,8 @@ export const useChatStore = defineStore('chat', () => { if (detail && detail.messages) { const mapped = mapHermesMessages(detail.messages) activeSession.value.messages = mapped + activeSession.value.inputTokens = detail.input_tokens + activeSession.value.outputTokens = detail.output_tokens // Update title: use Hermes title, or fallback to first user message if (detail.title) { activeSession.value.title = detail.title diff --git a/src/stores/usage.ts b/src/stores/usage.ts new file mode 100644 index 00000000..0faa4daa --- /dev/null +++ b/src/stores/usage.ts @@ -0,0 +1,141 @@ +import { fetchSessions, type SessionSummary } from '@/api/sessions' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +interface DailyUsage { + date: string + tokens: number + cache: number + sessions: number + cost: number +} + +interface ModelUsage { + model: string + inputTokens: number + outputTokens: number + cacheTokens: number + totalTokens: number + sessions: number +} + +export const useUsageStore = defineStore('usage', () => { + const sessions = ref([]) + const isLoading = ref(false) + + async function loadSessions() { + isLoading.value = true + try { + sessions.value = await fetchSessions() + } catch (err) { + console.error('Failed to load sessions for usage:', err) + } finally { + isLoading.value = false + } + } + + const totalInputTokens = computed(() => + sessions.value.reduce((sum, s) => sum + (s.input_tokens || 0), 0), + ) + + const totalOutputTokens = computed(() => + sessions.value.reduce((sum, s) => sum + (s.output_tokens || 0), 0), + ) + + const totalTokens = computed(() => totalInputTokens.value + totalOutputTokens.value) + + const totalSessions = computed(() => sessions.value.length) + + const totalCacheTokens = computed(() => + sessions.value.reduce((sum, s) => sum + (s.cache_read_tokens || 0), 0), + ) + + const cacheHitRate = computed(() => { + const total = totalInputTokens.value + if (total === 0) return null + return ((totalCacheTokens.value / total) * 100) + }) + + const estimatedCost = computed(() => + sessions.value.reduce((sum, s) => { + const cost = s.actual_cost_usd ?? s.estimated_cost_usd ?? 0 + return sum + cost + }, 0), + ) + + const modelUsage = computed(() => { + const map = new Map() + for (const s of sessions.value) { + const key = s.model || 'unknown' + if (!map.has(key)) { + map.set(key, { + model: key, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + totalTokens: 0, + sessions: 0, + }) + } + const entry = map.get(key)! + entry.inputTokens += s.input_tokens || 0 + entry.outputTokens += s.output_tokens || 0 + entry.cacheTokens += s.cache_read_tokens || 0 + entry.totalTokens += (s.input_tokens || 0) + (s.output_tokens || 0) + entry.sessions += 1 + } + return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens) + }) + + const dailyUsage = computed(() => { + const map = new Map() + const now = new Date() + + // Initialize last 30 days + for (let i = 29; i >= 0; i--) { + const d = new Date(now) + d.setDate(d.getDate() - i) + const key = d.toISOString().slice(0, 10) + map.set(key, { date: key, tokens: 0, cache: 0, sessions: 0, cost: 0 }) + } + + for (const s of sessions.value) { + const d = new Date(s.started_at * 1000) + const key = d.toISOString().slice(0, 10) + const entry = map.get(key) + if (entry) { + entry.tokens += (s.input_tokens || 0) + (s.output_tokens || 0) + entry.cache += s.cache_read_tokens || 0 + entry.sessions += 1 + const cost = s.actual_cost_usd ?? s.estimated_cost_usd ?? 0 + entry.cost += cost + } + } + + return [...map.values()] + }) + + const avgSessionsPerDay = computed(() => { + const firstDate = sessions.value.length > 0 + ? new Date(sessions.value[sessions.value.length - 1].started_at * 1000) + : new Date() + const days = Math.max(1, Math.ceil((Date.now() - firstDate.getTime()) / (1000 * 60 * 60 * 24))) + return totalSessions.value / days + }) + + return { + sessions, + isLoading, + loadSessions, + totalInputTokens, + totalOutputTokens, + totalTokens, + totalSessions, + totalCacheTokens, + cacheHitRate, + estimatedCost, + modelUsage, + dailyUsage, + avgSessionsPerDay, + } +}) diff --git a/src/views/UsageView.vue b/src/views/UsageView.vue new file mode 100644 index 00000000..4c629ca7 --- /dev/null +++ b/src/views/UsageView.vue @@ -0,0 +1,73 @@ + + + + +