refactor: restructure project for multi-agent extensibility

- Migrate source to packages/client and packages/server directories
- Namespace all Hermes-specific code under hermes/ subdirectories
  (api/hermes/, components/hermes/, views/hermes/, stores/hermes/)
- Add hermes.* route names and /hermes/* path prefixes
- Upgrade @koa/router to v15, adapt path-to-regexp v8 syntax
- Fix proxy path rewriting: /api/hermes/v1/* → /v1/*, /api/hermes/* → /api/*
- Fix frontend API paths to match backend /api/hermes/* routes
- Fix WebSocket terminal path to /api/hermes/terminal
- Add proxyMiddleware for reliable unmatched route proxying
- Add profiles route module and hermes-cli profile commands
- Update CLAUDE.md development guide with new architecture
- Add Chinese README (README_zh.md)
- Add Web Terminal feature to README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-16 08:38:18 +08:00
parent 4917242dca
commit 351c861777
106 changed files with 1409 additions and 317 deletions
+3 -2
View File
@@ -11,11 +11,12 @@ node_modules
dist
dist-ssr
server/dist
packages/server/dist
*.local
ROADMAP.md
# Server data
server/data/
server/node_modules/
packages/server/data/
packages/server/node_modules/
.hermes-web-ui/
# Editor directories and files
+452
View File
@@ -0,0 +1,452 @@
# CLAUDE.md — Hermes Web UI Development Guide
## Project Overview
Hermes Web UI is a web dashboard for [Hermes Agent](https://github.com/EKKOLearnAI/hermes-web-ui), a multi-platform AI chat system. It provides session management, scheduled jobs, usage analytics, model configuration, channel management (Telegram, Discord, Slack, WhatsApp, etc.), an integrated terminal, and a streaming chat interface.
The project is designed for **multi-agent extensibility** — Hermes is the first agent integration. All agent-specific code is namespaced under `hermes/` directories, so future agents can be added alongside without conflicts.
**Tech stack:**
- **Frontend:** Vue 3 (Composition API, `<script setup lang="ts">`), Naive UI, Pinia, vue-router (hash history), vue-i18n, SCSS, Vite
- **Backend:** Koa 2, @koa/router v15+, node-pty (WebSocket terminal), reverse proxy to Hermes gateway
- **Language:** TypeScript (strict mode), single package (no workspaces)
---
## Development Commands
```bash
npm run dev # Start both server (nodemon) and client (Vite) concurrently
npm run dev:client # Vite dev server only (proxies API to backend)
npm run dev:server # nodemon + ts-node for server only
npm run build # Type-check (vue-tsc) -> Vite build -> tsc server build
npm run preview # Preview production build with Vite
```
- **Dev port:** 8648 (client Vite dev server proxies `/api`, `/v1`, `/health`, `/upload`, `/webhook` to `http://127.0.0.1:8648`)
- **Prerequisite:** `hermes` CLI must be installed and on `$PATH` (the server wraps it via `child_process.execFile`)
---
## Project Structure
```
hermes-web-ui/
├── bin/ # CLI entry point (bin/hermes-web-ui.mjs)
├── dist/ # Build output
│ ├── client/ # Vite frontend build
│ └── server/ # tsc server build
├── packages/
│ ├── client/src/ # Vue 3 frontend
│ │ ├── api/ # API layer
│ │ │ ├── client.ts # Shared: base request utility (auth, fetch wrapper)
│ │ │ └── hermes/ # Hermes-specific API modules
│ │ │ ├── chat.ts # Gateway proxy: runs, SSE events, models
│ │ │ ├── jobs.ts # Gateway proxy: scheduled jobs CRUD
│ │ │ ├── sessions.ts # Local BFF: session management (wraps hermes CLI)
│ │ │ ├── config.ts # Local BFF: app config, weixin credentials
│ │ │ ├── logs.ts # Local BFF: log file listing & reading
│ │ │ ├── skills.ts # Local BFF: skills listing, memory CRUD
│ │ │ └── system.ts # Local BFF: health, model config, providers
│ │ ├── components/ # Vue components
│ │ │ ├── layout/ # Shared: AppSidebar, LanguageSwitch, ModelSelector
│ │ │ └── hermes/ # Hermes-specific components
│ │ │ ├── chat/ # ChatPanel, ChatInput, MessageList, MarkdownRenderer
│ │ │ ├── jobs/ # JobCard, JobFormModal, JobsPanel
│ │ │ ├── models/ # ProviderCard, ProviderFormModal, ProvidersPanel
│ │ │ ├── settings/ # AgentSettings, DisplaySettings, MemorySettings, etc.
│ │ │ ├── skills/ # SkillList, SkillDetail
│ │ │ └── usage/ # StatCards, DailyTrend, ModelBreakdown
│ │ ├── i18n/locales/ # en.ts, zh.ts
│ │ ├── router/index.ts # vue-router (hash history)
│ │ ├── stores/ # Pinia stores
│ │ │ └── hermes/ # Hermes-specific stores
│ │ │ ├── app.ts # App-level state (health, sidebar, models)
│ │ │ ├── chat.ts # Chat sessions, messages, streaming
│ │ │ ├── jobs.ts # Scheduled jobs CRUD
│ │ │ ├── models.ts # Model provider management
│ │ │ ├── settings.ts # App configuration
│ │ │ └── usage.ts # Usage statistics
│ │ ├── styles/ # global.scss, variables.scss
│ │ └── views/ # Page-level components
│ │ ├── LoginView.vue # Shared: login page
│ │ └── hermes/ # Hermes-specific pages
│ │ ├── ChatView.vue
│ │ ├── JobsView.vue
│ │ ├── ModelsView.vue
│ │ ├── LogsView.vue
│ │ ├── UsageView.vue
│ │ ├── SkillsView.vue
│ │ ├── MemoryView.vue
│ │ ├── SettingsView.vue
│ │ ├── ChannelsView.vue
│ │ └── TerminalView.vue
│ ├── server/src/ # Koa BFF server
│ │ ├── routes/hermes/ # Route modules
│ │ │ ├── index.ts # Aggregates all hermes sub-routers
│ │ │ ├── sessions.ts # Session CRUD (wraps hermes CLI)
│ │ │ ├── profiles.ts # Profile management (wraps hermes CLI)
│ │ │ ├── config.ts # App config read/write
│ │ │ ├── filesystem.ts # Skills, memory, model config, providers
│ │ │ ├── logs.ts # Log file listing & reading
│ │ │ ├── weixin.ts # Weixin QR code & credentials
│ │ │ ├── terminal.ts # WebSocket terminal (node-pty)
│ │ │ ├── proxy.ts # Reverse proxy routes + middleware
│ │ │ └── proxy-handler.ts # Proxy forwarding logic
│ │ ├── routes/ # Shared routes
│ │ │ ├── upload.ts # File upload
│ │ │ └── webhook.ts # Incoming webhooks
│ │ ├── services/ # Business logic
│ │ │ ├── hermes-cli.ts # Hermes CLI wrapper (child_process.execFile)
│ │ │ ├── auth.ts # Auth middleware & token management
│ │ │ └── hermes.ts # Hermes gateway helpers
│ │ ├── shared/providers.ts # Provider model catalogs
│ │ ├── config.ts # Server configuration
│ │ └── index.ts # Bootstrap, middleware setup, SPA fallback
│ └── client/src/shared/ # Frontend shared types (providers.ts)
├── package.json # Single package — no workspaces
├── vite.config.ts # root: packages/client, outDir: dist/client
└── tsconfig.json # Root tsconfig (references for vue-tsc)
```
---
## Naming Conventions
### Multi-Agent Namespacing
All agent-specific code lives under `{agent-name}/` subdirectories. Hermes is the first agent:
| Layer | Shared | Hermes |
|-------|--------|--------|
| API | `api/client.ts` | `api/hermes/*.ts` |
| Components | `components/layout/` | `components/hermes/*/*.vue` |
| Views | `views/LoginView.vue` | `views/hermes/*.vue` |
| Stores | _(future: `stores/app.ts`)_ | `stores/hermes/*.ts` |
| Routes | `path: '/'` (login) | `path: '/hermes/*'`, `name: 'hermes.*'` |
| API paths | `/health`, `/upload`, `/webhook` | `/api/hermes/*` |
When adding a new agent, create a new directory at each layer following the same pattern.
### Route Naming
- **Shared routes:** `login`
- **Agent routes:** `{agent}.{page}` — e.g., `hermes.chat`, `hermes.jobs`
- **Route paths:** `/hermes/{page}` — e.g., `/hermes/chat`, `/hermes/jobs`
---
## Frontend Conventions
### Vue Components
All components use `<script setup lang="ts">` with the Composition API:
```vue
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NModal, useMessage } from 'naive-ui'
import { someApi } from '@/api/hermes/something'
const { t } = useI18n()
const message = useMessage()
const loading = ref(false)
async function handleAction() {
loading.value = true
try {
await someApi()
message.success(t('common.saved'))
} catch {
message.error(t('common.saveFailed'))
} finally {
loading.value = false
}
}
</script>
<template>
<div class="my-component">
<NButton :loading="loading" @click="handleAction">{{ t('common.save') }}</NButton>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.my-component {
padding: 16px;
}
</style>
```
Key patterns:
- Import Naive UI components directly from `naive-ui`
- Use `useMessage()` for toast notifications
- Use `useI18n()` for translations, access via `t('key.path')`
- Scoped SCSS with `@use '@/styles/variables' as *`
### Pinia Stores
Use setup store syntax (function passed to `defineStore`):
```ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useMyStore = defineStore('myStore', () => {
const items = ref<Item[]>([])
const loading = ref(false)
async function fetchItems() {
loading.value = true
try {
items.value = await apiCall()
} finally {
loading.value = false
}
}
return { items, loading, fetchItems }
})
```
Existing stores in `packages/client/src/stores/hermes/`: `app`, `chat`, `jobs`, `models`, `settings`, `usage`.
### API Layer
Agent-specific API modules live in `api/{agent}/`. The shared base `api/client.ts` provides:
- `request<T>(path, options)` — typed fetch wrapper with automatic `Authorization: Bearer` header and global 401 handling (clears token, redirects to login)
- `getApiKey()` / `setApiKey()` / `clearApiKey()` — token management via `localStorage`
- `getBaseUrlValue()` — configurable server URL from `localStorage`
```ts
// packages/client/src/api/hermes/sessions.ts
import { request } from '../client'
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/hermes/sessions${query ? `?${query}` : ''}`)
return res.sessions
}
```
**API path rules:**
- Local BFF endpoints: `/api/hermes/{resource}` — handled by Koa routes, call Hermes CLI directly
- Gateway proxy endpoints: `/api/hermes/v1/*`, `/api/hermes/jobs/*` — forwarded to upstream Hermes gateway
- Shared endpoints: `/health`, `/upload`, `/webhook` — no agent prefix
### i18n
Two locales: `en.ts` and `zh.ts` in `packages/client/src/i18n/locales/`. Flat nested object structure organized by feature section:
```ts
// en.ts
export default {
chat: {
emptyState: 'Start a conversation with Hermes Agent',
inputPlaceholder: 'Type a message...',
sessions: 'Sessions',
// ...
},
common: {
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
// ...
},
}
```
When adding new strings, always add to both `en.ts` and `zh.ts`.
### SCSS Styling
- Global variables in `packages/client/src/styles/variables.scss` — import with `@use '@/styles/variables' as *`
- Theme: "Pure Ink" (monochrome black/white/gray), no color accent
- Mobile breakpoint: `$breakpoint-mobile: 768px`
- Global resets and shared classes in `packages/client/src/styles/global.scss`
- Component styles are always `<style scoped lang="scss">`
### Router
Hash-based routing (`createWebHashHistory`). All routes use lazy imports. Auth guard in `router.beforeEach` redirects unauthenticated users to `/` (login). Public routes use `meta: { public: true }`.
```ts
// Agent route example
{
path: '/hermes/chat',
name: 'hermes.chat',
component: () => import('@/views/hermes/ChatView.vue'),
}
```
---
## Backend Conventions
### Koa Server (`packages/server/src/index.ts`)
The server bootstraps in `bootstrap()`:
1. Creates data/upload directories
2. Sets up auth middleware (if token exists)
3. Ensures Hermes gateway is running (auto-starts if needed)
4. Registers CORS, body parser, all route modules
5. Registers proxy middleware (catches unmatched `/api/hermes/*` and `/v1/*`)
6. Serves static SPA files with fallback to `index.html`
7. Attaches WebSocket handler for terminal
### Route Modules
Each route module exports a `Router` instance, aggregated in `routes/hermes/index.ts`:
```ts
// packages/server/src/routes/hermes/sessions.ts
import Router from '@koa/router'
import * as hermesCli from '../../services/hermes-cli'
export const sessionRoutes = new Router()
sessionRoutes.get('/api/hermes/sessions', async (ctx) => {
const sessions = await hermesCli.listSessions()
ctx.body = { sessions }
})
```
**@koa/router v15 syntax** (path-to-regexp v8):
- Parameters: `:id` (single segment) or `{*path}` (wildcard, matches `/`)
- No regex groups `(.*)` — use `{*name}` instead
- No modifiers `:id+` or `:id*` — use `{*name}`
### Reverse Proxy
Unmatched `/api/hermes/*` and `/v1/*` requests are forwarded to the upstream Hermes gateway (`http://127.0.0.1:8642`). Path rewriting in `proxy-handler.ts`:
- `/api/hermes/v1/*``/v1/*` (upstream uses `/v1/` prefix)
- `/api/hermes/*``/api/*` (upstream uses `/api/` prefix)
The proxy is implemented as both a route (`proxyRoutes.all('/api/hermes/{*any}', proxy)`) and a middleware (`proxyMiddleware`) registered on the main app to catch any requests that slip through route matching.
### Hermes CLI Wrapper (`packages/server/src/services/hermes-cli.ts`)
All Hermes interactions go through `child_process.execFile('hermes', [...args])`. Each function wraps a CLI subcommand:
```ts
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
const { stdout } = await execFileAsync('hermes', ['sessions', 'export', '-'], {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
})
// Parse newline-delimited JSON output
}
```
CLI subcommands wrapped: `sessions export/delete/rename`, `profile list/show/create/delete/rename/use/export/import`, `gateway start/restart/stop`, `logs list/read`, `--version`.
### Auth Middleware (`packages/server/src/services/auth.ts`)
- Token stored in `{dataDir}/.token` (auto-generated on first run), or set via `AUTH_TOKEN` env var
- Auth disabled when `AUTH_DISABLED=1`
- Middleware skips `/health`, `/webhook`, and non-API paths
- Accepts `Authorization: Bearer <token>` header or `?token=<token>` query param
---
## Build System
- **Vite** builds the frontend: root is `packages/client`, output goes to `dist/client`
- **tsc** compiles the server: config in `packages/server/tsconfig.json`, output goes to `dist/server`
- Path alias: `@` maps to `packages/client/src`
- Build command: `vue-tsc -b && vite build && tsc -p packages/server/tsconfig.json`
- TypeScript strict mode enabled for both client and server
---
## Key Patterns
### SSE Streaming (Chat)
Chat uses Server-Sent Events via `EventSource`:
```ts
// packages/client/src/api/hermes/chat.ts
export function streamRunEvents(runId, onEvent, onDone, onError) {
const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events?token=...`
const source = new EventSource(url)
source.onmessage = (e) => {
const parsed = JSON.parse(e.data)
onEvent(parsed)
if (parsed.event === 'run.completed' || parsed.event === 'run.failed') {
source.close()
onDone()
}
}
}
```
Auth token is passed via query parameter since `EventSource` does not support custom headers.
### WebSocket Terminal
Terminal uses a raw WebSocket at `/api/hermes/terminal` with JSON control messages:
- Client sends: `{ type: "create" }`, `{ type: "switch", sessionId }`, `{ type: "close", sessionId }`, `{ type: "resize", cols, rows }`
- Client sends raw strings as keyboard input to the active PTY session
- Server sends raw PTY output strings and JSON messages like `{ type: "created", id, pid, shell }`, `{ type: "exited", id, exitCode }`
- Uses `node-pty` for pseudo-terminal, `@xterm/xterm` for frontend rendering
- Auth via `?token=` query parameter on WebSocket upgrade
---
## Testing
No test framework is currently configured. The intention is to add tests in the future.
---
## Environment Variables
| Variable | Description |
|---|---|
| `AUTH_DISABLED` | Set to `1` or `true` to disable auth |
| `AUTH_TOKEN` | Custom auth token (overrides auto-generated token) |
| `PORT` | Server listen port (default from config) |
| `UPSTREAM` | Hermes gateway URL (default `http://127.0.0.1:8642`) |
---
## Common Tasks
### Add a new Hermes page
1. Create view component in `packages/client/src/views/hermes/MyView.vue`
2. Add route in `packages/client/src/router/index.ts` with name `hermes.myPage` and path `/hermes/my-page`
3. Add sidebar entry in `packages/client/src/components/layout/AppSidebar.vue` with `handleNav('hermes.myPage')`
4. Add i18n keys to both `en.ts` and `zh.ts`
### Add a new Hermes API endpoint
1. Add the route handler in `packages/server/src/routes/hermes/` (new or existing module)
2. If it calls Hermes CLI, add a wrapper function in `packages/server/src/services/hermes-cli.ts`
3. Register the route in `packages/server/src/routes/hermes/index.ts` via `hermesRoutes.use(myRoutes.routes())`
4. Add the frontend API function in `packages/client/src/api/hermes/`
5. If the endpoint should be proxied to the upstream gateway (not handled locally), ensure the path starts with `/api/hermes/` — the `proxyMiddleware` will catch it automatically
### Add a new Hermes Pinia store
1. Create `packages/client/src/stores/hermes/myFeature.ts` using setup syntax
2. Export `useMyFeatureStore` from the module
### Add a new agent integration
1. Create `api/{agent}/`, `components/{agent}/`, `views/{agent}/`, `stores/{agent}/` directories
2. Create `server/src/routes/{agent}/` for agent-specific backend routes
3. Add routes with `path: '/{agent}/*'` and `name: '{agent}.*'` in the router
4. Follow the same patterns as the Hermes integration
+15 -5
View File
@@ -1,5 +1,6 @@
<p align="center">
<strong>Hermes Web UI</strong>
<a href="./README_zh.md">中文</a>
</p>
<p align="center">
@@ -13,14 +14,14 @@
</p>
<p align="center">
<img src="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/src/assets/output.gif" alt="Hermes Web UI Demo" width="680"/>
<img src="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/packages/client/src/assets/output.gif" alt="Hermes Web UI Demo" width="680"/>
</p>
<p align="center">
<strong>Mobile</strong>
</p>
<p align="center">
<video src="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/src/assets/video.mp4?raw=true" width="360" controls></video>
<video src="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/packages/client/src/assets/video.mp4?raw=true" width="360" controls></video>
</p>
<p align="center">
@@ -106,6 +107,13 @@ Unified configuration for **8 platforms** in one page:
- Privacy (PII redaction)
- API server configuration
### Web Terminal
- Integrated terminal powered by node-pty and @xterm/xterm
- Multi-session support — create, switch between, and close terminal sessions
- Real-time keyboard input and PTY output streaming via WebSocket
- Window resize support
---
## Quick Start
@@ -180,16 +188,18 @@ npm run build # outputs to dist/
## Architecture
```
Browser → BFF (Koa, :8648) → Hermes API (:8642)
Browser → BFF (Koa, :8648) → Hermes Gateway (:8642)
Hermes CLI (sessions, logs, version)
~/.hermes/config.yaml (channel behavior)
~/.hermes/.env (platform credentials)
~/.hermes/auth.json (credential pool)
Tencent iLink API (WeChat QR login)
```
The BFF layer handles API proxy, SSE streaming, file upload, session CRUD via CLI, config/credential management, WeChat QR login, model discovery, skills/memory management, log reading, and static file serving.
The frontend is designed with **multi-agent extensibility** — all Hermes-specific code is namespaced under `hermes/` directories (API, components, views, stores), making it straightforward to add new agent integrations alongside.
The BFF layer handles API proxy (with path rewriting), SSE streaming, file upload, session CRUD via CLI, config/credential management, WeChat QR login, model discovery, skills/memory management, log reading, and static file serving.
## Tech Stack
+213
View File
@@ -0,0 +1,213 @@
<p align="center">
<strong>Hermes Web UI</strong>
<a href="./README.md">English</a>
</p>
<p align="center">
<a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> 的全功能 Web 管理面板。<br/>
管理 AI 聊天会话、监控用量与成本、配置平台渠道、<br/>
管理定时任务、浏览技能 —— 全部在一个简洁响应式的 Web 界面中完成。
</p>
<p align="center">
<code>npm install -g hermes-web-ui && hermes-web-ui start</code>
</p>
<p align="center">
<img src="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/packages/client/src/assets/output.gif" alt="Hermes Web UI 演示" width="680"/>
</p>
<p align="center">
<strong>移动端</strong>
</p>
<p align="center">
<video src="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/packages/client/src/assets/video.mp4?raw=true" width="360" controls></video>
</p>
<p align="center">
<a href="https://www.npmjs.com/package/hermes-web-ui"><img src="https://img.shields.io/npm/v/hermes-web-ui?style=flat-square&color=blue" alt="npm 版本"/></a>
<a href="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/hermes-web-ui?style=flat-square" alt="许可证"/></a>
<a href="https://github.com/EKKOLearnAI/hermes-web-ui/stargazers"><img src="https://img.shields.io/github/stars/EKKOLearnAI/hermes-web-ui?style=flat-square" alt="Star"/></a>
</p>
---
## 功能特性
### AI 聊天
- 通过 SSE 实时流式输出,支持异步 Run
- 多会话管理 — 创建、重命名、删除、切换会话
- 按来源分组会话(Telegram、Discord、Slack 等),可折叠手风琴面板
- Markdown 渲染,支持语法高亮和代码复制
- 工具调用详情展开(参数 / 结果)
- 文件上传支持
- 全局模型选择器 — 自动从 `~/.hermes/auth.json` 凭证池发现可用模型
- 每个会话显示模型标签和上下文 Token 用量
### 平台渠道
在一个页面统一配置 **8 个平台**
| 平台 | 功能 |
|---|---|
| Telegram | Bot Token、提及控制、表情回应、自由回复聊天 |
| Discord | Bot Token、提及、自动线程、表情回应、频道白名单/黑名单 |
| Slack | Bot Token、提及控制、Bot 消息处理 |
| WhatsApp | 启用/禁用、提及控制、提及模式 |
| Matrix | Access Token、Homeserver、自动线程、私信提及线程 |
| 飞书 | App ID / Secret、提及控制 |
| 微信 | 扫码登录(浏览器扫码,自动保存凭证) |
| 企业微信 | Bot ID / Secret |
- 凭证管理写入 `~/.hermes/.env`
- 渠道行为设置写入 `~/.hermes/config.yaml`
- 配置变更后自动重启网关
- 每个平台已配置/未配置状态检测
### 用量分析
- Token 总用量明细(输入 / 输出)
- 会话数及日均统计
- 预估费用追踪及缓存命中率
- 模型使用分布图
- 30 天每日趋势(柱状图 + 数据表格)
### 定时任务
- 创建、编辑、暂停、恢复、删除 Cron 任务
- 立即触发执行
- Cron 表达式快捷预设
### 模型管理
- 从凭证池自动发现模型(`~/.hermes/auth.json`
- 从每个 Provider 端点获取可用模型(`/v1/models`
- 添加自定义 OpenAI 兼容 Provider
- Provider 级别模型分组
### 技能与记忆
- 浏览和搜索已安装的技能
- 查看技能详情和附件
- 用户笔记和档案管理
### 日志
- 查看 Agent / Gateway / Error 日志
- 按日志级别、日志文件和关键词过滤
- 结构化日志解析,HTTP 访问日志高亮
### 设置
- 显示(流式输出、紧凑模式、推理过程、费用显示)
- Agent(最大轮次、超时时间、工具强制执行)
- 记忆(启用/禁用、字符限制)
- 会话重置(空闲超时、定时重置)
- 隐私(PII 脱敏)
- API 服务器配置
### Web 终端
- 集成终端,基于 node-pty 和 @xterm/xterm
- 多会话支持 — 创建、切换、关闭终端会话
- 通过 WebSocket 实时传输键盘输入和 PTY 输出
- 支持窗口大小调整
---
## 快速开始
### npm 安装(推荐)
```bash
npm install -g hermes-web-ui
hermes-web-ui start
```
打开 **http://localhost:8648**
### 一键安装(自动检测系统)
自动安装 Node.js(如未安装)和 hermes-web-ui,支持 Debian/Ubuntu/macOS
```bash
bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh)
```
### WSL
```bash
bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh)
hermes-web-ui start
```
> WSL 会自动检测并使用 `hermes gateway run` 进行后台启动(无需 launchd/systemd)。
### CLI 命令
| 命令 | 说明 |
|---|---|
| `hermes-web-ui start` | 后台启动(守护进程模式) |
| `hermes-web-ui start --port 9000` | 自定义端口启动 |
| `hermes-web-ui stop` | 停止后台进程 |
| `hermes-web-ui restart` | 重启后台进程 |
| `hermes-web-ui status` | 查看运行状态 |
| `hermes-web-ui update` | 更新到最新版本并重启 |
| `hermes-web-ui -v` | 显示版本号 |
| `hermes-web-ui -h` | 显示帮助信息 |
### 自动配置
启动时 BFF 服务器会自动:
- 校验 `~/.hermes/config.yaml` 并补全缺失的 `api_server` 字段
- 修改时备份原配置到 `config.yaml.bak`
- 检测并启动网关(如未运行)
- 解决端口冲突(清理残留进程)
- 启动成功后自动打开浏览器
---
## 开发
```bash
git clone https://github.com/EKKOLearnAI/hermes-web-ui.git
cd hermes-web-ui
npm install
npm run dev
```
- 前端:http://localhost:5173
- BFF 服务器:http://localhost:8648(代理到 Hermes 网关 8642
```bash
npm run build # 构建输出到 dist/
```
## 架构
```
浏览器 → BFF (Koa, :8648) → Hermes 网关 (:8642)
Hermes CLI (会话、日志、版本)
~/.hermes/config.yaml (渠道行为配置)
~/.hermes/auth.json (凭证池)
腾讯 iLink API (微信扫码登录)
```
前端采用 **多 Agent 可扩展架构** — 所有 Hermes 相关代码都按命名空间组织在 `hermes/` 目录下(API、组件、视图、Store),可以方便地并行接入新的 Agent。
BFF 层负责:API 代理(含路径重写)、SSE 流式推送、文件上传、通过 CLI 管理会话 CRUD、配置/凭证管理、微信扫码登录、模型发现、技能/记忆管理、日志读取和静态文件服务。
## 技术栈
**前端:** Vue 3 + TypeScript + Vite + Naive UI + Pinia + Vue Router + vue-i18n + SCSS + markdown-it + highlight.js
**后端:** Koa 2BFF 服务器)+ node-ptyWeb 终端)
## 许可证
[MIT](./LICENSE)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

+5 -5
View File
@@ -33,8 +33,8 @@
"start": "vite --host --port 8648",
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "vite --host",
"dev:server": "nodemon --signal SIGTERM --watch server/src -e ts,tsx --exec node -r ts-node/register server/src/index.ts",
"build": "vue-tsc -b && vite build && tsc -p server/tsconfig.json",
"dev:server": "nodemon --signal SIGTERM --watch packages/server/src -e ts,tsx --exec node -r ts-node/register packages/server/src/index.ts",
"build": "vue-tsc -b && vite build && tsc -p packages/server/tsconfig.json",
"preview": "vite preview"
},
"files": [
@@ -44,7 +44,7 @@
"dependencies": {
"@koa/bodyparser": "^5.0.0",
"@koa/cors": "^5.0.0",
"@koa/router": "^13.1.0",
"@koa/router": "^15.4.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
@@ -68,7 +68,7 @@
"@types/js-yaml": "^4.0.9",
"@types/koa": "^2.15.0",
"@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4",
"@types/koa__router": "^12.0.5",
"@types/koa-send": "^4.1.6",
"@types/koa-static": "^4.0.4",
"@types/markdown-it": "^14.1.2",
@@ -85,4 +85,4 @@
"vite": "^8.0.4",
"vue-tsc": "^3.2.6"
}
}
}

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+1 -1
View File
@@ -5,7 +5,7 @@ import { NConfigProvider, NMessageProvider, NDialogProvider, NNotificationProvid
import { themeOverrides } from '@/styles/theme'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import { useKeyboard } from '@/composables/useKeyboard'
import { useAppStore } from '@/stores/app'
import { useAppStore } from '@/stores/hermes/app'
const appStore = useAppStore()
const route = useRoute()
@@ -1,4 +1,4 @@
import { request, getBaseUrlValue, getApiKey } from './client'
import { request, getBaseUrlValue, getApiKey } from '../client'
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
@@ -31,7 +31,7 @@ export interface RunEvent {
}
export async function startRun(body: StartRunRequest): Promise<StartRunResponse> {
return request<StartRunResponse>('/v1/runs', {
return request<StartRunResponse>('/api/hermes/v1/runs', {
method: 'POST',
body: JSON.stringify(body),
})
@@ -45,7 +45,7 @@ export function streamRunEvents(
) {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const url = `${baseUrl}/v1/runs/${runId}/events${token ? `?token=${encodeURIComponent(token)}` : ''}`
const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${token ? `?token=${encodeURIComponent(token)}` : ''}`
let closed = false
const source = new EventSource(url)
@@ -85,5 +85,5 @@ export function streamRunEvents(
}
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
return request('/v1/models')
return request('/api/hermes/v1/models')
}
@@ -1,4 +1,4 @@
import { request } from './client'
import { request } from '../client'
export interface DisplayConfig {
compact?: boolean
@@ -59,14 +59,14 @@ export interface AppConfig {
export async function fetchConfig(sections?: string[]): Promise<AppConfig> {
const query = sections ? `?sections=${sections.join(',')}` : ''
return request<AppConfig>(`/api/config${query}`)
return request<AppConfig>(`/api/hermes/config${query}`)
}
export async function updateConfigSection(
section: string,
values: Record<string, any>,
): Promise<void> {
await request('/api/config', {
await request('/api/hermes/config', {
method: 'PUT',
body: JSON.stringify({ section, values }),
})
@@ -76,7 +76,7 @@ export async function saveCredentials(
platform: string,
values: Record<string, any>,
): Promise<void> {
await request('/api/config/credentials', {
await request('/api/hermes/config/credentials', {
method: 'PUT',
body: JSON.stringify({ platform, values }),
})
@@ -95,11 +95,11 @@ export interface WeixinQrStatus {
}
export async function fetchWeixinQrCode(): Promise<WeixinQrCode> {
return request<WeixinQrCode>('/api/weixin/qrcode')
return request<WeixinQrCode>('/api/hermes/weixin/qrcode')
}
export async function pollWeixinQrStatus(qrcode: string): Promise<WeixinQrStatus> {
return request<WeixinQrStatus>(`/api/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`)
return request<WeixinQrStatus>(`/api/hermes/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`)
}
export async function saveWeixinCredentials(data: {
@@ -107,7 +107,7 @@ export async function saveWeixinCredentials(data: {
token: string
base_url?: string
}): Promise<void> {
await request('/api/weixin/save', {
await request('/api/hermes/weixin/save', {
method: 'POST',
body: JSON.stringify(data),
})
@@ -1,4 +1,4 @@
import { request } from './client'
import { request } from '../client'
export interface Job {
job_id: string
@@ -59,42 +59,42 @@ function unwrap(res: { job: Job }): Job {
}
export async function listJobs(): Promise<Job[]> {
const res = await request<{ jobs: Job[] }>('/api/jobs?include_disabled=true')
const res = await request<{ jobs: Job[] }>('/api/hermes/jobs?include_disabled=true')
return res.jobs
}
export async function getJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}`))
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`))
}
export async function createJob(data: CreateJobRequest): Promise<Job> {
return unwrap(await request<{ job: Job }>('/api/jobs', {
return unwrap(await request<{ job: Job }>('/api/hermes/jobs', {
method: 'POST',
body: JSON.stringify(data),
}))
}
export async function updateJob(jobId: string, data: UpdateJobRequest): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}`, {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}))
}
export async function deleteJob(jobId: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/jobs/${jobId}`, {
return request<{ ok: boolean }>(`/api/hermes/jobs/${jobId}`, {
method: 'DELETE',
})
}
export async function pauseJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/pause`, { method: 'POST' }))
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/pause`, { method: 'POST' }))
}
export async function resumeJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/resume`, { method: 'POST' }))
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/resume`, { method: 'POST' }))
}
export async function runJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/run`, { method: 'POST' }))
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/run`, { method: 'POST' }))
}
@@ -1,4 +1,4 @@
import { request } from './client'
import { request } from '../client'
export interface LogFileInfo {
name: string
@@ -15,7 +15,7 @@ export interface LogEntry {
}
export async function fetchLogFiles(): Promise<LogFileInfo[]> {
const res = await request<{ files: LogFileInfo[] }>('/api/logs')
const res = await request<{ files: LogFileInfo[] }>('/api/hermes/logs')
return res.files
}
@@ -31,6 +31,6 @@ export async function fetchLogs(name: string, params?: {
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}` : ''}`)
const res = await request<{ entries: (LogEntry | null)[] }>(`/api/hermes/logs/${name}${qs ? `?${qs}` : ''}`)
return res.entries.filter((e): e is LogEntry => e !== null)
}
@@ -1,4 +1,4 @@
import { request } from './client'
import { request } from '../client'
export interface SessionSummary {
id: string
@@ -43,13 +43,13 @@ export async function fetchSessions(source?: string, limit?: number): Promise<Se
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}` : ''}`)
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/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}`)
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}`)
return res.session
} catch {
return null
@@ -58,7 +58,7 @@ export async function fetchSession(id: string): Promise<SessionDetail | null> {
export async function deleteSession(id: string): Promise<boolean> {
try {
await request(`/api/sessions/${id}`, { method: 'DELETE' })
await request(`/api/hermes/sessions/${id}`, { method: 'DELETE' })
return true
} catch {
return false
@@ -67,7 +67,7 @@ export async function deleteSession(id: string): Promise<boolean> {
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await request(`/api/sessions/${id}/rename`, {
await request(`/api/hermes/sessions/${id}/rename`, {
method: 'POST',
body: JSON.stringify({ title }),
})
@@ -1,4 +1,4 @@
import { request } from './client'
import { request } from '../client'
export interface SkillInfo {
name: string
@@ -30,33 +30,33 @@ export interface MemoryData {
}
export async function fetchSkills(): Promise<SkillCategory[]> {
const res = await request<SkillListResponse>('/api/skills')
const res = await request<SkillListResponse>('/api/hermes/skills')
return res.categories
}
export async function fetchSkillContent(skillPath: string): Promise<string> {
const res = await request<{ content: string }>(`/api/skills/${skillPath}`)
const res = await request<{ content: string }>(`/api/hermes/skills/${skillPath}`)
return res.content
}
export async function fetchSkillFiles(category: string, skill: string): Promise<SkillFileEntry[]> {
const res = await request<{ files: SkillFileEntry[] }>(`/api/skills/${category}/${skill}/files`)
const res = await request<{ files: SkillFileEntry[] }>(`/api/hermes/skills/${category}/${skill}/files`)
return res.files
}
export async function fetchMemory(): Promise<MemoryData> {
return request<MemoryData>('/api/memory')
return request<MemoryData>('/api/hermes/memory')
}
export async function saveMemory(section: 'memory' | 'user', content: string): Promise<void> {
await request('/api/memory', {
await request('/api/hermes/memory', {
method: 'POST',
body: JSON.stringify({ section, content }),
})
}
export async function toggleSkill(name: string, enabled: boolean): Promise<void> {
await request('/api/skills/toggle', {
await request('/api/hermes/skills/toggle', {
method: 'PUT',
body: JSON.stringify({ name, enabled }),
})
@@ -1,21 +1,10 @@
import { request } from './client'
import { request } from '../client'
export interface HealthResponse {
status: string
version?: string
}
export interface Model {
id: string
object: string
owned_by: string
}
export interface ModelsResponse {
object: string
data: Model[]
}
// Config-based model types
export interface ModelInfo {
id: string
@@ -56,16 +45,12 @@ export async function checkHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/health')
}
export async function fetchModels(): Promise<ModelsResponse> {
return request<ModelsResponse>('/v1/models')
}
export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
return request<ConfigModelsResponse>('/api/config/models')
return request<ConfigModelsResponse>('/api/hermes/config/models')
}
export async function fetchAvailableModels(): Promise<AvailableModelsResponse> {
return request<AvailableModelsResponse>('/api/available-models')
return request<AvailableModelsResponse>('/api/hermes/available-models')
}
export async function updateDefaultModel(data: {
@@ -74,21 +59,21 @@ export async function updateDefaultModel(data: {
base_url?: string
api_key?: string
}): Promise<void> {
await request('/api/config/model', {
await request('/api/hermes/config/model', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function addCustomProvider(data: CustomProvider): Promise<void> {
await request('/api/config/providers', {
await request('/api/hermes/config/providers', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function removeCustomProvider(name: string): Promise<void> {
await request(`/api/config/providers/${encodeURIComponent(name)}`, {
await request(`/api/hermes/config/providers/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
}

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { Attachment } from '@/stores/chat'
import { useChatStore } from '@/stores/chat'
import type { Attachment } from '@/stores/hermes/chat'
import { useChatStore } from '@/stores/hermes/chat'
import { NButton, NTooltip } from 'naive-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { renameSession } from '@/api/sessions'
import { useChatStore, type Session } from '@/stores/chat'
import { renameSession } from '@/api/hermes/sessions'
import { useChatStore, type Session } from '@/stores/hermes/chat'
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Message } from "@/stores/chat";
import type { Message } from "@/stores/hermes/chat";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import MarkdownRenderer from "./MarkdownRenderer.vue";
@@ -2,7 +2,7 @@
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import MessageItem from './MessageItem.vue'
import { useChatStore } from '@/stores/chat'
import { useChatStore } from '@/stores/hermes/chat'
import thinkingVideo from '@/assets/thinking.mp4'
const chatStore = useChatStore()
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NTooltip, useMessage } from 'naive-ui'
import type { Job } from '@/api/jobs'
import { useJobsStore } from '@/stores/jobs'
import type { Job } from '@/api/hermes/jobs'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ job: Job }>()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
import { useJobsStore } from '@/stores/jobs'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -51,7 +51,7 @@ const targetOptions = computed(() => [
onMounted(async () => {
if (props.jobId) {
try {
const { getJob } = await import('@/api/jobs')
const { getJob } = await import('@/api/hermes/jobs')
const job = await getJob(props.jobId)
formData.value = {
name: job.name,
@@ -1,6 +1,6 @@
<script setup lang="ts">
import JobCard from './JobCard.vue'
import { useJobsStore } from '@/stores/jobs'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, useMessage, useDialog } from 'naive-ui'
import type { AvailableModelGroup } from '@/api/system'
import { useModelsStore } from '@/stores/models'
import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ provider: AvailableModelGroup }>()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
import { useModelsStore } from '@/stores/models'
import { useModelsStore } from '@/stores/hermes/models'
import { PROVIDER_PRESETS } from '@/shared/providers'
import { useI18n } from 'vue-i18n'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import ProviderCard from './ProviderCard.vue'
import { useModelsStore } from '@/stores/models'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { NSwitch, NInputNumber, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
@@ -2,8 +2,8 @@
import { ref, onUnmounted } from 'vue'
import { NSwitch, NInput, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/config'
import { useSettingsStore } from '@/stores/hermes/settings'
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/hermes/config'
import PlatformCard from './PlatformCard.vue'
import SettingRow from './SettingRow.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
import { fetchSkillContent, fetchSkillFiles, type SkillFileEntry } from '@/api/skills'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
import { fetchSkillContent, fetchSkillFiles, type SkillFileEntry } from '@/api/hermes/skills'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NSwitch, useMessage } from 'naive-ui'
import type { SkillCategory } from '@/api/skills'
import { toggleSkill } from '@/api/skills'
import type { SkillCategory } from '@/api/hermes/skills'
import { toggleSkill } from '@/api/hermes/skills'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/usage'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/usage'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/usage'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
@@ -2,7 +2,7 @@
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useAppStore } from "@/stores/app";
import { useAppStore } from "@/stores/hermes/app";
import ModelSelector from "./ModelSelector.vue";
import LanguageSwitch from "./LanguageSwitch.vue";
import danceVideo from "@/assets/dance.mp4";
@@ -67,7 +67,7 @@ function handleNav(key: string) {
<template>
<aside class="sidebar" :class="{ open: appStore.sidebarOpen }">
<div class="sidebar-logo" @click="router.push('/chat')">
<div class="sidebar-logo" @click="router.push('/hermes/chat')">
<img src="/logo.png" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
<canvas ref="canvasRef" class="logo-dance" />
@@ -76,8 +76,8 @@ function handleNav(key: string) {
<nav class="sidebar-nav">
<button
class="nav-item"
:class="{ active: selectedKey === 'chat' }"
@click="handleNav('chat')"
:class="{ active: selectedKey === 'hermes.chat' }"
@click="handleNav('hermes.chat')"
>
<svg
width="16"
@@ -98,8 +98,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'jobs' }"
@click="handleNav('jobs')"
:class="{ active: selectedKey === 'hermes.jobs' }"
@click="handleNav('hermes.jobs')"
>
<svg
width="16"
@@ -121,8 +121,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'models' }"
@click="handleNav('models')"
:class="{ active: selectedKey === 'hermes.models' }"
@click="handleNav('hermes.models')"
>
<svg
width="16"
@@ -149,8 +149,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'channels' }"
@click="handleNav('channels')"
:class="{ active: selectedKey === 'hermes.channels' }"
@click="handleNav('hermes.channels')"
>
<svg
width="16"
@@ -169,8 +169,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'skills' }"
@click="handleNav('skills')"
:class="{ active: selectedKey === 'hermes.skills' }"
@click="handleNav('hermes.skills')"
>
<svg
width="16"
@@ -191,8 +191,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'memory' }"
@click="handleNav('memory')"
:class="{ active: selectedKey === 'hermes.memory' }"
@click="handleNav('hermes.memory')"
>
<svg
width="16"
@@ -213,8 +213,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'logs' }"
@click="handleNav('logs')"
:class="{ active: selectedKey === 'hermes.logs' }"
@click="handleNav('hermes.logs')"
>
<svg
width="16"
@@ -239,8 +239,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'usage' }"
@click="handleNav('usage')"
:class="{ active: selectedKey === 'hermes.usage' }"
@click="handleNav('hermes.usage')"
>
<svg
width="16"
@@ -261,8 +261,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'terminal' }"
@click="handleNav('terminal')"
:class="{ active: selectedKey === 'hermes.terminal' }"
@click="handleNav('hermes.terminal')"
>
<svg
width="16"
@@ -282,8 +282,8 @@ function handleNav(key: string) {
<button
class="nav-item"
:class="{ active: selectedKey === 'settings' }"
@click="handleNav('settings')"
:class="{ active: selectedKey === 'hermes.settings' }"
@click="handleNav('hermes.settings')"
>
<svg
width="16"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NSelect } from 'naive-ui'
import { useAppStore } from '@/stores/app'
import { useAppStore } from '@/stores/hermes/app'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -1,6 +1,6 @@
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useChatStore } from '@/stores/hermes/chat'
export function useKeyboard() {
const router = useRouter()
@@ -16,7 +16,7 @@ export function useKeyboard() {
if (mod && e.key === 'j') {
e.preventDefault()
router.push({ name: 'jobs' })
router.push({ name: 'hermes.jobs' })
}
if (e.key === 'Escape') {
View File
+87
View File
@@ -0,0 +1,87 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { hasApiKey } from '@/api/client'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/hermes/chat',
name: 'hermes.chat',
component: () => import('@/views/hermes/ChatView.vue'),
},
{
path: '/hermes/jobs',
name: 'hermes.jobs',
component: () => import('@/views/hermes/JobsView.vue'),
},
{
path: '/hermes/models',
name: 'hermes.models',
component: () => import('@/views/hermes/ModelsView.vue'),
},
{
path: '/hermes/logs',
name: 'hermes.logs',
component: () => import('@/views/hermes/LogsView.vue'),
},
{
path: '/hermes/usage',
name: 'hermes.usage',
component: () => import('@/views/hermes/UsageView.vue'),
},
{
path: '/hermes/skills',
name: 'hermes.skills',
component: () => import('@/views/hermes/SkillsView.vue'),
},
{
path: '/hermes/memory',
name: 'hermes.memory',
component: () => import('@/views/hermes/MemoryView.vue'),
},
{
path: '/hermes/settings',
name: 'hermes.settings',
component: () => import('@/views/hermes/SettingsView.vue'),
},
{
path: '/hermes/channels',
name: 'hermes.channels',
component: () => import('@/views/hermes/ChannelsView.vue'),
},
{
path: '/hermes/terminal',
name: 'hermes.terminal',
component: () => import('@/views/hermes/TerminalView.vue'),
},
],
})
router.beforeEach((to, _from, next) => {
// Public pages don't need auth
if (to.meta.public) {
// Already has key, skip login
if (to.name === 'login' && hasApiKey()) {
next({ path: '/hermes/chat' })
return
}
next()
return
}
// All other pages require token
if (!hasApiKey()) {
next({ name: 'login' })
return
}
next()
})
export default router
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { checkHealth, fetchAvailableModels, updateDefaultModel, type AvailableModelGroup } from '@/api/system'
import { checkHealth, fetchAvailableModels, updateDefaultModel, type AvailableModelGroup } from '@/api/hermes/system'
export const useAppStore = defineStore('app', () => {
const sidebarOpen = ref(false)
@@ -1,5 +1,5 @@
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/sessions'
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAppStore } from './app'
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as jobsApi from '@/api/jobs'
import type { Job, CreateJobRequest, UpdateJobRequest } from '@/api/jobs'
import * as jobsApi from '@/api/hermes/jobs'
import type { Job, CreateJobRequest, UpdateJobRequest } from '@/api/hermes/jobs'
function matchId(job: Job, id: string): boolean {
return job.job_id === id || job.id === id
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as systemApi from '@/api/system'
import type { AvailableModelGroup, CustomProvider } from '@/api/system'
import * as systemApi from '@/api/hermes/system'
import type { AvailableModelGroup, CustomProvider } from '@/api/hermes/system'
import { useAppStore } from './app'
export const useModelsStore = defineStore('models', () => {
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as configApi from '@/api/config'
import type { DisplayConfig, AgentConfig, MemoryConfig, SessionResetConfig, PrivacyConfig } from '@/api/config'
import * as configApi from '@/api/hermes/config'
import type { DisplayConfig, AgentConfig, MemoryConfig, SessionResetConfig, PrivacyConfig } from '@/api/hermes/config'
export const useSettingsStore = defineStore('settings', () => {
const loading = ref(false)
@@ -1,4 +1,4 @@
import { fetchSessions, type SessionSummary } from '@/api/sessions'
import { fetchSessions, type SessionSummary } from '@/api/hermes/sessions'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -15,7 +15,7 @@ const loading = ref(false);
const errorMsg = ref("");
// If already has a key, try to go to main page
if (hasApiKey()) {
router.replace("/chat");
router.replace("/hermes/chat");
}
async function handleLogin() {
@@ -41,7 +41,7 @@ async function handleLogin() {
}
setApiKey(key);
router.replace("/chat");
router.replace("/hermes/chat");
} catch {
errorMsg.value = t("login.connectionFailed");
} finally {
@@ -2,8 +2,8 @@
import { onMounted } from 'vue'
import { NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import PlatformSettings from '@/components/settings/PlatformSettings.vue'
import { useSettingsStore } from '@/stores/hermes/settings'
import PlatformSettings from '@/components/hermes/settings/PlatformSettings.vue'
const settingsStore = useSettingsStore()
const { t } = useI18n()
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import ChatPanel from '@/components/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/app'
import { useChatStore } from '@/stores/chat'
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
const appStore = useAppStore()
const chatStore = useChatStore()
@@ -2,9 +2,9 @@
import { ref, onMounted } from 'vue'
import { NButton, NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import JobsPanel from '@/components/jobs/JobsPanel.vue'
import JobFormModal from '@/components/jobs/JobFormModal.vue'
import { useJobsStore } from '@/stores/jobs'
import JobsPanel from '@/components/hermes/jobs/JobsPanel.vue'
import JobFormModal from '@/components/hermes/jobs/JobFormModal.vue'
import { useJobsStore } from '@/stores/hermes/jobs'
const { t } = useI18n()
const jobsStore = useJobsStore()
@@ -2,7 +2,7 @@
import { ref, onMounted, computed } from 'vue'
import { NSelect, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { fetchLogFiles, fetchLogs, type LogEntry } from '@/api/logs'
import { fetchLogFiles, fetchLogs, type LogEntry } from '@/api/hermes/logs'
const { t } = useI18n()
const message = useMessage()
@@ -2,8 +2,8 @@
import { ref, onMounted, computed } from 'vue'
import { NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
import { fetchMemory, saveMemory, type MemoryData } from '@/api/skills'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
import { fetchMemory, saveMemory, type MemoryData } from '@/api/hermes/skills'
const { t } = useI18n()
const message = useMessage()
@@ -2,9 +2,9 @@
import { ref, onMounted } from 'vue'
import { NButton, NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import ProvidersPanel from '@/components/models/ProvidersPanel.vue'
import ProviderFormModal from '@/components/models/ProviderFormModal.vue'
import { useModelsStore } from '@/stores/models'
import ProvidersPanel from '@/components/hermes/models/ProvidersPanel.vue'
import ProviderFormModal from '@/components/hermes/models/ProviderFormModal.vue'
import { useModelsStore } from '@/stores/hermes/models'
const { t } = useI18n()
const modelsStore = useModelsStore()
@@ -2,13 +2,13 @@
import { onMounted } from 'vue'
import { NTabs, NTabPane, NSpin, NSwitch, NInput, NInputNumber, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import DisplaySettings from '@/components/settings/DisplaySettings.vue'
import AgentSettings from '@/components/settings/AgentSettings.vue'
import MemorySettings from '@/components/settings/MemorySettings.vue'
import SessionSettings from '@/components/settings/SessionSettings.vue'
import PrivacySettings from '@/components/settings/PrivacySettings.vue'
import SettingRow from '@/components/settings/SettingRow.vue'
import { useSettingsStore } from '@/stores/hermes/settings'
import DisplaySettings from '@/components/hermes/settings/DisplaySettings.vue'
import AgentSettings from '@/components/hermes/settings/AgentSettings.vue'
import MemorySettings from '@/components/hermes/settings/MemorySettings.vue'
import SessionSettings from '@/components/hermes/settings/SessionSettings.vue'
import PrivacySettings from '@/components/hermes/settings/PrivacySettings.vue'
import SettingRow from '@/components/hermes/settings/SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
@@ -2,9 +2,9 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { NInput } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import SkillList from '@/components/skills/SkillList.vue'
import SkillDetail from '@/components/skills/SkillDetail.vue'
import { fetchSkills, type SkillCategory } from '@/api/skills'
import SkillList from '@/components/hermes/skills/SkillList.vue'
import SkillDetail from '@/components/hermes/skills/SkillDetail.vue'
import { fetchSkills, type SkillCategory } from '@/api/hermes/skills'
const { t } = useI18n()
const categories = ref<SkillCategory[]>([])
@@ -60,14 +60,14 @@ function buildWsUrl(): string {
: "ws:";
if (base) {
return `${wsProtocol}//${new URL(base).host}/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
// Dev mode: connect directly to backend port; Production: same host
const host = import.meta.env.DEV
? `${location.hostname}:8648`
: location.host;
return `${wsProtocol}//${host}/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
function connect() {
@@ -2,10 +2,10 @@
import { NButton } from 'naive-ui'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/usage'
import StatCards from '@/components/usage/StatCards.vue'
import ModelBreakdown from '@/components/usage/ModelBreakdown.vue'
import DailyTrend from '@/components/usage/DailyTrend.vue'
import { useUsageStore } from '@/stores/hermes/usage'
import StatCards from '@/components/hermes/usage/StatCards.vue'
import ModelBreakdown from '@/components/hermes/usage/ModelBreakdown.vue'
import DailyTrend from '@/components/hermes/usage/DailyTrend.vue'
const { t } = useI18n()
const usageStore = useUsageStore()
@@ -6,15 +6,9 @@ import send from 'koa-send'
import { resolve } from 'path'
import { mkdir } from 'fs/promises'
import { config } from './config'
import { proxyRoutes } from './routes/proxy'
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
import { uploadRoutes } from './routes/upload'
import { sessionRoutes } from './routes/sessions'
import { webhookRoutes } from './routes/webhook'
import { logRoutes } from './routes/logs'
import { fsRoutes } from './routes/filesystem'
import { configRoutes } from './routes/config'
import { weixinRoutes } from './routes/weixin'
import { setupTerminalWebSocket } from './routes/terminal'
import * as hermesCli from './services/hermes-cli'
import { getToken, authMiddleware } from './services/auth'
@@ -45,12 +39,9 @@ export async function bootstrap() {
app.use(bodyParser())
app.use(webhookRoutes.routes())
app.use(logRoutes.routes())
app.use(uploadRoutes.routes())
app.use(sessionRoutes.routes())
app.use(fsRoutes.routes())
app.use(configRoutes.routes())
app.use(weixinRoutes.routes())
app.use(hermesRoutes.routes())
app.use(proxyMiddleware)
// health
app.use(async (ctx, next) => {
@@ -77,14 +68,11 @@ export async function bootstrap() {
await next()
})
app.use(proxyRoutes.routes())
// SPA
const distDir = resolve(__dirname, '..')
const distDir = resolve(__dirname, '..', 'client')
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') {
@@ -4,7 +4,7 @@ import { chmod } from 'fs/promises'
import { resolve } from 'path'
import { homedir } from 'os'
import YAML from 'js-yaml'
import { restartGateway } from '../services/hermes-cli'
import { restartGateway } from '../../services/hermes-cli'
// Platform sections that require gateway restart after config change
const PLATFORM_SECTIONS = new Set([
@@ -168,7 +168,7 @@ async function writeConfig(data: Record<string, any>): Promise<void> {
export const configRoutes = new Router()
// GET /api/config — read config sections
configRoutes.get('/api/config', async (ctx) => {
configRoutes.get('/api/hermes/config', async (ctx) => {
try {
const config = await readConfig()
// Merge .env platform credentials into platforms section
@@ -202,7 +202,7 @@ configRoutes.get('/api/config', async (ctx) => {
})
// PUT /api/config — update a config section (writes to config.yaml)
configRoutes.put('/api/config', async (ctx) => {
configRoutes.put('/api/hermes/config', async (ctx) => {
const { section, values } = ctx.request.body as {
section: string
values: Record<string, any>
@@ -232,7 +232,7 @@ configRoutes.put('/api/config', async (ctx) => {
// PUT /api/config/credentials — save platform credentials to .env
// Body: { platform: string, values: Record<string, any> }
// values keys match PlatformConfig paths: 'token', 'extra.app_id', 'extra.app_secret', etc.
configRoutes.put('/api/config/credentials', async (ctx) => {
configRoutes.put('/api/hermes/config/credentials', async (ctx) => {
const { platform, values } = ctx.request.body as {
platform: string
values: Record<string, any>
@@ -57,7 +57,7 @@ async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<str
}
// --- Hardcoded model catalogs (single source: src/shared/providers.ts) ---
import { buildProviderModelMap } from '../shared/providers'
import { buildProviderModelMap } from '../../shared/providers'
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
export const fsRoutes = new Router()
@@ -144,7 +144,7 @@ async function writeConfigYaml(config: Record<string, any>): Promise<void> {
// --- Skills Routes ---
// List all skills grouped by category
fsRoutes.get('/api/skills', async (ctx) => {
fsRoutes.get('/api/hermes/skills', async (ctx) => {
const skillsDir = join(hermesDir, 'skills')
try {
@@ -195,7 +195,7 @@ fsRoutes.get('/api/skills', async (ctx) => {
})
// Toggle skill enabled/disabled via config.yaml skills.disabled
fsRoutes.put('/api/skills/toggle', async (ctx) => {
fsRoutes.put('/api/hermes/skills/toggle', async (ctx) => {
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
if (!name || typeof enabled !== 'boolean') {
@@ -248,7 +248,7 @@ async function listFilesRecursive(dir: string, prefix: string): Promise<{ path:
return result
}
fsRoutes.get('/api/skills/:category/:skill/files', async (ctx) => {
fsRoutes.get('/api/hermes/skills/:category/:skill/files', async (ctx) => {
const { category, skill } = ctx.params
const skillDir = join(hermesDir, 'skills', category, skill)
@@ -262,9 +262,9 @@ fsRoutes.get('/api/skills/:category/:skill/files', async (ctx) => {
}
})
// Read a specific file under skills/
fsRoutes.get('/api/skills/:path(.+)', async (ctx) => {
const filePath = ctx.params.path
// Read a specific file under skills/ (must be registered after the /files route)
fsRoutes.get('/api/hermes/skills/{*path}', async (ctx) => {
const filePath = (ctx.params as any).path
const fullPath = resolve(join(hermesDir, 'skills', filePath))
if (!fullPath.startsWith(join(hermesDir, 'skills'))) {
@@ -285,7 +285,7 @@ fsRoutes.get('/api/skills/:path(.+)', async (ctx) => {
// --- Memory Routes ---
fsRoutes.get('/api/memory', async (ctx) => {
fsRoutes.get('/api/hermes/memory', async (ctx) => {
const memoryPath = join(hermesDir, 'memories', 'MEMORY.md')
const userPath = join(hermesDir, 'memories', 'USER.md')
@@ -304,7 +304,7 @@ fsRoutes.get('/api/memory', async (ctx) => {
}
})
fsRoutes.post('/api/memory', async (ctx) => {
fsRoutes.post('/api/hermes/memory', async (ctx) => {
const { section, content } = ctx.request.body as { section: string; content: string }
if (!section || !content) {
@@ -387,7 +387,7 @@ function buildModelGroups(config: Record<string, any>): { default: string; group
}
// GET /api/available-models — fetch models from all credential pool endpoints
fsRoutes.get('/api/available-models', async (ctx) => {
fsRoutes.get('/api/hermes/available-models', async (ctx) => {
try {
const auth = await loadAuthJson()
const pool = auth?.credential_pool || {}
@@ -466,7 +466,7 @@ fsRoutes.get('/api/available-models', async (ctx) => {
})
// GET /api/config/models
fsRoutes.get('/api/config/models', async (ctx) => {
fsRoutes.get('/api/hermes/config/models', async (ctx) => {
try {
const config = await readConfigYaml()
ctx.body = buildModelGroups(config)
@@ -477,7 +477,7 @@ fsRoutes.get('/api/config/models', async (ctx) => {
})
// PUT /api/config/model
fsRoutes.put('/api/config/model', async (ctx) => {
fsRoutes.put('/api/hermes/config/model', async (ctx) => {
const { default: defaultModel, provider: reqProvider } = ctx.request.body as {
default: string
provider?: string
@@ -510,7 +510,7 @@ fsRoutes.put('/api/config/model', async (ctx) => {
})
// POST /api/config/providers
fsRoutes.post('/api/config/providers', async (ctx) => {
fsRoutes.post('/api/hermes/config/providers', async (ctx) => {
const { name, base_url, api_key, model, providerKey } = ctx.request.body as {
name: string
base_url: string
@@ -579,7 +579,7 @@ fsRoutes.post('/api/config/providers', async (ctx) => {
})
// DELETE /api/config/providers/:poolKey
fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => {
const poolKey = decodeURIComponent(ctx.params.poolKey)
try {
@@ -0,0 +1,21 @@
import Router from '@koa/router'
import { sessionRoutes } from './sessions'
import { profileRoutes } from './profiles'
import { configRoutes } from './config'
import { fsRoutes } from './filesystem'
import { logRoutes } from './logs'
import { weixinRoutes } from './weixin'
import { proxyRoutes, proxyMiddleware } from './proxy'
import { setupTerminalWebSocket } from './terminal'
export const hermesRoutes = new Router()
hermesRoutes.use(sessionRoutes.routes())
hermesRoutes.use(profileRoutes.routes())
hermesRoutes.use(configRoutes.routes())
hermesRoutes.use(fsRoutes.routes())
hermesRoutes.use(logRoutes.routes())
hermesRoutes.use(weixinRoutes.routes())
hermesRoutes.use(proxyRoutes.routes())
export { setupTerminalWebSocket, proxyMiddleware }
@@ -1,10 +1,10 @@
import Router from '@koa/router'
import * as hermesCli from '../services/hermes-cli'
import * as hermesCli from '../../services/hermes-cli'
export const logRoutes = new Router()
// List available log files
logRoutes.get('/api/logs', async (ctx) => {
logRoutes.get('/api/hermes/logs', async (ctx) => {
const files = await hermesCli.listLogFiles()
ctx.body = { files }
})
@@ -35,7 +35,7 @@ function parseLine(line: string): LogEntry | null {
}
// Read log lines (parsed)
logRoutes.get('/api/logs/:name', async (ctx) => {
logRoutes.get('/api/hermes/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
@@ -0,0 +1,190 @@
import Router from '@koa/router'
import * as hermesCli from '../../services/hermes-cli'
export const profileRoutes = new Router()
// GET /api/profiles - List all profiles
profileRoutes.get('/api/hermes/profiles', async (ctx) => {
try {
const profiles = await hermesCli.listProfiles()
ctx.body = { profiles }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/profiles - Create a new profile
profileRoutes.post('/api/hermes/profiles', async (ctx) => {
const { name, clone } = ctx.request.body as { name?: string; clone?: boolean }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
try {
const output = await hermesCli.createProfile(name, clone)
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// GET /api/profiles/:name - Get profile details
profileRoutes.get('/api/hermes/profiles/:name', async (ctx) => {
const { name } = ctx.params
try {
const profile = await hermesCli.getProfile(name)
ctx.body = { profile }
} catch (err: any) {
ctx.status = err.message.includes('not found') ? 404 : 500
ctx.body = { error: err.message }
}
})
// DELETE /api/profiles/:name - Delete a profile
profileRoutes.delete('/api/hermes/profiles/:name', async (ctx) => {
const { name } = ctx.params
if (name === 'default') {
ctx.status = 400
ctx.body = { error: 'Cannot delete the default profile' }
return
}
try {
const ok = await hermesCli.deleteProfile(name)
if (ok) {
ctx.body = { success: true }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to delete profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/profiles/:name/rename - Rename a profile
profileRoutes.post('/api/hermes/profiles/:name/rename', async (ctx) => {
const { name } = ctx.params
const { new_name } = ctx.request.body as { new_name?: string }
if (!new_name) {
ctx.status = 400
ctx.body = { error: 'Missing new_name' }
return
}
try {
const ok = await hermesCli.renameProfile(name, new_name)
if (ok) {
ctx.body = { success: true }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to rename profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// PUT /api/profiles/active - Switch active profile
profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
const { name } = ctx.request.body as { name?: string }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
try {
// 1. Stop gateway (try launchd/systemd first, ignore if unavailable e.g. WSL)
try { await hermesCli.stopGateway() } catch { }
// 2. Kill gateway by port if still running (for WSL / background mode)
try {
const { execSync } = await import('child_process')
const isWin = process.platform === 'win32'
let pids = ''
if (isWin) {
const out = execSync('netstat -aon | findstr :8642', { encoding: 'utf-8', timeout: 5000 }).trim()
const lines = out.split('\n').filter(l => l.includes('LISTENING'))
pids = Array.from(new Set(lines.map(l => l.trim().split(/\s+/).pop()).filter(Boolean))).join(' ')
} else {
pids = execSync('lsof -ti:8642', { encoding: 'utf-8', timeout: 5000 }).trim()
}
if (pids) {
if (isWin) {
execSync(`taskkill /F /PID ${pids.split(' ').join(' /PID ')}`, { timeout: 5000 })
} else {
execSync(`kill -9 ${pids}`, { timeout: 5000 })
}
await new Promise(r => setTimeout(r, 2000))
}
} catch { }
// 3. Switch profile
const output = await hermesCli.useProfile(name)
await new Promise(r => setTimeout(r, 1000))
// 4. Start gateway — try launchd/systemd first, fall back to background mode
try {
await hermesCli.restartGateway()
} catch {
// Fallback for WSL / environments without launchd/systemd
try {
const pid = await hermesCli.startGatewayBackground()
await new Promise(r => setTimeout(r, 3000))
console.log(`[Profile] Gateway started in background mode (PID: ${pid})`)
} catch (err: any) {
console.error('[Profile] Gateway start failed:', err.message)
}
}
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/profiles/:name/export - Export profile to archive
profileRoutes.post('/api/hermes/profiles/:name/export', async (ctx) => {
const { name } = ctx.params
const { output } = ctx.request.body as { output?: string }
try {
const result = await hermesCli.exportProfile(name, output)
ctx.body = { success: true, message: result.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/profiles/import - Import profile from archive
profileRoutes.post('/api/hermes/profiles/import', async (ctx) => {
const { archive, name } = ctx.request.body as { archive?: string; name?: string }
if (!archive) {
ctx.status = 400
ctx.body = { error: 'Missing archive path' }
return
}
try {
const result = await hermesCli.importProfile(archive, name)
ctx.body = { success: true, message: result.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
@@ -1,9 +1,13 @@
import type { Context } from 'koa'
import { config } from '../config'
import { config } from '../../config'
export async function proxy(ctx: Context) {
const upstream = config.upstream.replace(/\/$/, '')
const url = `${upstream}${ctx.path}${ctx.search || ''}`
// Rewrite path for upstream gateway:
// /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix)
// /api/hermes/* -> /api/* (upstream uses /api/ prefix)
const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api')
const url = `${upstream}${upstreamPath}${ctx.search || ''}`
console.log(`[PROXY] ${ctx.method} ${ctx.path} -> ${url}`)
// Build headers — forward most, strip browser-specific ones
@@ -0,0 +1,17 @@
import Router from '@koa/router'
import type { Context, Next } from 'koa'
import { proxy } from './proxy-handler'
export const proxyRoutes = new Router()
// Proxy unmatched /api/hermes/* and /v1/* to upstream Hermes API
proxyRoutes.all('/api/hermes/{*any}', proxy)
proxyRoutes.all('/v1/{*any}', proxy)
// Also register as middleware so it works reliably with nested .use()
export async function proxyMiddleware(ctx: Context, next: Next) {
if (ctx.path.startsWith('/api/hermes/') || ctx.path.startsWith('/v1/')) {
return proxy(ctx)
}
await next()
}
@@ -1,10 +1,10 @@
import Router from '@koa/router'
import * as hermesCli from '../services/hermes-cli'
import * as hermesCli from '../../services/hermes-cli'
export const sessionRoutes = new Router()
// List sessions from Hermes
sessionRoutes.get('/api/sessions', async (ctx) => {
sessionRoutes.get('/api/hermes/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)
@@ -12,7 +12,7 @@ sessionRoutes.get('/api/sessions', async (ctx) => {
})
// Get single session with messages
sessionRoutes.get('/api/sessions/:id', async (ctx) => {
sessionRoutes.get('/api/hermes/sessions/:id', async (ctx) => {
const session = await hermesCli.getSession(ctx.params.id)
if (!session) {
ctx.status = 404
@@ -23,7 +23,7 @@ sessionRoutes.get('/api/sessions/:id', async (ctx) => {
})
// Delete session from Hermes
sessionRoutes.delete('/api/sessions/:id', async (ctx) => {
sessionRoutes.delete('/api/hermes/sessions/:id', async (ctx) => {
const ok = await hermesCli.deleteSession(ctx.params.id)
if (!ok) {
ctx.status = 500
@@ -34,7 +34,7 @@ sessionRoutes.delete('/api/sessions/:id', async (ctx) => {
})
// Rename session
sessionRoutes.post('/api/sessions/:id/rename', async (ctx) => {
sessionRoutes.post('/api/hermes/sessions/:id/rename', async (ctx) => {
const { title } = ctx.request.body as { title?: string }
if (!title || typeof title !== 'string') {
ctx.status = 400
@@ -2,7 +2,7 @@ import { WebSocketServer } from 'ws'
import type { Server as HttpServer } from 'http'
import { existsSync } from 'fs'
import * as pty from 'node-pty'
import { getToken } from '../services/auth'
import { getToken } from '../../services/auth'
// ─── Shell detection ────────────────────────────────────────────
@@ -80,7 +80,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
httpServer.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/terminal') {
if (url.pathname !== '/api/hermes/terminal') {
socket.destroy()
return
}
@@ -237,7 +237,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
if (!session) return
const cols = Math.max(1, parsed.cols || 0)
const rows = Math.max(1, parsed.rows || 0)
try { session.pty.resize(cols, rows) } catch {}
try { session.pty.resize(cols, rows) } catch { }
break
}
}
@@ -247,7 +247,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
ws.on('close', () => {
for (const session of Array.from(conn.sessions.values())) {
try { session.pty.kill() } catch {}
try { session.pty.kill() } catch { }
}
conn.sessions.clear()
console.log(`[Terminal] Connection closed, all sessions killed`)
@@ -255,7 +255,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
ws.on('error', () => {
for (const session of Array.from(conn.sessions.values())) {
try { session.pty.kill() } catch {}
try { session.pty.kill() } catch { }
}
conn.sessions.clear()
})
@@ -4,7 +4,7 @@ import { readFile, writeFile } from 'fs/promises'
import { chmod } from 'fs/promises'
import { resolve } from 'path'
import { homedir } from 'os'
import { restartGateway } from '../services/hermes-cli'
import { restartGateway } from '../../services/hermes-cli'
const envPath = resolve(homedir(), '.hermes/.env')
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
@@ -12,7 +12,7 @@ const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
export const weixinRoutes = new Router()
// GET /api/weixin/qrcode — fetch QR code from Tencent iLink API
weixinRoutes.get('/api/weixin/qrcode', async (ctx) => {
weixinRoutes.get('/api/hermes/weixin/qrcode', async (ctx) => {
try {
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, {
params: { bot_type: 3 },
@@ -35,7 +35,7 @@ weixinRoutes.get('/api/weixin/qrcode', async (ctx) => {
})
// GET /api/weixin/qrcode/status — poll QR scan status
weixinRoutes.get('/api/weixin/qrcode/status', async (ctx) => {
weixinRoutes.get('/api/hermes/weixin/qrcode/status', async (ctx) => {
const qrcode = ctx.query.qrcode as string
if (!qrcode) {
ctx.status = 400
@@ -68,7 +68,7 @@ weixinRoutes.get('/api/weixin/qrcode/status', async (ctx) => {
})
// POST /api/weixin/save — save weixin credentials to .env
weixinRoutes.post('/api/weixin/save', async (ctx) => {
weixinRoutes.post('/api/hermes/weixin/save', async (ctx) => {
const { account_id, token, base_url } = ctx.request.body as {
account_id: string
token: string
@@ -256,6 +256,17 @@ export async function restartGateway(): Promise<string> {
return stdout || stderr
}
/**
* Stop Hermes gateway
*/
export async function stopGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'stop'], {
timeout: 30000,
...execOpts,
})
return stdout || stderr
}
/**
* List available log files
*/
@@ -311,3 +322,206 @@ export async function readLogs(
throw new Error(`Failed to read logs: ${err.message}`)
}
}
// ─── Profile management ──────────────────────────────────────
export interface HermesProfile {
name: string
active: boolean
model: string
gateway: string
alias: string
}
export interface HermesProfileDetail {
name: string
path: string
model: string
provider: string
gateway: string
skills: number
hasEnv: boolean
hasSoulMd: boolean
}
/**
* List all profiles
*/
export async function listProfiles(): Promise<HermesProfile[]> {
try {
const { stdout } = await execFileAsync('hermes', ['profile', 'list'], {
timeout: 10000,
...execOpts,
})
const lines = stdout.trim().split('\n').filter(Boolean)
const profiles: HermesProfile[] = []
// Skip header lines (starts with " Profile" or " ─")
for (const line of lines) {
if (line.startsWith(' Profile') || line.match(/^ ─/)) continue
const match = line.match(/^\s+(◆)?(\S+)\s{2,}(\S+)\s{2,}(\S+)\s{2,}(.*)$/)
if (match) {
profiles.push({
name: match[2],
active: !!match[1],
model: match[3],
gateway: match[4],
alias: match[5].trim() === '—' ? '' : match[5].trim(),
})
}
}
return profiles
} catch (err: any) {
console.error('[Hermes CLI] profile list failed:', err.message)
throw new Error(`Failed to list profiles: ${err.message}`)
}
}
/**
* Get profile details
*/
export async function getProfile(name: string): Promise<HermesProfileDetail> {
try {
const { stdout } = await execFileAsync('hermes', ['profile', 'show', name], {
timeout: 10000,
...execOpts,
})
const result: Record<string, string> = {}
for (const line of stdout.trim().split('\n')) {
const match = line.match(/^(\w[\w\s]*?):\s+(.+)$/)
if (match) {
result[match[1].trim().toLowerCase().replace(/\s+/g, '_')] = match[2].trim()
}
}
const modelFull = result.model || ''
const providerMatch = modelFull.match(/\((.+)\)/)
const model = providerMatch ? modelFull.replace(/\s*\(.+\)/, '').trim() : modelFull
return {
name: result.profile || name,
path: result.path || '',
model,
provider: providerMatch ? providerMatch[1] : '',
gateway: result.gateway || '',
skills: parseInt(result.skills || '0', 10),
hasEnv: result['.env'] === 'exists',
hasSoulMd: result.soul_md === 'exists',
}
} catch (err: any) {
if (err.code === 1 || err.status === 1) {
throw new Error(`Profile "${name}" not found`)
}
console.error('[Hermes CLI] profile show failed:', err.message)
throw new Error(`Failed to get profile: ${err.message}`)
}
}
/**
* Create a new profile
*/
export async function createProfile(name: string, clone?: boolean): Promise<string> {
const args = ['profile', 'create', name]
if (clone) args.push('--clone')
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
timeout: 15000,
...execOpts,
})
return stdout || stderr
} catch (err: any) {
console.error('[Hermes CLI] profile create failed:', err.message)
throw new Error(`Failed to create profile: ${err.message}`)
}
}
/**
* Delete a profile
*/
export async function deleteProfile(name: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['profile', 'delete', name, '--yes'], {
timeout: 10000,
...execOpts,
})
return true
} catch (err: any) {
console.error('[Hermes CLI] profile delete failed:', err.message)
return false
}
}
/**
* Rename a profile
*/
export async function renameProfile(oldName: string, newName: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['profile', 'rename', oldName, newName], {
timeout: 10000,
...execOpts,
})
return true
} catch (err: any) {
console.error('[Hermes CLI] profile rename failed:', err.message)
return false
}
}
/**
* Switch active profile
*/
export async function useProfile(name: string): Promise<string> {
try {
const { stdout, stderr } = await execFileAsync('hermes', ['profile', 'use', name], {
timeout: 10000,
...execOpts,
})
return stdout || stderr
} catch (err: any) {
console.error('[Hermes CLI] profile use failed:', err.message)
throw new Error(`Failed to switch profile: ${err.message}`)
}
}
/**
* Export profile to archive
*/
export async function exportProfile(name: string, outputPath?: string): Promise<string> {
const args = ['profile', 'export', name]
if (outputPath) args.push('--output', outputPath)
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
timeout: 60000,
...execOpts,
})
return stdout || stderr
} catch (err: any) {
console.error('[Hermes CLI] profile export failed:', err.message)
throw new Error(`Failed to export profile: ${err.message}`)
}
}
/**
* Import profile from archive
*/
export async function importProfile(archivePath: string, name?: string): Promise<string> {
const args = ['profile', 'import', archivePath]
if (name) args.push('--name', name)
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
timeout: 60000,
...execOpts,
})
return stdout || stderr
} catch (err: any) {
console.error('[Hermes CLI] profile import failed:', err.message)
throw new Error(`Failed to import profile: ${err.message}`)
}
}

Some files were not shown because too many files have changed in this diff Show More