feat: add Koa2 BFF server, CLI management, sessions CLI integration, and logs page

- Add Koa2 BFF layer for API proxy, file upload, session management
- Auto-check and enable api_server in ~/.hermes/config.yaml on startup
- Integrate sessions with Hermes CLI (list, get, delete)
- Add Logs page with level filtering, log file selection, and search
- Add CLI commands: start/stop/restart/status for daemon management
- Unify package.json for frontend and server dependencies
- Default port changed to 8648

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-11 21:33:04 +08:00
parent a2f8f6aec5
commit ee9f56dfbd
25 changed files with 1613 additions and 713 deletions
+6
View File
@@ -10,8 +10,14 @@ package-lock.json
node_modules
dist
dist-ssr
server/dist
*.local
# Server data
server/data/
server/node_modules/
.hermes-web-ui/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+93 -389
View File
@@ -1,6 +1,6 @@
# Hermes Web UI
Web dashboard for [Hermes Agent](https://github.com/EKKOLearnAI/hermes-agent) — chat interaction and scheduled job management.
Web dashboard for [Hermes Agent](https://github.com/EKKOLearnAI/hermes-agent) — chat interaction, session management, scheduled jobs, and log viewing.
## Tech Stack
@@ -10,42 +10,35 @@ Web dashboard for [Hermes Agent](https://github.com/EKKOLearnAI/hermes-agent)
- **Naive UI** — Component library
- **Pinia** — State management
- **Vue Router** — Routing (Hash mode)
- **Koa 2** — BFF server (API proxy, file upload, session management)
- **SCSS** — Style preprocessor
- **markdown-it** + **highlight.js** — Markdown rendering and code highlighting
## Getting Started
### 1. Configure API Server
Edit `~/.hermes/config.yaml` and enable the API Server:
```yaml
platforms:
api_server:
enabled: true
host: "127.0.0.1"
port: 8642
key: ""
cors_origins: "*"
```
Restart the Gateway to apply changes:
## Install and Run
```bash
hermes gateway restart
```
### 2. Install and Run
```bash
# Global install
npm install -g hermes-web-ui
# Start the web dashboard (default http://localhost:8648)
hermes-web-ui start
```
### Development Mode
Open http://localhost:8648
### CLI Commands
| Command | Description |
|---------|-------------|
| `hermes-web-ui start` | Start in background (daemon mode) |
| `hermes-web-ui start --port 9000` | Start on custom port |
| `hermes-web-ui stop` | Stop background process |
| `hermes-web-ui restart` | Restart background process |
| `hermes-web-ui status` | Check if running |
| `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.
## Development
```bash
git clone https://github.com/EKKOLearnAI/hermes-web-ui.git
@@ -54,386 +47,97 @@ npm install
npm run dev
```
This starts:
- Frontend: http://localhost:5173
- BFF Server: http://localhost:8648 (proxies to Hermes on 8642)
### Build
```bash
npm run build
```
Outputs to `dist/` (frontend + compiled BFF server).
## Project Structure
```
src/
├── api/
── client.ts # HTTP client (fetch + Bearer Auth)
│ ├── chat.ts # Chat API (startRun + SSE event stream)
│ ├── jobs.ts # Scheduled job CRUD
── system.ts # Health check, model list
├── stores/
│ ├── app.ts # Global state (connection, version, models)
│ ├── chat.ts # Chat state (messages, sessions, streaming)
└── jobs.ts # Job state (list, CRUD operations)
├── components/
├── layout/
│ └── AppSidebar.vue # Sidebar navigation
├── chat/
│ │ ├── ChatPanel.vue # Chat panel (session list + chat area)
│ ├── MessageList.vue # Message list (auto-scroll, loading animation)
│ ├── MessageItem.vue # Single message (user/AI/tool/system)
│ ├── ChatInput.vue # Input box (Enter to send, Shift+Enter for newline)
│ │ ── MarkdownRenderer.vue # Markdown renderer (code highlighting, copy)
└── jobs/
── JobsPanel.vue # Job panel
├── JobCard.vue # Job card
── JobFormModal.vue # Create/edit job modal
├── views/
├── ChatView.vue # Chat page
│ └── JobsView.vue # Jobs page
── router/
── index.ts # Router configuration
├── styles/
── variables.scss # SCSS design tokens
│ ├── global.scss # Global styles
│ └── theme.ts # Naive UI theme overrides
├── composables/
│ └── useKeyboard.ts # Keyboard shortcuts
└── main.ts # App entry point
hermes-web-ui/
├── bin/
── hermes-web-ui.mjs # CLI entry (start/stop/restart/status)
├── server/src/
│ ├── index.ts # BFF entry (Koa app bootstrap)
── config.ts # Configuration (port, upstream, etc.)
│ ├── routes/
│ ├── proxy.ts # API proxy to Hermes (/api/*, /v1/*)
│ ├── upload.ts # File upload (POST /upload)
│ ├── sessions.ts # Session management via Hermes CLI
│ │ ├── webhook.ts # Webhook receiver
│ └── logs.ts # Log file listing and reading
│ └── services/
└── hermes-cli.ts # Hermes CLI wrapper (sessions, logs, version)
├── src/
│ ├── api/ # Frontend API layer
│ ├── stores/ # Pinia state management
│ ├── components/
│ │ ── layout/AppSidebar.vue # Sidebar navigation
│ ├── chat/ # Chat components
── jobs/ # Job components
├── views/
── ChatView.vue # Chat page
│ │ ├── JobsView.vue # Jobs page
│ └── LogsView.vue # Logs page
│ └── router/index.ts # Router configuration
── dist/ # Build output (published to npm)
── server/index.js # Compiled BFF
├── index.html # Frontend entry
── assets/ # Frontend static assets
```
## Features
### Chat
- Async Run + SSE event stream via `/v1/runs` + `/v1/runs/{id}/events`
- Real-time streaming output with tool call progress visualization
- Multi-session management with localStorage persistence
- Markdown rendering with syntax highlighting and one-click code copy
- Async Run + SSE event streaming via BFF proxy
- Session management via Hermes CLI
- Multi-session switching with message history
- Markdown rendering with syntax highlighting and code copy
- File upload support (saved to temp, path passed to API)
### Scheduled Jobs
- Job list view (including paused/disabled jobs)
- Create, edit, and delete jobs
- Pause and resume jobs
- Create, edit, pause, resume, and delete jobs
- Trigger immediate job execution
- Cron expression quick presets
### Logs
- View Hermes agent/gateway/error logs
- Filter by log level, log file, and search keyword
- Structured log parsing with HTTP access log highlighting
### Other
- Real-time connection status monitoring
- Hermes version display in sidebar
- Auto config check on startup
- Minimalist dark theme
- Real-time connection status monitoring (30s polling)
- Minimalist black-and-white theme
- Keyboard shortcuts (Ctrl+N for new chat, Ctrl+J for jobs)
## Architecture
```
Browser → BFF (Koa, :8648) → Hermes API (:8642)
Hermes CLI (sessions, logs, version)
```
The BFF layer handles:
- API proxy to Hermes (with header forwarding)
- SSE streaming passthrough
- File upload to temp directory
- Session CRUD via Hermes CLI
- Log file reading and parsing
- Static file serving (SPA fallback)
---
## API Reference
Base URL: `http://127.0.0.1:8642`
### Authentication
All endpoints except `/health` support Bearer Token authentication (if `key` is configured on the server):
```
Authorization: Bearer <your-api-key>
```
When no key is configured, all requests are allowed without authentication.
### Error Format
```json
{
"error": {
"message": "Error description",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}
```
| Status Code | Description |
|-------------|-------------|
| 200 | Success |
| 400 | Bad request |
| 401 | Invalid API key |
| 404 | Not found |
| 413 | Request body too large (max 1MB) |
| 429 | Concurrent run limit exceeded (max 10 runs) |
| 500 | Internal server error |
---
### 1. Health Check
**GET** `/health` or `/v1/health`
No authentication required.
```json
{"status": "ok", "platform": "hermes-agent"}
```
---
### 2. Model List
**GET** `/v1/models`
```json
{
"object": "list",
"data": [
{
"id": "hermes-agent",
"object": "model",
"created": 1744348800,
"owned_by": "hermes"
}
]
}
```
---
### 3. Chat Completions (OpenAI Compatible)
**POST** `/v1/chat/completions`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| messages | array | Y | Message array, same format as OpenAI |
| stream | boolean | N | Enable streaming, default false |
| model | string | N | Model name, default "hermes-agent" |
Optional header: `X-Hermes-Session-Id` to specify a session ID.
**stream=false response:**
```json
{
"id": "chatcmpl-xxxxx",
"object": "chat.completion",
"created": 1744348800,
"model": "hermes-agent",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Response content"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}
}
```
**stream=true response:** SSE stream (`Content-Type: text/event-stream`)
```
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"Hello"},"index":0}]}
data: [DONE]
```
---
### 4. Responses (Stateful Chained Conversations)
**POST** `/v1/responses`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| input | string / array | Y | User input |
| instructions | string | N | System instructions |
| previous_response_id | string | N | Previous response ID for chained conversation |
| conversation | string | N | Conversation name, auto-chains to latest response |
| conversation_history | array | N | Explicit conversation history |
| store | boolean | N | Whether to store the response, default true |
| truncation | string | N | Set to "auto" to truncate history to 100 messages |
| model | string | N | Model name |
> `conversation` and `previous_response_id` are mutually exclusive.
Optional header: `Idempotency-Key` for idempotency.
```json
{
"id": "resp_xxx",
"object": "response",
"status": "completed",
"created_at": 1744348800,
"output": [{"type": "message", "role": "assistant", "content": "Response content"}],
"usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}
}
```
---
### 5. Get / Delete Stored Responses
**GET** `/v1/responses/{response_id}` — Get a stored response
**DELETE** `/v1/responses/{response_id}` — Delete a stored response
```json
{"id": "resp_xxx", "object": "response", "deleted": true}
```
---
### 6. Start Async Run
**POST** `/v1/runs`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| input | string / array | Y | User input |
| instructions | string | N | System instructions |
| previous_response_id | string | N | Chained conversation ID |
| conversation_history | array | N | Conversation history |
| session_id | string | N | Session ID, defaults to run_id |
```json
{"run_id": "run_xxx", "status": "started"}
```
---
### 7. SSE Event Stream
**GET** `/v1/runs/{run_id}/events`
`Content-Type: text/event-stream`
**Event types:**
| Event | Description |
|-------|-------------|
| `run.started` | Run started |
| `message.delta` | Message content fragment (field `delta`) |
| `tool.started` | Tool call started (fields `tool`, `preview`) |
| `tool.completed` | Tool call completed (fields `tool`, `duration`) |
| `run.completed` | Run completed (fields `output`, `usage`) |
| `run.failed` | Run failed (field `error`) |
Example:
```
data: {"event":"message.delta","run_id":"run_xxx","delta":"Hello","timestamp":...}
data: {"event":"tool.started","run_id":"run_xxx","tool":"browser_navigate","preview":"https://...","timestamp":...}
data: {"event":"tool.completed","run_id":"run_xxx","tool":"browser_navigate","duration":3.8,"timestamp":...}
data: {"event":"run.completed","run_id":"run_xxx","output":"Full response","usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}
```
---
### 8. Scheduled Jobs
#### List Jobs
**GET** `/api/jobs?include_disabled=true`
```json
{
"jobs": [
{
"job_id": "61a5eb0baeb9",
"name": "Job name",
"schedule": "0 9 * * *",
"repeat": "forever",
"deliver": "origin",
"next_run_at": "2026-04-12T09:00:00+08:00",
"last_run_at": "2026-04-11T09:04:25+08:00",
"last_status": "ok",
"enabled": true,
"state": "scheduled",
"prompt_preview": "...",
"skills": []
}
]
}
```
#### Create Job
**POST** `/api/jobs`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Y | Job name (max 200 characters) |
| schedule | string | Y | Cron expression |
| prompt | string | N | Job prompt |
| deliver | string | N | Delivery target (origin / local / telegram / discord) |
| skills | array | N | Skill name array |
| repeat | integer | N | Repeat count, omit for indefinite |
Response is wrapped in `{"job": {...}}`.
#### Get Job Detail
**GET** `/api/jobs/{job_id}`
#### Update Job
**PATCH** `/api/jobs/{job_id}`
Updatable fields: `name`, `schedule`, `prompt`, `deliver`, `skills`, `repeat`, `enabled`
#### Delete Job
**DELETE** `/api/jobs/{job_id}`
```json
{"ok": true}
```
#### Pause Job
**POST** `/api/jobs/{job_id}/pause`
```json
{"job": {"job_id": "xxx", "enabled": false, "state": "paused", ...}}
```
#### Resume Job
**POST** `/api/jobs/{job_id}/resume`
```json
{"job": {"job_id": "xxx", "enabled": true, "state": "scheduled", ...}}
```
#### Trigger Job Now
**POST** `/api/jobs/{job_id}/run`
```json
{"job": {"job_id": "xxx", "state": "scheduled", ...}}
```
---
## Quick Test
```bash
# Health check
curl http://127.0.0.1:8642/health
# Model list
curl http://127.0.0.1:8642/v1/models
# Chat Completions
curl -X POST http://127.0.0.1:8642/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Hello"}]}'
# Start async Run
curl -X POST http://127.0.0.1:8642/v1/runs \
-H "Content-Type: application/json" \
-d '{"input":"Hello"}'
# Listen to Run event stream
curl http://127.0.0.1:8642/v1/runs/{run_id}/events
# List jobs (including disabled)
curl "http://127.0.0.1:8642/api/jobs?include_disabled=true"
# Create job
curl -X POST http://127.0.0.1:8642/api/jobs \
-H "Content-Type: application/json" \
-d '{"name":"Test Job","schedule":"0 9 * * *","prompt":"Run test"}'
# Pause / Resume / Trigger / Delete
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/pause
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/resume
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/run
curl -X DELETE http://127.0.0.1:8642/api/jobs/{job_id}
```
## License
[MIT](./LICENSE)
+108 -154
View File
@@ -1,185 +1,139 @@
#!/usr/bin/env node
import { createServer as createViteServer } from 'http'
import { spawn } from 'child_process'
import { resolve, dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { readFile, stat, readdir, writeFile, mkdir } from 'fs/promises'
import { tmpdir } from 'os'
import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync } from 'fs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const distDir = resolve(__dirname, '..', 'dist')
const API_TARGET = 'http://127.0.0.1:8642'
const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js')
const PID_DIR = resolve(__dirname, '..', '.hermes-web-ui')
const PID_FILE = join(PID_DIR, 'server.pid')
const LOG_FILE = join(PID_DIR, 'server.log')
const DEFAULT_PORT = 8648
const UPLOAD_DIR = join(tmpdir(), 'hermes-uploads')
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
function getPort() {
if (process.argv[3] && !isNaN(process.argv[3])) return parseInt(process.argv[3])
if (process.argv.includes('--port')) return parseInt(process.argv[process.argv.indexOf('--port') + 1])
return DEFAULT_PORT
}
function getMimeType(filePath) {
const ext = filePath.substring(filePath.lastIndexOf('.'))
return MIME_TYPES[ext] || 'application/octet-stream'
}
async function ensureUploadDir() {
await mkdir(UPLOAD_DIR, { recursive: true })
}
async function serveStatic(reqPath, res) {
let filePath = join(distDir, reqPath)
function getPid() {
try {
const s = await stat(filePath)
if (s.isDirectory()) filePath = join(filePath, 'index.html')
const data = await readFile(filePath)
res.writeHead(200, {
'Content-Type': getMimeType(filePath),
'Cache-Control': 'public, max-age=3600',
})
res.end(data)
return parseInt(readFileSync(PID_FILE, 'utf-8').trim())
} catch {
// SPA fallback
try {
const data = await readFile(join(distDir, 'index.html'))
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(data)
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
}
return null
}
}
async function handleUpload(req, res) {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Method not allowed' }))
return
}
const contentType = req.headers['content-type'] || ''
if (!contentType.startsWith('multipart/form-data')) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Expected multipart/form-data' }))
return
}
function isRunning(pid) {
try {
await ensureUploadDir()
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString()
const boundary = '--' + contentType.split('boundary=')[1]
const parts = body.split(boundary).slice(1, -1)
const results = []
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const header = part.substring(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2)
const nameMatch = header.match(/name="([^"]+)"/)
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!nameMatch || !filenameMatch) continue
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = join(UPLOAD_DIR, savedName)
await writeFile(savedPath, Buffer.from(data, 'binary'))
results.push({ name: filename, path: savedPath })
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ files: results }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function proxyRequest(req, res, reqPath) {
const url = `${API_TARGET}${reqPath}`
const headers = { ...req.headers, host: '127.0.0.1:8642' }
delete headers['origin']
delete headers['referer']
function writePid(pid) {
writeFileSync(PID_FILE, String(pid))
}
try {
const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
const bodyChunks = hasBody ? [] : null
if (hasBody) {
for await (const chunk of req) bodyChunks.push(chunk)
}
function removePid() {
try { unlinkSync(PID_FILE) } catch {}
}
const apiRes = await fetch(url, {
method: req.method,
headers,
body: bodyChunks ? Buffer.concat(bodyChunks) : undefined,
})
function startDaemon(port) {
const existing = getPid()
if (existing && isRunning(existing)) {
console.log(` ✗ hermes-web-ui is already running (PID: ${existing})`)
console.log(` Use "hermes-web-ui stop" to stop it first`)
process.exit(1)
}
removePid() // stale pid file
mkdirSync(PID_DIR, { recursive: true })
const resHeaders = {}
apiRes.headers.forEach((v, k) => {
if (k !== 'transfer-encoding' && k !== 'connection') {
resHeaders[k] = v
}
})
resHeaders['x-accel-buffering'] = 'no'
resHeaders['cache-control'] = 'no-cache'
const logStream = openSync(LOG_FILE, 'a')
const child = spawn(process.execPath, [serverEntry], {
detached: true,
stdio: ['ignore', logStream, logStream],
env: { ...process.env, PORT: String(port) },
})
res.writeHead(apiRes.status, resHeaders)
child.unref()
writePid(child.pid)
if (apiRes.body) {
const reader = apiRes.body.getReader()
const pump = async () => {
while (true) {
const { done, value } = await reader.read()
if (done) break
res.write(value)
}
res.end()
}
await pump()
// Wait a moment and check if the process is still alive
setTimeout(() => {
if (isRunning(child.pid)) {
console.log(` ✓ hermes-web-ui started (PID: ${child.pid}, port: ${port})`)
console.log(` http://localhost:${port}`)
console.log(` Log: ${LOG_FILE}`)
} else {
res.end()
console.log(' ✗ Failed to start hermes-web-ui')
console.log(` Check log: ${LOG_FILE}`)
removePid()
process.exit(1)
}
}, 500)
}
function stopDaemon() {
const pid = getPid()
if (!pid) {
console.log(' ✗ hermes-web-ui is not running')
process.exit(1)
}
if (!isRunning(pid)) {
console.log(` ✗ Process ${pid} is not alive (stale PID file)`)
removePid()
process.exit(1)
}
try {
process.kill(pid, 'SIGTERM')
removePid()
console.log(` ✓ hermes-web-ui stopped (PID: ${pid})`)
} catch (err) {
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' })
}
res.end(JSON.stringify({ error: { message: `API proxy error: ${err.message}` } }))
console.log(` ✗ Failed to stop: ${err.message}`)
process.exit(1)
}
}
const command = process.argv[2]
if (command === 'build') {
console.log('Build is done during npm install. Use "npm run build" in the source repo.')
process.exit(1)
}
// start (default)
const port = parseInt(process.argv[2] && !isNaN(process.argv[2]) ? process.argv[2] : process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : '') || DEFAULT_PORT
createViteServer(async (req, res) => {
const reqPath = req.url.split('?')[0]
if (reqPath === '/__upload') {
await handleUpload(req, res)
} else if (reqPath.startsWith('/api/') || reqPath.startsWith('/v1/') || reqPath === '/health' || reqPath.startsWith('/health')) {
await proxyRequest(req, res, reqPath)
function showStatus() {
const pid = getPid()
if (pid && isRunning(pid)) {
console.log(` ✓ hermes-web-ui is running (PID: ${pid})`)
} else {
await serveStatic(reqPath, res)
if (pid) removePid() // clean stale
console.log(' ✗ hermes-web-ui is not running')
}
}).listen(port, '0.0.0.0', () => {
console.log(` ➜ Hermes Web UI: http://localhost:${port}`)
})
}
const command = process.argv[2] || 'start'
switch (command) {
case 'start':
startDaemon(getPort())
break
case 'stop':
stopDaemon()
break
case 'restart':
stopDaemon()
setTimeout(() => startDaemon(getPort()), 500)
break
case 'status':
showStatus()
break
default:
// Direct run (foreground): hermes-web-ui [port]
const port = !isNaN(command) ? parseInt(command) : DEFAULT_PORT
const child = spawn(process.execPath, [serverEntry], {
stdio: 'inherit',
env: { ...process.env, PORT: String(port) },
})
child.on('exit', (code) => process.exit(code ?? 1))
process.on('SIGTERM', () => child.kill('SIGTERM'))
process.on('SIGINT', () => child.kill('SIGINT'))
}
+19 -4
View File
@@ -1,7 +1,6 @@
{
"name": "hermes-web-ui",
"version": "0.1.2",
"type": "module",
"version": "0.1.3",
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
"repository": {
"type": "git",
@@ -14,8 +13,10 @@
},
"scripts": {
"start": "vite --host --port 8648",
"dev": "vite --host",
"build": "vue-tsc -b && vite build",
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "vite --host",
"dev:server": "nodemon --watch server/src --ext ts --exec node -r ts-node/register server/src/index.ts",
"build": "vue-tsc -b && vite build && tsc -p server/tsconfig.json",
"preview": "vite preview"
},
"files": [
@@ -23,7 +24,13 @@
"dist/"
],
"dependencies": {
"@koa/bodyparser": "^5.0.0",
"@koa/cors": "^5.0.0",
"@koa/router": "^13.1.0",
"highlight.js": "^11.11.1",
"koa": "^2.15.3",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"markdown-it": "^14.1.1",
"naive-ui": "^2.44.1",
"pinia": "^3.0.4",
@@ -31,11 +38,19 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/koa": "^2.15.0",
"@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4",
"@types/koa-send": "^4.1.6",
"@types/koa-static": "^4.0.4",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"concurrently": "^9.2.1",
"nodemon": "^3.1.14",
"sass": "^1.99.0",
"ts-node": "^10.9.2",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vue-tsc": "^3.2.6"
+10
View File
@@ -0,0 +1,10 @@
import { resolve } from 'path'
import { tmpdir } from 'os'
export const config = {
port: parseInt(process.env.PORT || '8648', 10),
upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642',
uploadDir: process.env.UPLOAD_DIR || resolve(tmpdir(), 'hermes-uploads'),
dataDir: resolve(__dirname, '..', 'data'),
corsOrigins: process.env.CORS_ORIGINS || '*',
}
+133
View File
@@ -0,0 +1,133 @@
import Koa from 'koa'
import cors from '@koa/cors'
import bodyParser from '@koa/bodyparser'
import serve from 'koa-static'
import send from 'koa-send'
import { resolve } from 'path'
import { mkdir } from 'fs/promises'
import { config } from './config'
import { proxyRoutes } from './routes/proxy'
import { uploadRoutes } from './routes/upload'
import { sessionRoutes } from './routes/sessions'
import { webhookRoutes } from './routes/webhook'
import { logRoutes } from './routes/logs'
import * as hermesCli from './services/hermes-cli'
const { restartGateway } = hermesCli
export async function bootstrap() {
await mkdir(config.uploadDir, { recursive: true })
await mkdir(config.dataDir, { recursive: true })
await ensureApiServerConfig()
const app = new Koa()
app.use(cors({ origin: config.corsOrigins }))
app.use(bodyParser())
app.use(webhookRoutes.routes())
app.use(logRoutes.routes())
app.use(uploadRoutes.routes())
app.use(sessionRoutes.routes())
// Health endpoint with version
app.use(async (ctx, next) => {
if (ctx.path === '/health') {
const raw = await hermesCli.getVersion()
const version = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
ctx.body = { status: 'ok', platform: 'hermes-agent', version }
return
}
await next()
})
app.use(proxyRoutes.routes())
// SPA fallback
const distDir = resolve(__dirname, '..')
app.use(serve(distDir))
app.use(async (ctx) => {
if (!ctx.path.startsWith('/api') && !ctx.path.startsWith('/v1') && ctx.path !== '/health' && ctx.path !== '/upload' && ctx.path !== '/webhook') {
await send(ctx, 'index.html', { root: distDir })
}
})
app.listen(config.port, '0.0.0.0', () => {
console.log(` ➜ Hermes BFF Server: http://localhost:${config.port}`)
console.log(` ➜ Upstream: ${config.upstream}`)
})
}
async function ensureApiServerConfig() {
const { homedir } = await import('os')
const { readFileSync, writeFileSync, existsSync } = await import('fs')
const configPath = resolve(homedir(), '.hermes/config.yaml')
try {
if (!existsSync(configPath)) {
console.log(' ✗ config.yaml not found, skipping')
return
}
const content = readFileSync(configPath, 'utf-8')
// Case 1: api_server section exists, check if enabled is true
if (/api_server:/.test(content)) {
// Check specifically under api_server: look for a direct child `enabled: false`
// Match api_server block and find enabled at the correct indent level
const blockMatch = content.match(/api_server:\n((?:[ \t]+.*\n)*?)(?=\S|$)/)
if (blockMatch) {
const block = blockMatch[1]
if (/^([ \t]*)enabled:\s*true/m.test(block)) {
console.log(' ✓ api_server.enabled is true')
return
}
if (/^([ \t]*)enabled:\s*false/m.test(block)) {
// Backup before modifying
const { copyFileSync } = await import('fs')
copyFileSync(configPath, configPath + '.bak')
const updated = content.replace(
/(api_server:\n(?:[ \t]*.*\n)*?[ \t]*)enabled:\s*false/,
'$1enabled: true'
)
writeFileSync(configPath, updated, 'utf-8')
console.log(' ✓ api_server.enabled changed to true (backup saved to config.yaml.bak)')
await restartGateway()
return
}
}
// api_server exists but no enabled key — don't touch, assume default
console.log(' ✓ api_server section exists')
return
}
// Case 2: api_server section exists and enabled is true (or missing but default true)
if (/api_server:/.test(content)) {
console.log(' ✓ api_server section exists')
return
}
// Case 3: platforms section exists but no api_server — append api_server block
if (/platforms:/.test(content)) {
const { copyFileSync } = await import('fs')
copyFileSync(configPath, configPath + '.bak')
const append = `\n api_server:\n enabled: true\n host: "127.0.0.1"\n port: 8642\n key: ""\n cors_origins: "*"\n`
const updated = content.replace(/(platforms:)/, '$1' + append)
writeFileSync(configPath, updated, 'utf-8')
console.log(' ✓ api_server block appended to platforms (backup saved to config.yaml.bak)')
await restartGateway()
return
}
// Case 4: No platforms section at all — append at end of file
const { copyFileSync } = await import('fs')
copyFileSync(configPath, configPath + '.bak')
const append = `\nplatforms:\n api_server:\n enabled: true\n host: "127.0.0.1"\n port: 8642\n key: ""\n cors_origins: "*"\n`
writeFileSync(configPath, content + append, 'utf-8')
console.log(' ✓ platforms.api_server block appended (backup saved to config.yaml.bak)')
await restartGateway()
} catch (err: any) {
console.error(' ✗ Failed to update config:', err.message)
}
}
bootstrap()
+61
View File
@@ -0,0 +1,61 @@
import Router from '@koa/router'
import * as hermesCli from '../services/hermes-cli'
export const logRoutes = new Router()
// List available log files
logRoutes.get('/api/logs', async (ctx) => {
const files = await hermesCli.listLogFiles()
ctx.body = { files }
})
interface LogEntry {
timestamp: string
level: string
logger: string
message: string
raw: string
}
// Parse a single log line into structured entry
function parseLine(line: string): LogEntry | null {
// Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message
const match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
if (match) {
return {
timestamp: match[1],
level: match[2],
logger: match[3],
message: match[4],
raw: line,
}
}
// Unparseable line (e.g. traceback continuation)
return null
}
// Read log lines (parsed)
logRoutes.get('/api/logs/:name', async (ctx) => {
const logName = ctx.params.name
const lines = ctx.query.lines ? parseInt(ctx.query.lines as string, 10) : 100
const level = (ctx.query.level as string) || undefined
const session = (ctx.query.session as string) || undefined
const since = (ctx.query.since as string) || undefined
try {
const content = await hermesCli.readLogs(logName, lines, level, session, since)
const rawLines = content.split('\n')
const entries: (LogEntry | null)[] = []
for (const line of rawLines) {
// Skip header lines like "--- ~/.hermes/logs/agent.log (last 100) ---"
if (line.startsWith('---') || line.trim() === '') continue
entries.push(parseLine(line))
}
ctx.body = { entries }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
+81
View File
@@ -0,0 +1,81 @@
import type { Context } from 'koa'
import { config } from '../config'
export async function proxy(ctx: Context) {
const upstream = config.upstream.replace(/\/$/, '')
const url = `${upstream}${ctx.path}${ctx.search || ''}`
console.log(`[PROXY] ${ctx.method} ${ctx.path} -> ${url}`)
// Build headers — forward most, strip browser-specific ones
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(ctx.headers)) {
if (value == null) continue
const lower = key.toLowerCase()
if (lower === 'host') {
headers['host'] = new URL(upstream).host
} else if (lower !== 'origin' && lower !== 'referer' && lower !== 'connection') {
const v = Array.isArray(value) ? value[0] : value
if (v) headers[key] = v
}
}
// Add SSE-friendly headers
if (ctx.path.match(/\/events$/)) {
headers['x-accel-buffering'] = 'no'
headers['cache-control'] = 'no-cache'
}
try {
// Build request body from raw body
let body: string | undefined
if (ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD') {
body = (ctx as any).request.rawBody as string | undefined
}
const res = await fetch(url, {
method: ctx.req.method,
headers,
body,
})
// Set response headers
const resHeaders: Record<string, string> = {}
res.headers.forEach((value, key) => {
const lower = key.toLowerCase()
if (lower !== 'transfer-encoding' && lower !== 'connection') {
resHeaders[key] = value
}
})
if (ctx.path.match(/\/events$/)) {
resHeaders['x-accel-buffering'] = 'no'
resHeaders['cache-control'] = 'no-cache'
}
ctx.status = res.status
ctx.set(resHeaders)
// Stream response body
if (res.body) {
const reader = res.body.getReader()
const pump = async () => {
while (true) {
const { done, value } = await reader.read()
if (done) break
ctx.res.write(value)
}
ctx.res.end()
}
await pump()
} else {
ctx.res.end()
}
} catch (err: any) {
if (!ctx.res.headersSent) {
ctx.status = 502
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: `Proxy error: ${err.message}` } }
} else {
ctx.res.end()
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import { proxy } from './proxy-handler'
export const proxyRoutes = new Router()
// Proxy all /api/*, /v1/* to upstream Hermes API
proxyRoutes.all('/api/(.*)', proxy)
proxyRoutes.all('/v1/(.*)', proxy)
+34
View File
@@ -0,0 +1,34 @@
import Router from '@koa/router'
import * as hermesCli from '../services/hermes-cli'
export const sessionRoutes = new Router()
// List sessions from Hermes
sessionRoutes.get('/api/sessions', async (ctx) => {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const sessions = await hermesCli.listSessions(source, limit)
ctx.body = { sessions }
})
// Get single session with messages
sessionRoutes.get('/api/sessions/:id', async (ctx) => {
const session = await hermesCli.getSession(ctx.params.id)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = { session }
})
// Delete session from Hermes
sessionRoutes.delete('/api/sessions/:id', async (ctx) => {
const ok = await hermesCli.deleteSession(ctx.params.id)
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to delete session' }
return
}
ctx.body = { ok: true }
})
+52
View File
@@ -0,0 +1,52 @@
import Router from '@koa/router'
import { randomBytes } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { config } from '../config'
export const uploadRoutes = new Router()
uploadRoutes.post('/upload', async (ctx) => {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary' }
return
}
await mkdir(config.uploadDir, { recursive: true })
// Read raw body
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString('latin1')
const parts = body.split(boundary).slice(1, -1)
const results: { name: string; path: string }[] = []
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const header = part.substring(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2)
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = `${config.uploadDir}/${savedName}`
await writeFile(savedPath, Buffer.from(data, 'binary'))
results.push({ name: filename, path: savedPath })
}
ctx.body = { files: results }
})
+33
View File
@@ -0,0 +1,33 @@
import Router from '@koa/router'
import { emitWebhook } from '../services/hermes'
export const webhookRoutes = new Router()
/**
* POST /webhook — receive callbacks from Hermes Agent
*
* Expected body:
* {
* "event": "run.completed" | "job.completed" | ...,
* "run_id": "...",
* "data": { ... }
* }
*
* TODO: Add signature verification when Hermes supports webhook signing
*/
webhookRoutes.post('/webhook', async (ctx) => {
const payload = ctx.request.body
if (!payload || !payload.event) {
ctx.status = 400
ctx.body = { error: 'Missing event field' }
return
}
console.log(`[Webhook] Received event: ${payload.event}`)
// Emit to registered callbacks
emitWebhook(payload)
ctx.body = { ok: true }
})
+223
View File
@@ -0,0 +1,223 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
export interface HermesSession {
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
billing_provider: string | null
estimated_cost_usd: number
messages?: any[]
}
interface HermesSessionFull extends HermesSession {
system_prompt?: string
model_config?: string
cache_read_tokens?: number
cache_write_tokens?: number
reasoning_tokens?: number
actual_cost_usd?: number | null
cost_status?: string
cost_source?: string
pricing_version?: string | null
[key: string]: any
}
/**
* List sessions from Hermes CLI (without messages)
*/
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
const args = ['sessions', 'export', '-']
if (source) args.push('--source', source)
try {
const { stdout } = await execFileAsync('hermes', args, {
maxBuffer: 50 * 1024 * 1024, // 50MB
timeout: 30000,
})
const lines = stdout.trim().split('\n').filter(Boolean)
const sessions: HermesSession[] = []
for (const line of lines) {
try {
const raw: HermesSessionFull = JSON.parse(line)
sessions.push({
id: raw.id,
source: raw.source,
user_id: raw.user_id,
model: raw.model,
title: raw.title,
started_at: raw.started_at,
ended_at: raw.ended_at,
end_reason: raw.end_reason,
message_count: raw.message_count,
tool_call_count: raw.tool_call_count,
input_tokens: raw.input_tokens,
output_tokens: raw.output_tokens,
billing_provider: raw.billing_provider,
estimated_cost_usd: raw.estimated_cost_usd,
})
} catch { /* skip malformed lines */ }
}
// Sort by started_at descending
sessions.sort((a, b) => b.started_at - a.started_at)
if (limit && limit > 0) {
return sessions.slice(0, limit)
}
return sessions
} catch (err: any) {
console.error('[Hermes CLI] sessions export failed:', err.message)
throw new Error(`Failed to list sessions: ${err.message}`)
}
}
/**
* Get a single session with messages from Hermes CLI
*/
export async function getSession(id: string): Promise<HermesSession | null> {
const args = ['sessions', 'export', '-', '--session-id', id]
try {
const { stdout } = await execFileAsync('hermes', args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
})
const lines = stdout.trim().split('\n').filter(Boolean)
if (lines.length === 0) return null
const raw: HermesSessionFull = JSON.parse(lines[0])
return {
id: raw.id,
source: raw.source,
user_id: raw.user_id,
model: raw.model,
title: raw.title,
started_at: raw.started_at,
ended_at: raw.ended_at,
end_reason: raw.end_reason,
message_count: raw.message_count,
tool_call_count: raw.tool_call_count,
input_tokens: raw.input_tokens,
output_tokens: raw.output_tokens,
billing_provider: raw.billing_provider,
estimated_cost_usd: raw.estimated_cost_usd,
messages: raw.messages,
}
} catch (err: any) {
if (err.code === 1 || err.status === 1) return null
console.error('[Hermes CLI] session export failed:', err.message)
throw new Error(`Failed to get session: ${err.message}`)
}
}
/**
* Delete a session from Hermes CLI
*/
export async function deleteSession(id: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['sessions', 'delete', id, '--yes'], {
timeout: 10000,
})
return true
} catch (err: any) {
console.error('[Hermes CLI] session delete failed:', err.message)
return false
}
}
export interface LogFileInfo {
name: string
size: string
modified: string
}
/**
* Get Hermes version
*/
export async function getVersion(): Promise<string> {
try {
const { stdout } = await execFileAsync('hermes', ['--version'], { timeout: 5000 })
return stdout.trim()
} catch {
return ''
}
}
/**
* Update Hermes Agent
*/
export async function restartGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'restart'], {
timeout: 30000,
})
return stdout || stderr
}
/**
* List available log files
*/
export async function listLogFiles(): Promise<LogFileInfo[]> {
try {
const { stdout } = await execFileAsync('hermes', ['logs', 'list'], {
timeout: 10000,
})
const files: LogFileInfo[] = []
const lines = stdout.trim().split('\n').filter(l => l.includes('.log'))
for (const line of lines) {
const match = line.match(/^\s+(\S+)\s+([\d.]+\w+)\s+(.+)$/)
if (match) {
const rawName = match[1]
const name = rawName.replace(/\.log$/, '')
if (['agent', 'errors', 'gateway'].includes(name)) {
files.push({ name, size: match[2], modified: match[3].trim() })
}
}
}
return files
} catch (err: any) {
console.error('[Hermes CLI] logs list failed:', err.message)
return []
}
}
/**
* Read log lines
*/
export async function readLogs(
logName: string = 'agent',
lines: number = 100,
level?: string,
session?: string,
since?: string,
): Promise<string> {
const args = ['logs', logName, '-n', String(lines)]
if (level) args.push('--level', level)
if (session) args.push('--session', session)
if (since) args.push('--since', since)
try {
const { stdout } = await execFileAsync('hermes', args, {
maxBuffer: 10 * 1024 * 1024,
timeout: 15000,
})
return stdout
} catch (err: any) {
console.error('[Hermes CLI] logs read failed:', err.message)
throw new Error(`Failed to read logs: ${err.message}`)
}
}
+127
View File
@@ -0,0 +1,127 @@
import { config } from '../config'
const UPSTREAM = config.upstream.replace(/\/$/, '')
/**
* Send an instruction to Hermes Agent via /v1/runs
*/
export async function sendInstruction(params: {
input: string | any[]
instructions?: string
conversationHistory?: any[]
sessionId?: string
authToken?: string
}): Promise<{ run_id: string; status: string }> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (params.authToken) {
headers['Authorization'] = `Bearer ${params.authToken}`
}
const body: any = { input: params.input }
if (params.instructions) body.instructions = params.instructions
if (params.conversationHistory) body.conversation_history = params.conversationHistory
if (params.sessionId) body.session_id = params.sessionId
const res = await fetch(`${UPSTREAM}/v1/runs`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Hermes API error ${res.status}: ${text}`)
}
return res.json()
}
/**
* Get run status (poll /v1/runs/:id if supported)
*/
export async function getRunStatus(runId: string): Promise<any> {
const res = await fetch(`${UPSTREAM}/v1/runs/${runId}`)
if (!res.ok) {
throw new Error(`Failed to get run status: ${res.status}`)
}
return res.json()
}
/**
* Subscribe to SSE events for a run
*/
export async function* streamRunEvents(runId: string, authToken?: string): AsyncGenerator<any> {
const headers: Record<string, string> = {}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
const res = await fetch(`${UPSTREAM}/v1/runs/${runId}/events`, { headers })
if (!res.ok || !res.body) {
throw new Error(`Failed to stream run events: ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') return
try {
const event = JSON.parse(data)
yield event
if (event.event === 'run.completed' || event.event === 'run.failed') return
} catch { /* skip malformed lines */ }
}
}
}
} finally {
reader.releaseLock()
}
}
/**
* Health check
*/
export async function healthCheck(): Promise<{ status: string; version?: string }> {
const res = await fetch(`${UPSTREAM}/health`)
return res.json()
}
/**
* Fetch available models
*/
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
const res = await fetch(`${UPSTREAM}/v1/models`)
return res.json()
}
// Webhook callback registry
type WebhookCallback = (payload: any) => void | Promise<void>
const webhookCallbacks: WebhookCallback[] = []
export function onWebhook(callback: WebhookCallback) {
webhookCallbacks.push(callback)
}
export function emitWebhook(payload: any) {
for (const cb of webhookCallbacks) {
const result = cb(payload)
if (result && typeof result.catch === 'function') {
result.catch((err: Error) => console.error('Webhook callback error:', err))
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "../dist/server",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true
},
"include": ["src/**/*.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
import { request } from './client'
export interface LogFileInfo {
name: string
size: string
modified: string
}
export interface LogEntry {
timestamp: string
level: string
logger: string
message: string
raw: string
}
export async function fetchLogFiles(): Promise<LogFileInfo[]> {
const res = await request<{ files: LogFileInfo[] }>('/api/logs')
return res.files
}
export async function fetchLogs(name: string, params?: {
lines?: number
level?: string
session?: string
since?: string
}): Promise<LogEntry[]> {
const query = new URLSearchParams()
if (params?.lines) query.set('lines', String(params.lines))
if (params?.level) query.set('level', params.level)
if (params?.session) query.set('session', params.session)
if (params?.since) query.set('since', params.since)
const qs = query.toString()
const res = await request<{ entries: (LogEntry | null)[] }>(`/api/logs/${name}${qs ? `?${qs}` : ''}`)
return res.entries.filter((e): e is LogEntry => e !== null)
}
+61
View File
@@ -0,0 +1,61 @@
import { request } from './client'
export interface SessionSummary {
id: string
source: string
model: string
title: string | null
started_at: number
ended_at: number | null
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
billing_provider: string | null
estimated_cost_usd: number
}
export interface SessionDetail extends SessionSummary {
messages: HermesMessage[]
}
export interface HermesMessage {
id: number
session_id: string
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
}
export async function fetchSessions(source?: string, limit?: number): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/sessions${query ? `?${query}` : ''}`)
return res.sessions
}
export async function fetchSession(id: string): Promise<SessionDetail | null> {
try {
const res = await request<{ session: SessionDetail }>(`/api/sessions/${id}`)
return res.session
} catch {
return null
}
}
export async function deleteSession(id: string): Promise<boolean> {
try {
await request(`/api/sessions/${id}`, { method: 'DELETE' })
return true
} catch {
return false
}
}
+6 -6
View File
@@ -12,6 +12,10 @@ const attachments = ref<Attachment[]>([])
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleSend() {
const text = inputText.value.trim()
if (!text && attachments.value.length === 0) return
@@ -39,10 +43,6 @@ function handleInput(e: Event) {
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
@@ -128,7 +128,7 @@ function isImage(type: string): boolean {
@input="handleInput"
></textarea>
<div class="input-actions">
<!-- <NTooltip trigger="hover">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="small" @click="handleAttachClick" circle>
<template #icon>
@@ -137,7 +137,7 @@ function isImage(type: string): boolean {
</NButton>
</template>
Attach files
</NTooltip> -->
</NTooltip>
<NButton
v-if="chatStore.isStreaming"
size="small"
+14 -4
View File
@@ -13,11 +13,11 @@ const message = useMessage()
const showSessions = ref(true)
const sortedSessions = computed(() => {
return [...chatStore.sessions].sort((a, b) => b.updatedAt - a.updatedAt)
return [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt)
})
const activeSessionLabel = computed(() =>
chatStore.activeSession?.title || 'New Chat',
chatStore.activeSession?.id || 'New Chat',
)
function handleNewChat() {
@@ -58,6 +58,8 @@ function formatTime(ts: number) {
</NButton>
</div>
<div v-if="showSessions" class="session-items">
<div v-if="chatStore.isLoadingSessions" class="session-loading">Loading...</div>
<div v-else-if="sortedSessions.length === 0" class="session-empty">No sessions</div>
<button
v-for="s in sortedSessions"
:key="s.id"
@@ -66,8 +68,8 @@ function formatTime(ts: number) {
@click="chatStore.switchSession(s.id)"
>
<div class="session-item-content">
<span class="session-item-title">{{ s.title }}</span>
<span class="session-item-time">{{ formatTime(s.updatedAt) }}</span>
<span class="session-item-title">{{ s.id }}</span>
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
</div>
<NPopconfirm
v-if="s.id !== chatStore.activeSessionId || sortedSessions.length > 1"
@@ -169,6 +171,14 @@ function formatTime(ts: number) {
padding: 0 6px 12px;
}
.session-loading,
.session-empty {
padding: 16px 10px;
font-size: 12px;
color: $text-muted;
text-align: center;
}
.session-item {
display: flex;
align-items: center;
+29 -5
View File
@@ -46,12 +46,29 @@ function handleNav(key: string) {
</svg>
<span>Jobs</span>
</button>
<button
class="nav-item"
: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" />
<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>Logs</span>
</button>
</nav>
<div class="sidebar-footer">
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
<span class="status-dot"></span>
<span class="status-text">{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
<div class="status-row">
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
<span class="status-dot"></span>
<span class="status-text">{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
</div>
</div>
<div class="version-info">Hermes {{ appStore.serverVersion || 'v0.1.0' }}</div>
</div>
@@ -133,11 +150,17 @@ function handleNav(key: string) {
border-top: 1px solid $border-color;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 12px;
.status-dot {
@@ -162,8 +185,9 @@ function handleNav(key: string) {
}
.version-info {
padding: 4px 12px;
padding: 2px 12px 8px;
font-size: 11px;
color: $text-muted;
}
</style>
+5
View File
@@ -13,6 +13,11 @@ const router = createRouter({
name: 'jobs',
component: () => import('@/views/JobsView.vue'),
},
{
path: '/logs',
name: 'logs',
component: () => import('@/views/LogsView.vue'),
},
{
path: '/settings',
name: 'settings',
+148 -79
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
import { useAppStore } from './app'
import { fetchSessions, fetchSession, deleteSession as deleteSessionApi, type SessionSummary, type HermesMessage } from '@/api/sessions'
export interface Attachment {
id: string
@@ -24,12 +24,14 @@ export interface Message {
attachments?: Attachment[]
}
interface Session {
export interface Session {
id: string
title: string
messages: Message[]
createdAt: number
updatedAt: number
model?: string
messageCount?: number
}
function uid(): string {
@@ -42,43 +44,117 @@ async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; p
for (const att of attachments) {
if (att.file) formData.append('file', att.file, att.name)
}
const res = await fetch('/__upload', { method: 'POST', body: formData })
const res = await fetch('/upload', { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json() as { files: { name: string; path: string }[] }
return data.files
}
const SESSIONS_KEY = 'hermes_chat_sessions'
const ACTIVE_SESSION_KEY = 'hermes_active_session'
function mapHermesMessages(msgs: HermesMessage[]): Message[] {
// Build a lookup of tool_call_id -> tool name from assistant messages with tool_calls
const toolNameMap = new Map<string, string>()
for (const msg of msgs) {
if (msg.role === 'assistant' && msg.tool_calls) {
for (const tc of msg.tool_calls) {
if (tc.function?.name && tc.id) {
toolNameMap.set(tc.id, tc.function.name)
}
}
}
}
function loadSessions(): Session[] {
try {
return JSON.parse(localStorage.getItem(SESSIONS_KEY) || '[]')
} catch {
return []
const result: Message[] = []
for (const msg of msgs) {
// Skip assistant messages that only contain tool_calls (no meaningful content)
if (msg.role === 'assistant' && msg.tool_calls?.length && !msg.content?.trim()) {
// Emit a tool.started message for each tool call
for (const tc of msg.tool_calls) {
result.push({
id: String(msg.id) + '_' + tc.id,
role: 'tool',
content: '',
timestamp: Math.round(msg.timestamp * 1000),
toolName: tc.function?.name || 'Tool',
toolStatus: 'done',
})
}
continue
}
// Tool result messages
if (msg.role === 'tool') {
const toolName = msg.tool_name || toolNameMap.get(msg.tool_call_id || '') || 'Tool'
// Extract a short preview from the content
let preview = ''
if (msg.content) {
try {
const parsed = JSON.parse(msg.content)
preview = parsed.url || parsed.title || parsed.preview || parsed.summary || ''
} catch {
preview = msg.content.slice(0, 80)
}
}
result.push({
id: String(msg.id),
role: 'tool',
content: '',
timestamp: Math.round(msg.timestamp * 1000),
toolName,
toolPreview: preview.slice(0, 100) || undefined,
toolStatus: 'done',
})
continue
}
// Normal user/assistant messages
result.push({
id: String(msg.id),
role: msg.role,
content: msg.content || '',
timestamp: Math.round(msg.timestamp * 1000),
})
}
return result
}
function mapHermesSession(s: SessionSummary): Session {
return {
id: s.id,
title: s.title || 'New Chat',
messages: [],
createdAt: Math.round(s.started_at * 1000),
updatedAt: Math.round((s.ended_at || s.started_at) * 1000),
model: s.model,
messageCount: s.message_count,
}
}
function saveSessions(sessions: Session[]) {
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions))
}
function loadActiveSessionId(): string | null {
return localStorage.getItem(ACTIVE_SESSION_KEY)
}
export const useChatStore = defineStore('chat', () => {
const appStore = useAppStore()
const sessions = ref<Session[]>(loadSessions())
const activeSessionId = ref<string | null>(loadActiveSessionId())
const sessions = ref<Session[]>([])
const activeSessionId = ref<string | null>(null)
const isStreaming = ref(false)
const abortController = ref<AbortController | null>(null)
const isLoadingSessions = ref(false)
const isLoadingMessages = ref(false)
const activeSession = ref<Session | null>(
sessions.value.find(s => s.id === activeSessionId.value) || null,
)
const activeSession = ref<Session | null>(null)
const messages = ref<Message[]>([])
const messages = ref<Message[]>(activeSession.value?.messages || [])
async function loadSessions() {
isLoadingSessions.value = true
try {
const list = await fetchSessions('api_server')
sessions.value = list.map(mapHermesSession)
// Auto-select the most recent session
if (!activeSessionId.value && sessions.value.length > 0) {
await switchSession(sessions.value[0].id)
}
} catch (err) {
console.error('Failed to load sessions:', err)
} finally {
isLoadingSessions.value = false
}
}
function createSession(): Session {
const session: Session = {
@@ -89,14 +165,33 @@ export const useChatStore = defineStore('chat', () => {
updatedAt: Date.now(),
}
sessions.value.unshift(session)
saveSessions(sessions.value)
return session
}
function switchSession(sessionId: string) {
async function switchSession(sessionId: string) {
activeSessionId.value = sessionId
localStorage.setItem(ACTIVE_SESSION_KEY, sessionId)
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
// If session has no messages loaded, fetch from API
if (activeSession.value && activeSession.value.messages.length === 0) {
isLoadingMessages.value = true
try {
const detail = await fetchSession(sessionId)
if (detail && detail.messages) {
const mapped = mapHermesMessages(detail.messages)
activeSession.value.messages = mapped
// Update title from Hermes data
if (detail.title) {
activeSession.value.title = detail.title
}
}
} catch (err) {
console.error('Failed to load session messages:', err)
} finally {
isLoadingMessages.value = false
}
}
messages.value = activeSession.value ? [...activeSession.value.messages] : []
}
@@ -106,12 +201,12 @@ export const useChatStore = defineStore('chat', () => {
switchSession(session.id)
}
function deleteSession(sessionId: string) {
async function deleteSession(sessionId: string) {
await deleteSessionApi(sessionId)
sessions.value = sessions.value.filter(s => s.id !== sessionId)
saveSessions(sessions.value)
if (activeSessionId.value === sessionId) {
if (sessions.value.length > 0) {
switchSession(sessions.value[0].id)
await switchSession(sessions.value[0].id)
} else {
const session = createSession()
switchSession(session.id)
@@ -119,33 +214,6 @@ export const useChatStore = defineStore('chat', () => {
}
}
function stripNonSerializable(msgs: Message[]): Message[] {
return msgs.map(m => ({
...m,
attachments: m.attachments?.map(a => ({ ...a, file: undefined, url: '' })),
}))
}
function persistMessages() {
if (!activeSession.value || !appStore.sessionPersistence) return
activeSession.value.messages = stripNonSerializable(messages.value)
activeSession.value.updatedAt = Date.now()
if (activeSession.value.title === 'New Chat') {
const firstUser = messages.value.find(m => m.role === 'user')
if (firstUser) {
const title = firstUser.attachments?.length
? firstUser.attachments.map(a => a.name).join(', ')
: firstUser.content
activeSession.value.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
}
}
const idx = sessions.value.findIndex(s => s.id === activeSession.value!.id)
if (idx !== -1) sessions.value[idx] = activeSession.value
saveSessions(sessions.value)
}
function addMessage(msg: Message) {
messages.value.push(msg)
}
@@ -157,6 +225,20 @@ export const useChatStore = defineStore('chat', () => {
}
}
function updateSessionTitle() {
if (!activeSession.value) return
if (activeSession.value.title === 'New Chat') {
const firstUser = messages.value.find(m => m.role === 'user')
if (firstUser) {
const title = firstUser.attachments?.length
? firstUser.attachments.map(a => a.name).join(', ')
: firstUser.content
activeSession.value.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
}
}
activeSession.value.updatedAt = Date.now()
}
async function sendMessage(content: string, attachments?: Attachment[]) {
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
@@ -173,7 +255,7 @@ export const useChatStore = defineStore('chat', () => {
attachments: attachments && attachments.length > 0 ? attachments : undefined,
}
addMessage(userMsg)
persistMessages()
updateSessionTitle()
isStreaming.value = true
@@ -206,7 +288,6 @@ export const useChatStore = defineStore('chat', () => {
timestamp: Date.now(),
})
isStreaming.value = false
persistMessages()
return
}
@@ -217,11 +298,9 @@ export const useChatStore = defineStore('chat', () => {
(evt: RunEvent) => {
switch (evt.event) {
case 'run.started':
// run started, nothing to render yet
break
case 'message.delta': {
// Find or create the assistant message
const last = messages.value[messages.value.length - 1]
if (last?.role === 'assistant' && last.isStreaming) {
last.content += evt.delta || ''
@@ -238,12 +317,10 @@ export const useChatStore = defineStore('chat', () => {
}
case 'tool.started': {
// Close any streaming assistant message first
const last = messages.value[messages.value.length - 1]
if (last?.isStreaming) {
updateMessage(last.id, { isStreaming: false })
}
// Add tool message
addMessage({
id: uid(),
role: 'tool',
@@ -257,7 +334,6 @@ export const useChatStore = defineStore('chat', () => {
}
case 'tool.completed': {
// Find the running tool message and mark done
const toolMsgs = messages.value.filter(
m => m.role === 'tool' && m.toolStatus === 'running',
)
@@ -269,18 +345,16 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.completed':
// Close any streaming message
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(lastMsg.id, { isStreaming: false })
}
isStreaming.value = false
abortController.value = null
persistMessages()
updateSessionTitle()
break
case 'run.failed':
// Mark error
const lastErr = messages.value[messages.value.length - 1]
if (lastErr?.isStreaming) {
updateMessage(lastErr.id, {
@@ -296,7 +370,6 @@ export const useChatStore = defineStore('chat', () => {
timestamp: Date.now(),
})
}
// Mark any running tools as error
messages.value.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
messages.value[i] = { ...m, toolStatus: 'error' }
@@ -304,7 +377,6 @@ export const useChatStore = defineStore('chat', () => {
})
isStreaming.value = false
abortController.value = null
persistMessages()
break
}
},
@@ -316,7 +388,7 @@ export const useChatStore = defineStore('chat', () => {
}
isStreaming.value = false
abortController.value = null
persistMessages()
updateSessionTitle()
},
// onError
(err) => {
@@ -337,7 +409,6 @@ export const useChatStore = defineStore('chat', () => {
}
isStreaming.value = false
abortController.value = null
persistMessages()
},
)
} catch (err: any) {
@@ -349,7 +420,6 @@ export const useChatStore = defineStore('chat', () => {
})
isStreaming.value = false
abortController.value = null
persistMessages()
}
}
@@ -363,12 +433,8 @@ export const useChatStore = defineStore('chat', () => {
abortController.value = null
}
if (sessions.value.length === 0) {
const session = createSession()
switchSession(session.id)
} else if (!activeSession.value) {
switchSession(sessions.value[0].id)
}
// Load sessions on init
loadSessions()
return {
sessions,
@@ -376,10 +442,13 @@ export const useChatStore = defineStore('chat', () => {
activeSession,
messages,
isStreaming,
isLoadingSessions,
isLoadingMessages,
newChat,
switchSession,
deleteSession,
sendMessage,
stopStreaming,
loadSessions,
}
})
+3
View File
@@ -2,11 +2,14 @@
import { onMounted } from 'vue'
import ChatPanel from '@/components/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/app'
import { useChatStore } from '@/stores/chat'
const appStore = useAppStore()
const chatStore = useChatStore()
onMounted(() => {
appStore.loadModels()
chatStore.loadSessions()
})
</script>
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NSelect, NButton, NSpin, useMessage } from 'naive-ui'
import { fetchLogFiles, fetchLogs, type LogEntry } from '@/api/logs'
const message = useMessage()
const logFiles = ref<{ name: string; size: string; modified: string }[]>([])
const selectedLog = ref('agent')
const entries = ref<LogEntry[]>([])
const loading = ref(false)
const lineCount = ref(100)
const levelFilter = ref<string>('')
const searchQuery = ref('')
const logOptions = computed(() =>
logFiles.value.map(f => ({ label: `${f.name} (${f.size})`, value: f.name })),
)
const levelOptions = [
{ label: 'All', value: '' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'WARNING', value: 'WARNING' },
{ label: 'INFO', value: 'INFO' },
{ label: 'DEBUG', value: 'DEBUG' },
]
const lineOptions = [
{ label: '50', value: 50 },
{ label: '100', value: 100 },
{ label: '200', value: 200 },
{ label: '500', value: 500 },
]
const filteredEntries = computed(() => {
if (!searchQuery.value) return entries.value
const q = searchQuery.value.toLowerCase()
return entries.value.filter(e =>
e.message.toLowerCase().includes(q) ||
e.logger.toLowerCase().includes(q) ||
e.raw.toLowerCase().includes(q),
)
})
function levelClass(level: string): string {
switch (level) {
case 'ERROR': return 'level-error'
case 'WARNING': return 'level-warning'
case 'DEBUG': return 'level-debug'
default: return 'level-info'
}
}
function formatTime(ts: string): string {
const match = ts.match(/\d{2}:\d{2}:\d{2}/)
return match ? match[0] : ts
}
function parseAccessLog(msg: string) {
const match = msg.match(/"(\w+)\s+(\S+)\s+HTTP\/[^"]+"\s+(\d+)/)
if (match) return { method: match[1], path: match[2], status: match[3] }
return null
}
async function loadLogs() {
loading.value = true
try {
const data = await fetchLogs(selectedLog.value, {
lines: lineCount.value,
level: levelFilter.value || undefined,
})
entries.value = data.filter((e): e is LogEntry => e !== null)
} catch (e: any) {
message.error(e.message)
} finally {
loading.value = false
}
}
onMounted(async () => {
logFiles.value = await fetchLogFiles()
await loadLogs()
})
</script>
<template>
<div class="logs-view">
<header class="logs-header">
<h2 class="header-title">Logs</h2>
<div class="header-actions">
<NSelect
v-model:value="selectedLog"
:options="logOptions"
size="small"
style="width: 200px"
@update:value="loadLogs"
/>
<NSelect
:value="levelFilter"
:options="levelOptions"
size="small"
style="width: 110px"
@update:value="(v: string) => { levelFilter = v; loadLogs() }"
/>
<NSelect
:value="lineCount"
:options="lineOptions"
size="small"
style="width: 80px"
@update:value="(v: number) => { lineCount = v; loadLogs() }"
/>
<input
v-model="searchQuery"
class="search-input"
placeholder="Search..."
/>
<NButton size="small" :loading="loading" @click="loadLogs">Refresh</NButton>
</div>
</header>
<div class="logs-body">
<NSpin :show="loading">
<div v-if="filteredEntries.length === 0 && !loading" class="logs-empty">
No log entries
</div>
<div class="log-list">
<div
v-for="(entry, idx) in filteredEntries"
:key="idx"
class="log-entry"
:class="levelClass(entry.level)"
>
<span class="log-time">{{ formatTime(entry.timestamp) }}</span>
<span class="log-level" :class="levelClass(entry.level)">{{ entry.level }}</span>
<span class="log-logger">{{ entry.logger }}</span>
<template v-if="parseAccessLog(entry.message)">
<span class="access-method">{{ parseAccessLog(entry.message)!.method }}</span>
<span class="access-path">{{ parseAccessLog(entry.message)!.path }}</span>
<span class="access-status" :class="'status-' + (parseAccessLog(entry.message)!.status?.[0] || 'x')">
{{ parseAccessLog(entry.message)!.status }}
</span>
</template>
<span v-else class="log-message">{{ entry.message }}</span>
</div>
</div>
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.logs-view {
height: 100vh;
display: flex;
flex-direction: column;
}
.logs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
gap: 12px;
flex-wrap: wrap;
.header-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
}
.search-input {
padding: 4px 10px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-input;
color: $text-primary;
font-size: 13px;
outline: none;
width: 160px;
transition: border-color $transition-fast;
&:focus { border-color: $accent-primary; }
&::placeholder { color: $text-muted; }
}
.logs-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.logs-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
font-size: 13px;
}
.log-list {
padding: 4px 0;
}
.log-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 20px;
font-family: $font-code;
font-size: 12px;
line-height: 1.6;
border-left: 2px solid transparent;
&:hover {
background-color: rgba($accent-primary, 0.03);
}
&.level-error {
border-left-color: $error;
.log-message { color: $error; }
}
&.level-warning {
border-left-color: $warning;
.log-message { color: #d9720f; }
}
}
.log-time {
color: $text-muted;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.log-level {
flex-shrink: 0;
font-weight: 600;
font-size: 10px;
padding: 0 4px;
border-radius: 2px;
min-width: 42px;
text-align: center;
&.level-error { background: rgba($error, 0.12); color: $error; }
&.level-warning { background: rgba($warning, 0.12); color: #d9720f; }
&.level-debug { background: rgba($accent-primary, 0.06); color: $text-muted; }
&.level-info { background: rgba($accent-primary, 0.06); color: $text-muted; }
}
.log-logger {
color: $text-muted;
flex-shrink: 0;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-message {
color: $text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.access-method {
font-weight: 600;
color: $text-primary;
flex-shrink: 0;
}
.access-path {
color: $accent-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.access-status {
font-weight: 600;
flex-shrink: 0;
font-size: 11px;
&.status-2 { color: $success; }
&.status-3 { color: $warning; }
&.status-4 { color: $error; }
&.status-5 { color: $error; }
}
</style>
+4 -72
View File
@@ -2,14 +2,10 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import type { ProxyOptions } from 'vite'
import { resolve } from 'path'
import type { IncomingMessage, ServerResponse } from 'http'
import { mkdir, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { randomBytes } from 'crypto'
function createProxyConfig(): ProxyOptions {
return {
target: 'http://127.0.0.1:8642',
target: 'http://127.0.0.1:8648',
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
@@ -25,74 +21,8 @@ function createProxyConfig(): ProxyOptions {
}
}
const UPLOAD_DIR = resolve(tmpdir(), 'hermes-uploads')
async function handleUpload(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Method not allowed' }))
return
}
const contentType = req.headers['content-type'] || ''
if (!contentType.startsWith('multipart/form-data')) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Expected multipart/form-data' }))
return
}
try {
await mkdir(UPLOAD_DIR, { recursive: true })
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString()
const boundary = '--' + contentType.split('boundary=')[1]
const parts = body.split(boundary).slice(1, -1)
const results: { name: string; path: string }[] = []
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const header = part.substring(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2)
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = resolve(UPLOAD_DIR, savedName)
await writeFile(savedPath, Buffer.from(data, 'binary'))
results.push({ name: filename, path: savedPath })
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ files: results }))
} catch (err: any) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
}
export default defineConfig({
plugins: [
vue(),
{
name: 'upload-middleware',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url?.startsWith('/__upload')) {
handleUpload(req as any, res as any)
} else {
next()
}
})
},
},
],
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
@@ -103,6 +33,8 @@ export default defineConfig({
'/api': createProxyConfig(),
'/v1': createProxyConfig(),
'/health': createProxyConfig(),
'/upload': createProxyConfig(),
'/webhook': createProxyConfig(),
},
},
})