diff --git a/.gitignore b/.gitignore index a931ded8..6b4e9311 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 66d0174e..1fbc9e77 100644 --- a/README.md +++ b/README.md @@ -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 -``` - -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) diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index 98e77cc2..b2ec8732 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -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')) +} diff --git a/package.json b/package.json index 69fdcf01..93e3e12a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 00000000..83e2724b --- /dev/null +++ b/server/src/config.ts @@ -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 || '*', +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 00000000..767d65cc --- /dev/null +++ b/server/src/index.ts @@ -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() diff --git a/server/src/routes/logs.ts b/server/src/routes/logs.ts new file mode 100644 index 00000000..b043e6d3 --- /dev/null +++ b/server/src/routes/logs.ts @@ -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 } + } +}) diff --git a/server/src/routes/proxy-handler.ts b/server/src/routes/proxy-handler.ts new file mode 100644 index 00000000..a90fa937 --- /dev/null +++ b/server/src/routes/proxy-handler.ts @@ -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 = {} + 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 = {} + 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() + } + } +} diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts new file mode 100644 index 00000000..6a9873ea --- /dev/null +++ b/server/src/routes/proxy.ts @@ -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) diff --git a/server/src/routes/sessions.ts b/server/src/routes/sessions.ts new file mode 100644 index 00000000..264c236b --- /dev/null +++ b/server/src/routes/sessions.ts @@ -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 } +}) diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts new file mode 100644 index 00000000..34d0b984 --- /dev/null +++ b/server/src/routes/upload.ts @@ -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 } +}) diff --git a/server/src/routes/webhook.ts b/server/src/routes/webhook.ts new file mode 100644 index 00000000..19a5fe36 --- /dev/null +++ b/server/src/routes/webhook.ts @@ -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 } +}) diff --git a/server/src/services/hermes-cli.ts b/server/src/services/hermes-cli.ts new file mode 100644 index 00000000..12d6063c --- /dev/null +++ b/server/src/services/hermes-cli.ts @@ -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 { + 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 { + 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 { + 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 { + try { + const { stdout } = await execFileAsync('hermes', ['--version'], { timeout: 5000 }) + return stdout.trim() + } catch { + return '' + } +} + +/** + * Update Hermes Agent + */ +export async function restartGateway(): Promise { + const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'restart'], { + timeout: 30000, + }) + return stdout || stderr +} + +/** + * List available log files + */ +export async function listLogFiles(): Promise { + 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 { + 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}`) + } +} diff --git a/server/src/services/hermes.ts b/server/src/services/hermes.ts new file mode 100644 index 00000000..5ccdfa9f --- /dev/null +++ b/server/src/services/hermes.ts @@ -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 = { + '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 { + 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 { + const headers: Record = {} + 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 +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)) + } + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..fb53ffc7 --- /dev/null +++ b/server/tsconfig.json @@ -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"] +} diff --git a/src/api/logs.ts b/src/api/logs.ts new file mode 100644 index 00000000..8b610465 --- /dev/null +++ b/src/api/logs.ts @@ -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 { + 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 { + 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) +} diff --git a/src/api/sessions.ts b/src/api/sessions.ts new file mode 100644 index 00000000..68498702 --- /dev/null +++ b/src/api/sessions.ts @@ -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 { + 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 { + try { + const res = await request<{ session: SessionDetail }>(`/api/sessions/${id}`) + return res.session + } catch { + return null + } +} + +export async function deleteSession(id: string): Promise { + try { + await request(`/api/sessions/${id}`, { method: 'DELETE' }) + return true + } catch { + return false + } +} diff --git a/src/components/chat/ChatInput.vue b/src/components/chat/ChatInput.vue index bb2e3469..f3efed9e 100644 --- a/src/components/chat/ChatInput.vue +++ b/src/components/chat/ChatInput.vue @@ -12,6 +12,10 @@ const attachments = ref([]) 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" >
- + { - 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) {
+
Loading...
+
No sessions
+ +