feat: add usage statistics page, CLI improvements, and UI enhancements

- Add Usage Stats page with token breakdown, model distribution, and 30-day trend
- Pass through cache/cost token fields in BFF (cache_read/write_tokens, reasoning_tokens, actual_cost_usd)
- Add CLI commands: -v/--version, -h/--help, update/upgrade with auto-restart
- Auto-open browser on startup, auto-kill port conflicts (cross-platform)
- Validate all api_server config fields on startup (enabled, host, port, key, cors_origins)
- Add streaming thinking video animation with tool calls panel
- Add context token usage display (used / total) in chat header
- Sidebar: white logo area with shadow, dance video beside logo (canvas seamless loop)
- Fix sidebar nav scroll (app-main overflow-y: auto)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-14 14:47:18 +08:00
parent f8fc64ff5c
commit 9dd5fca9f9
24 changed files with 1433 additions and 137 deletions
+53 -7
View File
@@ -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
+88 -1
View File
@@ -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 <command> [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 <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], {
+1 -1
View File
@@ -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",
+15 -7
View File
@@ -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<string, any> = {
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)')
+33 -3
View File
@@ -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<Her
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 || '',
})
} catch { /* skip malformed lines */ }
}
@@ -122,8 +147,13 @@ export async function getSession(id: string): Promise<HermesSession | null> {
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) {
+1 -1
View File
@@ -49,7 +49,7 @@ useKeyboard()
.app-main {
flex: 1;
overflow: hidden;
overflow-y: auto;
background-color: $bg-primary;
}
</style>
+5
View File
@@ -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 {
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.
+61
View File
@@ -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<string, number> = {
'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() {
</header>
<MessageList />
<div v-if="contextWindow !== null" class="context-info">
<span>{{ formatTokens(totalTokens) }} / {{ formatTokens(contextWindow) }}</span>
</div>
<ChatInput />
</div>
</div>
@@ -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;
}
</style>
+137 -54
View File
@@ -1,84 +1,133 @@
<script setup lang="ts">
import type { Message } from '@/stores/chat';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import MarkdownRenderer from './MarkdownRenderer.vue';
import type { Message } from "@/stores/chat";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import MarkdownRenderer from "./MarkdownRenderer.vue";
const props = defineProps<{ message: Message }>()
const { t } = useI18n()
const props = defineProps<{ message: Message }>();
const { t } = useI18n();
const isSystem = computed(() => props.message.role === 'system')
const toolExpanded = ref(false)
const isSystem = computed(() => props.message.role === "system");
const toolExpanded = ref(false);
const timeStr = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
const d = new Date(props.message.timestamp);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
});
function isImage(type: string): boolean {
return type.startsWith('image/')
return type.startsWith("image/");
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
const hasAttachments = computed(() => (props.message.attachments?.length ?? 0) > 0)
const hasAttachments = computed(
() => (props.message.attachments?.length ?? 0) > 0,
);
const hasToolDetails = computed(() => !!(props.message.toolArgs || props.message.toolResult))
const hasToolDetails = computed(
() => !!(props.message.toolArgs || props.message.toolResult),
);
const formattedToolArgs = computed(() => {
if (!props.message.toolArgs) return ''
if (!props.message.toolArgs) return "";
try {
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2)
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2);
} catch {
return props.message.toolArgs
return props.message.toolArgs;
}
})
});
const formattedToolResult = computed(() => {
if (!props.message.toolResult) return ''
if (!props.message.toolResult) return "";
try {
const parsed = JSON.parse(props.message.toolResult)
const str = JSON.stringify(parsed, null, 2)
const parsed = JSON.parse(props.message.toolResult);
const str = JSON.stringify(parsed, null, 2);
// Truncate very long output
if (str.length > 2000) return str.slice(0, 2000) + '\n' + t('chat.truncated')
return str
if (str.length > 2000)
return str.slice(0, 2000) + "\n" + t("chat.truncated");
return str;
} catch {
const raw = props.message.toolResult
if (raw.length > 2000) return raw.slice(0, 2000) + '\n' + t('chat.truncated')
return raw
const raw = props.message.toolResult;
if (raw.length > 2000)
return raw.slice(0, 2000) + "\n" + t("chat.truncated");
return raw;
}
})
});
</script>
<template>
<div class="message" :class="[message.role]">
<template v-if="message.role === 'tool'">
<div class="tool-line" :class="{ expandable: hasToolDetails }" @click="hasToolDetails && (toolExpanded = !toolExpanded)">
<svg v-if="hasToolDetails" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="tool-chevron" :class="{ rotated: toolExpanded }"><polyline points="9 18 15 12 9 6"/></svg>
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="tool-icon"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<div
class="tool-line"
:class="{ expandable: hasToolDetails }"
@click="hasToolDetails && (toolExpanded = !toolExpanded)"
>
<svg
v-if="hasToolDetails"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="tool-chevron"
:class="{ rotated: toolExpanded }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<svg
v-else
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-icon"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
<span class="tool-name">{{ message.toolName }}</span>
<span v-if="message.toolPreview && !toolExpanded" class="tool-preview">{{ message.toolPreview }}</span>
<span v-if="message.toolStatus === 'running'" class="tool-spinner"></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{ t('chat.error') }}</span>
<span
v-if="message.toolPreview && !toolExpanded"
class="tool-preview"
>{{ message.toolPreview }}</span
>
<span
v-if="message.toolStatus === 'running'"
class="tool-spinner"
></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{
t("chat.error")
}}</span>
</div>
<div v-if="toolExpanded && hasToolDetails" class="tool-details">
<div v-if="formattedToolArgs" class="tool-detail-section">
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
<div class="tool-detail-label">{{ t("chat.arguments") }}</div>
<pre class="tool-detail-code">{{ formattedToolArgs }}</pre>
</div>
<div v-if="formattedToolResult" class="tool-detail-section">
<div class="tool-detail-label">{{ t('chat.result') }}</div>
<div class="tool-detail-label">{{ t("chat.result") }}</div>
<pre class="tool-detail-code">{{ formattedToolResult }}</pre>
</div>
</div>
</template>
<template v-else>
<div class="msg-body">
<img v-if="message.role === 'assistant'" src="/assets/logo.png" alt="Hermes" class="msg-avatar" />
<img
v-if="message.role === 'assistant'"
src="/assets/logo.png"
alt="Hermes"
class="msg-avatar"
/>
<div class="msg-content" :class="message.role">
<div class="message-bubble" :class="{ system: isSystem }">
<div v-if="hasAttachments" class="msg-attachments">
@@ -89,22 +138,41 @@ const formattedToolResult = computed(() => {
:class="{ image: isImage(att.type) }"
>
<template v-if="isImage(att.type) && att.url">
<img :src="att.url" :alt="att.name" class="msg-attachment-thumb" />
<img
:src="att.url"
:alt="att.name"
class="msg-attachment-thumb"
/>
</template>
<template v-else>
<div class="msg-attachment-file">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="att-name">{{ att.name }}</span>
<span class="att-size">{{ formatSize(att.size) }}</span>
</div>
</template>
</div>
</div>
<MarkdownRenderer v-if="message.content" :content="message.content" />
<span v-if="message.isStreaming" class="streaming-cursor"></span>
<div v-if="message.isStreaming && !message.content" class="streaming-dots">
<MarkdownRenderer
v-if="message.content"
:content="message.content"
/>
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
<span></span><span></span><span></span>
</div>
</span>
</div>
<div class="message-time">{{ timeStr }}</div>
</div>
@@ -114,7 +182,7 @@ const formattedToolResult = computed(() => {
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
@use "@/styles/variables" as *;
.message {
display: flex;
@@ -147,9 +215,8 @@ const formattedToolResult = computed(() => {
}
.msg-avatar {
width: 28px;
height: 28px;
border-radius: 4px;
width: 40px;
height: 40px;
flex-shrink: 0;
margin-top: 2px;
}
@@ -345,7 +412,9 @@ const formattedToolResult = computed(() => {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.streaming-cursor {
@@ -376,12 +445,26 @@ const formattedToolResult = computed(() => {
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
</style>
+128 -20
View File
@@ -1,13 +1,30 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import MessageItem from './MessageItem.vue'
import { useChatStore } from '@/stores/chat'
import thinkingVideo from '@/assets/thinking.mp4'
const chatStore = useChatStore()
const { t } = useI18n()
const listRef = ref<HTMLElement>()
const displayMessages = computed(() =>
chatStore.messages.filter(m => m.role !== 'tool'),
)
const currentToolCalls = computed(() => {
const msgs = chatStore.messages
// Find the last user message index
let lastUserIdx = -1
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') { lastUserIdx = i; break }
}
// Only tool calls after the last user message, newest on top
const tools = msgs.filter((m, i) => m.role === 'tool' && i > lastUserIdx)
return [...tools].reverse()
})
function scrollToBottom() {
nextTick(() => {
if (listRef.value) {
@@ -19,6 +36,7 @@ function scrollToBottom() {
watch(() => chatStore.messages.length, scrollToBottom)
watch(() => chatStore.messages[chatStore.messages.length - 1]?.content, scrollToBottom)
watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
watch(currentToolCalls, scrollToBottom)
</script>
<template>
@@ -28,13 +46,38 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
<p>{{ t('chat.emptyState') }}</p>
</div>
<MessageItem
v-for="msg in chatStore.messages"
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
/>
<div v-if="chatStore.isStreaming" class="streaming-indicator">
<span></span><span></span><span></span>
</div>
<Transition name="fade">
<div v-if="chatStore.isStreaming" class="streaming-indicator">
<video :src="thinkingVideo" autoplay loop muted playsinline class="thinking-video" />
<div v-if="currentToolCalls.length > 0" class="tool-calls-panel">
<div
v-for="tc in currentToolCalls"
:key="tc.id"
class="tool-call-item"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span class="tool-call-name">{{ tc.toolName }}</span>
<span v-if="tc.toolPreview" class="tool-call-preview">{{ tc.toolPreview }}</span>
<span v-if="tc.toolStatus === 'running'" class="tool-call-spinner"></span>
<span v-if="tc.toolStatus === 'error'" class="tool-call-error">{{ t('chat.error') }}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
@@ -48,6 +91,7 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
display: flex;
flex-direction: column;
gap: 16px;
background-color: #ffffff;
}
.empty-state {
@@ -70,27 +114,91 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.streaming-indicator {
display: flex;
align-items: center;
gap: 4px;
align-items: flex-start;
gap: 12px;
padding: 4px;
color: $text-muted;
span {
width: 5px;
height: 5px;
background-color: $text-muted;
border-radius: 50%;
animation: stream-pulse 1.4s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
.thinking-video {
width: 120px;
height: 120px;
border-radius: $radius-md;
object-fit: contain;
flex-shrink: 0;
}
}
@keyframes stream-pulse {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
.tool-calls-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
padding-top: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.tool-call-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-secondary;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.03);
border-radius: $radius-sm;
.tool-call-icon {
flex-shrink: 0;
color: $text-muted;
}
.tool-call-name {
font-family: $font-code;
flex-shrink: 0;
}
.tool-call-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
color: $text-muted;
}
}
.tool-call-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-call-error {
font-size: 9px;
color: $error;
background: rgba($error, 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+224 -43
View File
@@ -1,20 +1,67 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ModelSelector from './ModelSelector.vue'
import LanguageSwitch from './LanguageSwitch.vue'
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useAppStore } from "@/stores/app";
import ModelSelector from "./ModelSelector.vue";
import LanguageSwitch from "./LanguageSwitch.vue";
import danceVideo from "@/assets/dance.mp4";
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const canvasRef = ref<HTMLCanvasElement>();
const selectedKey = computed(() => route.name as string)
const selectedKey = computed(() => route.name as string);
onMounted(() => {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const video = document.createElement("video");
video.src = danceVideo;
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.addEventListener("loadeddata", () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
});
function draw() {
if (video.readyState >= 2 && ctx && canvas) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
}
if (video.currentTime >= video.duration - 0.05) {
video.currentTime = 0;
}
requestAnimationFrame(draw);
}
video.addEventListener("canplay", () => {
draw();
});
video.play();
const onVisible = () => {
if (document.visibilityState === "visible" && video.paused) {
video.play();
}
};
document.addEventListener("visibilitychange", onVisible);
onUnmounted(() => {
document.removeEventListener("visibilitychange", onVisible);
});
});
function handleNav(key: string) {
router.push({ name: key })
router.push({ name: key });
}
</script>
@@ -23,6 +70,7 @@ function handleNav(key: string) {
<div class="sidebar-logo" @click="router.push('/')">
<img src="/assets/logo.png" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
<canvas ref="canvasRef" class="logo-dance" />
</div>
<nav class="sidebar-nav">
@@ -31,10 +79,21 @@ function handleNav(key: string) {
:class="{ active: selectedKey === 'chat' }"
@click="handleNav('chat')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
<span>{{ t('sidebar.chat') }}</span>
<span>{{ t("sidebar.chat") }}</span>
</button>
<button
@@ -42,13 +101,22 @@ function handleNav(key: string) {
:class="{ active: selectedKey === 'jobs' }"
@click="handleNav('jobs')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>{{ t('sidebar.jobs') }}</span>
<span>{{ t("sidebar.jobs") }}</span>
</button>
<button
@@ -56,14 +124,27 @@ function handleNav(key: string) {
:class="{ active: selectedKey === 'models' }"
@click="handleNav('models')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" /><path d="M12 19v4" />
<path d="M1 12h4" /><path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" /><path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" /><path d="M16.95 7.05l2.83-2.83" />
<path d="M12 1v4" />
<path d="M12 19v4" />
<path d="M1 12h4" />
<path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" />
<path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" />
<path d="M16.95 7.05l2.83-2.83" />
</svg>
<span>{{ t('sidebar.models') }}</span>
<span>{{ t("sidebar.models") }}</span>
</button>
<button
@@ -71,10 +152,19 @@ function handleNav(key: string) {
:class="{ active: selectedKey === 'channels' }"
@click="handleNav('channels')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>{{ t('sidebar.channels') }}</span>
<span>{{ t("sidebar.channels") }}</span>
</button>
<button
@@ -82,12 +172,21 @@ function handleNav(key: string) {
:class="{ active: selectedKey === 'skills' }"
@click="handleNav('skills')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>{{ t('sidebar.skills') }}</span>
<span>{{ t("sidebar.skills") }}</span>
</button>
<button
@@ -95,12 +194,21 @@ function handleNav(key: string) {
:class="{ active: selectedKey === 'memory' }"
@click="handleNav('memory')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>{{ t('sidebar.memory') }}</span>
<span>{{ t("sidebar.memory") }}</span>
</button>
<button
@@ -108,14 +216,47 @@ function handleNav(key: string) {
:class="{ active: selectedKey === 'logs' }"
@click="handleNav('logs')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>{{ t('sidebar.logs') }}</span>
<span>{{ t("sidebar.logs") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'usage' }"
@click="handleNav('usage')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
<span>{{ t("sidebar.usage") }}</span>
</button>
<button
@@ -123,11 +264,22 @@ function handleNav(key: string) {
:class="{ active: selectedKey === 'settings' }"
@click="handleNav('settings')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<span>{{ t('sidebar.settings') }}</span>
<span>{{ t("sidebar.settings") }}</span>
</button>
</nav>
@@ -135,19 +287,31 @@ function handleNav(key: string) {
<div class="sidebar-footer">
<div class="status-row">
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
<div
class="status-indicator"
:class="{
connected: appStore.connected,
disconnected: !appStore.connected,
}"
>
<span class="status-dot"></span>
<span class="status-text">{{ appStore.connected ? t('sidebar.connected') : t('sidebar.disconnected') }}</span>
<span class="status-text">{{
appStore.connected
? t("sidebar.connected")
: t("sidebar.disconnected")
}}</span>
</div>
<LanguageSwitch />
</div>
<div class="version-info">Hermes {{ appStore.serverVersion || 'v0.1.0' }}</div>
<div class="version-info">
Hermes {{ appStore.serverVersion || "v0.1.0" }}
</div>
</div>
</aside>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
@use "@/styles/variables" as *;
.sidebar {
width: $sidebar-width;
@@ -156,7 +320,7 @@ function handleNav(key: string) {
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
padding: 20px 12px;
padding: 0 12px 20px;
flex-shrink: 0;
transition: width $transition-normal;
}
@@ -164,7 +328,7 @@ function handleNav(key: string) {
.logo-img {
width: 28px;
height: 28px;
border-radius: 50%;
border-radius: 0;
flex-shrink: 0;
}
@@ -172,20 +336,38 @@ function handleNav(key: string) {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 12px 20px;
padding: 20px 12px;
margin: 0 -12px;
color: $text-primary;
cursor: pointer;
background-color: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
.logo-text {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.logo-dance {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
height: 100px;
border-radius: $radius-md;
object-fit: contain;
flex-shrink: 0;
width: auto;
}
}
.sidebar-nav {
flex: 1;
display: flex;
padding-top: 12px;
flex-direction: column;
gap: 4px;
}
@@ -260,5 +442,4 @@ function handleNav(key: string) {
font-size: 11px;
color: $text-muted;
}
</style>
+228
View File
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
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)
}
function formatCost(n: number): string {
if (n === 0) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
const maxTokens = computed(() =>
Math.max(...usageStore.dailyUsage.map(d => d.tokens), 1),
)
</script>
<script lang="ts">
import { computed } from 'vue'
</script>
<template>
<div class="daily-trend">
<h3 class="section-title">{{ t('usage.dailyTrend') }}</h3>
<div class="bar-chart">
<div
v-for="d in usageStore.dailyUsage"
:key="d.date"
class="bar-col"
>
<div class="bar-track">
<div
class="bar-fill"
:style="{ height: (d.tokens / maxTokens * 100) + '%' }"
/>
</div>
<div class="bar-tooltip">
<div class="tooltip-date">{{ d.date }}</div>
<div class="tooltip-row">{{ t('usage.tokens') }}: {{ formatTokens(d.tokens) }}</div>
<div class="tooltip-row">{{ t('usage.cache') }}: {{ formatTokens(d.cache) }}</div>
<div class="tooltip-row">{{ t('usage.sessions') }}: {{ d.sessions }}</div>
<div class="tooltip-row">{{ t('usage.cost') }}: {{ formatCost(d.cost) }}</div>
</div>
</div>
</div>
<div class="bar-dates">
<span>{{ usageStore.dailyUsage[0]?.date.slice(5) }}</span>
<span>{{ usageStore.dailyUsage[usageStore.dailyUsage.length - 1]?.date.slice(5) }}</span>
</div>
<div class="trend-table">
<table>
<thead>
<tr>
<th>{{ t('usage.date') }}</th>
<th>{{ t('usage.tokens') }}</th>
<th>{{ t('usage.cache') }}</th>
<th>{{ t('usage.sessions') }}</th>
<th>{{ t('usage.cost') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="d in [...usageStore.dailyUsage].reverse().slice(0, 30)" :key="d.date">
<td>{{ d.date }}</td>
<td>{{ formatTokens(d.tokens) }}</td>
<td>{{ formatTokens(d.cache) }}</td>
<td>{{ d.sessions }}</td>
<td>{{ formatCost(d.cost) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.daily-trend {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: $text-secondary;
margin: 0 0 12px;
}
.bar-chart {
display: flex;
gap: 2px;
margin-bottom: 16px;
}
.bar-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.bar-track {
width: 100%;
height: 140px;
background: $bg-secondary;
border-radius: 2px 2px 0 0;
display: flex;
align-items: flex-end;
}
.bar-fill {
width: 100%;
background: $text-primary;
border-radius: 2px 2px 0 0;
min-height: 0;
transition: height 0.3s ease;
}
.bar-col {
position: relative;
}
.bar-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: $text-primary;
color: #fff;
padding: 6px 10px;
border-radius: $radius-sm;
font-size: 11px;
white-space: nowrap;
z-index: 10;
pointer-events: none;
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: $text-primary;
}
}
.bar-col:hover .bar-tooltip {
display: block;
}
.tooltip-date {
font-weight: 600;
margin-bottom: 2px;
}
.tooltip-row {
font-size: 10px;
opacity: 0.85;
line-height: 1.5;
}
.bar-label {
display: none;
}
.bar-dates {
display: flex;
justify-content: space-between;
font-size: 10px;
color: $text-muted;
margin-top: 4px;
margin-bottom: 16px;
}
.trend-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
thead {
position: sticky;
top: 0;
}
th {
text-align: left;
padding: 8px 10px;
font-weight: 600;
color: $text-muted;
border-bottom: 1px solid $border-color;
background: $bg-card;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
td {
padding: 6px 10px;
color: $text-secondary;
border-bottom: 1px solid $border-light;
font-family: $font-code;
font-size: 11px;
}
tr:last-child td {
border-bottom: none;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
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)
}
</script>
<template>
<div class="model-breakdown">
<h3 class="section-title">{{ t('usage.modelBreakdown') }}</h3>
<div class="model-list">
<div v-for="m in usageStore.modelUsage" :key="m.model" class="model-row">
<span class="model-name">{{ m.model }}</span>
<div class="model-bar-wrap">
<div
class="model-bar"
:style="{ width: (m.totalTokens / usageStore.modelUsage[0].totalTokens * 100) + '%' }"
/>
</div>
<span class="model-tokens">{{ formatTokens(m.totalTokens) }}</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.model-breakdown {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
margin-bottom: 20px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: $text-secondary;
margin: 0 0 12px;
}
.model-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-row {
display: flex;
align-items: center;
gap: 10px;
}
.model-name {
font-size: 12px;
font-family: $font-code;
color: $text-secondary;
width: 140px;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-bar-wrap {
flex: 1;
height: 16px;
background: $bg-secondary;
border-radius: 3px;
overflow: hidden;
}
.model-bar {
height: 100%;
background: $text-primary;
border-radius: 3px;
min-width: 2px;
transition: width 0.3s ease;
}
.model-tokens {
font-size: 12px;
color: $text-muted;
width: 60px;
text-align: right;
flex-shrink: 0;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
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)
}
function formatCost(n: number): string {
if (n === 0) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
</script>
<template>
<div class="stat-cards">
<div class="stat-card">
<div class="stat-label">{{ t('usage.totalTokens') }}</div>
<div class="stat-value">{{ formatTokens(usageStore.totalTokens) }}</div>
<div class="stat-sub">
{{ formatTokens(usageStore.totalInputTokens) }} {{ t('usage.inputTokens') }} /
{{ formatTokens(usageStore.totalOutputTokens) }} {{ t('usage.outputTokens') }}
</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.totalSessions') }}</div>
<div class="stat-value">{{ usageStore.totalSessions }}</div>
<div class="stat-sub">{{ t('usage.avgPerDay', { n: usageStore.avgSessionsPerDay.toFixed(1) }) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.estimatedCost') }}</div>
<div class="stat-value">{{ formatCost(usageStore.estimatedCost) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.cacheHitRate') }}</div>
<div class="stat-value">{{ usageStore.cacheHitRate !== null ? usageStore.cacheHitRate.toFixed(1) + '%' : '--' }}</div>
<div class="stat-sub" v-if="usageStore.cacheHitRate !== null">
{{ formatTokens(usageStore.totalCacheTokens) }} tokens
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
}
.stat-label {
font-size: 12px;
color: $text-muted;
margin-bottom: 6px;
}
.stat-value {
font-size: 22px;
font-weight: 600;
color: $text-primary;
line-height: 1.2;
}
.stat-sub {
font-size: 11px;
color: $text-muted;
margin-top: 4px;
}
@media (max-width: 768px) {
.stat-cards {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+23
View File
@@ -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',
},
}
+23
View File
@@ -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: '暂无用量数据',
},
}
+5
View File
@@ -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',
+6
View File
@@ -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
+141
View File
@@ -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<SessionSummary[]>([])
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<ModelUsage[]>(() => {
const map = new Map<string, ModelUsage>()
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<DailyUsage[]>(() => {
const map = new Map<string, DailyUsage>()
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,
}
})
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/usage'
import StatCards from '@/components/usage/StatCards.vue'
import ModelBreakdown from '@/components/usage/ModelBreakdown.vue'
import DailyTrend from '@/components/usage/DailyTrend.vue'
const { t } = useI18n()
const usageStore = useUsageStore()
onMounted(() => {
usageStore.loadSessions()
})
</script>
<template>
<div class="usage-view">
<header class="usage-header">
<h2 class="usage-title">{{ t('usage.title') }}</h2>
<NButton size="small" quaternary :loading="usageStore.isLoading" @click="usageStore.loadSessions()">
{{ t('usage.refresh') }}
</NButton>
</header>
<div v-if="usageStore.isLoading && usageStore.sessions.length === 0" class="usage-loading">
{{ t('common.loading') }}
</div>
<template v-else-if="usageStore.sessions.length > 0">
<StatCards />
<ModelBreakdown />
<DailyTrend />
</template>
<div v-else class="usage-empty">
{{ t('usage.noData') }}
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.usage-view {
padding: 24px;
max-width: 960px;
margin: 0 auto;
}
.usage-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.usage-title {
font-size: 18px;
font-weight: 600;
color: $text-primary;
margin: 0;
}
.usage-loading,
.usage-empty {
text-align: center;
padding: 60px 0;
color: $text-muted;
font-size: 14px;
}
</style>