feat: add i18n, platform channels page, and WeChat QR login

- 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 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-13 15:15:14 +08:00
parent 9e069a20a1
commit e89a240f1d
42 changed files with 2627 additions and 378 deletions
+55 -4
View File
@@ -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)
+6 -1
View File
@@ -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",
+4
View File
@@ -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) => {
+315
View File
@@ -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<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
}
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> {
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<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/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] = { ...(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/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 }
}
})
+136
View File
@@ -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<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'
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 }
}
})
+114
View File
@@ -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<string, any>
discord?: Record<string, any>
slack?: Record<string, any>
whatsapp?: Record<string, any>
matrix?: Record<string, any>
weixin?: Record<string, any>
wecom?: Record<string, any>
feishu?: Record<string, any>
dingtalk?: Record<string, any>
platforms?: Record<string, any>
[key: string]: any
}
export async function fetchConfig(sections?: string[]): Promise<AppConfig> {
const query = sections ? `?sections=${sections.join(',')}` : ''
return request<AppConfig>(`/api/config${query}`)
}
export async function updateConfigSection(
section: string,
values: Record<string, any>,
): Promise<void> {
await request('/api/config', {
method: 'PUT',
body: JSON.stringify({ section, values }),
})
}
export async function saveCredentials(
platform: string,
values: Record<string, any>,
): Promise<void> {
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<WeixinQrCode> {
return request<WeixinQrCode>('/api/weixin/qrcode')
}
export async function pollWeixinQrStatus(qrcode: string): Promise<WeixinQrStatus> {
return request<WeixinQrStatus>(`/api/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`)
}
export async function saveWeixinCredentials(data: {
account_id: string
token: string
base_url?: string
}): Promise<void> {
await request('/api/weixin/save', {
method: 'POST',
body: JSON.stringify(data),
})
}
+6 -4
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NTooltip } from 'naive-ui'
import { useChatStore } from '@/stores/chat'
import type { Attachment } from '@/stores/chat'
const chatStore = useChatStore()
const { t } = useI18n()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const fileInputRef = ref<HTMLInputElement>()
@@ -201,7 +203,7 @@ function isImage(type: string): boolean {
ref="textareaRef"
v-model="inputText"
class="input-textarea"
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
:placeholder="t('chat.inputPlaceholder')"
rows="1"
@keydown="handleKeydown"
@input="handleInput"
@@ -216,7 +218,7 @@ function isImage(type: string): boolean {
</template>
</NButton>
</template>
Attach files
{{ t('chat.attachFiles') }}
</NTooltip>
<NButton
v-if="chatStore.isStreaming"
@@ -224,7 +226,7 @@ function isImage(type: string): boolean {
type="error"
@click="chatStore.stopStreaming()"
>
Stop
{{ t('chat.stop') }}
</NButton>
<NButton
size="small"
@@ -235,7 +237,7 @@ function isImage(type: string): boolean {
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</template>
Send
{{ t('chat.send') }}
</NButton>
</div>
</div>
+28 -24
View File
@@ -3,18 +3,20 @@ import { renameSession } from '@/api/sessions'
import { useChatStore, type Session } from '@/stores/chat'
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ChatInput from './ChatInput.vue'
import MessageList from './MessageList.vue'
const chatStore = useChatStore()
const message = useMessage()
const { t } = useI18n()
const showSessions = ref(true)
const showRenameModal = ref(false)
const renameValue = ref('')
const renameSessionId = ref<string | null>(null)
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
const collapsedGroups = ref<Set<string>>(new Set())
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
const sourceLabel: Record<string, string> = {
telegram: 'Telegram',
@@ -74,7 +76,7 @@ const groupedSessions = computed<SessionGroup[]>(() => {
return keys.map(key => ({
source: key,
label: key ? getSourceLabel(key) : 'Other',
label: key ? getSourceLabel(key) : t('chat.other'),
sessions: map.get(key)!,
}))
})
@@ -93,16 +95,18 @@ function toggleGroup(source: string) {
chatStore.switchSession(group.sessions[0].id)
}
}
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
}
// Default: expand only the first group, collapse the rest
// Default: expand only the first group if no saved state
watch(groupedSessions, (groups) => {
if (collapsedGroups.value.size > 0) return
collapsedGroups.value = new Set(groups.map(g => g.source))
if (localStorage.getItem('hermes_collapsed_groups') !== null) return
collapsedGroups.value = new Set(groups.slice(1).map(g => g.source))
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
}, { once: true })
const activeSessionTitle = computed(() =>
chatStore.activeSession?.title || 'New Chat',
chatStore.activeSession?.title || t('chat.newChat'),
)
const activeSessionSource = computed(() =>
@@ -117,13 +121,13 @@ function copySessionId(id?: string) {
const sessionId = id || chatStore.activeSessionId
if (sessionId) {
navigator.clipboard.writeText(sessionId)
message.success('Copied')
message.success(t('common.copied'))
}
}
function handleDeleteSession(id: string) {
chatStore.deleteSession(id)
message.success('Session deleted')
message.success(t('chat.sessionDeleted'))
}
function formatTime(ts: number) {
@@ -135,10 +139,10 @@ function formatTime(ts: number) {
}
// Context menu
const contextMenuOptions = [
{ label: 'Rename', key: 'rename' },
{ label: 'Copy Session ID', key: 'copy-id' },
]
const contextMenuOptions = computed(() => [
{ label: t('chat.rename'), key: 'rename' },
{ label: t('chat.copySessionId'), key: 'copy-id' },
])
const contextSessionId = ref<string | null>(null)
function handleContextMenu(e: MouseEvent, sessionId: string) {
@@ -182,9 +186,9 @@ async function handleRenameConfirm() {
if (chatStore.activeSession?.id === renameSessionId.value) {
chatStore.activeSession.title = renameValue.value.trim()
}
message.success('Renamed')
message.success(t('chat.renamed'))
} else {
message.error('Rename failed')
message.error(t('chat.renameFailed'))
}
showRenameModal.value = false
}
@@ -195,7 +199,7 @@ async function handleRenameConfirm() {
<!-- Session List -->
<aside class="session-list" :class="{ collapsed: !showSessions }">
<div class="session-list-header">
<span v-if="showSessions" class="session-list-title">Sessions</span>
<span v-if="showSessions" class="session-list-title">{{ t('chat.sessions') }}</span>
<NButton quaternary size="tiny" @click="handleNewChat" circle>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
@@ -203,8 +207,8 @@ async function handleRenameConfirm() {
</NButton>
</div>
<div v-if="showSessions" class="session-items">
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">Loading...</div>
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">No sessions</div>
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">{{ t('common.loading') }}</div>
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">{{ t('chat.noSessions') }}</div>
<template v-for="group in groupedSessions" :key="group.source">
<div class="session-group-header" @click="toggleGroup(group.source)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="group-chevron" :class="{ collapsed: collapsedGroups.has(group.source) }"><polyline points="9 18 15 12 9 6"/></svg>
@@ -236,7 +240,7 @@ async function handleRenameConfirm() {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
Delete this session?
{{ t('chat.deleteSession') }}
</NPopconfirm>
</button>
</template>
@@ -260,15 +264,15 @@ async function handleRenameConfirm() {
<NModal
v-model:show="showRenameModal"
preset="dialog"
title="Rename Session"
positive-text="OK"
negative-text="Cancel"
:title="t('chat.renameSession')"
:positive-text="t('common.ok')"
:negative-text="t('common.cancel')"
@positive-click="handleRenameConfirm"
>
<NInput
ref="renameInputRef"
v-model:value="renameValue"
placeholder="Enter new title"
:placeholder="t('chat.enterNewTitle')"
@keydown.enter="handleRenameConfirm"
/>
</NModal>
@@ -294,13 +298,13 @@ async function handleRenameConfirm() {
</template>
</NButton>
</template>
Copy Session ID
{{ t('chat.copySessionId') }}
</NTooltip>
<NButton size="small" @click="handleNewChat">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
New Chat
{{ t('chat.newChat') }}
</NButton>
</div>
</header>
+4 -2
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
const props = defineProps<{ content: string }>()
const { t } = useI18n()
const md: MarkdownIt = new MarkdownIt({
html: false,
@@ -12,12 +14,12 @@ const md: MarkdownIt = new MarkdownIt({
highlight(str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
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)">Copy</button></div><code class="hljs language-${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
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)">Copy</button></div><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
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>`
},
})
+7 -5
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { Message } from '@/stores/chat';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import MarkdownRenderer from './MarkdownRenderer.vue';
const props = defineProps<{ message: Message }>()
const { t } = useI18n()
const isSystem = computed(() => props.message.role === 'system')
const toolExpanded = ref(false)
@@ -42,11 +44,11 @@ const formattedToolResult = computed(() => {
const parsed = JSON.parse(props.message.toolResult)
const str = JSON.stringify(parsed, null, 2)
// Truncate very long output
if (str.length > 2000) return str.slice(0, 2000) + '\n... (truncated)'
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... (truncated)'
if (raw.length > 2000) return raw.slice(0, 2000) + '\n' + t('chat.truncated')
return raw
}
})
@@ -61,15 +63,15 @@ const formattedToolResult = computed(() => {
<span class="tool-name">{{ message.toolName }}</span>
<span v-if="message.toolPreview && !toolExpanded" class="tool-preview">{{ message.toolPreview }}</span>
<span v-if="message.toolStatus === 'running'" class="tool-spinner"></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">error</span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{ t('chat.error') }}</span>
</div>
<div v-if="toolExpanded && hasToolDetails" class="tool-details">
<div v-if="formattedToolArgs" class="tool-detail-section">
<div class="tool-detail-label">Arguments</div>
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
<pre class="tool-detail-code">{{ formattedToolArgs }}</pre>
</div>
<div v-if="formattedToolResult" class="tool-detail-section">
<div class="tool-detail-label">Result</div>
<div class="tool-detail-label">{{ t('chat.result') }}</div>
<pre class="tool-detail-code">{{ formattedToolResult }}</pre>
</div>
</div>
+3 -1
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import MessageItem from './MessageItem.vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const { t } = useI18n()
const listRef = ref<HTMLElement>()
function scrollToBottom() {
@@ -23,7 +25,7 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
<div ref="listRef" class="message-list">
<div v-if="chatStore.messages.length === 0" class="empty-state">
<img src="/assets/logo.png" alt="Hermes" class="empty-logo" />
<p>Start a conversation with Hermes Agent</p>
<p>{{ t('chat.emptyState') }}</p>
</div>
<MessageItem
v-for="msg in chatStore.messages"
+23 -21
View File
@@ -3,22 +3,24 @@ import { computed } from 'vue'
import { NButton, NTooltip, useMessage } from 'naive-ui'
import type { Job } from '@/api/jobs'
import { useJobsStore } from '@/stores/jobs'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ job: Job }>()
const emit = defineEmits<{
edit: [jobId: string]
}>()
const { t } = useI18n()
const jobsStore = useJobsStore()
const message = useMessage()
const jobId = computed(() => props.job.job_id || props.job.id)
const statusLabel = computed(() => {
if (props.job.state === 'running') return 'Running'
if (props.job.state === 'paused') return 'Paused'
if (!props.job.enabled) return 'Disabled'
return 'Scheduled'
if (props.job.state === 'running') return t('jobs.status.running')
if (props.job.state === 'paused') return t('jobs.status.paused')
if (!props.job.enabled) return t('jobs.status.disabled')
return t('jobs.status.scheduled')
})
const statusType = computed(() => {
@@ -42,7 +44,7 @@ const formatTime = (t?: string | null) => {
async function handlePause() {
try {
await jobsStore.pauseJob(jobId.value)
message.success('Job paused')
message.success(t('jobs.jobPaused'))
} catch (e: any) {
message.error(e.message)
}
@@ -51,7 +53,7 @@ async function handlePause() {
async function handleResume() {
try {
await jobsStore.resumeJob(jobId.value)
message.success('Job resumed')
message.success(t('jobs.jobResumed'))
} catch (e: any) {
message.error(e.message)
}
@@ -60,7 +62,7 @@ async function handleResume() {
async function handleRun() {
try {
await jobsStore.runJob(jobId.value)
message.info('Job triggered')
message.info(t('jobs.jobTriggered'))
} catch (e: any) {
message.error(e.message)
}
@@ -69,7 +71,7 @@ async function handleRun() {
async function handleDelete() {
try {
await jobsStore.deleteJob(jobId.value)
message.success('Job deleted')
message.success(t('jobs.jobDeleted'))
} catch (e: any) {
message.error(e.message)
}
@@ -85,11 +87,11 @@ async function handleDelete() {
<div class="card-body">
<div class="info-row">
<span class="info-label">Schedule</span>
<span class="info-label">{{ t('jobs.info.schedule') }}</span>
<code class="info-value mono">{{ scheduleExpr }}</code>
</div>
<div class="info-row">
<span class="info-label">Last Run</span>
<span class="info-label">{{ t('jobs.info.lastRun') }}</span>
<span class="info-value">
{{ formatTime(job.last_run_at) }}
<span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
@@ -98,15 +100,15 @@ async function handleDelete() {
</span>
</div>
<div class="info-row">
<span class="info-label">Next Run</span>
<span class="info-label">{{ t('jobs.info.nextRun') }}</span>
<span class="info-value">{{ formatTime(job.next_run_at) }}</span>
</div>
<div class="info-row">
<span class="info-label">Deliver</span>
<span class="info-label">{{ t('jobs.info.deliver') }}</span>
<span class="info-value">{{ job.deliver }}<template v-if="job.origin"> ({{ job.origin.platform }})</template></span>
</div>
<div v-if="job.repeat" class="info-row">
<span class="info-label">Repeat</span>
<span class="info-label">{{ t('jobs.info.repeat') }}</span>
<span class="info-value">
<template v-if="typeof job.repeat === 'string'">{{ job.repeat }}</template>
<template v-else>{{ job.repeat.completed }} / {{ job.repeat.times ?? '' }}</template>
@@ -117,24 +119,24 @@ async function handleDelete() {
<div class="card-actions">
<NTooltip v-if="job.state !== 'paused' && job.enabled">
<template #trigger>
<NButton size="tiny" quaternary @click="handlePause">Pause</NButton>
<NButton size="tiny" quaternary @click="handlePause">{{ t('jobs.action.pause') }}</NButton>
</template>
Pause job
{{ t('jobs.action.pauseJob') }}
</NTooltip>
<NTooltip v-else-if="job.state === 'paused'">
<template #trigger>
<NButton size="tiny" quaternary @click="handleResume">Resume</NButton>
<NButton size="tiny" quaternary @click="handleResume">{{ t('jobs.action.resume') }}</NButton>
</template>
Resume job
{{ t('jobs.action.resumeJob') }}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="tiny" quaternary @click="handleRun">Run Now</NButton>
<NButton size="tiny" quaternary @click="handleRun">{{ t('jobs.action.runNow') }}</NButton>
</template>
Trigger immediately
{{ t('jobs.action.triggerImmediately') }}
</NTooltip>
<NButton size="tiny" quaternary @click="emit('edit', jobId)">Edit</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDelete">Delete</NButton>
<NButton size="tiny" quaternary @click="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
</div>
</template>
+35 -32
View File
@@ -2,6 +2,9 @@
import { ref, onMounted, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
import { useJobsStore } from '@/stores/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
jobId: string | null
@@ -30,20 +33,20 @@ const presetValue = ref<string | null>(null)
const isEdit = computed(() => !!props.jobId)
const schedulePresets = [
{ label: 'Every minute', value: '* * * * *' },
{ label: 'Every 5 minutes', value: '*/5 * * * *' },
{ label: 'Every hour', value: '0 * * * *' },
{ label: 'Every day at 00:00', value: '0 0 * * *' },
{ label: 'Every day at 09:00', value: '0 9 * * *' },
{ label: 'Every Monday at 09:00', value: '0 9 * * 1' },
{ label: 'Every month 1st at 09:00', value: '0 9 1 * *' },
]
const schedulePresets = computed(() => [
{ label: t('jobs.presetEveryMinute'), value: '* * * * *' },
{ label: t('jobs.presetEvery5Min'), value: '*/5 * * * *' },
{ label: t('jobs.presetEveryHour'), value: '0 * * * *' },
{ label: t('jobs.presetEveryDay'), value: '0 0 * * *' },
{ label: t('jobs.presetEveryDay9'), value: '0 9 * * *' },
{ label: t('jobs.presetEveryMonday'), value: '0 9 * * 1' },
{ label: t('jobs.presetEveryMonth'), value: '0 9 1 * *' },
])
const targetOptions = [
{ label: 'Origin', value: 'origin' },
{ label: 'Local', value: 'local' },
]
const targetOptions = computed(() => [
{ label: t('jobs.origin'), value: 'origin' },
{ label: t('jobs.local'), value: 'local' },
])
onMounted(async () => {
if (props.jobId) {
@@ -58,18 +61,18 @@ onMounted(async () => {
repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null),
}
} catch (e: any) {
message.error('Failed to load job: ' + e.message)
message.error(t('jobs.loadFailed') + ': ' + e.message)
}
}
})
async function handleSave() {
if (!formData.value.name.trim()) {
message.warning('Name is required')
message.warning(t('jobs.nameRequired'))
return
}
if (!formData.value.schedule.trim()) {
message.warning('Schedule is required')
message.warning(t('jobs.scheduleRequired'))
return
}
@@ -85,10 +88,10 @@ async function handleSave() {
if (isEdit.value) {
await jobsStore.updateJob(props.jobId!, payload)
message.success('Job updated')
message.success(t('jobs.jobUpdated'))
} else {
await jobsStore.createJob(payload)
message.success('Job created')
message.success(t('jobs.jobCreated'))
}
emit('saved')
} catch (e: any) {
@@ -108,60 +111,60 @@ function handleClose() {
<NModal
v-model:show="showModal"
preset="card"
:title="isEdit ? 'Edit Job' : 'Create Job'"
:title="isEdit ? t('jobs.editJob') : t('jobs.createJob')"
:style="{ width: '520px' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem label="Name" required>
<NFormItem :label="t('jobs.name')" required>
<NInput
v-model:value="formData.name"
placeholder="Job name"
:placeholder="t('jobs.namePlaceholder')"
maxlength="200"
show-count
/>
</NFormItem>
<NFormItem label="Schedule (Cron Expression)" required>
<NFormItem :label="t('jobs.schedule')" required>
<NInput
v-model:value="formData.schedule"
placeholder="e.g. 0 9 * * *"
:placeholder="t('jobs.schedulePlaceholder')"
/>
</NFormItem>
<NFormItem label="Quick Presets">
<NFormItem :label="t('jobs.quickPresets')">
<NSelect
v-model:value="presetValue"
:options="schedulePresets"
placeholder="Select a preset..."
:placeholder="t('jobs.selectPreset')"
@update:value="v => formData.schedule = v"
/>
</NFormItem>
<NFormItem label="Prompt" required>
<NFormItem :label="t('jobs.prompt')" required>
<NInput
v-model:value="formData.prompt"
type="textarea"
placeholder="The prompt to execute"
:placeholder="t('jobs.promptPlaceholder')"
:rows="4"
maxlength="5000"
show-count
/>
</NFormItem>
<NFormItem label="Deliver Target">
<NFormItem :label="t('jobs.deliverTarget')">
<NSelect
v-model:value="formData.deliver"
:options="targetOptions"
/>
</NFormItem>
<NFormItem label="Repeat Count (optional)">
<NFormItem :label="t('jobs.repeatCount')">
<NInputNumber
v-model:value="formData.repeat_times"
:min="1"
placeholder="Leave empty for infinite"
:placeholder="t('jobs.repeatPlaceholder')"
clearable
style="width: 100%"
/>
@@ -170,9 +173,9 @@ function handleClose() {
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">Cancel</NButton>
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ isEdit ? 'Update' : 'Create' }}
{{ isEdit ? t('common.update') : t('common.create') }}
</NButton>
</div>
</template>
+4 -1
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import JobCard from './JobCard.vue'
import { useJobsStore } from '@/stores/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
edit: [jobId: string]
@@ -17,7 +20,7 @@ const jobsStore = useJobsStore()
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<p>No scheduled jobs yet. Create one to get started.</p>
<p>{{ t('jobs.noJobs') }}</p>
</div>
<div v-else class="jobs-grid">
<JobCard
+34 -7
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ModelSelector from './ModelSelector.vue'
import LanguageSwitch from './LanguageSwitch.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
@@ -31,7 +34,7 @@ function handleNav(key: string) {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>Chat</span>
<span>{{ t('sidebar.chat') }}</span>
</button>
<button
@@ -45,7 +48,7 @@ function handleNav(key: string) {
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>Jobs</span>
<span>{{ t('sidebar.jobs') }}</span>
</button>
<button
@@ -60,7 +63,18 @@ function handleNav(key: string) {
<path d="M4.22 4.22l2.83 2.83" /><path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" /><path d="M16.95 7.05l2.83-2.83" />
</svg>
<span>Models</span>
<span>{{ t('sidebar.models') }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'channels' }"
@click="handleNav('channels')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>{{ t('sidebar.channels') }}</span>
</button>
<button
@@ -73,7 +87,7 @@ function handleNav(key: string) {
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>Skills</span>
<span>{{ t('sidebar.skills') }}</span>
</button>
<button
@@ -86,7 +100,7 @@ function handleNav(key: string) {
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>Memory</span>
<span>{{ t('sidebar.memory') }}</span>
</button>
<button
@@ -101,7 +115,19 @@ function handleNav(key: string) {
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>Logs</span>
<span>{{ t('sidebar.logs') }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'settings' }"
@click="handleNav('settings')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>{{ t('sidebar.settings') }}</span>
</button>
</nav>
@@ -111,8 +137,9 @@ function handleNav(key: string) {
<div class="status-row">
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
<span class="status-dot"></span>
<span class="status-text">{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
<span class="status-text">{{ appStore.connected ? t('sidebar.connected') : t('sidebar.disconnected') }}</span>
</div>
<LanguageSwitch />
</div>
<div class="version-info">Hermes {{ appStore.serverVersion || 'v0.1.0' }}</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NSelect } from 'naive-ui'
const { locale, availableLocales } = useI18n()
const options = computed(() =>
availableLocales.map(loc => ({
label: loc === 'zh' ? '中文' : 'English',
value: loc,
})),
)
function handleChange(val: string) {
locale.value = val
localStorage.setItem('hermes_locale', val)
}
</script>
<template>
<NSelect
:value="locale"
:options="options"
size="tiny"
:consistent-menu-width="false"
style="width: 90px"
@update:value="handleChange"
/>
</template>
+11 -9
View File
@@ -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() {
<div class="card-header">
<h3 class="provider-name">{{ displayName }}</h3>
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
{{ isCustom ? 'Custom' : 'Built-in' }}
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Provider</span>
<span class="info-label">{{ t('models.provider') }}</span>
<code class="info-value mono">{{ provider.provider }}</code>
</div>
<div class="info-row">
<span class="info-label">Base URL</span>
<span class="info-label">{{ t('models.baseUrl') }}</span>
<code class="info-value mono">{{ provider.base_url }}</code>
</div>
</div>
<div class="card-actions">
<NButton size="tiny" quaternary type="error" @click="handleDelete">Delete</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
</div>
</template>
+31 -28
View File
@@ -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<Array<{ label: string; value: string }>>([])
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() {
<NModal
v-model:show="showModal"
preset="card"
title="Add Provider"
:title="t('models.addProvider')"
:style="{ width: '520px' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem label="Provider Type">
<NFormItem :label="t('models.providerType')">
<div style="display: flex; gap: 12px">
<NButton
:type="providerType === 'preset' ? 'primary' : 'default'"
size="small"
@click="providerType = 'preset'"
>
Preset
{{ t('models.preset') }}
</NButton>
<NButton
:type="providerType === 'custom' ? 'primary' : 'default'"
size="small"
@click="providerType = 'custom'"
>
Custom
{{ t('models.custom') }}
</NButton>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'preset'" label="Select Provider" required>
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
<NSelect
v-model:value="selectedPreset"
:options="PRESET_PROVIDERS"
placeholder="Choose a provider..."
:placeholder="t('models.chooseProvider')"
filterable
/>
</NFormItem>
<NFormItem v-if="providerType === 'custom'" label="Name">
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
<NInput
v-model:value="formData.name"
placeholder="Auto-generated from Base URL"
:placeholder="t('models.autoGeneratedName')"
disabled
/>
</NFormItem>
<NFormItem label="Base URL" required>
<NFormItem :label="t('models.baseUrl')" required>
<NInput
v-model:value="formData.base_url"
placeholder="e.g. https://api.example.com/v1"
:placeholder="t('models.baseUrlPlaceholder')"
:disabled="providerType === 'preset'"
/>
</NFormItem>
<NFormItem label="API Key" required>
<NFormItem :label="t('models.apiKey')" required>
<NInput
v-model:value="formData.api_key"
type="password"
show-password-on="click"
placeholder="sk-..."
:placeholder="t('models.apiKeyPlaceholder')"
/>
</NFormItem>
<NFormItem label="Default Model" required>
<NFormItem :label="t('models.defaultModel')" required>
<div style="display: flex; gap: 8px; width: 100%">
<NSelect
v-model:value="formData.model"
:options="modelOptions"
filterable
placeholder="Select a model..."
:placeholder="t('models.selectModel')"
style="flex: 1"
/>
<NButton
@@ -219,7 +222,7 @@ function handleClose() {
:loading="fetchingModels"
@click="fetchModels"
>
Fetch
{{ t('common.fetch') }}
</NButton>
</div>
</NFormItem>
@@ -227,9 +230,9 @@ function handleClose() {
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">Cancel</NButton>
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
Add
{{ t('common.add') }}
</NButton>
</div>
</template>
+3 -1
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import ProviderCard from './ProviderCard.vue'
import { useModelsStore } from '@/stores/models'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const modelsStore = useModelsStore()
</script>
@@ -12,7 +14,7 @@ const modelsStore = useModelsStore()
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p>No providers found. Add a custom provider to get started.</p>
<p>{{ t('models.noProviders') }}</p>
</div>
<div v-else class="providers-grid">
<ProviderCard
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
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<string, any>) {
try {
await settingsStore.saveSection('agent', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.agent.maxTurns')" :hint="t('settings.agent.maxTurnsHint')">
<NInputNumber
:value="settingsStore.agent.max_turns"
:min="1" :max="200" :step="5"
size="small" style="width: 120px"
@update:value="v => v != null && save({ max_turns: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.gatewayTimeout')" :hint="t('settings.agent.gatewayTimeoutHint')">
<NInputNumber
:value="settingsStore.agent.gateway_timeout"
:min="60" :max="7200" :step="60"
size="small" style="width: 120px"
@update:value="v => v != null && save({ gateway_timeout: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.restartDrainTimeout')" :hint="t('settings.agent.restartDrainTimeoutHint')">
<NInputNumber
:value="settingsStore.agent.restart_drain_timeout"
:min="10" :max="300" :step="10"
size="small" style="width: 120px"
@update:value="v => v != null && save({ restart_drain_timeout: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.toolEnforcement')" :hint="t('settings.agent.toolEnforcementHint')">
<NSelect
:value="settingsStore.agent.tool_use_enforcement || 'auto'"
:options="[
{ label: t('settings.agent.auto'), value: 'auto' },
{ label: t('settings.agent.always'), value: 'always' },
{ label: t('settings.agent.never'), value: 'never' },
]"
size="small" style="width: 120px"
@update:value="v => save({ tool_use_enforcement: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { NSwitch, 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<string, any>) {
try {
await settingsStore.saveSection('display', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.display.streaming')" :hint="t('settings.display.streamingHint')">
<NSwitch :value="settingsStore.display.streaming" @update:value="v => save({ streaming: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.compact')" :hint="t('settings.display.compactHint')">
<NSwitch :value="settingsStore.display.compact" @update:value="v => save({ compact: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.showReasoning')" :hint="t('settings.display.showReasoningHint')">
<NSwitch :value="settingsStore.display.show_reasoning" @update:value="v => save({ show_reasoning: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.showCost')" :hint="t('settings.display.showCostHint')">
<NSwitch :value="settingsStore.display.show_cost" @update:value="v => save({ show_cost: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.inlineDiffs')" :hint="t('settings.display.inlineDiffsHint')">
<NSwitch :value="settingsStore.display.inline_diffs" @update:value="v => save({ inline_diffs: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.bellOnComplete')" :hint="t('settings.display.bellOnCompleteHint')">
<NSwitch :value="settingsStore.display.bell_on_complete" @update:value="v => save({ bell_on_complete: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.busyInputMode')" :hint="t('settings.display.busyInputModeHint')">
<NSwitch :value="settingsStore.display.busy_input_mode === 'interrupt'" @update:value="v => save({ busy_input_mode: v ? 'interrupt' : 'off' })" />
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { NSwitch, NInputNumber, 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<string, any>) {
try {
await settingsStore.saveSection('memory', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.memory.enabled')" :hint="t('settings.memory.enabledHint')">
<NSwitch :value="settingsStore.memory.memory_enabled" @update:value="v => save({ memory_enabled: v })" />
</SettingRow>
<SettingRow :label="t('settings.memory.userProfile')" :hint="t('settings.memory.userProfileHint')">
<NSwitch :value="settingsStore.memory.user_profile_enabled" @update:value="v => save({ user_profile_enabled: v })" />
</SettingRow>
<SettingRow :label="t('settings.memory.charLimit')" :hint="t('settings.memory.charLimitHint')">
<NInputNumber
:value="settingsStore.memory.memory_char_limit"
:min="100" :max="10000" :step="100"
size="small" style="width: 120px"
@update:value="v => v != null && save({ memory_char_limit: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.memory.userCharLimit')" :hint="t('settings.memory.userCharLimitHint')">
<NInputNumber
:value="settingsStore.memory.user_char_limit"
:min="100" :max="10000" :step="100"
size="small" style="width: 120px"
@update:value="v => v != null && save({ user_char_limit: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NTag } from 'naive-ui'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
name: string
icon: string
config: Record<string, any>
credentials?: Record<string, any>
}>()
const expanded = ref(true)
const { t } = useI18n()
const configured = computed(() => {
const creds = props.credentials
if (!creds) return false
const keys = ['token', 'api_key', 'app_id', 'client_id', 'secret', 'app_secret', 'client_secret', 'access_token', 'bot_id', 'account_id', 'enabled']
// Check top-level and nested extra.*
const targets = [creds, creds.extra].filter(Boolean)
return targets.some(obj =>
keys.some(key => {
const val = (obj as Record<string, any>)[key]
return val !== undefined && val !== null && val !== '' && val !== false
})
)
})
</script>
<template>
<div class="platform-card" :class="{ configured }">
<div class="platform-card-header" @click="expanded = !expanded">
<div class="platform-info">
<span class="platform-icon" v-html="icon" />
<span class="platform-name">{{ name }}</span>
<NTag :type="configured ? 'success' : 'default'" size="small" round>
{{ configured ? t('common.configured') : t('common.notConfigured') }}
</NTag>
</div>
<span class="expand-icon" :class="{ expanded }">&#9662;</span>
</div>
<div v-if="expanded" class="platform-card-body">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.platform-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
margin-bottom: 12px;
overflow: hidden;
&.configured {
border-color: rgba($success, 0.2);
}
}
.platform-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba($text-primary, 0.03);
}
}
.platform-info {
display: flex;
align-items: center;
gap: 10px;
}
.platform-icon {
width: 18px;
height: 18px;
color: $text-secondary;
flex-shrink: 0;
}
.platform-name {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
.expand-icon {
font-size: 12px;
color: $text-muted;
transition: transform 0.2s;
&.expanded {
transform: rotate(0deg);
}
&:not(.expanded) {
transform: rotate(-90deg);
}
}
.platform-card-body {
padding: 0 16px 12px;
border-top: 1px solid $border-light;
}
</style>
@@ -0,0 +1,361 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NSwitch, NInput, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/config'
import PlatformCard from './PlatformCard.vue'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function saveChannel(platform: string, values: Record<string, any>) {
try {
await settingsStore.saveSection(platform, values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
// Save credentials to .env (matching hermes gateway setup behavior)
async function saveCredentials(platform: string, values: Record<string, any>) {
try {
await saveCredsApi(platform, values)
// Refresh to pick up new .env values
await settingsStore.fetchSettings()
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
function getCreds(key: string) {
return (settingsStore.platforms[key] || {}) as Record<string, any>
}
// Weixin QR code login state
const wxQrUrl = ref('')
const wxQrId = ref('')
const wxQrStatus = ref<'idle' | 'loading' | 'waiting' | 'scaned' | 'confirmed' | 'error' | 'expired'>('idle')
let wxPollTimer: ReturnType<typeof setTimeout> | null = null
async function startWeixinQrLogin() {
wxQrStatus.value = 'loading'
wxQrUrl.value = ''
wxQrId.value = ''
stopWeixinPoll()
try {
const data = await fetchWeixinQrCode()
wxQrId.value = data.qrcode
wxQrUrl.value = data.qrcode_url
window.open(data.qrcode_url, '_blank')
wxQrStatus.value = 'waiting'
pollWeixinStatus()
} catch (err: any) {
wxQrStatus.value = 'error'
message.error(err.message || 'Failed to get QR code')
}
}
function pollWeixinStatus() {
if (!wxQrId.value) return
wxPollTimer = setTimeout(async () => {
try {
const data = await pollWeixinQrStatus(wxQrId.value)
if (data.status === 'wait') {
pollWeixinStatus()
} else if (data.status === 'scaned') {
wxQrStatus.value = 'scaned'
pollWeixinStatus()
} else if (data.status === 'expired') {
wxQrStatus.value = 'expired'
} else if (data.status === 'confirmed') {
wxQrStatus.value = 'confirmed'
// Save credentials to .env
await saveWeixinCredentials({
account_id: data.account_id!,
token: data.token!,
base_url: data.base_url,
})
await settingsStore.fetchSettings()
message.success(t('settings.saved'))
}
} catch {
// Retry poll on network error
pollWeixinStatus()
}
}, 3000)
}
function stopWeixinPoll() {
if (wxPollTimer) {
clearTimeout(wxPollTimer)
wxPollTimer = null
}
}
onUnmounted(() => {
stopWeixinPoll()
})
const platforms = [
{
key: 'telegram',
name: 'Telegram',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
},
{
key: 'discord',
name: 'Discord',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg>',
},
{
key: 'slack',
name: 'Slack',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 0a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V5.042zm-1.27 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.27a2.527 2.527 0 0 1 2.523-2.52h6.313A2.528 2.528 0 0 1 24 18.956a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
},
{
key: 'whatsapp',
name: 'WhatsApp',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>',
},
{
key: 'matrix',
name: 'Matrix',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.48.324.448.217.786.619 1.017 1.205.24-.376.558-.702.956-.98.398-.277.872-.414 1.424-.414.41 0 .784.065 1.122.194.34.13.629.325.87.588.241.263.428.59.56.984.132.393.198.85.198 1.368v5.89h-2.49v-4.893c0-.268-.016-.525-.048-.77a1.627 1.627 0 00-.2-.63 1.028 1.028 0 00-.392-.426 1.294 1.294 0 00-.616-.134c-.277 0-.508.05-.693.15a1.043 1.043 0 00-.43.41 1.768 1.768 0 00-.214.616 4.15 4.15 0 00-.06.74v4.937H9.29v-4.937c0-.25-.01-.498-.032-.742a1.84 1.84 0 00-.166-.638.998.998 0 00-.363-.448 1.206 1.206 0 00-.624-.154c-.26 0-.483.048-.67.144a1.055 1.055 0 00-.436.402 1.744 1.744 0 00-.227.616 4.108 4.108 0 00-.063.74v4.937H5.21V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>',
},
{
key: 'feishu',
name: 'Feishu',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.59 3.41a2.25 2.25 0 0 1 3.182 0L13.5 7.14l-3.182 3.182L6.59 7.59a2.25 2.25 0 0 1 0-3.182zm5.303 5.303L15.075 5.53a2.25 2.25 0 0 1 3.182 3.182L15.075 11.894 11.893 8.713zM3.41 6.59a2.25 2.25 0 0 1 3.182 0l3.182 3.182-3.182 3.182a2.25 2.25 0 0 1-3.182-3.182L3.41 6.59zm5.303 5.303L11.894 15.075a2.25 2.25 0 0 1-3.182 3.182L5.53 15.075 8.713 11.893zm5.303-5.303L17.478 9.778a2.25 2.25 0 0 1-3.182 3.182L10.53 10.075l3.182-3.182 0 .023z"/></svg>',
},
// {
// key: 'dingtalk',
// name: 'DingTalk',
// icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.897 6.376a.5.5 0 0 1-.957-.016l-1.238-3.81a.5.5 0 0 0-.477-.354l-3.81-.324a.5.5 0 0 1-.074-.993l6.376-1.897a.5.5 0 0 1 .577.718z"/></svg>',
// },
{
key: 'weixin',
name: 'Weixin',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
{
key: 'wecom',
name: 'WeCom',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
]
</script>
<template>
<section class="settings-section">
<PlatformCard
v-for="p in platforms"
:key="p.key"
:name="p.name"
:icon="p.icon"
:config="settingsStore[p.key as keyof typeof settingsStore] as Record<string, any>"
:credentials="getCreds(p.key)"
>
<!-- Telegram -->
<template v-if="p.key === 'telegram'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('telegram').token || ''" clearable size="small" style="width: 300px" placeholder="123456:ABC-DEF..." @update:value="v => saveCredentials('telegram', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.telegram.require_mention" @update:value="v => saveChannel('telegram', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="settingsStore.telegram.reactions" @update:value="v => saveChannel('telegram', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.telegram.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('telegram', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :value="(settingsStore.telegram.mention_patterns || []).join(', ')" size="small" placeholder="pattern1, pattern2" @update:value="v => saveChannel('telegram', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Discord -->
<template v-if="p.key === 'discord'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('discord').token || ''" clearable size="small" style="width: 300px" placeholder="Bot token..." @update:value="v => saveCredentials('discord', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.discord.require_mention" @update:value="v => saveChannel('discord', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHint')">
<NSwitch :value="settingsStore.discord.auto_thread" @update:value="v => saveChannel('discord', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="settingsStore.discord.reactions" @update:value="v => saveChannel('discord', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :value="settingsStore.discord.free_response_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { free_response_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
<NInput :value="settingsStore.discord.allowed_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { allowed_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
<NInput :value="settingsStore.discord.ignored_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { ignored_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
<NInput :value="settingsStore.discord.no_thread_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { no_thread_channels: v })" />
</SettingRow>
</template>
<!-- Slack -->
<template v-if="p.key === 'slack'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('slack').token || ''" clearable size="small" style="width: 300px" placeholder="xoxb-..." @update:value="v => saveCredentials('slack', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.slack.require_mention" @update:value="v => saveChannel('slack', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowBots')" :hint="t('platform.allowBotsHint')">
<NSwitch :value="settingsStore.slack.allow_bots" @update:value="v => saveChannel('slack', { allow_bots: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :value="settingsStore.slack.free_response_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('slack', { free_response_channels: v })" />
</SettingRow>
</template>
<!-- WhatsApp -->
<template v-if="p.key === 'whatsapp'">
<SettingRow :label="t('platform.waEnabled')" :hint="t('platform.waEnabledHint')">
<NSwitch :value="getCreds('whatsapp').enabled" @update:value="v => saveCredentials('whatsapp', { enabled: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.whatsapp.require_mention" @update:value="v => saveChannel('whatsapp', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.whatsapp.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('whatsapp', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :value="(settingsStore.whatsapp.mention_patterns || []).join(', ')" size="small" placeholder="pattern1, pattern2" @update:value="v => saveChannel('whatsapp', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Matrix -->
<template v-if="p.key === 'matrix'">
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
<NInput :value="getCreds('matrix').token || ''" clearable size="small" style="width: 300px" placeholder="syt_..." @update:value="v => saveCredentials('matrix', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
<NInput :value="getCreds('matrix').extra?.homeserver || ''" clearable size="small" style="width: 300px" placeholder="https://matrix.org" @update:value="v => saveCredentials('matrix', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
<NSwitch :value="settingsStore.matrix.require_mention" @update:value="v => saveChannel('matrix', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHintRoom')">
<NSwitch :value="settingsStore.matrix.auto_thread" @update:value="v => saveChannel('matrix', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.dmMentionThreads')" :hint="t('platform.dmMentionThreadsHint')">
<NSwitch :value="settingsStore.matrix.dm_mention_threads" @update:value="v => saveChannel('matrix', { dm_mention_threads: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
<NInput :value="settingsStore.matrix.free_response_rooms || ''" size="small" placeholder="room_id1,room_id2" @update:value="v => saveChannel('matrix', { free_response_rooms: v })" />
</SettingRow>
</template>
<!-- Feishu -->
<template v-if="p.key === 'feishu'">
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
<NInput :value="getCreds('feishu').extra?.app_id || ''" clearable size="small" style="width: 300px" placeholder="cli_..." @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
<NInput :value="getCreds('feishu').extra?.app_secret || ''" clearable size="small" style="width: 300px" placeholder="App Secret" @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.feishu.require_mention" @update:value="v => saveChannel('feishu', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.feishu.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('feishu', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- DingTalk -->
<template v-if="p.key === 'dingtalk'">
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
<NInput :value="getCreds('dingtalk').extra?.client_id || ''" clearable size="small" style="width: 300px" placeholder="Client ID" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
<NInput :value="getCreds('dingtalk').extra?.client_secret || ''" clearable size="small" style="width: 300px" placeholder="Client Secret" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.dingtalk.require_mention" @update:value="v => saveChannel('dingtalk', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.dingtalk.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('dingtalk', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- Weixin -->
<template v-if="p.key === 'weixin'">
<div class="weixin-qr-section">
<NButton
v-if="wxQrStatus === 'idle' || wxQrStatus === 'error' || wxQrStatus === 'expired' || wxQrStatus === 'confirmed'"
type="primary"
size="small"
@click="startWeixinQrLogin"
>
{{ wxQrStatus === 'confirmed' ? t('platform.qrRelogin') : t('platform.qrLogin') }}
</NButton>
<div v-if="wxQrStatus === 'loading'" class="weixin-qr-loading">
<NSpin size="small" />
<span>{{ t('platform.qrFetching') }}</span>
</div>
<div v-if="wxQrStatus === 'waiting' || wxQrStatus === 'scaned'" class="weixin-qr-hint">
{{ wxQrStatus === 'scaned' ? t('platform.qrScanedHint') : t('platform.qrScanHint') }}
</div>
</div>
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
<NInput :value="getCreds('weixin').token || ''" clearable size="small" style="width: 300px" placeholder="Token" @update:value="v => saveCredentials('weixin', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
<NInput :value="getCreds('weixin').extra?.account_id || ''" clearable size="small" style="width: 300px" placeholder="Account ID" @update:value="v => saveCredentials('weixin', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
</SettingRow>
</template>
<!-- WeCom -->
<template v-if="p.key === 'wecom'">
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
<NInput :value="getCreds('wecom').extra?.bot_id || ''" clearable size="small" style="width: 300px" placeholder="Bot ID" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
<NInput :value="getCreds('wecom').extra?.secret || ''" clearable size="small" style="width: 300px" placeholder="Secret" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, secret: v } })" />
</SettingRow>
</template>
</PlatformCard>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
.weixin-qr-section {
margin-top: 12px;
margin-bottom: 12px;
}
.weixin-qr-loading {
display: flex;
align-items: center;
gap: 8px;
color: $text-muted;
font-size: 13px;
}
.weixin-qr-hint {
font-size: 13px;
color: $text-secondary;
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { NSwitch, 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<string, any>) {
try {
await settingsStore.saveSection('privacy', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.privacy.redactPii')" :hint="t('settings.privacy.redactPiiHint')">
<NSwitch :value="settingsStore.privacy.redact_pii" @update:value="v => save({ redact_pii: v })" />
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,60 @@
<script setup lang="ts">
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<string, any>) {
try {
await settingsStore.saveSection('session_reset', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.session.mode')" :hint="t('settings.session.modeHint')">
<NSelect
:value="settingsStore.sessionReset.mode || 'both'"
:options="[
{ label: t('settings.session.modeBoth'), value: 'both' },
{ label: t('settings.session.modeIdle'), value: 'idle' },
{ label: t('settings.session.modeHourly'), value: 'hourly' },
]"
size="small" style="width: 140px"
@update:value="v => save({ mode: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.session.idleMinutes')" :hint="t('settings.session.idleMinutesHint')">
<NInputNumber
:value="settingsStore.sessionReset.idle_minutes"
:min="10" :max="10080" :step="30"
size="small" style="width: 120px"
@update:value="v => v != null && save({ idle_minutes: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.session.atHour')" :hint="t('settings.session.atHourHint')">
<NInputNumber
:value="settingsStore.sessionReset.at_hour"
:min="0" :max="23" :step="1"
size="small" style="width: 120px"
@update:value="v => v != null && save({ at_hour: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
defineProps<{
label: string
hint?: string
}>()
</script>
<template>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label">{{ label }}</label>
<p v-if="hint" class="setting-hint">{{ hint }}</p>
</div>
<div class="setting-control">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid $border-light;
&:last-child {
border-bottom: none;
}
}
.setting-info {
flex: 1;
margin-right: 16px;
}
.setting-label {
font-size: 13px;
color: $text-primary;
display: block;
}
.setting-hint {
font-size: 12px;
color: $text-muted;
margin-top: 2px;
}
.setting-control {
flex-shrink: 0;
}
</style>
+8 -5
View File
@@ -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 })
<span class="detail-name">{{ skill }}</span>
</div>
<div v-if="loading && !content" class="detail-loading">Loading...</div>
<div v-if="loading && !content" class="detail-loading">{{ t('common.loading') }}</div>
<template v-else>
<!-- Breadcrumb for file view -->
@@ -85,7 +88,7 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to {{ skill }}
{{ t('skills.backTo') }} {{ skill }}
</button>
<span class="breadcrumb-path">{{ viewingFile }}</span>
</div>
@@ -98,7 +101,7 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
<!-- Attached files -->
<div v-if="!viewingFile && files.length > 0" class="detail-files">
<div class="files-header">Attached Files</div>
<div class="files-header">{{ t('skills.attachedFiles') }}</div>
<div class="files-list">
<button
v-for="f in files"
+4 -1
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { SkillCategory } from '@/api/skills'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
categories: SkillCategory[]
@@ -43,7 +46,7 @@ function handleSelect(category: string, skill: string) {
<template>
<div class="skill-list">
<div v-if="filteredCategories.length === 0" class="skill-empty">
{{ searchQuery ? 'No skills match your search' : 'No skills found' }}
{{ searchQuery ? t('skills.noMatch') : t('skills.noSkills') }}
</div>
<div
v-for="cat in filteredCategories"
+13
View File
@@ -0,0 +1,13 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en'
import zh from './locales/zh'
const saved = localStorage.getItem('hermes_locale')
const detected = navigator.language.slice(0, 2)
export const i18n = createI18n({
legacy: false,
locale: saved || (detected === 'zh' ? 'zh' : 'en'),
fallbackLocale: 'en',
messages: { en, zh },
})
+340
View File
@@ -0,0 +1,340 @@
export default {
// Common
common: {
loading: 'Loading...',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
save: 'Save',
saved: 'Saved',
update: 'Update',
create: 'Create',
saveFailed: 'Save failed',
ok: 'OK',
copied: 'Copied',
copy: 'Copy',
noData: 'No data',
fetch: 'Fetch',
add: 'Add',
enable: 'Enable',
disable: 'Disable',
configured: 'Configured',
notConfigured: 'Not configured',
},
// Sidebar
sidebar: {
chat: 'Chat',
jobs: 'Jobs',
models: 'Models',
skills: 'Skills',
memory: 'Memory',
logs: 'Logs',
channels: 'Channels',
settings: 'Settings',
connected: 'Connected',
disconnected: 'Disconnected',
},
// Chat
chat: {
emptyState: 'Start a conversation with Hermes Agent',
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
attachFiles: 'Attach files',
stop: 'Stop',
send: 'Send',
sessions: 'Sessions',
noSessions: 'No sessions',
newChat: 'New Chat',
deleteSession: 'Delete this session?',
sessionDeleted: 'Session deleted',
rename: 'Rename',
copySessionId: 'Copy Session ID',
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameSession: 'Rename Session',
enterNewTitle: 'Enter new title',
other: 'Other',
runFailed: 'Run failed',
tool: 'Tool',
error: 'error',
arguments: 'Arguments',
result: 'Result',
truncated: '... (truncated)',
},
// Jobs
jobs: {
title: 'Scheduled Jobs',
createJob: 'Create Job',
editJob: 'Edit Job',
noJobs: 'No scheduled jobs yet. Create one to get started.',
name: 'Name',
namePlaceholder: 'Job name',
schedule: 'Schedule (Cron Expression)',
schedulePlaceholder: 'e.g. 0 9 * * *',
quickPresets: 'Quick Presets',
selectPreset: 'Select a preset...',
presetEveryMinute: 'Every minute',
presetEvery5Min: 'Every 5 minutes',
presetEveryHour: 'Every hour',
presetEveryDay: 'Every day at 00:00',
presetEveryDay9: 'Every day at 09:00',
presetEveryMonday: 'Every Monday at 09:00',
presetEveryMonth: 'Every month 1st at 09:00',
prompt: 'Prompt',
promptPlaceholder: 'The prompt to execute',
deliverTarget: 'Deliver Target',
origin: 'Origin',
local: 'Local',
repeatCount: 'Repeat Count (optional)',
repeatPlaceholder: 'Leave empty for infinite',
jobCreated: 'Job created',
jobUpdated: 'Job updated',
nameRequired: 'Name is required',
scheduleRequired: 'Schedule is required',
loadFailed: 'Failed to load job',
jobPaused: 'Job paused',
jobResumed: 'Job resumed',
jobTriggered: 'Job triggered',
jobDeleted: 'Job deleted',
status: {
running: 'Running',
paused: 'Paused',
disabled: 'Disabled',
scheduled: 'Scheduled',
},
info: {
schedule: 'Schedule',
lastRun: 'Last Run',
nextRun: 'Next Run',
deliver: 'Deliver',
repeat: 'Repeat',
},
action: {
pause: 'Pause',
pauseJob: 'Pause job',
resume: 'Resume',
resumeJob: 'Resume job',
runNow: 'Run Now',
triggerImmediately: 'Trigger immediately',
},
},
// Skills
skills: {
title: 'Skills',
searchPlaceholder: 'Search skills...',
noMatch: 'No skills match your search',
noSkills: 'No skills found',
backTo: 'Back to',
attachedFiles: 'Attached Files',
loadFailed: 'Failed to load skill',
fileLoadFailed: 'Failed to load file',
},
// Memory
memory: {
title: 'Memory',
refresh: 'Refresh',
loadFailed: 'Failed to load memory',
myNotes: 'My Notes',
noNotes: 'No notes yet.',
notesPlaceholder: 'Write your notes...',
userProfile: 'User Profile',
noProfile: 'No profile yet.',
profilePlaceholder: 'Write your profile...',
},
// Models
models: {
title: 'Models',
addProvider: 'Add Provider',
providerType: 'Provider Type',
preset: 'Preset',
custom: 'Custom',
selectProvider: 'Select Provider',
chooseProvider: 'Choose a provider...',
name: 'Name',
autoGeneratedName: 'Auto-generated from Base URL',
baseUrl: 'Base URL',
baseUrlPlaceholder: 'e.g. https://api.example.com/v1',
apiKey: 'API Key',
apiKeyPlaceholder: 'sk-...',
defaultModel: 'Default Model',
selectModel: 'Select a model...',
providerAdded: 'Provider added',
providerDeleted: 'Provider deleted',
deleteProvider: 'Delete Provider',
deleteConfirm: 'Are you sure you want to delete "{name}"?',
noProviders: 'No providers found. Add a custom provider to get started.',
builtIn: 'Built-in',
customType: 'Custom',
provider: 'Provider',
local: 'Local ({host})',
selectProviderRequired: 'Please select a provider',
baseUrlRequired: 'Base URL is required',
apiKeyRequired: 'API Key is required',
modelRequired: 'Default Model is required',
enterBaseUrl: 'Please enter Base URL first',
unexpectedFormat: 'Unexpected response format',
foundModels: 'Found {count} models',
fetchFailed: 'Failed to fetch models',
},
// Logs
logs: {
title: 'Logs',
all: 'All',
searchPlaceholder: 'Search...',
refresh: 'Refresh',
noEntries: 'No log entries',
},
// Settings
settings: {
title: 'Settings',
saved: 'Saved',
saveFailed: 'Save failed',
tabs: {
display: 'Display',
agent: 'Agent',
memory: 'Memory',
session: 'Session',
privacy: 'Privacy',
apiServer: 'API Server',
},
display: {
streaming: 'Stream Responses',
streamingHint: 'Show AI replies in real-time',
compact: 'Compact Mode',
compactHint: 'Reduce message spacing',
showReasoning: 'Show Reasoning',
showReasoningHint: 'Show model thinking process',
showCost: 'Show Cost',
showCostHint: 'Show token usage in replies',
inlineDiffs: 'Inline Diffs',
inlineDiffsHint: 'Show code changes inline',
bellOnComplete: 'Completion Sound',
bellOnCompleteHint: 'Play sound when AI finishes',
busyInputMode: 'Busy Input Mode',
busyInputModeHint: 'Allow input while AI is processing',
},
agent: {
maxTurns: 'Max Turns',
maxTurnsHint: 'Maximum interaction rounds per conversation',
gatewayTimeout: 'Gateway Timeout',
gatewayTimeoutHint: 'Request timeout in seconds',
restartDrainTimeout: 'Restart Drain Timeout',
restartDrainTimeoutHint: 'Drain timeout before restart in seconds',
toolEnforcement: 'Tool Enforcement',
toolEnforcementHint: 'Control tool call execution mode',
auto: 'Auto',
always: 'Always',
never: 'Never',
},
memory: {
enabled: 'Enable Memory',
enabledHint: 'Allow AI to remember conversation context',
userProfile: 'User Profile',
userProfileHint: 'Allow AI to remember user preferences',
charLimit: 'Memory Char Limit',
charLimitHint: 'Max characters for MEMORY.md',
userCharLimit: 'User Profile Char Limit',
userCharLimitHint: 'Max characters for USER.md',
},
session: {
mode: 'Reset Mode',
modeHint: 'Trigger condition for session reset',
modeBoth: 'Idle + Scheduled',
modeIdle: 'Idle Only',
modeHourly: 'Scheduled Only',
idleMinutes: 'Idle Timeout',
idleMinutesHint: 'Wait time before auto-reset (minutes)',
atHour: 'Scheduled Reset Time',
atHourHint: 'Reset session at this hour daily',
},
privacy: {
redactPii: 'Redact PII',
redactPiiHint: 'Auto-detect and hide sensitive info (passwords, keys, etc.)',
},
apiServer: {
enable: 'Enable',
enableHint: 'Enable API server',
host: 'Host',
hostHint: 'Listen address',
port: 'Port',
portHint: 'Listen port',
key: 'Key',
keyHint: 'API access key',
cors: 'CORS Origins',
corsHint: 'Allowed cross-origin sources',
},
},
// Platform channel settings
platform: {
requireMention: "Require {'@'}Mention",
requireMentionGroup: "Require {'@'}mention in groups to respond",
requireMentionChannel: "Require {'@'}mention in channels to respond",
requireMentionRoom: "Require {'@'}mention in rooms to respond",
reactions: 'Reactions',
reactionsHint: 'React to messages with emoji',
freeResponseChats: 'Free Response Chats',
freeResponseChatsHint: "Chat IDs that respond without {'@'}mention (comma-separated)",
freeResponseChannels: 'Free Response Channels',
freeResponseChannelsHint: "Channel IDs that respond without {'@'}mention (comma-separated)",
freeResponseRooms: 'Free Response Rooms',
freeResponseRoomsHint: "Room IDs that respond without {'@'}mention (comma-separated)",
mentionPatterns: 'Custom Mention Patterns',
mentionPatternsHint: 'Additional trigger patterns',
autoThread: 'Auto Thread',
autoThreadHint: "Auto-create reply threads after {'@'}mention",
autoThreadHintRoom: 'Auto-create reply threads in rooms',
dmMentionThreads: 'DM Mention Threads',
dmMentionThreadsHint: 'Use thread replies for mentions in DMs',
allowBots: 'Allow Bot Messages',
allowBotsHint: 'Respond to messages from other bots',
allowedChannels: 'Allowed Channels',
allowedChannelsHint: 'Whitelist channel IDs (comma-separated)',
ignoredChannels: 'Ignored Channels',
ignoredChannelsHint: 'Channels where bot never responds (comma-separated)',
noThreadChannels: 'No-Thread Channels',
noThreadChannelsHint: 'Channels where bot responds without threads (comma-separated)',
botToken: 'Bot Token',
botTokenHint: 'Bot token from developer portal',
accessToken: 'Access Token',
accessTokenHint: 'Matrix access token',
homeserver: 'Homeserver URL',
homeserverHint: 'Matrix homeserver URL',
appId: 'App ID',
appIdHint: 'Feishu App ID',
appSecret: 'App Secret',
appSecretHint: 'Feishu App Secret',
clientId: 'Client ID',
clientIdHint: 'DingTalk Client ID',
clientSecret: 'Client Secret',
clientSecretHint: 'DingTalk Client Secret',
botId: 'Bot ID',
botIdHint: 'WeCom Bot ID',
wecomSecretHint: 'WeCom Bot Secret',
waEnabled: 'Enable WhatsApp',
waEnabledHint: 'Enable WhatsApp via QR code pairing',
weixinToken: 'Weixin Token',
weixinTokenHint: 'From weixin CLI QR login (hermes weixin)',
accountId: 'Account ID',
accountIdHint: 'Weixin account ID',
qrLogin: 'QR Login',
qrRelogin: 'Re-login',
qrFetching: 'Fetching QR code...',
qrScanHint: 'Scan with WeChat to login',
qrScanedHint: 'Scaned, please confirm on phone...',
},
// Language
language: {
label: 'Language',
zh: '中文',
en: 'English',
},
}
+340
View File
@@ -0,0 +1,340 @@
export default {
// 通用
common: {
loading: '加载中...',
cancel: '取消',
delete: '删除',
edit: '编辑',
save: '保存',
saved: '已保存',
saveFailed: '保存失败',
ok: '确定',
copied: '已复制',
copy: '复制',
update: '更新',
create: '创建',
noData: '暂无数据',
fetch: '获取',
add: '添加',
enable: '启用',
disable: '禁用',
configured: '已配置',
notConfigured: '未配置',
},
// 侧边栏
sidebar: {
chat: '对话',
jobs: '任务',
models: '模型',
skills: '技能',
memory: '记忆',
logs: '日志',
channels: '频道',
settings: '设置',
connected: '已连接',
disconnected: '未连接',
},
// 对话
chat: {
emptyState: '开始与 Hermes Agent 对话',
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
attachFiles: '添加附件',
stop: '停止',
send: '发送',
sessions: '会话',
noSessions: '暂无会话',
newChat: '新建对话',
deleteSession: '确定删除此会话?',
sessionDeleted: '会话已删除',
rename: '重命名',
copySessionId: '复制会话 ID',
renamed: '已重命名',
renameFailed: '重命名失败',
renameSession: '重命名会话',
enterNewTitle: '输入新标题',
other: '其他',
runFailed: '运行失败',
tool: '工具',
error: '错误',
arguments: '参数',
result: '结果',
truncated: '... (已截断)',
},
// 定时任务
jobs: {
title: '定时任务',
createJob: '创建任务',
editJob: '编辑任务',
noJobs: '暂无定时任务,创建一个开始吧。',
name: '名称',
namePlaceholder: '任务名称',
schedule: '调度表达式 (Cron)',
schedulePlaceholder: '例如 0 9 * * *',
quickPresets: '快速预设',
selectPreset: '选择预设...',
presetEveryMinute: '每分钟',
presetEvery5Min: '每 5 分钟',
presetEveryHour: '每小时',
presetEveryDay: '每天 00:00',
presetEveryDay9: '每天 09:00',
presetEveryMonday: '每周一 09:00',
presetEveryMonth: '每月 1 日 09:00',
prompt: '提示词',
promptPlaceholder: '要执行的内容',
deliverTarget: '投递目标',
origin: '来源',
local: '本地',
repeatCount: '重复次数(可选)',
repeatPlaceholder: '留空表示无限重复',
jobCreated: '任务已创建',
jobUpdated: '任务已更新',
nameRequired: '名称为必填项',
scheduleRequired: '调度表达式为必填项',
loadFailed: '加载任务失败',
jobPaused: '任务已暂停',
jobResumed: '任务已恢复',
jobTriggered: '任务已触发',
jobDeleted: '任务已删除',
status: {
running: '运行中',
paused: '已暂停',
disabled: '已禁用',
scheduled: '已调度',
},
info: {
schedule: '调度',
lastRun: '上次运行',
nextRun: '下次运行',
deliver: '投递',
repeat: '重复',
},
action: {
pause: '暂停',
pauseJob: '暂停任务',
resume: '恢复',
resumeJob: '恢复任务',
runNow: '立即运行',
triggerImmediately: '立即触发',
},
},
// 技能
skills: {
title: '技能',
searchPlaceholder: '搜索技能...',
noMatch: '没有匹配的技能',
noSkills: '暂无技能',
backTo: '返回',
attachedFiles: '附件文件',
loadFailed: '加载技能失败',
fileLoadFailed: '加载文件失败',
},
// 记忆
memory: {
title: '记忆',
refresh: '刷新',
loadFailed: '加载记忆失败',
myNotes: '我的笔记',
noNotes: '暂无笔记。',
notesPlaceholder: '输入笔记内容...',
userProfile: '用户画像',
noProfile: '暂无画像。',
profilePlaceholder: '输入用户画像...',
},
// 模型
models: {
title: '模型',
addProvider: '添加 Provider',
providerType: 'Provider 类型',
preset: '预设',
custom: '自定义',
selectProvider: '选择 Provider',
chooseProvider: '选择一个 provider...',
name: '名称',
autoGeneratedName: '根据 Base URL 自动生成',
baseUrl: 'Base URL',
baseUrlPlaceholder: '例如 https://api.example.com/v1',
apiKey: 'API Key',
apiKeyPlaceholder: 'sk-...',
defaultModel: '默认模型',
selectModel: '选择模型...',
providerAdded: 'Provider 已添加',
providerDeleted: 'Provider 已删除',
deleteProvider: '删除 Provider',
deleteConfirm: '确定删除 "{name}" 吗?',
noProviders: '暂无 Provider,添加一个开始吧。',
builtIn: '内置',
customType: '自定义',
provider: 'Provider',
local: '本地 ({host})',
selectProviderRequired: '请选择 Provider',
baseUrlRequired: 'Base URL 为必填项',
apiKeyRequired: 'API Key 为必填项',
modelRequired: '默认模型为必填项',
enterBaseUrl: '请先输入 Base URL',
unexpectedFormat: '响应格式异常',
foundModels: '找到 {count} 个模型',
fetchFailed: '获取模型失败',
},
// 日志
logs: {
title: '日志',
all: '全部',
searchPlaceholder: '搜索...',
refresh: '刷新',
noEntries: '暂无日志',
},
// 设置
settings: {
title: '设置',
saved: '已保存',
saveFailed: '保存失败',
tabs: {
display: '显示',
agent: '代理',
memory: '记忆',
session: '会话',
privacy: '隐私',
apiServer: 'API 服务器',
},
display: {
streaming: '流式响应',
streamingHint: '实时显示 AI 回复',
compact: '紧凑模式',
compactHint: '减少消息间距',
showReasoning: '显示推理过程',
showReasoningHint: '展示模型思考过程',
showCost: '显示费用',
showCostHint: '在回复中显示 token 使用量',
inlineDiffs: '内联差异',
inlineDiffsHint: '代码变更以内联方式显示',
bellOnComplete: '完成提示音',
bellOnCompleteHint: 'AI 回复完成时播放提示音',
busyInputMode: '忙碌输入模式',
busyInputModeHint: 'AI 处理中仍可输入',
},
agent: {
maxTurns: '最大轮次',
maxTurnsHint: '单次对话最大交互轮数',
gatewayTimeout: '网关超时',
gatewayTimeoutHint: '单次请求超时时间(秒)',
restartDrainTimeout: '重启排空超时',
restartDrainTimeoutHint: '重启前排空请求的超时时间(秒)',
toolEnforcement: '工具执行策略',
toolEnforcementHint: '控制工具调用的执行模式',
auto: '自动',
always: '始终',
never: '从不',
},
memory: {
enabled: '启用记忆',
enabledHint: '允许 AI 记住对话上下文',
userProfile: '用户画像',
userProfileHint: '允许 AI 记住用户偏好信息',
charLimit: '记忆字符上限',
charLimitHint: 'MEMORY.md 最大字符数',
userCharLimit: '用户画像字符上限',
userCharLimitHint: 'USER.md 最大字符数',
},
session: {
mode: '重置模式',
modeHint: '会话重置的触发条件',
modeBoth: '空闲 + 定时',
modeIdle: '仅空闲',
modeHourly: '仅定时',
idleMinutes: '空闲超时',
idleMinutesHint: '无操作后自动重置的等待时间(分钟)',
atHour: '定时重置时间',
atHourHint: '每天在指定小时重置会话',
},
privacy: {
redactPii: '脱敏 PII',
redactPiiHint: '自动检测并隐藏敏感信息(密码、密钥等)',
},
apiServer: {
enable: '启用',
enableHint: '启用 API 服务器',
host: '主机',
hostHint: '监听地址',
port: '端口',
portHint: '监听端口',
key: '密钥',
keyHint: 'API 访问密钥',
cors: 'CORS 来源',
corsHint: '允许的跨域来源',
},
},
// 平台频道设置
platform: {
requireMention: "需要 {'@'}提及",
requireMentionGroup: "群组中需要 {'@'}机器人 才会响应",
requireMentionChannel: "频道中需要 {'@'}机器人 才会响应",
requireMentionRoom: "房间中需要 {'@'}机器人 才会响应",
reactions: '表情回应',
reactionsHint: '对消息添加表情回应',
freeResponseChats: '自由响应聊天',
freeResponseChatsHint: "不需要 {'@'}提及即响应的聊天 ID(逗号分隔)",
freeResponseChannels: '自由响应频道',
freeResponseChannelsHint: "不需要 {'@'}提及即响应的频道 ID(逗号分隔)",
freeResponseRooms: '自由响应房间',
freeResponseRoomsHint: "不需要 {'@'}提及即响应的房间 ID(逗号分隔)",
mentionPatterns: '自定义提及模式',
mentionPatternsHint: '额外的触发模式列表',
autoThread: '自动创建线程',
autoThreadHint: "{'@'}提及 后自动创建回复线程",
autoThreadHintRoom: '在房间中自动创建回复线程',
dmMentionThreads: 'DM 提及线程',
dmMentionThreadsHint: '在私聊中也使用线程回复提及',
allowBots: '允许机器人消息',
allowBotsHint: '响应其他机器人发送的消息',
allowedChannels: '允许的频道',
allowedChannelsHint: '白名单频道 ID(逗号分隔)',
ignoredChannels: '忽略的频道',
ignoredChannelsHint: '不响应的频道 ID(逗号分隔)',
noThreadChannels: '无线程频道',
noThreadChannelsHint: '不创建线程的频道 ID(逗号分隔)',
botToken: 'Bot Token',
botTokenHint: '开发者门户获取的 Bot Token',
accessToken: 'Access Token',
accessTokenHint: 'Matrix Access Token',
homeserver: 'Homeserver URL',
homeserverHint: 'Matrix 服务器地址',
appId: 'App ID',
appIdHint: '飞书 App ID',
appSecret: 'App Secret',
appSecretHint: '飞书 App Secret',
clientId: 'Client ID',
clientIdHint: '钉钉 Client ID',
clientSecret: 'Client Secret',
clientSecretHint: '钉钉 Client Secret',
botId: 'Bot ID',
botIdHint: '企业微信 Bot ID',
wecomSecretHint: '企业微信 Bot Secret',
waEnabled: '启用 WhatsApp',
waEnabledHint: '通过二维码配对启用 WhatsApp',
weixinToken: '微信 Token',
weixinTokenHint: '通过 weixin CLI 扫码登录获取 (hermes weixin)',
accountId: 'Account ID',
accountIdHint: '微信 Account ID',
qrLogin: '扫码登录',
qrRelogin: '重新登录',
qrFetching: '正在获取二维码...',
qrScanHint: '使用微信扫描二维码登录',
qrScanedHint: '已扫描,请在手机上确认...',
},
// 语言
language: {
label: '语言',
zh: '中文',
en: 'English',
},
}
+2
View File
@@ -1,10 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import { i18n } from './i18n'
import App from './App.vue'
import './styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(i18n)
app.use(router)
app.mount('#app')
+5
View File
@@ -38,6 +38,11 @@ const router = createRouter({
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
{
path: '/channels',
name: 'channels',
component: () => import('@/views/ChannelsView.vue'),
},
],
})
+87
View File
@@ -0,0 +1,87 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as configApi from '@/api/config'
import type { DisplayConfig, AgentConfig, MemoryConfig, SessionResetConfig, PrivacyConfig } from '@/api/config'
export const useSettingsStore = defineStore('settings', () => {
const loading = ref(false)
const display = ref<DisplayConfig>({})
const agent = ref<AgentConfig>({})
const memory = ref<MemoryConfig>({})
const sessionReset = ref<SessionResetConfig>({})
const privacy = ref<PrivacyConfig>({})
const telegram = ref<Record<string, any>>({})
const discord = ref<Record<string, any>>({})
const slack = ref<Record<string, any>>({})
const whatsapp = ref<Record<string, any>>({})
const matrix = ref<Record<string, any>>({})
const wecom = ref<Record<string, any>>({})
const feishu = ref<Record<string, any>>({})
const dingtalk = ref<Record<string, any>>({})
const weixin = ref<Record<string, any>>({})
const platforms = ref<Record<string, any>>({})
async function fetchSettings() {
loading.value = true
try {
const data = await configApi.fetchConfig()
display.value = data.display || {}
agent.value = data.agent || {}
memory.value = data.memory || {}
sessionReset.value = data.session_reset || {}
privacy.value = data.privacy || {}
telegram.value = data.telegram || {}
discord.value = data.discord || {}
slack.value = data.slack || {}
whatsapp.value = data.whatsapp || {}
matrix.value = data.matrix || {}
wecom.value = data.wecom || {}
feishu.value = data.feishu || {}
dingtalk.value = data.dingtalk || {}
weixin.value = data.weixin || {}
platforms.value = data.platforms || {}
} catch (err) {
console.error('Failed to fetch settings:', err)
} finally {
loading.value = false
}
}
async function saveSection(section: string, values: Record<string, any>) {
await configApi.updateConfigSection(section, values)
switch (section) {
case 'display': display.value = { ...display.value, ...values }; break
case 'agent': agent.value = { ...agent.value, ...values }; break
case 'memory': memory.value = { ...memory.value, ...values }; break
case 'session_reset': sessionReset.value = { ...sessionReset.value, ...values }; break
case 'privacy': privacy.value = { ...privacy.value, ...values }; break
case 'telegram': telegram.value = { ...telegram.value, ...values }; break
case 'discord': discord.value = { ...discord.value, ...values }; break
case 'slack': slack.value = { ...slack.value, ...values }; break
case 'whatsapp': whatsapp.value = { ...whatsapp.value, ...values }; break
case 'matrix': matrix.value = { ...matrix.value, ...values }; break
case 'wechat': case 'wecom': wecom.value = { ...wecom.value, ...values }; break
case 'feishu': feishu.value = { ...feishu.value, ...values }; break
case 'dingtalk': dingtalk.value = { ...dingtalk.value, ...values }; break
case 'weixin': weixin.value = { ...weixin.value, ...values }; break
case 'platforms': {
// Deep-merge each platform's credentials
for (const [key, val] of Object.entries(values)) {
platforms.value = {
...platforms.value,
[key]: { ...(platforms.value[key] || {}), ...(val as Record<string, any>) },
}
}
break
}
}
}
return {
loading,
display, agent, memory, sessionReset, privacy,
telegram, discord, slack, whatsapp, matrix, wecom, feishu, dingtalk, weixin, platforms,
fetchSettings, saveSection,
}
})
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import PlatformSettings from '@/components/settings/PlatformSettings.vue'
const settingsStore = useSettingsStore()
const { t } = useI18n()
onMounted(() => {
settingsStore.fetchSettings()
})
</script>
<template>
<div class="channels-view">
<header class="channels-header">
<h2 class="header-title">{{ t('sidebar.channels') }}</h2>
</header>
<div class="channels-content">
<NSpin :show="settingsStore.loading">
<PlatformSettings />
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.channels-view {
height: 100vh;
display: flex;
flex-direction: column;
}
.channels-header {
display: flex;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
.channels-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
</style>
+4 -2
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NButton, NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import JobsPanel from '@/components/jobs/JobsPanel.vue'
import JobFormModal from '@/components/jobs/JobFormModal.vue'
import { useJobsStore } from '@/stores/jobs'
const { t } = useI18n()
const jobsStore = useJobsStore()
const showModal = ref(false)
const editingJob = ref<string | null>(null)
@@ -37,12 +39,12 @@ async function handleSave() {
<template>
<div class="jobs-view">
<header class="jobs-header">
<h2 class="header-title">Scheduled Jobs</h2>
<h2 class="header-title">{{ t('jobs.title') }}</h2>
<NButton type="primary" @click="openCreateModal">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
Create Job
{{ t('jobs.createJob') }}
</NButton>
</header>
+9 -7
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NSelect, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { fetchLogFiles, fetchLogs, type LogEntry } from '@/api/logs'
const { t } = useI18n()
const message = useMessage()
const logFiles = ref<{ name: string; size: string; modified: string }[]>([])
const selectedLog = ref('agent')
@@ -16,13 +18,13 @@ const logOptions = computed(() =>
logFiles.value.map(f => ({ label: `${f.name} (${f.size})`, value: f.name })),
)
const levelOptions = [
{ label: 'All', value: '' },
const levelOptions = computed(() => [
{ label: t('logs.all'), value: '' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'WARNING', value: 'WARNING' },
{ label: 'INFO', value: 'INFO' },
{ label: 'DEBUG', value: 'DEBUG' },
]
])
const lineOptions = [
{ label: '50', value: 50 },
@@ -85,7 +87,7 @@ onMounted(async () => {
<template>
<div class="logs-view">
<header class="logs-header">
<h2 class="header-title">Logs</h2>
<h2 class="header-title">{{ t('logs.title') }}</h2>
<div class="header-actions">
<NSelect
v-model:value="selectedLog"
@@ -111,16 +113,16 @@ onMounted(async () => {
<input
v-model="searchQuery"
class="search-input"
placeholder="Search..."
:placeholder="t('logs.searchPlaceholder')"
/>
<NButton size="small" :loading="loading" @click="loadLogs">Refresh</NButton>
<NButton size="small" :loading="loading" @click="loadLogs">{{ t('logs.refresh') }}</NButton>
</div>
</header>
<div class="logs-body">
<NSpin :show="loading">
<div v-if="filteredEntries.length === 0 && !loading" class="logs-empty">
No log entries
{{ t('logs.noEntries') }}
</div>
<div class="log-list">
<div
+20 -18
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
import { fetchMemory, saveMemory, type MemoryData } from '@/api/skills'
const { t } = useI18n()
const message = useMessage()
const loading = ref(false)
const data = ref<MemoryData | null>(null)
@@ -19,7 +21,7 @@ async function loadMemory() {
data.value = await fetchMemory()
} catch (err: any) {
console.error('Failed to load memory:', err)
message.error('Failed to load memory')
message.error(t('memory.loadFailed'))
} finally {
loading.value = false
}
@@ -43,9 +45,9 @@ async function handleSave() {
await loadMemory()
editingSection.value = null
editContent.value = ''
message.success('Saved')
message.success(t('common.saved'))
} catch (err: any) {
message.error(`Save failed: ${err.message}`)
message.error(`${t('common.saveFailed')}: ${err.message}`)
} finally {
saving.value = false
}
@@ -71,7 +73,7 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
<template>
<div class="memory-view">
<header class="memory-header">
<h2 class="header-title">Memory</h2>
<h2 class="header-title">{{ t('memory.title') }}</h2>
<NButton size="small" quaternary @click="loadMemory">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -79,12 +81,12 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</template>
Refresh
{{ t('memory.refresh') }}
</NButton>
</header>
<div class="memory-content">
<div v-if="loading && !data" class="memory-loading">Loading...</div>
<div v-if="loading && !data" class="memory-loading">{{ t('common.loading') }}</div>
<div v-else class="memory-sections">
<!-- My Notes -->
<div class="memory-section">
@@ -98,7 +100,7 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</span>
<span class="section-title">My Notes</span>
<span class="section-title">{{ t('memory.myNotes') }}</span>
<span v-if="data?.memory_mtime" class="section-mtime">{{ formatTime(data.memory_mtime) }}</span>
</div>
<NButton v-if="editingSection !== 'memory'" size="tiny" quaternary @click="startEdit('memory')">
@@ -108,14 +110,14 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</template>
Edit
{{ t('common.edit') }}
</NButton>
</div>
<!-- View mode -->
<div v-if="editingSection !== 'memory'" class="section-body">
<MarkdownRenderer v-if="!memoryEmpty" :content="displayMemory" />
<p v-else class="empty-text">No notes yet.</p>
<p v-else class="empty-text">{{ t('memory.noNotes') }}</p>
</div>
<!-- Edit mode -->
@@ -123,12 +125,12 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
<textarea
v-model="editContent"
class="edit-textarea"
placeholder="Write your notes..."
:placeholder="t('memory.notesPlaceholder')"
spellcheck="false"
></textarea>
<div class="edit-actions">
<NButton size="small" @click="cancelEdit">Cancel</NButton>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">Save</NButton>
<NButton size="small" @click="cancelEdit">{{ t('common.cancel') }}</NButton>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</NButton>
</div>
</div>
</div>
@@ -143,7 +145,7 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="section-title">User Profile</span>
<span class="section-title">{{ t('memory.userProfile') }}</span>
<span v-if="data?.user_mtime" class="section-mtime">{{ formatTime(data.user_mtime) }}</span>
</div>
<NButton v-if="editingSection !== 'user'" size="tiny" quaternary @click="startEdit('user')">
@@ -153,14 +155,14 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</template>
Edit
{{ t('common.edit') }}
</NButton>
</div>
<!-- View mode -->
<div v-if="editingSection !== 'user'" class="section-body">
<MarkdownRenderer v-if="!userEmpty" :content="displayUser" />
<p v-else class="empty-text">No profile yet.</p>
<p v-else class="empty-text">{{ t('memory.noProfile') }}</p>
</div>
<!-- Edit mode -->
@@ -168,12 +170,12 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
<textarea
v-model="editContent"
class="edit-textarea"
placeholder="Write your profile..."
:placeholder="t('memory.profilePlaceholder')"
spellcheck="false"
></textarea>
<div class="edit-actions">
<NButton size="small" @click="cancelEdit">Cancel</NButton>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">Save</NButton>
<NButton size="small" @click="cancelEdit">{{ t('common.cancel') }}</NButton>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</NButton>
</div>
</div>
</div>
+4 -2
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NButton, NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import ProvidersPanel from '@/components/models/ProvidersPanel.vue'
import ProviderFormModal from '@/components/models/ProviderFormModal.vue'
import { useModelsStore } from '@/stores/models'
const { t } = useI18n()
const modelsStore = useModelsStore()
const showModal = ref(false)
@@ -29,12 +31,12 @@ async function handleSaved() {
<template>
<div class="models-view">
<header class="models-header">
<h2 class="header-title">Models</h2>
<h2 class="header-title">{{ t('models.title') }}</h2>
<NButton type="primary" @click="openCreateModal">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
Add Provider
{{ t('models.addProvider') }}
</NButton>
</header>
+80 -201
View File
@@ -1,128 +1,99 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
NButton, NSwitch, NSlider, NDataTable, useMessage,
} from 'naive-ui'
import { useAppStore } from '@/stores/app'
import { onMounted } from 'vue'
import { NTabs, NTabPane, NSpin, NSwitch, NInput, NInputNumber, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import DisplaySettings from '@/components/settings/DisplaySettings.vue'
import AgentSettings from '@/components/settings/AgentSettings.vue'
import MemorySettings from '@/components/settings/MemorySettings.vue'
import SessionSettings from '@/components/settings/SessionSettings.vue'
import PrivacySettings from '@/components/settings/PrivacySettings.vue'
import SettingRow from '@/components/settings/SettingRow.vue'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
const testingConnection = ref(false)
onMounted(() => {
settingsStore.fetchSettings()
})
async function handleTestConnection() {
testingConnection.value = true
async function saveApiServer(values: Record<string, any>) {
try {
await appStore.checkConnection()
if (appStore.connected) {
message.success('Connected successfully')
} else {
message.error('Connection failed')
}
} catch (e: any) {
message.error(e.message)
} finally {
testingConnection.value = false
await settingsStore.saveSection('platforms', { api_server: values })
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
const providerColumns = [
{ title: 'Provider', key: 'provider' },
{ title: 'Models', key: 'models' },
{ title: 'Base URL', key: 'base_url' },
]
const endpoints = [
{ method: 'GET', endpoint: '/health', description: 'Health Check' },
{ method: 'POST', endpoint: '/v1/runs', description: 'Start Async Run' },
{ method: 'GET', endpoint: '/v1/runs/{id}/events', description: 'SSE Event Stream' },
{ method: 'GET', endpoint: '/api/jobs', description: 'List Jobs' },
{ method: 'POST', endpoint: '/api/jobs', description: 'Create Job' },
{ method: 'POST', endpoint: '/api/jobs/{id}/run', description: 'Trigger Job Now' },
]
</script>
<template>
<div class="settings-view">
<header class="settings-header">
<h2 class="header-title">Settings</h2>
<h2 class="header-title">{{ t('settings.title') }}</h2>
</header>
<div class="settings-content">
<!-- API Configuration -->
<section class="settings-section">
<h3 class="section-title">API Configuration</h3>
<div class="form-group">
<div class="connection-status">
<span class="status-dot" :class="{ on: appStore.connected, off: !appStore.connected }"></span>
<span>{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
<span v-if="appStore.serverVersion" class="version">v{{ appStore.serverVersion }}</span>
</div>
<NButton type="primary" size="small" :loading="testingConnection" @click="handleTestConnection">
Test Connection
</NButton>
</div>
</section>
<!-- Model Management -->
<section class="settings-section">
<h3 class="section-title">Model Management</h3>
<div class="form-group">
<label class="form-label">Current Model</label>
<div class="current-model">{{ appStore.selectedModel || 'Not set' }}</div>
</div>
<div v-if="appStore.modelGroups.length > 0" class="form-group">
<label class="form-label">Available Models</label>
<p class="form-hint">Models are discovered from ~/.hermes/auth.json credential pool. Use the sidebar selector to switch.</p>
<NDataTable
:columns="providerColumns"
:data="appStore.modelGroups.map(g => ({
provider: g.label,
models: g.models.join(', '),
base_url: g.base_url,
}))"
:bordered="false"
size="small"
:row-props="() => ({ style: 'cursor: default;' })"
/>
</div>
</section>
<!-- Chat Settings -->
<section class="settings-section">
<h3 class="section-title">Chat Settings</h3>
<div class="form-group">
<label class="form-label">Stream Responses</label>
<NSwitch v-model:value="appStore.streamEnabled" />
</div>
<div class="form-group">
<label class="form-label">Session Persistence</label>
<NSwitch v-model:value="appStore.sessionPersistence" />
</div>
<div class="form-group">
<label class="form-label">Max Tokens: {{ appStore.maxTokens }}</label>
<NSlider v-model:value="appStore.maxTokens" :min="256" :max="32768" :step="256" />
</div>
</section>
<!-- About -->
<section class="settings-section">
<h3 class="section-title">About</h3>
<p class="about-text">
Hermes Agent Web UI
<br />Version 0.1.3
</p>
<div class="endpoint-table">
<NDataTable
:columns="[{ title: 'Method', key: 'method', width: 80 }, { title: 'Endpoint', key: 'endpoint' }, { title: 'Description', key: 'description' }]"
:data="endpoints"
:bordered="false"
size="small"
:row-props="() => ({ style: 'cursor: default;' })"
/>
</div>
</section>
<NSpin :show="settingsStore.loading">
<NTabs type="line" animated>
<NTabPane name="display" :tab="t('settings.tabs.display')">
<DisplaySettings />
</NTabPane>
<NTabPane name="agent" :tab="t('settings.tabs.agent')">
<AgentSettings />
</NTabPane>
<NTabPane name="memory" :tab="t('settings.tabs.memory')">
<MemorySettings />
</NTabPane>
<NTabPane name="session" :tab="t('settings.tabs.session')">
<SessionSettings />
</NTabPane>
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
<PrivacySettings />
</NTabPane>
<NTabPane name="api_server" :tab="t('settings.tabs.apiServer')">
<section class="settings-section">
<SettingRow :label="t('settings.apiServer.enable')" :hint="t('settings.apiServer.enableHint')">
<NSwitch
:value="settingsStore.platforms?.api_server?.enabled"
@update:value="v => saveApiServer({ enabled: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.apiServer.host')" :hint="t('settings.apiServer.hostHint')">
<NInput
:value="settingsStore.platforms?.api_server?.host || ''"
size="small" style="width: 200px"
@update:value="v => saveApiServer({ host: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.apiServer.port')" :hint="t('settings.apiServer.portHint')">
<NInputNumber
:value="settingsStore.platforms?.api_server?.port"
:min="1024" :max="65535"
size="small" style="width: 120px"
@update:value="v => v != null && saveApiServer({ port: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.apiServer.key')" :hint="t('settings.apiServer.keyHint')">
<NInput
:value="settingsStore.platforms?.api_server?.key || ''"
type="password" show-password-on="click"
size="small" style="width: 200px"
@update:value="v => saveApiServer({ key: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.apiServer.cors')" :hint="t('settings.apiServer.corsHint')">
<NInput
:value="settingsStore.platforms?.api_server?.cors_origins || ''"
size="small" style="width: 200px"
@update:value="v => saveApiServer({ cors_origins: v })"
/>
</SettingRow>
</section>
</NTabPane>
</NTabs>
</NSpin>
</div>
</div>
</template>
@@ -154,97 +125,5 @@ const endpoints = [
flex: 1;
overflow-y: auto;
padding: 20px;
max-width: 640px;
}
.settings-section {
margin-bottom: 28px;
.section-title {
font-size: 13px;
font-weight: 600;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid $border-light;
}
}
.form-group {
margin-bottom: 14px;
.form-label {
display: block;
font-size: 13px;
color: $text-secondary;
margin-bottom: 6px;
}
}
.form-hint {
font-size: 12px;
color: $text-muted;
margin-bottom: 10px;
}
.connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: $text-secondary;
margin-bottom: 10px;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.on {
background-color: $success;
box-shadow: 0 0 6px rgba($success, 0.5);
}
&.off {
background-color: $error;
}
}
.version {
color: $text-muted;
font-size: 12px;
}
}
.current-model {
font-size: 14px;
font-weight: 500;
color: $text-primary;
padding: 6px 10px;
background: $bg-secondary;
border-radius: $radius-sm;
display: inline-block;
}
.empty-text {
font-size: 13px;
color: $text-muted;
font-style: italic;
}
.about-text {
font-size: 13px;
color: $text-secondary;
line-height: 1.6;
margin-bottom: 14px;
}
.endpoint-table {
:deep(.n-data-table) {
--n-td-color: transparent;
--n-th-color: rgba($accent-primary, 0.04);
}
}
</style>
+4 -2
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NInput } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import SkillList from '@/components/skills/SkillList.vue'
import SkillDetail from '@/components/skills/SkillDetail.vue'
import { fetchSkills, type SkillCategory } from '@/api/skills'
const { t } = useI18n()
const categories = ref<SkillCategory[]>([])
const loading = ref(false)
const selectedCategory = ref('')
@@ -33,10 +35,10 @@ function handleSelect(category: string, skill: string) {
<template>
<div class="skills-view">
<header class="skills-header">
<h2 class="header-title">Skills</h2>
<h2 class="header-title">{{ t('skills.title') }}</h2>
<NInput
v-model:value="searchQuery"
placeholder="Search skills..."
:placeholder="t('skills.searchPlaceholder')"
size="small"
clearable
class="search-input"