diff --git a/README.md b/README.md index 091e0ce..0da5496 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hermes Web UI -Web dashboard for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — chat interaction, session management, scheduled jobs, and log viewing. +Web dashboard for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — chat interaction, session management, scheduled jobs, platform channel configuration, and log viewing. ![Hermes Web UI Demo](https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/src/assets/output.gif) @@ -12,6 +12,7 @@ Web dashboard for [Hermes Agent](https://github.com/NousResearch/hermes-agent) - **Naive UI** — Component library - **Pinia** — State management - **Vue Router** — Routing (Hash mode) +- **vue-i18n** — Internationalization (Chinese / English) - **Koa 2** — BFF server (API proxy, file upload, session management) - **SCSS** — Style preprocessor - **markdown-it** + **highlight.js** — Markdown rendering and code highlighting @@ -72,6 +73,8 @@ hermes-web-ui/ │ ├── config.ts # Configuration (port, upstream, etc.) │ ├── routes/ │ │ ├── proxy.ts # API proxy to Hermes (/api/*, /v1/*) +│ │ ├── config.ts # Config & credentials management +│ │ ├── weixin.ts # WeChat QR code login proxy │ │ ├── upload.ts # File upload (POST /upload) │ │ ├── sessions.ts # Session management via Hermes CLI │ │ ├── filesystem.ts # Skills, memory, config model management @@ -80,19 +83,34 @@ hermes-web-ui/ │ └── services/ │ └── hermes-cli.ts # Hermes CLI wrapper (sessions, logs, version) ├── src/ +│ ├── i18n/ # Internationalization (en / zh) +│ │ ├── index.ts # i18n instance setup +│ │ └── locales/ +│ │ ├── en.ts # English translations +│ │ └── zh.ts # Chinese translations │ ├── api/ # Frontend API layer │ ├── stores/ # Pinia state management │ ├── components/ │ │ ├── layout/ │ │ │ ├── AppSidebar.vue # Sidebar navigation +│ │ │ ├── LanguageSwitch.vue # Language toggle (EN / 中文) │ │ │ └── ModelSelector.vue # Global model selector │ │ ├── chat/ # Chat components -│ │ └── jobs/ # Job components +│ │ ├── jobs/ # Job components +│ │ ├── models/ # Model/provider components +│ │ ├── settings/ # Settings components +│ │ │ ├── PlatformCard.vue # Platform card with config status +│ │ │ └── PlatformSettings.vue # Platform channel configuration +│ │ └── skills/ # Skill components │ ├── views/ │ │ ├── ChatView.vue # Chat page │ │ ├── JobsView.vue # Jobs page │ │ ├── LogsView.vue # Logs page -│ │ └── SettingsView.vue # Settings (model management) +│ │ ├── ModelsView.vue # Model management page +│ │ ├── ChannelsView.vue # Platform channels page +│ │ ├── SkillsView.vue # Skills page +│ │ ├── MemoryView.vue # Memory page +│ │ └── SettingsView.vue # Settings page │ └── router/index.ts # Router configuration └── dist/ # Build output (published to npm) ├── server/index.js # Compiled BFF @@ -106,35 +124,62 @@ hermes-web-ui/ - Async Run + SSE event streaming via BFF proxy - Session management via Hermes CLI - Multi-session switching with message history +- Session grouping by source (Telegram, Discord, Slack, etc.) with collapsible accordion +- Session rename and deletion - Markdown rendering with syntax highlighting and code copy +- Tool call detail expansion (arguments / result) - File upload support (saved to temp, path passed to API) - Model selector — automatically discovers available models from `~/.hermes/auth.json` credential pool - Global model switching (updates `~/.hermes/config.yaml`) - Per-session model display (badge in chat header and session list) +### Platform Channels +- Unified channel configuration page (Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, WeChat, WeCom) +- Credential management — writes to `~/.hermes/.env` (matching `hermes gateway setup` behavior) +- Channel behavior settings — writes to `~/.hermes/config.yaml` +- WeChat QR code login — opens QR in browser, polls scan status, auto-saves credentials +- Auto gateway restart after any channel config change +- Per-platform configured/unconfigured status detection + ### Model Management - Automatically reads credential pool from `~/.hermes/auth.json` - Fetches available models from each provider endpoint (`/v1/models`) - Groups models by provider (e.g. zai, subrouter.ai) +- Add custom OpenAI-compatible providers - Switching model updates `model.provider` in config.yaml to bypass env auto-detection - Error handling: parallel fetching, per-provider timeout, fallback to config.yaml parsing +### Settings +- Display settings (streaming, compact mode, reasoning, cost, etc.) +- Agent settings (max turns, timeout, tool enforcement) +- Memory settings (enable/disable, char limits) +- Session reset settings (idle timeout, scheduled reset) +- Privacy settings (PII redaction) +- API server settings + ### Scheduled Jobs - Job list view (including paused/disabled jobs) - Create, edit, pause, resume, and delete jobs - Trigger immediate job execution - Cron expression quick presets +### Skills & Memory +- Browse and search installed skills +- View skill details and attached files +- User notes and profile management + ### Logs - View Hermes agent/gateway/error logs - Filter by log level, log file, and search keyword - Structured log parsing with HTTP access log highlighting ### Other +- Internationalization — auto-detect browser language, manual toggle between Chinese and English - Real-time connection status monitoring - Hermes version display in sidebar - Auto config check on startup - Minimalist dark theme +- Session group collapse state persisted across navigation ## Architecture @@ -142,6 +187,10 @@ hermes-web-ui/ Browser → BFF (Koa, :8648) → Hermes API (:8642) ↓ Hermes CLI (sessions, logs, version) + ↓ + ~/.hermes/config.yaml (channel behavior) + ~/.hermes/.env (platform credentials) + Tencent iLink API (WeChat QR login) ``` The BFF layer handles: @@ -149,8 +198,10 @@ The BFF layer handles: - SSE streaming passthrough - File upload to temp directory - Session CRUD via Hermes CLI +- Config & credential management (config.yaml + .env) +- WeChat QR code login flow (fetch QR, poll status, save credentials) +- Auto gateway restart on platform config changes - Model discovery from `~/.hermes/auth.json` credential pool -- Config.yaml model switching (reads/writes `~/.hermes/config.yaml`) - Skills, memory, and custom provider management - Log file reading and parsing - Static file serving (SPA fallback) diff --git a/package.json b/package.json index c0f8edc..9e11fe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.1.5", + "version": "0.1.7", "description": "Hermes Agent Web UI - Chat and Job Management Dashboard", "repository": { "type": "git", @@ -28,16 +28,20 @@ "@koa/cors": "^5.0.0", "@koa/router": "^13.1.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", "pinia": "^3.0.4", + "qrcode": "^1.5.4", "vue": "^3.5.32", + "vue-i18n": "^11.3.2", "vue-router": "^4.6.4" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/koa": "^2.15.0", "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.4", @@ -45,6 +49,7 @@ "@types/koa-static": "^4.0.4", "@types/markdown-it": "^14.1.2", "@types/node": "^24.12.2", + "@types/qrcode": "^1.5.6", "@vitejs/plugin-vue": "^6.0.5", "@vue/tsconfig": "^0.9.1", "concurrently": "^9.2.1", diff --git a/server/src/index.ts b/server/src/index.ts index f7e8ba9..a006179 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,6 +12,8 @@ import { sessionRoutes } from './routes/sessions' import { webhookRoutes } from './routes/webhook' import { logRoutes } from './routes/logs' import { fsRoutes } from './routes/filesystem' +import { configRoutes } from './routes/config' +import { weixinRoutes } from './routes/weixin' import * as hermesCli from './services/hermes-cli' const { restartGateway } = hermesCli @@ -30,6 +32,8 @@ export async function bootstrap() { app.use(uploadRoutes.routes()) app.use(sessionRoutes.routes()) app.use(fsRoutes.routes()) + app.use(configRoutes.routes()) + app.use(weixinRoutes.routes()) // Health endpoint with version app.use(async (ctx, next) => { diff --git a/server/src/routes/config.ts b/server/src/routes/config.ts new file mode 100644 index 0000000..f3a7ba4 --- /dev/null +++ b/server/src/routes/config.ts @@ -0,0 +1,315 @@ +import Router from '@koa/router' +import { readFile, writeFile, copyFile } from 'fs/promises' +import { chmod } from 'fs/promises' +import { resolve } from 'path' +import { homedir } from 'os' +import YAML from 'js-yaml' +import { restartGateway } from '../services/hermes-cli' + +// 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 = resolve(homedir(), '.hermes/config.yaml') +const envPath = resolve(homedir(), '.hermes/.env') + +// Env var → (platform, configPath in PlatformConfig) mapping +// Matches hermes _apply_env_overrides() in gateway/config.py +const envPlatformMap: Record = { + TELEGRAM_BOT_TOKEN: ['telegram', 'token'], + DISCORD_BOT_TOKEN: ['discord', 'token'], + SLACK_BOT_TOKEN: ['slack', 'token'], + MATRIX_ACCESS_TOKEN: ['matrix', 'token'], + MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'], + FEISHU_APP_ID: ['feishu', 'extra.app_id'], + FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'], + DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'], + DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'], + // DingTalk has no _apply_env_overrides entry in hermes; + // the adapter reads these env vars directly at runtime. + DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'], + WECOM_BOT_ID: ['wecom', 'extra.bot_id'], + WECOM_SECRET: ['wecom', 'extra.secret'], + WEIXIN_TOKEN: ['weixin', 'token'], + WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'], + WEIXIN_BASE_URL: ['weixin', 'extra.base_url'], + WHATSAPP_ENABLED: ['whatsapp', 'enabled'], +} + +// Reverse map: (platform, configPath) → env var +const platformEnvMap: Record> = {} +for (const [envVar, [platform, configPath]] of Object.entries(envPlatformMap)) { + if (!platformEnvMap[platform]) platformEnvMap[platform] = {} + platformEnvMap[platform][configPath] = envVar +} + +function parseEnv(raw: string): Record { + const env: Record = {} + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + const key = trimmed.slice(0, eqIdx).trim() + const val = trimmed.slice(eqIdx + 1).trim() + if (val) env[key] = val + } + return env +} + +function setNested(obj: Record, path: string, value: any) { + const parts = path.split('.') + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { + if (!cur[parts[i]]) cur[parts[i]] = {} + cur = cur[parts[i]] + } + cur[parts[parts.length - 1]] = value +} + +function getNested(obj: Record, path: string): any { + const parts = path.split('.') + let cur = obj + for (const p of parts) { + if (!cur || typeof cur !== 'object') return undefined + cur = cur[p] + } + return cur +} + +async function readEnvPlatforms(): Promise> { + try { + const raw = await readFile(envPath, 'utf-8') + const env = parseEnv(raw) + const platforms: Record = {} + for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) { + const val = env[envKey] + if (val === undefined || val === '') continue + if (!platforms[platform]) platforms[platform] = {} + let finalVal: any = val + if (cfgPath === 'enabled') finalVal = val === 'true' + setNested(platforms[platform], cfgPath, finalVal) + } + return platforms + } catch { + return {} + } +} + +// Write a KEY=value to .env (matching hermes save_env_value behavior) +// If value is empty, remove the line instead +async function saveEnvValue(key: string, value: string): Promise { + let raw: string + try { + raw = await readFile(envPath, 'utf-8') + } catch { + raw = '' + } + + const remove = !value + const lines = raw.split('\n') + let found = false + const result: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('#')) { + // Check if there's a commented-out version of this key + if (trimmed.startsWith(`# ${key}=`)) { + if (!remove) { + result.push(`${key}=${value}`) + } + found = true + } else { + result.push(line) + } + } else { + const eqIdx = trimmed.indexOf('=') + if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) { + if (!remove) { + result.push(`${key}=${value}`) + } + found = true + } else { + result.push(line) + } + } + } + + if (!found && !remove) { + result.push(`${key}=${value}`) + } + + // Remove trailing empty lines, keep exactly one trailing newline + let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' + await writeFile(envPath, output, 'utf-8') + // Set permissions to 0600 (owner only), matching hermes behavior + try { await chmod(envPath, 0o600) } catch { /* ignore */ } +} + +async function readConfig(): Promise> { + const raw = await readFile(configPath, 'utf-8') + return (YAML.load(raw) as Record) || {} +} + +async function writeConfig(data: Record): Promise { + await copyFile(configPath, configPath + '.bak') + const yamlStr = YAML.dump(data, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false, + }) + await writeFile(configPath, yamlStr, 'utf-8') +} + +export const configRoutes = new Router() + +// GET /api/config — read config sections +configRoutes.get('/api/config', async (ctx) => { + try { + const config = await readConfig() + // Merge .env platform credentials into platforms section + const envPlatforms = await readEnvPlatforms() + if (Object.keys(envPlatforms).length > 0) { + // Deep-merge: env values fill in missing, don't overwrite config.yaml + const existing = config.platforms || {} + for (const [platform, vals] of Object.entries(envPlatforms)) { + existing[platform] = { ...(existing[platform] || {}), ...(vals as Record) } + } + config.platforms = existing + } + const { section, sections } = ctx.query + + if (section) { + ctx.body = { [section as string]: config[section as string] || {} } + } else if (sections) { + const keys = (sections as string).split(',') + const result: Record = {} + for (const key of keys) { + result[key.trim()] = config[key.trim()] || {} + } + ctx.body = result + } else { + ctx.body = config + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// PUT /api/config — update a config section (writes to config.yaml) +configRoutes.put('/api/config', async (ctx) => { + const { section, values } = ctx.request.body as { + section: string + values: Record + } + + if (!section || !values) { + ctx.status = 400 + ctx.body = { error: 'Missing section or values' } + return + } + + try { + const config = await readConfig() + config[section] = { ...(config[section] || {}), ...values } + await writeConfig(config) + // Restart gateway for platform/channel config changes + if (PLATFORM_SECTIONS.has(section)) { + await restartGateway() + } + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// PUT /api/config/credentials — save platform credentials to .env +// Body: { platform: string, values: Record } +// values keys match PlatformConfig paths: 'token', 'extra.app_id', 'extra.app_secret', etc. +configRoutes.put('/api/config/credentials', async (ctx) => { + const { platform, values } = ctx.request.body as { + platform: string + values: Record + } + + if (!platform || !values) { + ctx.status = 400 + ctx.body = { error: 'Missing platform or values' } + return + } + + try { + const envMap = platformEnvMap[platform] + if (!envMap) { + ctx.status = 400 + ctx.body = { error: `Unknown platform: ${platform}` } + return + } + + // Also clean up config.yaml platforms. to keep in sync + const config = await readConfig() + let configChanged = false + + // Flatten nested values: { extra: { app_id: '' } } → { 'extra.app_id': '' } + const flatValues: Record = {} + for (const [key, val] of Object.entries(values)) { + if (key === 'extra' && val && typeof val === 'object') { + for (const [subKey, subVal] of Object.entries(val as Record)) { + flatValues[`extra.${subKey}`] = subVal + } + } else { + flatValues[key] = val + } + } + + for (const [cfgPath, val] of Object.entries(flatValues)) { + const envVar = envMap[cfgPath] + if (!envVar) continue + if (val === undefined || val === null || val === '') { + await saveEnvValue(envVar, '') + // Remove from config.yaml too + const parts = cfgPath.split('.') + let obj: any = config.platforms?.[platform] + if (obj) { + if (parts.length === 1) { + delete obj[parts[0]] + } else { + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { + if (!cur[parts[i]]) break + cur = cur[parts[i]] + } + delete cur[parts[parts.length - 1]] + // Clean up empty extra + if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra + } + if (Object.keys(obj).length === 0) { + if (!config.platforms) config.platforms = {} + delete config.platforms[platform] + } + configChanged = true + } + } else { + await saveEnvValue(envVar, String(val)) + } + } + + if (configChanged) { + await writeConfig(config) + } + + // Restart gateway for platform credential changes + await restartGateway() + + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) diff --git a/server/src/routes/filesystem.ts b/server/src/routes/filesystem.ts index 980bca7..7ad6242 100644 --- a/server/src/routes/filesystem.ts +++ b/server/src/routes/filesystem.ts @@ -51,6 +51,10 @@ async function fetchProviderModels(baseUrl: string, apiKey: string): Promise { }) } - // Fetch all provider models in parallel - const results = await Promise.allSettled( - endpoints.map(async ep => { - const models = await fetchProviderModels(ep.base_url, ep.token) - return { ...ep, models } - }), - ) - + // Resolve models: hardcoded catalog first, live probe as fallback const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = [] - for (const result of results) { - if (result.status === 'fulfilled' && result.value.models.length > 0) { - const { key, label, base_url, models } = result.value - groups.push({ provider: key, label, base_url, models }) - } else if (result.status === 'rejected') { - console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`) + const liveEndpoints: typeof endpoints = [] + + for (const ep of endpoints) { + const catalogModels = PROVIDER_MODEL_CATALOG[ep.key] + if (catalogModels && catalogModels.length > 0) { + groups.push({ provider: ep.key, label: ep.label, base_url: ep.base_url, models: catalogModels }) + } else { + liveEndpoints.push(ep) + } + } + + // Only probe endpoints not in the catalog + if (liveEndpoints.length > 0) { + const results = await Promise.allSettled( + liveEndpoints.map(async ep => { + const models = await fetchProviderModels(ep.base_url, ep.token) + return { ...ep, models } + }), + ) + + for (const result of results) { + if (result.status === 'fulfilled' && result.value.models.length > 0) { + const { key, label, base_url, models } = result.value + groups.push({ provider: key, label, base_url, models }) + } else if (result.status === 'rejected') { + console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`) + } } } @@ -457,11 +479,12 @@ fsRoutes.put('/api/config/model', async (ctx) => { // POST /api/config/providers fsRoutes.post('/api/config/providers', async (ctx) => { - const { name, base_url, api_key, model } = ctx.request.body as { + 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) { @@ -470,11 +493,18 @@ fsRoutes.post('/api/config/providers', async (ctx) => { return } + if (!api_key) { + ctx.status = 400 + ctx.body = { error: 'Missing API key' } + return + } + try { + // 1. Write to config.yaml custom_providers await copyFile(configPath, configPath + '.bak') let yaml = await safeReadFile(configPath) || '' - const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key || ''}\n model: ${model}\n` + const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key}\n model: ${model}\n` if (/^custom_providers:/m.test(yaml)) { yaml = yaml.replace(/^(custom_providers:)/m, `$1\n${newEntry}`) @@ -483,6 +513,37 @@ fsRoutes.post('/api/config/providers', async (ctx) => { } await writeFile(configPath, yaml, 'utf-8') + + // 2. Write to auth.json credential_pool so GET /api/available-models sees it immediately + const poolKey = providerKey + || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}` + const auth = await loadAuthJson() || { credential_pool: {} } + if (!auth.credential_pool) auth.credential_pool = {} + + // Don't overwrite existing entries for built-in providers + if (!auth.credential_pool[poolKey]) { + auth.credential_pool[poolKey] = [] + } + + auth.credential_pool[poolKey].push({ + id: `${poolKey}-${Date.now()}`, + label: name, + base_url, + access_token: api_key, + last_status: null, + }) + + await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8') + + // 3. Auto-switch model to the newly added provider + let yaml2 = await safeReadFile(configPath) || '' + const modelBlockMatch = yaml2.match(/^(model:\s*\n(?: .+\n)*)/m) + if (modelBlockMatch) { + const lines = [`model:`, ` default: ${model}`, ` provider: ${poolKey}`] + yaml2 = yaml2.replace(modelBlockMatch[1], lines.join('\n') + '\n') + await writeFile(configPath, yaml2, 'utf-8') + } + ctx.body = { success: true } } catch (err: any) { ctx.status = 500 @@ -490,19 +551,78 @@ fsRoutes.post('/api/config/providers', async (ctx) => { } }) -// DELETE /api/config/providers/:name -fsRoutes.delete('/api/config/providers/:name', async (ctx) => { - const name = ctx.params.name +// DELETE /api/config/providers/:poolKey +fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => { + const poolKey = decodeURIComponent(ctx.params.poolKey) try { - await copyFile(configPath, configPath + '.bak') - let yaml = await safeReadFile(configPath) || '' + const auth = await loadAuthJson() + if (!auth?.credential_pool) { + ctx.status = 404 + ctx.body = { error: 'No credential pool found' } + return + } - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const blockRegex = new RegExp(` - name:\\s*${escaped}\\s*\\n(?: .+\\n)*`, 'g') - yaml = yaml.replace(blockRegex, '') + const keys = Object.keys(auth.credential_pool) + + // Guard: cannot delete the last provider + if (keys.length <= 1) { + ctx.status = 400 + ctx.body = { error: 'Cannot delete the last provider' } + return + } + + if (!(poolKey in auth.credential_pool)) { + ctx.status = 404 + ctx.body = { error: `Provider "${poolKey}" not found` } + return + } + + // Check if this is the current active provider + const yaml = await safeReadFile(configPath) || '' + const providerMatch = yaml.match(/^ provider:\s*(.+)$/m) + const isCurrent = providerMatch && providerMatch[1].trim() === poolKey + + // Save base_url before deleting (needed for config.yaml cleanup) + const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url + + // 1. Delete from auth.json + delete auth.credential_pool[poolKey] + await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8') + + // 2. Remove matching entry from config.yaml custom_providers + // Use base_url to match — more reliable than name (preset key ≠ display name) + if (deletedBaseUrl) { + await copyFile(configPath, configPath + '.bak') + let newYaml = await safeReadFile(configPath) || '' + const entryRegex = new RegExp( + `^- name:.*\\n(?:[ \\t]+.*\\n)*? base_url:\\s*${escapeRegExp(deletedBaseUrl)}\\s*\\n(?:[ \\t]+.*\\n)*`, + 'gm', + ) + newYaml = newYaml.replace(entryRegex, '').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n' + await writeFile(configPath, newYaml, 'utf-8') + } + + // 3. If was the current provider, switch to first remaining + if (isCurrent) { + const remainingKeys = Object.keys(auth.credential_pool) + if (remainingKeys.length > 0) { + const fallback = remainingKeys[0] + const fallbackEntry = auth.credential_pool[fallback]?.[0] + const catalogModels = PROVIDER_MODEL_CATALOG[fallback] || [] + const fallbackModel = catalogModels[0] || fallbackEntry?.label || fallback + + await copyFile(configPath, configPath + '.bak') + let newYaml = await safeReadFile(configPath) || '' + const modelBlockMatch = newYaml.match(/^(model:\s*\n(?: .+\n)*)/m) + if (modelBlockMatch) { + const lines = [`model:`, ` default: ${fallbackModel}`, ` provider: ${fallback}`] + newYaml = newYaml.replace(modelBlockMatch[1], lines.join('\n') + '\n') + await writeFile(configPath, newYaml, 'utf-8') + } + } + } - await writeFile(configPath, yaml, 'utf-8') ctx.body = { success: true } } catch (err: any) { ctx.status = 500 diff --git a/server/src/routes/weixin.ts b/server/src/routes/weixin.ts new file mode 100644 index 0000000..ef9bf85 --- /dev/null +++ b/server/src/routes/weixin.ts @@ -0,0 +1,136 @@ +import Router from '@koa/router' +import axios from 'axios' +import { readFile, writeFile } from 'fs/promises' +import { chmod } from 'fs/promises' +import { resolve } from 'path' +import { homedir } from 'os' +import { restartGateway } from '../services/hermes-cli' + +const envPath = resolve(homedir(), '.hermes/.env') +const ILINK_BASE = 'https://ilinkai.weixin.qq.com' + +export const weixinRoutes = new Router() + +// GET /api/weixin/qrcode — fetch QR code from Tencent iLink API +weixinRoutes.get('/api/weixin/qrcode', async (ctx) => { + try { + const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, { + params: { bot_type: 3 }, + timeout: 15000, + }) + const data = res.data + if (!data || !data.qrcode) { + ctx.status = 500 + ctx.body = { error: 'Failed to get QR code' } + return + } + ctx.body = { + qrcode: data.qrcode, + qrcode_url: data.qrcode_img_content, + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message || 'Failed to connect to iLink API' } + } +}) + +// GET /api/weixin/qrcode/status — poll QR scan status +weixinRoutes.get('/api/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/weixin/save', async (ctx) => { + const { account_id, token, base_url } = ctx.request.body as { + account_id: string + token: string + base_url?: string + } + + if (!account_id || !token) { + ctx.status = 400 + ctx.body = { error: 'Missing account_id or token' } + return + } + + try { + let raw: string + try { + raw = await readFile(envPath, 'utf-8') + } catch { + raw = '' + } + + const entries: Record = { + WEIXIN_ACCOUNT_ID: account_id, + WEIXIN_TOKEN: token, + } + if (base_url) entries.WEIXIN_BASE_URL = base_url + + const lines = raw.split('\n') + const existingKeys = new Set() + + const result: string[] = [] + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('#')) { + result.push(line) + continue + } + const eqIdx = trimmed.indexOf('=') + if (eqIdx !== -1) { + const key = trimmed.slice(0, eqIdx).trim() + if (key in entries) { + result.push(`${key}=${entries[key]}`) + existingKeys.add(key) + continue + } + } + result.push(line) + } + + for (const [key, val] of Object.entries(entries)) { + if (!existingKeys.has(key)) { + result.push(`${key}=${val}`) + } + } + + let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' + await writeFile(envPath, output, 'utf-8') + try { await chmod(envPath, 0o600) } catch { /* ignore */ } + await restartGateway() + + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) diff --git a/server/src/shared/providers.ts b/server/src/shared/providers.ts new file mode 100644 index 0000000..26868fa --- /dev/null +++ b/server/src/shared/providers.ts @@ -0,0 +1,215 @@ +/** + * Provider registry — single source of truth for both frontend and backend. + * Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS. + */ + +export interface ProviderPreset { + label: string + value: string + base_url: string + models: string[] +} + +export const PROVIDER_PRESETS: ProviderPreset[] = [ + { + label: 'Anthropic', + value: 'anthropic', + base_url: 'https://api.anthropic.com', + models: [ + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-opus-4-5-20251101', + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-20250514', + 'claude-sonnet-4-20250514', + 'claude-haiku-4-5-20251001', + ], + }, + { + label: 'Google AI Studio', + value: 'gemini', + base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', + models: [ + 'gemini-3.1-pro-preview', + 'gemini-3-flash-preview', + 'gemini-3.1-flash-lite-preview', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + 'gemma-4-31b-it', + 'gemma-4-26b-it', + ], + }, + { + label: 'DeepSeek', + value: 'deepseek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-chat', 'deepseek-reasoner'], + }, + { + label: 'Z.AI / GLM', + value: 'zai', + base_url: 'https://api.z.ai/api/paas/v4', + models: ['glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'], + }, + { + label: 'Kimi Coding Plan', + value: 'kimi-coding', + base_url: 'https://api.kimi.com/coding/v1', + models: [ + 'kimi-for-coding', + 'kimi-k2.5', + 'kimi-k2-thinking', + 'kimi-k2-thinking-turbo', + 'kimi-k2-turbo-preview', + 'kimi-k2-0905-preview', + ], + }, + { + label: 'Moonshot (Pay-as-you-go)', + value: 'moonshot', + base_url: 'https://api.moonshot.ai/v1', + models: ['kimi-k2.5', 'kimi-k2-thinking', 'kimi-k2-turbo-preview', 'kimi-k2-0905-preview'], + }, + { + label: 'xAI', + value: 'xai', + base_url: 'https://api.x.ai/v1', + models: [ + 'grok-4.20-0309-reasoning', + 'grok-4.20-0309-non-reasoning', + 'grok-4-1-fast-reasoning', + 'grok-4-1-fast-non-reasoning', + 'grok-4-fast-reasoning', + 'grok-4-fast-non-reasoning', + 'grok-4-0709', + 'grok-code-fast-1', + 'grok-3', + 'grok-3-mini', + ], + }, + { + label: 'MiniMax', + value: 'minimax', + base_url: 'https://api.minimax.io/anthropic', + models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'], + }, + { + label: 'MiniMax (China)', + value: 'minimax-cn', + base_url: 'https://api.minimaxi.com/anthropic', + models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'], + }, + { + label: 'Alibaba Cloud', + value: 'alibaba', + base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + models: [ + 'qwen3.5-plus', + 'qwen3-coder-plus', + 'qwen3-coder-next', + 'glm-5', + 'glm-4.7', + 'kimi-k2.5', + 'MiniMax-M2.5', + ], + }, + { + label: 'Hugging Face', + value: 'huggingface', + base_url: 'https://router.huggingface.co/v1', + models: [ + 'Qwen/Qwen3.5-397B-A17B', + 'Qwen/Qwen3.5-35B-A3B', + 'deepseek-ai/DeepSeek-V3.2', + 'moonshotai/Kimi-K2.5', + 'MiniMaxAI/MiniMax-M2.5', + 'zai-org/GLM-5', + 'XiaomiMiMo/MiMo-V2-Flash', + 'moonshotai/Kimi-K2-Thinking', + ], + }, + { + label: 'Xiaomi MiMo', + value: 'xiaomi', + base_url: 'https://api.xiaomimimo.com/v1', + models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'], + }, + { + label: 'Kilo Code', + value: 'kilocode', + base_url: 'https://api.kilo.ai/api/gateway', + models: [ + 'anthropic/claude-opus-4.6', + 'anthropic/claude-sonnet-4.6', + 'openai/gpt-5.4', + 'google/gemini-3-pro-preview', + 'google/gemini-3-flash-preview', + ], + }, + { + label: 'AI Gateway', + value: 'ai-gateway', + base_url: 'https://ai-gateway.vercel.sh/v1', + models: [ + 'anthropic/claude-opus-4.6', + 'anthropic/claude-sonnet-4.6', + 'anthropic/claude-sonnet-4.5', + 'anthropic/claude-haiku-4.5', + 'openai/gpt-5', + 'openai/gpt-4.1', + 'openai/gpt-4.1-mini', + 'google/gemini-3-pro-preview', + 'google/gemini-3-flash', + 'google/gemini-2.5-pro', + 'google/gemini-2.5-flash', + 'deepseek/deepseek-v3.2', + ], + }, + { + label: 'OpenCode Zen', + value: 'opencode-zen', + base_url: 'https://opencode.ai/zen/v1', + models: [ + 'gpt-5.4-pro', + 'gpt-5.4', + 'gpt-5.3-codex', + 'gpt-5.2', + 'gpt-5.1', + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-haiku-4-5', + 'gemini-3.1-pro', + 'gemini-3-pro', + 'gemini-3-flash', + 'minimax-m2.7', + 'minimax-m2.5', + 'glm-5', + 'glm-4.7', + 'kimi-k2.5', + ], + }, + { + label: 'OpenCode Go', + value: 'opencode-go', + base_url: 'https://opencode.ai/zen/go/v1', + models: ['glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'], + }, + { + label: 'OpenRouter', + value: 'openrouter', + base_url: 'https://openrouter.ai/api/v1', + models: [], + }, +] + +/** Build a Record for backend lookup */ +export function buildProviderModelMap(): Record { + const map: Record = {} + for (const p of PROVIDER_PRESETS) { + if (p.models.length > 0) { + map[p.value] = p.models + } + } + return map +} diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..5beb0cd --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,114 @@ +import { request } from './client' + +export interface DisplayConfig { + compact?: boolean + personality?: string + resume_display?: string + busy_input_mode?: string + bell_on_complete?: boolean + show_reasoning?: boolean + streaming?: boolean + inline_diffs?: boolean + show_cost?: boolean + skin?: string +} + +export interface AgentConfig { + max_turns?: number + gateway_timeout?: number + restart_drain_timeout?: number + service_tier?: string + tool_use_enforcement?: string +} + +export interface MemoryConfig { + memory_enabled?: boolean + user_profile_enabled?: boolean + memory_char_limit?: number + user_char_limit?: number +} + +export interface SessionResetConfig { + mode?: string + idle_minutes?: number + at_hour?: number +} + +export interface PrivacyConfig { + redact_pii?: boolean +} + +export interface AppConfig { + display?: DisplayConfig + agent?: AgentConfig + memory?: MemoryConfig + session_reset?: SessionResetConfig + privacy?: PrivacyConfig + telegram?: Record + discord?: Record + slack?: Record + whatsapp?: Record + matrix?: Record + weixin?: Record + wecom?: Record + feishu?: Record + dingtalk?: Record + platforms?: Record + [key: string]: any +} + +export async function fetchConfig(sections?: string[]): Promise { + const query = sections ? `?sections=${sections.join(',')}` : '' + return request(`/api/config${query}`) +} + +export async function updateConfigSection( + section: string, + values: Record, +): Promise { + await request('/api/config', { + method: 'PUT', + body: JSON.stringify({ section, values }), + }) +} + +export async function saveCredentials( + platform: string, + values: Record, +): Promise { + await request('/api/config/credentials', { + method: 'PUT', + body: JSON.stringify({ platform, values }), + }) +} + +export interface WeixinQrCode { + qrcode: string + qrcode_url: string +} + +export interface WeixinQrStatus { + status: 'wait' | 'scaned' | 'scaned_but_redirect' | 'expired' | 'confirmed' + account_id?: string + token?: string + base_url?: string +} + +export async function fetchWeixinQrCode(): Promise { + return request('/api/weixin/qrcode') +} + +export async function pollWeixinQrStatus(qrcode: string): Promise { + return request(`/api/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`) +} + +export async function saveWeixinCredentials(data: { + account_id: string + token: string + base_url?: string +}): Promise { + await request('/api/weixin/save', { + method: 'POST', + body: JSON.stringify(data), + }) +} diff --git a/src/api/system.ts b/src/api/system.ts index e6b881f..7ef424c 100644 --- a/src/api/system.ts +++ b/src/api/system.ts @@ -49,6 +49,7 @@ export interface CustomProvider { base_url: string api_key: string model: string + providerKey?: string | null } export async function checkHealth(): Promise { diff --git a/src/assets/output.gif b/src/assets/output.gif index c6a6083..3362a2b 100644 Binary files a/src/assets/output.gif and b/src/assets/output.gif differ diff --git a/src/components/chat/ChatInput.vue b/src/components/chat/ChatInput.vue index 5fddf36..2cfeeb1 100644 --- a/src/components/chat/ChatInput.vue +++ b/src/components/chat/ChatInput.vue @@ -1,10 +1,12 @@ + + diff --git a/src/components/models/ProviderCard.vue b/src/components/models/ProviderCard.vue new file mode 100644 index 0000000..016c5ee --- /dev/null +++ b/src/components/models/ProviderCard.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/src/components/models/ProviderFormModal.vue b/src/components/models/ProviderFormModal.vue new file mode 100644 index 0000000..11f081a --- /dev/null +++ b/src/components/models/ProviderFormModal.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/src/components/models/ProvidersPanel.vue b/src/components/models/ProvidersPanel.vue new file mode 100644 index 0000000..720a8c6 --- /dev/null +++ b/src/components/models/ProvidersPanel.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/components/settings/AgentSettings.vue b/src/components/settings/AgentSettings.vue new file mode 100644 index 0000000..3b424b9 --- /dev/null +++ b/src/components/settings/AgentSettings.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/components/settings/DisplaySettings.vue b/src/components/settings/DisplaySettings.vue new file mode 100644 index 0000000..ef7ff37 --- /dev/null +++ b/src/components/settings/DisplaySettings.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/components/settings/MemorySettings.vue b/src/components/settings/MemorySettings.vue new file mode 100644 index 0000000..b7bed25 --- /dev/null +++ b/src/components/settings/MemorySettings.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/components/settings/PlatformCard.vue b/src/components/settings/PlatformCard.vue new file mode 100644 index 0000000..d541b7f --- /dev/null +++ b/src/components/settings/PlatformCard.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/src/components/settings/PlatformSettings.vue b/src/components/settings/PlatformSettings.vue new file mode 100644 index 0000000..25ef38e --- /dev/null +++ b/src/components/settings/PlatformSettings.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/src/components/settings/PrivacySettings.vue b/src/components/settings/PrivacySettings.vue new file mode 100644 index 0000000..2fea652 --- /dev/null +++ b/src/components/settings/PrivacySettings.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/components/settings/SessionSettings.vue b/src/components/settings/SessionSettings.vue new file mode 100644 index 0000000..21c110c --- /dev/null +++ b/src/components/settings/SessionSettings.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/components/settings/SettingRow.vue b/src/components/settings/SettingRow.vue new file mode 100644 index 0000000..7df0338 --- /dev/null +++ b/src/components/settings/SettingRow.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/src/components/skills/SkillDetail.vue b/src/components/skills/SkillDetail.vue index a643365..25f845f 100644 --- a/src/components/skills/SkillDetail.vue +++ b/src/components/skills/SkillDetail.vue @@ -2,6 +2,9 @@ import { ref, watch } from 'vue' import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue' import { fetchSkillContent, fetchSkillFiles, type SkillFileEntry } from '@/api/skills' +import { useI18n } from 'vue-i18n' + +const { t } = useI18n() const props = defineProps<{ category: string @@ -30,7 +33,7 @@ async function loadSkill() { content.value = skillContent files.value = skillFiles.filter(f => !f.isDir && f.path !== 'SKILL.md') } catch (err: any) { - content.value = `Failed to load skill: ${err.message}` + content.value = t('skills.loadFailed') + `: ${err.message}` } finally { loading.value = false } @@ -53,7 +56,7 @@ async function viewFile(filePath: string) { } fileContent.value = await fetchSkillContent(`${base}${relPath}`) } catch (err: any) { - fileContent.value = `Failed to load file: ${err.message}` + fileContent.value = t('skills.fileLoadFailed') + `: ${err.message}` } finally { fileLoading.value = false } @@ -76,7 +79,7 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true }) {{ skill }} -
Loading...
+
{{ t('common.loading') }}