From 477af66232581bb51541693bdf798028fb6b3f95 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:35:48 +0800 Subject: [PATCH] fix: auth bypass, SPA serving, and provider improvements (#97) * feat(chat): polish syntax highlighting and tool payload rendering (#94) * [verified] feat(chat): polish syntax highlighting and tool payload rendering * [verified] fix(chat): tighten large tool payload rendering * docs: update data volume path in Docker docs Align documentation with docker-compose.yml change: hermes-web-ui-data -> hermes-web-ui, /app/dist/data -> /root/.hermes-web-ui Co-Authored-By: Claude Opus 4.6 * refactor: bundle server build and restructure service modules - Add build-server.mjs script for standalone server compilation - Add logger service with structured output - Restructure auth, gateway-manager, hermes-cli, hermes services - Update docker-compose volume mount path - Update tsconfig and entry point for bundled server Co-Authored-By: Claude Opus 4.6 * refactor: separate controllers from routes and centralize route registration - Extract business logic from route handlers into controllers/ - Add centralized route registry in routes/index.ts with public/auth/protected layers - Replace global auth whitelist with sequential middleware registration - Extract shared helpers to services/config-helpers.ts - Allow custom provider name to be user-editable in ProviderFormModal - Deduplicate custom providers by poolKey instead of base_url in getAvailable Co-Authored-By: Claude Opus 4.6 * fix: auth bypass via path case, SPA serving, and provider improvements - Fix auth bypass: path case-insensitive check for /api, /v1, /upload - Fix SPA returning 401: skip auth for non-API paths (static files) - Fix profile switch: use local loading state instead of shared store ref - Auto-append /v1 to base_url when fetching models (frontend + backend) - Guard .env writing to built-in providers only - Add builtin field to provider presets, enable base_url input in form - Print auth token to console on startup (pino only writes to file) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- README.md | 2 +- README_zh.md | 2 +- bin/hermes-web-ui.mjs | 2 +- docker-compose.yml | 2 +- docs/docker.md | 8 +- package.json | 50 +- .../hermes/chat/MarkdownRenderer.vue | 101 +-- .../components/hermes/chat/MessageItem.vue | 149 +++- .../src/components/hermes/chat/highlight.ts | 106 +++ .../hermes/models/ProviderFormModal.vue | 6 +- .../hermes/profiles/ProfileCard.vue | 13 +- packages/client/src/styles/code-block.scss | 170 ++++ packages/client/src/styles/global.scss | 1 + packages/server/src/controllers/health.ts | 54 ++ .../src/controllers/hermes/codex-auth.ts | 176 ++++ .../server/src/controllers/hermes/config.ts | 191 ++++ .../server/src/controllers/hermes/gateways.ts | 33 + .../server/src/controllers/hermes/logs.ts | 76 ++ .../server/src/controllers/hermes/memory.ts | 46 + .../server/src/controllers/hermes/models.ts | 135 +++ .../server/src/controllers/hermes/profiles.ts | 197 +++++ .../src/controllers/hermes/providers.ts | 139 +++ .../server/src/controllers/hermes/sessions.ts | 55 ++ .../server/src/controllers/hermes/skills.ts | 94 ++ .../server/src/controllers/hermes/weixin.ts | 68 ++ packages/server/src/controllers/update.ts | 19 + packages/server/src/controllers/upload.ts | 62 ++ packages/server/src/controllers/webhook.ts | 14 + packages/server/src/index.ts | 66 +- packages/server/src/routes/health.ts | 71 +- .../server/src/routes/hermes/codex-auth.ts | 347 +------- packages/server/src/routes/hermes/config.ts | 330 +------ .../server/src/routes/hermes/filesystem.ts | 817 ------------------ packages/server/src/routes/hermes/gateways.ts | 72 +- packages/server/src/routes/hermes/index.ts | 25 - packages/server/src/routes/hermes/logs.ts | 100 +-- packages/server/src/routes/hermes/memory.ts | 7 + packages/server/src/routes/hermes/models.ts | 8 + packages/server/src/routes/hermes/profiles.ts | 262 +----- .../server/src/routes/hermes/providers.ts | 8 + .../server/src/routes/hermes/proxy-handler.ts | 4 +- packages/server/src/routes/hermes/sessions.ts | 62 +- packages/server/src/routes/hermes/skills.ts | 9 + packages/server/src/routes/hermes/terminal.ts | 25 +- packages/server/src/routes/hermes/weixin.ts | 137 +-- packages/server/src/routes/index.ts | 54 ++ packages/server/src/routes/update.ts | 31 +- packages/server/src/routes/upload.ts | 88 +- packages/server/src/routes/webhook.ts | 31 +- packages/server/src/services/auth.ts | 35 +- .../server/src/services/config-helpers.ts | 239 +++++ .../server/src/services/gateway-bootstrap.ts | 4 +- .../src/services/hermes/gateway-manager.ts | 43 +- .../server/src/services/hermes/hermes-cli.ts | 34 +- packages/server/src/services/hermes/hermes.ts | 3 +- packages/server/src/services/logger.ts | 32 + packages/server/src/services/shutdown.ts | 18 +- packages/server/src/shared/providers.ts | 27 + packages/server/tsconfig.json | 6 +- scripts/build-server.mjs | 25 + tests/client/highlight-helper.test.ts | 80 ++ tests/client/highlight-safety.test.ts | 39 + tests/client/markdown-rendering.test.ts | 93 ++ tests/client/message-item-highlight.test.ts | 160 ++++ vitest.config.ts | 1 + 65 files changed, 2743 insertions(+), 2621 deletions(-) create mode 100644 packages/client/src/components/hermes/chat/highlight.ts create mode 100644 packages/client/src/styles/code-block.scss create mode 100644 packages/server/src/controllers/health.ts create mode 100644 packages/server/src/controllers/hermes/codex-auth.ts create mode 100644 packages/server/src/controllers/hermes/config.ts create mode 100644 packages/server/src/controllers/hermes/gateways.ts create mode 100644 packages/server/src/controllers/hermes/logs.ts create mode 100644 packages/server/src/controllers/hermes/memory.ts create mode 100644 packages/server/src/controllers/hermes/models.ts create mode 100644 packages/server/src/controllers/hermes/profiles.ts create mode 100644 packages/server/src/controllers/hermes/providers.ts create mode 100644 packages/server/src/controllers/hermes/sessions.ts create mode 100644 packages/server/src/controllers/hermes/skills.ts create mode 100644 packages/server/src/controllers/hermes/weixin.ts create mode 100644 packages/server/src/controllers/update.ts create mode 100644 packages/server/src/controllers/upload.ts create mode 100644 packages/server/src/controllers/webhook.ts delete mode 100644 packages/server/src/routes/hermes/filesystem.ts delete mode 100644 packages/server/src/routes/hermes/index.ts create mode 100644 packages/server/src/routes/hermes/memory.ts create mode 100644 packages/server/src/routes/hermes/models.ts create mode 100644 packages/server/src/routes/hermes/providers.ts create mode 100644 packages/server/src/routes/hermes/skills.ts create mode 100644 packages/server/src/routes/index.ts create mode 100644 packages/server/src/services/config-helpers.ts create mode 100644 packages/server/src/services/logger.ts create mode 100644 scripts/build-server.mjs create mode 100644 tests/client/highlight-helper.test.ts create mode 100644 tests/client/highlight-safety.test.ts create mode 100644 tests/client/markdown-rendering.test.ts create mode 100644 tests/client/message-item-highlight.test.ts diff --git a/README.md b/README.md index c38891a..29ac1a2 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ docker compose logs -f hermes-webui Open **http://localhost:6060** - Persistent Hermes data is stored in `./hermes_data` -- Web UI auth token is stored in `./hermes_data/hermes-web-ui-data/.token` +- Web UI auth token is stored in `./hermes_data/hermes-web-ui/.token` - On first run with auth enabled, the token is printed to container logs - All runtime settings are environment-variable driven in `docker-compose.yml` diff --git a/README_zh.md b/README_zh.md index 4b4af86..cd180b9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -175,7 +175,7 @@ docker compose logs -f hermes-webui 打开 **http://localhost:6060** - Hermes 持久化数据目录:`./hermes_data` -- Web UI 认证 Token 存储在 `./hermes_data/hermes-web-ui-data/.token` +- Web UI 认证 Token 存储在 `./hermes_data/hermes-web-ui/.token` - 首次启动并开启认证时,Token 会打印到容器日志中 - 运行参数全部由 `docker-compose.yml` 环境变量驱动 diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index e27ceb8..b58e36d 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -14,7 +14,7 @@ const VERSION = pkg.version const PID_DIR = resolve(homedir(), '.hermes-web-ui') const PID_FILE = join(PID_DIR, 'server.pid') const LOG_FILE = join(PID_DIR, 'server.log') -const TOKEN_FILE = resolve(__dirname, '..', 'dist', 'server', 'data', '.token') +const TOKEN_FILE = join(PID_DIR, '.token') const DEFAULT_PORT = 8648 // ─── Auto-fix node-pty native module ────────────────────────── diff --git a/docker-compose.yml b/docker-compose.yml index 36d22e4..5eb5d92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: - "${PORT:-6060}:${PORT:-6060}" volumes: - ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes - - ${HERMES_DATA_DIR:-./hermes_data}/hermes-web-ui-data:/app/dist/data + - ${HERMES_DATA_DIR:-./hermes_data}/hermes-web-ui:/root/.hermes-web-ui - hermes-agent-src:/opt/hermes environment: - PORT=${PORT:-6060} diff --git a/docs/docker.md b/docs/docker.md index ab327ca..da95081 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -62,11 +62,11 @@ AUTH_DISABLED=false | Path | Description | |---|---| | `${HERMES_DATA_DIR}` (`./hermes_data`) | Hermes runtime data (sessions, config, profiles) | -| `${HERMES_DATA_DIR}/hermes-web-ui-data` | Web UI data (auth token) | +| `${HERMES_DATA_DIR}/hermes-web-ui` | Web UI data (auth token, etc.) | - Hermes data persists in `./hermes_data`, mapped to `/home/agent/.hermes` in the container. -- Web UI auth token persists in `./hermes_data/hermes-web-ui-data/.token`. -- When `AUTH_DISABLED=false`, the token is auto-generated on first run and printed to container logs. +- Web UI data persists in `./hermes_data/hermes-web-ui/`, mapped to `/root/.hermes-web-ui` in the container. +- When `AUTH_DISABLED=false`, the auth token is auto-generated on first run and printed to container logs. - Deleting the token file and restarting will generate a new one. ## Port Mapping @@ -96,7 +96,7 @@ View auth token: ```bash docker compose logs hermes-webui | grep token # or -cat ./hermes_data/hermes-web-ui-data/.token +cat ./hermes_data/hermes-web-ui/.token ``` Stop: diff --git a/package.json b/package.json index 10c0ec8..46c858b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.4.0", + "version": "0.4.1", "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", @@ -9,7 +9,7 @@ "homepage": "https://github.com/EKKOLearnAI/hermes-web-ui", "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=23.0.0" }, "keywords": [ "hermes", @@ -37,7 +37,7 @@ "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "dev:client": "vite --host", "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", + "build": "vue-tsc -b && vite build && tsc --noEmit -p packages/server/tsconfig.json && node scripts/build-server.mjs", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", @@ -48,29 +48,12 @@ "dist/" ], "dependencies": { + "node-pty": "^1.1.0" + }, + "devDependencies": { "@koa/bodyparser": "^5.0.0", "@koa/cors": "^5.0.0", "@koa/router": "^15.4.0", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/xterm": "^6.0.0", - "axios": "^1.9.0", - "highlight.js": "^11.11.1", - "js-yaml": "^4.1.1", - "koa": "^2.15.3", - "koa-send": "^5.0.1", - "koa-static": "^5.0.0", - "markdown-it": "^14.1.1", - "naive-ui": "^2.44.1", - "node-pty": "^1.1.0", - "pinia": "^3.0.4", - "qrcode": "^1.5.4", - "vue": "^3.5.32", - "vue-i18n": "^11.3.2", - "vue-router": "^4.6.4", - "ws": "^8.20.0" - }, - "devDependencies": { "@pinia/testing": "^1.0.3", "@types/js-yaml": "^4.0.9", "@types/koa": "^2.15.0", @@ -85,14 +68,33 @@ "@vitejs/plugin-vue": "^6.0.5", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.9.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "axios": "^1.9.0", "concurrently": "^9.2.1", + "highlight.js": "^11.11.1", + "js-yaml": "^4.1.1", "jsdom": "^27.0.1", + "koa": "^2.15.3", + "koa-send": "^5.0.1", + "koa-static": "^5.0.0", + "markdown-it": "^14.1.1", + "naive-ui": "^2.44.1", "nodemon": "^3.1.14", + "pinia": "^3.0.4", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "qrcode": "^1.5.4", "sass": "^1.99.0", "ts-node": "^10.9.2", "typescript": "~6.0.2", "vite": "^8.0.4", "vitest": "^3.2.4", - "vue-tsc": "^3.2.6" + "vue": "^3.5.32", + "vue-i18n": "^11.3.2", + "vue-router": "^4.6.4", + "vue-tsc": "^3.2.6", + "ws": "^8.20.0" } } \ No newline at end of file diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index c28fce7..bb2b5cd 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import MarkdownIt from 'markdown-it' -import hljs from 'highlight.js' +import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight' const props = defineProps<{ content: string }>() const { t } = useI18n() @@ -12,22 +12,19 @@ const md: MarkdownIt = new MarkdownIt({ linkify: true, typographer: true, highlight(str: string, lang: string): string { - if (lang && hljs.getLanguage(lang)) { - try { - return `
${lang}
${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}
` - } catch { - // fall through - } - } - return `
${md.utils.escapeHtml(str)}
` + return renderHighlightedCodeBlock(str, lang, t('common.copy')) }, }) const renderedHtml = computed(() => md.render(props.content)) + +function handleMarkdownClick(event: MouseEvent): void { + void handleCodeBlockCopyClick(event) +} diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue index 6f50254..ffd6a19 100644 --- a/packages/client/src/components/hermes/chat/MessageItem.vue +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -3,6 +3,13 @@ import type { Message } from "@/stores/hermes/chat"; import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; import MarkdownRenderer from "./MarkdownRenderer.vue"; +import { + copyTextToClipboard, + handleCodeBlockCopyClick, + renderHighlightedCodeBlock, +} from "./highlight"; + +const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000; const props = defineProps<{ message: Message }>(); const { t } = useI18n(); @@ -25,6 +32,66 @@ function formatSize(bytes: number): string { return (bytes / (1024 * 1024)).toFixed(1) + " MB"; } +type ToolPayload = { + full: string; + display: string; + language?: string; +}; + +function formatToolPayload(raw?: string): ToolPayload { + if (!raw) { + return { full: "", display: "" }; + } + + try { + const full = JSON.stringify(JSON.parse(raw), null, 2); + return { + full, + display: + full.length > TOOL_PAYLOAD_DISPLAY_LIMIT + ? full.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + "\n" + t("chat.truncated") + : full, + language: "json", + }; + } catch { + return { + full: raw, + display: + raw.length > TOOL_PAYLOAD_DISPLAY_LIMIT + ? raw.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + "\n" + t("chat.truncated") + : raw, + }; + } +} + +function renderToolPayload(content: string, language?: string): string { + return renderHighlightedCodeBlock(content, language, t("common.copy"), { + maxHighlightLength: TOOL_PAYLOAD_DISPLAY_LIMIT, + }); +} + +async function handleToolDetailClick(event: MouseEvent): Promise { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + const button = target.closest("[data-copy-code=\"true\"]"); + if (!button) return; + + event.preventDefault(); + + const source = button.closest("[data-copy-source]")?.dataset.copySource; + if (source === "tool-args" && fullToolArgs.value) { + await copyTextToClipboard(fullToolArgs.value); + return; + } + if (source === "tool-result" && fullToolResult.value) { + await copyTextToClipboard(fullToolResult.value); + return; + } + + await handleCodeBlockCopyClick(event); +} + const hasAttachments = computed( () => (props.message.attachments?.length ?? 0) > 0, ); @@ -33,30 +100,28 @@ const hasToolDetails = computed( () => !!(props.message.toolArgs || props.message.toolResult), ); -const formattedToolArgs = computed(() => { - if (!props.message.toolArgs) return ""; - try { - return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2); - } catch { - return props.message.toolArgs; - } +const toolArgsPayload = computed(() => formatToolPayload(props.message.toolArgs)); +const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult)); + +const fullToolArgs = computed(() => toolArgsPayload.value.full); +const formattedToolArgs = computed(() => toolArgsPayload.value.display); +const fullToolResult = computed(() => toolResultPayload.value.full); +const formattedToolResult = computed(() => toolResultPayload.value.display); + +const renderedToolArgs = computed(() => { + if (!formattedToolArgs.value) return ""; + return renderToolPayload( + formattedToolArgs.value, + toolArgsPayload.value.language, + ); }); -const formattedToolResult = computed(() => { - if (!props.message.toolResult) return ""; - try { - const parsed = JSON.parse(props.message.toolResult); - const str = JSON.stringify(parsed, null, 2); - // Truncate very long output - if (str.length > 2000) - return str.slice(0, 2000) + "\n" + t("chat.truncated"); - return str; - } catch { - const raw = props.message.toolResult; - if (raw.length > 2000) - return raw.slice(0, 2000) + "\n" + t("chat.truncated"); - return raw; - } +const renderedToolResult = computed(() => { + if (!formattedToolResult.value) return ""; + return renderToolPayload( + formattedToolResult.value, + toolResultPayload.value.language, + ); }); @@ -109,14 +174,14 @@ const formattedToolResult = computed(() => { t("chat.error") }} -
-
+
+
{{ t("chat.arguments") }}
-
{{ formattedToolArgs }}
+
-
+
{{ t("chat.result") }}
-
{{ formattedToolResult }}
+
@@ -400,20 +465,22 @@ const formattedToolResult = computed(() => { margin-bottom: 2px; } -.tool-detail-code { - font-family: $font-code; - font-size: 11px; - line-height: 1.5; - color: $text-secondary; - background: $code-bg; - border-radius: $radius-sm; - padding: 6px 8px; - margin: 0; - overflow-x: auto; - max-height: 300px; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-all; +.tool-detail-code-block { + :deep(.hljs-code-block) { + margin: 0; + } + + :deep(.code-header) { + background: rgba(0, 0, 0, 0.02); + } + + :deep(code.hljs) { + font-size: 11px; + max-height: 300px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + } } @keyframes spin { diff --git a/packages/client/src/components/hermes/chat/highlight.ts b/packages/client/src/components/hermes/chat/highlight.ts new file mode 100644 index 0000000..73636ba --- /dev/null +++ b/packages/client/src/components/hermes/chat/highlight.ts @@ -0,0 +1,106 @@ +import hljs from 'highlight.js' + +const LANGUAGE_ALIASES: Record = { + shellscript: 'bash', + sh: 'bash', + zsh: 'bash', + yml: 'yaml', + vue: 'xml', +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function sanitizeLanguageClass(value: string): string { + return value.replace(/[^a-z0-9_-]/gi, '-') || 'plain' +} + +export function normalizeHighlightLanguage(lang?: string): string { + const normalized = lang?.trim().toLowerCase() || '' + return LANGUAGE_ALIASES[normalized] || normalized +} + +export function inferStructuredLanguage(content: string): string | undefined { + try { + JSON.parse(content) + return 'json' + } catch { + return undefined + } +} + +type RenderHighlightedCodeBlockOptions = { + maxHighlightLength?: number +} + +export function renderHighlightedCodeBlock( + content: string, + lang: string | undefined, + copyLabel: string, + options: RenderHighlightedCodeBlockOptions = {}, +): string { + const requestedLanguage = lang?.trim().toLowerCase() || '' + const normalizedLanguage = normalizeHighlightLanguage(requestedLanguage) + const highlightLimit = options.maxHighlightLength ?? Number.POSITIVE_INFINITY + + let highlighted = '' + let codeClassLanguage = normalizedLanguage || requestedLanguage || 'plain' + let labelLanguage = requestedLanguage + + try { + if (normalizedLanguage && hljs.getLanguage(normalizedLanguage) && content.length <= highlightLimit) { + highlighted = hljs.highlight(content, { + language: normalizedLanguage, + ignoreIllegals: true, + }).value + codeClassLanguage = normalizedLanguage + } else { + highlighted = escapeHtml(content) + if (!labelLanguage) { + labelLanguage = 'text' + } + } + } catch { + highlighted = escapeHtml(content) + if (!labelLanguage) { + labelLanguage = 'text' + } + } + + const languageLabelHtml = labelLanguage + ? `${escapeHtml(labelLanguage)}` + : '' + + return `
${languageLabelHtml}
${highlighted}
` +} + +export async function copyTextToClipboard(text: string): Promise { + try { + await navigator.clipboard?.writeText?.(text) + } catch { + // Ignore clipboard failures; the code block still renders safely. + } +} + +export async function handleCodeBlockCopyClick(event: MouseEvent): Promise { + const target = event.target + if (!(target instanceof HTMLElement)) return + + const button = target.closest('[data-copy-code="true"]') + if (!button) return + + event.preventDefault() + + const block = button.closest('.hljs-code-block') + const code = block?.querySelector('code') + const text = code?.textContent ?? '' + if (!text) return + + await copyTextToClipboard(text) +} diff --git a/packages/client/src/components/hermes/models/ProviderFormModal.vue b/packages/client/src/components/hermes/models/ProviderFormModal.vue index 671768a..32194fa 100644 --- a/packages/client/src/components/hermes/models/ProviderFormModal.vue +++ b/packages/client/src/components/hermes/models/ProviderFormModal.vue @@ -64,7 +64,7 @@ watch(selectedPreset, (val) => { }) watch(() => formData.value.base_url, (url) => { - if (providerType.value === 'custom' && url.trim()) { + if (providerType.value === 'custom' && url.trim() && !formData.value.name) { formData.value.name = autoGenerateName(url.trim()) } }) @@ -90,7 +90,8 @@ async function fetchModels() { fetchingModels.value = true try { - const url = base_url.replace(/\/+$/, '') + '/models' + const base = base_url.replace(/\/+$/, '') + const url = base.endsWith('/v1') ? `${base}/models` : `${base}/v1/models` const headers: Record = {} if (formData.value.api_key.trim()) { headers['Authorization'] = `Bearer ${formData.value.api_key.trim()}` @@ -213,7 +214,6 @@ function handleClose() { diff --git a/packages/client/src/components/hermes/profiles/ProfileCard.vue b/packages/client/src/components/hermes/profiles/ProfileCard.vue index 384692b..d314f5a 100644 --- a/packages/client/src/components/hermes/profiles/ProfileCard.vue +++ b/packages/client/src/components/hermes/profiles/ProfileCard.vue @@ -16,6 +16,7 @@ const dialog = useDialog() const expanded = ref(false) const detailLoading = ref(false) const exporting = ref(false) +const switching = ref(false) const detail = ref(null) const isDefault = computed(() => props.profile.name === 'default') @@ -34,14 +35,18 @@ async function toggleDetail() { } } -function handleSwitch() { - profilesStore.switchProfile(props.profile.name).then(ok => { +async function handleSwitch() { + switching.value = true + try { + const ok = await profilesStore.switchProfile(props.profile.name) if (ok) { window.location.reload() } else { message.error(t('profiles.switchFailed')) } - }) + } finally { + switching.value = false + } } function handleDelete() { @@ -139,7 +144,7 @@ async function handleExport() { { try { const { readFileSync } = require('fs'); const { resolve } = require('path'); return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version } catch { return '0.0.0' } })() + +let cachedLatestVersion = '' + +export async function checkLatestVersion(): Promise { + try { + const { readFileSync } = require('fs') + const pkg = JSON.parse(readFileSync(resolve(require('path').join(__dirname, '../../package.json')), 'utf-8')) + const name = pkg.name + const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(10000) }) + if (res.ok) { + const data = await res.json() as { version: string } + cachedLatestVersion = data.version + if (cachedLatestVersion !== LOCAL_VERSION) { + console.log(`Update available: ${LOCAL_VERSION} → ${cachedLatestVersion}`) + } + } + } catch { /* ignore */ } +} + +export function startVersionCheck(): void { + setTimeout(checkLatestVersion, 5000) + setInterval(checkLatestVersion, 30 * 60 * 1000) +} + +export async function healthCheck(ctx: any) { + const raw = await hermesCli.getVersion() + const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || '' + let gatewayOk = false + try { + const mgr = getGatewayManagerInstance() + const upstream = mgr?.getUpstream() || config.upstream + const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, { signal: AbortSignal.timeout(5000) }) + gatewayOk = res.ok + } catch { } + ctx.body = { + status: gatewayOk ? 'ok' : 'error', + platform: 'hermes-agent', + version: hermesVersion, + gateway: gatewayOk ? 'running' : 'stopped', + webui_version: LOCAL_VERSION, + webui_latest: cachedLatestVersion, + webui_update_available: cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION, + } +} + +function resolve(p: string) { return p } diff --git a/packages/server/src/controllers/hermes/codex-auth.ts b/packages/server/src/controllers/hermes/codex-auth.ts new file mode 100644 index 0000000..ac48242 --- /dev/null +++ b/packages/server/src/controllers/hermes/codex-auth.ts @@ -0,0 +1,176 @@ +import { randomUUID } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { getActiveAuthPath } from '../../services/hermes/hermes-profile' +import { logger } from '../../services/logger' + +// --- OAuth Constants --- +const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode' +const CODEX_DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token' +const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token' +const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api/codex' +const CODEX_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback' +const CODEX_VERIFICATION_URL = 'https://auth.openai.com/codex/device' +const CODEX_HOME = join(homedir(), '.codex') +const POLL_MAX_DURATION = 15 * 60 * 1000 +const POLL_DEFAULT_INTERVAL = 5000 + +// --- Session Store --- +interface CodexSession { + id: string; userCode: string; deviceAuthId: string + status: 'pending' | 'approved' | 'expired' | 'error' + error?: string; accessToken?: string; refreshToken?: string; createdAt: number +} + +const sessions = new Map() + +function cleanupExpiredSessions() { + const now = Date.now() + sessions.forEach((session, id) => { if (now - session.createdAt > POLL_MAX_DURATION + 60000) { sessions.delete(id) } }) +} + +// --- Auth file helpers --- +interface AuthJson { version?: number; active_provider?: string; providers?: Record; credential_pool?: Record; updated_at?: string } + +function loadAuthJson(authPath: string): AuthJson { + try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } } +} + +function saveAuthJson(authPath: string, data: AuthJson): void { + data.updated_at = new Date().toISOString() + const dir = authPath.substring(0, authPath.lastIndexOf('/')) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }) +} + +function saveCodexCliTokens(accessToken: string, refreshToken: string): void { + const codexHome = process.env.CODEX_HOME || CODEX_HOME + const codexAuthPath = join(codexHome, 'auth.json') + const dir = codexAuthPath.substring(0, codexAuthPath.lastIndexOf('/')) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(codexAuthPath, JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken }, last_refresh: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 }) +} + +function decodeJwtExp(token: string): number | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const payload = Buffer.from(parts[1], 'base64url').toString('utf-8') + const claims = JSON.parse(payload) + return typeof claims.exp === 'number' ? claims.exp : null + } catch { return null } +} + +// --- Background login worker --- +async function codexLoginWorker(session: CodexSession, authPath: string): Promise { + const startTime = Date.now() + const interval = POLL_DEFAULT_INTERVAL + while (Date.now() - startTime < POLL_MAX_DURATION) { + await new Promise(resolve => setTimeout(resolve, interval)) + if (session.status !== 'pending') return + try { + const pollRes = await fetch(CODEX_DEVICE_TOKEN_URL, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_auth_id: session.deviceAuthId, user_code: session.userCode }), + signal: AbortSignal.timeout(10000), + }) + if (pollRes.status === 200) { + const pollData = await pollRes.json() as { authorization_code: string; code_verifier: string } + const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code: pollData.authorization_code, redirect_uri: CODEX_REDIRECT_URI, client_id: CODEX_CLIENT_ID, code_verifier: pollData.code_verifier }).toString(), + signal: AbortSignal.timeout(15000), + }) + if (!tokenRes.ok) { const errText = await tokenRes.text(); logger.error('Token exchange failed: %d %s', tokenRes.status, errText); session.status = 'error'; session.error = `Token exchange failed: ${tokenRes.status}`; return } + const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string } + const refreshToken = tokenData.refresh_token || '' + session.accessToken = tokenData.access_token; session.refreshToken = refreshToken; session.status = 'approved' + const auth = loadAuthJson(authPath) + if (!auth.providers) auth.providers = {} + auth.providers['openai-codex'] = { tokens: { access_token: tokenData.access_token, refresh_token: refreshToken }, last_refresh: new Date().toISOString(), auth_mode: 'chatgpt' } + if (!auth.credential_pool) auth.credential_pool = {} + auth.credential_pool['openai-codex'] = [{ id: `openai-codex-${Date.now()}`, label: 'OpenAI Codex', base_url: CODEX_DEFAULT_BASE_URL, access_token: tokenData.access_token, last_status: null }] + saveAuthJson(authPath, auth) + saveCodexCliTokens(tokenData.access_token, refreshToken) + logger.info('Login successful') + return + } + if (pollRes.status === 403 || pollRes.status === 404) { continue } + logger.error('Poll failed: %d', pollRes.status); session.status = 'error'; session.error = `Poll failed: ${pollRes.status}`; return + } catch (err: any) { + if (err.name === 'TimeoutError' || err.name === 'AbortError') { continue } + logger.error(err, 'Poll error'); session.status = 'error'; session.error = err.message; return + } + } + session.status = 'expired' +} + +// --- Controller functions --- + +export async function start(ctx: any) { + try { + cleanupExpiredSessions() + const res = await fetch(CODEX_DEVICE_AUTH_URL, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'node-fetch' }, + body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), signal: AbortSignal.timeout(10000), + }) + if (!res.ok) { + let errorBody: any = null; try { errorBody = await res.json() } catch { } + logger.error('Device code request failed: %d %s', res.status, errorBody) + let errorMessage = `Device code request failed: ${res.status}` + if (errorBody?.error?.code === 'unsupported_country_region_territory') { errorMessage = 'OpenAI does not support your region. You may need to use a proxy or VPN to access Codex.' } + ctx.status = 502; ctx.body = { error: errorMessage, code: errorBody?.error?.code }; return + } + const data = await res.json() as { user_code: string; device_auth_id: string; interval?: string } + const sessionId = randomUUID() + const session: CodexSession = { id: sessionId, userCode: data.user_code, deviceAuthId: data.device_auth_id, status: 'pending', createdAt: Date.now() } + sessions.set(sessionId, session) + const authPath = getActiveAuthPath() + codexLoginWorker(session, authPath).catch(err => { logger.error(err, 'Worker error'); session.status = 'error'; session.error = err.message }) + ctx.body = { session_id: sessionId, user_code: data.user_code, verification_url: CODEX_VERIFICATION_URL, expires_in: 900 } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function poll(ctx: any) { + const session = sessions.get(ctx.params.sessionId) + if (!session) { ctx.status = 404; ctx.body = { error: 'Session not found' }; return } + ctx.body = { status: session.status, error: session.error || null } +} + +export async function status(ctx: any) { + try { + const authPath = getActiveAuthPath() + const auth = loadAuthJson(authPath) + const tokens = auth.providers?.['openai-codex']?.tokens + if (!tokens?.access_token || !auth.providers) { ctx.body = { authenticated: false }; return } + const codexProvider = auth.providers['openai-codex']! + const exp = decodeJwtExp(tokens.access_token) + if (exp && exp <= Date.now() / 1000 + 120) { + if (tokens.refresh_token) { + try { + const refreshRes = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, client_id: CODEX_CLIENT_ID }).toString(), + signal: AbortSignal.timeout(15000), + }) + if (refreshRes.ok) { + const newTokens = await refreshRes.json() as { access_token: string; refresh_token?: string } + codexProvider.tokens.access_token = newTokens.access_token + if (newTokens.refresh_token) { codexProvider.tokens.refresh_token = newTokens.refresh_token } + codexProvider.last_refresh = new Date().toISOString() + saveAuthJson(authPath, auth) + saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || tokens.refresh_token) + if (auth.credential_pool?.['openai-codex']?.[0]) { auth.credential_pool['openai-codex'][0].access_token = newTokens.access_token; saveAuthJson(authPath, auth) } + ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh }; return + } + } catch { } + } + ctx.body = { authenticated: false }; return + } + ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh } + } catch { ctx.body = { authenticated: false } } +} diff --git a/packages/server/src/controllers/hermes/config.ts b/packages/server/src/controllers/hermes/config.ts new file mode 100644 index 0000000..d8a179a --- /dev/null +++ b/packages/server/src/controllers/hermes/config.ts @@ -0,0 +1,191 @@ +import { readFile, writeFile, copyFile } from 'fs/promises' +import YAML from 'js-yaml' +import { restartGateway } from '../../services/hermes/hermes-cli' +import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile' +import { saveEnvValue } from '../../services/config-helpers' + +const PLATFORM_SECTIONS = new Set([ + 'telegram', 'discord', 'slack', 'whatsapp', 'matrix', + 'weixin', 'wecom', 'feishu', 'dingtalk', +]) + +const configPath = () => getActiveConfigPath() +const envPath = () => getActiveEnvPath() + +const envPlatformMap: Record = { + TELEGRAM_BOT_TOKEN: ['telegram', 'token'], + DISCORD_BOT_TOKEN: ['discord', 'token'], + SLACK_BOT_TOKEN: ['slack', 'token'], + MATRIX_ACCESS_TOKEN: ['matrix', 'token'], + MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'], + FEISHU_APP_ID: ['feishu', 'extra.app_id'], + FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'], + DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'], + DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'], + DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'], + WECOM_BOT_ID: ['wecom', 'extra.bot_id'], + WECOM_SECRET: ['wecom', 'extra.secret'], + WEIXIN_TOKEN: ['weixin', 'token'], + WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'], + WEIXIN_BASE_URL: ['weixin', 'extra.base_url'], + WHATSAPP_ENABLED: ['whatsapp', 'enabled'], +} + +const platformEnvMap: Record> = {} +for (const [envVar, [platform, cfgPath]] of Object.entries(envPlatformMap)) { + if (!platformEnvMap[platform]) platformEnvMap[platform] = {} + platformEnvMap[platform][cfgPath] = envVar +} + +function parseEnv(raw: string): Record { + const env: Record = {} + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + const key = trimmed.slice(0, eqIdx).trim() + const val = trimmed.slice(eqIdx + 1).trim() + if (val) env[key] = val + } + return env +} + +function setNested(obj: Record, path: string, value: any) { + const parts = path.split('.') + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]] } + cur[parts[parts.length - 1]] = value +} + +function deepMerge(target: Record, source: Record): Record { + for (const key of Object.keys(source)) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && + target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { + target[key] = deepMerge(target[key], source[key]) + } else { + target[key] = source[key] + } + } + return target +} + +async function readEnvPlatforms(): Promise> { + try { + const raw = await readFile(envPath(), 'utf-8') + const env = parseEnv(raw) + const platforms: Record = {} + for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) { + const val = env[envKey] + if (val === undefined || val === '') continue + if (!platforms[platform]) platforms[platform] = {} + let finalVal: any = val + if (cfgPath === 'enabled') finalVal = val === 'true' + setNested(platforms[platform], cfgPath, finalVal) + } + return platforms + } catch { return {} } +} + +async function readConfig(): Promise> { + const raw = await readFile(configPath(), 'utf-8') + return (YAML.load(raw) as Record) || {} +} + +async function writeConfig(data: Record): Promise { + const cp = configPath() + await copyFile(cp, cp + '.bak') + const yamlStr = YAML.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"', forceQuotes: false }) + await writeFile(cp, yamlStr, 'utf-8') +} + +export async function getConfig(ctx: any) { + try { + const config = await readConfig() + const envPlatforms = await readEnvPlatforms() + if (Object.keys(envPlatforms).length > 0) { + const existing = config.platforms || {} + for (const [platform, vals] of Object.entries(envPlatforms)) { + existing[platform] = { ...(existing[platform] || {}), ...(vals as Record) } + } + config.platforms = existing + } + const { section, sections } = ctx.query + if (section) { + ctx.body = { [section as string]: config[section as string] || {} } + } else if (sections) { + const keys = (sections as string).split(',') + const result: Record = {} + for (const key of keys) { result[key.trim()] = config[key.trim()] || {} } + ctx.body = result + } else { + ctx.body = config + } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function updateConfig(ctx: any) { + const { section, values } = ctx.request.body as { section: string; values: Record } + if (!section || !values) { + ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return + } + try { + const config = await readConfig() + config[section] = deepMerge(config[section] || {}, values) + await writeConfig(config) + if (PLATFORM_SECTIONS.has(section)) { await restartGateway() } + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function updateCredentials(ctx: any) { + const { platform, values } = ctx.request.body as { platform: string; values: Record } + if (!platform || !values) { + ctx.status = 400; ctx.body = { error: 'Missing platform or values' }; return + } + try { + const envMap = platformEnvMap[platform] + if (!envMap) { + ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return + } + const config = await readConfig() + let configChanged = false + const flatValues: Record = {} + for (const [key, val] of Object.entries(values)) { + if (key === 'extra' && val && typeof val === 'object') { + for (const [subKey, subVal] of Object.entries(val as Record)) { flatValues[`extra.${subKey}`] = subVal } + } else { flatValues[key] = val } + } + for (const [cfgPath, val] of Object.entries(flatValues)) { + const envVar = envMap[cfgPath] + if (!envVar) continue + if (val === undefined || val === null || val === '') { + await saveEnvValue(envVar, '') + const parts = cfgPath.split('.') + let obj: any = config.platforms?.[platform] + if (obj) { + if (parts.length === 1) { delete obj[parts[0]] } + else { + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) break; cur = cur[parts[i]] } + delete cur[parts[parts.length - 1]] + if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra + } + if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] } + configChanged = true + } + } else { + await saveEnvValue(envVar, String(val)) + } + } + if (configChanged) { await writeConfig(config) } + await restartGateway() + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/gateways.ts b/packages/server/src/controllers/hermes/gateways.ts new file mode 100644 index 0000000..56fc4de --- /dev/null +++ b/packages/server/src/controllers/hermes/gateways.ts @@ -0,0 +1,33 @@ +import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' + +export async function list(ctx: any) { + const mgr = getGatewayManagerInstance() + if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return } + const gateways = await mgr.listAll() + ctx.body = { gateways } +} + +export async function start(ctx: any) { + const mgr = getGatewayManagerInstance() + if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return } + try { + const status = await mgr.start(ctx.params.name) + ctx.body = { success: true, gateway: status } + } catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } } +} + +export async function stop(ctx: any) { + const mgr = getGatewayManagerInstance() + if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return } + try { + await mgr.stop(ctx.params.name) + ctx.body = { success: true } + } catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } } +} + +export async function health(ctx: any) { + const mgr = getGatewayManagerInstance() + if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return } + const status = await mgr.detectStatus(ctx.params.name) + ctx.body = { gateway: status } +} diff --git a/packages/server/src/controllers/hermes/logs.ts b/packages/server/src/controllers/hermes/logs.ts new file mode 100644 index 0000000..b3c9146 --- /dev/null +++ b/packages/server/src/controllers/hermes/logs.ts @@ -0,0 +1,76 @@ +import { existsSync, statSync } from 'fs' +import { readFile } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' +import * as hermesCli from '../../services/hermes/hermes-cli' + +const WEBUI_LOG_FILE = join(homedir(), '.hermes-web-ui', 'logs', 'server.log') + +interface LogEntry { + timestamp: string; level: string; logger: string; message: string; raw: string +} + +function parseLine(line: string): LogEntry { + try { + const obj = JSON.parse(line) + if (obj.level && obj.time) { + const ts = new Date(obj.time).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-') + const levelMap: Record = { 10: 'DEBUG', 20: 'INFO', 30: 'WARN', 40: 'ERROR', 50: 'FATAL' } + return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: obj.msg || '', message: typeof obj.msg === 'string' ? obj.msg : JSON.stringify(obj.msg), raw: line } + } + } catch {} + let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/) + if (match) { return { timestamp: match[1], level: match[2], logger: match[3], message: match[4], raw: line } } + match = line.match(/^\[(\S+?)\]\s+\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\]\s+\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s(.*)$/) + if (match) { return { timestamp: match[2], level: match[3], logger: match[1], message: match[4], raw: line } } + return { timestamp: '', level: '', logger: '', message: line, raw: line } +} + +export async function list(ctx: any) { + const files = await hermesCli.listLogFiles() + if (existsSync(WEBUI_LOG_FILE)) { + try { + const stat = statSync(WEBUI_LOG_FILE) + const size = stat.size > 1024 * 1024 ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` : `${(stat.size / 1024).toFixed(1)}KB` + const modified = stat.mtime.toLocaleString() + files.push({ name: 'webui', size, modified }) + } catch { } + } + ctx.body = { files } +} + +export async function read(ctx: any) { + const logName = ctx.params.name + const lines = ctx.query.lines ? parseInt(ctx.query.lines as string, 10) : 100 + const level = (ctx.query.level as string) || undefined + const session = (ctx.query.session as string) || undefined + const since = (ctx.query.since as string) || undefined + + if (logName === 'webui') { + try { + if (!existsSync(WEBUI_LOG_FILE)) { ctx.body = { entries: [] }; return } + const content = await readFile(WEBUI_LOG_FILE, 'utf-8') + const rawLines = content.split('\n') + const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines + const entries: LogEntry[] = [] + for (const line of sliced) { if (!line.trim()) continue; entries.push(parseLine(line)) } + ctx.body = { entries: entries.reverse() } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } + return + } + + try { + const content = await hermesCli.readLogs(logName, lines, level, session, since) + const rawLines = content.split('\n') + const entries: (LogEntry | null)[] = [] + for (const line of rawLines) { + if (line.startsWith('---') || line.trim() === '') continue + entries.push(parseLine(line)) + } + ctx.body = { entries: entries.reverse() } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/memory.ts b/packages/server/src/controllers/hermes/memory.ts new file mode 100644 index 0000000..7c6f776 --- /dev/null +++ b/packages/server/src/controllers/hermes/memory.ts @@ -0,0 +1,46 @@ +import { writeFile } from 'fs/promises' +import { join } from 'path' +import { safeReadFile, safeStat, getHermesDir } from '../../services/config-helpers' + +export async function get(ctx: any) { + const hd = getHermesDir() + const memoryPath = join(hd, 'memories', 'MEMORY.md') + const userPath = join(hd, 'memories', 'USER.md') + const soulPath = join(hd, 'SOUL.md') + const [memory, user, soul, memoryStat, userStat, soulStat] = await Promise.all([ + safeReadFile(memoryPath), safeReadFile(userPath), safeReadFile(soulPath), + safeStat(memoryPath), safeStat(userPath), safeStat(soulPath), + ]) + ctx.body = { + memory: memory || '', user: user || '', soul: soul || '', + memory_mtime: memoryStat?.mtime || null, user_mtime: userStat?.mtime || null, soul_mtime: soulStat?.mtime || null, + } +} + +export async function save(ctx: any) { + const { section, content } = ctx.request.body as { section: string; content: string } + if (!section || !content) { + ctx.status = 400 + ctx.body = { error: 'Missing section or content' } + return + } + if (section !== 'memory' && section !== 'user' && section !== 'soul') { + ctx.status = 400 + ctx.body = { error: 'Section must be "memory", "user", or "soul"' } + return + } + let filePath: string + if (section === 'soul') { + filePath = join(getHermesDir(), 'SOUL.md') + } else { + const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md' + filePath = join(getHermesDir(), 'memories', fileName) + } + try { + await writeFile(filePath, content, 'utf-8') + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/models.ts b/packages/server/src/controllers/hermes/models.ts new file mode 100644 index 0000000..11be2d5 --- /dev/null +++ b/packages/server/src/controllers/hermes/models.ts @@ -0,0 +1,135 @@ +import { readFile } from 'fs/promises' +import { existsSync, readFileSync } from 'fs' +import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile' +import { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers' +import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers' + +const PROVIDER_MODEL_CATALOG = buildProviderModelMap() + +export async function getAvailable(ctx: any) { + try { + const config = await readConfigYaml() + const modelSection = config.model + let currentDefault = '' + let currentDefaultProvider = '' + if (typeof modelSection === 'object' && modelSection !== null) { + currentDefault = String(modelSection.default || '').trim() + currentDefaultProvider = String(modelSection.provider || '').trim() + } else if (typeof modelSection === 'string') { + currentDefault = modelSection.trim() + } + + const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string }> = [] + const seenProviders = new Set() + + let envContent = '' + try { envContent = await readFile(getActiveEnvPath(), 'utf-8') } catch { } + + const envHasValue = (key: string): boolean => { + if (!key) return false + const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#') + } + const envGetValue = (key: string): string => { + if (!key) return '' + const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + return match?.[1]?.trim() || '' + } + const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string) => { + if (seenProviders.has(provider)) return + seenProviders.add(provider) + groups.push({ provider, label, base_url, models: [...models], api_key }) + } + + const isOAuthAuthorized = (providerKey: string): boolean => { + try { + const authPath = getActiveAuthPath() + if (!existsSync(authPath)) return false + const auth = JSON.parse(readFileSync(authPath, 'utf-8')) + return !!auth.providers?.[providerKey]?.tokens?.access_token + } catch { return false } + } + + for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) { + if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue + if (!envMapping.api_key_env && !isOAuthAuthorized(providerKey)) continue + const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey) + const label = preset?.label || providerKey.replace(/^custom:/, '') + const baseUrl = preset?.base_url || '' + const catalogModels = PROVIDER_MODEL_CATALOG[providerKey] + if (catalogModels && catalogModels.length > 0) { + const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : '' + addGroup(providerKey, label, baseUrl, catalogModels, apiKey) + } + } + + const customProviders = Array.isArray(config.custom_providers) + ? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }> + : [] + + const customFetches = await Promise.allSettled( + customProviders.map(async cp => { + if (!cp.base_url) return null + const providerKey = `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}` + const baseUrl = cp.base_url.replace(/\/+$/, '') + let models = [cp.model] + if (cp.api_key) { + try { const fetched = await fetchProviderModels(baseUrl, cp.api_key); if (fetched.length > 0) models = fetched } catch { } + } + return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' } + }), + ) + + for (const result of customFetches) { + if (result.status === 'fulfilled' && result.value) { + const { providerKey, label, base_url, models, api_key: cpApiKey } = result.value + addGroup(providerKey, label, base_url, models, cpApiKey) + } + } + + for (const g of groups) { g.models = Array.from(new Set(g.models)) } + + if (groups.length === 0) { + const fallback = buildModelGroups(config) + const allProviders = PROVIDER_PRESETS.map((p: any) => ({ provider: p.value, label: p.label, base_url: p.base_url, models: p.models })) + ctx.body = { ...fallback, allProviders } + return + } + + const allProviders = PROVIDER_PRESETS.map((p: any) => ({ provider: p.value, label: p.label, base_url: p.base_url, models: p.models })) + ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function getConfigModels(ctx: any) { + try { + const config = await readConfigYaml() + ctx.body = buildModelGroups(config) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function setConfigModel(ctx: any) { + const { default: defaultModel, provider: reqProvider } = ctx.request.body as { default: string; provider?: string } + if (!defaultModel) { + ctx.status = 400 + ctx.body = { error: 'Missing default model' } + return + } + try { + const config = await readConfigYaml() + if (typeof config.model !== 'object' || config.model === null) { config.model = {} } + config.model.default = defaultModel + if (reqProvider) { config.model.provider = reqProvider } + await writeConfigYaml(config) + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/profiles.ts b/packages/server/src/controllers/hermes/profiles.ts new file mode 100644 index 0000000..feca671 --- /dev/null +++ b/packages/server/src/controllers/hermes/profiles.ts @@ -0,0 +1,197 @@ +import { createReadStream, existsSync, unlinkSync, writeFileSync } from 'fs' +import { mkdir, writeFile } from 'fs/promises' +import { basename, join } from 'path' +import { tmpdir } from 'os' +import * as hermesCli from '../../services/hermes/hermes-cli' +import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' +import { logger } from '../../services/logger' + +export async function list(ctx: any) { + try { + const profiles = await hermesCli.listProfiles() + ctx.body = { profiles } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function create(ctx: any) { + 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) + const mgr = getGatewayManagerInstance() + if (mgr) { + try { await mgr.start(name) } catch (err: any) { + logger.error(err, 'Failed to start gateway for profile "%s"', name) + } + } + ctx.body = { success: true, message: output.trim() } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function get(ctx: any) { + try { + const profile = await hermesCli.getProfile(ctx.params.name) + ctx.body = { profile } + } catch (err: any) { + ctx.status = err.message.includes('not found') ? 404 : 500 + ctx.body = { error: err.message } + } +} + +export async function remove(ctx: any) { + const { name } = ctx.params + if (name === 'default') { + ctx.status = 400 + ctx.body = { error: 'Cannot delete the default profile' } + return + } + try { + const mgr = getGatewayManagerInstance() + if (mgr) { try { await mgr.stop(name) } catch { } } + 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 } + } +} + +export async function rename(ctx: any) { + 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(ctx.params.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 } + } +} + +export async function switchProfile(ctx: any) { + const { name } = ctx.request.body as { name?: string } + if (!name) { + ctx.status = 400 + ctx.body = { error: 'Missing profile name' } + return + } + try { + const output = await hermesCli.useProfile(name) + await new Promise(r => setTimeout(r, 1000)) + const mgr = getGatewayManagerInstance() + if (mgr) { mgr.setActiveProfile(name) } + try { + const detail = await hermesCli.getProfile(name) + logger.debug('Profile detail.path = %s', detail.path) + if (!existsSync(join(detail.path, 'config.yaml'))) { + try { await hermesCli.setupReset() } catch { } + } + const profileEnv = join(detail.path, '.env') + if (!existsSync(profileEnv)) { + writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8') + logger.info('Created .env for: %s', detail.path) + } + } catch (err: any) { + logger.error(err, 'Ensure config failed') + } + ctx.body = { success: true, message: output.trim() } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function exportProfile(ctx: any) { + const { name } = ctx.params + const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`) + try { + await hermesCli.exportProfile(name, outputPath) + if (!existsSync(outputPath)) { + ctx.status = 500 + ctx.body = { error: 'Export file not found' } + return + } + const filename = basename(outputPath) + ctx.set('Content-Disposition', `attachment; filename="${filename}"`) + ctx.set('Content-Type', 'application/gzip') + ctx.body = createReadStream(outputPath) + ctx.res.on('finish', () => { try { unlinkSync(outputPath) } catch { } }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function importProfile(ctx: any) { + const contentType = ctx.get('content-type') || '' + if (!contentType.startsWith('multipart/form-data')) { + ctx.status = 400 + ctx.body = { error: 'Expected multipart/form-data' } + return + } + const boundary = '--' + contentType.split('boundary=')[1] + if (!boundary || boundary === '--undefined') { + ctx.status = 400 + ctx.body = { error: 'Missing boundary' } + return + } + const tmpDir = join(tmpdir(), 'hermes-import') + await mkdir(tmpDir, { recursive: true }) + const chunks: Buffer[] = [] + for await (const chunk of ctx.req) chunks.push(chunk) + const body = Buffer.concat(chunks).toString('latin1') + const parts = body.split(boundary).slice(1, -1) + let archivePath = '' + for (const part of parts) { + const headerEnd = part.indexOf('\r\n\r\n') + if (headerEnd === -1) continue + const header = part.substring(0, headerEnd) + const data = part.substring(headerEnd + 4, part.length - 2) + const filenameMatch = header.match(/filename="([^"]+)"/) + if (!filenameMatch) continue + const filename = filenameMatch[1] + const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' + if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue + archivePath = join(tmpDir, filename) + await writeFile(archivePath, Buffer.from(data, 'binary')) + break + } + if (!archivePath) { + ctx.status = 400 + ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' } + return + } + try { + const result = await hermesCli.importProfile(archivePath) + try { unlinkSync(archivePath) } catch { } + ctx.body = { success: true, message: result.trim() } + } catch (err: any) { + try { unlinkSync(archivePath) } catch { } + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/providers.ts b/packages/server/src/controllers/hermes/providers.ts new file mode 100644 index 0000000..cfa8008 --- /dev/null +++ b/packages/server/src/controllers/hermes/providers.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync } from 'fs' +import { writeFile } from 'fs/promises' +import { getActiveAuthPath } from '../../services/hermes/hermes-profile' +import * as hermesCli from '../../services/hermes/hermes-cli' +import { readConfigYaml, writeConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services/config-helpers' +import { logger } from '../../services/logger' + +export async function create(ctx: any) { + const { name, base_url, api_key, model, providerKey } = ctx.request.body as { + name: string; base_url: string; api_key: string; model: string; providerKey?: string | null + } + if (!name || !base_url || !model) { + ctx.status = 400; ctx.body = { error: 'Missing name, base_url, or model' }; return + } + if (!api_key) { + ctx.status = 400; ctx.body = { error: 'Missing API key' }; return + } + try { + const poolKey = providerKey || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}` + const isBuiltin = poolKey in PROVIDER_ENV_MAP + if (!isBuiltin) { + const config = await readConfigYaml() + if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } + const existing = (config.custom_providers as any[]).find( + (e: any) => `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey + ) + if (existing) { + existing.base_url = base_url + existing.api_key = api_key + existing.model = model + } else { + config.custom_providers.push({ name, base_url, api_key, model }) + } + await writeConfigYaml(config) + } + const envMapping = isBuiltin ? (PROVIDER_ENV_MAP[poolKey] || PROVIDER_ENV_MAP[providerKey || '']) : null + if (envMapping) { + await saveEnvValue(envMapping.api_key_env, api_key) + if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, base_url) } + } + const config2 = await readConfigYaml() + if (typeof config2.model !== 'object' || config2.model === null) { config2.model = {} } + config2.model.default = model + config2.model.provider = poolKey + await writeConfigYaml(config2) + try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function update(ctx: any) { + const poolKey = decodeURIComponent(ctx.params.poolKey) + const { name, base_url, api_key, model } = ctx.request.body as { + name?: string; base_url?: string; api_key?: string; model?: string + } + try { + const isCustom = poolKey.startsWith('custom:') + if (isCustom) { + const config = await readConfigYaml() + if (!Array.isArray(config.custom_providers)) { + ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return + } + const entry = (config.custom_providers as any[]).find((e: any) => { + return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey + }) + if (!entry) { + ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return + } + if (name !== undefined) entry.name = name + if (base_url !== undefined) entry.base_url = base_url + if (api_key !== undefined) entry.api_key = api_key + if (model !== undefined) entry.model = model + await writeConfigYaml(config) + } else { + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (!envMapping?.api_key_env) { + ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return + } + if (api_key !== undefined) { await saveEnvValue(envMapping.api_key_env, api_key) } + } + try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function remove(ctx: any) { + const poolKey = decodeURIComponent(ctx.params.poolKey) + try { + const config = await readConfigYaml() + const isCustom = poolKey.startsWith('custom:') + if (isCustom) { + const idx = Array.isArray(config.custom_providers) + ? (config.custom_providers as any[]).findIndex((e: any) => { + return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey + }) + : -1 + if (idx === -1) { + ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return + } + (config.custom_providers as any[]).splice(idx, 1) + await writeConfigYaml(config) + } else { + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (envMapping?.api_key_env) { + await saveEnvValue(envMapping.api_key_env, '') + } else if (!envMapping?.api_key_env) { + try { + const authPath = getActiveAuthPath() + if (existsSync(authPath)) { + const auth = JSON.parse(readFileSync(authPath, 'utf-8')) + if (auth.providers?.[poolKey]) { delete auth.providers[poolKey] } + if (auth.credential_pool?.[poolKey]) { delete auth.credential_pool[poolKey] } + await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8') + } + } catch (err: any) { logger.error(err, 'Failed to clear OAuth tokens for %s', poolKey) } + } + } + const currentProvider = config.model?.provider + if (currentProvider === poolKey) { + const freshConfig = await readConfigYaml() + const remaining = Array.isArray(freshConfig.custom_providers) ? freshConfig.custom_providers as any[] : [] + const fallbackCp = remaining[0] + if (fallbackCp) { + const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}` + if (typeof freshConfig.model !== 'object' || freshConfig.model === null) { freshConfig.model = {} } + freshConfig.model.default = fallbackCp.model + freshConfig.model.provider = fallbackKey + await writeConfigYaml(freshConfig) + } + } + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts new file mode 100644 index 0000000..3524a24 --- /dev/null +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -0,0 +1,55 @@ +import * as hermesCli from '../../services/hermes/hermes-cli' +import { listSessionSummaries } from '../../services/hermes/sessions-db' +import { logger } from '../../services/logger' + +export async function list(ctx: any) { + const source = (ctx.query.source as string) || undefined + const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined + + try { + const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000) + ctx.body = { sessions } + return + } catch (err) { + logger.warn(err, 'Hermes Session DB: summary query failed, falling back to CLI') + } + + const sessions = await hermesCli.listSessions(source, limit) + ctx.body = { sessions } +} + +export async function get(ctx: any) { + const session = await hermesCli.getSession(ctx.params.id) + if (!session) { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + ctx.body = { session } +} + +export async function remove(ctx: any) { + const ok = await hermesCli.deleteSession(ctx.params.id) + if (!ok) { + ctx.status = 500 + ctx.body = { error: 'Failed to delete session' } + return + } + ctx.body = { ok: true } +} + +export async function rename(ctx: any) { + const { title } = ctx.request.body as { title?: string } + if (!title || typeof title !== 'string') { + ctx.status = 400 + ctx.body = { error: 'title is required' } + return + } + const ok = await hermesCli.renameSession(ctx.params.id, title.trim()) + if (!ok) { + ctx.status = 500 + ctx.body = { error: 'Failed to rename session' } + return + } + ctx.body = { ok: true } +} diff --git a/packages/server/src/controllers/hermes/skills.ts b/packages/server/src/controllers/hermes/skills.ts new file mode 100644 index 0000000..ef687ce --- /dev/null +++ b/packages/server/src/controllers/hermes/skills.ts @@ -0,0 +1,94 @@ +import { readdir } from 'fs/promises' +import { join, resolve } from 'path' +import { + readConfigYaml, writeConfigYaml, + safeReadFile, extractDescription, listFilesRecursive, getHermesDir, +} from '../../services/config-helpers' + +export async function list(ctx: any) { + const skillsDir = join(getHermesDir(), 'skills') + try { + const config = await readConfigYaml() + const disabledList: string[] = config.skills?.disabled || [] + const entries = await readdir(skillsDir, { withFileTypes: true }) + const categories: any[] = [] + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + const catDir = join(skillsDir, entry.name) + const catDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md')) + const catDescription = catDesc ? catDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : '' + const skillEntries = await readdir(catDir, { withFileTypes: true }) + const skills: any[] = [] + for (const se of skillEntries) { + if (!se.isDirectory()) continue + const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md')) + if (skillMd) { + skills.push({ name: se.name, description: extractDescription(skillMd), enabled: !disabledList.includes(se.name) }) + } + } + if (skills.length > 0) { + categories.push({ name: entry.name, description: catDescription, skills }) + } + } + categories.sort((a, b) => a.name.localeCompare(b.name)) + for (const cat of categories) { cat.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) } + ctx.body = { categories } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: `Failed to read skills directory: ${err.message}` } + } +} + +export async function toggle(ctx: any) { + const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean } + if (!name || typeof enabled !== 'boolean') { + ctx.status = 400 + ctx.body = { error: 'Missing name or enabled flag' } + return + } + try { + const config = await readConfigYaml() + if (!config.skills) config.skills = {} + if (!Array.isArray(config.skills.disabled)) config.skills.disabled = [] + const disabled = config.skills.disabled as string[] + const idx = disabled.indexOf(name) + if (enabled) { if (idx !== -1) disabled.splice(idx, 1) } + else { if (idx === -1) disabled.push(name) } + await writeConfigYaml(config) + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function listFiles(ctx: any) { + const { category, skill } = ctx.params + const skillDir = join(getHermesDir(), 'skills', category, skill) + try { + const allFiles = await listFilesRecursive(skillDir, '') + const files = allFiles.filter(f => f.path !== 'SKILL.md') + ctx.body = { files } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function readFile_(ctx: any) { + const filePath = (ctx.params as any).path + const hd = getHermesDir() + const fullPath = resolve(join(hd, 'skills', filePath)) + if (!fullPath.startsWith(join(hd, 'skills'))) { + ctx.status = 403 + ctx.body = { error: 'Access denied' } + return + } + const content = await safeReadFile(fullPath) + if (content === null) { + ctx.status = 404 + ctx.body = { error: 'File not found' } + return + } + ctx.body = { content } +} diff --git a/packages/server/src/controllers/hermes/weixin.ts b/packages/server/src/controllers/hermes/weixin.ts new file mode 100644 index 0000000..98f5d42 --- /dev/null +++ b/packages/server/src/controllers/hermes/weixin.ts @@ -0,0 +1,68 @@ +import axios from 'axios' +import { readFile, writeFile, chmod } from 'fs/promises' +import { getActiveEnvPath } from '../../services/hermes/hermes-profile' +import { restartGateway } from '../../services/hermes/hermes-cli' + +const ILINK_BASE = 'https://ilinkai.weixin.qq.com' +const envPath = () => getActiveEnvPath() + +export async function getQrcode(ctx: any) { + try { + const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, { params: { bot_type: 3 }, timeout: 15000 }) + const data = res.data + if (!data || !data.qrcode) { ctx.status = 500; ctx.body = { error: 'Failed to get QR code' }; return } + ctx.body = { qrcode: data.qrcode, qrcode_url: data.qrcode_img_content } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message || 'Failed to connect to iLink API' } + } +} + +export async function pollStatus(ctx: any) { + const qrcode = ctx.query.qrcode as string + if (!qrcode) { ctx.status = 400; ctx.body = { error: 'Missing qrcode parameter' }; return } + try { + const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, { params: { qrcode }, timeout: 35000 }) + const data = res.data + const status = data?.status || 'wait' + if (status === 'confirmed') { + ctx.body = { status: 'confirmed', account_id: data.ilink_bot_id, token: data.bot_token, base_url: data.baseurl } + } else { + ctx.body = { status } + } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message || 'Failed to poll QR status' } + } +} + +export async function save(ctx: any) { + const { account_id, token, base_url } = ctx.request.body as { account_id: string; token: string; base_url?: string } + if (!account_id || !token) { ctx.status = 400; ctx.body = { error: 'Missing account_id or token' }; return } + try { + let raw: string + try { raw = await readFile(envPath(), 'utf-8') } catch { raw = '' } + const entries: Record = { WEIXIN_ACCOUNT_ID: account_id, WEIXIN_TOKEN: token } + if (base_url) entries.WEIXIN_BASE_URL = base_url + const lines = raw.split('\n') + const existingKeys = new Set() + const result: string[] = [] + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('#')) { result.push(line); continue } + const eqIdx = trimmed.indexOf('=') + if (eqIdx !== -1) { + const key = trimmed.slice(0, eqIdx).trim() + if (key in entries) { result.push(`${key}=${entries[key]}`); existingKeys.add(key); continue } + } + result.push(line) + } + for (const [key, val] of Object.entries(entries)) { if (!existingKeys.has(key)) { result.push(`${key}=${val}`) } } + let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' + const ep = envPath() + await writeFile(ep, output, 'utf-8') + try { await chmod(ep, 0o600) } catch { } + await restartGateway() + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/update.ts b/packages/server/src/controllers/update.ts new file mode 100644 index 0000000..8e31b0b --- /dev/null +++ b/packages/server/src/controllers/update.ts @@ -0,0 +1,19 @@ +import { spawn } from 'child_process' + +export async function handleUpdate(ctx: any) { + const isWin = process.platform === 'win32' + const cmd = isWin ? 'cmd /c npm install -g hermes-web-ui@latest' : 'npm install -g hermes-web-ui@latest' + try { + const { execSync } = await import('child_process') + const output = execSync(cmd, { encoding: 'utf-8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] }) + ctx.body = { success: true, message: output.trim() } + setTimeout(() => { + spawn(isWin ? 'cmd' : 'sh', isWin ? ['/c', 'hermes-web-ui restart'] : ['-c', 'hermes-web-ui restart'], { + detached: true, stdio: 'ignore', windowsHide: true, + }).unref() + process.exit(0) + }, 2000) + } catch (err: any) { + ctx.status = 500; ctx.body = { success: false, message: err.stderr || err.message } + } +} diff --git a/packages/server/src/controllers/upload.ts b/packages/server/src/controllers/upload.ts new file mode 100644 index 0000000..b38f122 --- /dev/null +++ b/packages/server/src/controllers/upload.ts @@ -0,0 +1,62 @@ +import { randomBytes } from 'crypto' +import { writeFile } from 'fs/promises' +import { config } from '../config' + +const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB + +export async function handleUpload(ctx: any) { + const contentType = ctx.get('content-type') || '' + if (!contentType.startsWith('multipart/form-data')) { + ctx.status = 400; ctx.body = { error: 'Expected multipart/form-data' }; return + } + const boundary = '--' + contentType.split('boundary=')[1] + if (!boundary || boundary === '--undefined') { + ctx.status = 400; ctx.body = { error: 'Missing boundary' }; return + } + const chunks: Buffer[] = [] + let totalSize = 0 + for await (const chunk of ctx.req) { + totalSize += chunk.length + if (totalSize > MAX_UPLOAD_SIZE) { + ctx.status = 413; ctx.body = { error: `File too large (max ${MAX_UPLOAD_SIZE / 1024 / 1024}MB)` }; return + } + chunks.push(chunk) + } + const raw = Buffer.concat(chunks) + const boundaryBuf = Buffer.from(boundary) + const parts = splitMultipart(raw, boundaryBuf) + const results: { name: string; path: string }[] = [] + for (const part of parts) { + const headerEnd = part.indexOf(Buffer.from('\r\n\r\n')) + if (headerEnd === -1) continue + const headerBuf = part.subarray(0, headerEnd) + const header = headerBuf.toString('utf-8') + const data = part.subarray(headerEnd + 4, part.length - 2) + let filename = '' + const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i) + if (filenameStarMatch) { filename = decodeURIComponent(filenameStarMatch[1]) } + else { + const filenameMatch = header.match(/filename="([^"]+)"/) + if (!filenameMatch) continue + filename = filenameMatch[1] + } + const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' + const savedName = randomBytes(8).toString('hex') + ext + const savedPath = `${config.uploadDir}/${savedName}` + await writeFile(savedPath, data) + results.push({ name: filename, path: savedPath }) + } + ctx.body = { files: results } +} + +function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] { + const parts: Buffer[] = [] + let start = 0 + while (true) { + const idx = raw.indexOf(boundary, start) + if (idx === -1) break + if (start > 0) { parts.push(raw.subarray(start + 2, idx)) } + start = idx + boundary.length + } + return parts +} diff --git a/packages/server/src/controllers/webhook.ts b/packages/server/src/controllers/webhook.ts new file mode 100644 index 0000000..2d1b98e --- /dev/null +++ b/packages/server/src/controllers/webhook.ts @@ -0,0 +1,14 @@ +import { emitWebhook } from '../services/hermes/hermes' +import { logger } from '../services/logger' + +export async function handleWebhook(ctx: any) { + const payload = ctx.request.body + if (!payload || !payload.event) { + ctx.status = 400 + ctx.body = { error: 'Missing event field' } + return + } + logger.info('Received webhook event: %s', payload.event) + emitWebhook(payload) + ctx.body = { ok: true } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e0fb933..98a0b69 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,45 +6,57 @@ import send from 'koa-send' import os from 'os' import { resolve } from 'path' import { mkdir } from 'fs/promises' +import { readFileSync } from 'fs' import { config } from './config' -import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes' -import { uploadRoutes } from './routes/upload' -import { webhookRoutes } from './routes/webhook' -import { updateRoutes } from './routes/update' -import { healthRoutes, startVersionCheck } from './routes/health' -import { getToken, authMiddleware } from './services/auth' +import { getToken, requireAuth } from './services/auth' import { initGatewayManager } from './services/gateway-bootstrap' import { bindShutdown } from './services/shutdown' +import { setupTerminalWebSocket } from './routes/hermes/terminal' +import { startVersionCheck } from './routes/health' +import { registerRoutes } from './routes' +import { logger } from './services/logger' + +// Injected by esbuild at build time; fallback to reading package.json in dev mode +declare const __APP_VERSION__: string +const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' + ? __APP_VERSION__ + : (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version } catch { return 'dev' } } )() + +// Global error handlers +process.on('uncaughtException', (err) => { + logger.fatal(err, 'Uncaught exception') + process.exit(1) +}) + +process.on('unhandledRejection', (reason) => { + logger.error(reason, 'Unhandled rejection') +}) let server: any = null export async function bootstrap() { + console.log(`hermes-web-ui v${APP_VERSION} starting...`) await mkdir(config.uploadDir, { recursive: true }) await mkdir(config.dataDir, { recursive: true }) const authToken = await getToken() const app = new Koa() - if (authToken) { - app.use(await authMiddleware(authToken)) - console.log(`🔐 Auth enabled — token: ${authToken}`) - } - await initGatewayManager() + console.log('[bootstrap] gateway manager initialized') app.use(cors({ origin: config.corsOrigins })) app.use(bodyParser()) + console.log('[bootstrap] cors + bodyParser registered') - // Shared routes (no agent prefix) - app.use(webhookRoutes.routes()) - app.use(uploadRoutes.routes()) - app.use(updateRoutes.routes()) - - // Hermes routes (must be after update — proxy catch-all matches everything) - app.use(hermesRoutes.routes()) + // Register all routes (handles auth internally) + const proxyMiddleware = registerRoutes(app, requireAuth(authToken)) app.use(proxyMiddleware) + console.log('[bootstrap] routes registered') - // Health check - app.use(healthRoutes.routes()) + if (authToken) { + console.log(`Auth enabled — token: ${authToken}`) + logger.info('Auth enabled — token: %s', authToken) + } // SPA fallback const distDir = resolve(__dirname, '..', 'client') @@ -57,21 +69,29 @@ export async function bootstrap() { await send(ctx, 'index.html', { root: distDir }) } }) + console.log('[bootstrap] SPA fallback registered') // Start server + console.log(`[bootstrap] listening on port ${config.port}`) server = app.listen(config.port, '0.0.0.0') + console.log('[bootstrap] app.listen called') setupTerminalWebSocket(server) + console.log('[bootstrap] terminal websocket setup') server.on('listening', () => { const interfaces = os.networkInterfaces() const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost' - console.log(`➜ Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`) - console.log(`➜ Upstream: ${config.upstream}`) + console.log(`Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`) + console.log(`Upstream: ${config.upstream}`) + console.log(`Log: ~/.hermes-web-ui/logs/server.log`) + logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port) + logger.info('Upstream: %s', config.upstream) }) server.on('error', (err: any) => { - console.error('Server error:', err.message) + console.error('[bootstrap] server error:', err.code || err.message) + logger.error({ err }, 'Server error') }) bindShutdown(server) diff --git a/packages/server/src/routes/health.ts b/packages/server/src/routes/health.ts index 24d1e0c..d860f49 100644 --- a/packages/server/src/routes/health.ts +++ b/packages/server/src/routes/health.ts @@ -1,73 +1,8 @@ import Router from '@koa/router' -import { resolve } from 'path' -import { readFileSync } from 'fs' -import { getGatewayManager } from './hermes/gateways' -import * as hermesCli from '../services/hermes/hermes-cli' -import { config } from '../config' - -function getLocalVersion(): string { - const candidates = [ - resolve(__dirname, '../../../package.json'), - resolve(__dirname, '../../../../package.json'), - ] - for (const p of candidates) { - try { - return JSON.parse(readFileSync(p, 'utf-8')).version - } catch { } - } - return '0.0.0' -} - -const LOCAL_VERSION = getLocalVersion() -let cachedLatestVersion = '' - -export async function checkLatestVersion(): Promise { - try { - const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', { - signal: AbortSignal.timeout(5000), - headers: { 'Cache-Control': 'no-cache' }, - }) - if (res.ok) { - const data = await res.json() - const latest = data.version || '' - if (latest && latest !== cachedLatestVersion) { - cachedLatestVersion = latest - if (latest !== LOCAL_VERSION) { - console.log(`⬆ New version available: v${LOCAL_VERSION} → v${latest}`) - } - } - } - } catch { } -} - -export function startVersionCheck(): void { - checkLatestVersion() - setInterval(checkLatestVersion, 60 * 60 * 1000) -} +import * as ctrl from '../controllers/health' export const healthRoutes = new Router() -healthRoutes.get('/health', async (ctx) => { - const raw = await hermesCli.getVersion() - const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || '' +healthRoutes.get('/health', ctrl.healthCheck) - let gatewayOk = false - try { - const mgr = getGatewayManager() - const upstream = mgr?.getUpstream() || config.upstream - const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, { - signal: AbortSignal.timeout(5000), - }) - gatewayOk = res.ok - } catch { } - - ctx.body = { - status: gatewayOk ? 'ok' : 'error', - platform: 'hermes-agent', - version: hermesVersion, - gateway: gatewayOk ? 'running' : 'stopped', - webui_version: LOCAL_VERSION, - webui_latest: cachedLatestVersion, - webui_update_available: cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION, - } -}) +export { startVersionCheck } from '../controllers/health' diff --git a/packages/server/src/routes/hermes/codex-auth.ts b/packages/server/src/routes/hermes/codex-auth.ts index 7bf00c4..79d10c7 100644 --- a/packages/server/src/routes/hermes/codex-auth.ts +++ b/packages/server/src/routes/hermes/codex-auth.ts @@ -1,347 +1,8 @@ import Router from '@koa/router' -import { randomUUID } from 'crypto' -import { join } from 'path' -import { homedir } from 'os' -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' -import { getActiveAuthPath } from '../../services/hermes/hermes-profile' +import * as ctrl from '../../controllers/hermes/codex-auth' -// --- OAuth Constants --- -const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' -const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode' -const CODEX_DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token' -const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token' -const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api/codex' -const CODEX_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback' -const CODEX_VERIFICATION_URL = 'https://auth.openai.com/codex/device' -const CODEX_HOME = join(homedir(), '.codex') -const POLL_MAX_DURATION = 15 * 60 * 1000 // 15 minutes -const POLL_DEFAULT_INTERVAL = 5000 // 5 seconds - -// --- Session Store --- -interface CodexSession { - id: string - userCode: string - deviceAuthId: string - status: 'pending' | 'approved' | 'expired' | 'error' - error?: string - accessToken?: string - refreshToken?: string - createdAt: number -} - -const sessions = new Map() - -function cleanupExpiredSessions() { - const now = Date.now() - sessions.forEach((session, id) => { - if (now - session.createdAt > POLL_MAX_DURATION + 60000) { - sessions.delete(id) - } - }) -} - -// --- Auth file helpers --- -interface AuthJson { - version?: number - active_provider?: string - providers?: Record - credential_pool?: Record - updated_at?: string -} - -function loadAuthJson(authPath: string): AuthJson { - try { - const raw = readFileSync(authPath, 'utf-8') - return JSON.parse(raw) as AuthJson - } catch { - return { version: 1 } - } -} - -function saveAuthJson(authPath: string, data: AuthJson): void { - data.updated_at = new Date().toISOString() - const dir = authPath.substring(0, authPath.lastIndexOf('/')) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }) -} - -function saveCodexCliTokens(accessToken: string, refreshToken: string): void { - const codexHome = process.env.CODEX_HOME || CODEX_HOME - const codexAuthPath = join(codexHome, 'auth.json') - const dir = codexAuthPath.substring(0, codexAuthPath.lastIndexOf('/')) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - const data = { - tokens: { access_token: accessToken, refresh_token: refreshToken }, - last_refresh: new Date().toISOString(), - } - writeFileSync(codexAuthPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }) -} - -function decodeJwtExp(token: string): number | null { - try { - const parts = token.split('.') - if (parts.length !== 3) return null - const payload = Buffer.from(parts[1], 'base64url').toString('utf-8') - const claims = JSON.parse(payload) - return typeof claims.exp === 'number' ? claims.exp : null - } catch { - return null - } -} - -// --- Background login worker --- -async function codexLoginWorker(session: CodexSession, authPath: string): Promise { - const startTime = Date.now() - const interval = POLL_DEFAULT_INTERVAL - - while (Date.now() - startTime < POLL_MAX_DURATION) { - await new Promise(resolve => setTimeout(resolve, interval)) - - if (session.status !== 'pending') return - - try { - // Step 3: Poll for authorization - const pollRes = await fetch(CODEX_DEVICE_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - device_auth_id: session.deviceAuthId, - user_code: session.userCode, - }), - signal: AbortSignal.timeout(10000), - }) - - if (pollRes.status === 200) { - const pollData = await pollRes.json() as { authorization_code: string; code_verifier: string } - - // Step 4: Exchange authorization code for tokens - const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code: pollData.authorization_code, - redirect_uri: CODEX_REDIRECT_URI, - client_id: CODEX_CLIENT_ID, - code_verifier: pollData.code_verifier, - }).toString(), - signal: AbortSignal.timeout(15000), - }) - - if (!tokenRes.ok) { - const errText = await tokenRes.text() - console.error('[Codex Auth] Token exchange failed:', tokenRes.status, errText) - session.status = 'error' - session.error = `Token exchange failed: ${tokenRes.status}` - return - } - - const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string } - const refreshToken = tokenData.refresh_token || '' - - session.accessToken = tokenData.access_token - session.refreshToken = refreshToken - session.status = 'approved' - - // Save to auth.json - const auth = loadAuthJson(authPath) - if (!auth.providers) auth.providers = {} - auth.providers['openai-codex'] = { - tokens: { - access_token: tokenData.access_token, - refresh_token: refreshToken, - }, - last_refresh: new Date().toISOString(), - auth_mode: 'chatgpt', - } - - // Add to credential pool - if (!auth.credential_pool) auth.credential_pool = {} - auth.credential_pool['openai-codex'] = [{ - id: `openai-codex-${Date.now()}`, - label: 'OpenAI Codex', - base_url: CODEX_DEFAULT_BASE_URL, - access_token: tokenData.access_token, - last_status: null, - }] - - saveAuthJson(authPath, auth) - - // Save to ~/.codex/auth.json for CLI sync - saveCodexCliTokens(tokenData.access_token, refreshToken) - - console.log('[Codex Auth] Login successful') - return - } - - if (pollRes.status === 403 || pollRes.status === 404) { - // Not yet authorized, keep polling - continue - } - - // Other error status - console.error('[Codex Auth] Poll failed:', pollRes.status) - session.status = 'error' - session.error = `Poll failed: ${pollRes.status}` - return - } catch (err: any) { - if (err.name === 'TimeoutError' || err.name === 'AbortError') { - continue - } - console.error('[Codex Auth] Poll error:', err.message) - session.status = 'error' - session.error = err.message - return - } - } - - // Timeout - session.status = 'expired' -} - -// --- Routes --- export const codexAuthRoutes = new Router() -codexAuthRoutes.post('/api/hermes/auth/codex/start', async (ctx) => { - try { - cleanupExpiredSessions() - - // Step 1: Request device code - const res = await fetch(CODEX_DEVICE_AUTH_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'node-fetch', - }, - body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), - signal: AbortSignal.timeout(10000), - }) - - if (!res.ok) { - let errorBody: any = null - try { errorBody = await res.json() } catch { /* ignore */ } - console.error(`[codex-auth] Device code request failed: ${res.status}`, errorBody) - - let errorMessage = `Device code request failed: ${res.status}` - if (errorBody?.error?.code === 'unsupported_country_region_territory') { - errorMessage = 'OpenAI does not support your region. You may need to use a proxy or VPN to access Codex.' - } - - ctx.status = 502 - ctx.body = { error: errorMessage, code: errorBody?.error?.code } - return - } - - const data = await res.json() as { user_code: string; device_auth_id: string; interval?: string } - - const sessionId = randomUUID() - const session: CodexSession = { - id: sessionId, - userCode: data.user_code, - deviceAuthId: data.device_auth_id, - status: 'pending', - createdAt: Date.now(), - } - sessions.set(sessionId, session) - - // Start background worker - const authPath = getActiveAuthPath() - codexLoginWorker(session, authPath).catch(err => { - console.error('[Codex Auth] Worker error:', err) - session.status = 'error' - session.error = err.message - }) - - ctx.body = { - session_id: sessionId, - user_code: data.user_code, - verification_url: CODEX_VERIFICATION_URL, - expires_in: 900, // 15 minutes - } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', async (ctx) => { - const session = sessions.get(ctx.params.sessionId) - if (!session) { - ctx.status = 404 - ctx.body = { error: 'Session not found' } - return - } - - ctx.body = { - status: session.status, - error: session.error || null, - } -}) - -codexAuthRoutes.get('/api/hermes/auth/codex/status', async (ctx) => { - try { - const authPath = getActiveAuthPath() - const auth = loadAuthJson(authPath) - const tokens = auth.providers?.['openai-codex']?.tokens - - if (!tokens?.access_token || !auth.providers) { - ctx.body = { authenticated: false } - return - } - - const codexProvider = auth.providers['openai-codex']! - - // Check if token is expired - const exp = decodeJwtExp(tokens.access_token) - if (exp && exp <= Date.now() / 1000 + 120) { - // Try refresh - if (tokens.refresh_token) { - try { - const refreshRes = await fetch(CODEX_OAUTH_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: tokens.refresh_token, - client_id: CODEX_CLIENT_ID, - }).toString(), - signal: AbortSignal.timeout(15000), - }) - - if (refreshRes.ok) { - const newTokens = await refreshRes.json() as { access_token: string; refresh_token?: string } - codexProvider.tokens.access_token = newTokens.access_token - if (newTokens.refresh_token) { - codexProvider.tokens.refresh_token = newTokens.refresh_token - } - codexProvider.last_refresh = new Date().toISOString() - saveAuthJson(authPath, auth) - saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || tokens.refresh_token) - - // Update credential pool too - if (auth.credential_pool?.['openai-codex']?.[0]) { - auth.credential_pool['openai-codex'][0].access_token = newTokens.access_token - saveAuthJson(authPath, auth) - } - - ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh } - return - } - } catch { - // Refresh failed - } - } - - ctx.body = { authenticated: false } - return - } - - ctx.body = { - authenticated: true, - last_refresh: codexProvider.last_refresh, - } - } catch { - ctx.body = { authenticated: false } - } -}) +codexAuthRoutes.post('/api/hermes/auth/codex/start', ctrl.start) +codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', ctrl.poll) +codexAuthRoutes.get('/api/hermes/auth/codex/status', ctrl.status) diff --git a/packages/server/src/routes/hermes/config.ts b/packages/server/src/routes/hermes/config.ts index fb49476..8c41945 100644 --- a/packages/server/src/routes/hermes/config.ts +++ b/packages/server/src/routes/hermes/config.ts @@ -1,330 +1,8 @@ import Router from '@koa/router' -import { readFile, writeFile, copyFile } from 'fs/promises' -import { chmod } from 'fs/promises' -import { join } from 'path' -import YAML from 'js-yaml' -import { restartGateway } from '../../services/hermes/hermes-cli' -import { getActiveConfigPath, getActiveEnvPath, getActiveProfileDir } from '../../services/hermes/hermes-profile' - -// Platform sections that require gateway restart after config change -const PLATFORM_SECTIONS = new Set([ - 'telegram', 'discord', 'slack', 'whatsapp', 'matrix', - 'weixin', 'wecom', 'feishu', 'dingtalk', -]) - -const configPath = () => getActiveConfigPath() -const envPath = () => getActiveEnvPath() - -// Env var → (platform, configPath in PlatformConfig) mapping -// Matches hermes _apply_env_overrides() in gateway/config.py -const envPlatformMap: Record = { - TELEGRAM_BOT_TOKEN: ['telegram', 'token'], - DISCORD_BOT_TOKEN: ['discord', 'token'], - SLACK_BOT_TOKEN: ['slack', 'token'], - MATRIX_ACCESS_TOKEN: ['matrix', 'token'], - MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'], - FEISHU_APP_ID: ['feishu', 'extra.app_id'], - FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'], - DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'], - DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'], - // DingTalk has no _apply_env_overrides entry in hermes; - // the adapter reads these env vars directly at runtime. - DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'], - WECOM_BOT_ID: ['wecom', 'extra.bot_id'], - WECOM_SECRET: ['wecom', 'extra.secret'], - WEIXIN_TOKEN: ['weixin', 'token'], - WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'], - WEIXIN_BASE_URL: ['weixin', 'extra.base_url'], - WHATSAPP_ENABLED: ['whatsapp', 'enabled'], -} - -// Reverse map: (platform, configPath) → env var -const platformEnvMap: Record> = {} -for (const [envVar, [platform, configPath]] of Object.entries(envPlatformMap)) { - if (!platformEnvMap[platform]) platformEnvMap[platform] = {} - platformEnvMap[platform][configPath] = envVar -} - -function parseEnv(raw: string): Record { - const env: Record = {} - for (const line of raw.split('\n')) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) continue - const eqIdx = trimmed.indexOf('=') - if (eqIdx === -1) continue - const key = trimmed.slice(0, eqIdx).trim() - const val = trimmed.slice(eqIdx + 1).trim() - if (val) env[key] = val - } - return env -} - -function setNested(obj: Record, path: string, value: any) { - const parts = path.split('.') - let cur = obj - for (let i = 0; i < parts.length - 1; i++) { - if (!cur[parts[i]]) cur[parts[i]] = {} - cur = cur[parts[i]] - } - cur[parts[parts.length - 1]] = value -} - -function getNested(obj: Record, path: string): any { - const parts = path.split('.') - let cur = obj - for (const p of parts) { - if (!cur || typeof cur !== 'object') return undefined - cur = cur[p] - } - return cur -} - -function deepMerge(target: Record, source: Record): Record { - for (const key of Object.keys(source)) { - if ( - source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && - target[key] && typeof target[key] === 'object' && !Array.isArray(target[key]) - ) { - target[key] = deepMerge(target[key], source[key]) - } else { - target[key] = source[key] - } - } - return target -} - -async function readEnvPlatforms(): Promise> { - try { - const raw = await readFile(envPath(), 'utf-8') - const env = parseEnv(raw) - const platforms: Record = {} - for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) { - const val = env[envKey] - if (val === undefined || val === '') continue - if (!platforms[platform]) platforms[platform] = {} - let finalVal: any = val - if (cfgPath === 'enabled') finalVal = val === 'true' - setNested(platforms[platform], cfgPath, finalVal) - } - return platforms - } catch { - return {} - } -} - -// Write a KEY=value to .env (matching hermes save_env_value behavior) -// If value is empty, remove the line instead -async function saveEnvValue(key: string, value: string): Promise { - let raw: string - try { - raw = await readFile(envPath(), 'utf-8') - } catch { - raw = '' - } - - const remove = !value - const lines = raw.split('\n') - let found = false - const result: string[] = [] - - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('#')) { - // Check if there's a commented-out version of this key - if (trimmed.startsWith(`# ${key}=`)) { - if (!remove) { - result.push(`${key}=${value}`) - } - found = true - } else { - result.push(line) - } - } else { - const eqIdx = trimmed.indexOf('=') - if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) { - if (!remove) { - result.push(`${key}=${value}`) - } - found = true - } else { - result.push(line) - } - } - } - - if (!found && !remove) { - result.push(`${key}=${value}`) - } - - // Remove trailing empty lines, keep exactly one trailing newline - let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' - await writeFile(envPath(), output, 'utf-8') - // Set permissions to 0600 (owner only), matching hermes behavior - try { await chmod(envPath(), 0o600) } catch { /* ignore */ } -} - -async function readConfig(): Promise> { - const raw = await readFile(configPath(), 'utf-8') - return (YAML.load(raw) as Record) || {} -} - -async function writeConfig(data: Record): Promise { - const cp = configPath() - await copyFile(cp, cp + '.bak') - const yamlStr = YAML.dump(data, { - lineWidth: -1, - noRefs: true, - quotingType: '"', - forceQuotes: false, - }) - await writeFile(cp, yamlStr, 'utf-8') -} +import * as ctrl from '../../controllers/hermes/config' export const configRoutes = new Router() -// GET /api/config — read config sections -configRoutes.get('/api/hermes/config', async (ctx) => { - try { - const config = await readConfig() - // Merge .env platform credentials into platforms section - const envPlatforms = await readEnvPlatforms() - if (Object.keys(envPlatforms).length > 0) { - // Deep-merge: env values fill in missing, don't overwrite config.yaml - const existing = config.platforms || {} - for (const [platform, vals] of Object.entries(envPlatforms)) { - existing[platform] = { ...(existing[platform] || {}), ...(vals as Record) } - } - config.platforms = existing - } - const { section, sections } = ctx.query - - if (section) { - ctx.body = { [section as string]: config[section as string] || {} } - } else if (sections) { - const keys = (sections as string).split(',') - const result: Record = {} - for (const key of keys) { - result[key.trim()] = config[key.trim()] || {} - } - ctx.body = result - } else { - ctx.body = config - } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// PUT /api/config — update a config section (writes to config.yaml) -configRoutes.put('/api/hermes/config', async (ctx) => { - const { section, values } = ctx.request.body as { - section: string - values: Record - } - - if (!section || !values) { - ctx.status = 400 - ctx.body = { error: 'Missing section or values' } - return - } - - try { - const config = await readConfig() - config[section] = deepMerge(config[section] || {}, values) - await writeConfig(config) - // Restart gateway for platform/channel config changes - if (PLATFORM_SECTIONS.has(section)) { - await restartGateway() - } - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// PUT /api/config/credentials — save platform credentials to .env -// Body: { platform: string, values: Record } -// values keys match PlatformConfig paths: 'token', 'extra.app_id', 'extra.app_secret', etc. -configRoutes.put('/api/hermes/config/credentials', async (ctx) => { - const { platform, values } = ctx.request.body as { - platform: string - values: Record - } - - if (!platform || !values) { - ctx.status = 400 - ctx.body = { error: 'Missing platform or values' } - return - } - - try { - const envMap = platformEnvMap[platform] - if (!envMap) { - ctx.status = 400 - ctx.body = { error: `Unknown platform: ${platform}` } - return - } - - // Also clean up config.yaml platforms. to keep in sync - const config = await readConfig() - let configChanged = false - - // Flatten nested values: { extra: { app_id: '' } } → { 'extra.app_id': '' } - const flatValues: Record = {} - for (const [key, val] of Object.entries(values)) { - if (key === 'extra' && val && typeof val === 'object') { - for (const [subKey, subVal] of Object.entries(val as Record)) { - flatValues[`extra.${subKey}`] = subVal - } - } else { - flatValues[key] = val - } - } - - for (const [cfgPath, val] of Object.entries(flatValues)) { - const envVar = envMap[cfgPath] - if (!envVar) continue - if (val === undefined || val === null || val === '') { - await saveEnvValue(envVar, '') - // Remove from config.yaml too - const parts = cfgPath.split('.') - let obj: any = config.platforms?.[platform] - if (obj) { - if (parts.length === 1) { - delete obj[parts[0]] - } else { - let cur = obj - for (let i = 0; i < parts.length - 1; i++) { - if (!cur[parts[i]]) break - cur = cur[parts[i]] - } - delete cur[parts[parts.length - 1]] - // Clean up empty extra - if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra - } - if (Object.keys(obj).length === 0) { - if (!config.platforms) config.platforms = {} - delete config.platforms[platform] - } - configChanged = true - } - } else { - await saveEnvValue(envVar, String(val)) - } - } - - if (configChanged) { - await writeConfig(config) - } - - // Restart gateway for platform credential changes - await restartGateway() - - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) +configRoutes.get('/api/hermes/config', ctrl.getConfig) +configRoutes.put('/api/hermes/config', ctrl.updateConfig) +configRoutes.put('/api/hermes/config/credentials', ctrl.updateCredentials) diff --git a/packages/server/src/routes/hermes/filesystem.ts b/packages/server/src/routes/hermes/filesystem.ts deleted file mode 100644 index 86c6ee4..0000000 --- a/packages/server/src/routes/hermes/filesystem.ts +++ /dev/null @@ -1,817 +0,0 @@ -import Router from '@koa/router' -import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises' -import { existsSync, readFileSync } from 'fs' -import { join, resolve } from 'path' -import YAML from 'js-yaml' -import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile' -import * as hermesCli from '../../services/hermes/hermes-cli' - -// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) --- -// Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL } -const PROVIDER_ENV_MAP: Record = { - openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: '' }, - zai: { api_key_env: 'GLM_API_KEY', base_url_env: '' }, - 'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' }, - moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: '' }, - minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: '' }, - 'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: '' }, - deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: '' }, - alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: '' }, - anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' }, - xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' }, - xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' }, - gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' }, - kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: '' }, - 'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' }, - 'opencode-zen': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' }, - 'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' }, - huggingface: { api_key_env: 'HF_TOKEN', base_url_env: '' }, - arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' }, - 'openai-codex': { api_key_env: '', base_url_env: '' }, -} - -async function saveEnvValue(key: string, value: string): Promise { - const envPath = getActiveEnvPath() - let raw: string - try { - raw = await readFile(envPath, 'utf-8') - } catch { - raw = '' - } - const remove = !value - const lines = raw.split('\n') - let found = false - const result: string[] = [] - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) { - if (!remove) result.push(`${key}=${value}`) - found = true - } else { - const eqIdx = trimmed.indexOf('=') - if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) { - if (!remove) result.push(`${key}=${value}`) - found = true - } else { - result.push(line) - } - } - } - if (!found && !remove) { - result.push(`${key}=${value}`) - } - let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' - await writeFile(envPath, output, 'utf-8') -} - -// --- Auth / Credential Pool --- - -async function fetchProviderModels(baseUrl: string, apiKey: string): Promise { - try { - const url = baseUrl.replace(/\/+$/, '') + '/models' - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` }, - signal: AbortSignal.timeout(8000), - }) - if (!res.ok) { - console.warn(`[available-models] ${baseUrl} returned ${res.status}`) - return [] - } - const data = await res.json() as { data?: Array<{ id: string }> } - if (!Array.isArray(data.data)) { - console.warn(`[available-models] ${baseUrl} returned unexpected format`) - return [] - } - return data.data.map(m => m.id).sort() - } catch (err: any) { - console.error(`[available-models] ${baseUrl} failed: ${err.message}`) - return [] - } -} - -// --- Hardcoded model catalogs (single source: src/shared/providers.ts) --- -import { buildProviderModelMap } from '../../shared/providers' -const PROVIDER_MODEL_CATALOG = buildProviderModelMap() - -export const fsRoutes = new Router() - -const hermesDir = () => getActiveProfileDir() - -// --- Types --- - -interface SkillInfo { - name: string - description: string - enabled: boolean -} - -interface SkillCategory { - name: string - description: string - skills: SkillInfo[] -} - -// --- Helpers --- - -function extractDescription(content: string): string { - const lines = content.split('\n') - let inFrontmatter = false - let bodyStarted = false - - for (const line of lines) { - if (!bodyStarted && line.trim() === '---') { - if (!inFrontmatter) { - inFrontmatter = true - continue - } else { - inFrontmatter = false - bodyStarted = true - continue - } - } - if (inFrontmatter) continue - if (line.trim() === '') continue - if (line.startsWith('#')) continue - return line.trim().slice(0, 80) - } - return '' -} - -async function safeReadFile(filePath: string): Promise { - try { - return await readFile(filePath, 'utf-8') - } catch { - return null - } -} - -async function safeStat(filePath: string): Promise<{ mtime: number } | null> { - try { - const s = await stat(filePath) - return { mtime: Math.round(s.mtimeMs) } - } catch { - return null - } -} - -// --- Config YAML helpers --- - -const configPath = () => getActiveConfigPath() - -async function readConfigYaml(): Promise> { - const raw = await safeReadFile(configPath()) - if (!raw) return {} - return (YAML.load(raw) as Record) || {} -} - -async function writeConfigYaml(config: Record): Promise { - const cp = configPath() - await copyFile(cp, cp + '.bak') - const yamlStr = YAML.dump(config, { - lineWidth: -1, - noRefs: true, - quotingType: '"', - }) - await writeFile(cp, yamlStr, 'utf-8') -} - -// --- Skills Routes --- - -// List all skills grouped by category -fsRoutes.get('/api/hermes/skills', async (ctx) => { - const skillsDir = join(hermesDir(), 'skills') - - try { - // Read disabled skills list from config.yaml - const config = await readConfigYaml() - const disabledList: string[] = config.skills?.disabled || [] - - const entries = await readdir(skillsDir, { withFileTypes: true }) - const categories: SkillCategory[] = [] - - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue - - const catDir = join(skillsDir, entry.name) - const catDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md')) - const catDescription = catDesc ? catDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : '' - - const skillEntries = await readdir(catDir, { withFileTypes: true }) - const skills: SkillInfo[] = [] - - for (const se of skillEntries) { - if (!se.isDirectory()) continue - const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md')) - if (skillMd) { - skills.push({ - name: se.name, - description: extractDescription(skillMd), - enabled: !disabledList.includes(se.name), - }) - } - } - - if (skills.length > 0) { - categories.push({ name: entry.name, description: catDescription, skills }) - } - } - - categories.sort((a, b) => a.name.localeCompare(b.name)) - for (const cat of categories) { - cat.skills.sort((a, b) => a.name.localeCompare(b.name)) - } - - ctx.body = { categories } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: `Failed to read skills directory: ${err.message}` } - } -}) - -// Toggle skill enabled/disabled via config.yaml skills.disabled -fsRoutes.put('/api/hermes/skills/toggle', async (ctx) => { - const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean } - - if (!name || typeof enabled !== 'boolean') { - ctx.status = 400 - ctx.body = { error: 'Missing name or enabled flag' } - return - } - - try { - const config = await readConfigYaml() - if (!config.skills) config.skills = {} - if (!Array.isArray(config.skills.disabled)) config.skills.disabled = [] - - const disabled = config.skills.disabled as string[] - const idx = disabled.indexOf(name) - - if (enabled) { - // Enable: remove from disabled list - if (idx !== -1) disabled.splice(idx, 1) - } else { - // Disable: add to disabled list - if (idx === -1) disabled.push(name) - } - - await writeConfigYaml(config) - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// List files in a skill directory -async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> { - const result: { path: string; name: string }[] = [] - let entries - try { - entries = await readdir(dir, { withFileTypes: true }) - } catch { - return result - } - for (const entry of entries) { - const relPath = prefix ? `${prefix}/${entry.name}` : entry.name - if (entry.isDirectory()) { - result.push(...await listFilesRecursive(join(dir, entry.name), relPath)) - } else { - result.push({ path: relPath, name: entry.name }) - } - } - return result -} - -fsRoutes.get('/api/hermes/skills/:category/:skill/files', async (ctx) => { - const { category, skill } = ctx.params - const skillDir = join(hermesDir(), 'skills', category, skill) - - try { - const allFiles = await listFilesRecursive(skillDir, '') - const files = allFiles.filter(f => f.path !== 'SKILL.md') - ctx.body = { files } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// 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 hd = hermesDir() - const fullPath = resolve(join(hd, 'skills', filePath)) - - if (!fullPath.startsWith(join(hd, 'skills'))) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } - return - } - - const content = await safeReadFile(fullPath) - if (content === null) { - ctx.status = 404 - ctx.body = { error: 'File not found' } - return - } - - ctx.body = { content } -}) - -// --- Memory Routes --- - -fsRoutes.get('/api/hermes/memory', async (ctx) => { - const hd = hermesDir() - const memoryPath = join(hd, 'memories', 'MEMORY.md') - const userPath = join(hd, 'memories', 'USER.md') - const soulPath = join(hd, 'SOUL.md') - - const [memory, user, soul, memoryStat, userStat, soulStat] = await Promise.all([ - safeReadFile(memoryPath), - safeReadFile(userPath), - safeReadFile(soulPath), - safeStat(memoryPath), - safeStat(userPath), - safeStat(soulPath), - ]) - - ctx.body = { - memory: memory || '', - user: user || '', - soul: soul || '', - memory_mtime: memoryStat?.mtime || null, - user_mtime: userStat?.mtime || null, - soul_mtime: soulStat?.mtime || null, - } -}) - -fsRoutes.post('/api/hermes/memory', async (ctx) => { - const { section, content } = ctx.request.body as { section: string; content: string } - - if (!section || !content) { - ctx.status = 400 - ctx.body = { error: 'Missing section or content' } - return - } - - if (section !== 'memory' && section !== 'user' && section !== 'soul') { - ctx.status = 400 - ctx.body = { error: 'Section must be "memory", "user", or "soul"' } - return - } - - let filePath: string - if (section === 'soul') { - filePath = join(hermesDir(), 'SOUL.md') - } else { - const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md' - filePath = join(hermesDir(), 'memories', fileName) - } - - try { - await writeFile(filePath, content, 'utf-8') - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// --- Config Model Routes --- - -interface ModelInfo { - id: string - label: string -} - -interface ModelGroup { - provider: string - models: ModelInfo[] -} - -// Build model list from user's actual config.yaml using js-yaml -function buildModelGroups(config: Record): { default: string; groups: ModelGroup[] } { - let defaultModel = '' - const groups: ModelGroup[] = [] - const allModelIds = new Set() - - // 1. Extract current model - const modelSection = config.model - if (typeof modelSection === 'object' && modelSection !== null) { - defaultModel = String(modelSection.default || '').trim() - } else if (typeof modelSection === 'string') { - defaultModel = modelSection.trim() - } - - // 2. Extract custom_providers section - const customProviders = config.custom_providers - if (Array.isArray(customProviders)) { - const customModels: ModelInfo[] = [] - for (const entry of customProviders) { - if (entry && typeof entry === 'object') { - const cName = String(entry.name || '').trim() - const cModel = String(entry.model || '').trim() - if (cName && cModel) { - customModels.push({ id: cModel, label: `${cName}: ${cModel}` }) - allModelIds.add(cModel) - } - } - } - if (customModels.length > 0) { - groups.push({ provider: 'Custom', models: customModels }) - } - } - - return { default: defaultModel, groups } -} - -// GET /api/available-models — resolve models from .env authorized providers + credential pool + custom providers -fsRoutes.get('/api/hermes/available-models', async (ctx) => { - try { - const config = await readConfigYaml() - const modelSection = config.model - let currentDefault = '' - let currentDefaultProvider = '' - if (typeof modelSection === 'object' && modelSection !== null) { - currentDefault = String(modelSection.default || '').trim() - currentDefaultProvider = String(modelSection.provider || '').trim() - } else if (typeof modelSection === 'string') { - currentDefault = modelSection.trim() - } - - const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string }> = [] - const seenProviders = new Set() - - // 1. Read .env to discover authorized providers via PROVIDER_ENV_MAP - let envContent = '' - try { - envContent = await readFile(getActiveEnvPath(), 'utf-8') - } catch { } - - const envHasValue = (key: string): boolean => { - if (!key) return false - const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) - return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#') - } - - const envGetValue = (key: string): string => { - if (!key) return '' - const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) - return match?.[1]?.trim() || '' - } - - const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string) => { - if (seenProviders.has(provider)) return - seenProviders.add(provider) - groups.push({ provider, label, base_url, models: [...models], api_key }) - } - - // Import PROVIDER_PRESETS for label + base_url lookup - const { PROVIDER_PRESETS } = await import('../../shared/providers') - - // 1. Authorized providers from .env + OAuth-based providers (no api_key_env) - // Check OAuth auth (e.g. openai-codex) via auth.json - const isOAuthAuthorized = (providerKey: string): boolean => { - try { - const authPath = getActiveAuthPath() - if (!existsSync(authPath)) return false - const auth = JSON.parse(readFileSync(authPath, 'utf-8')) - return !!auth.providers?.[providerKey]?.tokens?.access_token - } catch { - return false - } - } - - for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) { - // Skip providers that require API key but don't have one configured - if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue - // Skip OAuth providers that haven't been authenticated - if (!envMapping.api_key_env && !isOAuthAuthorized(providerKey)) continue - const preset = PROVIDER_PRESETS.find(p => p.value === providerKey) - const label = preset?.label || providerKey.replace(/^custom:/, '') - const baseUrl = preset?.base_url || '' - const catalogModels = PROVIDER_MODEL_CATALOG[providerKey] - if (catalogModels && catalogModels.length > 0) { - const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : '' - addGroup(providerKey, label, baseUrl, catalogModels, apiKey) - } - } - - // 2. Custom providers from config.yaml — dynamically fetch models - const customProviders = Array.isArray(config.custom_providers) - ? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }> - : [] - - const customFetches = await Promise.allSettled( - customProviders.map(async cp => { - if (!cp.base_url) return null - const providerKey = `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}` - const baseUrl = cp.base_url.replace(/\/+$/, '') - let models = [cp.model] // always include the statically configured model - if (cp.api_key) { - try { - const fetched = await fetchProviderModels(baseUrl, cp.api_key) - if (fetched.length > 0) models = fetched - } catch { } - } - return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' } - }), - ) - - for (const result of customFetches) { - if (result.status === 'fulfilled' && result.value) { - const { providerKey, label, base_url, models, api_key: cpApiKey } = result.value - const existing = groups.find(g => g.base_url.replace(/\/+$/, '') === base_url) - if (existing) { - for (const m of models) { - if (!existing.models.includes(m)) existing.models.push(m) - } - } else { - addGroup(providerKey, label, base_url, models, cpApiKey) - } - } - } - - // Deduplicate models within each group - for (const g of groups) { - g.models = Array.from(new Set(g.models)) - } - - // Fallback: if still no providers, fall back to config.yaml parsing - if (groups.length === 0) { - const fallback = buildModelGroups(config) - const allProviders = PROVIDER_PRESETS.map(p => ({ - provider: p.value, - label: p.label, - base_url: p.base_url, - models: p.models, - })) - ctx.body = { ...fallback, allProviders } - return - } - - const allProviders = PROVIDER_PRESETS.map(p => ({ - provider: p.value, - label: p.label, - base_url: p.base_url, - models: p.models, - })) - - ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// GET /api/config/models -fsRoutes.get('/api/hermes/config/models', async (ctx) => { - try { - const config = await readConfigYaml() - ctx.body = buildModelGroups(config) - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// PUT /api/config/model -fsRoutes.put('/api/hermes/config/model', async (ctx) => { - const { default: defaultModel, provider: reqProvider } = ctx.request.body as { - default: string - provider?: string - } - - if (!defaultModel) { - ctx.status = 400 - ctx.body = { error: 'Missing default model' } - return - } - - try { - const config = await readConfigYaml() - - if (typeof config.model !== 'object' || config.model === null) { - config.model = {} - } - - config.model.default = defaultModel - if (reqProvider) { - config.model.provider = reqProvider - } - - await writeConfigYaml(config) - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// POST /api/config/providers -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 - api_key: string - model: string - providerKey?: string | null - } - - if (!name || !base_url || !model) { - ctx.status = 400 - ctx.body = { error: 'Missing name, base_url, or model' } - return - } - - if (!api_key) { - ctx.status = 400 - ctx.body = { error: 'Missing API key' } - return - } - - try { - // Determine if this is a built-in provider or a custom one - const poolKey = providerKey - || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}` - const isBuiltin = poolKey in PROVIDER_ENV_MAP - - if (!isBuiltin) { - // Custom provider: write to config.yaml custom_providers - const config = await readConfigYaml() - if (!Array.isArray(config.custom_providers)) { - config.custom_providers = [] - } - config.custom_providers.push({ name, base_url, api_key, model }) - await writeConfigYaml(config) - } - - // Write API key to .env (built-in providers only) - const envMapping = PROVIDER_ENV_MAP[poolKey] || PROVIDER_ENV_MAP[providerKey || ''] - if (envMapping) { - await saveEnvValue(envMapping.api_key_env, api_key) - if (envMapping.base_url_env) { - await saveEnvValue(envMapping.base_url_env, base_url) - } - } - - // Auto-switch model to the newly added provider - const config2 = await readConfigYaml() - if (typeof config2.model !== 'object' || config2.model === null) { - config2.model = {} - } - config2.model.default = model - config2.model.provider = poolKey - await writeConfigYaml(config2) - - // Restart gateway to pick up .env and config.yaml changes - try { - await hermesCli.restartGateway() - } catch (e: any) { - console.error('[Provider] Gateway restart failed:', e.message) - } - - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// PUT /api/config/providers/:poolKey — update existing provider -fsRoutes.put('/api/hermes/config/providers/:poolKey', async (ctx) => { - const poolKey = decodeURIComponent(ctx.params.poolKey) - const { name, base_url, api_key, model } = ctx.request.body as { - name?: string - base_url?: string - api_key?: string - model?: string - } - - try { - const isCustom = poolKey.startsWith('custom:') - - if (isCustom) { - // Update custom provider in config.yaml - const config = await readConfigYaml() - if (!Array.isArray(config.custom_providers)) { - ctx.status = 404 - ctx.body = { error: `Custom provider "${poolKey}" not found` } - return - } - const entry = (config.custom_providers as any[]).find((e: any) => { - const key = `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` - return key === poolKey - }) - if (!entry) { - ctx.status = 404 - ctx.body = { error: `Custom provider "${poolKey}" not found` } - return - } - if (name !== undefined) entry.name = name - if (base_url !== undefined) entry.base_url = base_url - if (api_key !== undefined) entry.api_key = api_key - if (model !== undefined) entry.model = model - await writeConfigYaml(config) - } else { - // Built-in provider: update API key in .env - const envMapping = PROVIDER_ENV_MAP[poolKey] - if (!envMapping?.api_key_env) { - // OAuth provider — cannot update key - ctx.status = 400 - ctx.body = { error: `Cannot update credentials for "${poolKey}"` } - return - } - if (api_key !== undefined) { - await saveEnvValue(envMapping.api_key_env, api_key) - } - } - - // Restart gateway to pick up changes - try { - await hermesCli.restartGateway() - } catch (e: any) { - console.error('[Provider] Gateway restart failed:', e.message) - } - - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// DELETE /api/config/providers/:poolKey -fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => { - const poolKey = decodeURIComponent(ctx.params.poolKey) - - try { - const config = await readConfigYaml() - const isCustom = poolKey.startsWith('custom:') - - if (isCustom) { - // Delete from config.yaml custom_providers - const idx = Array.isArray(config.custom_providers) - ? (config.custom_providers as any[]).findIndex((e: any) => { - const key = `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` - return key === poolKey - }) - : -1 - if (idx === -1) { - ctx.status = 404 - ctx.body = { error: `Custom provider "${poolKey}" not found` } - return - } - (config.custom_providers as any[]).splice(idx, 1) - await writeConfigYaml(config) - } else { - // Built-in provider: remove API key from .env - const envMapping = PROVIDER_ENV_MAP[poolKey] - if (envMapping?.api_key_env) { - await saveEnvValue(envMapping.api_key_env, '') - } else if (!envMapping?.api_key_env) { - // OAuth provider (e.g. openai-codex): clear tokens from auth.json - try { - const authPath = getActiveAuthPath() - if (existsSync(authPath)) { - const auth = JSON.parse(readFileSync(authPath, 'utf-8')) - if (auth.providers?.[poolKey]) { - delete auth.providers[poolKey] - } - if (auth.credential_pool?.[poolKey]) { - delete auth.credential_pool[poolKey] - } - const { writeFile: wfs } = await import('fs/promises') - await wfs(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8') - } - } catch (err: any) { - console.error(`[Provider] Failed to clear OAuth tokens for ${poolKey}:`, err.message) - } - } - } - - // If was the current provider, switch to first remaining - const currentProvider = config.model?.provider - const isCurrent = currentProvider === poolKey - if (isCurrent) { - // Find fallback from .env authorized providers or remaining custom_providers - const freshConfig = await readConfigYaml() - const remaining = Array.isArray(freshConfig.custom_providers) ? freshConfig.custom_providers as any[] : [] - const fallbackCp = remaining[0] - if (fallbackCp) { - const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}` - if (typeof freshConfig.model !== 'object' || freshConfig.model === null) { - freshConfig.model = {} - } - freshConfig.model.default = fallbackCp.model - freshConfig.model.provider = fallbackKey - await writeConfigYaml(freshConfig) - } - } - - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) diff --git a/packages/server/src/routes/hermes/gateways.ts b/packages/server/src/routes/hermes/gateways.ts index 47ff165..a63fb05 100644 --- a/packages/server/src/routes/hermes/gateways.ts +++ b/packages/server/src/routes/hermes/gateways.ts @@ -1,71 +1,9 @@ import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/gateways' export const gatewayRoutes = new Router() -// Get singleton instance — set during bootstrap -let manager: any = null - -export function setGatewayManager(mgr: any) { - manager = mgr -} - -export function getGatewayManager(): any { - return manager -} - -// List all gateway statuses -gatewayRoutes.get('/api/hermes/gateways', async (ctx) => { - if (!manager) { - ctx.status = 503 - ctx.body = { error: 'GatewayManager not initialized' } - return - } - const gateways = await manager.listAll() - ctx.body = { gateways } -}) - -// Start a profile's gateway -gatewayRoutes.post('/api/hermes/gateways/:name/start', async (ctx) => { - if (!manager) { - ctx.status = 503 - ctx.body = { error: 'GatewayManager not initialized' } - return - } - const { name } = ctx.params - try { - const status = await manager.start(name) - ctx.body = { success: true, gateway: status } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// Stop a profile's gateway -gatewayRoutes.post('/api/hermes/gateways/:name/stop', async (ctx) => { - if (!manager) { - ctx.status = 503 - ctx.body = { error: 'GatewayManager not initialized' } - return - } - const { name } = ctx.params - try { - await manager.stop(name) - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// Check a profile's gateway health -gatewayRoutes.get('/api/hermes/gateways/:name/health', async (ctx) => { - if (!manager) { - ctx.status = 503 - ctx.body = { error: 'GatewayManager not initialized' } - return - } - const { name } = ctx.params - const status = await manager.detectStatus(name) - ctx.body = { gateway: status } -}) +gatewayRoutes.get('/api/hermes/gateways', ctrl.list) +gatewayRoutes.post('/api/hermes/gateways/:name/start', ctrl.start) +gatewayRoutes.post('/api/hermes/gateways/:name/stop', ctrl.stop) +gatewayRoutes.get('/api/hermes/gateways/:name/health', ctrl.health) diff --git a/packages/server/src/routes/hermes/index.ts b/packages/server/src/routes/hermes/index.ts deleted file mode 100644 index 8db08eb..0000000 --- a/packages/server/src/routes/hermes/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 { codexAuthRoutes } from './codex-auth' -import { gatewayRoutes } from './gateways' -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(codexAuthRoutes.routes()) -hermesRoutes.use(gatewayRoutes.routes()) -hermesRoutes.use(proxyRoutes.routes()) - -export { setupTerminalWebSocket, proxyMiddleware } diff --git a/packages/server/src/routes/hermes/logs.ts b/packages/server/src/routes/hermes/logs.ts index ca04401..10d535c 100644 --- a/packages/server/src/routes/hermes/logs.ts +++ b/packages/server/src/routes/hermes/logs.ts @@ -1,101 +1,7 @@ import Router from '@koa/router' -import { existsSync, statSync } from 'fs' -import { readFile } from 'fs/promises' -import { join } from 'path' -import { homedir } from 'os' -import * as hermesCli from '../../services/hermes/hermes-cli' +import * as ctrl from '../../controllers/hermes/logs' export const logRoutes = new Router() -const WEBUI_LOG_FILE = join(homedir(), '.hermes-web-ui', 'server.log') - -// List available log files -logRoutes.get('/api/hermes/logs', async (ctx) => { - const files = await hermesCli.listLogFiles() - - if (existsSync(WEBUI_LOG_FILE)) { - try { - const stat = statSync(WEBUI_LOG_FILE) - const size = stat.size > 1024 * 1024 - ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` - : `${(stat.size / 1024).toFixed(1)}KB` - const modified = stat.mtime.toLocaleString() - files.push({ name: 'webui', size, modified }) - } catch { } - } - - ctx.body = { files } -}) - -interface LogEntry { - timestamp: string - level: string - logger: string - message: string - raw: string -} - -// Parse a single log line into structured entry -function parseLine(line: string): LogEntry { - // Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message (agent log format) - let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/) - if (match) { - return { timestamp: match[1], level: match[2], logger: match[3], message: match[4], raw: line } - } - // Match: [Lark] [2026-04-19 18:46:54,864] [INFO] message (gateway log format) - match = line.match(/^\[(\S+?)\]\s+\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\]\s+\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s(.*)$/) - if (match) { - return { timestamp: match[2], level: match[3], logger: match[1], message: match[4], raw: line } - } - // Unparseable line — keep as raw entry so nothing is lost - return { timestamp: '', level: '', logger: '', message: line, raw: line } -} - -// Read log lines (parsed) -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 - const session = (ctx.query.session as string) || undefined - const since = (ctx.query.since as string) || undefined - - // Handle hermes-web-ui's own server log - if (logName === 'webui') { - try { - if (!existsSync(WEBUI_LOG_FILE)) { - ctx.body = { entries: [] } - return - } - const content = await readFile(WEBUI_LOG_FILE, 'utf-8') - const rawLines = content.split('\n') - const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines - const entries: LogEntry[] = [] - for (const line of sliced) { - if (!line.trim()) continue - entries.push(parseLine(line)) - } - ctx.body = { entries } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } - return - } - - try { - const content = await hermesCli.readLogs(logName, lines, level, session, since) - const rawLines = content.split('\n') - - const entries: (LogEntry | null)[] = [] - for (const line of rawLines) { - // Skip header lines like "--- ~/.hermes/logs/agent.log (last 100) ---" - if (line.startsWith('---') || line.trim() === '') continue - entries.push(parseLine(line)) - } - - ctx.body = { entries } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) +logRoutes.get('/api/hermes/logs', ctrl.list) +logRoutes.get('/api/hermes/logs/:name', ctrl.read) diff --git a/packages/server/src/routes/hermes/memory.ts b/packages/server/src/routes/hermes/memory.ts new file mode 100644 index 0000000..abaa86c --- /dev/null +++ b/packages/server/src/routes/hermes/memory.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/memory' + +export const memoryRoutes = new Router() + +memoryRoutes.get('/api/hermes/memory', ctrl.get) +memoryRoutes.post('/api/hermes/memory', ctrl.save) diff --git a/packages/server/src/routes/hermes/models.ts b/packages/server/src/routes/hermes/models.ts new file mode 100644 index 0000000..4e9b9f1 --- /dev/null +++ b/packages/server/src/routes/hermes/models.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/models' + +export const modelRoutes = new Router() + +modelRoutes.get('/api/hermes/available-models', ctrl.getAvailable) +modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels) +modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel) diff --git a/packages/server/src/routes/hermes/profiles.ts b/packages/server/src/routes/hermes/profiles.ts index 8a0bd67..c67378d 100644 --- a/packages/server/src/routes/hermes/profiles.ts +++ b/packages/server/src/routes/hermes/profiles.ts @@ -1,257 +1,13 @@ import Router from '@koa/router' -import { createReadStream, existsSync, unlinkSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'fs' -import { mkdir, writeFile } from 'fs/promises' -import { basename, join } from 'path' -import { tmpdir, homedir } from 'os' -import YAML from 'js-yaml' -import * as hermesCli from '../../services/hermes/hermes-cli' -import { getGatewayManager } from './gateways' +import * as ctrl from '../../controllers/hermes/profiles' 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) - - // 创建完成后启动该 profile 的网关 - const mgr = getGatewayManager() - if (mgr) { - try { await mgr.start(name) } catch (err: any) { - console.error(`[Profile] Failed to start gateway for ${name}:`, err.message) - } - } - - 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 { - // Stop gateway for this profile before deleting - const mgr = getGatewayManager() - if (mgr) { - try { await mgr.stop(name) } catch { } - } - - 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. Switch profile only (no gateway stop/restart) - const output = await hermesCli.useProfile(name) - await new Promise(r => setTimeout(r, 1000)) - - // 2. Update GatewayManager active profile - const mgr = getGatewayManager() - if (mgr) { - mgr.setActiveProfile(name) - } - - // 3. Ensure api_server config for new profile - try { - const detail = await hermesCli.getProfile(name) - console.log(`[Profile] detail.path = ${detail.path}`) - if (!existsSync(join(detail.path, 'config.yaml'))) { - // No config.yaml — run setup --reset to create full default config - try { await hermesCli.setupReset() } catch { } - } - // Create .env if target has none - const profileEnv = join(detail.path, '.env') - if (!existsSync(profileEnv)) { - writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8') - console.log(`[Profile] Created .env for: ${detail.path}`) - } - } catch (err: any) { - console.error(`[Profile] Ensure config 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 and download -profileRoutes.post('/api/hermes/profiles/:name/export', async (ctx) => { - const { name } = ctx.params - const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`) - - try { - await hermesCli.exportProfile(name, outputPath) - - if (!existsSync(outputPath)) { - ctx.status = 500 - ctx.body = { error: 'Export file not found' } - return - } - - const filename = basename(outputPath) - ctx.set('Content-Disposition', `attachment; filename="${filename}"`) - ctx.set('Content-Type', 'application/gzip') - ctx.body = createReadStream(outputPath) - - // Clean up temp file after response ends - ctx.res.on('finish', () => { - try { unlinkSync(outputPath) } catch { } - }) - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) - -// POST /api/profiles/import - Import profile from uploaded archive -profileRoutes.post('/api/hermes/profiles/import', async (ctx) => { - const contentType = ctx.get('content-type') || '' - if (!contentType.startsWith('multipart/form-data')) { - ctx.status = 400 - ctx.body = { error: 'Expected multipart/form-data' } - return - } - - const boundary = '--' + contentType.split('boundary=')[1] - if (!boundary || boundary === '--undefined') { - ctx.status = 400 - ctx.body = { error: 'Missing boundary' } - return - } - - const tmpDir = join(tmpdir(), 'hermes-import') - await mkdir(tmpDir, { recursive: true }) - - // Read raw body and parse multipart - const chunks: Buffer[] = [] - for await (const chunk of ctx.req) chunks.push(chunk) - const body = Buffer.concat(chunks).toString('latin1') - const parts = body.split(boundary).slice(1, -1) - - let archivePath = '' - - for (const part of parts) { - const headerEnd = part.indexOf('\r\n\r\n') - if (headerEnd === -1) continue - const header = part.substring(0, headerEnd) - const data = part.substring(headerEnd + 4, part.length - 2) - - const filenameMatch = header.match(/filename="([^"]+)"/) - if (!filenameMatch) continue - - const filename = filenameMatch[1] - const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' - if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue - - archivePath = join(tmpDir, filename) - await writeFile(archivePath, Buffer.from(data, 'binary')) - break - } - - if (!archivePath) { - ctx.status = 400 - ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' } - return - } - - try { - const result = await hermesCli.importProfile(archivePath) - - // Clean up temp file - try { unlinkSync(archivePath) } catch { } - - ctx.body = { success: true, message: result.trim() } - } catch (err: any) { - try { unlinkSync(archivePath) } catch { } - ctx.status = 500 - ctx.body = { error: err.message } - } -}) +profileRoutes.get('/api/hermes/profiles', ctrl.list) +profileRoutes.post('/api/hermes/profiles', ctrl.create) +profileRoutes.get('/api/hermes/profiles/:name', ctrl.get) +profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove) +profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename) +profileRoutes.put('/api/hermes/profiles/active', ctrl.switchProfile) +profileRoutes.post('/api/hermes/profiles/:name/export', ctrl.exportProfile) +profileRoutes.post('/api/hermes/profiles/import', ctrl.importProfile) diff --git a/packages/server/src/routes/hermes/providers.ts b/packages/server/src/routes/hermes/providers.ts new file mode 100644 index 0000000..be722ab --- /dev/null +++ b/packages/server/src/routes/hermes/providers.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/providers' + +export const providerRoutes = new Router() + +providerRoutes.post('/api/hermes/config/providers', ctrl.create) +providerRoutes.put('/api/hermes/config/providers/:poolKey', ctrl.update) +providerRoutes.delete('/api/hermes/config/providers/:poolKey', ctrl.remove) diff --git a/packages/server/src/routes/hermes/proxy-handler.ts b/packages/server/src/routes/hermes/proxy-handler.ts index 7418dc0..54c1d2e 100644 --- a/packages/server/src/routes/hermes/proxy-handler.ts +++ b/packages/server/src/routes/hermes/proxy-handler.ts @@ -1,6 +1,8 @@ import type { Context } from 'koa' import { config } from '../../config' -import { getGatewayManager } from './gateways' +import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' + +function getGatewayManager() { return getGatewayManagerInstance() } function isTransientGatewayError(err: any): boolean { const msg = String(err?.message || '') diff --git a/packages/server/src/routes/hermes/sessions.ts b/packages/server/src/routes/hermes/sessions.ts index 07970b7..6ee7c74 100644 --- a/packages/server/src/routes/hermes/sessions.ts +++ b/packages/server/src/routes/hermes/sessions.ts @@ -1,61 +1,9 @@ import Router from '@koa/router' -import * as hermesCli from '../../services/hermes/hermes-cli' -import { listSessionSummaries } from '../../services/hermes/sessions-db' +import * as ctrl from '../../controllers/hermes/sessions' export const sessionRoutes = new Router() -// List sessions from Hermes -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 - - try { - const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000) - ctx.body = { sessions } - return - } catch (err) { - console.warn('[Hermes Session DB] summary query failed, falling back to CLI:', err) - } - - const sessions = await hermesCli.listSessions(source, limit) - ctx.body = { sessions } -}) - -// Get single session with messages -sessionRoutes.get('/api/hermes/sessions/:id', async (ctx) => { - const session = await hermesCli.getSession(ctx.params.id) - if (!session) { - ctx.status = 404 - ctx.body = { error: 'Session not found' } - return - } - ctx.body = { session } -}) - -// Delete session from Hermes -sessionRoutes.delete('/api/hermes/sessions/:id', async (ctx) => { - const ok = await hermesCli.deleteSession(ctx.params.id) - if (!ok) { - ctx.status = 500 - ctx.body = { error: 'Failed to delete session' } - return - } - ctx.body = { ok: true } -}) - -// Rename session -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 - ctx.body = { error: 'title is required' } - return - } - const ok = await hermesCli.renameSession(ctx.params.id, title.trim()) - if (!ok) { - ctx.status = 500 - ctx.body = { error: 'Failed to rename session' } - return - } - ctx.body = { ok: true } -}) +sessionRoutes.get('/api/hermes/sessions', ctrl.list) +sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get) +sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove) +sessionRoutes.post('/api/hermes/sessions/:id/rename', ctrl.rename) diff --git a/packages/server/src/routes/hermes/skills.ts b/packages/server/src/routes/hermes/skills.ts new file mode 100644 index 0000000..2ceed86 --- /dev/null +++ b/packages/server/src/routes/hermes/skills.ts @@ -0,0 +1,9 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/skills' + +export const skillRoutes = new Router() + +skillRoutes.get('/api/hermes/skills', ctrl.list) +skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle) +skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles) +skillRoutes.get('/api/hermes/skills/{*path}', ctrl.readFile_) diff --git a/packages/server/src/routes/hermes/terminal.ts b/packages/server/src/routes/hermes/terminal.ts index c602ed4..2a531a1 100644 --- a/packages/server/src/routes/hermes/terminal.ts +++ b/packages/server/src/routes/hermes/terminal.ts @@ -3,6 +3,7 @@ import type { Server as HttpServer } from 'http' import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs' import { dirname, join } from 'path' import { getToken } from '../../services/auth' +import { logger } from '../../services/logger' let pty: any = null @@ -23,11 +24,11 @@ function ensureNodePtySpawnHelperExecutable() { accessSync(helperPath, fsConstants.X_OK) } catch { chmodSync(helperPath, 0o755) - console.log(`[Terminal] Restored execute bit for node-pty helper: ${helperPath}`) + logger.debug('Restored execute bit for node-pty helper: %s', helperPath) } } } catch (err: any) { - console.warn(`[Terminal] Could not normalize node-pty helper permissions: ${err?.message || err}`) + logger.warn(err, 'Could not normalize node-pty helper permissions') } } @@ -36,7 +37,7 @@ try { // eslint-disable-next-line @typescript-eslint/no-require-imports pty = require('node-pty') } catch (err: any) { - console.warn(`[Terminal] node-pty failed to load, terminal feature disabled (${err?.message || 'unknown error'})`) + logger.warn(err, 'node-pty failed to load, terminal feature disabled') } // ─── Shell detection ──────────────────────────────────────────── @@ -111,7 +112,7 @@ function createSession(shell: string): PtySession { export function setupTerminalWebSocket(httpServer: HttpServer) { if (!pty) { - console.warn('[Terminal] node-pty not available, skipping terminal WebSocket setup') + logger.warn('node-pty not available, skipping terminal WebSocket setup') return } @@ -176,7 +177,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) { ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode })) } conn.sessions.delete(session.id) - console.log(`[Terminal] Session ${session.id} exited (pid ${session.pid}, code ${exitCode})`) + logger.info('Session %s exited (pid %d, code %d)', session.id, session.pid, exitCode) }) } @@ -227,7 +228,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) { pid: session.pid, shell: shellName(shell), })) - console.log(`[Terminal] Session created: ${session.id} (${shellName(shell)}, pid ${session.pid})`) + logger.info('Session created: %s (%s, pid %d)', session.id, shellName(shell), session.pid) break } @@ -252,7 +253,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) { conn.outputBuffers.delete(sessionId) } - console.log(`[Terminal] Switched to session ${sessionId}`) + logger.debug('Switched to session %s', sessionId) break } @@ -268,7 +269,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) { const remaining = Array.from(conn.sessions.keys()) conn.activeSessionId = remaining.length > 0 ? remaining[0] : null } - console.log(`[Terminal] Session closed: ${sessionId}`) + logger.info('Session closed: %s', sessionId) break } @@ -290,7 +291,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) { try { session.pty.kill() } catch { } } conn.sessions.clear() - console.log(`[Terminal] Connection closed, all sessions killed`) + logger.info('Connection closed, all sessions killed') }) ws.on('error', () => { @@ -307,7 +308,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) { firstSession = createSession(defaultShell) } catch (err: any) { ws.send(JSON.stringify({ type: 'error', message: err.message })) - console.error(`[Terminal] Failed to create session: ${err.message}`) + logger.error(err, 'Failed to create session') ws.close() return } @@ -320,8 +321,8 @@ export function setupTerminalWebSocket(httpServer: HttpServer) { pid: firstSession.pid, shell: shellName(defaultShell), })) - console.log(`[Terminal] First session created: ${firstSession.id} (${shellName(defaultShell)}, pid ${firstSession.pid})`) + logger.info('First session created: %s (%s, pid %d)', firstSession.id, shellName(defaultShell), firstSession.pid) }) - console.log(`[Terminal] WebSocket ready at /terminal (shell: ${defaultShell}, transport: node-pty)`) + logger.info('WebSocket ready at /terminal (shell: %s, transport: node-pty)', defaultShell) } diff --git a/packages/server/src/routes/hermes/weixin.ts b/packages/server/src/routes/hermes/weixin.ts index 712e661..f65ce99 100644 --- a/packages/server/src/routes/hermes/weixin.ts +++ b/packages/server/src/routes/hermes/weixin.ts @@ -1,137 +1,8 @@ import Router from '@koa/router' -import axios from 'axios' -import { readFile, writeFile } from 'fs/promises' -import { chmod } from 'fs/promises' -import { resolve } from 'path' -import { restartGateway } from '../../services/hermes/hermes-cli' -import { getActiveEnvPath } from '../../services/hermes/hermes-profile' - -const envPath = () => getActiveEnvPath() -const ILINK_BASE = 'https://ilinkai.weixin.qq.com' +import * as ctrl from '../../controllers/hermes/weixin' export const weixinRoutes = new Router() -// GET /api/weixin/qrcode — fetch QR code from Tencent iLink API -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 }, - timeout: 15000, - }) - const data = res.data - if (!data || !data.qrcode) { - ctx.status = 500 - ctx.body = { error: 'Failed to get QR code' } - return - } - ctx.body = { - qrcode: data.qrcode, - qrcode_url: data.qrcode_img_content, - } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message || 'Failed to connect to iLink API' } - } -}) - -// GET /api/weixin/qrcode/status — poll QR scan status -weixinRoutes.get('/api/hermes/weixin/qrcode/status', async (ctx) => { - const qrcode = ctx.query.qrcode as string - if (!qrcode) { - ctx.status = 400 - ctx.body = { error: 'Missing qrcode parameter' } - return - } - - try { - const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, { - params: { qrcode }, - timeout: 35000, - }) - const data = res.data - const status = data?.status || 'wait' - ctx.body = { status } - - // If confirmed, return credentials so frontend can save them - if (status === 'confirmed') { - ctx.body = { - status: 'confirmed', - account_id: data.ilink_bot_id, - token: data.bot_token, - base_url: data.baseurl, - } - } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message || 'Failed to poll QR status' } - } -}) - -// POST /api/weixin/save — save weixin credentials to .env -weixinRoutes.post('/api/hermes/weixin/save', async (ctx) => { - const { account_id, token, base_url } = ctx.request.body as { - account_id: string - token: string - base_url?: string - } - - if (!account_id || !token) { - ctx.status = 400 - ctx.body = { error: 'Missing account_id or token' } - return - } - - try { - let raw: string - try { - raw = await readFile(envPath(), 'utf-8') - } catch { - raw = '' - } - - const entries: Record = { - WEIXIN_ACCOUNT_ID: account_id, - WEIXIN_TOKEN: token, - } - if (base_url) entries.WEIXIN_BASE_URL = base_url - - const lines = raw.split('\n') - const existingKeys = new Set() - - const result: string[] = [] - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('#')) { - result.push(line) - continue - } - const eqIdx = trimmed.indexOf('=') - if (eqIdx !== -1) { - const key = trimmed.slice(0, eqIdx).trim() - if (key in entries) { - result.push(`${key}=${entries[key]}`) - existingKeys.add(key) - continue - } - } - result.push(line) - } - - for (const [key, val] of Object.entries(entries)) { - if (!existingKeys.has(key)) { - result.push(`${key}=${val}`) - } - } - - let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' - const ep = envPath() - await writeFile(ep, output, 'utf-8') - try { await chmod(ep, 0o600) } catch { /* ignore */ } - await restartGateway() - - ctx.body = { success: true } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } -}) +weixinRoutes.get('/api/hermes/weixin/qrcode', ctrl.getQrcode) +weixinRoutes.get('/api/hermes/weixin/qrcode/status', ctrl.pollStatus) +weixinRoutes.post('/api/hermes/weixin/save', ctrl.save) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts new file mode 100644 index 0000000..b81ac35 --- /dev/null +++ b/packages/server/src/routes/index.ts @@ -0,0 +1,54 @@ +import type { Context, Next } from 'koa' + +// Shared route modules +import { healthRoutes } from './health' +import { webhookRoutes } from './webhook' +import { uploadRoutes } from './upload' +import { updateRoutes } from './update' + +// Hermes route modules +import { sessionRoutes } from './hermes/sessions' +import { profileRoutes } from './hermes/profiles' +import { skillRoutes } from './hermes/skills' +import { memoryRoutes } from './hermes/memory' +import { modelRoutes } from './hermes/models' +import { providerRoutes } from './hermes/providers' +import { configRoutes } from './hermes/config' +import { logRoutes } from './hermes/logs' +import { codexAuthRoutes } from './hermes/codex-auth' +import { gatewayRoutes } from './hermes/gateways' +import { weixinRoutes } from './hermes/weixin' +import { proxyRoutes, proxyMiddleware } from './hermes/proxy' + +/** + * Register all routes on the Koa app. + * Public routes are registered first, then auth middleware, + * then all protected routes. Returns the proxy middleware (must be mounted last). + */ +export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) => Promise) { + // --- Public routes (no auth required) --- + app.use(healthRoutes.routes()) + app.use(webhookRoutes.routes()) + + // --- Auth middleware: all routes below require authentication --- + app.use(requireAuth) + + // --- Protected routes (auth required) --- + app.use(uploadRoutes.routes()) + app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything) + app.use(sessionRoutes.routes()) + app.use(profileRoutes.routes()) + app.use(skillRoutes.routes()) + app.use(memoryRoutes.routes()) + app.use(modelRoutes.routes()) + app.use(providerRoutes.routes()) + app.use(configRoutes.routes()) + app.use(logRoutes.routes()) + app.use(codexAuthRoutes.routes()) + app.use(gatewayRoutes.routes()) + app.use(weixinRoutes.routes()) + app.use(proxyRoutes.routes()) + + // Proxy catch-all middleware (must be last) + return proxyMiddleware +} diff --git a/packages/server/src/routes/update.ts b/packages/server/src/routes/update.ts index 5a13445..cc0cbf2 100644 --- a/packages/server/src/routes/update.ts +++ b/packages/server/src/routes/update.ts @@ -1,33 +1,6 @@ import Router from '@koa/router' +import * as ctrl from '../controllers/update' export const updateRoutes = new Router() -updateRoutes.post('/api/hermes/update', async (ctx) => { - const isWin = process.platform === 'win32' - const cmd = isWin - ? 'cmd /c npm install -g hermes-web-ui@latest' - : 'npm install -g hermes-web-ui@latest' - - try { - const { execSync } = await import('child_process') - const output = execSync(cmd, { - encoding: 'utf-8', - timeout: 120000, - stdio: ['pipe', 'pipe', 'pipe'], - }) - ctx.body = { success: true, message: output.trim() } - - setTimeout(() => { - const { spawn } = require('child_process') - spawn(isWin ? 'cmd' : 'sh', isWin ? ['/c', 'hermes-web-ui restart'] : ['-c', 'hermes-web-ui restart'], { - detached: true, - stdio: 'ignore', - windowsHide: true, - }).unref() - process.exit(0) - }, 2000) - } catch (err: any) { - ctx.status = 500 - ctx.body = { success: false, message: err.stderr || err.message } - } -}) +updateRoutes.post('/api/hermes/update', ctrl.handleUpdate) diff --git a/packages/server/src/routes/upload.ts b/packages/server/src/routes/upload.ts index 7661bb7..0407e82 100644 --- a/packages/server/src/routes/upload.ts +++ b/packages/server/src/routes/upload.ts @@ -1,90 +1,6 @@ import Router from '@koa/router' -import { randomBytes } from 'crypto' -import { writeFile } from 'fs/promises' -import { config } from '../config' - -const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB +import * as ctrl from '../controllers/upload' export const uploadRoutes = new Router() -uploadRoutes.post('/upload', async (ctx) => { - const contentType = ctx.get('content-type') || '' - if (!contentType.startsWith('multipart/form-data')) { - ctx.status = 400 - ctx.body = { error: 'Expected multipart/form-data' } - return - } - - const boundary = '--' + contentType.split('boundary=')[1] - if (!boundary || boundary === '--undefined') { - ctx.status = 400 - ctx.body = { error: 'Missing boundary' } - return - } - - // Read raw body as Buffer with size limit - const chunks: Buffer[] = [] - let totalSize = 0 - for await (const chunk of ctx.req) { - totalSize += chunk.length - if (totalSize > MAX_UPLOAD_SIZE) { - ctx.status = 413 - ctx.body = { error: `File too large (max ${MAX_UPLOAD_SIZE / 1024 / 1024}MB)` } - return - } - chunks.push(chunk) - } - const raw = Buffer.concat(chunks) - const boundaryBuf = Buffer.from(boundary) - const parts = splitMultipart(raw, boundaryBuf) - - const results: { name: string; path: string }[] = [] - - for (const part of parts) { - const headerEnd = part.indexOf(Buffer.from('\r\n\r\n')) - if (headerEnd === -1) continue - const headerBuf = part.subarray(0, headerEnd) - const header = headerBuf.toString('utf-8') - const data = part.subarray(headerEnd + 4, part.length - 2) - - // Try RFC 5987 filename* first, fall back to filename - let filename = '' - const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i) - if (filenameStarMatch) { - filename = decodeURIComponent(filenameStarMatch[1]) - } else { - const filenameMatch = header.match(/filename="([^"]+)"/) - if (!filenameMatch) continue - filename = filenameMatch[1] - } - - const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' - const savedName = randomBytes(8).toString('hex') + ext - const savedPath = `${config.uploadDir}/${savedName}` - - await writeFile(savedPath, data) - results.push({ name: filename, path: savedPath }) - } - - ctx.body = { files: results } -}) - -/** - * Split a multipart Buffer by boundary, returning part Buffers. - * Avoids string decoding so multi-byte characters (e.g. Chinese filenames) are preserved. - */ -function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] { - const parts: Buffer[] = [] - let start = 0 - while (true) { - const idx = raw.indexOf(boundary, start) - if (idx === -1) break - if (start > 0) { - // Skip the \r\n after boundary - const partStart = start + 2 - parts.push(raw.subarray(partStart, idx)) - } - start = idx + boundary.length - } - return parts -} +uploadRoutes.post('/upload', ctrl.handleUpload) diff --git a/packages/server/src/routes/webhook.ts b/packages/server/src/routes/webhook.ts index 7e738be..f282bde 100644 --- a/packages/server/src/routes/webhook.ts +++ b/packages/server/src/routes/webhook.ts @@ -1,33 +1,6 @@ import Router from '@koa/router' -import { emitWebhook } from '../services/hermes/hermes' +import * as ctrl from '../controllers/webhook' export const webhookRoutes = new Router() -/** - * POST /webhook — receive callbacks from Hermes Agent - * - * Expected body: - * { - * "event": "run.completed" | "job.completed" | ..., - * "run_id": "...", - * "data": { ... } - * } - * - * TODO: Add signature verification when Hermes supports webhook signing - */ -webhookRoutes.post('/webhook', async (ctx) => { - const payload = ctx.request.body - - if (!payload || !payload.event) { - ctx.status = 400 - ctx.body = { error: 'Missing event field' } - return - } - - console.log(`[Webhook] Received event: ${payload.event}`) - - // Emit to registered callbacks - emitWebhook(payload) - - ctx.body = { ok: true } -}) +webhookRoutes.post('/webhook', ctrl.handleWebhook) diff --git a/packages/server/src/services/auth.ts b/packages/server/src/services/auth.ts index 770212e..fa5cf82 100644 --- a/packages/server/src/services/auth.ts +++ b/packages/server/src/services/auth.ts @@ -1,10 +1,10 @@ -import { readFile, writeFile } from 'fs/promises' +import { readFile, writeFile, mkdir } from 'fs/promises' import { join } from 'path' import { randomBytes } from 'crypto' -import { config } from '../config' +import { homedir } from 'os' -// Token stored in project data directory -const TOKEN_FILE = join(config.dataDir, '.token') +const APP_HOME = join(homedir(), '.hermes-web-ui') +const TOKEN_FILE = join(APP_HOME, '.token') function generateToken(): string { return randomBytes(32).toString('hex') @@ -14,12 +14,10 @@ function generateToken(): string { * Get or create the auth token. Returns null if auth is disabled. */ export async function getToken(): Promise { - // Auth can be disabled via env var if (process.env.AUTH_DISABLED === '1' || process.env.AUTH_DISABLED === 'true') { return null } - // Custom token via env var if (process.env.AUTH_TOKEN) { return process.env.AUTH_TOKEN } @@ -28,41 +26,36 @@ export async function getToken(): Promise { const token = await readFile(TOKEN_FILE, 'utf-8') return token.trim() } catch { - // Generate a new token const token = generateToken() + await mkdir(APP_HOME, { recursive: true }) await writeFile(TOKEN_FILE, token + '\n', { mode: 0o600 }) return token } } /** - * Koa middleware: check Authorization header for API routes. - * Skips /health, /webhook, and static file requests. + * Koa middleware: check Authorization header or query token. + * No path whitelisting — applied globally after public routes. */ -export async function authMiddleware(token: string | null) { +export function requireAuth(token: string | null) { return async (ctx: any, next: () => Promise) => { - // If auth is disabled, skip if (!token) { await next() return } - // Skip non-API paths (static files, health check, SPA) - const path = ctx.path.toLowerCase() - if ( - path === '/health' || - (!path.startsWith('/api') && !path.startsWith('/v1') && path !== '/webhook' && path !== '/upload') - ) { - await next() - return - } - const auth = ctx.headers.authorization || '' const provided = auth.startsWith('Bearer ') ? auth.slice(7) : (ctx.query.token as string) || '' if (!provided || provided !== token) { + // Skip auth for non-API paths (SPA static files) + const lowerPath = ctx.path.toLowerCase() + if (!lowerPath.startsWith('/api') && !lowerPath.startsWith('/v1') && !lowerPath.startsWith('/upload')) { + await next() + return + } ctx.status = 401 ctx.set('Content-Type', 'application/json') ctx.body = { error: 'Unauthorized' } diff --git a/packages/server/src/services/config-helpers.ts b/packages/server/src/services/config-helpers.ts new file mode 100644 index 0000000..d3fddb4 --- /dev/null +++ b/packages/server/src/services/config-helpers.ts @@ -0,0 +1,239 @@ +import { readFile, writeFile, copyFile, chmod } from 'fs/promises' +import { readdir, stat } from 'fs/promises' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import YAML from 'js-yaml' +import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from './hermes/hermes-profile' +import { logger } from './logger' + +// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) --- +export const PROVIDER_ENV_MAP: Record = { + openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: '' }, + zai: { api_key_env: 'GLM_API_KEY', base_url_env: '' }, + 'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' }, + moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: '' }, + minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: '' }, + 'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: '' }, + deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: '' }, + alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: '' }, + anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' }, + xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' }, + xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' }, + gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' }, + kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: '' }, + 'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' }, + 'opencode-zen': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' }, + 'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' }, + huggingface: { api_key_env: 'HF_TOKEN', base_url_env: '' }, + arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' }, + 'openai-codex': { api_key_env: '', base_url_env: '' }, +} + +// --- Types --- + +export interface SkillInfo { + name: string + description: string + enabled: boolean +} + +export interface SkillCategory { + name: string + description: string + skills: SkillInfo[] +} + +export interface ModelInfo { + id: string + label: string +} + +export interface ModelGroup { + provider: string + models: ModelInfo[] +} + +// --- Config YAML helpers --- + +const configPath = () => getActiveConfigPath() + +export async function readConfigYaml(): Promise> { + const raw = await safeReadFile(configPath()) + if (!raw) return {} + return (YAML.load(raw) as Record) || {} +} + +export async function writeConfigYaml(config: Record): Promise { + const cp = configPath() + await copyFile(cp, cp + '.bak') + const yamlStr = YAML.dump(config, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + }) + await writeFile(cp, yamlStr, 'utf-8') +} + +// --- .env helpers --- + +export async function saveEnvValue(key: string, value: string): Promise { + const envPath = getActiveEnvPath() + let raw: string + try { + raw = await readFile(envPath, 'utf-8') + } catch { + raw = '' + } + const remove = !value + const lines = raw.split('\n') + let found = false + const result: string[] = [] + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) { + if (!remove) result.push(`${key}=${value}`) + found = true + } else { + const eqIdx = trimmed.indexOf('=') + if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) { + if (!remove) result.push(`${key}=${value}`) + found = true + } else { + result.push(line) + } + } + } + if (!found && !remove) { + result.push(`${key}=${value}`) + } + let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' + await writeFile(envPath, output, 'utf-8') + try { await chmod(envPath, 0o600) } catch { /* ignore */ } +} + +// --- File helpers --- + +export async function safeReadFile(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8') + } catch { + return null + } +} + +export async function safeStat(filePath: string): Promise<{ mtime: number } | null> { + try { + const s = await stat(filePath) + return { mtime: Math.round(s.mtimeMs) } + } catch { + return null + } +} + +// --- Skill helpers --- + +export function extractDescription(content: string): string { + const lines = content.split('\n') + let inFrontmatter = false + let bodyStarted = false + + for (const line of lines) { + if (!bodyStarted && line.trim() === '---') { + if (!inFrontmatter) { + inFrontmatter = true + continue + } else { + inFrontmatter = false + bodyStarted = true + continue + } + } + if (inFrontmatter) continue + if (line.trim() === '') continue + if (line.startsWith('#')) continue + return line.trim().slice(0, 80) + } + return '' +} + +export async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> { + const result: { path: string; name: string }[] = [] + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return result + } + for (const entry of entries) { + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + result.push(...await listFilesRecursive(join(dir, entry.name), relPath)) + } else { + result.push({ path: relPath, name: entry.name }) + } + } + return result +} + +// --- Provider model helpers --- + +export async function fetchProviderModels(baseUrl: string, apiKey: string): Promise { + const base = baseUrl.replace(/\/+$/, '') + const modelsUrl = base.endsWith('/v1') ? `${base}/models` : `${base}/v1/models` + try { + const res = await fetch(modelsUrl, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) { + logger.warn('available-models %s returned %d', modelsUrl, res.status) + return [] + } + const data = await res.json() as { data?: Array<{ id: string }> } + if (!Array.isArray(data.data)) { + logger.warn('available-models %s returned unexpected format', modelsUrl) + return [] + } + return data.data.map(m => m.id).sort() + } catch (err: any) { + logger.error(err, 'available-models %s failed', modelsUrl) + return [] + } +} + +export function buildModelGroups(config: Record): { default: string; groups: ModelGroup[] } { + let defaultModel = '' + const groups: ModelGroup[] = [] + + // 1. Extract current model + const modelSection = config.model + if (typeof modelSection === 'object' && modelSection !== null) { + defaultModel = String(modelSection.default || '').trim() + } else if (typeof modelSection === 'string') { + defaultModel = modelSection.trim() + } + + // 2. Extract custom_providers section + const customProviders = config.custom_providers + if (Array.isArray(customProviders)) { + const customModels: ModelInfo[] = [] + for (const entry of customProviders) { + if (entry && typeof entry === 'object') { + const cName = String(entry.name || '').trim() + const cModel = String(entry.model || '').trim() + if (cName && cModel) { + customModels.push({ id: cModel, label: `${cName}: ${cModel}` }) + } + } + } + if (customModels.length > 0) { + groups.push({ provider: 'Custom', models: customModels }) + } + } + + return { default: defaultModel, groups } +} + +// --- Profile directory helper --- + +export const getHermesDir = () => getActiveProfileDir() diff --git a/packages/server/src/services/gateway-bootstrap.ts b/packages/server/src/services/gateway-bootstrap.ts index 927b818..d55dc53 100644 --- a/packages/server/src/services/gateway-bootstrap.ts +++ b/packages/server/src/services/gateway-bootstrap.ts @@ -7,12 +7,10 @@ export function getGatewayManagerInstance(): any { export async function initGatewayManager(): Promise { const { GatewayManager } = await import('./hermes/gateway-manager') const { getActiveProfileName } = await import('./hermes/hermes-profile') - const { setGatewayManager } = await import('../routes/hermes/gateways') - const activeProfile = getActiveProfileName() gatewayManager = new GatewayManager(activeProfile) - setGatewayManager(gatewayManager) await gatewayManager.detectAllOnStartup() await gatewayManager.startAll() + console.log("startall") } diff --git a/packages/server/src/services/hermes/gateway-manager.ts b/packages/server/src/services/hermes/gateway-manager.ts index a3e1365..9538f31 100644 --- a/packages/server/src/services/hermes/gateway-manager.ts +++ b/packages/server/src/services/hermes/gateway-manager.ts @@ -33,10 +33,12 @@ import { spawn, type ChildProcess } from 'child_process' import { resolve, join } from 'path' import { homedir } from 'os' -import { readFileSync, writeFileSync, existsSync } from 'fs' +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs' import { execFile } from 'child_process' import { promisify } from 'util' import { createServer } from 'net' +import yaml from 'js-yaml' +import { logger } from '../logger' const execFileAsync = promisify(execFile) @@ -48,7 +50,7 @@ const HERMES_BASE = resolve(homedir(), '.hermes') const HERMES_BIN = process.env.HERMES_BIN?.trim() || 'hermes' // WSL / Docker 没有 systemd 或 launchd,需要用 "gateway run" 代替 "gateway start" -const isWsl = existsSync('/proc/version') && require('fs').readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft') +const isWsl = existsSync('/proc/version') && readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft') const isDocker = existsSync('/.dockerenv') const needsRunMode = isWsl || isDocker @@ -110,7 +112,6 @@ export class GatewayManager { if (!existsSync(configPath)) return { port: 8642, host: '127.0.0.1' } try { - const yaml = require('js-yaml') const content = readFileSync(configPath, 'utf-8') const cfg = yaml.load(content) as any || {} @@ -225,7 +226,6 @@ export class GatewayManager { private writeProfilePort(name: string, port: number, host: string): void { const configPath = join(this.profileDir(name), 'config.yaml') try { - const yaml = require('js-yaml') const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' const cfg = (yaml.load(content) as any) || {} @@ -248,9 +248,9 @@ export class GatewayManager { } writeFileSync(configPath, yaml.dump(cfg, { lineWidth: -1 }), 'utf-8') - console.log(`[GatewayManager] Updated ${configPath}: api_server.extra.port = ${port}`) + logger.debug('Updated %s: api_server.extra.port = %d', configPath, port) } catch (err) { - console.error(`[GatewayManager] Failed to write config for profile "${name}":`, err) + logger.error(err, 'Failed to write config for profile "%s"', name) } } @@ -280,7 +280,7 @@ export class GatewayManager { if (usedPorts.has(port)) { // 已管理端口冲突 → 找空闲端口 const newPort = await this.findFreePort(port, host) - console.log(`[GatewayManager] Port ${port} is in use for profile "${name}", reassigning to ${newPort}`) + logger.info('Port %d is in use for profile "%s", reassigning to %d', port, name, newPort) this.writeProfilePort(name, newPort, host) port = newPort } else { @@ -288,7 +288,7 @@ export class GatewayManager { const available = await this.checkPortAvailable(port, host) if (!available) { const newPort = await this.findFreePort(port, host) - console.log(`[GatewayManager] Port ${port} is occupied by another process for profile "${name}", reassigning to ${newPort}`) + logger.info('Port %d is occupied by another process for profile "%s", reassigning to %d', port, name, newPort) this.writeProfilePort(name, newPort, host) port = newPort } else { @@ -354,7 +354,6 @@ export class GatewayManager { // CLI 不可用时回退到文件系统扫描 const profiles = ['default'] const profilesDir = join(HERMES_BASE, 'profiles') - const { existsSync, readdirSync } = require('fs') if (existsSync(profilesDir)) { for (const entry of readdirSync(profilesDir, { withFileTypes: true })) { if (entry.isDirectory() && existsSync(join(profilesDir, entry.name, 'config.yaml'))) { @@ -423,7 +422,7 @@ export class GatewayManager { child.unref() const pid = child.pid ?? 0 - console.log(`[GatewayManager] Starting gateway for profile "${name}" (run mode, PID: ${pid}, port: ${port})`) + logger.info('Starting gateway for profile "%s" (run mode, PID: %d, port: %d)', name, pid, port) this.waitForReady(name, pid, port, host, url) .then(resolve) @@ -432,7 +431,7 @@ export class GatewayManager { } // 正常系统:先 start,失败则 restart(处理服务已运行的情况) - console.log(`[GatewayManager] Starting gateway for profile "${name}" (start mode, port: ${port})`) + logger.info('Starting gateway for profile "%s" (start mode, port: %d)', name, port) const env = { ...process.env, HERMES_HOME: hermesHome } try { const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], { @@ -440,7 +439,7 @@ export class GatewayManager { env, windowsHide: true, }) - console.log(`[GatewayManager] gateway start output: ${stdout?.trim()}`) + logger.debug('gateway start output: %s', stdout?.trim()) } catch { // start 失败(可能服务已运行),用 restart try { @@ -449,9 +448,9 @@ export class GatewayManager { env, windowsHide: true, }) - console.log(`[GatewayManager] gateway restart output: ${stdout?.trim()}`) + logger.debug('gateway restart output: %s', stdout?.trim()) } catch (err: any) { - console.log(`[GatewayManager] gateway start/restart (non-fatal): ${err.stderr?.trim() || err.message}`) + logger.warn(err, 'gateway start/restart (non-fatal)') } } @@ -518,14 +517,14 @@ export class GatewayManager { while (Date.now() < deadline) { if (!(await this.checkHealth(url, 1000))) { this.gateways.delete(name) - console.log(`[GatewayManager] Stopped gateway for profile "${name}"`) + logger.info('Stopped gateway for profile "%s"', name) return } await new Promise(r => setTimeout(r, 300)) } // 超时也清理 this.gateways.delete(name) - console.log(`[GatewayManager] Stopped gateway for profile "${name}" (timeout)`) + logger.warn('Stopped gateway for profile "%s" (timeout)', name) } /** 停止所有已管理的网关(并行执行) */ @@ -540,15 +539,15 @@ export class GatewayManager { /** 扫描所有 profile,检测网关运行状态并注册 */ async detectAllOnStartup(): Promise { - console.log('[GatewayManager] Scanning profiles for running gateways...') + logger.info('Scanning profiles for running gateways...') const profiles = await this.listProfiles() for (const name of profiles) { const status = await this.detectStatus(name) if (status.running) { - console.log(`[GatewayManager] ✓ ${name}: running (PID: ${status.pid}, port: ${status.port})`) + logger.info('%s: running (PID: %s, port: %d)', name, status.pid, status.port) } else { - console.log(`[GatewayManager] ○ ${name}: stopped`) + logger.debug('%s: stopped', name) } } } @@ -568,14 +567,14 @@ export class GatewayManager { for (const name of profiles) { const existing = this.gateways.get(name) if (existing && this.isProcessAlive(existing.pid)) { - console.log(`[GatewayManager] ${name}: already running (PID: ${existing.pid})`) + logger.info('%s: already running (PID: %d)', name, existing.pid) continue } // 有 PID 文件但进程未在正确端口运行 → 旧进程,先停掉 const pid = this.readPidFile(name) if (pid && this.isProcessAlive(pid)) { - console.log(`[GatewayManager] ${name}: stale process (PID: ${pid}), stopping`) + logger.info('%s: stale process (PID: %d), stopping', name, pid) try { await this.stop(name) } catch { } } @@ -588,7 +587,7 @@ export class GatewayManager { try { await this.start(name) } catch (err: any) { - console.error(`[GatewayManager] ✗ ${name}: failed to start — ${err.message}`) + logger.error(err, '%s: failed to start', name) } }) diff --git a/packages/server/src/services/hermes/hermes-cli.ts b/packages/server/src/services/hermes/hermes-cli.ts index a401420..3cab5eb 100644 --- a/packages/server/src/services/hermes/hermes-cli.ts +++ b/packages/server/src/services/hermes/hermes-cli.ts @@ -1,6 +1,7 @@ -import { execFile } from 'child_process' +import { execFile, spawn } from 'child_process' import { existsSync } from 'fs' import { promisify } from 'util' +import { logger } from '../logger' const execFileAsync = promisify(execFile) @@ -126,7 +127,7 @@ export async function listSessions(source?: string, limit?: number): Promise { } } catch (err: any) { if (err.code === 1 || err.status === 1) return null - console.error('[Hermes CLI] session export failed:', err.message) + logger.error(err, 'Hermes CLI: session export failed') throw new Error(`Failed to get session: ${err.message}`) } } @@ -190,7 +191,7 @@ export async function deleteSession(id: string): Promise { }) return true } catch (err: any) { - console.error('[Hermes CLI] session delete failed:', err.message) + logger.error(err, 'Hermes CLI: session delete failed') return false } } @@ -206,7 +207,7 @@ export async function renameSession(id: string, title: string): Promise }) return true } catch (err: any) { - console.error('[Hermes CLI] session rename failed:', err.message) + logger.error(err, 'Hermes CLI: session rename failed') return false } } @@ -250,7 +251,6 @@ export async function startGateway(): Promise { * Uses "hermes gateway run" as a detached background process */ export async function startGatewayBackground(): Promise { - const { spawn } = require('child_process') as typeof import('child_process') const child = spawn(HERMES_BIN, ['gateway', 'run'], { detached: true, stdio: 'ignore', @@ -311,7 +311,7 @@ export async function listLogFiles(): Promise { } return files } catch (err: any) { - console.error('[Hermes CLI] logs list failed:', err.message) + logger.error(err, 'Hermes CLI: logs list failed') return [] } } @@ -339,7 +339,7 @@ export async function readLogs( }) return stdout } catch (err: any) { - console.error('[Hermes CLI] logs read failed:', err.message) + logger.error(err, 'Hermes CLI: logs read failed') throw new Error(`Failed to read logs: ${err.message}`) } } @@ -396,7 +396,7 @@ export async function listProfiles(): Promise { return profiles } catch (err: any) { - console.error('[Hermes CLI] profile list failed:', err.message) + logger.error(err, 'Hermes CLI: profile list failed') throw new Error(`Failed to list profiles: ${err.message}`) } } @@ -437,7 +437,7 @@ export async function getProfile(name: string): Promise { if (err.code === 1 || err.status === 1) { throw new Error(`Profile "${name}" not found`) } - console.error('[Hermes CLI] profile show failed:', err.message) + logger.error(err, 'Hermes CLI: profile show failed') throw new Error(`Failed to get profile: ${err.message}`) } } @@ -456,7 +456,7 @@ export async function createProfile(name: string, clone?: boolean): Promise { }) return true } catch (err: any) { - console.error('[Hermes CLI] profile delete failed:', err.message) + logger.error(err, 'Hermes CLI: profile delete failed') return false } } @@ -488,7 +488,7 @@ export async function renameProfile(oldName: string, newName: string): Promise { }) return stdout || stderr } catch (err: any) { - console.error('[Hermes CLI] profile use failed:', err.message) + logger.error(err, 'Hermes CLI: profile use failed') throw new Error(`Failed to switch profile: ${err.message}`) } } @@ -523,7 +523,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise< }) return stdout || stderr } catch (err: any) { - console.error('[Hermes CLI] profile export failed:', err.message) + logger.error(err, 'Hermes CLI: profile export failed') throw new Error(`Failed to export profile: ${err.message}`) } } @@ -539,7 +539,7 @@ export async function setupReset(): Promise { }) return stdout || stderr } catch (err: any) { - console.error('[Hermes CLI] setup reset failed:', err.message) + logger.error(err, 'Hermes CLI: setup reset failed') throw new Error(`Failed to reset config: ${err.message}`) } } @@ -558,7 +558,7 @@ export async function importProfile(archivePath: string, name?: string): Promise }) return stdout || stderr } catch (err: any) { - console.error('[Hermes CLI] profile import failed:', err.message) + logger.error(err, 'Hermes CLI: profile import failed') throw new Error(`Failed to import profile: ${err.message}`) } } diff --git a/packages/server/src/services/hermes/hermes.ts b/packages/server/src/services/hermes/hermes.ts index c4bb896..d9859a8 100644 --- a/packages/server/src/services/hermes/hermes.ts +++ b/packages/server/src/services/hermes/hermes.ts @@ -1,4 +1,5 @@ import { config } from '../../config' +import { logger } from '../logger' const UPSTREAM = config.upstream.replace(/\/$/, '') @@ -121,7 +122,7 @@ export function emitWebhook(payload: any) { for (const cb of webhookCallbacks) { const result = cb(payload) if (result && typeof result.catch === 'function') { - result.catch((err: Error) => console.error('Webhook callback error:', err)) + result.catch((err: Error) => logger.error(err, 'Webhook callback error')) } } } diff --git a/packages/server/src/services/logger.ts b/packages/server/src/services/logger.ts new file mode 100644 index 0000000..751b05e --- /dev/null +++ b/packages/server/src/services/logger.ts @@ -0,0 +1,32 @@ +import pino from 'pino' +import { resolve } from 'path' +import { mkdirSync, statSync, truncateSync, openSync, readSync, closeSync, writeFileSync } from 'fs' +import { homedir } from 'os' + +const MAX_LOG_SIZE = 3 * 1024 * 1024 // 3MB + +const logDir = resolve(homedir(), '.hermes-web-ui', 'logs') +mkdirSync(logDir, { recursive: true }) + +const logFile = resolve(logDir, 'server.log') + +// Rotate log if it exceeds MAX_LOG_SIZE — truncate to keep the last half +try { + const stat = statSync(logFile) + if (stat.size > MAX_LOG_SIZE) { + const keepSize = Math.floor(MAX_LOG_SIZE / 2) + const fd = openSync(logFile, 'r') + const buf = Buffer.alloc(keepSize) + readSync(fd, buf, 0, keepSize, stat.size - keepSize) + closeSync(fd) + truncateSync(logFile, 0) + writeFileSync(logFile, buf) + } +} catch {} + +export const logger = pino({ + level: process.env.LOG_LEVEL || 'info', +}, pino.destination({ + dest: logFile, + sync: false, +})) diff --git a/packages/server/src/services/shutdown.ts b/packages/server/src/services/shutdown.ts index 7350b5d..52b2baa 100644 --- a/packages/server/src/services/shutdown.ts +++ b/packages/server/src/services/shutdown.ts @@ -1,3 +1,5 @@ +import { logger } from './logger' + export function bindShutdown(server: any): void { let isShuttingDown = false @@ -5,19 +7,19 @@ export function bindShutdown(server: any): void { if (isShuttingDown) return isShuttingDown = true - console.log(`\n[${signal}] shutting down...`) + logger.info('Shutting down (%s)...', signal) try { if (server) { await new Promise((resolve) => { server.close(() => { - console.log('✓ http server closed') + logger.info('HTTP server closed') resolve() }) }) } } catch (err) { - console.error('shutdown error:', err) + logger.error(err, 'Shutdown error') } process.exit(0) @@ -26,14 +28,4 @@ export function bindShutdown(server: any): void { process.once('SIGUSR2', shutdown) process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) - - process.on('uncaughtException', (err) => { - console.error('uncaughtException:', err) - shutdown('uncaughtException') - }) - - process.on('unhandledRejection', (err) => { - console.error('unhandledRejection:', err) - shutdown('unhandledRejection') - }) } diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index 66553a6..4dd8a68 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -8,12 +8,14 @@ export interface ProviderPreset { value: string base_url: string models: string[] + builtin: boolean } export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'Anthropic', value: 'anthropic', + builtin: true, base_url: 'https://api.anthropic.com', models: [ 'claude-opus-4-7', @@ -29,6 +31,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'Google AI Studio', value: 'gemini', + builtin: true, base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', models: [ 'gemini-3.1-pro-preview', @@ -44,18 +47,28 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'DeepSeek', value: 'deepseek', + builtin: true, base_url: 'https://api.deepseek.com', models: ['deepseek-chat', 'deepseek-reasoner'], }, { label: 'Z.AI / GLM', value: 'zai', + builtin: true, base_url: 'https://api.z.ai/api/paas/v4', models: ['glm-5.1', 'glm-5', 'glm-5v-turbo', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'], }, + { + label: 'GLMCodingPlan', + value: 'glm', + builtin: true, + base_url: 'https://api.z.ai/api/anthropic', + models: ['glm-5.1', 'glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'], + }, { label: 'Kimi for Coding', value: 'kimi-coding-cn', + builtin: true, base_url: 'https://api.kimi.com/coding/v1', models: [ 'kimi-for-coding', @@ -68,6 +81,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'Moonshot', value: 'moonshot', + builtin: true, base_url: 'https://api.moonshot.cn/v1', models: [ 'kimi-k2.5', @@ -79,24 +93,28 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'xAI', value: 'xai', + builtin: true, base_url: 'https://api.x.ai/v1', models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'], }, { label: 'MiniMax', value: 'minimax', + builtin: true, base_url: 'https://api.minimax.io/anthropic/v1', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'], }, { label: 'MiniMax (China)', value: 'minimax-cn', + builtin: true, base_url: 'https://api.minimaxi.com/v1', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'], }, { label: 'Alibaba Cloud', value: 'alibaba', + builtin: true, base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', models: [ 'qwen3.5-plus', @@ -111,6 +129,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'Hugging Face', value: 'huggingface', + builtin: true, base_url: 'https://router.huggingface.co/v1', models: [ 'Qwen/Qwen3.5-397B-A17B', @@ -126,12 +145,14 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'Xiaomi MiMo', value: 'xiaomi', + builtin: true, base_url: 'https://api.xiaomimimo.com/v1', models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'], }, { label: 'Kilo Code', value: 'kilocode', + builtin: true, base_url: 'https://api.kilo.ai/api/gateway', models: [ 'anthropic/claude-opus-4.6', @@ -144,6 +165,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'Vercel AI Gateway', value: 'ai-gateway', + builtin: true, base_url: 'https://ai-gateway.vercel.sh/v1', models: [ 'anthropic/claude-opus-4.6', @@ -163,6 +185,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'OpenCode Zen', value: 'opencode-zen', + builtin: true, base_url: 'https://opencode.ai/zen/v1', models: [ 'gpt-5.4-pro', @@ -206,24 +229,28 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { label: 'OpenCode Go', value: 'opencode-go', + builtin: true, base_url: 'https://opencode.ai/zen/go/v1', models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'], }, { label: 'OpenAI Codex', value: 'openai-codex', + builtin: true, base_url: 'https://chatgpt.com/backend-api/codex', models: ['gpt-5.4-mini', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'], }, { label: 'Arcee AI', value: 'arcee', + builtin: true, base_url: 'https://api.arcee.ai/v1', models: ['trinity-large-thinking', 'trinity-large-preview', 'trinity-mini'], }, { label: 'OpenRouter', value: 'openrouter', + builtin: true, base_url: 'https://openrouter.ai/api/v1', models: [], }, diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index b51d78c..ddc0adb 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -1,14 +1,12 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2024", "module": "commonjs", - "outDir": "../../dist/server", - "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "declaration": true + "noEmit": true }, "include": ["src/**/*.ts"] } diff --git a/scripts/build-server.mjs b/scripts/build-server.mjs new file mode 100644 index 0000000..b86daa3 --- /dev/null +++ b/scripts/build-server.mjs @@ -0,0 +1,25 @@ +import * as esbuild from 'esbuild' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' +import { readFileSync } from 'fs' + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8')) +const version = pkg.version + +await esbuild.build({ + entryPoints: [resolve(rootDir, 'packages/server/src/index.ts')], + bundle: true, + platform: 'node', + target: 'node23', + format: 'cjs', + outfile: resolve(rootDir, 'dist/server/index.js'), + external: ['node-pty', 'node:sqlite'], + define: { + __APP_VERSION__: JSON.stringify(version), + }, + sourcemap: true, + minify: false, + treeShaking: false, + logLevel: 'info', +}) diff --git a/tests/client/highlight-helper.test.ts b/tests/client/highlight-helper.test.ts new file mode 100644 index 0000000..1d9f7dd --- /dev/null +++ b/tests/client/highlight-helper.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const highlightJsMock = vi.hoisted(() => ({ + getLanguage: vi.fn((lang?: string) => ['shell', 'xml', 'yaml', 'bash', 'json'].includes(lang || '')), + highlight: vi.fn((content: string, { language }: { language: string }) => ({ + value: `${content}`, + })), +})) + +vi.mock('highlight.js', () => ({ + default: highlightJsMock, +})) + +import { normalizeHighlightLanguage, renderHighlightedCodeBlock } from '@/components/hermes/chat/highlight' + +describe('highlight helper', () => { + beforeEach(() => { + vi.clearAllMocks() + highlightJsMock.getLanguage.mockImplementation((lang?: string) => ['shell', 'xml', 'yaml', 'bash', 'json'].includes(lang || '')) + highlightJsMock.highlight.mockImplementation((content: string, { language }: { language: string }) => ({ + value: `${content}`, + })) + }) + + it.each([ + ['vue', 'xml'], + ['yml', 'yaml'], + ['sh', 'bash'], + ['zsh', 'bash'], + ['shellscript', 'bash'], + ['shell', 'shell'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeHighlightLanguage(input)).toBe(expected) + }) + + it('uses a delegated copy attribute instead of inline javascript', () => { + const html = renderHighlightedCodeBlock('x', 'json', 'Copy') + + expect(html).toContain('data-copy-code="true"') + expect(html).not.toContain('onclick=') + }) + + it('preserves shell-session highlighting instead of remapping shell fences to bash', () => { + const html = renderHighlightedCodeBlock('$ ls\nfoo.txt\n', 'shell', 'Copy') + + expect(highlightJsMock.highlight).toHaveBeenCalledWith('$ ls\nfoo.txt\n', { + language: 'shell', + ignoreIllegals: true, + }) + expect(html).toContain('class="code-lang">shell') + }) + + it('skips highlighting for large known-language blocks when a render limit is set', () => { + const html = renderHighlightedCodeBlock('x'.repeat(5000), 'vue', 'Copy', { + maxHighlightLength: 2000, + }) + + expect(highlightJsMock.highlight).not.toHaveBeenCalled() + expect(html).toContain('class="code-lang">vue') + }) + + it('falls back to escaped plaintext for unsupported fence labels', () => { + const html = renderHighlightedCodeBlock('', 'unknown', 'Copy') + + expect(highlightJsMock.highlight).not.toHaveBeenCalled() + expect(html).toContain('<tag>') + expect(html).toContain('class="code-lang">unknown') + }) + + it('falls back to escaped plaintext when direct highlighting throws', () => { + highlightJsMock.highlight.mockImplementationOnce(() => { + throw new Error('boom') + }) + + const html = renderHighlightedCodeBlock('', 'vue', 'Copy') + + expect(html).toContain('<tag>') + expect(html).toContain('class="code-lang">vue') + }) +}) diff --git a/tests/client/highlight-safety.test.ts b/tests/client/highlight-safety.test.ts new file mode 100644 index 0000000..b8669ab --- /dev/null +++ b/tests/client/highlight-safety.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' + +import { renderHighlightedCodeBlock } from '@/components/hermes/chat/highlight' + +describe('highlight safety', () => { + it('escapes large unknown code content', () => { + const html = renderHighlightedCodeBlock(''.repeat(100), 'unknown', 'Copy') + + expect(html).toContain('<img') + expect(html).not.toContain(' { + const html = renderHighlightedCodeBlock('', 'xml', 'Copy') + + expect(html).not.toContain('', 'Copy') + + expect(html).toContain('<script>alert(1)</script>') + expect(html).not.toContain('