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>
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 2(BFF 服务器)+ node-pty(Web 终端)
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
Before Width: | Height: | Size: 131 KiB |
@@ -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 |
@@ -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') {
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||