mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-06-02 01:10:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 || '*',
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user