From e89a240f1d31839f922c02882d6f29ed7245bb25 Mon Sep 17 00:00:00 2001 From: ekko Date: Mon, 13 Apr 2026 15:15:14 +0800 Subject: [PATCH] feat: add i18n, platform channels page, and WeChat QR login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vue-i18n with auto-detect browser language and manual toggle (EN/中文) - Move platform channels to separate page with credential management - Support Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, Weixin, WeCom - Add WeChat QR code login (opens in browser, polls status, auto-saves) - Write platform credentials to ~/.hermes/.env matching hermes gateway setup - Auto restart gateway after platform config changes - Add settings store with per-section save for all config categories - Persist session group collapse state across navigation - Fix pre-existing TypeScript build errors Co-Authored-By: Claude Opus 4.6 --- README.md | 59 ++- package.json | 7 +- server/src/index.ts | 4 + server/src/routes/config.ts | 315 ++++++++++++++++ server/src/routes/weixin.ts | 136 +++++++ src/api/config.ts | 114 ++++++ src/components/chat/ChatInput.vue | 10 +- src/components/chat/ChatPanel.vue | 52 +-- src/components/chat/MarkdownRenderer.vue | 6 +- src/components/chat/MessageItem.vue | 12 +- src/components/chat/MessageList.vue | 4 +- src/components/jobs/JobCard.vue | 44 +-- src/components/jobs/JobFormModal.vue | 67 ++-- src/components/jobs/JobsPanel.vue | 5 +- src/components/layout/AppSidebar.vue | 41 ++- src/components/layout/LanguageSwitch.vue | 30 ++ src/components/models/ProviderCard.vue | 20 +- src/components/models/ProviderFormModal.vue | 59 +-- src/components/models/ProvidersPanel.vue | 4 +- src/components/settings/AgentSettings.vue | 68 ++++ src/components/settings/DisplaySettings.vue | 53 +++ src/components/settings/MemorySettings.vue | 54 +++ src/components/settings/PlatformCard.vue | 114 ++++++ src/components/settings/PlatformSettings.vue | 361 +++++++++++++++++++ src/components/settings/PrivacySettings.vue | 35 ++ src/components/settings/SessionSettings.vue | 60 +++ src/components/settings/SettingRow.vue | 55 +++ src/components/skills/SkillDetail.vue | 13 +- src/components/skills/SkillList.vue | 5 +- src/i18n/index.ts | 13 + src/i18n/locales/en.ts | 340 +++++++++++++++++ src/i18n/locales/zh.ts | 340 +++++++++++++++++ src/main.ts | 2 + src/router/index.ts | 5 + src/stores/settings.ts | 87 +++++ src/views/ChannelsView.vue | 58 +++ src/views/JobsView.vue | 6 +- src/views/LogsView.vue | 16 +- src/views/MemoryView.vue | 38 +- src/views/ModelsView.vue | 6 +- src/views/SettingsView.vue | 281 ++++----------- src/views/SkillsView.vue | 6 +- 42 files changed, 2627 insertions(+), 378 deletions(-) create mode 100644 server/src/routes/config.ts create mode 100644 server/src/routes/weixin.ts create mode 100644 src/api/config.ts create mode 100644 src/components/layout/LanguageSwitch.vue create mode 100644 src/components/settings/AgentSettings.vue create mode 100644 src/components/settings/DisplaySettings.vue create mode 100644 src/components/settings/MemorySettings.vue create mode 100644 src/components/settings/PlatformCard.vue create mode 100644 src/components/settings/PlatformSettings.vue create mode 100644 src/components/settings/PrivacySettings.vue create mode 100644 src/components/settings/SessionSettings.vue create mode 100644 src/components/settings/SettingRow.vue create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en.ts create mode 100644 src/i18n/locales/zh.ts create mode 100644 src/stores/settings.ts create mode 100644 src/views/ChannelsView.vue 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 27d7d41..9e11fe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.1.6", + "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/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/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/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 index 1db9a01..016c5ee 100644 --- a/src/components/models/ProviderCard.vue +++ b/src/components/models/ProviderCard.vue @@ -3,9 +3,11 @@ import { computed } from 'vue' import { NButton, useMessage, useDialog } from 'naive-ui' import type { AvailableModelGroup } from '@/api/system' import { useModelsStore } from '@/stores/models' +import { useI18n } from 'vue-i18n' const props = defineProps<{ provider: AvailableModelGroup }>() +const { t } = useI18n() const modelsStore = useModelsStore() const message = useMessage() const dialog = useDialog() @@ -15,14 +17,14 @@ const displayName = computed(() => props.provider.label) async function handleDelete() { dialog.warning({ - title: 'Delete Provider', - content: `Are you sure you want to delete "${displayName.value}"?`, - positiveText: 'Delete', - negativeText: 'Cancel', + title: t('models.deleteProvider'), + content: t('models.deleteConfirm', { name: displayName.value }), + positiveText: t('common.delete'), + negativeText: t('common.cancel'), onPositiveClick: async () => { try { await modelsStore.removeProvider(props.provider.provider) - message.success('Provider deleted') + message.success(t('models.providerDeleted')) } catch (e: any) { message.error(e.message) } @@ -36,23 +38,23 @@ async function handleDelete() {

{{ displayName }}

- {{ isCustom ? 'Custom' : 'Built-in' }} + {{ isCustom ? t('models.customType') : t('models.builtIn') }}
- Provider + {{ t('models.provider') }} {{ provider.provider }}
- Base URL + {{ t('models.baseUrl') }} {{ provider.base_url }}
- Delete + {{ t('common.delete') }}
diff --git a/src/components/models/ProviderFormModal.vue b/src/components/models/ProviderFormModal.vue index c7a41cb..11f081a 100644 --- a/src/components/models/ProviderFormModal.vue +++ b/src/components/models/ProviderFormModal.vue @@ -3,6 +3,9 @@ import { ref, watch } from 'vue' import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui' import { useModelsStore } from '@/stores/models' import { PROVIDER_PRESETS } from '@/shared/providers' +import { useI18n } from 'vue-i18n' + +const { t } = useI18n() const emit = defineEmits<{ close: [] @@ -27,7 +30,7 @@ const formData = ref({ const modelOptions = ref>([]) -const PRESET_PROVIDERS = PROVIDER_PRESETS +const PRESET_PROVIDERS = PROVIDER_PRESETS as any[] function autoGenerateName(url: string): string { const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '') @@ -45,7 +48,7 @@ watch(selectedPreset, (val) => { if (preset) { formData.value.name = preset.label formData.value.base_url = preset.base_url - modelOptions.value = preset.models.map(m => ({ label: m, value: m })) + modelOptions.value = preset.models.map((m: string) => ({ label: m, value: m })) if (preset.models.length > 0) { formData.value.model = preset.models[0] } @@ -68,7 +71,7 @@ watch(providerType, () => { async function fetchModels() { const { base_url } = formData.value if (!base_url.trim()) { - message.warning('Please enter Base URL first') + message.warning(t('models.enterBaseUrl')) return } @@ -82,15 +85,15 @@ async function fetchModels() { const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() as { data?: Array<{ id: string }> } - if (!Array.isArray(data.data)) throw new Error('Unexpected response format') + if (!Array.isArray(data.data)) throw new Error(t('models.unexpectedFormat')) modelOptions.value = data.data.map(m => ({ label: m.id, value: m.id })) if (modelOptions.value.length > 0 && !formData.value.model) { formData.value.model = modelOptions.value[0].value } - message.success(`Found ${modelOptions.value.length} models`) + message.success(t('models.foundModels', { count: modelOptions.value.length })) } catch (e: any) { - message.error('Failed to fetch models: ' + e.message) + message.error(t('models.fetchFailed') + ': ' + e.message) } finally { fetchingModels.value = false } @@ -98,19 +101,19 @@ async function fetchModels() { async function handleSave() { if (providerType.value === 'preset' && !selectedPreset.value) { - message.warning('Please select a provider') + message.warning(t('models.selectProviderRequired')) return } if (!formData.value.base_url.trim()) { - message.warning('Base URL is required') + message.warning(t('models.baseUrlRequired')) return } if (!formData.value.api_key.trim()) { - message.warning('API Key is required') + message.warning(t('models.apiKeyRequired')) return } if (!formData.value.model) { - message.warning('Default Model is required') + message.warning(t('models.modelRequired')) return } @@ -127,7 +130,7 @@ async function handleSave() { model: formData.value.model, providerKey, }) - message.success('Provider added') + message.success(t('models.providerAdded')) emit('saved') } catch (e: any) { message.error(e.message) @@ -146,72 +149,72 @@ function handleClose() { - +
- Preset + {{ t('models.preset') }} - Custom + {{ t('models.custom') }}
- + - + - + - + - +
- Fetch + {{ t('common.fetch') }}
@@ -227,9 +230,9 @@ function handleClose() { diff --git a/src/components/models/ProvidersPanel.vue b/src/components/models/ProvidersPanel.vue index b619948..720a8c6 100644 --- a/src/components/models/ProvidersPanel.vue +++ b/src/components/models/ProvidersPanel.vue @@ -1,7 +1,9 @@ @@ -12,7 +14,7 @@ const modelsStore = useModelsStore() -

No providers found. Add a custom provider to get started.

+

{{ t('models.noProviders') }}

+import { NInputNumber, NSelect, useMessage } from 'naive-ui' +import { useI18n } from 'vue-i18n' +import { useSettingsStore } from '@/stores/settings' +import SettingRow from './SettingRow.vue' + +const settingsStore = useSettingsStore() +const message = useMessage() +const { t } = useI18n() + +async function save(values: Record) { + try { + await settingsStore.saveSection('agent', values) + message.success(t('settings.saved')) + } catch (err: any) { + message.error(t('settings.saveFailed')) + } +} + + + + + 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') }}