diff --git a/CLAUDE.md b/CLAUDE.md index 10f833a..7b84b4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -333,6 +333,8 @@ Unmatched `/api/hermes/*` and `/v1/*` requests are forwarded to the upstream Her 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. +**Important:** Custom API endpoints handled locally (not proxied) must be registered **before** `hermesRoutes.routes()` in `bootstrap()`. The proxy route `proxyRoutes.all('/api/hermes/{*any}')` matches all `/api/hermes/*` paths, so any middleware registered after it will never be reached. See the `update` middleware in `index.ts` for an example. + ### 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: diff --git a/README.md b/README.md index 4670c7d..7a78eed 100644 --- a/README.md +++ b/README.md @@ -132,13 +132,13 @@ Open **http://localhost:8648** Automatically installs Node.js (if missing) and hermes-web-ui on Debian/Ubuntu/macOS: ```bash -bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh) +bash <(curl -fsSL https://raw.githubusercontent.com/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) +bash <(curl -fsSL https://raw.githubusercontent.com/EKKOLearnAI/hermes-web-ui/main/scripts/setup.sh) hermes-web-ui start ``` diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index 52cc1b2..2a9ba49 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -135,23 +135,60 @@ function startDaemon(port) { child.unref() writePid(child.pid) - setTimeout(() => { - if (isRunning(child.pid)) { - console.log(` ✓ hermes-web-ui started (PID: ${child.pid}, port: ${port})`) - const url = token - ? `http://localhost:${port}/#/?token=${token}` - : `http://localhost:${port}` - console.log(` ${url}`) - console.log(` Log: ${LOG_FILE}`) - const isWin = process.platform === 'win32' - const cmd = isWin ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}` - try { execSync(cmd, { stdio: 'ignore' }) } catch {} - } else { + // Poll health endpoint until server is ready (setTimeout to avoid overlapping requests) + const healthUrl = `http://127.0.0.1:${port}/health` + const maxWait = 30000 + const interval = 500 + let waited = 0 + + console.log(` ⏳ Starting hermes-web-ui (PID: ${child.pid}, port: ${port})...`) + + function poll() { + waited += interval + if (!isRunning(child.pid)) { console.log(' ✗ Failed to start hermes-web-ui') + console.log(` Check log: ${LOG_FILE}`) removePid() process.exit(1) + return } - }, 500) + + fetch(healthUrl).then(res => { + if (res.ok) { + const url = token + ? `http://localhost:${port}/#/?token=${token}` + : `http://localhost:${port}` + console.log(` ✓ hermes-web-ui started`) + console.log(` ${url}`) + console.log(` Log: ${LOG_FILE}`) + const isWin = process.platform === 'win32' + const cmd = isWin ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}` + try { execSync(cmd, { stdio: 'ignore' }) } catch {} + } else if (waited < maxWait) { + setTimeout(poll, interval) + } else { + console.log(` ⚠ Server process is running but health check failed after ${maxWait / 1000}s`) + console.log(` Check log: ${LOG_FILE}`) + const url = token + ? `http://localhost:${port}/#/?token=${token}` + : `http://localhost:${port}` + console.log(` ${url}`) + } + }).catch(() => { + if (waited < maxWait) { + setTimeout(poll, interval) + } else { + console.log(` ⚠ Server process is running but health check failed after ${maxWait / 1000}s`) + console.log(` Check log: ${LOG_FILE}`) + const url = token + ? `http://localhost:${port}/#/?token=${token}` + : `http://localhost:${port}` + console.log(` ${url}`) + } + }) + } + + setTimeout(poll, interval) } function stopDaemon() { diff --git a/package.json b/package.json index 754f38a..2444eba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.2.7", + "version": "0.2.9", "description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)", "repository": { "type": "git", diff --git a/packages/client/src/api/hermes/profiles.ts b/packages/client/src/api/hermes/profiles.ts new file mode 100644 index 0000000..773e674 --- /dev/null +++ b/packages/client/src/api/hermes/profiles.ts @@ -0,0 +1,122 @@ +import { request, getBaseUrlValue, getApiKey } from '../client' + +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 +} + +export async function fetchProfiles(): Promise { + const res = await request<{ profiles: HermesProfile[] }>('/api/hermes/profiles') + return res.profiles +} + +export async function fetchProfileDetail(name: string): Promise { + const res = await request<{ profile: HermesProfileDetail }>(`/api/hermes/profiles/${encodeURIComponent(name)}`) + return res.profile +} + +export async function createProfile(name: string, clone?: boolean): Promise { + try { + await request('/api/hermes/profiles', { + method: 'POST', + body: JSON.stringify({ name, clone }), + }) + return true + } catch { + return false + } +} + +export async function deleteProfile(name: string): Promise { + try { + await request(`/api/hermes/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' }) + return true + } catch { + return false + } +} + +export async function renameProfile(name: string, newName: string): Promise { + try { + await request(`/api/hermes/profiles/${encodeURIComponent(name)}/rename`, { + method: 'POST', + body: JSON.stringify({ new_name: newName }), + }) + return true + } catch { + return false + } +} + +export async function switchProfile(name: string): Promise { + try { + await request('/api/hermes/profiles/active', { + method: 'PUT', + body: JSON.stringify({ name }), + }) + return true + } catch { + return false + } +} + +export async function exportProfile(name: string): Promise { + try { + const baseUrl = getBaseUrlValue() + const token = getApiKey() + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + + const res = await fetch(`${baseUrl}/api/hermes/profiles/${encodeURIComponent(name)}/export`, { + method: 'POST', + headers, + }) + if (!res.ok) throw new Error() + + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `hermes-profile-${name}.tar.gz` + a.click() + URL.revokeObjectURL(url) + return true + } catch { + return false + } +} + +export async function importProfile(file: File): Promise { + try { + const baseUrl = getBaseUrlValue() + const token = getApiKey() + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + + const formData = new FormData() + formData.append('file', file) + + const res = await fetch(`${baseUrl}/api/hermes/profiles/import`, { + method: 'POST', + headers, + body: formData, + }) + return res.ok + } catch { + return false + } +} diff --git a/packages/client/src/api/hermes/skills.ts b/packages/client/src/api/hermes/skills.ts index 490874d..b31fd10 100644 --- a/packages/client/src/api/hermes/skills.ts +++ b/packages/client/src/api/hermes/skills.ts @@ -25,8 +25,10 @@ export interface SkillFileEntry { export interface MemoryData { memory: string user: string + soul: string memory_mtime: number | null user_mtime: number | null + soul_mtime: number | null } export async function fetchSkills(): Promise { @@ -48,7 +50,7 @@ export async function fetchMemory(): Promise { return request('/api/hermes/memory') } -export async function saveMemory(section: 'memory' | 'user', content: string): Promise { +export async function saveMemory(section: 'memory' | 'user' | 'soul', content: string): Promise { await request('/api/hermes/memory', { method: 'POST', body: JSON.stringify({ section, content }), diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts index 3aaa9f5..1eeddab 100644 --- a/packages/client/src/api/hermes/system.ts +++ b/packages/client/src/api/hermes/system.ts @@ -3,6 +3,9 @@ import { request } from '../client' export interface HealthResponse { status: string version?: string + webui_version?: string + webui_latest?: string + webui_update_available?: boolean } // Config-based model types @@ -45,6 +48,10 @@ export async function checkHealth(): Promise { return request('/health') } +export async function triggerUpdate(): Promise<{ success: boolean; message: string }> { + return request<{ success: boolean; message: string }>('/api/hermes/update', { method: 'POST' }) +} + export async function fetchConfigModels(): Promise { return request('/api/hermes/config/models') } diff --git a/packages/client/src/components/hermes/profiles/ProfileCard.vue b/packages/client/src/components/hermes/profiles/ProfileCard.vue new file mode 100644 index 0000000..69db9f4 --- /dev/null +++ b/packages/client/src/components/hermes/profiles/ProfileCard.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/packages/client/src/components/hermes/profiles/ProfileCreateModal.vue b/packages/client/src/components/hermes/profiles/ProfileCreateModal.vue new file mode 100644 index 0000000..39bcd21 --- /dev/null +++ b/packages/client/src/components/hermes/profiles/ProfileCreateModal.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/client/src/components/hermes/profiles/ProfileImportModal.vue b/packages/client/src/components/hermes/profiles/ProfileImportModal.vue new file mode 100644 index 0000000..e9056a9 --- /dev/null +++ b/packages/client/src/components/hermes/profiles/ProfileImportModal.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/client/src/components/hermes/profiles/ProfileRenameModal.vue b/packages/client/src/components/hermes/profiles/ProfileRenameModal.vue new file mode 100644 index 0000000..aa09dbb --- /dev/null +++ b/packages/client/src/components/hermes/profiles/ProfileRenameModal.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/packages/client/src/components/hermes/profiles/ProfilesPanel.vue b/packages/client/src/components/hermes/profiles/ProfilesPanel.vue new file mode 100644 index 0000000..2a1e3e9 --- /dev/null +++ b/packages/client/src/components/hermes/profiles/ProfilesPanel.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/packages/client/src/components/hermes/settings/PlatformSettings.vue b/packages/client/src/components/hermes/settings/PlatformSettings.vue index 8273a6b..7bc4996 100644 --- a/packages/client/src/components/hermes/settings/PlatformSettings.vue +++ b/packages/client/src/components/hermes/settings/PlatformSettings.vue @@ -1,5 +1,5 @@