mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-26 14:00:14 +00:00
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:
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
@@ -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
@@ -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
@@ -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)')
|
||||
|
||||
@@ -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
@@ -49,7 +49,7 @@ useKeyboard()
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
background-color: $bg-primary;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: '暂无用量数据',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user