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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -174,7 +174,7 @@ docker compose logs -f hermes-webui
|
|||||||
Open **http://localhost:6060**
|
Open **http://localhost:6060**
|
||||||
|
|
||||||
- Persistent Hermes data is stored in `./hermes_data`
|
- 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
|
- On first run with auth enabled, the token is printed to container logs
|
||||||
- All runtime settings are environment-variable driven in `docker-compose.yml`
|
- All runtime settings are environment-variable driven in `docker-compose.yml`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -175,7 +175,7 @@ docker compose logs -f hermes-webui
|
|||||||
打开 **http://localhost:6060**
|
打开 **http://localhost:6060**
|
||||||
|
|
||||||
- Hermes 持久化数据目录:`./hermes_data`
|
- Hermes 持久化数据目录:`./hermes_data`
|
||||||
- Web UI 认证 Token 存储在 `./hermes_data/hermes-web-ui-data/.token`
|
- Web UI 认证 Token 存储在 `./hermes_data/hermes-web-ui/.token`
|
||||||
- 首次启动并开启认证时,Token 会打印到容器日志中
|
- 首次启动并开启认证时,Token 会打印到容器日志中
|
||||||
- 运行参数全部由 `docker-compose.yml` 环境变量驱动
|
- 运行参数全部由 `docker-compose.yml` 环境变量驱动
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const VERSION = pkg.version
|
|||||||
const PID_DIR = resolve(homedir(), '.hermes-web-ui')
|
const PID_DIR = resolve(homedir(), '.hermes-web-ui')
|
||||||
const PID_FILE = join(PID_DIR, 'server.pid')
|
const PID_FILE = join(PID_DIR, 'server.pid')
|
||||||
const LOG_FILE = join(PID_DIR, 'server.log')
|
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
|
const DEFAULT_PORT = 8648
|
||||||
|
|
||||||
// ─── Auto-fix node-pty native module ──────────────────────────
|
// ─── Auto-fix node-pty native module ──────────────────────────
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ services:
|
|||||||
- "${PORT:-6060}:${PORT:-6060}"
|
- "${PORT:-6060}:${PORT:-6060}"
|
||||||
volumes:
|
volumes:
|
||||||
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
|
- ${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
|
- hermes-agent-src:/opt/hermes
|
||||||
environment:
|
environment:
|
||||||
- PORT=${PORT:-6060}
|
- PORT=${PORT:-6060}
|
||||||
|
|||||||
+4
-4
@@ -62,11 +62,11 @@ AUTH_DISABLED=false
|
|||||||
| Path | Description |
|
| Path | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `${HERMES_DATA_DIR}` (`./hermes_data`) | Hermes runtime data (sessions, config, profiles) |
|
| `${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.
|
- 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`.
|
- Web UI data persists in `./hermes_data/hermes-web-ui/`, mapped to `/root/.hermes-web-ui` in the container.
|
||||||
- When `AUTH_DISABLED=false`, the token is auto-generated on first run and printed to container logs.
|
- 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.
|
- Deleting the token file and restarting will generate a new one.
|
||||||
|
|
||||||
## Port Mapping
|
## Port Mapping
|
||||||
@@ -96,7 +96,7 @@ View auth token:
|
|||||||
```bash
|
```bash
|
||||||
docker compose logs hermes-webui | grep token
|
docker compose logs hermes-webui | grep token
|
||||||
# or
|
# or
|
||||||
cat ./hermes_data/hermes-web-ui-data/.token
|
cat ./hermes_data/hermes-web-ui/.token
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop:
|
Stop:
|
||||||
|
|||||||
+26
-24
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"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)",
|
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"homepage": "https://github.com/EKKOLearnAI/hermes-web-ui",
|
"homepage": "https://github.com/EKKOLearnAI/hermes-web-ui",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=23.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"hermes",
|
"hermes",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
"dev:client": "vite --host",
|
"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",
|
"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",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
@@ -48,29 +48,12 @@
|
|||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"node-pty": "^1.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
"@koa/bodyparser": "^5.0.0",
|
"@koa/bodyparser": "^5.0.0",
|
||||||
"@koa/cors": "^5.0.0",
|
"@koa/cors": "^5.0.0",
|
||||||
"@koa/router": "^15.4.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",
|
"@pinia/testing": "^1.0.3",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/koa": "^2.15.0",
|
"@types/koa": "^2.15.0",
|
||||||
@@ -85,14 +68,33 @@
|
|||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@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",
|
"concurrently": "^9.2.1",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"jsdom": "^27.0.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",
|
"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",
|
"sass": "^1.99.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.4",
|
"vite": "^8.0.4",
|
||||||
"vitest": "^3.2.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import hljs from 'highlight.js'
|
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||||
|
|
||||||
const props = defineProps<{ content: string }>()
|
const props = defineProps<{ content: string }>()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -12,22 +12,19 @@ const md: MarkdownIt = new MarkdownIt({
|
|||||||
linkify: true,
|
linkify: true,
|
||||||
typographer: true,
|
typographer: true,
|
||||||
highlight(str: string, lang: string): string {
|
highlight(str: string, lang: string): string {
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
return renderHighlightedCodeBlock(str, lang, t('common.copy'))
|
||||||
try {
|
|
||||||
return `<pre class="hljs-code-block"><div class="code-header"><span class="code-lang">${lang}</span><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">${t('common.copy')}</button></div><code class="hljs language-${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
|
|
||||||
} catch {
|
|
||||||
// fall through
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `<pre class="hljs-code-block"><div class="code-header"><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">${t('common.copy')}</button></div><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderedHtml = computed(() => md.render(props.content))
|
const renderedHtml = computed(() => md.render(props.content))
|
||||||
|
|
||||||
|
function handleMarkdownClick(event: MouseEvent): void {
|
||||||
|
void handleCodeBlockCopyClick(event)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="markdown-body" v-html="renderedHtml"></div>
|
<div class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -121,88 +118,4 @@ const renderedHtml = computed(() => md.render(props.content))
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-code-block {
|
|
||||||
margin: 8px 0;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
overflow: hidden;
|
|
||||||
background: $code-bg;
|
|
||||||
border: 1px solid $border-color;
|
|
||||||
|
|
||||||
.code-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: rgba(0, 0, 0, 0.03);
|
|
||||||
border-bottom: 1px solid $border-color;
|
|
||||||
|
|
||||||
.code-lang {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $text-muted;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-btn {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $text-muted;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: all $transition-fast;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $text-primary;
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code.hljs {
|
|
||||||
display: block;
|
|
||||||
padding: 12px;
|
|
||||||
font-family: $font-code;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// highlight.js theme override — pure ink B&W
|
|
||||||
.hljs {
|
|
||||||
color: #2a2a2a;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-keyword,
|
|
||||||
.hljs-selector-tag { color: #1a1a1a; font-weight: 600; }
|
|
||||||
.hljs-string,
|
|
||||||
.hljs-attr { color: #555555; }
|
|
||||||
.hljs-number { color: #333333; }
|
|
||||||
.hljs-comment { color: #999999; font-style: italic; }
|
|
||||||
.hljs-built_in { color: #444444; }
|
|
||||||
.hljs-type { color: #3a3a3a; }
|
|
||||||
.hljs-variable { color: #1a1a1a; }
|
|
||||||
.hljs-title,
|
|
||||||
.hljs-title\.function_ { color: #1a1a1a; }
|
|
||||||
.hljs-params { color: #2a2a2a; }
|
|
||||||
.hljs-meta { color: #999999; }
|
|
||||||
|
|
||||||
// Dark mode highlight.js — inverted pure ink
|
|
||||||
.dark .hljs { color: #d0d0d0; }
|
|
||||||
.dark .hljs-keyword,
|
|
||||||
.dark .hljs-selector-tag { color: #f0f0f0; font-weight: 600; }
|
|
||||||
.dark .hljs-string,
|
|
||||||
.dark .hljs-attr { color: #aaaaaa; }
|
|
||||||
.dark .hljs-number { color: #cccccc; }
|
|
||||||
.dark .hljs-comment { color: #666666; font-style: italic; }
|
|
||||||
.dark .hljs-built_in { color: #bbbbbb; }
|
|
||||||
.dark .hljs-type { color: #c6c6c6; }
|
|
||||||
.dark .hljs-variable { color: #f0f0f0; }
|
|
||||||
.dark .hljs-title,
|
|
||||||
.dark .hljs-title\.function_ { color: #f0f0f0; }
|
|
||||||
.dark .hljs-params { color: #d0d0d0; }
|
|
||||||
.dark .hljs-meta { color: #666666; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import type { Message } from "@/stores/hermes/chat";
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
||||||
|
import {
|
||||||
|
copyTextToClipboard,
|
||||||
|
handleCodeBlockCopyClick,
|
||||||
|
renderHighlightedCodeBlock,
|
||||||
|
} from "./highlight";
|
||||||
|
|
||||||
|
const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
|
||||||
|
|
||||||
const props = defineProps<{ message: Message }>();
|
const props = defineProps<{ message: Message }>();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -25,6 +32,66 @@ function formatSize(bytes: number): string {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
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<void> {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const button = target.closest<HTMLElement>("[data-copy-code=\"true\"]");
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const source = button.closest<HTMLElement>("[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(
|
const hasAttachments = computed(
|
||||||
() => (props.message.attachments?.length ?? 0) > 0,
|
() => (props.message.attachments?.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
@@ -33,30 +100,28 @@ const hasToolDetails = computed(
|
|||||||
() => !!(props.message.toolArgs || props.message.toolResult),
|
() => !!(props.message.toolArgs || props.message.toolResult),
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedToolArgs = computed(() => {
|
const toolArgsPayload = computed(() => formatToolPayload(props.message.toolArgs));
|
||||||
if (!props.message.toolArgs) return "";
|
const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult));
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2);
|
const fullToolArgs = computed(() => toolArgsPayload.value.full);
|
||||||
} catch {
|
const formattedToolArgs = computed(() => toolArgsPayload.value.display);
|
||||||
return props.message.toolArgs;
|
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(() => {
|
const renderedToolResult = computed(() => {
|
||||||
if (!props.message.toolResult) return "";
|
if (!formattedToolResult.value) return "";
|
||||||
try {
|
return renderToolPayload(
|
||||||
const parsed = JSON.parse(props.message.toolResult);
|
formattedToolResult.value,
|
||||||
const str = JSON.stringify(parsed, null, 2);
|
toolResultPayload.value.language,
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -109,14 +174,14 @@ const formattedToolResult = computed(() => {
|
|||||||
t("chat.error")
|
t("chat.error")
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="toolExpanded && hasToolDetails" class="tool-details">
|
<div v-if="toolExpanded && hasToolDetails" class="tool-details" @click="handleToolDetailClick">
|
||||||
<div v-if="formattedToolArgs" class="tool-detail-section">
|
<div v-if="formattedToolArgs" class="tool-detail-section" data-copy-source="tool-args">
|
||||||
<div class="tool-detail-label">{{ t("chat.arguments") }}</div>
|
<div class="tool-detail-label">{{ t("chat.arguments") }}</div>
|
||||||
<pre class="tool-detail-code">{{ formattedToolArgs }}</pre>
|
<div class="tool-detail-code-block" v-html="renderedToolArgs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="formattedToolResult" class="tool-detail-section">
|
<div v-if="formattedToolResult" class="tool-detail-section" data-copy-source="tool-result">
|
||||||
<div class="tool-detail-label">{{ t("chat.result") }}</div>
|
<div class="tool-detail-label">{{ t("chat.result") }}</div>
|
||||||
<pre class="tool-detail-code">{{ formattedToolResult }}</pre>
|
<div class="tool-detail-code-block" v-html="renderedToolResult"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -400,20 +465,22 @@ const formattedToolResult = computed(() => {
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-detail-code {
|
.tool-detail-code-block {
|
||||||
font-family: $font-code;
|
:deep(.hljs-code-block) {
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: $text-secondary;
|
|
||||||
background: $code-bg;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
padding: 6px 8px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow-x: auto;
|
}
|
||||||
|
|
||||||
|
:deep(.code-header) {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(code.hljs) {
|
||||||
|
font-size: 11px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-word;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
|
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||||
|
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
|
||||||
|
? `<span class="code-lang">${escapeHtml(labelLanguage)}</span>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return `<pre class="hljs-code-block"><div class="code-header">${languageLabelHtml}<button type="button" class="copy-btn" data-copy-code="true">${escapeHtml(copyLabel)}</button></div><code class="hljs language-${sanitizeLanguageClass(codeClassLanguage)}">${highlighted}</code></pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard?.writeText?.(text)
|
||||||
|
} catch {
|
||||||
|
// Ignore clipboard failures; the code block still renders safely.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<void> {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
const button = target.closest<HTMLElement>('[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)
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ watch(selectedPreset, (val) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(() => formData.value.base_url, (url) => {
|
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())
|
formData.value.name = autoGenerateName(url.trim())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -90,7 +90,8 @@ async function fetchModels() {
|
|||||||
|
|
||||||
fetchingModels.value = true
|
fetchingModels.value = true
|
||||||
try {
|
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<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
if (formData.value.api_key.trim()) {
|
if (formData.value.api_key.trim()) {
|
||||||
headers['Authorization'] = `Bearer ${formData.value.api_key.trim()}`
|
headers['Authorization'] = `Bearer ${formData.value.api_key.trim()}`
|
||||||
@@ -213,7 +214,6 @@ function handleClose() {
|
|||||||
<NInput
|
<NInput
|
||||||
v-model:value="formData.name"
|
v-model:value="formData.name"
|
||||||
:placeholder="t('models.autoGeneratedName')"
|
:placeholder="t('models.autoGeneratedName')"
|
||||||
disabled
|
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const dialog = useDialog()
|
|||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
|
const switching = ref(false)
|
||||||
const detail = ref<HermesProfileDetail | null>(null)
|
const detail = ref<HermesProfileDetail | null>(null)
|
||||||
|
|
||||||
const isDefault = computed(() => props.profile.name === 'default')
|
const isDefault = computed(() => props.profile.name === 'default')
|
||||||
@@ -34,14 +35,18 @@ async function toggleDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSwitch() {
|
async function handleSwitch() {
|
||||||
profilesStore.switchProfile(props.profile.name).then(ok => {
|
switching.value = true
|
||||||
|
try {
|
||||||
|
const ok = await profilesStore.switchProfile(props.profile.name)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else {
|
} else {
|
||||||
message.error(t('profiles.switchFailed'))
|
message.error(t('profiles.switchFailed'))
|
||||||
}
|
}
|
||||||
})
|
} finally {
|
||||||
|
switching.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@@ -139,7 +144,7 @@ async function handleExport() {
|
|||||||
<NButton
|
<NButton
|
||||||
v-if="!profile.active"
|
v-if="!profile.active"
|
||||||
size="tiny"
|
size="tiny"
|
||||||
:loading="profilesStore.switching"
|
:loading="switching"
|
||||||
quaternary
|
quaternary
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="handleSwitch"
|
@click="handleSwitch"
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
@use 'variables' as *;
|
||||||
|
|
||||||
|
// Shared code-block styles used by markdown rendering and tool payload details.
|
||||||
|
// Keep these global so v-html output in both MarkdownRenderer.vue and MessageItem.vue
|
||||||
|
// stays styled even though the HTML is not compiled as Vue template content.
|
||||||
|
|
||||||
|
.hljs-code-block {
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $code-bg;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
|
||||||
|
.code-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
|
||||||
|
.code-lang {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-muted;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-primary;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code.hljs {
|
||||||
|
display: block;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: $font-code;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlight.js theme override — higher-contrast, still calm
|
||||||
|
.hljs-code-block {
|
||||||
|
.hljs {
|
||||||
|
color: #1f2937;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-selector-tag,
|
||||||
|
.hljs-meta .hljs-keyword {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-template-variable {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-literal,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-bullet {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-title.class_,
|
||||||
|
.hljs-title.function_ {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-variable,
|
||||||
|
.hljs-property,
|
||||||
|
.hljs-params {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-tag,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-section,
|
||||||
|
.hljs-title {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-meta {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode highlight.js — clearer separation without neon
|
||||||
|
.dark .hljs-code-block {
|
||||||
|
.hljs {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-selector-tag,
|
||||||
|
.hljs-meta .hljs-keyword {
|
||||||
|
color: #c084fc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-template-variable {
|
||||||
|
color: #5eead4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-literal,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-bullet {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-title.class_,
|
||||||
|
.hljs-title.function_ {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-variable,
|
||||||
|
.hljs-property,
|
||||||
|
.hljs-params {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-tag,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-section,
|
||||||
|
.hljs-title {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-meta {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@use 'variables' as *;
|
@use 'variables' as *;
|
||||||
|
@use 'code-block';
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as hermesCli from '../services/hermes/hermes-cli'
|
||||||
|
import { getGatewayManagerInstance } from '../services/gateway-bootstrap'
|
||||||
|
import { config } from '../config'
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
|
const LOCAL_VERSION = typeof __APP_VERSION__ !== 'undefined'
|
||||||
|
? __APP_VERSION__
|
||||||
|
: (() => { 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<void> {
|
||||||
|
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 }
|
||||||
@@ -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<string, CodexSession>()
|
||||||
|
|
||||||
|
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<string, any>; credential_pool?: Record<string, any[]>; 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<void> {
|
||||||
|
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 } }
|
||||||
|
}
|
||||||
@@ -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<string, [string, string]> = {
|
||||||
|
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<string, Record<string, string>> = {}
|
||||||
|
for (const [envVar, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
|
||||||
|
if (!platformEnvMap[platform]) platformEnvMap[platform] = {}
|
||||||
|
platformEnvMap[platform][cfgPath] = envVar
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnv(raw: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {}
|
||||||
|
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<string, any>, 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<string, any>, source: Record<string, any>): Record<string, any> {
|
||||||
|
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<Record<string, any>> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(envPath(), 'utf-8')
|
||||||
|
const env = parseEnv(raw)
|
||||||
|
const platforms: Record<string, any> = {}
|
||||||
|
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<Record<string, any>> {
|
||||||
|
const raw = await readFile(configPath(), 'utf-8')
|
||||||
|
return (YAML.load(raw) as Record<string, any>) || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeConfig(data: Record<string, any>): Promise<void> {
|
||||||
|
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<string, any>) }
|
||||||
|
}
|
||||||
|
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<string, any> = {}
|
||||||
|
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<string, any> }
|
||||||
|
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<string, any> }
|
||||||
|
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<string, any> = {}
|
||||||
|
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<string, any>)) { 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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<number, string> = { 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>()
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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<string, string> = { 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<string>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -6,45 +6,57 @@ import send from 'koa-send'
|
|||||||
import os from 'os'
|
import os from 'os'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { mkdir } from 'fs/promises'
|
import { mkdir } from 'fs/promises'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
|
import { getToken, requireAuth } from './services/auth'
|
||||||
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 { initGatewayManager } from './services/gateway-bootstrap'
|
import { initGatewayManager } from './services/gateway-bootstrap'
|
||||||
import { bindShutdown } from './services/shutdown'
|
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
|
let server: any = null
|
||||||
|
|
||||||
export async function bootstrap() {
|
export async function bootstrap() {
|
||||||
|
console.log(`hermes-web-ui v${APP_VERSION} starting...`)
|
||||||
await mkdir(config.uploadDir, { recursive: true })
|
await mkdir(config.uploadDir, { recursive: true })
|
||||||
await mkdir(config.dataDir, { recursive: true })
|
await mkdir(config.dataDir, { recursive: true })
|
||||||
|
|
||||||
const authToken = await getToken()
|
const authToken = await getToken()
|
||||||
const app = new Koa()
|
const app = new Koa()
|
||||||
|
|
||||||
if (authToken) {
|
|
||||||
app.use(await authMiddleware(authToken))
|
|
||||||
console.log(`🔐 Auth enabled — token: ${authToken}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
await initGatewayManager()
|
await initGatewayManager()
|
||||||
|
console.log('[bootstrap] gateway manager initialized')
|
||||||
app.use(cors({ origin: config.corsOrigins }))
|
app.use(cors({ origin: config.corsOrigins }))
|
||||||
app.use(bodyParser())
|
app.use(bodyParser())
|
||||||
|
console.log('[bootstrap] cors + bodyParser registered')
|
||||||
|
|
||||||
// Shared routes (no agent prefix)
|
// Register all routes (handles auth internally)
|
||||||
app.use(webhookRoutes.routes())
|
const proxyMiddleware = registerRoutes(app, requireAuth(authToken))
|
||||||
app.use(uploadRoutes.routes())
|
|
||||||
app.use(updateRoutes.routes())
|
|
||||||
|
|
||||||
// Hermes routes (must be after update — proxy catch-all matches everything)
|
|
||||||
app.use(hermesRoutes.routes())
|
|
||||||
app.use(proxyMiddleware)
|
app.use(proxyMiddleware)
|
||||||
|
console.log('[bootstrap] routes registered')
|
||||||
|
|
||||||
// Health check
|
if (authToken) {
|
||||||
app.use(healthRoutes.routes())
|
console.log(`Auth enabled — token: ${authToken}`)
|
||||||
|
logger.info('Auth enabled — token: %s', authToken)
|
||||||
|
}
|
||||||
|
|
||||||
// SPA fallback
|
// SPA fallback
|
||||||
const distDir = resolve(__dirname, '..', 'client')
|
const distDir = resolve(__dirname, '..', 'client')
|
||||||
@@ -57,21 +69,29 @@ export async function bootstrap() {
|
|||||||
await send(ctx, 'index.html', { root: distDir })
|
await send(ctx, 'index.html', { root: distDir })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
console.log('[bootstrap] SPA fallback registered')
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
|
console.log(`[bootstrap] listening on port ${config.port}`)
|
||||||
server = app.listen(config.port, '0.0.0.0')
|
server = app.listen(config.port, '0.0.0.0')
|
||||||
|
console.log('[bootstrap] app.listen called')
|
||||||
|
|
||||||
setupTerminalWebSocket(server)
|
setupTerminalWebSocket(server)
|
||||||
|
console.log('[bootstrap] terminal websocket setup')
|
||||||
|
|
||||||
server.on('listening', () => {
|
server.on('listening', () => {
|
||||||
const interfaces = os.networkInterfaces()
|
const interfaces = os.networkInterfaces()
|
||||||
const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost'
|
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(`Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`)
|
||||||
console.log(`➜ Upstream: ${config.upstream}`)
|
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) => {
|
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)
|
bindShutdown(server)
|
||||||
|
|||||||
@@ -1,73 +1,8 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { resolve } from 'path'
|
import * as ctrl from '../controllers/health'
|
||||||
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<void> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const healthRoutes = new Router()
|
export const healthRoutes = new Router()
|
||||||
|
|
||||||
healthRoutes.get('/health', async (ctx) => {
|
healthRoutes.get('/health', ctrl.healthCheck)
|
||||||
const raw = await hermesCli.getVersion()
|
|
||||||
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
|
|
||||||
|
|
||||||
let gatewayOk = false
|
export { startVersionCheck } from '../controllers/health'
|
||||||
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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,347 +1,8 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { randomUUID } from 'crypto'
|
import * as ctrl from '../../controllers/hermes/codex-auth'
|
||||||
import { join } from 'path'
|
|
||||||
import { homedir } from 'os'
|
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
|
||||||
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
|
||||||
|
|
||||||
// --- 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<string, CodexSession>()
|
|
||||||
|
|
||||||
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<string, any>
|
|
||||||
credential_pool?: Record<string, any[]>
|
|
||||||
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<void> {
|
|
||||||
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()
|
export const codexAuthRoutes = new Router()
|
||||||
|
|
||||||
codexAuthRoutes.post('/api/hermes/auth/codex/start', async (ctx) => {
|
codexAuthRoutes.post('/api/hermes/auth/codex/start', ctrl.start)
|
||||||
try {
|
codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', ctrl.poll)
|
||||||
cleanupExpiredSessions()
|
codexAuthRoutes.get('/api/hermes/auth/codex/status', ctrl.status)
|
||||||
|
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,330 +1,8 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { readFile, writeFile, copyFile } from 'fs/promises'
|
import * as ctrl from '../../controllers/hermes/config'
|
||||||
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<string, [string, string]> = {
|
|
||||||
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<string, Record<string, string>> = {}
|
|
||||||
for (const [envVar, [platform, configPath]] of Object.entries(envPlatformMap)) {
|
|
||||||
if (!platformEnvMap[platform]) platformEnvMap[platform] = {}
|
|
||||||
platformEnvMap[platform][configPath] = envVar
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnv(raw: string): Record<string, string> {
|
|
||||||
const env: Record<string, string> = {}
|
|
||||||
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<string, any>, 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<string, any>, 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<string, any>, source: Record<string, any>): Record<string, any> {
|
|
||||||
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<Record<string, any>> {
|
|
||||||
try {
|
|
||||||
const raw = await readFile(envPath(), 'utf-8')
|
|
||||||
const env = parseEnv(raw)
|
|
||||||
const platforms: Record<string, any> = {}
|
|
||||||
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<void> {
|
|
||||||
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<Record<string, any>> {
|
|
||||||
const raw = await readFile(configPath(), 'utf-8')
|
|
||||||
return (YAML.load(raw) as Record<string, any>) || {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeConfig(data: Record<string, any>): Promise<void> {
|
|
||||||
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 const configRoutes = new Router()
|
export const configRoutes = new Router()
|
||||||
|
|
||||||
// GET /api/config — read config sections
|
configRoutes.get('/api/hermes/config', ctrl.getConfig)
|
||||||
configRoutes.get('/api/hermes/config', async (ctx) => {
|
configRoutes.put('/api/hermes/config', ctrl.updateConfig)
|
||||||
try {
|
configRoutes.put('/api/hermes/config/credentials', ctrl.updateCredentials)
|
||||||
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<string, any>) }
|
|
||||||
}
|
|
||||||
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<string, any> = {}
|
|
||||||
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<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, any> }
|
|
||||||
// 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<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
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.<platform> to keep in sync
|
|
||||||
const config = await readConfig()
|
|
||||||
let configChanged = false
|
|
||||||
|
|
||||||
// Flatten nested values: { extra: { app_id: '' } } → { 'extra.app_id': '' }
|
|
||||||
const flatValues: Record<string, any> = {}
|
|
||||||
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<string, any>)) {
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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<string, { api_key_env: string; base_url_env: string }> = {
|
|
||||||
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<void> {
|
|
||||||
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<string[]> {
|
|
||||||
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<string | null> {
|
|
||||||
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<Record<string, any>> {
|
|
||||||
const raw = await safeReadFile(configPath())
|
|
||||||
if (!raw) return {}
|
|
||||||
return (YAML.load(raw) as Record<string, any>) || {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
|
||||||
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<string, any>): { default: string; groups: ModelGroup[] } {
|
|
||||||
let defaultModel = ''
|
|
||||||
const groups: ModelGroup[] = []
|
|
||||||
const allModelIds = new Set<string>()
|
|
||||||
|
|
||||||
// 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<string>()
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,71 +1,9 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
|
import * as ctrl from '../../controllers/hermes/gateways'
|
||||||
|
|
||||||
export const gatewayRoutes = new Router()
|
export const gatewayRoutes = new Router()
|
||||||
|
|
||||||
// Get singleton instance — set during bootstrap
|
gatewayRoutes.get('/api/hermes/gateways', ctrl.list)
|
||||||
let manager: any = null
|
gatewayRoutes.post('/api/hermes/gateways/:name/start', ctrl.start)
|
||||||
|
gatewayRoutes.post('/api/hermes/gateways/:name/stop', ctrl.stop)
|
||||||
export function setGatewayManager(mgr: any) {
|
gatewayRoutes.get('/api/hermes/gateways/:name/health', ctrl.health)
|
||||||
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 }
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -1,101 +1,7 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { existsSync, statSync } from 'fs'
|
import * as ctrl from '../../controllers/hermes/logs'
|
||||||
import { readFile } from 'fs/promises'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { homedir } from 'os'
|
|
||||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
|
||||||
|
|
||||||
export const logRoutes = new Router()
|
export const logRoutes = new Router()
|
||||||
|
|
||||||
const WEBUI_LOG_FILE = join(homedir(), '.hermes-web-ui', 'server.log')
|
logRoutes.get('/api/hermes/logs', ctrl.list)
|
||||||
|
logRoutes.get('/api/hermes/logs/:name', ctrl.read)
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -1,257 +1,13 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { createReadStream, existsSync, unlinkSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'fs'
|
import * as ctrl from '../../controllers/hermes/profiles'
|
||||||
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'
|
|
||||||
|
|
||||||
export const profileRoutes = new Router()
|
export const profileRoutes = new Router()
|
||||||
|
|
||||||
// GET /api/profiles - List all profiles
|
profileRoutes.get('/api/hermes/profiles', ctrl.list)
|
||||||
profileRoutes.get('/api/hermes/profiles', async (ctx) => {
|
profileRoutes.post('/api/hermes/profiles', ctrl.create)
|
||||||
try {
|
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
|
||||||
const profiles = await hermesCli.listProfiles()
|
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
|
||||||
ctx.body = { profiles }
|
profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename)
|
||||||
} catch (err: any) {
|
profileRoutes.put('/api/hermes/profiles/active', ctrl.switchProfile)
|
||||||
ctx.status = 500
|
profileRoutes.post('/api/hermes/profiles/:name/export', ctrl.exportProfile)
|
||||||
ctx.body = { error: err.message }
|
profileRoutes.post('/api/hermes/profiles/import', ctrl.importProfile)
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Context } from 'koa'
|
import type { Context } from 'koa'
|
||||||
import { config } from '../../config'
|
import { config } from '../../config'
|
||||||
import { getGatewayManager } from './gateways'
|
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||||
|
|
||||||
|
function getGatewayManager() { return getGatewayManagerInstance() }
|
||||||
|
|
||||||
function isTransientGatewayError(err: any): boolean {
|
function isTransientGatewayError(err: any): boolean {
|
||||||
const msg = String(err?.message || '')
|
const msg = String(err?.message || '')
|
||||||
|
|||||||
@@ -1,61 +1,9 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
import * as ctrl from '../../controllers/hermes/sessions'
|
||||||
import { listSessionSummaries } from '../../services/hermes/sessions-db'
|
|
||||||
|
|
||||||
export const sessionRoutes = new Router()
|
export const sessionRoutes = new Router()
|
||||||
|
|
||||||
// List sessions from Hermes
|
sessionRoutes.get('/api/hermes/sessions', ctrl.list)
|
||||||
sessionRoutes.get('/api/hermes/sessions', async (ctx) => {
|
sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get)
|
||||||
const source = (ctx.query.source as string) || undefined
|
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove)
|
||||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
sessionRoutes.post('/api/hermes/sessions/:id/rename', ctrl.rename)
|
||||||
|
|
||||||
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 }
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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_)
|
||||||
@@ -3,6 +3,7 @@ import type { Server as HttpServer } from 'http'
|
|||||||
import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs'
|
import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { getToken } from '../../services/auth'
|
import { getToken } from '../../services/auth'
|
||||||
|
import { logger } from '../../services/logger'
|
||||||
|
|
||||||
let pty: any = null
|
let pty: any = null
|
||||||
|
|
||||||
@@ -23,11 +24,11 @@ function ensureNodePtySpawnHelperExecutable() {
|
|||||||
accessSync(helperPath, fsConstants.X_OK)
|
accessSync(helperPath, fsConstants.X_OK)
|
||||||
} catch {
|
} catch {
|
||||||
chmodSync(helperPath, 0o755)
|
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) {
|
} 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
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
pty = require('node-pty')
|
pty = require('node-pty')
|
||||||
} catch (err: any) {
|
} 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 ────────────────────────────────────────────
|
// ─── Shell detection ────────────────────────────────────────────
|
||||||
@@ -111,7 +112,7 @@ function createSession(shell: string): PtySession {
|
|||||||
|
|
||||||
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||||
if (!pty) {
|
if (!pty) {
|
||||||
console.warn('[Terminal] node-pty not available, skipping terminal WebSocket setup')
|
logger.warn('node-pty not available, skipping terminal WebSocket setup')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
|
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
|
||||||
}
|
}
|
||||||
conn.sessions.delete(session.id)
|
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,
|
pid: session.pid,
|
||||||
shell: shellName(shell),
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
conn.outputBuffers.delete(sessionId)
|
conn.outputBuffers.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Terminal] Switched to session ${sessionId}`)
|
logger.debug('Switched to session %s', sessionId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +269,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
const remaining = Array.from(conn.sessions.keys())
|
const remaining = Array.from(conn.sessions.keys())
|
||||||
conn.activeSessionId = remaining.length > 0 ? remaining[0] : null
|
conn.activeSessionId = remaining.length > 0 ? remaining[0] : null
|
||||||
}
|
}
|
||||||
console.log(`[Terminal] Session closed: ${sessionId}`)
|
logger.info('Session closed: %s', sessionId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +291,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
try { session.pty.kill() } catch { }
|
try { session.pty.kill() } catch { }
|
||||||
}
|
}
|
||||||
conn.sessions.clear()
|
conn.sessions.clear()
|
||||||
console.log(`[Terminal] Connection closed, all sessions killed`)
|
logger.info('Connection closed, all sessions killed')
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.on('error', () => {
|
ws.on('error', () => {
|
||||||
@@ -307,7 +308,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
firstSession = createSession(defaultShell)
|
firstSession = createSession(defaultShell)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ws.send(JSON.stringify({ type: 'error', message: err.message }))
|
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()
|
ws.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -320,8 +321,8 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
pid: firstSession.pid,
|
pid: firstSession.pid,
|
||||||
shell: shellName(defaultShell),
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +1,8 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import axios from 'axios'
|
import * as ctrl from '../../controllers/hermes/weixin'
|
||||||
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'
|
|
||||||
|
|
||||||
export const weixinRoutes = new Router()
|
export const weixinRoutes = new Router()
|
||||||
|
|
||||||
// GET /api/weixin/qrcode — fetch QR code from Tencent iLink API
|
weixinRoutes.get('/api/hermes/weixin/qrcode', ctrl.getQrcode)
|
||||||
weixinRoutes.get('/api/hermes/weixin/qrcode', async (ctx) => {
|
weixinRoutes.get('/api/hermes/weixin/qrcode/status', ctrl.pollStatus)
|
||||||
try {
|
weixinRoutes.post('/api/hermes/weixin/save', ctrl.save)
|
||||||
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<string, string> = {
|
|
||||||
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<string>()
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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<void>) {
|
||||||
|
// --- 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
|
||||||
|
}
|
||||||
@@ -1,33 +1,6 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
|
import * as ctrl from '../controllers/update'
|
||||||
|
|
||||||
export const updateRoutes = new Router()
|
export const updateRoutes = new Router()
|
||||||
|
|
||||||
updateRoutes.post('/api/hermes/update', async (ctx) => {
|
updateRoutes.post('/api/hermes/update', ctrl.handleUpdate)
|
||||||
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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,90 +1,6 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { randomBytes } from 'crypto'
|
import * as ctrl from '../controllers/upload'
|
||||||
import { writeFile } from 'fs/promises'
|
|
||||||
import { config } from '../config'
|
|
||||||
|
|
||||||
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB
|
|
||||||
|
|
||||||
export const uploadRoutes = new Router()
|
export const uploadRoutes = new Router()
|
||||||
|
|
||||||
uploadRoutes.post('/upload', async (ctx) => {
|
uploadRoutes.post('/upload', ctrl.handleUpload)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,6 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { emitWebhook } from '../services/hermes/hermes'
|
import * as ctrl from '../controllers/webhook'
|
||||||
|
|
||||||
export const webhookRoutes = new Router()
|
export const webhookRoutes = new Router()
|
||||||
|
|
||||||
/**
|
webhookRoutes.post('/webhook', ctrl.handleWebhook)
|
||||||
* 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 }
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { readFile, writeFile } from 'fs/promises'
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { config } from '../config'
|
import { homedir } from 'os'
|
||||||
|
|
||||||
// Token stored in project data directory
|
const APP_HOME = join(homedir(), '.hermes-web-ui')
|
||||||
const TOKEN_FILE = join(config.dataDir, '.token')
|
const TOKEN_FILE = join(APP_HOME, '.token')
|
||||||
|
|
||||||
function generateToken(): string {
|
function generateToken(): string {
|
||||||
return randomBytes(32).toString('hex')
|
return randomBytes(32).toString('hex')
|
||||||
@@ -14,12 +14,10 @@ function generateToken(): string {
|
|||||||
* Get or create the auth token. Returns null if auth is disabled.
|
* Get or create the auth token. Returns null if auth is disabled.
|
||||||
*/
|
*/
|
||||||
export async function getToken(): Promise<string | null> {
|
export async function getToken(): Promise<string | null> {
|
||||||
// Auth can be disabled via env var
|
|
||||||
if (process.env.AUTH_DISABLED === '1' || process.env.AUTH_DISABLED === 'true') {
|
if (process.env.AUTH_DISABLED === '1' || process.env.AUTH_DISABLED === 'true') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom token via env var
|
|
||||||
if (process.env.AUTH_TOKEN) {
|
if (process.env.AUTH_TOKEN) {
|
||||||
return process.env.AUTH_TOKEN
|
return process.env.AUTH_TOKEN
|
||||||
}
|
}
|
||||||
@@ -28,41 +26,36 @@ export async function getToken(): Promise<string | null> {
|
|||||||
const token = await readFile(TOKEN_FILE, 'utf-8')
|
const token = await readFile(TOKEN_FILE, 'utf-8')
|
||||||
return token.trim()
|
return token.trim()
|
||||||
} catch {
|
} catch {
|
||||||
// Generate a new token
|
|
||||||
const token = generateToken()
|
const token = generateToken()
|
||||||
|
await mkdir(APP_HOME, { recursive: true })
|
||||||
await writeFile(TOKEN_FILE, token + '\n', { mode: 0o600 })
|
await writeFile(TOKEN_FILE, token + '\n', { mode: 0o600 })
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Koa middleware: check Authorization header for API routes.
|
* Koa middleware: check Authorization header or query token.
|
||||||
* Skips /health, /webhook, and static file requests.
|
* 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<void>) => {
|
return async (ctx: any, next: () => Promise<void>) => {
|
||||||
// If auth is disabled, skip
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
await next()
|
await next()
|
||||||
return
|
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 auth = ctx.headers.authorization || ''
|
||||||
const provided = auth.startsWith('Bearer ')
|
const provided = auth.startsWith('Bearer ')
|
||||||
? auth.slice(7)
|
? auth.slice(7)
|
||||||
: (ctx.query.token as string) || ''
|
: (ctx.query.token as string) || ''
|
||||||
|
|
||||||
if (!provided || provided !== token) {
|
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.status = 401
|
||||||
ctx.set('Content-Type', 'application/json')
|
ctx.set('Content-Type', 'application/json')
|
||||||
ctx.body = { error: 'Unauthorized' }
|
ctx.body = { error: 'Unauthorized' }
|
||||||
|
|||||||
@@ -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<string, { api_key_env: string; base_url_env: string }> = {
|
||||||
|
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<Record<string, any>> {
|
||||||
|
const raw = await safeReadFile(configPath())
|
||||||
|
if (!raw) return {}
|
||||||
|
return (YAML.load(raw) as Record<string, any>) || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string, any>): { 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()
|
||||||
@@ -7,12 +7,10 @@ export function getGatewayManagerInstance(): any {
|
|||||||
export async function initGatewayManager(): Promise<void> {
|
export async function initGatewayManager(): Promise<void> {
|
||||||
const { GatewayManager } = await import('./hermes/gateway-manager')
|
const { GatewayManager } = await import('./hermes/gateway-manager')
|
||||||
const { getActiveProfileName } = await import('./hermes/hermes-profile')
|
const { getActiveProfileName } = await import('./hermes/hermes-profile')
|
||||||
const { setGatewayManager } = await import('../routes/hermes/gateways')
|
|
||||||
|
|
||||||
const activeProfile = getActiveProfileName()
|
const activeProfile = getActiveProfileName()
|
||||||
gatewayManager = new GatewayManager(activeProfile)
|
gatewayManager = new GatewayManager(activeProfile)
|
||||||
setGatewayManager(gatewayManager)
|
|
||||||
|
|
||||||
await gatewayManager.detectAllOnStartup()
|
await gatewayManager.detectAllOnStartup()
|
||||||
await gatewayManager.startAll()
|
await gatewayManager.startAll()
|
||||||
|
console.log("startall")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,12 @@
|
|||||||
import { spawn, type ChildProcess } from 'child_process'
|
import { spawn, type ChildProcess } from 'child_process'
|
||||||
import { resolve, join } from 'path'
|
import { resolve, join } from 'path'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'
|
||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { createServer } from 'net'
|
import { createServer } from 'net'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
import { logger } from '../logger'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ const HERMES_BASE = resolve(homedir(), '.hermes')
|
|||||||
const HERMES_BIN = process.env.HERMES_BIN?.trim() || 'hermes'
|
const HERMES_BIN = process.env.HERMES_BIN?.trim() || 'hermes'
|
||||||
|
|
||||||
// WSL / Docker 没有 systemd 或 launchd,需要用 "gateway run" 代替 "gateway start"
|
// 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 isDocker = existsSync('/.dockerenv')
|
||||||
const needsRunMode = isWsl || isDocker
|
const needsRunMode = isWsl || isDocker
|
||||||
|
|
||||||
@@ -110,7 +112,6 @@ export class GatewayManager {
|
|||||||
if (!existsSync(configPath)) return { port: 8642, host: '127.0.0.1' }
|
if (!existsSync(configPath)) return { port: 8642, host: '127.0.0.1' }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const yaml = require('js-yaml')
|
|
||||||
const content = readFileSync(configPath, 'utf-8')
|
const content = readFileSync(configPath, 'utf-8')
|
||||||
const cfg = yaml.load(content) as any || {}
|
const cfg = yaml.load(content) as any || {}
|
||||||
|
|
||||||
@@ -225,7 +226,6 @@ export class GatewayManager {
|
|||||||
private writeProfilePort(name: string, port: number, host: string): void {
|
private writeProfilePort(name: string, port: number, host: string): void {
|
||||||
const configPath = join(this.profileDir(name), 'config.yaml')
|
const configPath = join(this.profileDir(name), 'config.yaml')
|
||||||
try {
|
try {
|
||||||
const yaml = require('js-yaml')
|
|
||||||
const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
|
const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
|
||||||
const cfg = (yaml.load(content) as any) || {}
|
const cfg = (yaml.load(content) as any) || {}
|
||||||
|
|
||||||
@@ -248,9 +248,9 @@ export class GatewayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(configPath, yaml.dump(cfg, { lineWidth: -1 }), 'utf-8')
|
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) {
|
} 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)) {
|
if (usedPorts.has(port)) {
|
||||||
// 已管理端口冲突 → 找空闲端口
|
// 已管理端口冲突 → 找空闲端口
|
||||||
const newPort = await this.findFreePort(port, host)
|
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)
|
this.writeProfilePort(name, newPort, host)
|
||||||
port = newPort
|
port = newPort
|
||||||
} else {
|
} else {
|
||||||
@@ -288,7 +288,7 @@ export class GatewayManager {
|
|||||||
const available = await this.checkPortAvailable(port, host)
|
const available = await this.checkPortAvailable(port, host)
|
||||||
if (!available) {
|
if (!available) {
|
||||||
const newPort = await this.findFreePort(port, host)
|
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)
|
this.writeProfilePort(name, newPort, host)
|
||||||
port = newPort
|
port = newPort
|
||||||
} else {
|
} else {
|
||||||
@@ -354,7 +354,6 @@ export class GatewayManager {
|
|||||||
// CLI 不可用时回退到文件系统扫描
|
// CLI 不可用时回退到文件系统扫描
|
||||||
const profiles = ['default']
|
const profiles = ['default']
|
||||||
const profilesDir = join(HERMES_BASE, 'profiles')
|
const profilesDir = join(HERMES_BASE, 'profiles')
|
||||||
const { existsSync, readdirSync } = require('fs')
|
|
||||||
if (existsSync(profilesDir)) {
|
if (existsSync(profilesDir)) {
|
||||||
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
||||||
if (entry.isDirectory() && existsSync(join(profilesDir, entry.name, 'config.yaml'))) {
|
if (entry.isDirectory() && existsSync(join(profilesDir, entry.name, 'config.yaml'))) {
|
||||||
@@ -423,7 +422,7 @@ export class GatewayManager {
|
|||||||
child.unref()
|
child.unref()
|
||||||
|
|
||||||
const pid = child.pid ?? 0
|
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)
|
this.waitForReady(name, pid, port, host, url)
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
@@ -432,7 +431,7 @@ export class GatewayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 正常系统:先 start,失败则 restart(处理服务已运行的情况)
|
// 正常系统:先 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 }
|
const env = { ...process.env, HERMES_HOME: hermesHome }
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
|
const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
|
||||||
@@ -440,7 +439,7 @@ export class GatewayManager {
|
|||||||
env,
|
env,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
console.log(`[GatewayManager] gateway start output: ${stdout?.trim()}`)
|
logger.debug('gateway start output: %s', stdout?.trim())
|
||||||
} catch {
|
} catch {
|
||||||
// start 失败(可能服务已运行),用 restart
|
// start 失败(可能服务已运行),用 restart
|
||||||
try {
|
try {
|
||||||
@@ -449,9 +448,9 @@ export class GatewayManager {
|
|||||||
env,
|
env,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
console.log(`[GatewayManager] gateway restart output: ${stdout?.trim()}`)
|
logger.debug('gateway restart output: %s', stdout?.trim())
|
||||||
} catch (err: any) {
|
} 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) {
|
while (Date.now() < deadline) {
|
||||||
if (!(await this.checkHealth(url, 1000))) {
|
if (!(await this.checkHealth(url, 1000))) {
|
||||||
this.gateways.delete(name)
|
this.gateways.delete(name)
|
||||||
console.log(`[GatewayManager] Stopped gateway for profile "${name}"`)
|
logger.info('Stopped gateway for profile "%s"', name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await new Promise(r => setTimeout(r, 300))
|
await new Promise(r => setTimeout(r, 300))
|
||||||
}
|
}
|
||||||
// 超时也清理
|
// 超时也清理
|
||||||
this.gateways.delete(name)
|
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,检测网关运行状态并注册 */
|
/** 扫描所有 profile,检测网关运行状态并注册 */
|
||||||
async detectAllOnStartup(): Promise<void> {
|
async detectAllOnStartup(): Promise<void> {
|
||||||
console.log('[GatewayManager] Scanning profiles for running gateways...')
|
logger.info('Scanning profiles for running gateways...')
|
||||||
const profiles = await this.listProfiles()
|
const profiles = await this.listProfiles()
|
||||||
|
|
||||||
for (const name of profiles) {
|
for (const name of profiles) {
|
||||||
const status = await this.detectStatus(name)
|
const status = await this.detectStatus(name)
|
||||||
if (status.running) {
|
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 {
|
} else {
|
||||||
console.log(`[GatewayManager] ○ ${name}: stopped`)
|
logger.debug('%s: stopped', name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,14 +567,14 @@ export class GatewayManager {
|
|||||||
for (const name of profiles) {
|
for (const name of profiles) {
|
||||||
const existing = this.gateways.get(name)
|
const existing = this.gateways.get(name)
|
||||||
if (existing && this.isProcessAlive(existing.pid)) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有 PID 文件但进程未在正确端口运行 → 旧进程,先停掉
|
// 有 PID 文件但进程未在正确端口运行 → 旧进程,先停掉
|
||||||
const pid = this.readPidFile(name)
|
const pid = this.readPidFile(name)
|
||||||
if (pid && this.isProcessAlive(pid)) {
|
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 { }
|
try { await this.stop(name) } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +587,7 @@ export class GatewayManager {
|
|||||||
try {
|
try {
|
||||||
await this.start(name)
|
await this.start(name)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[GatewayManager] ✗ ${name}: failed to start — ${err.message}`)
|
logger.error(err, '%s: failed to start', name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { execFile } from 'child_process'
|
import { execFile, spawn } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
|
import { logger } from '../logger'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ export async function listSessions(source?: string, limit?: number): Promise<Her
|
|||||||
}
|
}
|
||||||
return sessions
|
return sessions
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Hermes CLI] sessions export failed:', err.message)
|
logger.error(err, 'Hermes CLI: sessions export failed')
|
||||||
throw new Error(`Failed to list sessions: ${err.message}`)
|
throw new Error(`Failed to list sessions: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +175,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === 1 || err.status === 1) return null
|
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}`)
|
throw new Error(`Failed to get session: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +191,7 @@ export async function deleteSession(id: string): Promise<boolean> {
|
|||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Hermes CLI] session delete failed:', err.message)
|
logger.error(err, 'Hermes CLI: session delete failed')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,7 @@ export async function renameSession(id: string, title: string): Promise<boolean>
|
|||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Hermes CLI] session rename failed:', err.message)
|
logger.error(err, 'Hermes CLI: session rename failed')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +251,6 @@ export async function startGateway(): Promise<string> {
|
|||||||
* Uses "hermes gateway run" as a detached background process
|
* Uses "hermes gateway run" as a detached background process
|
||||||
*/
|
*/
|
||||||
export async function startGatewayBackground(): Promise<number | null> {
|
export async function startGatewayBackground(): Promise<number | null> {
|
||||||
const { spawn } = require('child_process') as typeof import('child_process')
|
|
||||||
const child = spawn(HERMES_BIN, ['gateway', 'run'], {
|
const child = spawn(HERMES_BIN, ['gateway', 'run'], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
@@ -311,7 +311,7 @@ export async function listLogFiles(): Promise<LogFileInfo[]> {
|
|||||||
}
|
}
|
||||||
return files
|
return files
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Hermes CLI] logs list failed:', err.message)
|
logger.error(err, 'Hermes CLI: logs list failed')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +339,7 @@ export async function readLogs(
|
|||||||
})
|
})
|
||||||
return stdout
|
return stdout
|
||||||
} catch (err: any) {
|
} 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}`)
|
throw new Error(`Failed to read logs: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,7 +396,7 @@ export async function listProfiles(): Promise<HermesProfile[]> {
|
|||||||
|
|
||||||
return profiles
|
return profiles
|
||||||
} catch (err: any) {
|
} 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}`)
|
throw new Error(`Failed to list profiles: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ export async function getProfile(name: string): Promise<HermesProfileDetail> {
|
|||||||
if (err.code === 1 || err.status === 1) {
|
if (err.code === 1 || err.status === 1) {
|
||||||
throw new Error(`Profile "${name}" not found`)
|
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}`)
|
throw new Error(`Failed to get profile: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,7 +456,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
|
|||||||
})
|
})
|
||||||
return stdout || stderr
|
return stdout || stderr
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Hermes CLI] profile create failed:', err.message)
|
logger.error(err, 'Hermes CLI: profile create failed')
|
||||||
throw new Error(`Failed to create profile: ${err.message}`)
|
throw new Error(`Failed to create profile: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +472,7 @@ export async function deleteProfile(name: string): Promise<boolean> {
|
|||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Hermes CLI] profile delete failed:', err.message)
|
logger.error(err, 'Hermes CLI: profile delete failed')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,7 +488,7 @@ export async function renameProfile(oldName: string, newName: string): Promise<b
|
|||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Hermes CLI] profile rename failed:', err.message)
|
logger.error(err, 'Hermes CLI: profile rename failed')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,7 +504,7 @@ export async function useProfile(name: string): Promise<string> {
|
|||||||
})
|
})
|
||||||
return stdout || stderr
|
return stdout || stderr
|
||||||
} catch (err: any) {
|
} 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}`)
|
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
|
return stdout || stderr
|
||||||
} catch (err: any) {
|
} 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}`)
|
throw new Error(`Failed to export profile: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,7 +539,7 @@ export async function setupReset(): Promise<string> {
|
|||||||
})
|
})
|
||||||
return stdout || stderr
|
return stdout || stderr
|
||||||
} catch (err: any) {
|
} 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}`)
|
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
|
return stdout || stderr
|
||||||
} catch (err: any) {
|
} 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}`)
|
throw new Error(`Failed to import profile: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { config } from '../../config'
|
import { config } from '../../config'
|
||||||
|
import { logger } from '../logger'
|
||||||
|
|
||||||
const UPSTREAM = config.upstream.replace(/\/$/, '')
|
const UPSTREAM = config.upstream.replace(/\/$/, '')
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export function emitWebhook(payload: any) {
|
|||||||
for (const cb of webhookCallbacks) {
|
for (const cb of webhookCallbacks) {
|
||||||
const result = cb(payload)
|
const result = cb(payload)
|
||||||
if (result && typeof result.catch === 'function') {
|
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'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { logger } from './logger'
|
||||||
|
|
||||||
export function bindShutdown(server: any): void {
|
export function bindShutdown(server: any): void {
|
||||||
let isShuttingDown = false
|
let isShuttingDown = false
|
||||||
|
|
||||||
@@ -5,19 +7,19 @@ export function bindShutdown(server: any): void {
|
|||||||
if (isShuttingDown) return
|
if (isShuttingDown) return
|
||||||
isShuttingDown = true
|
isShuttingDown = true
|
||||||
|
|
||||||
console.log(`\n[${signal}] shutting down...`)
|
logger.info('Shutting down (%s)...', signal)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (server) {
|
if (server) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log('✓ http server closed')
|
logger.info('HTTP server closed')
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('shutdown error:', err)
|
logger.error(err, 'Shutdown error')
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
@@ -26,14 +28,4 @@ export function bindShutdown(server: any): void {
|
|||||||
process.once('SIGUSR2', shutdown)
|
process.once('SIGUSR2', shutdown)
|
||||||
process.on('SIGINT', shutdown)
|
process.on('SIGINT', shutdown)
|
||||||
process.on('SIGTERM', 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')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export interface ProviderPreset {
|
|||||||
value: string
|
value: string
|
||||||
base_url: string
|
base_url: string
|
||||||
models: string[]
|
models: string[]
|
||||||
|
builtin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||||
{
|
{
|
||||||
label: 'Anthropic',
|
label: 'Anthropic',
|
||||||
value: 'anthropic',
|
value: 'anthropic',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.anthropic.com',
|
base_url: 'https://api.anthropic.com',
|
||||||
models: [
|
models: [
|
||||||
'claude-opus-4-7',
|
'claude-opus-4-7',
|
||||||
@@ -29,6 +31,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'Google AI Studio',
|
label: 'Google AI Studio',
|
||||||
value: 'gemini',
|
value: 'gemini',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||||
models: [
|
models: [
|
||||||
'gemini-3.1-pro-preview',
|
'gemini-3.1-pro-preview',
|
||||||
@@ -44,18 +47,28 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'DeepSeek',
|
label: 'DeepSeek',
|
||||||
value: 'deepseek',
|
value: 'deepseek',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.deepseek.com',
|
base_url: 'https://api.deepseek.com',
|
||||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Z.AI / GLM',
|
label: 'Z.AI / GLM',
|
||||||
value: 'zai',
|
value: 'zai',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.z.ai/api/paas/v4',
|
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'],
|
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',
|
label: 'Kimi for Coding',
|
||||||
value: 'kimi-coding-cn',
|
value: 'kimi-coding-cn',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.kimi.com/coding/v1',
|
base_url: 'https://api.kimi.com/coding/v1',
|
||||||
models: [
|
models: [
|
||||||
'kimi-for-coding',
|
'kimi-for-coding',
|
||||||
@@ -68,6 +81,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'Moonshot',
|
label: 'Moonshot',
|
||||||
value: 'moonshot',
|
value: 'moonshot',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.moonshot.cn/v1',
|
base_url: 'https://api.moonshot.cn/v1',
|
||||||
models: [
|
models: [
|
||||||
'kimi-k2.5',
|
'kimi-k2.5',
|
||||||
@@ -79,24 +93,28 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'xAI',
|
label: 'xAI',
|
||||||
value: 'xai',
|
value: 'xai',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.x.ai/v1',
|
base_url: 'https://api.x.ai/v1',
|
||||||
models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'],
|
models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'MiniMax',
|
label: 'MiniMax',
|
||||||
value: 'minimax',
|
value: 'minimax',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.minimax.io/anthropic/v1',
|
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'],
|
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)',
|
label: 'MiniMax (China)',
|
||||||
value: 'minimax-cn',
|
value: 'minimax-cn',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.minimaxi.com/v1',
|
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'],
|
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',
|
label: 'Alibaba Cloud',
|
||||||
value: 'alibaba',
|
value: 'alibaba',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||||
models: [
|
models: [
|
||||||
'qwen3.5-plus',
|
'qwen3.5-plus',
|
||||||
@@ -111,6 +129,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'Hugging Face',
|
label: 'Hugging Face',
|
||||||
value: 'huggingface',
|
value: 'huggingface',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://router.huggingface.co/v1',
|
base_url: 'https://router.huggingface.co/v1',
|
||||||
models: [
|
models: [
|
||||||
'Qwen/Qwen3.5-397B-A17B',
|
'Qwen/Qwen3.5-397B-A17B',
|
||||||
@@ -126,12 +145,14 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'Xiaomi MiMo',
|
label: 'Xiaomi MiMo',
|
||||||
value: 'xiaomi',
|
value: 'xiaomi',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.xiaomimimo.com/v1',
|
base_url: 'https://api.xiaomimimo.com/v1',
|
||||||
models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
|
models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Kilo Code',
|
label: 'Kilo Code',
|
||||||
value: 'kilocode',
|
value: 'kilocode',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.kilo.ai/api/gateway',
|
base_url: 'https://api.kilo.ai/api/gateway',
|
||||||
models: [
|
models: [
|
||||||
'anthropic/claude-opus-4.6',
|
'anthropic/claude-opus-4.6',
|
||||||
@@ -144,6 +165,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'Vercel AI Gateway',
|
label: 'Vercel AI Gateway',
|
||||||
value: 'ai-gateway',
|
value: 'ai-gateway',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://ai-gateway.vercel.sh/v1',
|
base_url: 'https://ai-gateway.vercel.sh/v1',
|
||||||
models: [
|
models: [
|
||||||
'anthropic/claude-opus-4.6',
|
'anthropic/claude-opus-4.6',
|
||||||
@@ -163,6 +185,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'OpenCode Zen',
|
label: 'OpenCode Zen',
|
||||||
value: 'opencode-zen',
|
value: 'opencode-zen',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://opencode.ai/zen/v1',
|
base_url: 'https://opencode.ai/zen/v1',
|
||||||
models: [
|
models: [
|
||||||
'gpt-5.4-pro',
|
'gpt-5.4-pro',
|
||||||
@@ -206,24 +229,28 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
label: 'OpenCode Go',
|
label: 'OpenCode Go',
|
||||||
value: 'opencode-go',
|
value: 'opencode-go',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://opencode.ai/zen/go/v1',
|
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'],
|
models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'OpenAI Codex',
|
label: 'OpenAI Codex',
|
||||||
value: 'openai-codex',
|
value: 'openai-codex',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
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'],
|
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',
|
label: 'Arcee AI',
|
||||||
value: 'arcee',
|
value: 'arcee',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://api.arcee.ai/v1',
|
base_url: 'https://api.arcee.ai/v1',
|
||||||
models: ['trinity-large-thinking', 'trinity-large-preview', 'trinity-mini'],
|
models: ['trinity-large-thinking', 'trinity-large-preview', 'trinity-mini'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'OpenRouter',
|
label: 'OpenRouter',
|
||||||
value: 'openrouter',
|
value: 'openrouter',
|
||||||
|
builtin: true,
|
||||||
base_url: 'https://openrouter.ai/api/v1',
|
base_url: 'https://openrouter.ai/api/v1',
|
||||||
models: [],
|
models: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2024",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "../../dist/server",
|
|
||||||
"rootDir": "src",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"declaration": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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: `<span class="mock-${language}">${content}</span>`,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
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: `<span class="mock-${language}">${content}</span>`,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
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</span>')
|
||||||
|
})
|
||||||
|
|
||||||
|
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</span>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to escaped plaintext for unsupported fence labels', () => {
|
||||||
|
const html = renderHighlightedCodeBlock('<tag>', 'unknown', 'Copy')
|
||||||
|
|
||||||
|
expect(highlightJsMock.highlight).not.toHaveBeenCalled()
|
||||||
|
expect(html).toContain('<tag>')
|
||||||
|
expect(html).toContain('class="code-lang">unknown</span>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to escaped plaintext when direct highlighting throws', () => {
|
||||||
|
highlightJsMock.highlight.mockImplementationOnce(() => {
|
||||||
|
throw new Error('boom')
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderHighlightedCodeBlock('<tag>', 'vue', 'Copy')
|
||||||
|
|
||||||
|
expect(html).toContain('<tag>')
|
||||||
|
expect(html).toContain('class="code-lang">vue</span>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('<img src=x onerror=alert(1)>'.repeat(100), 'unknown', 'Copy')
|
||||||
|
|
||||||
|
expect(html).toContain('<img')
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit executable HTML for known-language code', () => {
|
||||||
|
const html = renderHighlightedCodeBlock('<script>alert(1)</script>', 'xml', 'Copy')
|
||||||
|
|
||||||
|
expect(html).not.toContain('<script>')
|
||||||
|
expect(html).toContain('<')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes the language label', () => {
|
||||||
|
const html = renderHighlightedCodeBlock('x'.repeat(5000), '<script>alert(1)</script>', 'Copy')
|
||||||
|
|
||||||
|
expect(html).toContain('<script>alert(1)</script>')
|
||||||
|
expect(html).not.toContain('<script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes the language class', () => {
|
||||||
|
const html = renderHighlightedCodeBlock('x'.repeat(5000), 'foo bar"><img', 'Copy')
|
||||||
|
|
||||||
|
expect(html).toContain('language-foo-bar---img')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes the copy label', () => {
|
||||||
|
const html = renderHighlightedCodeBlock('x', 'json', 'Copy <now>')
|
||||||
|
|
||||||
|
expect(html).toContain('Copy <now>')
|
||||||
|
expect(html).not.toContain('Copy <now>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||||
|
|
||||||
|
describe('MarkdownRenderer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
writeText: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights vue fenced blocks instead of rendering them as plain text', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '```vue\n<template><div>Hello</div></template>\n```',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.code-lang').text()).toBe('vue')
|
||||||
|
expect(wrapper.find('code.hljs').html()).toContain('hljs-tag')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps shell-session fences on the shell grammar', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '```shell\n$ ls\nfoo.txt\n```',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.code-lang').text()).toBe('shell')
|
||||||
|
expect(wrapper.find('code.hljs').html()).toContain('hljs-meta')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still highlights long supported code fences', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: `\`\`\`json\n${JSON.stringify({ content: 'x'.repeat(2500), ok: true })}\n\`\`\``,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.code-lang').text()).toBe('json')
|
||||||
|
expect(wrapper.find('code.hljs').html()).toMatch(/hljs-(attr|string|punctuation)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to plain escaped text when a fence language is unsupported', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '```foobar\n{"answer":42,"ok":true}\n```',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.code-lang').text()).toBe('foobar')
|
||||||
|
expect(wrapper.find('code.hljs').findAll('span')).toHaveLength(0)
|
||||||
|
expect(wrapper.find('code.hljs').text()).toContain('{"answer":42,"ok":true}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps unlabeled code fences as plain text instead of guessing a grammar', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '```\nINFO Starting server\nConnected to 127.0.0.1\nDone\n```',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.code-lang').text()).toBe('text')
|
||||||
|
expect(wrapper.find('code.hljs').findAll('span')).toHaveLength(0)
|
||||||
|
expect(wrapper.find('code.hljs').text()).toContain('INFO Starting server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies code through the delegated click handler', async () => {
|
||||||
|
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '```ts\nconst answer = 42\n```',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const expected = wrapper.find('code.hljs').element.textContent ?? ''
|
||||||
|
await wrapper.find('[data-copy-code="true"]').trigger('click')
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import MessageItem from '@/components/hermes/chat/MessageItem.vue'
|
||||||
|
import type { Message } from '@/stores/hermes/chat'
|
||||||
|
|
||||||
|
describe('MessageItem tool details', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
writeText: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders highlighted code blocks for tool arguments and tool results', async () => {
|
||||||
|
const wrapper = mount(MessageItem, {
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
id: 'tool-1',
|
||||||
|
role: 'tool',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolName: 'web_search',
|
||||||
|
toolArgs: '{"query":"syntax highlighting"}',
|
||||||
|
toolResult: '{"results":[{"title":"Done"}]}',
|
||||||
|
toolStatus: 'done',
|
||||||
|
} satisfies Message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('.tool-line').trigger('click')
|
||||||
|
|
||||||
|
const blocks = wrapper.findAll('.tool-details .hljs-code-block')
|
||||||
|
expect(blocks).toHaveLength(2)
|
||||||
|
expect(blocks[0].find('.code-lang').text()).toBe('json')
|
||||||
|
expect(blocks[1].find('.code-lang').text()).toBe('json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies tool detail code through the delegated click handler', async () => {
|
||||||
|
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||||
|
const wrapper = mount(MessageItem, {
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
id: 'tool-copy',
|
||||||
|
role: 'tool',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolName: 'web_search',
|
||||||
|
toolArgs: '{"query":"syntax highlighting"}',
|
||||||
|
toolStatus: 'done',
|
||||||
|
} satisfies Message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('.tool-line').trigger('click')
|
||||||
|
|
||||||
|
const expected = wrapper.find('.tool-details code.hljs').text()
|
||||||
|
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates large tool arguments for display but copies the full formatted payload', async () => {
|
||||||
|
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||||
|
const message = {
|
||||||
|
content: 'x'.repeat(4000),
|
||||||
|
ok: true,
|
||||||
|
}
|
||||||
|
const wrapper = mount(MessageItem, {
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
id: 'tool-args-large',
|
||||||
|
role: 'tool',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolName: 'write_file',
|
||||||
|
toolArgs: JSON.stringify(message),
|
||||||
|
toolStatus: 'done',
|
||||||
|
} satisfies Message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('.tool-line').trigger('click')
|
||||||
|
|
||||||
|
const expected = JSON.stringify(message, null, 2)
|
||||||
|
const code = wrapper.find('.tool-details code.hljs')
|
||||||
|
expect(wrapper.find('.tool-details .code-lang').text()).toBe('json')
|
||||||
|
expect(wrapper.html()).toContain('chat.truncated')
|
||||||
|
expect(code.findAll('span')).toHaveLength(0)
|
||||||
|
|
||||||
|
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies the full large JSON tool result even when the display is truncated', async () => {
|
||||||
|
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||||
|
const fullResult = {
|
||||||
|
content: 'x'.repeat(4000),
|
||||||
|
ok: true,
|
||||||
|
}
|
||||||
|
const wrapper = mount(MessageItem, {
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
id: 'tool-2',
|
||||||
|
role: 'tool',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolName: 'read_file',
|
||||||
|
toolResult: JSON.stringify(fullResult),
|
||||||
|
toolStatus: 'done',
|
||||||
|
} satisfies Message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('.tool-line').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.find('.tool-details .code-lang').text()).toBe('json')
|
||||||
|
expect(wrapper.html()).toContain('chat.truncated')
|
||||||
|
expect(wrapper.find('.tool-details code.hljs').findAll('span')).toHaveLength(0)
|
||||||
|
|
||||||
|
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||||
|
expect(writeText).toHaveBeenCalledWith(JSON.stringify(fullResult, null, 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies the full large raw tool result even when the display is truncated', async () => {
|
||||||
|
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||||
|
const fullResult = 'line\n'.repeat(1200)
|
||||||
|
const wrapper = mount(MessageItem, {
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
id: 'tool-raw',
|
||||||
|
role: 'tool',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolName: 'read_file',
|
||||||
|
toolResult: fullResult,
|
||||||
|
toolStatus: 'done',
|
||||||
|
} satisfies Message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('.tool-line').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.find('.tool-details .code-lang').text()).toBe('text')
|
||||||
|
expect(wrapper.html()).toContain('chat.truncated')
|
||||||
|
expect(wrapper.find('.tool-details code.hljs').findAll('span')).toHaveLength(0)
|
||||||
|
|
||||||
|
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||||
|
expect(writeText).toHaveBeenCalledWith(fullResult)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'packages/client/src'),
|
'@': resolve(__dirname, 'packages/client/src'),
|
||||||
|
'/logo.png': resolve(__dirname, 'packages/client/src/assets/logo.png'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user