feat(hermes): align dashboard APIs and add xintian engine

This commit is contained in:
晴天
2026-04-25 10:31:32 +08:00
parent b25808f7f0
commit 3ed59fcb2b
40 changed files with 15246 additions and 1105 deletions

View File

@@ -0,0 +1,206 @@
# Hermes UI 全面重构规划
> 参考:`.tmp/hermes-web-ui`(官方 Vue + Koa 实现)
> 目标ClawPanel Hermes 引擎视觉 + 功能与官方看齐,保留 editorial luxury 主题。
> 作者CascadeAI 助手日期2026-04-24
---
## 🎯 总体目标
1. **功能完备度**对齐官方 `hermes-web-ui`,不再是"初级 UI"。
2. **视觉风格**继续用已做好的 editorial luxury暖黑 + 金色 + Serif 标题)。
3. **架构**:前端 Vanilla JS + CSS scope后端走 ClawPanel Rust 命令(部分已有,部分需新增)。
4. **分阶段交付**,每阶段独立 PR可回滚。
---
## 📊 官方 vs ClawPanel 现状对比
| 页面 | 官方功能 | ClawPanel 现状 | Gap |
|---|---|---|---|
| **Logs** | 文件列表、级别过滤、行数、搜索、**logger 列**、**access log 彩色method/path/status** | 文件列表、级别、行数、搜索 | 缺 logger 列 + access log 解析 + tail + 下载 |
| **Chat** | SSE 流式、工具可视化、**持久化 SessionSQLite**、**会话搜索**、多 profile、token 用量、context length | localStorage 会话、流式、工具卡片 | **架构性差距**:无 DB session、无搜索、无 usage、无 profile |
| **Skills** | 分类列表、详情、**toggle enable**、**category 描述**、**skill files tree** | 只读分类列表 + 详情 | 缺 toggle、files tree、CRUD |
| **Memory** | 三段式memory/user/**soul**)、**mtime 时间戳** | 二段式memory/user | 缺 soul 段、mtime 显示 |
| **Jobs (Cron)** | 完整 Job 字段next_run_at、last_run_at、last_status、last_error、delivery、**origin** 聊天平台溯源、**repeat**、skills 绑定、model/provider 绑定) | 基础 CRUD + 统计 | 缺 next/last run 时间、历史、绑定、delivery |
| **Files** ⭐ | 完整文件管理器(上传/下载/删除/预览) | **页面不存在** | 整个缺失 |
| **Sessions** ⭐ | 独立会话浏览器(列表/搜索/重命名/删除/usage | 无 | 整个缺失 |
| **Gateways** ⭐ | 多 Gateway 切换 | 单 Gateway已有基础 | 多 gateway 管理 UI 缺 |
| **Models** | 模型库浏览 + 用量 | 只有仪表盘的模型配置 | 独立 Models 页缺 |
| **Profiles** ⭐ | Profile 管理(不同 config 切换) | 无 | 整个缺失 |
| **Usage** ⭐ | token 用量统计、成本分析 | 无 | 整个缺失 |
| **Terminal** ⭐ | 内置终端 | 无 | 整个缺失 |
| **Channels** | 消息渠道(飞书/Telegram 等) | 占位 "coming soon" | 整个缺失 |
⭐ = 官方独有的全新页面
---
## 🛠️ 工作量评估
### 前端Vanilla JS + hermes.css 组件)
| 模块 | 代码量 | 复杂度 |
|---|---|---|
| Logs 重写 | ~300 行 | 中(含 access log 解析) |
| Chat 重写 | ~700 行 | 高SSE + session DB + usage |
| Skills 重写 | ~400 行 | 中(加 toggle + files |
| Memory 重写 | ~250 行 | 低 |
| Cron 重写 | ~500 行 | 中高(字段丰富) |
| Files 新增 | ~400 行 | 中 |
| Sessions 新增 | ~450 行 | 中高 |
| Usage 新增 | ~200 行 | 低 |
| Profiles 新增 | ~250 行 | 中 |
| Models 新增 | ~300 行 | 中 |
| **合计** | **~3750 行** | — |
### 后端Rust 新增命令)
| 命令 | 数据源 | 估计 |
|---|---|---|
| `hermes_sessions_list/get/delete/rename` | `~/.hermes/sessions.db` SQLite | 中4 个命令) |
| `hermes_session_usage` | `usage` 表 | 低2 个) |
| `hermes_context_length` | 模型元数据 | 低 |
| `hermes_skill_toggle/create/delete` | `~/.hermes/skills/*/` FS | 中3 个) |
| `hermes_skill_files` | 遍历 skill 目录 | 低 |
| `hermes_memory_soul` | `~/.hermes/memories/SOUL.md` | 低 |
| `hermes_logs_tail` | SSE 流 `~/.hermes/logs/*.log` | **高**(流式) |
| `hermes_logs_download` | 文件下载 | 低 |
| `hermes_files_list/read/delete/upload` | `~/.hermes/` 任意文件 | 中4 个) |
| `hermes_profiles_list/switch` | `~/.hermes/profiles.json` | 低 |
| `hermes_job_history` | Gateway `/api/jobs/:id/runs` | 低 |
| **合计** | | **~18 个命令** |
### 样式hermes.css 扩展)
| 组件 | 估计 |
|---|---|
| Logs 页access log/logger 徽章) | ~60 行 |
| Chat 页session browser/usage bar | ~100 行 |
| Skills 页files tree/toggle | ~80 行 |
| Memory 页(三段布局) | ~40 行 |
| Files 页(新) | ~120 行 |
| Sessions 页(新) | ~100 行 |
| Usage / Profiles / Models | ~150 行 |
| **合计** | **~650 行 CSS** |
---
## 🗓️ 分阶段交付(建议 6 个独立 PR
### Phase 1 — **Logs + Memory 重写**(低风险起步)
- 后端新增:`hermes_logs_tail`SSE`hermes_logs_download``hermes_memory_read/write` 扩展支持 soul
- 前端重写logs.jsaccess log 解析/logger 列/tail toggle/下载/清空显示)
- 前端重写memory.js三段式memory/user/soul加 mtime、字数
- **工作量**~600 行前端 + ~250 行 Rust + ~100 行 CSS
- **风险**:低(功能相对独立)
- **PR 大小**:中
### Phase 2 — **Cron (Jobs) 完整字段**
- 后端:`hermes_job_history`(走 Gateway REST
- 前端cron.js 重写,含 next_run_at / last_run_at / last_status / 执行历史抽屉 / delivery 字段 / skills 绑定
- **工作量**~500 行前端 + ~80 行 Rust + ~80 行 CSS
- **风险**:中(需要验证 Gateway REST 支持所有字段)
### Phase 3 — **Skills CRUD**
- 后端:`hermes_skill_toggle/create/delete``hermes_skill_files`
- 前端skills.js 重写,加 toggle / 新建 modal / 编辑 / 删除 / 文件树
- **工作量**~400 行前端 + ~300 行 Rust + ~80 行 CSS
- **风险**FS 操作需权限/错误处理严谨)
### Phase 4 — **Chat 架构重构**(重头戏)
- 后端:`hermes_sessions_*`(读 Hermes 的 SQLite`hermes_session_usage``hermes_context_length`
- 前端chat.js 完全重写
- 从 localStorage 迁移到后端 session API
- 增加停止按钮、消息复制、代码块语法高亮(引入 highlight.js 或自研)
- Token 用量实时显示
- context length bar
- 搜索历史会话
- **工作量**~700 行前端 + ~400 行 Rust + ~150 行 CSS
- **风险****高**架构迁移、localStorage 数据要兼容迁移)
### Phase 5 — **新页面Sessions + Usage**
- Sessions 独立页面(浏览所有历史、搜索、重命名、删除)
- Usage 页面token 统计、成本)
- 侧栏导航项新增
- **工作量**~650 行前端 + ~100 行 Rust + ~150 行 CSS
### Phase 6 — **Files + Profiles**(可选,根据需求)
- Files 浏览器(上传下载)
- Profiles 切换(多 config
- **工作量**~650 行前端 + ~250 行 Rust + ~170 行 CSS
---
## ⚠️ 关键技术决策
### 1. 后端协议SQLite 直读 or Gateway REST
Hermes Gateway **自身不暴露 session REST API**。必须**直接读 SQLite `~/.hermes/sessions.db`**Hermes 进程也用这个文件,要注意并发)。
**方案**Rust 端用 `rusqlite` crate + `WAL mode` 读。写操作rename/delete必须等 Gateway 停机或通过 Hermes CLI。
### 2. SSE 在 Tauri 中的实现
Tauri WebView 支持 EventSource但 Windows 上某些版本的 WebView2 可能有 buffer 问题。**已知方案**Rust 端用 `tauri::Event` 推事件,前端 listen 就好。logs tail、chat run 已在用这个模式。
### 3. SQLite 迁移 localStorage chat sessions
chat.js 现有 localStorage 数据要迁移。**方案**:首次进 chat 页检测 localStorage 有旧 session询问用户是否导入到 Hermes DBor 保留只读访问。
### 4. Session DB 的 schema 兼容性
Hermes 的 SQLite schema 可能版本间变化。参考 `.tmp/hermes-web-ui/packages/server/src/db/hermes/sessions-db.ts` 的 query 写法,保持一致。遇到 schema 差异用 `PRAGMA user_version` 检测。
### 5. Files 页面的权限边界
只允许访问 `~/.hermes/` 目录,**拒绝绝对路径**`../` 遍历要 reject。
### 6. Profiles 的切换语义
切换 profile = 切换 `~/.hermes/config.yaml` + `.env` + 重启 Gateway。每个 profile 是一个完整配置目录。UI 要明示切换后需要重启 Gateway。
---
## 📋 验收标准(每阶段)
- [ ] 新功能单元测试Rust 端 `cargo test`
- [ ] 前端 lint 通过 `npm run build`
- [ ] 手动测试Tauri 桌面端 + Web 模式两端都跑一遍
- [ ] 与官方 hermes-web-ui 视觉对比(截图),功能缺失 ≤ 10%
- [ ] 不影响 OpenClaw 引擎scope 隔离验证)
- [ ] 性能1000 条会话列表加载 < 500ms
---
## ⏱️ 估算总时长
| 阶段 | 预估时间 |
|---|---|
| Phase 1 | **1 天** |
| Phase 2 | **1 天** |
| Phase 3 | **1.5 天** |
| Phase 4 | **2.5 天** |
| Phase 5 | **1.5 天** |
| Phase 6 | **1.5 天** |
| **合计** | **~9 天** |
AI 辅助开发可能压缩到 5-6 个工作日。
---
## 🚀 建议执行顺序
1. **先合并当前 `feat/hermes-v0.14.1-providers` PR**dashboard + sidebar 新样式 + env-editor + skeleton scope
2. **再开 Phase 1 分支** `feat/hermes-logs-memory-refactor`,按此规划交付
3. Phase 2-6 每个都独立分支 + PR按优先级排
这样用户可以:
- 每次只 review 一个 PR
- 任一阶段失败不会阻塞前面的工作
- 可以随时暂停,保留已完成的阶段成果
---
## 📎 参考实现
- 官方 Vue UI`.tmp/hermes-web-ui/packages/client/src/views/hermes/*.vue`
- 官方 Server`.tmp/hermes-web-ui/packages/server/src/services/hermes/*.ts`
- 官方 DB 层:`.tmp/hermes-web-ui/packages/server/src/db/hermes/*.ts`
- ClawPanel 现有:`src/engines/hermes/pages/*.js``src-tauri/src/commands/hermes.rs`
- 设计系统:`design-system/clawpanel/MASTER.md``src/engines/hermes/style/hermes.css`

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -25,6 +25,16 @@ function hermesHome() {
return process.env.HERMES_HOME || HERMES_HOME
}
/** Resolve memory kind (memory|user|soul) → markdown file name. */
function memoryFileName(kind) {
switch (kind) {
case 'memory': return 'MEMORY.md'
case 'user': return 'USER.md'
case 'soul': return 'SOUL.md'
default: return null
}
}
function uvBinDir() {
if (isWindows) {
const appdata = process.env.APPDATA
@@ -6996,7 +7006,7 @@ const handlers = {
const envPath = path.join(hermesHome(), '.env')
if (!fs.existsSync(envPath)) return []
const raw = fs.readFileSync(envPath, 'utf8')
const managed = new Set(this._hermesManagedEnvKeys())
const managed = new Set(handlers._hermesManagedEnvKeys())
const seen = new Set()
const out = []
for (const line of raw.split('\n')) {
@@ -7018,7 +7028,7 @@ const handlers = {
if (!/^[A-Z0-9_]+$/i.test(key)) {
throw new Error(`Invalid env var key '${key}': only [A-Z0-9_] are allowed`)
}
const managed = new Set(this._hermesManagedEnvKeys())
const managed = new Set(handlers._hermesManagedEnvKeys())
if (managed.has(key)) {
throw new Error(`'${key}' is managed by ClawPanel; please configure it via the provider setup page`)
}
@@ -7049,7 +7059,7 @@ const handlers = {
hermes_env_delete({ key } = {}) {
key = (key || '').trim()
if (!key) throw new Error('Key cannot be empty')
const managed = new Set(this._hermesManagedEnvKeys())
const managed = new Set(handlers._hermesManagedEnvKeys())
if (managed.has(key)) {
throw new Error(`'${key}' is managed by ClawPanel; please configure it via the provider setup page`)
}
@@ -7071,6 +7081,121 @@ const handlers = {
return null
},
hermes_env_reveal({ key } = {}) {
key = (key || '').trim()
if (!key) throw new Error('Key cannot be empty')
const envPath = path.join(hermesHome(), '.env')
if (!fs.existsSync(envPath)) throw new Error('.env not found')
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
const eq = t.indexOf('=')
if (eq > 0 && t.slice(0, eq).trim() === key) return { key, value: t.slice(eq + 1) }
}
throw new Error(`${key} not found in .env`)
},
hermes_config_raw_read() {
const configPath = path.join(hermesHome(), 'config.yaml')
return { yaml: fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '' }
},
hermes_config_raw_write({ yamlText } = {}) {
const configPath = path.join(hermesHome(), 'config.yaml')
fs.mkdirSync(path.dirname(configPath), { recursive: true })
if (fs.existsSync(configPath)) fs.copyFileSync(configPath, `${configPath}.bak-${Math.floor(Date.now() / 1000)}`)
fs.writeFileSync(configPath, yamlText || '')
return { ok: true }
},
hermes_dashboard_themes() {
const configPath = path.join(hermesHome(), 'config.yaml')
const raw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''
const active = (raw.match(/^\s*theme:\s*["']?([^"'\n#]+)["']?/m)?.[1] || 'default').trim()
const themes = [
{ name: 'default', label: 'Default', description: 'Hermes default dashboard theme' },
{ name: 'midnight', label: 'Midnight', description: 'Dark blue dashboard theme' },
{ name: 'ember', label: 'Ember', description: 'Warm dashboard theme' },
{ name: 'mono', label: 'Mono', description: 'Monochrome dashboard theme' },
{ name: 'cyberpunk', label: 'Cyberpunk', description: 'Neon dashboard theme' },
{ name: 'rose', label: 'Rose', description: 'Soft rose dashboard theme' },
]
const dir = path.join(hermesHome(), 'dashboard-themes')
if (fs.existsSync(dir)) {
for (const file of fs.readdirSync(dir)) {
if (!/\.ya?ml$/i.test(file)) continue
const name = path.basename(file).replace(/\.ya?ml$/i, '')
if (!themes.some(t => t.name === name)) themes.push({ name, label: name, description: 'User dashboard theme' })
}
}
return { themes, active }
},
hermes_dashboard_theme_set({ name } = {}) {
name = (name || '').trim()
if (!name) throw new Error('Theme name cannot be empty')
const configPath = path.join(hermesHome(), 'config.yaml')
fs.mkdirSync(path.dirname(configPath), { recursive: true })
const raw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''
let content
if (/^dashboard:\s*$/m.test(raw)) {
content = /^\s+theme:/m.test(raw)
? raw.replace(/^(\s+)theme:.*$/m, `$1theme: ${name}`)
: raw.replace(/^dashboard:\s*$/m, `dashboard:\n theme: ${name}`)
} else {
content = `${raw.replace(/\s*$/, '')}\n\ndashboard:\n theme: ${name}\n`
}
fs.writeFileSync(configPath, content)
return { ok: true, theme: name }
},
hermes_dashboard_plugins() {
const root = path.join(hermesHome(), 'plugins')
if (!fs.existsSync(root)) return []
const out = []
const seen = new Set()
for (const name of fs.readdirSync(root)) {
const dir = path.join(root, name)
const manifest = path.join(dir, 'dashboard', 'manifest.json')
if (!fs.existsSync(manifest)) continue
try {
const data = JSON.parse(fs.readFileSync(manifest, 'utf8'))
const id = data.name || name
if (!id || seen.has(id)) continue
seen.add(id)
out.push({
name: id,
label: data.label || id,
description: data.description || '',
icon: data.icon || 'Puzzle',
version: data.version || '0.0.0',
tab: data.tab || { path: `/${id}`, position: 'end' },
slots: data.slots || [],
entry: data.entry || 'dist/index.js',
css: data.css || null,
has_api: !!data.api,
source: 'user',
})
} catch {}
}
return out
},
hermes_dashboard_plugins_rescan() {
return { ok: true, count: handlers.hermes_dashboard_plugins().length }
},
hermes_toolsets_list() {
const r = runHermesSilent('hermes', ['tools', 'list', '--platform', 'cli'])
return { raw: r.ok ? r.stdout : '' }
},
hermes_cron_jobs_list() {
const jobsPath = path.join(hermesHome(), 'cron', 'jobs.json')
if (!fs.existsSync(jobsPath)) return []
return JSON.parse(fs.readFileSync(jobsPath, 'utf8'))
},
async hermes_fetch_models({ baseUrl, apiKey, apiType, provider: _provider } = {}) {
const api = apiType || 'openai'
let base = baseUrl.replace(/\/+$/, '')
@@ -7196,8 +7321,10 @@ const handlers = {
// Hermes Sessions / Logs / Skills / Memory
// =========================================================================
hermes_sessions_list({ source, limit } = {}) {
const args = ['sessions', 'export', '-']
hermes_sessions_list({ source, limit, profile } = {}) {
const args = []
if (profile) args.push('--profile', profile)
args.push('sessions', 'export', '-')
if (source) args.push('--source', source)
const r = runHermesSilent('hermes', args)
if (!r.ok) return []
@@ -7207,6 +7334,14 @@ const handlers = {
if (!t) continue
try {
const obj = JSON.parse(t)
// `started_at` may arrive as POSIX seconds from the Hermes CLI. Fall
// back to parsing `created_at` as ISO8601 so the Usage view can group
// sessions by day even on older Hermes builds.
let startedAt = typeof obj.started_at === 'number' ? obj.started_at : 0
if (!startedAt && obj.created_at) {
const ms = Date.parse(obj.created_at)
if (!Number.isNaN(ms)) startedAt = Math.floor(ms / 1000)
}
sessions.push({
id: obj.session_id || obj.id || '',
title: obj.title || obj.name || '',
@@ -7215,6 +7350,14 @@ const handlers = {
created_at: obj.created_at || obj.createdAt || '',
updated_at: obj.updated_at || obj.updatedAt || '',
message_count: obj.message_count || (obj.messages ? obj.messages.length : 0),
// Usage analytics fields (match Rust backend shape).
started_at: startedAt,
input_tokens: Number(obj.input_tokens || 0),
output_tokens: Number(obj.output_tokens || 0),
cache_read_tokens: Number(obj.cache_read_tokens || 0),
cache_write_tokens: Number(obj.cache_write_tokens || 0),
estimated_cost_usd: typeof obj.estimated_cost_usd === 'number' ? obj.estimated_cost_usd : null,
actual_cost_usd: typeof obj.actual_cost_usd === 'number' ? obj.actual_cost_usd : null,
})
} catch {}
}
@@ -7223,9 +7366,127 @@ const handlers = {
return sessions
},
hermes_session_detail({ sessionId } = {}) {
hermes_sessions_summary_list({ source, limit, profile } = {}) {
const lim = Math.max(1, Math.min(Number(limit || 80), 500))
const args = []
if (profile) args.push('--profile', profile)
args.push('sessions', 'list', '--limit', String(lim))
if (source) args.push('--source', source)
const r = runHermesSilent('hermes', args)
if (!r.ok) return []
const sessions = []
let hasTitles = false
for (const line of r.stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed === 'No sessions found.' || trimmed.startsWith('─')) continue
if (trimmed.includes('Title') && trimmed.includes('Preview') && trimmed.includes('ID')) { hasTitles = true; continue }
if (trimmed.includes('Preview') && trimmed.includes('Last Active') && trimmed.includes('ID')) { hasTitles = false; continue }
const cols = trimmed.split(/\s{2,}/).filter(Boolean)
if (cols.length < 3) continue
const id = cols[cols.length - 1]
if (!id) continue
if (hasTitles) {
sessions.push({
id,
title: cols[0] === '—' ? '' : cols[0],
source: source || '',
model: '',
created_at: '',
updated_at: '',
last_active_label: cols[2] || '',
preview: cols[1] || '',
message_count: 0,
input_tokens: 0,
output_tokens: 0,
})
} else {
sessions.push({
id,
title: '',
source: cols[2] || source || '',
model: '',
created_at: '',
updated_at: '',
last_active_label: cols[1] || '',
preview: cols[0] || '',
message_count: 0,
input_tokens: 0,
output_tokens: 0,
})
}
}
return sessions
},
async hermes_usage_analytics({ days = 30, profile } = {}) {
days = Math.max(1, Math.min(Number(days || 30), 365))
const cutoff = Math.floor(Date.now() / 1000) - days * 86400
const sessions = await handlers.hermes_sessions_list({ profile })
const daily = new Map()
const byModel = new Map()
const totals = {
total_input: 0,
total_output: 0,
total_cache_read: 0,
total_cache_write: 0,
total_estimated_cost: 0,
total_actual_cost: 0,
total_sessions: 0,
total_api_calls: 0,
}
for (const s of Array.isArray(sessions) ? sessions : []) {
const started = Number(s.started_at || 0)
if (started > 0 && started < cutoff) continue
const input = Number(s.input_tokens || 0)
const output = Number(s.output_tokens || 0)
const cacheRead = Number(s.cache_read_tokens || 0)
const cacheWrite = Number(s.cache_write_tokens || 0)
const estimated = Number(s.estimated_cost_usd || 0)
const actual = Number(s.actual_cost_usd || 0)
totals.total_input += input
totals.total_output += output
totals.total_cache_read += cacheRead
totals.total_cache_write += cacheWrite
totals.total_estimated_cost += estimated
totals.total_actual_cost += actual
totals.total_sessions += 1
const day = started > 0 ? new Date(started * 1000).toISOString().slice(0, 10) : 'unknown'
if (!daily.has(day)) daily.set(day, { day, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, estimated_cost: 0, actual_cost: 0, sessions: 0 })
const d = daily.get(day)
d.input_tokens += input
d.output_tokens += output
d.cache_read_tokens += cacheRead
d.estimated_cost += estimated
d.actual_cost += actual
d.sessions += 1
const model = s.model || ''
if (model) {
if (!byModel.has(model)) byModel.set(model, { model, input_tokens: 0, output_tokens: 0, estimated_cost: 0, sessions: 0 })
const m = byModel.get(model)
m.input_tokens += input
m.output_tokens += output
m.estimated_cost += estimated
m.sessions += 1
}
}
return {
daily: [...daily.values()],
by_model: [...byModel.values()].sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
totals,
period_days: days,
skills: {
summary: { total_skill_loads: 0, total_skill_edits: 0, total_skill_actions: 0, distinct_skills_used: 0 },
top_skills: [],
},
}
},
hermes_session_detail({ sessionId, profile } = {}) {
if (!sessionId) throw new Error('sessionId is required')
const r = runHermesSilent('hermes', ['sessions', 'export', '-'])
const args = []
if (profile) args.push('--profile', profile)
args.push('sessions', 'export', '-', '--session-id', sessionId)
const r = runHermesSilent('hermes', args)
if (!r.ok) throw new Error('Failed to read sessions')
for (const line of r.stdout.split('\n')) {
const t = line.trim()
@@ -7251,20 +7512,67 @@ const handlers = {
throw new Error('Session not found')
},
hermes_session_delete({ sessionId } = {}) {
hermes_session_delete({ sessionId, profile } = {}) {
if (!sessionId) throw new Error('sessionId is required')
const r = runHermesSilent('hermes', ['sessions', 'delete', sessionId, '--yes'])
const args = []
if (profile) args.push('--profile', profile)
args.push('sessions', 'delete', sessionId, '--yes')
const r = runHermesSilent('hermes', args)
if (!r.ok) throw new Error(`Failed to delete session: ${r.stderr || 'unknown error'}`)
return 'ok'
},
hermes_session_rename({ sessionId, title } = {}) {
hermes_session_rename({ sessionId, title, profile } = {}) {
if (!sessionId || !title) throw new Error('sessionId and title are required')
const r = runHermesSilent('hermes', ['sessions', 'rename', sessionId, title])
const args = []
if (profile) args.push('--profile', profile)
args.push('sessions', 'rename', sessionId, title)
const r = runHermesSilent('hermes', args)
if (!r.ok) throw new Error(`Failed to rename session: ${r.stderr || 'unknown error'}`)
return 'ok'
},
hermes_profiles_list() {
const r = runHermesSilent('hermes', ['profile', 'list'])
if (!r.ok) return { active: 'default', profiles: [] }
let active = 'default'
const profiles = []
for (const line of r.stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.includes('Profile') || trimmed.startsWith('─') || trimmed.startsWith('-')) continue
const isActive = trimmed.startsWith('◆')
const row = trimmed.replace(/^◆/, '').trim()
const parts = row.split(/\s+/)
if (parts.length < 3) continue
const name = parts[0]
if (name !== 'default' && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) continue
const gatewayIdx = parts.findIndex(p => p === 'running' || p === 'stopped')
if (gatewayIdx <= 1) continue
const model = parts.slice(1, gatewayIdx).join(' ')
const alias = parts[gatewayIdx + 1] || ''
if (isActive) active = name
profiles.push({
name,
active: isActive,
model: model === '—' ? '' : model,
gatewayRunning: parts[gatewayIdx] === 'running',
alias: alias === '—' ? '' : alias,
})
}
if (!profiles.some(p => p.active)) {
const d = profiles.find(p => p.name === 'default')
if (d) d.active = true
}
return { active, profiles }
},
hermes_profile_use({ name } = {}) {
if (!name) throw new Error('name is required')
const r = runHermesSilent('hermes', ['profile', 'use', name])
if (!r.ok) throw new Error(`Failed to switch profile: ${r.stderr || 'unknown error'}`)
return 'ok'
},
hermes_logs_list() {
const r = runHermesSilent('hermes', ['logs', 'list'])
if (!r.ok) {
@@ -7318,47 +7626,99 @@ const handlers = {
hermes_skills_list() {
const skillsDir = path.join(hermesHome(), 'skills')
if (!fs.existsSync(skillsDir)) return []
const disabled = readHermesDisabledSkills()
const isEnabled = (name) => !disabled.includes(name)
const categories = []
try {
const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
if (entry.isDirectory()) {
const catDir = path.join(skillsDir, entry.name)
// Category description from DESCRIPTION.md if present
let catDesc = ''
try {
const dmPath = path.join(catDir, 'DESCRIPTION.md')
if (fs.existsSync(dmPath)) {
const raw = fs.readFileSync(dmPath, 'utf8')
const heading = raw.match(/^#\s+(.+)/m)
catDesc = (heading ? heading[1] : raw.trim().split('\n')[0] || '').trim().slice(0, 200)
}
} catch {}
const skills = []
for (const file of fs.readdirSync(catDir)) {
if (!file.endsWith('.md')) continue
const filePath = path.join(catDir, file)
for (const sub of fs.readdirSync(catDir, { withFileTypes: true })) {
if (sub.name === 'DESCRIPTION.md') continue
// v0.14.1 structured skill: SKILL.md inside a directory
if (sub.isDirectory()) {
const skillMd = path.join(catDir, sub.name, 'SKILL.md')
if (!fs.existsSync(skillMd)) continue
const content = fs.readFileSync(skillMd, 'utf8')
const nameMatch = content.match(/^#\s+(.+)/m)
const descMatch = content.match(/^[^#\n].{10,}/m)
skills.push({
file: sub.name,
name: nameMatch ? nameMatch[1].trim() : sub.name,
slug: sub.name,
description: descMatch ? descMatch[0].trim().slice(0, 200) : '',
path: skillMd,
skill_dir: path.join(catDir, sub.name),
isDir: true,
enabled: isEnabled(sub.name),
})
continue
}
if (!sub.name.endsWith('.md')) continue
const filePath = path.join(catDir, sub.name)
const content = fs.readFileSync(filePath, 'utf8')
const nameMatch = content.match(/^#\s+(.+)/m)
const descMatch = content.match(/^(?:##\s+)?(?:Description|描述)[:\s]*(.+)/mi) || content.match(/^[^#\n].{10,}/m)
const descMatch = content.match(/^[^#\n].{10,}/m)
const slug = sub.name.replace(/\.md$/, '')
skills.push({
file: file,
name: nameMatch ? nameMatch[1].trim() : file.replace('.md', ''),
description: descMatch ? descMatch[1].trim().slice(0, 200) : '',
file: sub.name,
name: nameMatch ? nameMatch[1].trim() : slug,
slug,
description: descMatch ? descMatch[0].trim().slice(0, 200) : '',
path: filePath,
isDir: false,
enabled: isEnabled(slug),
})
}
if (skills.length > 0) {
categories.push({ category: entry.name, skills })
skills.sort((a, b) => a.name.localeCompare(b.name))
categories.push({ category: entry.name, description: catDesc, skills })
}
} else if (entry.name.endsWith('.md')) {
// Top-level skill
} else if (entry.name.endsWith('.md') && entry.name !== 'DESCRIPTION.md') {
const filePath = path.join(skillsDir, entry.name)
const content = fs.readFileSync(filePath, 'utf8')
const nameMatch = content.match(/^#\s+(.+)/m)
const slug = entry.name.replace(/\.md$/, '')
categories.push({
category: '_root',
skills: [{ file: entry.name, name: nameMatch ? nameMatch[1].trim() : entry.name.replace('.md', ''), description: '', path: filePath }]
description: '',
skills: [{
file: entry.name,
name: nameMatch ? nameMatch[1].trim() : slug,
slug,
description: '',
path: filePath,
isDir: false,
enabled: isEnabled(slug),
}],
})
}
}
} catch {}
categories.sort((a, b) => a.category.localeCompare(b.category))
return categories
},
hermes_skill_detail({ filePath } = {}) {
if (!filePath) throw new Error('filePath is required')
// Security: ensure path is within hermes skills dir
const skillsDir = path.join(hermesHome(), 'skills')
const resolved = path.resolve(filePath)
if (!resolved.startsWith(skillsDir)) throw new Error('Access denied')
@@ -7366,9 +7726,57 @@ const handlers = {
return fs.readFileSync(resolved, 'utf8')
},
hermes_skill_toggle({ name, enabled } = {}) {
if (!name) throw new Error('Skill name is required')
const configPath = path.join(hermesHome(), 'config.yaml')
if (!fs.existsSync(configPath)) throw new Error('config.yaml not found')
const raw = fs.readFileSync(configPath, 'utf8')
// Backup
const backup = path.join(hermesHome(), `config.yaml.bak-${Math.floor(Date.now() / 1000)}`)
try { fs.writeFileSync(backup, raw) } catch {}
const patched = patchHermesYamlToggleSkill(raw, name, !!enabled)
fs.writeFileSync(configPath, patched)
return { ok: true, skill: name, enabled: !!enabled, backup }
},
hermes_skill_files({ category, skill } = {}) {
if (!category || !skill) throw new Error('category and skill are required')
const skillDir = path.join(hermesHome(), 'skills', category, skill)
if (!fs.existsSync(skillDir) || !fs.statSync(skillDir).isDirectory()) return []
const out = []
const walk = (root, relBase) => {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (relBase === '' && entry.name === 'SKILL.md') continue
const rel = relBase ? `${relBase}/${entry.name}` : entry.name
const full = path.join(root, entry.name)
const isDir = entry.isDirectory()
out.push({ path: rel, name: entry.name, isDir })
if (isDir) walk(full, rel)
}
}
walk(skillDir, '')
out.sort((a, b) => a.path.localeCompare(b.path))
return out
},
hermes_skill_write({ filePath, content } = {}) {
if (!filePath) throw new Error('filePath is required')
if (content == null) throw new Error('content is required')
const skillsDir = path.join(hermesHome(), 'skills')
const targetAbs = path.isAbsolute(filePath) ? filePath : path.join(skillsDir, filePath)
const parent = path.dirname(targetAbs)
fs.mkdirSync(parent, { recursive: true })
const parentReal = fs.realpathSync(parent)
const skillsReal = fs.realpathSync(skillsDir)
if (!parentReal.startsWith(skillsReal)) throw new Error('Access denied')
fs.writeFileSync(targetAbs, content, 'utf8')
return 'ok'
},
hermes_memory_read({ type = 'memory' } = {}) {
const home = hermesHome()
const fileName = type === 'user' ? 'USER.md' : 'MEMORY.md'
const fileName = memoryFileName(type)
if (!fileName) throw new Error(`Invalid memory kind '${type}' (expected memory|user|soul)`)
const filePath = path.join(home, 'memories', fileName)
if (!fs.existsSync(filePath)) return ''
return fs.readFileSync(filePath, 'utf8')
@@ -7377,14 +7785,54 @@ const handlers = {
hermes_memory_write({ type = 'memory', content } = {}) {
if (content == null) throw new Error('content is required')
const home = hermesHome()
const fileName = memoryFileName(type)
if (!fileName) throw new Error(`Invalid memory kind '${type}' (expected memory|user|soul)`)
const memDir = path.join(home, 'memories')
fs.mkdirSync(memDir, { recursive: true })
const fileName = type === 'user' ? 'USER.md' : 'MEMORY.md'
const filePath = path.join(memDir, fileName)
fs.writeFileSync(filePath, content, 'utf8')
return 'ok'
},
hermes_memory_read_all() {
const home = hermesHome()
const memDir = path.join(home, 'memories')
const readSection = (kind) => {
const name = memoryFileName(kind)
if (!name) return ['', null]
const p = path.join(memDir, name)
if (!fs.existsSync(p)) return ['', null]
const content = fs.readFileSync(p, 'utf8')
const mtime = Math.floor(fs.statSync(p).mtimeMs / 1000)
return [content, mtime]
}
const [memory, memory_mtime] = readSection('memory')
const [user, user_mtime] = readSection('user')
const [soul, soul_mtime] = readSection('soul')
return { memory, user, soul, memory_mtime, user_mtime, soul_mtime }
},
hermes_logs_download({ name, saveToDisk = false } = {}) {
if (!name) throw new Error('log file name is required')
// Reject traversal (mirror the Rust-side check)
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid log file name')
}
const logsDir = path.join(hermesHome(), 'logs')
const filePath = path.join(logsDir, name)
const resolved = fs.realpathSync(filePath)
const canonDir = fs.realpathSync(logsDir)
if (!resolved.startsWith(canonDir)) throw new Error('Access denied')
const content = fs.readFileSync(resolved, 'utf8')
if (!saveToDisk) return content
const outDir = path.join(os.homedir(), 'Downloads', 'ClawPanel')
fs.mkdirSync(outDir, { recursive: true })
const safeName = name.replace(/[\\/:*?"<>|]/g, '_')
const outPath = path.join(outDir, safeName)
fs.writeFileSync(outPath, content)
return { path: outPath }
},
async update_hermes() {
const uvPath = path.join(uvBinDir(), isWindows ? 'uv.exe' : 'uv')
const uv = fs.existsSync(uvPath) ? uvPath : 'uv'

View File

@@ -4,6 +4,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-set-theme",
"shell:allow-open",
"autostart:allow-enable",
"autostart:allow-disable",

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"ClawPanel 默认权限","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]}}
{"default":{"identifier":"default","description":"ClawPanel 默认权限","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-set-theme","shell:allow-open","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]}}

File diff suppressed because it is too large Load Diff

View File

@@ -234,19 +234,37 @@ pub fn run() {
hermes::hermes_env_read_unmanaged,
hermes::hermes_env_set,
hermes::hermes_env_delete,
hermes::hermes_env_reveal,
hermes::hermes_config_raw_read,
hermes::hermes_config_raw_write,
hermes::hermes_set_gateway_url,
hermes::update_hermes,
hermes::uninstall_hermes,
hermes::hermes_sessions_list,
hermes::hermes_sessions_summary_list,
hermes::hermes_usage_analytics,
hermes::hermes_session_detail,
hermes::hermes_session_delete,
hermes::hermes_session_rename,
hermes::hermes_profiles_list,
hermes::hermes_profile_use,
hermes::hermes_logs_list,
hermes::hermes_logs_read,
hermes::hermes_skills_list,
hermes::hermes_skill_detail,
hermes::hermes_skill_toggle,
hermes::hermes_skill_files,
hermes::hermes_skill_write,
hermes::hermes_memory_read,
hermes::hermes_memory_write,
hermes::hermes_memory_read_all,
hermes::hermes_logs_download,
hermes::hermes_dashboard_themes,
hermes::hermes_dashboard_theme_set,
hermes::hermes_dashboard_plugins,
hermes::hermes_dashboard_plugins_rescan,
hermes::hermes_toolsets_list,
hermes::hermes_cron_jobs_list,
])
.on_window_event(|window, event| {
// 关闭窗口时最小化到托盘,不退出应用

View File

@@ -88,7 +88,9 @@ const ICONS = {
agents: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>',
gateway: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>',
memory: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
inbox: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>',
extensions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
package: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',

View File

@@ -76,7 +76,9 @@ export default {
items: [
{ route: '/h/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
{ route: '/h/chat', label: t('sidebar.chat'), icon: 'chat' },
{ route: '/h/sessions', label: t('sidebar.sessions'), icon: 'inbox' },
{ route: '/h/logs', label: t('sidebar.logs'), icon: 'logs' },
{ route: '/h/usage', label: t('sidebar.usage'), icon: 'bar-chart' },
]
}, {
section: t('sidebar.sectionManage'),
@@ -84,6 +86,7 @@ export default {
{ route: '/h/skills', label: t('sidebar.skills'), icon: 'skills' },
{ route: '/h/memory', label: t('sidebar.memory'), icon: 'memory' },
{ route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' },
{ route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' },
]
}, {
section: '',
@@ -101,10 +104,13 @@ export default {
{ path: '/h/setup', loader: () => import('./pages/setup.js') },
{ path: '/h/dashboard', loader: () => import('./pages/dashboard.js') },
{ path: '/h/chat', loader: () => import('./pages/chat.js') },
{ path: '/h/sessions', loader: () => import('./pages/sessions.js') },
{ path: '/h/logs', loader: () => import('./pages/logs.js') },
{ path: '/h/usage', loader: () => import('./pages/usage.js') },
{ path: '/h/skills', loader: () => import('./pages/skills.js') },
{ path: '/h/memory', loader: () => import('./pages/memory.js') },
{ path: '/h/cron', loader: () => import('./pages/cron.js') },
{ path: '/h/extensions', loader: () => import('./pages/extensions.js') },
{ path: '/h/services', loader: () => import('./pages/services.js') },
{ path: '/h/config', loader: () => import('./pages/config.js') },
{ path: '/h/channels', loader: () => import('./pages/channels.js') },

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import { t } from '../../../lib/i18n.js'
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
el.innerHTML = `
<div class="page-header"><h1>${t('engine.hermesChannelsTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,91 @@
* Hermes Agent 配置编辑
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.innerHTML = `
<div class="page-header"><h1>${t('engine.hermesConfigTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('engine.comingSoonPhase2')}
</div></div>
`
el.dataset.engine = 'hermes'
let yaml = ''
let loading = true
let saving = false
let error = ''
function esc(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function draw() {
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">HERMES AGENT · CONFIG</div>
<h1 class="hm-hero-h1">${t('engine.hermesConfigTitle')}</h1>
<div class="hm-hero-sub">~/.hermes/config.yaml</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-config-reload" ${loading || saving ? 'disabled' : ''}>重新加载</button>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-config-save" ${loading || saving ? 'disabled' : ''}>保存配置</button>
</div>
</div>
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">config.yaml</div>
<div class="hm-panel-actions">
<span class="hm-muted">${saving ? 'saving…' : loading ? 'loading…' : 'raw yaml editor'}</span>
</div>
</div>
<div class="hm-panel-body" style="padding:0">
${error ? `<div style="margin:16px 18px;padding:10px 14px;border-radius:var(--hm-radius-sm);background:var(--hm-error-soft);color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12px">${esc(error)}</div>` : ''}
<textarea id="hm-config-yaml" class="hm-input" spellcheck="false" ${loading || saving ? 'disabled' : ''} style="width:100%;min-height:560px;border:0;border-radius:0;background:var(--hm-surface-0);font-family:var(--hm-font-mono);font-size:12px;line-height:1.7;padding:18px 20px;resize:vertical">${esc(yaml)}</textarea>
</div>
</div>
`
el.querySelector('#hm-config-reload')?.addEventListener('click', load)
el.querySelector('#hm-config-save')?.addEventListener('click', save)
}
async function load() {
loading = true
error = ''
draw()
try {
const data = await api.hermesConfigRawRead()
yaml = data?.yaml || ''
} catch (err) {
error = String(err?.message || err).replace(/^Error:\s*/, '')
} finally {
loading = false
draw()
}
}
async function save() {
const textarea = el.querySelector('#hm-config-yaml')
yaml = textarea?.value || ''
saving = true
error = ''
draw()
try {
await api.hermesConfigRawWrite(yaml)
toast('配置已保存,建议重启 Hermes Gateway 生效', 'success')
} catch (err) {
error = String(err?.message || err).replace(/^Error:\s*/, '')
toast(error, 'error')
} finally {
saving = false
draw()
}
}
draw()
load()
return el
}

View File

@@ -69,6 +69,7 @@ const ICONS = {
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
let jobs = []
let gwOnline = false
@@ -86,109 +87,265 @@ export function render() {
const info = await api.checkHermes()
gwOnline = !!info?.gatewayRunning
} catch (_) {}
if (gwOnline) await loadJobs()
await loadJobs()
loading = false
draw()
}
async function loadJobs() {
try {
const data = await gw('/api/jobs')
jobs = data.jobs || []
if (gwOnline) {
const data = await gw('/api/jobs')
jobs = data.jobs || []
} else {
const data = await api.hermesCronJobsList()
jobs = Array.isArray(data) ? data : []
}
errorMsg = ''
} catch (e) {
errorMsg = String(e.message || e)
jobs = []
try {
const data = await api.hermesCronJobsList()
jobs = Array.isArray(data) ? data : []
errorMsg = ''
} catch (_) {
errorMsg = String(e.message || e)
jobs = []
}
}
}
// ── 主渲染 ──
// ── Helpers ──
/**
* Derive a semantic job state label.
* Priority: running > paused > disabled > scheduled
* Mirrors the logic used by hermes-web-ui's JobCard.vue.
*/
function jobStateOf(j) {
if (j.state === 'running') return 'running'
if (j.state === 'paused' || j.paused) return 'paused'
if (j.enabled === false) return 'disabled'
return 'scheduled'
}
/** Format any server-side timestamp (ISO / epoch-sec / epoch-ms) → local. */
function fmtJobTime(ts) {
if (!ts && ts !== 0) return '—'
let d
if (typeof ts === 'number') {
d = new Date(ts > 1e12 ? ts : ts * 1000)
} else {
d = new Date(ts)
}
if (isNaN(d.getTime())) return String(ts)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Human-friendly "in X minutes" hint for next_run_at. */
function relativeFuture(ts) {
if (!ts && ts !== 0) return ''
let d
if (typeof ts === 'number') d = new Date(ts > 1e12 ? ts : ts * 1000)
else d = new Date(ts)
const diff = Math.floor((d.getTime() - Date.now()) / 1000)
if (diff < 0) return t('engine.cronOverdue')
if (diff < 60) return t('engine.cronInSeconds').replace('{n}', diff)
if (diff < 3600) return t('engine.cronInMinutes').replace('{n}', Math.floor(diff / 60))
if (diff < 86400) return t('engine.cronInHours').replace('{n}', Math.floor(diff / 3600))
return t('engine.cronInDays').replace('{n}', Math.floor(diff / 86400))
}
// ── 主渲染 ──
function draw() {
if (editingJob) { drawForm(); return }
const total = jobs.length
const active = jobs.filter(j => !j.paused).length
const paused = total - active
const runningCount = jobs.filter(j => jobStateOf(j) === 'running').length
const paused = jobs.filter(j => jobStateOf(j) === 'paused').length
const failed = jobs.filter(j => j.last_status && j.last_status !== 'ok').length
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<h1 style="margin:0">${t('engine.hermesCronTitle')}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary hm-cron-refresh" title="Refresh" style="padding:4px 10px">${ICONS.refresh}</button>
<button class="btn btn-primary btn-sm hm-cron-create" ${!gwOnline ? 'disabled' : ''}>${t('engine.cronCreate')}</button>
<!-- Editorial hero -->
<div class="hm-hero" data-state="${gwOnline ? 'running' : 'stopped'}">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--${gwOnline ? 'run' : 'stop'}"></span>
${t('engine.cronEyebrow')}
</div>
<h1 class="hm-hero-h1">${t('engine.hermesCronTitle')}</h1>
<div class="hm-hero-sub">${total} ${t('engine.cronJobs')} · ${runningCount} ${t('engine.cronRunning').toLowerCase()}</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-cron-refresh" ${!gwOnline || loading ? 'disabled' : ''} title="${t('engine.logsRefresh')}">
${ICONS.refresh} ${t('engine.logsRefresh')}
</button>
<button class="hm-btn hm-btn--cta hm-cron-create" ${!gwOnline ? 'disabled' : ''}>
+ ${t('engine.cronCreate')}
</button>
</div>
</div>
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px;padding:8px 12px;background:var(--error-muted, #fee2e2);border-radius:6px">${esc(errorMsg)}</div>` : ''}
${errorMsg ? `
<div class="hm-panel" style="margin-bottom:16px">
<div class="hm-panel-body hm-panel-body--tight">
<div style="color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12.5px">${esc(errorMsg)}</div>
</div>
</div>
` : ''}
${!gwOnline ? `
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
<div style="margin-bottom:8px">${ICONS.clock.replace('width="14"', 'width="32"').replace('height="14"', 'height="32"')}</div>
${t('engine.chatGatewayOffline')}
<div class="hm-panel"><div class="hm-panel-body" style="text-align:center;padding:40px 28px">
<div style="margin-bottom:10px;color:var(--hm-text-muted)">${ICONS.clock.replace('width="14"', 'width="32"').replace('height="14"', 'height="32"')}</div>
<div style="font-family:var(--hm-font-serif);font-style:italic;font-size:15px;color:var(--hm-text-tertiary)">${t('engine.chatGatewayOffline')}</div>
</div></div>
` : ''}
${gwOnline && !loading ? `
<!-- 统计卡片 -->
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
<div class="card"><div class="card-body" style="padding:12px 16px">
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronTotal')}</div>
<div style="font-size:20px;font-weight:700">${total}</div>
</div></div>
<div class="card"><div class="card-body" style="padding:12px 16px">
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronRunning')}</div>
<div style="font-size:20px;font-weight:700;color:var(--success,#22c55e)">${active}</div>
</div></div>
<div class="card"><div class="card-body" style="padding:12px 16px">
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronPaused')}</div>
<div style="font-size:20px;font-weight:700;color:var(--text-tertiary)">${paused}</div>
</div></div>
<!-- KPI grid (4 stats) -->
<div class="hm-kpi-grid">
<div class="hm-kpi" data-tone="accent">
<div class="hm-kpi-label">${t('engine.cronTotal')}</div>
<div class="hm-kpi-value">${total}</div>
<div class="hm-kpi-foot">jobs defined</div>
</div>
<div class="hm-kpi" data-tone="success">
<div class="hm-kpi-label">${t('engine.cronRunning')}</div>
<div class="hm-kpi-value">${runningCount}</div>
<div class="hm-kpi-foot">actively executing</div>
</div>
<div class="hm-kpi" data-tone="${paused > 0 ? 'warn' : ''}">
<div class="hm-kpi-label">${t('engine.cronPaused')}</div>
<div class="hm-kpi-value">${paused}</div>
<div class="hm-kpi-foot">manually paused</div>
</div>
<div class="hm-kpi" data-tone="${failed > 0 ? 'error' : ''}">
<div class="hm-kpi-label">${t('engine.cronFailed')}</div>
<div class="hm-kpi-value">${failed}</div>
<div class="hm-kpi-foot">last run failed</div>
</div>
</div>
${total === 0 ? `
<div class="card"><div class="card-body" style="padding:40px;text-align:center">
<div style="margin-bottom:8px;color:var(--text-tertiary)">${ICONS.clock.replace('width="14"', 'width="40"').replace('height="14"', 'height="40"')}</div>
<div style="font-size:15px;color:var(--text-secondary);margin-bottom:6px">${t('engine.cronNoJobs')}</div>
<div style="font-size:12px;color:var(--text-tertiary)">${t('engine.cronNoJobsHint')}</div>
<div class="hm-panel"><div class="hm-panel-body" style="text-align:center;padding:48px 28px">
<div style="margin-bottom:12px;color:var(--hm-text-muted)">${ICONS.clock.replace('width="14"', 'width="40"').replace('height="14"', 'height="40"')}</div>
<div style="font-family:var(--hm-font-serif);font-size:16px;color:var(--hm-text-secondary);margin-bottom:6px">${t('engine.cronNoJobs')}</div>
<div class="hm-muted">${t('engine.cronNoJobsHint')}</div>
</div></div>
` : renderJobList()}
` : ''}
${loading ? `
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
${[1,2,3].map(() => '<div class="card"><div class="card-body" style="padding:12px 16px"><div class="skeleton-line" style="width:60%;height:12px;margin-bottom:8px"></div><div class="skeleton-line" style="width:40%;height:20px"></div></div></div>').join('')}
<div class="hm-kpi-grid">
${[1,2,3,4].map(() => `<div class="hm-kpi">
<div class="hm-skel" style="width:60%;height:11px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:40%;height:20px;margin-bottom:8px"></div>
<div class="hm-skel" style="width:50%;height:10px"></div>
</div>`).join('')}
</div>
${[1,2].map(() => '<div class="card" style="margin-bottom:12px"><div class="card-body" style="padding:16px"><div class="skeleton-line" style="width:50%;height:14px;margin-bottom:8px"></div><div class="skeleton-line" style="width:70%;height:12px"></div></div></div>').join('')}
${[1,2].map(() => `<div class="hm-panel" style="margin-bottom:12px"><div class="hm-panel-body">
<div class="hm-skel" style="width:30%;height:14px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:60%;height:12px"></div>
</div></div>`).join('')}
` : ''}
`
bindList()
}
function renderJobList() {
return `<div style="display:flex;flex-direction:column;gap:10px">${jobs.map(j => {
return `<div class="hm-cron-list">${jobs.map(j => {
const expr = extractCronExpr(j.schedule)
const desc = describeCron(j.schedule)
const id = esc(j.id || j.name)
const id = esc(j.id || j.job_id || j.name)
const state = jobStateOf(j)
const stateBadge = {
running: { cls: 'hm-badge--accent', label: t('engine.cronStateRunning') },
paused: { cls: 'hm-badge--warn', label: t('engine.cronStatePaused') },
disabled: { cls: 'hm-badge--error', label: t('engine.cronStateDisabled') },
scheduled: { cls: 'hm-badge--success', label: t('engine.cronStateScheduled') },
}[state]
const lastStatus = j.last_status
? (j.last_status === 'ok'
? `<span class="hm-cron-last-ok"> ok</span>`
: `<span class="hm-cron-last-err" title="${esc(j.last_error || '')}">✗ ${esc(j.last_status)}</span>`)
: ''
const repeatTxt = j.repeat && typeof j.repeat === 'object'
? `${j.repeat.completed ?? 0} / ${j.repeat.times ?? '∞'}`
: (typeof j.repeat === 'string' ? j.repeat : '')
const deliverLabel = j.deliver
? (j.deliver === 'origin' && j.origin
? `${esc(j.deliver)} (${esc(j.origin.platform || '')})`
: esc(j.deliver))
: '—'
const promptPreview = j.prompt_preview || j.prompt || ''
return `
<div class="card hm-cron-item" data-id="${id}" style="transition:opacity .2s;${j.paused ? 'opacity:0.65' : ''}">
<div class="card-body" style="padding:14px 18px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:200px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="font-weight:600;font-size:14px">${esc(j.name)}</span>
<span style="font-size:10px;padding:2px 8px;border-radius:10px;font-weight:500;background:${j.paused ? 'var(--bg-tertiary)' : 'rgba(34,197,94,0.1)'};color:${j.paused ? 'var(--text-tertiary)' : 'var(--success,#22c55e)'}">${j.paused ? t('engine.cronPaused') : t('engine.cronActive')}</span>
<div class="hm-panel hm-cron-item" data-id="${id}" data-state="${state}">
<div class="hm-cron-head">
<div class="hm-cron-head-left">
<div class="hm-cron-title-row">
<span class="hm-cron-name">${esc(j.name)}</span>
<span class="hm-badge ${stateBadge.cls}">${stateBadge.label}</span>
</div>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-tertiary);margin-bottom:2px">
${ICONS.clock}
<span>${esc(desc)}</span>
<code style="font-size:11px;padding:1px 6px;background:var(--bg-tertiary);border-radius:4px;color:var(--text-secondary)">${esc(expr)}</code>
</div>
${j.prompt ? `<div style="font-size:12px;color:var(--text-secondary);margin-top:4px;max-width:500px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.prompt)}</div>` : ''}
${promptPreview ? `<div class="hm-cron-prompt">${esc(promptPreview)}</div>` : ''}
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn btn-sm btn-secondary hm-cron-toggle" data-id="${id}" data-paused="${j.paused ? '1' : '0'}" title="${j.paused ? 'Resume' : 'Pause'}" style="padding:5px 8px">${j.paused ? ICONS.play : ICONS.pause}</button>
<button class="btn btn-sm btn-secondary hm-cron-run" data-id="${id}" title="${t('engine.cronRunNow')}" style="padding:5px 8px">${ICONS.zap}</button>
<button class="btn btn-sm btn-secondary hm-cron-edit" data-id="${id}" title="${t('engine.cronEdit')}" style="padding:5px 8px">${ICONS.edit}</button>
<button class="btn btn-sm btn-secondary hm-cron-del" data-id="${id}" title="${t('engine.cronDelete')}" style="padding:5px 8px;color:var(--error)">${ICONS.trash}</button>
<div class="hm-cron-actions">
<button class="hm-btn hm-btn--icon hm-cron-toggle" data-id="${id}" data-paused="${state === 'paused' ? '1' : '0'}" title="${state === 'paused' ? t('engine.cronResume') : t('engine.cronPauseBtn')}">
${state === 'paused' ? ICONS.play : ICONS.pause}
</button>
<button class="hm-btn hm-btn--icon hm-cron-run" data-id="${id}" title="${t('engine.cronRunNow')}">${ICONS.zap}</button>
<button class="hm-btn hm-btn--icon hm-cron-edit" data-id="${id}" title="${t('engine.cronEdit')}">${ICONS.edit}</button>
<button class="hm-btn hm-btn--icon hm-cron-del" data-id="${id}" title="${t('engine.cronDelete')}" style="color:var(--hm-error)">${ICONS.trash}</button>
</div>
</div>
</div>
</div>`
<div class="hm-cron-meta">
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronScheduleLabel')}</span>
<span class="hm-cron-meta-value">
<span class="hm-cron-schedule-desc">${esc(desc)}</span>
<code class="hm-code hm-cron-schedule-expr">${esc(expr)}</code>
</span>
</div>
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronNextRun')}</span>
<span class="hm-cron-meta-value">
${esc(fmtJobTime(j.next_run_at))}
${j.next_run_at ? `<span class="hm-cron-rel">${esc(relativeFuture(j.next_run_at))}</span>` : ''}
</span>
</div>
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronLastRun')}</span>
<span class="hm-cron-meta-value">
${esc(fmtJobTime(j.last_run_at))}
${lastStatus}
</span>
</div>
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronDeliverLabel')}</span>
<span class="hm-cron-meta-value">${deliverLabel}</span>
</div>
${repeatTxt ? `
<div class="hm-cron-meta-item">
<span class="hm-cron-meta-label">${t('engine.cronRepeatLabel')}</span>
<span class="hm-cron-meta-value">${esc(repeatTxt)}</span>
</div>
` : ''}
${Array.isArray(j.skills) && j.skills.length ? `
<div class="hm-cron-meta-item hm-cron-meta-item--skills">
<span class="hm-cron-meta-label">${t('engine.cronSkillsLabel')}</span>
<span class="hm-cron-meta-value">${j.skills.map(s => `<span class="hm-cron-skill-tag">${esc(s)}</span>`).join('')}</span>
</div>
` : ''}
</div>
${j.last_error ? `
<div class="hm-cron-err">
<span class="hm-cron-err-label">${t('engine.cronLastError')}</span>
<code class="hm-cron-err-msg">${esc(j.last_error)}</code>
</div>
` : ''}
</div>`
}).join('')}</div>`
}
@@ -247,47 +404,95 @@ export function render() {
// ── 创建/编辑表单 ──
/** Light cron expression sanity check — 5 space-separated fields. */
function validateCron(expr) {
if (!expr) return false
const parts = expr.trim().split(/\s+/)
return parts.length === 5
}
function drawForm() {
const isEdit = !!editingJob._editing
const id = editingJob.id || editingJob.name
const id = editingJob.id || editingJob.job_id || editingJob.name
const initSchedule = editingJob.schedule || '0 9 * * *'
const initDeliver = editingJob.deliver || 'origin'
const initRepeat = editingJob.repeat_times != null
? editingJob.repeat_times
: (typeof editingJob.repeat === 'number'
? editingJob.repeat
: (typeof editingJob.repeat === 'object' ? editingJob.repeat?.times : ''))
const shortcutsHtml = CRON_SHORTCUTS().map(s => {
const selected = s.expr === initSchedule
return `<button type="button" class="btn btn-sm ${selected ? 'btn-primary' : 'btn-secondary'} hm-cron-shortcut" data-expr="${escAttr(s.expr)}" style="font-size:11px;padding:3px 10px">${s.text}</button>`
return `<button type="button" class="hm-pill hm-cron-shortcut ${selected ? 'is-active' : ''}" data-expr="${escAttr(s.expr)}">${s.text}</button>`
}).join('')
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
<button class="btn btn-sm btn-secondary hm-cron-back" style="padding:5px 8px">${ICONS.back}</button>
<h1 style="margin:0">${isEdit ? t('engine.cronEdit') + ' — ' + esc(editingJob.name) : t('engine.cronCreate')}</h1>
</div>
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px;padding:8px 12px;background:var(--error-muted, #fee2e2);border-radius:6px">${esc(errorMsg)}</div>` : ''}
<div class="card">
<div class="card-body" style="padding:24px;display:flex;flex-direction:column;gap:18px">
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronName')}</label>
<input class="input" id="hm-cron-name" value="${escAttr(editingJob.name)}" placeholder="${t('engine.cronName')}" style="width:100%" ${isEdit ? 'disabled' : ''}>
<!-- Back hero -->
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<button class="hm-cron-back" style="color:inherit;background:none;border:none;cursor:pointer;display:inline-flex;align-items:center;gap:6px;font:inherit;padding:0">
${ICONS.back} ${t('engine.hermesCronTitle')}
</button>
</div>
<h1 class="hm-hero-h1">${isEdit ? t('engine.cronEdit') : t('engine.cronCreate')}</h1>
<div class="hm-hero-sub">${isEdit ? esc(editingJob.name) : t('engine.cronNoJobsHint')}</div>
</div>
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronSchedule')}</label>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">${shortcutsHtml}</div>
<input class="input" id="hm-cron-schedule" value="${escAttr(initSchedule)}" placeholder="0 9 * * *" style="width:100%;font-family:var(--font-mono,monospace)">
<div id="hm-cron-preview" style="font-size:12px;color:var(--text-tertiary);margin-top:6px;display:flex;align-items:center;gap:6px">
${errorMsg ? `
<div class="hm-panel" style="margin-bottom:16px">
<div class="hm-panel-body hm-panel-body--tight">
<div style="color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12.5px">${esc(errorMsg)}</div>
</div>
</div>
` : ''}
<div class="hm-panel">
<div class="hm-panel-body" style="display:flex;flex-direction:column;gap:22px">
<!-- Name -->
<label class="hm-field">
<span class="hm-field-label">${t('engine.cronName')}</span>
<input class="hm-input" id="hm-cron-name" value="${escAttr(editingJob.name)}" placeholder="daily-standup-summary" ${isEdit ? 'disabled' : ''}>
</label>
<!-- Schedule -->
<div class="hm-field">
<span class="hm-field-label">${t('engine.cronSchedule')}</span>
<div class="hm-pills" style="margin-bottom:10px">${shortcutsHtml}</div>
<input class="hm-input" id="hm-cron-schedule" value="${escAttr(initSchedule)}" placeholder="0 9 * * *">
<div id="hm-cron-preview" class="hm-muted" style="margin-top:6px;display:flex;align-items:center;gap:6px">
${ICONS.clock} <span>${describeCron(initSchedule)}</span>
</div>
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronPrompt')}</label>
<textarea class="input" id="hm-cron-prompt" rows="4" style="width:100%;resize:vertical;font-size:13px;line-height:1.5" placeholder="${t('engine.cronPrompt')}">${esc(editingJob.prompt || '')}</textarea>
<!-- Prompt -->
<label class="hm-field">
<span class="hm-field-label">${t('engine.cronPrompt')}</span>
<textarea class="hm-input" id="hm-cron-prompt" rows="5" style="resize:vertical;height:auto;min-height:120px;line-height:1.6;padding:12px 14px" placeholder="e.g. Summarize today's standup and post to the team channel">${esc(editingJob.prompt || '')}</textarea>
</label>
<!-- Deliver + Repeat (side-by-side) -->
<div class="hm-field-row">
<label class="hm-field">
<span class="hm-field-label">${t('engine.cronDeliverLabel')}</span>
<select class="hm-input" id="hm-cron-deliver">
<option value="origin" ${initDeliver === 'origin' ? 'selected' : ''}>${t('engine.cronDeliverOrigin')}</option>
<option value="local" ${initDeliver === 'local' ? 'selected' : ''}>${t('engine.cronDeliverLocal')}</option>
</select>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.cronRepeatLimit')}</span>
<input class="hm-input" id="hm-cron-repeat" type="number" min="1" step="1" value="${initRepeat != null && initRepeat !== '' ? String(initRepeat) : ''}" placeholder="∞">
<span class="hm-muted" style="margin-top:4px">${t('engine.cronRepeatLimitHint')}</span>
</label>
</div>
<div style="display:flex;gap:10px;margin-top:4px">
<button class="btn btn-primary btn-sm hm-cron-save" ${busy ? 'disabled' : ''}>${busy ? t('engine.cronSaving') : t('engine.cronSave')}</button>
<button class="btn btn-secondary btn-sm hm-cron-cancel">${t('engine.cronCancel')}</button>
<div class="hm-stack" style="margin-top:8px">
<button class="hm-btn hm-btn--cta hm-cron-save" ${busy ? 'disabled' : ''}>${busy ? t('engine.cronSaving') : t('engine.cronSave')}</button>
<button class="hm-btn hm-btn--sm hm-cron-cancel">${t('engine.cronCancel')}</button>
</div>
</div>
</div>
@@ -299,42 +504,61 @@ export function render() {
el.querySelector('.hm-cron-back')?.addEventListener('click', () => { editingJob = null; errorMsg = ''; draw() })
el.querySelector('.hm-cron-cancel')?.addEventListener('click', () => { editingJob = null; errorMsg = ''; draw() })
// 快捷预设
// Cron shortcut pills
el.querySelectorAll('.hm-cron-shortcut').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelectorAll('.hm-cron-shortcut').forEach(b => { b.classList.remove('btn-primary'); b.classList.add('btn-secondary') })
btn.classList.remove('btn-secondary'); btn.classList.add('btn-primary')
el.querySelectorAll('.hm-cron-shortcut').forEach(b => b.classList.remove('is-active'))
btn.classList.add('is-active')
const input = el.querySelector('#hm-cron-schedule')
input.value = btn.dataset.expr
updatePreview(btn.dataset.expr)
})
})
// 实时预览
// Live preview & sync shortcut highlight
const schedInput = el.querySelector('#hm-cron-schedule')
schedInput?.addEventListener('input', () => {
const val = schedInput.value.trim()
updatePreview(val)
el.querySelectorAll('.hm-cron-shortcut').forEach(b => {
b.classList.remove('btn-primary'); b.classList.add('btn-secondary')
if (b.dataset.expr === val) { b.classList.remove('btn-secondary'); b.classList.add('btn-primary') }
b.classList.toggle('is-active', b.dataset.expr === val)
})
})
// 保存
// Save
el.querySelector('.hm-cron-save')?.addEventListener('click', async () => {
const name = el.querySelector('#hm-cron-name')?.value?.trim()
const name = el.querySelector('#hm-cron-name')?.value?.trim()
const schedule = el.querySelector('#hm-cron-schedule')?.value?.trim()
const prompt = el.querySelector('#hm-cron-prompt')?.value?.trim()
if (!name) { errorMsg = t('engine.cronNameRequired'); draw(); return }
if (!schedule) { errorMsg = t('engine.cronScheduleRequired'); draw(); return }
if (!prompt) { errorMsg = t('engine.cronPromptRequired'); draw(); return }
const prompt = el.querySelector('#hm-cron-prompt')?.value?.trim()
const deliver = el.querySelector('#hm-cron-deliver')?.value || 'origin'
const repeatRaw = el.querySelector('#hm-cron-repeat')?.value?.trim()
const repeat = repeatRaw ? parseInt(repeatRaw, 10) : undefined
if (!name) { errorMsg = t('engine.cronNameRequired'); draw(); return }
if (!schedule) { errorMsg = t('engine.cronScheduleRequired'); draw(); return }
if (!validateCron(schedule)){ errorMsg = t('engine.cronInvalidCron'); draw(); return }
if (!prompt) { errorMsg = t('engine.cronPromptRequired'); draw(); return }
if (repeat !== undefined && (Number.isNaN(repeat) || repeat < 1)) {
errorMsg = t('engine.cronRepeatLimit'); draw(); return
}
busy = true; errorMsg = ''; draw()
try {
const payload = {
name,
schedule: { kind: 'cron', expr: schedule },
prompt,
deliver,
}
if (repeat !== undefined) payload.repeat = repeat
if (isEdit) {
await gw(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ schedule: { kind: 'cron', expr: schedule }, prompt }) })
// PATCH does not accept `name`; keep it out to match hermes-web-ui contract.
const patch = { schedule: payload.schedule, prompt, deliver }
if (repeat !== undefined) patch.repeat = repeat
await gw(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch) })
} else {
await gw('/api/jobs', { method: 'POST', body: JSON.stringify({ name, schedule: { kind: 'cron', expr: schedule }, prompt }) })
await gw('/api/jobs', { method: 'POST', body: JSON.stringify(payload) })
}
editingJob = null
await loadJobs()

View File

@@ -30,9 +30,31 @@ async function tauriListen(event, cb) {
return _listenFn(event, cb)
}
const HERMES_DASHBOARD_URL = 'http://127.0.0.1:9119/'
/**
* Open `url` in the user's system browser. Tauri desktop uses the shell
* plugin (which respects `xdg-open` / `start` / `open`); Web mode falls back
* to `window.open` with a `noopener` to avoid tab-jacking.
*/
async function openExternalUrl(url) {
if (!url) return
try {
if (window.__TAURI_INTERNALS__) {
const { open } = await import('@tauri-apps/plugin-shell')
await open(url)
return
}
} catch (_) { /* fall through to window.open */ }
window.open(url, '_blank', 'noopener,noreferrer')
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
// Scope the new Hermes-dense design system to this subtree only,
// so OpenClaw and other engines stay completely unaffected.
el.dataset.engine = 'hermes'
let info = null
let health = null
@@ -101,29 +123,37 @@ export function render() {
}
function draw() {
// 加载骨架屏
// 加载骨架屏data-dense style
if (loading) {
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;gap:12px">
<h1 style="margin:0">${t('engine.hermesDashboardTitle')}</h1>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:20px">
${[1,2,3,4].map(() => `<div class="card"><div class="card-body" style="padding:16px">
<div class="skeleton-line" style="width:60%;height:12px;margin-bottom:10px"></div>
<div class="skeleton-line" style="width:80%;height:20px"></div>
</div></div>`).join('')}
</div>
<div class="card" style="margin-bottom:20px"><div class="card-body" style="padding:20px">
<div class="skeleton-line" style="width:40%;height:16px;margin-bottom:16px"></div>
<div style="display:flex;gap:6px;margin-bottom:14px">${[1,2,3,4].map(() => '<div class="skeleton-line" style="width:60px;height:24px;border-radius:12px"></div>').join('')}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="skeleton-line" style="height:36px"></div>
<div class="skeleton-line" style="height:36px"></div>
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--idle"></span>
${t('engine.dashEyebrowLoading')}
</div>
<div class="hm-skel" style="width:240px;height:28px;margin-bottom:6px"></div>
<div class="hm-skel" style="width:180px;height:14px"></div>
</div>
</div></div>
<div class="card" style="margin-bottom:20px"><div class="card-body" style="padding:16px">
<div class="skeleton-line" style="width:120px;height:32px;border-radius:6px"></div>
</div></div>
</div>
<div class="hm-kpi-grid">
${[1,2,3,4,5].map(() => `
<div class="hm-kpi">
<div class="hm-skel" style="width:70%;height:10px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:50%;height:22px;margin-bottom:8px"></div>
<div class="hm-skel" style="width:40%;height:10px"></div>
</div>
`).join('')}
</div>
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-skel" style="width:120px;height:12px"></div>
</div>
<div class="hm-panel-body">
<div class="hm-skel" style="width:100%;height:34px;margin-bottom:12px"></div>
<div class="hm-skel" style="width:100%;height:34px"></div>
</div>
</div>
`
return
}
@@ -137,188 +167,255 @@ export function render() {
// 服务商高亮匹配
const activePreset = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
// 模型下拉 HTML
// 模型下拉 HTMLdata-dense
const dropdownHtml = showDropdown && models.length
? `<div id="hm-model-dropdown" style="position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--bg-primary);border:1px solid var(--border-primary);border-radius:6px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.15)">${models.map(m =>
`<div class="hm-model-opt" data-model="${esc(m)}" style="padding:5px 10px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border-primary);${m === formModel ? 'font-weight:600;color:var(--accent)' : ''}">${esc(m)}</div>`
? `<div id="hm-model-dropdown" class="hm-dropdown">${models.map(m =>
`<div class="hm-dropdown-item hm-model-opt ${m === formModel ? 'is-selected' : ''}" data-model="${esc(m)}">${esc(m)}</div>`
).join('')}</div>`
: ''
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;gap:12px">
<h1 style="margin:0">${t('engine.hermesDashboardTitle')}</h1>
<button class="btn-icon hm-dash-refresh" title="Refresh" style="opacity:0.5;cursor:pointer;background:none;border:none;padding:4px">${ICONS.refresh}</button>
</div>
<!-- 状态卡片行 -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:20px">
<div class="card" style="border-left:4px solid ${gwRunning ? 'var(--success, #22c55e)' : 'var(--error, #ef4444)'}">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashGatewayStatus')}</div>
<div style="display:flex;align-items:center;gap:8px">
${gwRunning ? ICONS.running : ICONS.stopped}
<span style="font-size:16px;font-weight:600">${gwRunning ? t('engine.dashRunning') : t('engine.dashStopped')}</span>
</div>
<!-- Hero strip: dynamic colored bar + title + CTA + icon actions -->
<div class="hm-hero" data-state="${gwRunning ? 'running' : 'stopped'}">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--${gwRunning ? 'run' : 'stop'}"></span>
${gwRunning ? t('engine.dashEyebrowOnline') : t('engine.dashEyebrowOffline')}
</div>
<h1 class="hm-hero-h1">${t('engine.hermesDashboardTitle')}</h1>
<div class="hm-hero-sub">127.0.0.1:${port} · ${esc(displayModel || '—')} · v${version}</div>
</div>
<div class="card">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashModel')}</div>
<div style="font-size:14px;font-weight:600;word-break:break-all">${esc(displayModel)}</div>
</div>
</div>
<div class="card">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashVersion')}</div>
<div style="font-size:14px;font-weight:600">${version}</div>
</div>
</div>
<div class="card">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashApiEndpoint')}</div>
<div style="font-size:13px;font-weight:600;font-family:var(--font-mono, monospace)">http://127.0.0.1:${port}</div>
</div>
</div>
<div class="card hm-dash-open-panel" style="cursor:pointer;border-left:4px solid var(--accent,#6366f1)">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px;display:flex;align-items:center;gap:6px">
${t('engine.dashOpenPanel')}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" style="opacity:.6"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<div style="font-size:14px;font-weight:600">${t('engine.dashOpenPanelDesc')}</div>
</div>
<div class="hm-hero-actions">
${!gwRunning ? `<button class="hm-btn hm-btn--cta hm-dash-start" ${actionBusy ? 'disabled' : ''}>▶ ${actionBusy ? t('engine.gatewayStarting') : t('engine.dashStartGw')}</button>` : ''}
${gwRunning ? `<button class="hm-btn hm-btn--danger hm-dash-stop" ${actionBusy ? 'disabled' : ''}>■ ${actionBusy ? t('engine.dashStopping') : t('engine.dashStopGw')}</button>` : ''}
${gwRunning ? `<button class="hm-btn hm-dash-restart" ${actionBusy ? 'disabled' : ''}>↻ ${actionBusy ? t('engine.dashRestarting') : t('engine.dashRestartGw')}</button>` : ''}
<button class="hm-btn hm-btn--icon hm-dash-refresh" title="${t('engine.dashRefresh')}">${ICONS.refresh}</button>
</div>
</div>
<!-- 模型配置区 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:0">
<div class="hm-cfg-toggle" style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;cursor:pointer;user-select:none">
<h3 style="margin:0;font-size:15px">${t('engine.dashModelConfig')}</h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="transition:transform .2s;transform:rotate(${modelConfigCollapsed ? '0' : '180'}deg);opacity:0.5"><polyline points="6 9 12 15 18 9"/></svg>
<!-- KPI grid: 5 cards with tone indicators -->
<div class="hm-kpi-grid">
<div class="hm-kpi" data-tone="${gwRunning ? 'success' : 'error'}">
<div class="hm-kpi-label">${t('engine.dashGatewayStatus')}</div>
<div class="hm-kpi-value" style="font-size:15px">
<span class="hm-dot hm-dot--${gwRunning ? 'run' : 'stop'}"></span>
${gwRunning ? t('engine.dashRunning') : t('engine.dashStopped')}
</div>
<div style="${modelConfigCollapsed ? 'display:none' : 'padding:0 20px 20px'}">
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
${hermesProviders.filter(p => p.id !== 'custom').map(p => {
const api = p.transport === 'anthropic_messages' ? 'anthropic-messages'
: p.transport === 'google_gemini' ? 'google-generative-ai'
: 'openai-completions'
const active = activePreset?.id === p.id
return `<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.id}" data-url="${esc(p.baseUrl)}" data-api="${api}" style="font-size:11px;padding:2px 8px;${active ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.name}</button>`
}).join('')}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
API Base URL
<input type="text" id="hm-cfg-baseurl" class="input" value="${esc(formBaseUrl)}" placeholder="https://gpt.qt.cool/v1" style="font-size:13px">
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
API Key
<input type="password" id="hm-cfg-apikey" class="input" value="${esc(formApiKey)}" placeholder="sk-..." style="font-size:13px">
</label>
</div>
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:12px">
<label style="flex:1;display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
${t('engine.configModel')}
<div style="position:relative">
<input type="text" id="hm-cfg-model" class="input" value="${esc(formModel)}" placeholder="QC-B01" style="font-size:13px">
${dropdownHtml}
</div>
</label>
<button class="btn btn-sm btn-secondary hm-fetch-models" style="white-space:nowrap;flex-shrink:0" ${fetchBusy ? 'disabled' : ''}>${fetchBusy ? t('engine.configFetching') : t('engine.configFetchModels')}</button>
</div>
<div id="hm-cfg-msg" style="font-size:12px;min-height:16px;margin-bottom:8px">${cfgMsg}</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-primary btn-sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
<a href="#/h/env" style="font-size:11px;color:var(--text-tertiary);text-decoration:none;margin-left:auto" title=".env 文件高级编辑(自定义环境变量)">.env 高级编辑 →</a>
</div>
<div class="hm-kpi-foot">${t('engine.dashPort')} <span style="color:var(--hm-text-secondary)">:${port}</span></div>
</div>
<div class="hm-kpi" data-tone="accent">
<div class="hm-kpi-label">${t('engine.dashModel')}</div>
<div class="hm-kpi-value" style="font-size:13px;word-break:break-all">${esc(displayModel)}</div>
<div class="hm-kpi-foot">${t('engine.dashProvider')} <code class="hm-code" style="padding:0 5px;font-size:10px">${esc(hermesConfig?.provider || activePreset?.id || '—')}</code></div>
</div>
<div class="hm-kpi">
<div class="hm-kpi-label">${t('engine.dashVersion')}</div>
<div class="hm-kpi-value">v${version}</div>
<div class="hm-kpi-foot"><span class="hm-badge hm-badge--accent">uv-tool</span></div>
</div>
<div class="hm-kpi">
<div class="hm-kpi-label">${t('engine.dashApiEndpoint')}</div>
<div class="hm-kpi-value" style="font-size:13px">127.0.0.1</div>
<div class="hm-kpi-foot"><code class="hm-code" style="padding:0 5px;font-size:10.5px">:${port}/v1</code></div>
</div>
<div class="hm-kpi hm-kpi--link hm-dash-open-panel" data-tone="accent">
<div class="hm-kpi-label">
${t('engine.dashOpenPanel')}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="10" height="10" style="opacity:.7"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<div class="hm-kpi-value" style="font-size:13px">${t('engine.dashOpenPanelDesc')}</div>
<div class="hm-kpi-foot">${t('engine.dashOpenChat')}</div>
</div>
</div>
<!-- Gateway 控制 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
${!gwRunning ? `<button class="btn btn-primary btn-sm hm-dash-start" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.gatewayStarting') : t('engine.dashStartGw')}</button>` : ''}
${gwRunning ? `<button class="btn btn-sm btn-secondary hm-dash-stop" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.dashStopping') : t('engine.dashStopGw')}</button>` : ''}
${gwRunning ? `<button class="btn btn-sm btn-secondary hm-dash-restart" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.dashRestarting') : t('engine.dashRestartGw')}</button>` : ''}
<div id="hm-dash-msg" style="font-size:12px;margin-left:8px"></div>
</div>
<div class="hm-native-dashboard-hint">
<span>${t('engine.dashNativePanelDesc')}</span>
<button class="hm-native-dashboard-link hm-dash-open-native" data-href="${HERMES_DASHBOARD_URL}">
${t('engine.dashNativePanelOpen')}
</button>
</div>
<!-- 连接目标 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<h3 style="margin:0;font-size:15px">${t('engine.dashConnectTarget')}</h3>
<button class="btn btn-sm btn-secondary hm-detect-env" ${envDetecting ? 'disabled' : ''} style="font-size:11px;padding:2px 10px">${envDetecting ? t('engine.dashDetecting') : t('engine.dashDetectEnv')}</button>
<!-- Model config panel (collapsible) -->
<div class="hm-panel">
<div class="hm-panel-header hm-panel-header--toggle hm-cfg-toggle ${modelConfigCollapsed ? '' : 'is-open'}">
<div class="hm-panel-title">
<svg class="hm-panel-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6M4.22 19.78l4.24-4.24M15.54 8.46l4.24-4.24"/></svg>
${t('engine.dashModelConfig')}
<span class="hm-panel-title-count">${hermesProviders.filter(p => p.id !== 'custom').length}</span>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
<button class="btn btn-sm hm-connect-mode ${connectMode === 'local' ? 'btn-primary' : 'btn-secondary'}" data-mode="local" style="font-size:11px;padding:2px 10px">
🖥️ ${t('engine.dashConnLocal')}
</button>
${envData?.wsl2?.available ? `<button class="btn btn-sm hm-connect-mode ${connectMode === 'wsl2' ? 'btn-primary' : 'btn-secondary'}" data-mode="wsl2" style="font-size:11px;padding:2px 10px">
🐧 WSL2 ${envData.wsl2.gatewayRunning ? '✅' : envData.wsl2.hermesInstalled ? '⚠️' : ''}
</button>` : ''}
${envData?.docker?.available ? `<button class="btn btn-sm hm-connect-mode ${connectMode === 'docker' ? 'btn-primary' : 'btn-secondary'}" data-mode="docker" style="font-size:11px;padding:2px 10px">
🐋 Docker ${envData.docker.hermesContainers?.length ? '✅' : ''}
</button>` : ''}
<button class="btn btn-sm hm-connect-mode ${connectMode === 'custom' ? 'btn-primary' : 'btn-secondary'}" data-mode="custom" style="font-size:11px;padding:2px 10px">
🌐 ${t('engine.dashConnCustom')}
</button>
<div class="hm-panel-actions">
<svg class="hm-panel-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
</div>
${!modelConfigCollapsed ? `
<div class="hm-panel-body">
<div class="hm-field-label" style="margin-bottom:10px">${t('engine.dashProviderPresets')}</div>
<div class="hm-pills" style="margin-bottom:18px">
${hermesProviders.filter(p => p.id !== 'custom').map(p => {
const api = p.transport === 'anthropic_messages' ? 'anthropic-messages'
: p.transport === 'google_gemini' ? 'google-generative-ai'
: 'openai-completions'
const active = activePreset?.id === p.id
return `<button class="hm-pill hm-preset-btn ${active ? 'is-active' : ''}" data-key="${p.id}" data-url="${esc(p.baseUrl)}" data-api="${api}">${esc(p.name)}</button>`
}).join('')}
</div>
<div class="hm-field-row">
<label class="hm-field">
<span class="hm-field-label">${t('engine.dashApiBaseUrl')}</span>
<input type="text" id="hm-cfg-baseurl" class="hm-input" value="${esc(formBaseUrl)}" placeholder="https://api.deepseek.com/v1">
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.dashApiKey')}</span>
<input type="password" id="hm-cfg-apikey" class="hm-input" value="${esc(formApiKey)}" placeholder="sk-…">
</label>
</div>
<div style="display:flex;gap:10px;align-items:flex-end;margin-top:12px">
<label class="hm-field" style="flex:1">
<span class="hm-field-label">${t('engine.configModel')}</span>
<div style="position:relative">
<input type="text" id="hm-cfg-model" class="hm-input" value="${esc(formModel)}" placeholder="deepseek-chat">
${dropdownHtml}
</div>
</label>
<button class="hm-btn hm-btn--sm hm-fetch-models" ${fetchBusy ? 'disabled' : ''}>${fetchBusy ? t('engine.configFetching') : t('engine.configFetchModels')}</button>
</div>
<div id="hm-cfg-msg" class="hm-muted" style="min-height:16px;margin:12px 0 6px">${cfgMsg}</div>
<div class="hm-stack">
<button class="hm-btn hm-btn--primary hm-btn--sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
<span class="hm-spacer"></span>
<a href="#/h/env" class="hm-btn hm-btn--ghost hm-btn--sm" title="${t('engine.dashEnvAdvancedEdit')}">${t('engine.dashEnvAdvancedEdit')}</a>
</div>
</div>
` : ''}
</div>
<!-- Gateway message line (actions moved to Hero bar) -->
<div id="hm-dash-msg" class="hm-muted" style="min-height:14px;margin:-6px 4px 12px;font-size:11px"></div>
<!-- Connection target panel -->
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<svg class="hm-panel-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M2 12h20M12 2a15.3 15.3 0 010 20M12 2a15.3 15.3 0 000 20"/></svg>
${t('engine.dashConnectTarget')}
</div>
<div class="hm-panel-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-detect-env" ${envDetecting ? 'disabled' : ''}>${envDetecting ? t('engine.dashDetecting') : '↻ ' + t('engine.dashDetectEnv')}</button>
</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-pills" style="margin-bottom:12px">
<button class="hm-pill hm-connect-mode ${connectMode === 'local' ? 'is-active' : ''}" data-mode="local">${t('engine.dashConnLocal')} · 127.0.0.1</button>
${envData?.wsl2?.available ? `<button class="hm-pill hm-connect-mode ${connectMode === 'wsl2' ? 'is-active' : ''}" data-mode="wsl2">${t('engine.dashConnWsl2')}${envData.wsl2.gatewayRunning ? ' ✓' : envData.wsl2.hermesInstalled ? ' !' : ''}</button>` : ''}
${envData?.docker?.available ? `<button class="hm-pill hm-connect-mode ${connectMode === 'docker' ? 'is-active' : ''}" data-mode="docker">${t('engine.dashConnDocker')}${envData.docker.hermesContainers?.length ? ' ✓' : ''}</button>` : ''}
<button class="hm-pill hm-connect-mode ${connectMode === 'custom' ? 'is-active' : ''}" data-mode="custom">${t('engine.dashConnCustom')}</button>
</div>
${connectMode === 'wsl2' && envData?.wsl2 ? `
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
<div>IP: <code>${esc(envData.wsl2.ip || '-')}</code> · Distros: ${(envData.wsl2.distros || []).join(', ')}</div>
${envData.wsl2.hermesInstalled ? `<div style="color:var(--success)">✓ Hermes ${esc(envData.wsl2.hermesInfo || '')}</div>` : '<div style="color:var(--warning)">Hermes 未安装</div>'}
${envData.wsl2.gatewayRunning ? `<div style="color:var(--success)">✓ Gateway: ${esc(envData.wsl2.gatewayUrl || '')}</div>` : '<div style="color:var(--text-tertiary)">Gateway 未运行</div>'}
<div class="hm-term" style="margin-bottom:12px">
<span class="hm-muted">$ wsl --status</span><br>
IP <span style="color:var(--hm-accent)">${esc(envData.wsl2.ip || '-')}</span> · distros [${(envData.wsl2.distros || []).join(', ')}]<br>
${envData.wsl2.hermesInstalled ? `<span style="color:var(--hm-cta)">✓ hermes ${esc(envData.wsl2.hermesInfo || '')}</span>` : `<span style="color:var(--hm-warn)">! ${t('engine.dashHermesMissing')}</span>`}<br>
${envData.wsl2.gatewayRunning ? `<span style="color:var(--hm-cta)">✓ gateway: ${esc(envData.wsl2.gatewayUrl || '')}</span>` : `<span class="hm-muted">${t('engine.dashGatewayNotRunning')}</span>`}
</div>
` : ''}
${connectMode === 'docker' && envData?.docker ? `
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
<div>Docker ${esc(envData.docker.version || '')}</div>
<div class="hm-term" style="margin-bottom:12px">
<span class="hm-muted">$ docker ps --filter ancestor=hermes</span><br>
engine <span style="color:var(--hm-accent)">${esc(envData.docker.version || '')}</span><br>
${envData.docker.hermesContainers?.length ? envData.docker.hermesContainers.map(c =>
`<div style="margin-top:4px">🔹 <code>${esc(c.name)}</code> (${esc(c.image)}) ${esc(c.ports)}</div>`
).join('') : '<div style="color:var(--text-tertiary)">未发现 Hermes 容器</div>'}
`<span style="color:var(--hm-cta)">▶</span> <code>${esc(c.name)}</code> (${esc(c.image)}) ${esc(c.ports)}`
).join('<br>') : `<span class="hm-muted">${t('engine.dashNoHermesContainers')}</span>`}
</div>
` : ''}
${connectMode === 'custom' ? `
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<input type="text" id="hm-custom-gw-url" class="input" value="${esc(customGwUrl)}" placeholder="http://192.168.1.100:8642" style="flex:1;font-size:13px">
<div style="margin-bottom:12px">
<input type="text" id="hm-custom-gw-url" class="hm-input" value="${esc(customGwUrl)}" placeholder="http://192.168.1.100:8642">
</div>
` : ''}
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-sm btn-primary hm-apply-connect" style="font-size:11px;padding:2px 12px">${t('engine.dashConnApply')}</button>
<span id="hm-connect-msg" style="font-size:12px">${connectMsg}</span>
<div class="hm-stack">
<button class="hm-btn hm-btn--primary hm-btn--sm hm-apply-connect">${t('engine.dashConnApply')}</button>
<span id="hm-connect-msg" class="hm-muted">${connectMsg}</span>
</div>
</div>
</div>
<!-- 快捷操作 -->
<div style="margin-bottom:12px;font-size:14px;font-weight:600">${t('engine.dashQuickActions')}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px">
<button class="card hm-dash-link" data-route="/h/chat" style="cursor:pointer;border:none;text-align:left">
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:10px">
${ICONS.chat}
<span style="font-size:14px;font-weight:500">${t('engine.dashOpenChat')}</span>
<!-- Quick actions -->
<div class="hm-field-label" style="margin:8px 2px 10px">${t('engine.dashQuickActions')}</div>
<div class="hm-kpi-grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
<button class="hm-kpi hm-kpi--link hm-dash-link" data-route="/h/chat" data-tone="accent" style="text-align:left;font-family:inherit;color:inherit;cursor:pointer">
<div class="hm-kpi-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
${t('engine.dashOpenChat')}
</div>
<div class="hm-kpi-value" style="font-size:13px">${t('engine.dashOpenChat')}</div>
<div class="hm-kpi-foot">${t('engine.dashInteractiveSession')}</div>
</button>
<button class="card hm-dash-link" data-route="/h/setup" style="cursor:pointer;border:none;text-align:left">
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:10px">
${ICONS.config}
<span style="font-size:14px;font-weight:500">${t('engine.dashOpenSetup')}</span>
<button class="hm-kpi hm-kpi--link hm-dash-link" data-route="/h/setup" data-tone="accent" style="text-align:left;font-family:inherit;color:inherit;cursor:pointer">
<div class="hm-kpi-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6"/></svg>
${t('engine.dashOpenSetup')}
</div>
<div class="hm-kpi-value" style="font-size:13px">${t('engine.dashOpenSetup')}</div>
<div class="hm-kpi-foot">${t('engine.dashInstallerWizard')}</div>
</button>
<button class="hm-kpi hm-kpi--link hm-dash-link" data-route="/h/logs" data-tone="info" style="text-align:left;font-family:inherit;color:inherit;cursor:pointer">
<div class="hm-kpi-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
${t('engine.servicesOpenLogs')}
</div>
<div class="hm-kpi-value" style="font-size:13px">gateway.log</div>
<div class="hm-kpi-foot">${t('engine.dashLogsFoot')}</div>
</button>
<button class="hm-kpi hm-kpi--link hm-dash-link" data-route="/h/env" data-tone="warn" style="text-align:left;font-family:inherit;color:inherit;cursor:pointer">
<div class="hm-kpi-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
.ENV
</div>
<div class="hm-kpi-value" style="font-size:13px">${t('engine.dashAdvancedEdit')}</div>
<div class="hm-kpi-foot">${t('engine.dashCustomVars')}</div>
</button>
</div>
<!-- 终端命令 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:20px">
<h3 style="margin:0 0 4px;font-size:15px">${t('engine.dashCliTitle')}</h3>
<p style="margin:0 0 14px;font-size:12px;color:var(--text-tertiary)">${t('engine.dashCliDesc')}</p>
<div class="hm-cli-grid">
${renderCliCommands()}
<!-- CLI reference as data table -->
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<svg class="hm-panel-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
${t('engine.dashCliTitle')}
<span class="hm-panel-title-count">${CLI_COMMANDS.length}</span>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${t('engine.dashCliDesc')}</span>
</div>
</div>
<div class="hm-panel-body hm-panel-body--none">
<table class="hm-table">
<thead>
<tr>
<th style="width:38%">${t('engine.dashCliCommand')}</th>
<th>${t('engine.dashCliDescription')}</th>
<th style="width:48px;text-align:center">${t('engine.dashCliCopy')}</th>
</tr>
</thead>
<tbody>
${CLI_COMMANDS.map((c, i) => `
<tr>
<td><code class="hm-code">${esc(c.cmd)}</code></td>
<td>
<div style="color:var(--hm-text-primary);font-family:var(--hm-font-sans);font-size:12px;font-weight:500;margin-bottom:2px">${c.label}</div>
<div class="hm-muted">${c.desc}</div>
</td>
<td style="text-align:center">
<button class="hm-btn hm-btn--icon hm-cli-copy" data-cmd-idx="${i}" title="${t('common.copy')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`
@@ -339,7 +436,7 @@ export function render() {
showGwMsg(t('engine.gatewayStarting'), false)
try {
const result = await api.hermesGatewayAction('start')
showGwMsg(result || 'Gateway 已启动', false)
showGwMsg(result || t('engine.dashGatewayStarted'), false)
} catch (e) {
showGwMsg(String(e).replace(/^Error:\s*/, ''), true)
}
@@ -365,6 +462,17 @@ export function render() {
})
// Open panel card
el.querySelector('.hm-dash-open-panel')?.addEventListener('click', () => { window.location.hash = '#/h/chat' })
// Open Hermes native dashboard in system browser
el.querySelector('.hm-dash-open-native')?.addEventListener('click', async (e) => {
const href = e.currentTarget.dataset.href
if (!href) return
try {
await openExternalUrl(href)
} catch (err) {
const { toast } = await import('../../../components/toast.js')
toast(t('engine.dashNativePanelOpenFail') + ': ' + (err?.message || err), 'error')
}
})
// Provider presets — 点击填充 URL
el.querySelectorAll('.hm-preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
@@ -452,7 +560,7 @@ export function render() {
async function doSaveModel() {
syncFormFromDom()
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
if (!formModel) { cfgMsg = `<span style="color:var(--warning)">请输入模型名</span>`; draw(); return }
if (!formModel) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configModelRequired')}</span>`; draw(); return }
const matched = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
const provider = matched?.id || 'custom'
@@ -460,7 +568,7 @@ export function render() {
modelBusy = true; cfgMsg = ''; draw()
try {
await api.configureHermes(provider, formApiKey, formModel, formBaseUrl || null)
cfgMsg = `<span style="color:var(--success)">✓ 配置已保存</span>`
cfgMsg = `<span style="color:var(--success)">✓ ${t('engine.configSaved')}</span>`
// 刷新后端状态(不覆盖 form
try { hermesConfig = await api.hermesReadConfig() } catch (_) {}
} catch (e) {
@@ -475,7 +583,7 @@ export function render() {
try {
envData = await api.hermesDetectEnvironments()
} catch (e) {
connectMsg = `<span style="color:var(--error)">探测失败: ${String(e).replace(/^Error:\s*/, '')}</span>`
connectMsg = `<span style="color:var(--error)">${t('engine.envDetectFailed')}: ${String(e).replace(/^Error:\s*/, '')}</span>`
}
envDetecting = false; draw()
}
@@ -487,7 +595,7 @@ export function render() {
} else if (connectMode === 'wsl2') {
targetUrl = envData?.wsl2?.gatewayUrl || null
if (!targetUrl) {
connectMsg = '<span style="color:var(--warning)">WSL2 Gateway 未运行,请先在 WSL 中启动</span>'
connectMsg = `<span style="color:var(--warning)">${t('engine.connWslGatewayMissing')}</span>`
draw(); return
}
} else if (connectMode === 'docker') {
@@ -495,14 +603,14 @@ export function render() {
const urlInput = el.querySelector('#hm-custom-gw-url')
targetUrl = urlInput?.value?.trim() || null
if (!targetUrl && envData?.docker?.hermesContainers?.length) {
connectMsg = '<span style="color:var(--warning)">请切换到"自定义"模式并输入容器的 Gateway URL</span>'
connectMsg = `<span style="color:var(--warning)">${t('engine.connDockerCustomHint')}</span>`
draw(); return
}
} else if (connectMode === 'custom') {
const urlInput = el.querySelector('#hm-custom-gw-url')
targetUrl = urlInput?.value?.trim() || null
if (!targetUrl) {
connectMsg = '<span style="color:var(--warning)">请输入 Gateway URL</span>'
connectMsg = `<span style="color:var(--warning)">${t('engine.connUrlRequired')}</span>`
draw(); return
}
}
@@ -587,7 +695,7 @@ export function render() {
// 监听 config.yaml 自愈事件api_server guardian
const unlisten3 = await tauriListen('hermes-config-patched', async (evt) => {
const { toast } = await import('../../../components/toast.js')
const msg = evt?.payload?.message || 'config.yaml 已自动修复'
const msg = evt?.payload?.message || t('engine.dashConfigPatched')
toast(msg, 'info', { duration: 6000 })
})
unlisteners.push(unlisten3)

View File

@@ -18,6 +18,7 @@ const ICONS = {
back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="15 18 9 12 15 6"/></svg>`,
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>`,
edit: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
eye: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>`,
save: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>`,
cancel: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
@@ -26,6 +27,7 @@ const ICONS = {
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
let rows = [] // [{ key, value, editing: false, draftValue: '', isNew: false }]
let loading = true
@@ -35,22 +37,56 @@ export function render() {
function skeleton() {
return `
<div class="page-header" style="display:flex;align-items:center;gap:12px">
<a href="#/h/dashboard" class="btn-text" style="display:inline-flex;align-items:center;gap:4px;font-size:13px">
${ICONS.back} 返回仪表盘
</a>
<h1 style="margin:0;font-size:20px">.env 高级编辑</h1>
</div>
<div style="max-width:860px">
<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:20px">
<div style="padding:10px 14px;background:var(--bg-tertiary);border-radius:var(--radius-sm,6px);font-size:12px;line-height:1.6;color:var(--text-secondary);margin-bottom:16px">
以下环境变量由 ClawPanel 在 Hermes 配置页面管理:<code>OPENAI_API_KEY</code> / <code>ANTHROPIC_API_KEY</code> / <code>DEEPSEEK_API_KEY</code> 等 provider 密钥和 base URL以及 <code>GATEWAY_ALLOW_ALL_USERS</code> / <code>API_SERVER_KEY</code>。请通过 Hermes 仪表盘的「模型配置」修改这些项——本页仅用于添加自定义环境变量(如 <code>TAVILY_API_KEY</code>、<code>HTTP_PROXY</code>、Skills 所需的自定义变量等)。
</div>
<div id="env-list"></div>
<div id="env-empty" style="display:none;padding:18px 14px;text-align:center;color:var(--text-tertiary);font-size:13px"></div>
<div id="env-error" style="display:none;padding:10px 14px;background:var(--error-bg, #fef2f2);border:1px solid var(--error, #ef4444);border-radius:var(--radius-sm,6px);color:var(--error, #ef4444);font-size:13px;margin-top:12px"></div>
<!-- Hero: editorial title + back link -->
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<a href="#/h/dashboard" style="color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:6px">
${ICONS.back} back to dashboard
</a>
</div>
<h1 class="hm-hero-h1">.env editor</h1>
<div class="hm-hero-sub">custom environment variables · ~/.hermes/.env</div>
</div>
</div>
<!-- Notice panel: which keys are managed elsewhere -->
<div class="hm-panel" style="margin-bottom:18px">
<div class="hm-panel-body hm-panel-body--tight">
<div style="font-family:var(--hm-font-serif);font-style:italic;font-size:13px;color:var(--hm-text-tertiary);line-height:1.75">
以下变量由 ClawPanel 在仪表盘「模型配置」中托管:
<code class="hm-code">OPENAI_API_KEY</code>
<code class="hm-code">ANTHROPIC_API_KEY</code>
<code class="hm-code">DEEPSEEK_API_KEY</code>
等 provider 密钥及 base URL以及
<code class="hm-code">GATEWAY_ALLOW_ALL_USERS</code>
<code class="hm-code">API_SERVER_KEY</code>。
请通过仪表盘修改这些项——本页仅管理你的自定义变量(如
<code class="hm-code">TAVILY_API_KEY</code>、
<code class="hm-code">HTTP_PROXY</code>、
skills 自定义变量等)。
</div>
</div>
</div>
<!-- Variables panel -->
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<svg class="hm-panel-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
custom.env
</div>
<div class="hm-panel-actions">
<span class="hm-muted" id="env-row-count"></span>
</div>
</div>
<div class="hm-panel-body hm-panel-body--none">
<div id="env-list"></div>
<div id="env-empty" style="display:none;padding:32px 28px;text-align:center">
<div style="font-family:var(--hm-font-serif);font-style:italic;font-size:14px;color:var(--hm-text-tertiary);margin-bottom:6px">no custom variables yet</div>
<div class="hm-muted">click "add variable" below to create one</div>
</div>
<div id="env-error" style="display:none;margin:14px 28px;padding:10px 14px;background:var(--hm-error-soft);border-radius:var(--hm-radius-sm);color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12px"></div>
</div>
</div>
`
@@ -61,52 +97,62 @@ export function render() {
const emptyEl = el.querySelector('#env-empty')
if (!listEl) return
// update count badge in panel header
const countEl = el.querySelector('#env-row-count')
if (countEl) {
countEl.textContent = loading ? '' : (rows.length ? `${rows.length} variable${rows.length > 1 ? 's' : ''}` : '')
}
if (loading) {
listEl.innerHTML = `<div style="padding:18px 14px;text-align:center;color:var(--text-tertiary);font-size:13px">加载中…</div>`
listEl.innerHTML = `
<div style="padding:28px 28px;text-align:center">
<div class="hm-skel" style="width:60%;height:14px;margin:0 auto 10px"></div>
<div class="hm-skel" style="width:40%;height:12px;margin:0 auto"></div>
</div>
`
if (emptyEl) emptyEl.style.display = 'none'
return
}
if (!rows.length) {
listEl.innerHTML = ''
if (emptyEl) {
emptyEl.textContent = '暂无自定义环境变量。点击下方「添加变量」新增一条。'
emptyEl.style.display = 'block'
}
if (emptyEl) emptyEl.style.display = 'block'
renderFooter()
return
}
if (emptyEl) emptyEl.style.display = 'none'
// Table-style header
const header = `
<div style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;padding:6px 4px;font-size:11px;color:var(--text-tertiary);font-weight:500">
<div>变量名</div>
<div></div>
<div style="text-align:right">操作</div>
<div style="display:grid;grid-template-columns:1fr 2fr 148px;gap:14px;padding:14px 28px;font-family:var(--hm-font-serif);font-style:italic;font-size:12px;color:var(--hm-text-tertiary);background:var(--hm-surface-0);border-bottom:1px solid var(--hm-border)">
<div>variable</div>
<div>value</div>
<div style="text-align:right">action</div>
</div>
`
const body = rows.map((row, idx) => {
if (row.editing) {
return `
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;align-items:center;padding:6px 4px;border-top:1px solid var(--border-primary)">
<input type="text" class="input env-key-input" ${row.isNew ? '' : 'readonly'} value="${esc(row.key)}" placeholder="EXAMPLE_KEY" style="font-family:var(--font-mono, ui-monospace);font-size:12px;padding:4px 8px">
<input type="text" class="input env-value-input" value="${esc(row.draftValue)}" placeholder="..." style="font-size:12px;padding:4px 8px">
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 148px;gap:14px;align-items:center;padding:12px 28px;border-bottom:1px solid var(--hm-border-subtle);background:var(--hm-accent-soft)">
<input type="text" class="hm-input env-key-input" ${row.isNew ? '' : 'readonly'} value="${esc(row.key)}" placeholder="EXAMPLE_KEY" style="height:32px;font-size:12px">
<input type="text" class="hm-input env-value-input" value="${esc(row.draftValue)}" placeholder="value..." style="height:32px;font-size:12px">
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn btn-sm btn-primary env-save-btn" title="保存">${ICONS.save}</button>
<button class="btn btn-sm btn-secondary env-cancel-btn" title="取消">${ICONS.cancel}</button>
<button class="hm-btn hm-btn--cta hm-btn--sm env-save-btn" title="保存">${ICONS.save}</button>
<button class="hm-btn hm-btn--sm env-cancel-btn" title="取消">${ICONS.cancel}</button>
</div>
</div>
`
}
return `
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;align-items:center;padding:6px 4px;border-top:1px solid var(--border-primary)">
<code style="font-size:12px;color:var(--text-primary);word-break:break-all">${esc(row.key)}</code>
<code style="font-size:12px;color:var(--text-secondary);word-break:break-all;opacity:0.8">${esc(maskValue(row.value))}</code>
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 148px;gap:14px;align-items:center;padding:14px 28px;border-bottom:1px solid var(--hm-border-subtle);transition:background 180ms ease">
<code class="hm-code" style="background:transparent;border:none;padding:0;font-size:12px;color:var(--hm-text-primary);word-break:break-all">${esc(row.key)}</code>
<code class="hm-code" style="background:transparent;border:none;padding:0;font-size:12px;color:var(--hm-text-tertiary);word-break:break-all">${esc(row.revealed ? row.value : maskValue(row.value))}</code>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn btn-sm btn-secondary env-edit-btn" title="编辑">${ICONS.edit}</button>
<button class="btn btn-sm btn-secondary env-delete-btn" title="删除" style="color:var(--error)">${ICONS.trash}</button>
<button class="hm-btn hm-btn--icon env-reveal-btn" title="${row.revealed ? '隐藏' : '明文'}">${ICONS.eye}</button>
<button class="hm-btn hm-btn--icon env-edit-btn" title="编辑">${ICONS.edit}</button>
<button class="hm-btn hm-btn--icon env-delete-btn" title="删除" style="color:var(--hm-error)">${ICONS.trash}</button>
</div>
</div>
`
@@ -123,10 +169,12 @@ export function render() {
// Append footer after list contents
const hasAddRow = rows.some(r => r.isNew)
const footer = document.createElement('div')
footer.style.cssText = 'margin-top:14px;display:flex;gap:10px'
footer.style.cssText = 'padding:18px 28px;border-top:1px solid var(--hm-border);display:flex;gap:10px;align-items:center'
footer.innerHTML = hasAddRow
? ''
: `<button class="btn btn-primary env-add-btn" style="display:inline-flex;align-items:center;gap:6px">${ICONS.plus} 添加变量</button>`
? '<span class="hm-muted">editing new variable…</span>'
: `<button class="hm-btn hm-btn--cta env-add-btn">${ICONS.plus} 添加变量</button>
<span class="hm-spacer"></span>
<span class="hm-muted">changes take effect on next gateway restart</span>`
// Remove existing footer
const old = el.querySelector('.env-footer')
if (old) old.remove()
@@ -154,6 +202,21 @@ export function render() {
row.draftValue = row.value
renderList()
})
rowEl.querySelector('.env-reveal-btn')?.addEventListener('click', async () => {
if (row.revealed) {
row.revealed = false
renderList()
return
}
try {
const data = await api.hermesEnvReveal(row.key)
row.value = data?.value ?? row.value
row.revealed = true
renderList()
} catch (err) {
toast(String(err).replace(/^Error:\s*/, ''), 'error')
}
})
rowEl.querySelector('.env-cancel-btn')?.addEventListener('click', () => {
if (row.isNew) {
rows.splice(idx, 1)
@@ -215,6 +278,7 @@ export function render() {
value: v,
editing: false,
draftValue: '',
revealed: false,
isNew: false,
}))
} catch (err) {

View File

@@ -0,0 +1,177 @@
import { api } from '../../../lib/tauri-api.js'
import { icon } from '../../../lib/icons.js'
import { toast } from '../../../components/toast.js'
import { t } from '../../../lib/i18n.js'
function esc(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatTokens(value) {
const n = Number(value || 0)
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(Math.round(n))
}
function formatCost(value) {
const n = Number(value || 0)
if (!n) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
export function render() {
const el = document.createElement('div')
el.className = 'page hm-extensions-page'
el.dataset.engine = 'hermes'
let loading = true
let themes = []
let activeTheme = 'default'
let plugins = []
let analytics = null
let error = ''
const docs = [
['engine.extensionsDocGettingStarted', 'https://hermes-agent.nousresearch.com/docs/getting-started/installation/'],
['engine.extensionsDocCron', 'https://hermes-agent.nousresearch.com/docs/guides/automate-with-cron/'],
['engine.extensionsDocSkills', 'https://hermes-agent.nousresearch.com/docs/guides/skills/'],
['engine.extensionsDocDashboard', 'http://127.0.0.1:9119/'],
]
function draw() {
const totals = analytics?.totals || {}
const tokens = Number(totals.total_input || 0) + Number(totals.total_output || 0)
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">${esc(t('engine.extensionsEyebrow'))}</div>
<h1 class="hm-hero-h1">${esc(t('engine.extensionsTitle'))}</h1>
<div class="hm-hero-sub">${esc(t('engine.extensionsDesc'))}</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-ext-refresh" ${loading ? 'disabled' : ''}>${icon('refresh-cw', 14)}${esc(t('engine.extensionsRefresh'))}</button>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-ext-rescan" ${loading ? 'disabled' : ''}>${icon('package', 14)}${esc(t('engine.extensionsRescan'))}</button>
</div>
</div>
${error ? `<div class="hm-panel" style="margin-bottom:16px"><div class="hm-panel-body" style="color:var(--hm-error)">${esc(error)}</div></div>` : ''}
<div class="hm-grid hm-grid--2" style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px;margin-bottom:18px">
<section class="hm-panel">
<div class="hm-panel-header"><div class="hm-panel-title">${esc(t('engine.extensionsDocs'))}</div></div>
<div class="hm-panel-body" style="display:grid;gap:10px">
${docs.map(([labelKey, href]) => `<a class="hm-native-dashboard-link" href="${esc(href)}" target="_blank" rel="noopener noreferrer">${esc(t(labelKey))} <span>↗</span></a>`).join('')}
</div>
</section>
<section class="hm-panel">
<div class="hm-panel-header"><div class="hm-panel-title">${esc(t('engine.extensionsAnalytics'))}</div></div>
<div class="hm-panel-body">
<div class="hm-kpi-grid" style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">
<div class="hm-kpi"><div class="hm-kpi-label">${esc(t('engine.extensionsSessions'))}</div><div class="hm-kpi-value">${esc(totals.total_sessions || 0)}</div></div>
<div class="hm-kpi"><div class="hm-kpi-label">${esc(t('engine.extensionsTokens'))}</div><div class="hm-kpi-value">${esc(formatTokens(tokens))}</div></div>
<div class="hm-kpi"><div class="hm-kpi-label">${esc(t('engine.extensionsCost'))}</div><div class="hm-kpi-value">${esc(formatCost(totals.total_actual_cost || totals.total_estimated_cost))}</div></div>
</div>
</div>
</section>
</div>
<div class="hm-grid hm-grid--2" style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px">
<section class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">${esc(t('engine.extensionsThemes'))}</div>
<div class="hm-panel-actions"><span class="hm-muted">${esc(t('engine.extensionsActive'))}: ${esc(activeTheme)}</span></div>
</div>
<div class="hm-panel-body" style="display:grid;gap:10px">
${themes.length ? themes.map(theme => `
<button class="hm-btn ${theme.name === activeTheme ? 'hm-btn--cta' : 'hm-btn--ghost'} hm-theme-choice" data-theme="${esc(theme.name)}" style="justify-content:flex-start;text-align:left;height:auto;padding:12px 14px">
<span style="display:grid;gap:3px">
<strong>${esc(theme.label || theme.name)}</strong>
<span class="hm-muted">${esc(theme.description || theme.name)}</span>
</span>
</button>
`).join('') : `<div class="hm-muted">${esc(t('engine.extensionsNoThemes'))}</div>`}
</div>
</section>
<section class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">${esc(t('engine.extensionsPlugins'))}</div>
<div class="hm-panel-actions"><span class="hm-muted">${esc(t('engine.extensionsManifestCount').replace('{n}', plugins.length))}</span></div>
</div>
<div class="hm-panel-body" style="display:grid;gap:10px">
${plugins.length ? plugins.map(plugin => `
<article style="padding:12px 14px;border:1px solid var(--hm-border);border-radius:var(--hm-radius-sm);background:var(--hm-surface-0)">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:4px">
<strong>${esc(plugin.label || plugin.name)}</strong>
<span class="hm-muted">v${esc(plugin.version || '0.0.0')}</span>
</div>
<div class="hm-muted" style="line-height:1.6">${esc(plugin.description || t('engine.extensionsNoDescription'))}</div>
<div class="hm-muted" style="margin-top:6px;font-family:var(--hm-font-mono);font-size:11px">${esc(plugin.tab?.path || '')}${plugin.has_api ? ' · API' : ''}</div>
</article>
`).join('') : `<div class="hm-muted">${esc(t('engine.extensionsNoPlugins'))}</div>`}
</div>
</section>
</div>
`
el.querySelector('#hm-ext-refresh')?.addEventListener('click', load)
el.querySelector('#hm-ext-rescan')?.addEventListener('click', rescan)
el.querySelectorAll('.hm-theme-choice').forEach(btn => {
btn.addEventListener('click', async () => {
const name = btn.dataset.theme
if (!name || name === activeTheme) return
try {
await api.hermesDashboardThemeSet(name)
activeTheme = name
toast(t('engine.extensionsThemeSaved'), 'success')
draw()
} catch (err) {
toast(String(err?.message || err).replace(/^Error:\s*/, ''), 'error')
}
})
})
}
async function load() {
loading = true
error = ''
draw()
try {
const [themeData, pluginData, usageData] = await Promise.all([
api.hermesDashboardThemes(),
api.hermesDashboardPlugins(),
api.hermesUsageAnalytics(30),
])
themes = Array.isArray(themeData?.themes) ? themeData.themes : []
activeTheme = themeData?.active || 'default'
plugins = Array.isArray(pluginData) ? pluginData : []
analytics = usageData || null
} catch (err) {
error = String(err?.message || err).replace(/^Error:\s*/, '')
} finally {
loading = false
draw()
}
}
async function rescan() {
try {
await api.hermesDashboardPluginsRescan()
await load()
toast(t('engine.extensionsPluginsRescanned'), 'success')
} catch (err) {
toast(String(err?.message || err).replace(/^Error:\s*/, ''), 'error')
}
}
draw()
load()
return el
}

View File

@@ -1,19 +1,89 @@
/**
* Hermes Agent 日志查看器
* 支持按文件/级别/关键字过滤,实时查看 Agent 运行日志
* Hermes Agent — Log viewer
*
* Data contract mirrors `hermes-web-ui`'s `/api/hermes/logs` endpoints:
* { files: [{ name, size, modified }] }
* { entries: [{ timestamp, level, logger, message, raw }, ...] }
*
* Extras beyond the official UI:
* - Download entire log file to user's disk
* - Clear the currently rendered entries (local only)
* - Auto-refresh (polling tail) toggle — 2s tick
* - Access-log colouring: method / path / status are parsed and highlighted
* - Live regex search that also highlights matches inline
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const LOG_LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
const LEVEL_CLASS = { DEBUG: 'debug', INFO: 'info', WARNING: 'warn', WARN: 'warn', ERROR: 'error', CRITICAL: 'error', FATAL: 'error' }
const LEVEL_TONE = {
DEBUG: 'debug',
INFO: 'info',
WARNING: 'warn', WARN: 'warn',
ERROR: 'error', CRITICAL: 'error', FATAL: 'error',
}
const ICONS = {
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
clear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>',
play: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
pause: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>',
}
/** Extract HH:MM:SS from arbitrary timestamp string; fallback to the raw. */
function formatTime(ts) {
if (!ts) return ''
const match = String(ts).match(/\d{2}:\d{2}:\d{2}/)
return match ? match[0] : String(ts)
}
/** Parse an HTTP access log message. Returns null on miss. */
function parseAccessLog(msg) {
const match = String(msg || '').match(/"(\w+)\s+(\S+)\s+HTTP\/[^"]+"\s+(\d+)/)
if (!match) return null
return { method: match[1], path: match[2], status: match[3] }
}
function formatSize(bytes) {
if (typeof bytes === 'string') return bytes
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
/** Highlight substrings matching `query` in an HTML-escaped text. */
function highlight(text, query) {
if (!query) return text
const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig')
return text.replace(re, '<mark class="hm-log-hl">$1</mark>')
}
/** Trigger a browser file download of `content` as `filename`. */
function triggerDownload(content, filename) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
export function render() {
const el = document.createElement('div')
el.className = 'hermes-logs-page'
el.dataset.engine = 'hermes'
// --- State ---
let logFiles = []
let activeFile = ''
let entries = []
@@ -22,23 +92,30 @@ export function render() {
let searchQuery = ''
let lineLimit = 200
let autoScroll = true
let tailing = false // auto-refresh tick active
let downloading = false
let tailTimer = null
// --- Data ---
async function loadFiles() {
try {
logFiles = await api.hermesLogsList()
if (logFiles.length && !activeFile) activeFile = logFiles[0].name
} catch (e) {
console.error('Failed to load log files:', e)
console.error('[logs] Failed to load file list:', e)
logFiles = []
}
}
async function loadEntries() {
if (!activeFile) { entries = []; draw(); return }
loading = true
draw()
async function loadEntries({ silent = false } = {}) {
if (!activeFile) { entries = []; if (!silent) draw(); return }
if (!silent) { loading = true; draw() }
try {
entries = await api.hermesLogsRead(activeFile, lineLimit, levelFilter !== 'ALL' ? levelFilter : null)
entries = await api.hermesLogsRead(
activeFile,
lineLimit,
levelFilter !== 'ALL' ? levelFilter : null,
)
} catch (e) {
entries = [{ raw: `⚠️ ${t('engine.logsLoadFailed')}: ${e.message || e}` }]
}
@@ -49,53 +126,180 @@ export function render() {
function filteredEntries() {
if (!searchQuery) return entries
const q = searchQuery.toLowerCase()
return entries.filter(e => (e.raw || e.message || '').toLowerCase().includes(q))
return entries.filter(e => {
const hay = [e.raw, e.message, e.logger].filter(Boolean).join(' ').toLowerCase()
return hay.includes(q)
})
}
function formatSize(bytes) {
if (typeof bytes === 'string') return bytes
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
// --- Tailing (simple poll, 2s) ---
function startTail() {
if (tailTimer) return
tailing = true
tailTimer = setInterval(() => loadEntries({ silent: true }), 2000)
draw()
}
function stopTail() {
if (tailTimer) { clearInterval(tailTimer); tailTimer = null }
tailing = false
draw()
}
function toggleTail() { tailing ? stopTail() : startTail() }
// --- Actions ---
async function doDownload() {
if (!activeFile || downloading) return
downloading = true
draw()
try {
const result = await api.hermesLogsDownload(activeFile)
if (typeof result === 'string') {
triggerDownload(result, activeFile)
toast(t('engine.logsDownloadBrowserOk'), 'success', { duration: 5000 })
} else {
const path = result?.path || ''
toast(t('engine.logsDownloadOk').replace('{path}', path), 'success', { duration: 7000 })
}
} catch (e) {
toast(t('engine.logsDownloadFailed') + ': ' + (e?.message || e), 'error')
}
downloading = false
draw()
}
function doClearView() {
// Local-only clear: drop rendered entries. The file on disk is untouched.
entries = []
draw()
}
// --- Rendering ---
function renderLevelBadge(lvl, tone) {
return `<span class="hm-log-level" data-tone="${tone || ''}">${escHtml(lvl || '-')}</span>`
}
function renderEntry(e) {
const lvl = (e.level || '').toUpperCase()
const tone = LEVEL_TONE[lvl] || ''
const logger = e.logger || ''
const time = formatTime(e.timestamp)
const rawMsg = e.message || ''
const access = parseAccessLog(rawMsg)
// Raw (unparsed) fallback — preserve full line
if (!e.timestamp && !lvl) {
const raw = escHtml(e.raw || '')
return `<div class="hm-log-entry hm-log-entry--raw">
<span class="hm-log-msg">${highlight(raw, searchQuery)}</span>
</div>`
}
let msgHtml
if (access) {
const statusClass = `hm-log-status--${access.status?.[0] || 'x'}xx`
msgHtml = `
<span class="hm-log-access">
<span class="hm-log-method">${escHtml(access.method)}</span>
<span class="hm-log-path">${escHtml(access.path)}</span>
<span class="hm-log-status ${statusClass}">${escHtml(access.status)}</span>
</span>
`
} else {
msgHtml = `<span class="hm-log-msg">${highlight(escHtml(rawMsg), searchQuery)}</span>`
}
return `<div class="hm-log-entry" data-tone="${tone}">
<span class="hm-log-time">${escHtml(time)}</span>
${renderLevelBadge(lvl, tone)}
${logger ? `<span class="hm-log-logger">${highlight(escHtml(logger), searchQuery)}</span>` : ''}
${msgHtml}
</div>`
}
function draw() {
const filtered = filteredEntries()
const totalVisible = filtered.length
const totalLoaded = entries.length
el.innerHTML = `
<div class="hm-logs-header">
<span class="hm-logs-header-title">${t('engine.hermesLogsTitle')}</span>
<div class="hm-logs-header-actions">
<select id="hm-logs-level" class="hm-logs-select">
${LOG_LEVELS.map(l => `<option value="${l}" ${l === levelFilter ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<select id="hm-logs-lines" class="hm-logs-select">
${[100, 200, 500, 1000].map(n => `<option value="${n}" ${n === lineLimit ? 'selected' : ''}>${n} ${t('engine.logsLines')}</option>`).join('')}
</select>
<input type="text" id="hm-logs-search" class="hm-logs-search" placeholder="${t('engine.logsSearch')}" value="${escHtml(searchQuery)}">
<button class="btn btn-sm" id="hm-logs-refresh">${t('engine.logsRefresh')}</button>
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--${tailing ? 'run' : 'idle'}"></span>
${tailing ? t('engine.logsTailing') : t('engine.logsEyebrow')}
</div>
<h1 class="hm-hero-h1">${t('engine.hermesLogsTitle')}</h1>
<div class="hm-hero-sub">~/.hermes/logs/${activeFile ? ' · ' + escHtml(activeFile) : ''}</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-logs-tail ${tailing ? 'is-active' : ''}" title="${t('engine.logsToggleTail')}">
${tailing ? ICONS.pause : ICONS.play} ${tailing ? t('engine.logsTailStop') : t('engine.logsTailStart')}
</button>
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-logs-download" ${!activeFile || downloading ? 'disabled' : ''} title="${t('engine.logsDownload')}">
${ICONS.download} ${downloading ? '…' : t('engine.logsDownload')}
</button>
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-logs-refresh" ${loading ? 'disabled' : ''} title="${t('engine.logsRefresh')}">
${ICONS.refresh} ${t('engine.logsRefresh')}
</button>
</div>
</div>
<div class="hm-logs-layout">
<div class="hm-logs-sidebar">
<div class="hm-logs-sidebar-title">${t('engine.logsFiles')}</div>
${logFiles.length === 0 ? `<div class="hm-logs-empty">${t('engine.logsNoFiles')}</div>` : ''}
${logFiles.map(f => `
<div class="hm-logs-file-item ${f.name === activeFile ? 'active' : ''}" data-file="${escHtml(f.name)}">
<span class="hm-logs-file-name">${escHtml(f.name)}</span>
<span class="hm-logs-file-size">${formatSize(f.size)}</span>
</div>
`).join('')}
</div>
<div class="hm-logs-main">
<aside class="hm-logs-sidebar">
<div class="hm-panel-title hm-logs-sidebar-title">${t('engine.logsFiles')}</div>
<div class="hm-logs-file-list">
${logFiles.length === 0
? `<div class="hm-logs-empty hm-muted">${t('engine.logsNoFiles')}</div>`
: logFiles.map(f => `
<button class="hm-logs-file-item ${f.name === activeFile ? 'is-active' : ''}" data-file="${escHtml(f.name)}">
<span class="hm-logs-file-name">${escHtml(f.name)}</span>
<span class="hm-logs-file-size">${formatSize(f.size)}</span>
</button>
`).join('')}
</div>
</aside>
<section class="hm-logs-main">
<div class="hm-logs-toolbar">
<div class="hm-logs-count">${filtered.length} ${t('engine.logsEntries')}</div>
<label class="hm-logs-toolbar-item">
<span class="hm-field-label">${t('engine.logsLevel')}</span>
<select id="hm-logs-level" class="hm-input hm-logs-select">
${LOG_LEVELS.map(l => `<option value="${l}" ${l === levelFilter ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</label>
<label class="hm-logs-toolbar-item">
<span class="hm-field-label">${t('engine.logsLinesLabel')}</span>
<select id="hm-logs-lines" class="hm-input hm-logs-select">
${[100, 200, 500, 1000].map(n => `<option value="${n}" ${n === lineLimit ? 'selected' : ''}>${n} ${t('engine.logsLines')}</option>`).join('')}
</select>
</label>
<label class="hm-logs-toolbar-item hm-logs-toolbar-item--grow">
<span class="hm-field-label">${t('engine.logsSearchLabel')}</span>
<input type="text" id="hm-logs-search" class="hm-input" placeholder="${t('engine.logsSearch')}" value="${escHtml(searchQuery)}">
</label>
<div class="hm-logs-toolbar-item hm-logs-toolbar-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-logs-clear" ${!entries.length ? 'disabled' : ''} title="${t('engine.logsClear')}">
${ICONS.clear}
</button>
</div>
</div>
<div class="hm-logs-count hm-muted">
${totalVisible} / ${totalLoaded} ${t('engine.logsEntries')}
${searchQuery ? `· ${t('engine.logsFilteredBy')} "${escHtml(searchQuery)}"` : ''}
</div>
<div class="hm-logs-content" id="hm-logs-content">
${loading ? `<div class="hm-logs-loading">${t('engine.logsLoading')}</div>` : ''}
${!loading && filtered.length === 0 ? `<div class="hm-logs-empty-content">${t('engine.logsEmpty')}</div>` : ''}
${!loading ? filtered.map(e => renderEntry(e)).join('') : ''}
${loading ? `
<div class="hm-logs-loading">
<div class="hm-skel" style="width:70%;height:14px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:80%;height:14px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:60%;height:14px"></div>
</div>
` : ''}
${!loading && totalVisible === 0 ? `<div class="hm-logs-empty-content hm-muted">${t('engine.logsEmpty')}</div>` : ''}
${!loading ? filtered.map(renderEntry).join('') : ''}
</div>
</div>
</section>
</div>
`
bind()
@@ -105,25 +309,16 @@ export function render() {
}
}
function renderEntry(e) {
const lvl = (e.level || '').toUpperCase()
const cls = LEVEL_CLASS[lvl] || ''
if (e.timestamp) {
const time = e.timestamp.replace(/^.*?(\d{2}:\d{2}:\d{2}).*$/, '$1') || e.timestamp
return `<div class="hm-log-entry ${cls}">
<span class="hm-log-time">${escHtml(time)}</span>
<span class="hm-log-level ${cls}">${escHtml(lvl || '-')}</span>
<span class="hm-log-msg">${escHtml(e.message || '')}</span>
</div>`
}
return `<div class="hm-log-entry raw"><span class="hm-log-msg">${escHtml(e.raw || '')}</span></div>`
}
// --- Event binding ---
function bind() {
el.querySelector('#hm-logs-refresh')?.addEventListener('click', () => loadEntries())
el.querySelector('.hm-logs-refresh')?.addEventListener('click', () => loadEntries())
el.querySelector('.hm-logs-tail')?.addEventListener('click', toggleTail)
el.querySelector('.hm-logs-download')?.addEventListener('click', doDownload)
el.querySelector('.hm-logs-clear')?.addEventListener('click', doClearView)
el.querySelectorAll('.hm-logs-file-item').forEach(item => {
item.addEventListener('click', () => {
if (item.dataset.file === activeFile) return
activeFile = item.dataset.file
loadEntries()
})
@@ -145,7 +340,18 @@ export function render() {
})
}
// Init
// --- Lifecycle: stop tail when the page is detached ---
const detachObserver = new MutationObserver(() => {
if (!el.isConnected) {
stopTail()
detachObserver.disconnect()
}
})
requestAnimationFrame(() => {
if (el.parentNode) detachObserver.observe(el.parentNode, { childList: true })
})
// --- Init ---
async function init() {
await loadFiles()
await loadEntries()

View File

@@ -1,13 +1,28 @@
/**
* Hermes Agent 记忆编辑器
* 读写 ~/.hermes/memories/MEMORY.md 和 USER.md
* 支持 Markdown 预览和编辑模式切换
* Hermes Agent — Memory editor (three-section: MEMORY / USER / SOUL)
*
* Mirrors the data contract used by the official `hermes-web-ui`:
* GET /api/hermes/memory → { memory, user, soul, mtimes }
* POST /api/hermes/memory → { section, content }
*
* ClawPanel calls the equivalent Rust/Web-stub commands (`hermes_memory_read_all`
* + `hermes_memory_write`) so the page works on Tauri and Web modes.
*
* All three files live in `~/.hermes/memories/` and are plain Markdown.
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { showContentModal } from '../../../components/modal.js'
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* Markdown → HTML. Intentionally minimal (no external dep). Good enough for
* short agent persona notes. Code blocks preserved. Tables NOT supported.
*/
function mdToHtml(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
@@ -23,122 +38,269 @@ function mdToHtml(text) {
.replace(/\n/g, '<br>')
}
const ICONS = {
memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
user: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
soul: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M12 6v6l4 2"/></svg>',
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
save: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
}
/** Format epoch-seconds → relative/short local time (serif-friendly). */
function fmtMtime(epoch) {
if (!epoch) return ''
const now = Date.now() / 1000
const diff = now - epoch
if (diff < 60) return t('engine.memoryJustNow')
if (diff < 3600) return t('engine.memoryMinAgo').replace('{n}', Math.floor(diff / 60))
if (diff < 86400) return t('engine.memoryHrAgo').replace('{n}', Math.floor(diff / 3600))
const d = new Date(epoch * 1000)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Rough word + char count. CJK counted per character. */
function contentStats(text) {
const t = text || ''
const chars = t.length
// Split on whitespace OR CJK character boundary
const words = (t.match(/[\u4e00-\u9fff]|[A-Za-z0-9_]+/g) || []).length
return { chars, words }
}
export function render() {
const el = document.createElement('div')
el.className = 'hermes-memory-page'
el.dataset.engine = 'hermes'
let memoryContent = ''
let userContent = ''
let editingSection = null // null | 'memory' | 'user'
let editBuffer = ''
// --- State ---
const SECTIONS = [
{ key: 'memory', titleKey: 'engine.memoryNotes', icon: ICONS.memory, descKey: 'engine.memoryNotesDesc' },
{ key: 'user', titleKey: 'engine.memoryProfile', icon: ICONS.user, descKey: 'engine.memoryProfileDesc' },
{ key: 'soul', titleKey: 'engine.memorySoul', icon: ICONS.soul, descKey: 'engine.memorySoulDesc' },
]
const data = { memory: '', user: '', soul: '' }
const mtimes = { memory: null, user: null, soul: null }
let editing = null // { key, buffer }
let loading = true
let saving = false
let loadError = null
async function loadAll() {
loading = true
loadError = null
draw()
try {
const [mem, usr] = await Promise.all([
api.hermesMemoryRead('memory'),
api.hermesMemoryRead('user'),
])
memoryContent = mem || ''
userContent = usr || ''
const res = await api.hermesMemoryReadAll()
data.memory = res?.memory || ''
data.user = res?.user || ''
data.soul = res?.soul || ''
mtimes.memory = res?.memory_mtime ?? null
mtimes.user = res?.user_mtime ?? null
mtimes.soul = res?.soul_mtime ?? null
} catch (e) {
console.error('Failed to load memory:', e)
loadError = String(e?.message || e).replace(/^Error:\s*/, '')
}
loading = false
draw()
}
function startEdit(section) {
editingSection = section
editBuffer = section === 'memory' ? memoryContent : userContent
draw()
el.querySelector('#hm-memory-textarea')?.focus()
function startEdit(key) {
const section = SECTIONS.find(s => s.key === key)
editing = { key, buffer: data[key] || '' }
const { chars, words } = contentStats(editing.buffer)
const overlay = showContentModal({
title: `${t(section?.titleKey || 'engine.hermesMemoryTitle')} · ${t('engine.memoryEdit')}`,
width: 920,
content: `
<div class="hm-mem-modal-wrap">
<div class="hm-mem-desc">${t(section?.descKey || 'engine.memoryNotesDesc')}</div>
<textarea id="hm-mem-modal-textarea" class="hm-input hm-mem-editor hm-mem-modal-editor" spellcheck="false" placeholder="${t('engine.memoryPlaceholder')}">${escHtml(editing.buffer)}</textarea>
<div class="hm-mem-modal-foot">
<span class="hm-mem-stats" id="hm-mem-modal-stats">
<span>${words} ${t('engine.memoryWords')}</span>
<span class="hm-mem-sep">·</span>
<span>${chars} ${t('engine.memoryChars')}</span>
</span>
<span class="hm-spacer"></span>
<span class="hm-muted">${t('engine.memorySaveHint')}</span>
</div>
</div>
`,
buttons: [{ id: 'hm-mem-modal-save', className: 'btn btn-primary btn-sm', label: t('engine.memorySave') }],
})
overlay.classList.add('hm-mem-modal-overlay')
overlay.dataset.engine = 'hermes'
const ta = overlay.querySelector('#hm-mem-modal-textarea')
const cancelBtn = overlay.querySelector('[data-action="cancel"]')
const saveBtn = overlay.querySelector('#hm-mem-modal-save')
const closeWithConfirm = () => {
if (!editing) {
overlay.remove()
return
}
const dirty = editing.buffer !== (data[editing.key] || '')
if (dirty && !confirm(t('engine.memoryUnsaved'))) return
editing = null
overlay.remove()
}
cancelBtn.textContent = t('engine.memoryCancel')
cancelBtn.onclick = closeWithConfirm
saveBtn.onclick = save
overlay.addEventListener('click', (e) => {
if (e.target !== overlay) return
e.stopImmediatePropagation()
closeWithConfirm()
}, true)
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopImmediatePropagation()
closeWithConfirm()
}
}, true)
ta.focus()
ta.setSelectionRange(ta.value.length, ta.value.length)
ta.addEventListener('input', (e) => {
if (!editing) return
editing.buffer = e.target.value
const statsEl = overlay.querySelector('#hm-mem-modal-stats')
const stats = contentStats(editing.buffer)
if (statsEl) {
statsEl.innerHTML = `
<span>${stats.words} ${t('engine.memoryWords')}</span>
<span class="hm-mem-sep">·</span>
<span>${stats.chars} ${t('engine.memoryChars')}</span>
`
}
})
ta.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
save()
}
})
}
function cancelEdit() {
const original = editingSection === 'memory' ? memoryContent : userContent
if (editBuffer !== original && !confirm(t('engine.memoryUnsaved'))) return
editingSection = null
editBuffer = ''
if (!editing) return
const dirty = editing.buffer !== (data[editing.key] || '')
if (dirty && !confirm(t('engine.memoryUnsaved'))) return
editing = null
document.querySelector('.hm-mem-modal-overlay')?.remove()
draw()
}
async function save() {
if (!editingSection) return
if (!editing || saving) return
saving = true
draw()
const saveBtn = document.querySelector('#hm-mem-modal-save')
if (saveBtn) {
saveBtn.disabled = true
saveBtn.textContent = t('engine.memorySaving')
}
const { key, buffer } = editing
try {
await api.hermesMemoryWrite(editingSection, editBuffer)
if (editingSection === 'memory') memoryContent = editBuffer
else userContent = editBuffer
editingSection = null
editBuffer = ''
await api.hermesMemoryWrite(key, buffer)
data[key] = buffer
mtimes[key] = Math.floor(Date.now() / 1000)
editing = null
document.querySelector('.hm-mem-modal-overlay')?.remove()
toast(t('engine.memorySaved'), 'success')
} catch (e) {
alert(`${t('engine.memorySaveFailed')}: ${e.message || e}`)
if (saveBtn) {
saveBtn.disabled = false
saveBtn.textContent = t('engine.memorySave')
}
toast(t('engine.memorySaveFailed') + ': ' + (e?.message || e), 'error')
}
saving = false
draw()
}
function renderSection(type, title, iconSvg, content) {
const isEditing = editingSection === type
return `<div class="hm-memory-section">
<div class="hm-memory-section-header">
<div class="hm-memory-section-title-row">
<span class="hm-memory-section-icon">${iconSvg}</span>
<span class="hm-memory-section-title">${title}</span>
</div>
${!isEditing ? `<button class="btn btn-sm btn-secondary hm-memory-edit-btn" data-section="${type}">${t('engine.memoryEdit')}</button>` : ''}
</div>
${isEditing ? `
<div class="hm-memory-edit-wrap">
<textarea class="hm-memory-editor" id="hm-memory-textarea" placeholder="${t('engine.memoryPlaceholder')}">${escHtml(editBuffer)}</textarea>
<div class="hm-memory-edit-actions">
<button class="btn btn-sm" id="hm-memory-cancel">${t('engine.memoryCancel')}</button>
<button class="btn btn-sm btn-primary" id="hm-memory-save" ${saving ? 'disabled' : ''}>${saving ? t('engine.memorySaving') : t('engine.memorySave')}</button>
function renderSection(section) {
const content = data[section.key] || ''
const { chars, words } = contentStats(content)
const mtime = mtimes[section.key]
const statsMarkup = `<span class="hm-mem-stats">
<span>${words} ${t('engine.memoryWords')}</span>
<span class="hm-mem-sep">·</span>
<span>${chars} ${t('engine.memoryChars')}</span>
${mtime ? `<span class="hm-mem-sep">·</span><span>${escHtml(fmtMtime(mtime))}</span>` : ''}
</span>`
return `
<div class="hm-panel hm-mem-panel" data-key="${section.key}">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${section.icon}</span>
${t(section.titleKey)}
</div>
<div class="hm-panel-actions">
${statsMarkup}
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-mem-edit" data-key="${section.key}">${ICONS.edit} ${t('engine.memoryEdit')}</button>
</div>
</div>
` : `
<div class="hm-memory-section-body markdown-body">
${content.trim() ? mdToHtml(content) : `<div class="hm-memory-empty">${t('engine.memoryEmpty')}</div>`}
<div class="hm-panel-body">
<div class="hm-mem-desc">${t(section.descKey)}</div>
${content.trim()
? `<div class="hm-mem-rendered markdown-body">${mdToHtml(content)}</div>`
: `<div class="hm-mem-empty">
<span class="hm-mem-empty-title">${t('engine.memoryEmpty')}</span>
<span class="hm-muted">${t(section.descKey)}</span>
</div>`}
</div>
`}
</div>`
</div>
`
}
function draw() {
const notesIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
const userIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
el.innerHTML = `
<div class="hm-memory-header">
<span class="hm-memory-header-title">${t('engine.hermesMemoryTitle')}</span>
<button class="btn btn-sm" id="hm-memory-refresh">${t('engine.logsRefresh')}</button>
</div>
<div class="hm-memory-content">
${loading ? `<div class="hm-memory-loading">${t('engine.memoryLoading')}</div>` : `
<div class="hm-memory-sections">
${renderSection('memory', t('engine.memoryNotes'), notesIcon, memoryContent)}
${renderSection('user', t('engine.memoryProfile'), userIcon, userContent)}
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--run"></span>
${t('engine.memoryEyebrow')}
</div>
`}
<h1 class="hm-hero-h1">${t('engine.hermesMemoryTitle')}</h1>
<div class="hm-hero-sub">~/.hermes/memories/ · 3 files</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-mem-refresh" ${loading ? 'disabled' : ''} title="${t('engine.logsRefresh')}">
${ICONS.refresh} ${t('engine.logsRefresh')}
</button>
</div>
</div>
${loadError ? `
<div class="hm-panel" style="margin-bottom:18px">
<div class="hm-panel-body hm-panel-body--tight">
<div style="color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12.5px">
${escHtml(loadError)}
</div>
</div>
</div>
` : ''}
${loading ? `
<div class="hm-panel"><div class="hm-panel-body">
<div class="hm-skel" style="width:40%;height:14px;margin-bottom:12px"></div>
<div class="hm-skel" style="width:100%;height:80px"></div>
</div></div>
<div class="hm-panel"><div class="hm-panel-body">
<div class="hm-skel" style="width:30%;height:14px;margin-bottom:12px"></div>
<div class="hm-skel" style="width:100%;height:60px"></div>
</div></div>
` : SECTIONS.map(renderSection).join('')}
`
bind()
}
function bind() {
el.querySelector('#hm-memory-refresh')?.addEventListener('click', () => loadAll())
el.querySelectorAll('.hm-memory-edit-btn').forEach(btn => {
btn.addEventListener('click', () => startEdit(btn.dataset.section))
})
el.querySelector('#hm-memory-cancel')?.addEventListener('click', () => cancelEdit())
el.querySelector('#hm-memory-save')?.addEventListener('click', () => save())
el.querySelector('#hm-memory-textarea')?.addEventListener('input', (e) => {
editBuffer = e.target.value
el.querySelector('.hm-mem-refresh')?.addEventListener('click', () => loadAll())
el.querySelectorAll('.hm-mem-edit').forEach(btn => {
btn.addEventListener('click', () => startEdit(btn.dataset.key))
})
}

View File

@@ -1,16 +1,563 @@
/**
* Hermes Agent 服务管理
*/
import { api, invalidate } from '../../../lib/tauri-api.js'
import { t } from '../../../lib/i18n.js'
const ICONS = {
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
start: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
stop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
restart: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.13-3.36L23 10"/><path d="M20.49 15A9 9 0 016.36 18.36L1 14"/></svg>',
package: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="15" height="15"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="7.5 4.21 12 6.81 16.5 4.21"/><polyline points="7.5 19.79 7.5 14.6 3 12"/><polyline points="21 12 16.5 14.6 16.5 19.79"/><polyline points="12 22.08 12 16.8 21 12"/><polyline points="12 16.8 3 12"/></svg>',
config: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="15" height="15"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h10"/><circle cx="17" cy="17" r="2"/><circle cx="8" cy="7" r="2"/><circle cx="14" cy="12" r="2"/></svg>',
health: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" width="15" height="15"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>',
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>',
}
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function stripError(error) {
return String(error?.message || error || '').replace(/^Error:\s*/, '')
}
function maskSecret(value) {
const raw = String(value || '').trim()
if (!raw) return t('engine.servicesNotSet')
if (raw.length <= 8) return '••••••••'
return `${raw.slice(0, 4)}••••${raw.slice(-4)}`
}
function isLocalGatewayUrl(url, port) {
if (!url) return true
try {
const parsed = new URL(url)
if (!['127.0.0.1', 'localhost'].includes(parsed.hostname)) return false
if (!parsed.port) return true
return Number(parsed.port) === Number(port || 8642)
} catch (_) {
return false
}
}
function summarizeHealth(value, limit = 8) {
const rows = []
function visit(prefix, current, depth) {
if (rows.length >= limit || depth > 1 || current == null) return
if (typeof current === 'string' || typeof current === 'number' || typeof current === 'boolean') {
rows.push({
key: prefix || 'status',
value: typeof current === 'boolean' ? (current ? 'true' : 'false') : String(current),
})
return
}
if (Array.isArray(current)) {
if (current.every(item => ['string', 'number', 'boolean'].includes(typeof item))) {
rows.push({ key: prefix || 'items', value: current.join(', ') })
}
return
}
if (typeof current === 'object') {
for (const [key, item] of Object.entries(current)) {
visit(prefix ? `${prefix}.${key}` : key, item, depth + 1)
if (rows.length >= limit) break
}
}
}
visit('', value, 0)
return rows
}
function renderKpi(label, value, foot, tone = '') {
return `
<div class="hm-kpi" data-tone="${tone}">
<div class="hm-kpi-label">${esc(label)}</div>
<div class="hm-kpi-value">${esc(value)}</div>
<div class="hm-kpi-foot">${esc(foot)}</div>
</div>
`
}
function renderInfoRow(label, value, mono = false) {
return `
<div class="hm-services-row">
<div class="hm-services-row-label">${esc(label)}</div>
<div class="hm-services-row-value ${mono ? 'is-mono' : ''}">${esc(value)}</div>
</div>
`
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.innerHTML = `
<div class="page-header"><h1>${t('engine.hermesServicesTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('engine.comingSoonPhase2')}
</div></div>
`
el.className = 'page hm-services-page'
el.dataset.engine = 'hermes'
let info = null
let config = null
let health = null
let envData = null
let loading = true
let refreshBusy = false
let actionBusy = false
let targetBusy = false
let envBusy = false
let maintenanceBusy = false
let pageMsg = ''
let pageMsgTone = 'muted'
let connectMsg = ''
let connectMsgTone = 'muted'
let targetMode = 'local'
let customUrl = ''
function syncCustomInput() {
const input = el.querySelector('#hm-services-custom-url')
if (input) customUrl = input.value
}
function syncTargetFromInfo() {
const port = info?.gatewayPort || 8642
const currentUrl = info?.gatewayUrl || `http://127.0.0.1:${port}`
if (isLocalGatewayUrl(currentUrl, port)) {
targetMode = 'local'
customUrl = ''
return
}
if (envData?.wsl2?.gatewayUrl && currentUrl === envData.wsl2.gatewayUrl) {
targetMode = 'wsl2'
customUrl = currentUrl
return
}
targetMode = 'custom'
customUrl = currentUrl
}
function setPageMessage(message, tone = 'muted') {
pageMsg = message
pageMsgTone = tone
}
function setConnectMessage(message, tone = 'muted') {
connectMsg = message
connectMsgTone = tone
}
function draw() {
if (loading) {
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow"><span class="hm-dot hm-dot--idle"></span>${esc(t('engine.servicesEyebrow'))}</div>
<div class="hm-skel" style="width:260px;height:28px;margin-bottom:8px"></div>
<div class="hm-skel" style="width:220px;height:14px"></div>
</div>
</div>
<div class="hm-kpi-grid">
${[1, 2, 3, 4].map(() => `
<div class="hm-kpi">
<div class="hm-skel" style="width:60%;height:10px;margin-bottom:10px"></div>
<div class="hm-skel" style="width:45%;height:22px;margin-bottom:8px"></div>
<div class="hm-skel" style="width:55%;height:10px"></div>
</div>
`).join('')}
</div>
`
return
}
const gwRunning = !!info?.gatewayRunning
const port = info?.gatewayPort || 8642
const version = info?.version || '—'
const gatewayUrl = info?.gatewayUrl || `http://127.0.0.1:${port}`
const model = config?.model || info?.model || health?.model || t('engine.dashNoModel')
const provider = config?.provider || t('engine.servicesUnknown')
const installType = info?.managed || (info?.installed ? 'uv-tool' : t('engine.servicesUnknown'))
const installState = info?.installed ? t('engine.servicesInstalled') : t('engine.servicesMissing')
const llmBaseUrl = config?.base_url || t('engine.servicesNotSet')
const configModel = config?.model_raw || config?.model || info?.model || t('engine.dashNoModel')
const targetLabel = targetMode === 'local'
? t('engine.installModeLocal')
: targetMode === 'custom'
? t('engine.installModeCustom')
: targetMode === 'wsl2'
? 'WSL2'
: 'Docker'
const healthRows = summarizeHealth(health)
const configExists = !!(config?.config_exists || info?.configExists)
const envExists = !!info?.envExists
const customInputVisible = targetMode === 'custom' || targetMode === 'docker'
const targetNote = targetMode === 'local'
? `${t('engine.installModeLocal')} · http://127.0.0.1:${port}`
: targetMode === 'wsl2'
? (envData?.wsl2?.gatewayUrl || t('engine.servicesWslHint'))
: targetMode === 'docker'
? t('engine.servicesDockerHint')
: t('engine.installCustomDesc')
el.innerHTML = `
<div class="hm-hero" data-state="${gwRunning ? 'running' : 'stopped'}">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--${gwRunning ? 'run' : 'stop'}"></span>
${esc(t('engine.servicesEyebrow'))}
</div>
<h1 class="hm-hero-h1">${esc(t('engine.hermesServicesTitle'))}</h1>
<div class="hm-hero-sub">${esc(gatewayUrl)} · ${esc(model)} · v${esc(version)}</div>
</div>
<div class="hm-hero-actions">
${info?.installed && !gwRunning ? `<button class="hm-btn hm-btn--cta hm-btn--sm hm-services-start" ${actionBusy ? 'disabled' : ''}>${ICONS.start}<span>${esc(actionBusy ? t('engine.gatewayStarting') : t('engine.gatewayStartBtn'))}</span></button>` : ''}
${info?.installed && gwRunning ? `<button class="hm-btn hm-btn--danger hm-btn--sm hm-services-stop" ${actionBusy ? 'disabled' : ''}>${ICONS.stop}<span>${esc(actionBusy ? t('engine.dashStopping') : t('engine.dashStopGw'))}</span></button>` : ''}
${info?.installed && gwRunning ? `<button class="hm-btn hm-btn--sm hm-services-restart" ${actionBusy ? 'disabled' : ''}>${ICONS.restart}<span>${esc(actionBusy ? t('engine.dashRestarting') : t('engine.dashRestartGw'))}</span></button>` : ''}
<button class="hm-btn hm-btn--ghost hm-btn--icon hm-services-refresh" title="${esc(t('engine.logsRefresh'))}" ${refreshBusy ? 'disabled' : ''}>${ICONS.refresh}</button>
</div>
</div>
<div class="hm-services-desc">${esc(t('engine.servicesDesc'))}</div>
<div class="hm-kpi-grid">
${renderKpi(t('engine.servicesInstallState'), installState, `${t('engine.servicesInstallType')} · ${installType}`, info?.installed ? 'success' : 'error')}
${renderKpi(t('engine.dashGatewayStatus'), gwRunning ? t('engine.dashRunning') : t('engine.dashStopped'), `:${port}`, gwRunning ? 'success' : 'error')}
${renderKpi(t('engine.dashModel'), model, provider, 'accent')}
${renderKpi(t('engine.dashConnectTarget'), targetLabel, gatewayUrl, 'info')}
</div>
${pageMsg ? `<div class="hm-services-msg" data-tone="${esc(pageMsgTone)}">${esc(pageMsg)}</div>` : ''}
<div class="hm-services-grid">
<section class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.package}</span>
${esc(t('engine.servicesInstallState'))}
</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-services-rows">
${renderInfoRow(t('engine.dashVersion'), `v${version}`)}
${renderInfoRow(t('engine.servicesInstallType'), installType)}
${renderInfoRow(t('engine.servicesPath'), info?.path || t('engine.servicesNotSet'), true)}
${renderInfoRow(t('engine.servicesHome'), info?.hermesHome || t('engine.servicesNotSet'), true)}
</div>
<div class="hm-field-label" style="margin:16px 0 10px">${esc(t('engine.servicesConfigFiles'))}</div>
<div class="hm-pills">
<span class="hm-pill ${configExists ? 'hm-pill--ok' : 'hm-pill--muted'}">config.yaml</span>
<span class="hm-pill ${envExists ? 'hm-pill--ok' : 'hm-pill--muted'}">.env</span>
</div>
</div>
</section>
<section class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.config}</span>
${esc(t('engine.hermesConfigTitle'))}
</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-services-rows">
${renderInfoRow(t('engine.configProvider'), provider)}
${renderInfoRow(t('engine.configModel'), configModel)}
${renderInfoRow(t('engine.configBaseUrl'), llmBaseUrl, true)}
${renderInfoRow(t('engine.configApiKey'), maskSecret(config?.api_key), true)}
</div>
<div class="hm-stack" style="margin-top:14px">
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/config">${esc(t('engine.servicesOpenConfig'))}</a>
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/env">${esc(t('engine.servicesOpenEnv'))}</a>
</div>
</div>
</section>
</div>
<section class="hm-panel hm-services-panel" style="margin-top:16px">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.link}</span>
${esc(t('engine.dashConnectTarget'))}
</div>
<div class="hm-panel-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-services-detect-env" ${envBusy ? 'disabled' : ''}>${esc(envBusy ? t('engine.dashDetecting') : t('engine.dashDetectEnv'))}</button>
</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-pills" style="margin-bottom:12px">
<button class="hm-pill hm-services-mode ${targetMode === 'local' ? 'is-active' : ''}" data-mode="local">${esc(t('engine.installModeLocal'))}</button>
${envData?.wsl2?.available ? `<button class="hm-pill hm-services-mode ${targetMode === 'wsl2' ? 'is-active' : ''}" data-mode="wsl2">WSL2${envData.wsl2.gatewayRunning ? ` · ${esc(t('engine.servicesReadyTag'))}` : ''}</button>` : ''}
${envData?.docker?.available ? `<button class="hm-pill hm-services-mode ${targetMode === 'docker' ? 'is-active' : ''}" data-mode="docker">Docker</button>` : ''}
<button class="hm-pill hm-services-mode ${targetMode === 'custom' ? 'is-active' : ''}" data-mode="custom">${esc(t('engine.installModeCustom'))}</button>
</div>
${customInputVisible ? `
<label class="hm-field" style="margin-bottom:12px">
<span class="hm-field-label">${esc(t('engine.servicesCustomUrl'))}</span>
<input id="hm-services-custom-url" class="hm-input" type="text" value="${esc(customUrl)}" placeholder="http://192.168.1.100:8642">
</label>
` : ''}
<div class="hm-services-note">${esc(targetNote)}</div>
${envData ? `
<div class="hm-services-env-grid">
${envData?.wsl2?.available ? `
<div class="hm-services-env-card">
<div class="hm-services-env-title">WSL2</div>
<div class="hm-services-env-meta">${esc((envData.wsl2.distros || []).join(', ') || t('engine.servicesDefaultDistro'))}</div>
<div class="hm-services-env-meta">${esc(envData.wsl2.ip || '—')}</div>
<div class="hm-services-env-meta">${esc(envData.wsl2.gatewayRunning ? (envData.wsl2.gatewayUrl || '') : t('engine.servicesWslHint'))}</div>
</div>
` : ''}
${envData?.docker?.available ? `
<div class="hm-services-env-card">
<div class="hm-services-env-title">Docker</div>
<div class="hm-services-env-meta">${esc(envData.docker.version || '—')}</div>
<div class="hm-services-env-meta">${esc(t('engine.servicesContainerCount', { n: String(envData.docker.hermesContainers?.length || 0) }))}</div>
<div class="hm-services-env-meta">${esc(t('engine.servicesDockerHint'))}</div>
</div>
` : ''}
</div>
` : ''}
<div class="hm-stack" style="margin-top:14px">
<button class="hm-btn hm-btn--primary hm-btn--sm hm-services-apply-target" ${targetBusy ? 'disabled' : ''}>${esc(t('engine.dashConnApply'))}</button>
${connectMsg ? `<span class="hm-services-inline-msg" data-tone="${esc(connectMsgTone)}">${esc(connectMsg)}</span>` : ''}
</div>
</div>
</section>
<section class="hm-panel hm-services-panel" style="margin-top:16px">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.health}</span>
${esc(t('engine.servicesHealthTitle'))}
</div>
<div class="hm-panel-actions">
<span class="hm-pill ${gwRunning ? 'hm-pill--ok' : 'hm-pill--muted'}">${esc(gwRunning ? t('engine.dashRunning') : t('engine.dashStopped'))}</span>
</div>
</div>
<div class="hm-panel-body">
${healthRows.length ? `
<div class="hm-services-health-grid">
${healthRows.map(row => `
<div class="hm-services-health-card">
<div class="hm-services-health-key">${esc(row.key)}</div>
<div class="hm-services-health-value">${esc(row.value)}</div>
</div>
`).join('')}
</div>
<details class="hm-services-json-wrap">
<summary>${esc(t('engine.servicesRawJson'))}</summary>
<pre class="hm-term hm-services-json">${esc(JSON.stringify(health, null, 2))}</pre>
</details>
` : `
<div class="hm-services-empty">${esc(t('engine.servicesNoHealth'))}</div>
`}
</div>
</section>
<section class="hm-panel hm-services-panel" style="margin-top:16px">
<div class="hm-panel-header">
<div class="hm-panel-title">
<span class="hm-panel-title-icon">${ICONS.upload}</span>
${esc(t('engine.servicesMaintenance'))}
</div>
</div>
<div class="hm-panel-body">
<div class="hm-services-action-grid">
<button class="hm-btn hm-btn--primary hm-btn--sm hm-services-upgrade" ${maintenanceBusy || !info?.installed ? 'disabled' : ''}>${ICONS.upload}<span>${esc(t('engine.servicesUpgrade'))}</span></button>
<button class="hm-btn hm-btn--sm hm-services-uninstall" ${maintenanceBusy || !info?.installed ? 'disabled' : ''}>${ICONS.trash}<span>${esc(t('engine.servicesUninstall'))}</span></button>
<button class="hm-btn hm-btn--danger hm-btn--sm hm-services-uninstall-clean" ${maintenanceBusy || !info?.installed ? 'disabled' : ''}>${ICONS.trash}<span>${esc(t('engine.servicesUninstallClean'))}</span></button>
</div>
<div class="hm-stack" style="margin-top:14px">
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/logs">${esc(t('engine.servicesOpenLogs'))}</a>
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/config">${esc(t('engine.servicesOpenConfig'))}</a>
<a class="hm-btn hm-btn--ghost hm-btn--sm" href="#/h/setup">${esc(t('engine.servicesOpenSetup'))}</a>
</div>
</div>
</section>
`
bind()
}
async function refresh(withSpinner = true) {
if (withSpinner) {
refreshBusy = true
if (!loading) draw()
}
invalidate('check_hermes')
try {
info = await api.checkHermes()
if (info?.gatewayRunning) {
try {
health = await api.hermesHealthCheck()
} catch (error) {
health = null
setPageMessage(stripError(error), 'warn')
}
} else {
health = null
}
try {
config = await api.hermesReadConfig()
} catch (_) {
config = null
}
syncTargetFromInfo()
} catch (error) {
setPageMessage(stripError(error), 'error')
} finally {
loading = false
refreshBusy = false
draw()
}
}
async function runGatewayAction(action) {
if (actionBusy) return
actionBusy = true
setPageMessage(
action === 'start'
? t('engine.gatewayStarting')
: action === 'restart'
? t('engine.dashRestarting')
: t('engine.dashStopping'),
'muted'
)
draw()
try {
if (action === 'restart') {
try { await api.hermesGatewayAction('stop') } catch (_) {}
await new Promise(resolve => setTimeout(resolve, 1200))
const result = await api.hermesGatewayAction('start')
setPageMessage(result || t('engine.dashRestartGw'), 'success')
} else {
const result = await api.hermesGatewayAction(action)
setPageMessage(result || action, 'success')
}
} catch (error) {
setPageMessage(stripError(error), 'error')
}
actionBusy = false
await refresh(false)
}
async function detectEnvironments() {
if (envBusy) return
envBusy = true
draw()
try {
envData = await api.hermesDetectEnvironments()
if (info) syncTargetFromInfo()
setConnectMessage('', 'muted')
} catch (error) {
setConnectMessage(stripError(error), 'error')
}
envBusy = false
draw()
}
async function applyTarget() {
if (targetBusy) return
syncCustomInput()
let targetUrl = null
if (targetMode === 'wsl2') {
targetUrl = envData?.wsl2?.gatewayUrl || null
if (!targetUrl) {
setConnectMessage(t('engine.servicesDetectFirst'), 'warn')
draw()
return
}
} else if (targetMode === 'docker') {
targetUrl = customUrl.trim() || null
if (!targetUrl) {
setConnectMessage(t('engine.servicesDockerHint'), 'warn')
draw()
return
}
} else if (targetMode === 'custom') {
targetUrl = customUrl.trim() || null
if (!targetUrl) {
setConnectMessage(t('engine.installCustomEmpty'), 'warn')
draw()
return
}
}
targetBusy = true
draw()
try {
const result = await api.hermesSetGatewayUrl(targetUrl)
setConnectMessage(result, 'success')
setPageMessage(result, 'success')
} catch (error) {
setConnectMessage(stripError(error), 'error')
}
targetBusy = false
await refresh(false)
}
async function runMaintenance(kind) {
if (maintenanceBusy) return
const confirmText = kind === 'upgrade'
? t('engine.servicesConfirmUpgrade')
: kind === 'uninstall-clean'
? t('engine.servicesConfirmUninstallClean')
: t('engine.servicesConfirmUninstall')
if (!confirm(confirmText)) return
maintenanceBusy = true
setPageMessage(kind === 'upgrade' ? t('engine.servicesUpgrade') : t('engine.servicesUninstall'), 'muted')
draw()
try {
const result = kind === 'upgrade'
? await api.updateHermes()
: await api.uninstallHermes(kind === 'uninstall-clean')
setPageMessage(result, 'success')
invalidate('check_hermes')
await refresh(false)
if (kind !== 'upgrade' && !info?.installed) {
window.location.hash = '#/h/setup'
}
} catch (error) {
setPageMessage(stripError(error), 'error')
}
maintenanceBusy = false
draw()
}
function bind() {
el.querySelector('.hm-services-refresh')?.addEventListener('click', () => refresh())
el.querySelector('.hm-services-start')?.addEventListener('click', () => runGatewayAction('start'))
el.querySelector('.hm-services-stop')?.addEventListener('click', () => runGatewayAction('stop'))
el.querySelector('.hm-services-restart')?.addEventListener('click', () => runGatewayAction('restart'))
el.querySelector('.hm-services-detect-env')?.addEventListener('click', detectEnvironments)
el.querySelector('.hm-services-apply-target')?.addEventListener('click', applyTarget)
el.querySelectorAll('.hm-services-mode').forEach(button => {
button.addEventListener('click', () => {
syncCustomInput()
targetMode = button.dataset.mode
if (targetMode === 'wsl2' && envData?.wsl2?.gatewayUrl) customUrl = envData.wsl2.gatewayUrl
if (targetMode === 'local') customUrl = ''
draw()
})
})
el.querySelector('#hm-services-custom-url')?.addEventListener('input', (event) => {
customUrl = event.target.value
})
el.querySelector('.hm-services-upgrade')?.addEventListener('click', () => runMaintenance('upgrade'))
el.querySelector('.hm-services-uninstall')?.addEventListener('click', () => runMaintenance('uninstall'))
el.querySelector('.hm-services-uninstall-clean')?.addEventListener('click', () => runMaintenance('uninstall-clean'))
}
draw()
refresh()
return el
}

View File

@@ -0,0 +1,474 @@
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { showConfirm } from '../../../components/modal.js'
import { icon } from '../../../lib/icons.js'
import { getChatStore, getSourceLabel } from '../lib/chat-store.js'
function escHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escAttr(value) {
return escHtml(value).replace(/'/g, '&#39;')
}
function parseEpochMs(value) {
if (!value) return 0
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? Math.round(value * 1000) : Math.round(value)
}
const ts = Date.parse(String(value))
return Number.isFinite(ts) ? ts : 0
}
function formatTime(value) {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return '—'
const diff = Date.now() - d.getTime()
if (diff < 60_000) return t('engine.sessionsJustNow')
if (diff < 3_600_000) return t('engine.sessionsMinutesAgo').replace('{n}', String(Math.max(1, Math.floor(diff / 60_000))))
if (diff < 86_400_000) return t('engine.sessionsHoursAgo').replace('{n}', String(Math.max(1, Math.floor(diff / 3_600_000))))
return d.toLocaleString()
}
function sessionKey(session) {
return `${session.profile || 'default'}::${session.id}`
}
function sessionTitle(session) {
return session?.title || session?.messages?.find(m => m.role === 'user')?.content?.slice(0, 64) || t('engine.sessionsUntitled')
}
function messagePreview(session) {
const first = session?.messages?.find(m => m.role === 'user') || session?.messages?.[0]
return first?.content ? String(first.content).replace(/\s+/g, ' ').slice(0, 180) : (session?.preview || t('engine.sessionsNoPreview'))
}
function tokenCount(session) {
return Number(session?.inputTokens || 0) + Number(session?.outputTokens || 0)
}
function formatTokens(value) {
const n = Number(value || 0)
if (!n) return '0'
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(Math.round(n))
}
function normalizeMessage(m) {
const raw = m?.content ?? m?.toolResult ?? m?.toolArgs ?? ''
return {
role: m?.role || 'message',
content: typeof raw === 'string' ? raw : JSON.stringify(raw),
timestamp: m?.timestamp || m?.created_at || '',
}
}
function mapSessionSummary(s, profile) {
return {
id: s?.id || s?.session_id || '',
profile: profile || 'default',
title: s?.title || '',
source: s?.source || '',
model: s?.model || '',
preview: s?.preview || '',
lastActiveLabel: s?.last_active_label || '',
messageCount: Number(s?.message_count || s?.messageCount || 0),
createdAt: parseEpochMs(s?.created_at || s?.started_at || s?.createdAt),
updatedAt: parseEpochMs(s?.updated_at || s?.last_active || s?.ended_at || s?.created_at || s?.started_at || s?.updatedAt),
inputTokens: Number(s?.input_tokens || s?.inputTokens || 0),
outputTokens: Number(s?.output_tokens || s?.outputTokens || 0),
messages: Array.isArray(s?.messages) ? s.messages.map(normalizeMessage) : [],
messagesLoaded: Array.isArray(s?.messages),
}
}
function getFilteredSessions(rows, query, source) {
const q = (query || '').trim().toLowerCase()
let sessions = rows.slice()
if (source !== '__all__') sessions = sessions.filter(s => (s.source || '') === source)
if (q) {
sessions = sessions.filter(s => {
const hay = [s.id, s.profile, s.title, s.model, s.source, ...(s.messages || []).slice(0, 3).map(m => m.content)].join('\n').toLowerCase()
return hay.includes(q)
})
}
return sessions.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0))
}
function uniqueSources(sessions) {
return Array.from(new Set(sessions.map(s => s.source || ''))).sort((a, b) => getSourceLabel(a).localeCompare(getSourceLabel(b)))
}
export function render() {
const el = document.createElement('div')
el.className = 'page hm-sessions-page'
el.dataset.engine = 'hermes'
const store = getChatStore()
let query = ''
let source = '__all__'
let profileScope = store.state.activeProfile || 'default'
let rows = []
let selectedKey = null
let selected = new Set()
let loading = false
let busy = false
let detailLoadingKey = null
const unsubscribe = store.subscribe(() => draw())
function availableProfiles() {
const profiles = store.state.profiles || []
if (profiles.length) return profiles.map(p => p.name).filter(Boolean)
return [store.state.activeProfile || 'default']
}
function targetProfiles() {
return profileScope === '__all__' ? availableProfiles() : [profileScope]
}
function currentSessions() {
return getFilteredSessions(rows, query, source)
}
function findByKey(key) {
return rows.find(s => sessionKey(s) === key) || null
}
function currentSession() {
return findByKey(selectedKey) || currentSessions()[0] || null
}
async function loadRows() {
loading = true
draw()
try {
const profiles = targetProfiles()
const settled = await Promise.allSettled(profiles.map(async (profile) => {
const list = await api.hermesSessionsSummaryList(null, 80, profile)
return (Array.isArray(list) ? list : []).map(s => mapSessionSummary(s, profile)).filter(s => s.id)
}))
rows = settled.flatMap(r => r.status === 'fulfilled' ? r.value : [])
const failed = settled.filter(r => r.status === 'rejected').length
if (failed) toast(t('engine.sessionsProfileLoadPartial').replace('{n}', String(failed)), 'warning')
const visible = currentSessions()
selected = new Set([...selected].filter(key => rows.some(s => sessionKey(s) === key)))
selectedKey = selectedKey && rows.some(s => sessionKey(s) === selectedKey) ? selectedKey : (visible[0] ? sessionKey(visible[0]) : null)
} catch (err) {
toast(String(err?.message || err), 'error')
} finally {
loading = false
draw()
}
}
async function loadDetail(key, redraw = true) {
const session = findByKey(key)
if (!session || session.messagesLoaded || detailLoadingKey === key) return
detailLoadingKey = key
if (redraw) draw()
try {
const detail = await api.hermesSessionDetail(session.id, session.profile)
session.messages = Array.isArray(detail?.messages) ? detail.messages.map(normalizeMessage) : []
session.messagesLoaded = true
session.title = session.title || detail?.title || ''
session.model = session.model || detail?.model || ''
session.source = session.source || detail?.source || ''
session.messageCount = session.messageCount || session.messages.length
} catch (err) {
toast(t('engine.sessionsDetailLoadFailed') + ': ' + (err?.message || err), 'error')
} finally {
detailLoadingKey = null
if (redraw) draw()
}
}
function renderProfileBar() {
const profiles = availableProfiles()
return `
<select class="hm-sessions-profile-select" id="hm-sessions-profile">
<option value="__all__" ${profileScope === '__all__' ? 'selected' : ''}>${escHtml(t('engine.sessionsAllProfiles'))}</option>
${profiles.map(name => `<option value="${escAttr(name)}" ${profileScope === name ? 'selected' : ''}>${escHtml(name)}${name === store.state.activeProfile ? ' · active' : ''}</option>`).join('')}
</select>
`
}
function renderSessionRow(s) {
const key = sessionKey(s)
const checked = selected.has(key)
const active = currentSession() && sessionKey(currentSession()) === key
const pinned = s.profile === store.state.activeProfile && store.state.pinned.has(s.id)
const tokens = tokenCount(s)
return `
<button class="hm-session-row ${active ? 'is-active' : ''} ${checked ? 'is-selected' : ''}" data-session-key="${escAttr(key)}">
<span class="hm-session-row-check" data-check-id="${escAttr(key)}">${icon(checked ? 'check-circle' : 'circle', 16)}</span>
<span class="hm-session-row-main">
<span class="hm-session-row-title">${pinned ? icon('crown', 12) : ''}${escHtml(sessionTitle(s))}</span>
<span class="hm-session-row-preview">${escHtml(messagePreview(s))}</span>
<span class="hm-session-row-meta">
<span>${escHtml(s.profile || 'default')}</span>
<span>${escHtml(getSourceLabel(s.source || ''))}</span>
${s.model ? `<span>${escHtml(s.model)}</span>` : ''}
<span>${formatTokens(tokens)} tok</span>
</span>
</span>
<span class="hm-session-row-time">${escHtml(s.lastActiveLabel || formatTime(s.updatedAt || s.createdAt))}</span>
</button>
`
}
function renderDetail(session) {
if (!session) {
return `
<section class="hm-session-detail is-empty">
${icon('message-square', 34)}
<h3>${escHtml(t('engine.sessionsNoSelection'))}</h3>
<p>${escHtml(t('engine.sessionsNoSelectionDesc'))}</p>
</section>
`
}
const key = sessionKey(session)
const messages = (session.messages || []).slice(-30)
const canPin = session.profile === store.state.activeProfile
return `
<section class="hm-session-detail">
<div class="hm-session-detail-head">
<div>
<div class="hm-session-detail-kicker">${escHtml(session.profile || 'default')} · ${escHtml(getSourceLabel(session.source || ''))}</div>
<h2>${escHtml(sessionTitle(session))}</h2>
<div class="hm-session-detail-id">${escHtml(session.id)}</div>
</div>
<div class="hm-session-detail-actions">
<button class="hm-sessions-btn" id="hm-session-open-chat">${icon('message-circle', 14)}${escHtml(t('engine.sessionsOpenChat'))}</button>
${canPin ? `<button class="hm-sessions-btn" id="hm-session-pin">${icon(store.state.pinned.has(session.id) ? 'crown' : 'target', 14)}${escHtml(store.state.pinned.has(session.id) ? t('engine.sessionsUnpin') : t('engine.sessionsPin'))}</button>` : ''}
<button class="hm-sessions-btn is-danger" id="hm-session-delete" data-session-key="${escAttr(key)}">${icon('trash', 14)}${escHtml(t('engine.chatDeleteSession'))}</button>
</div>
</div>
<div class="hm-session-stat-grid">
<div><span>${escHtml(t('engine.sessionsMessages'))}</span><strong>${Number(session.messageCount || session.messages?.length || 0)}</strong></div>
<div><span>${escHtml(t('engine.sessionsTokens'))}</span><strong>${formatTokens(tokenCount(session))}</strong></div>
<div><span>${escHtml(t('engine.sessionsModel'))}</span><strong>${escHtml(session.model || '—')}</strong></div>
<div><span>${escHtml(t('engine.sessionsUpdated'))}</span><strong>${escHtml(session.lastActiveLabel || formatTime(session.updatedAt || session.createdAt))}</strong></div>
</div>
<div class="hm-session-message-list">
${detailLoadingKey === key ? `<div class="hm-session-empty-messages">${escHtml(t('engine.chatLoadingMessages'))}</div>` : ''}
${detailLoadingKey !== key && messages.length ? messages.map(m => `
<article class="hm-session-msg hm-session-msg--${escAttr(m.role || 'unknown')}">
<div class="hm-session-msg-role">${escHtml(m.role || 'message')}</div>
<div class="hm-session-msg-body">${escHtml(m.content || '')}</div>
</article>
`).join('') : ''}
${detailLoadingKey !== key && !messages.length ? `<div class="hm-session-empty-messages">${escHtml(t('engine.sessionsMessagesNotLoaded'))}</div>` : ''}
</div>
</section>
`
}
function draw() {
const sessions = currentSessions()
const detail = currentSession()
const sources = uniqueSources(rows)
const allVisibleSelected = sessions.length > 0 && sessions.every(s => selected.has(sessionKey(s)))
el.innerHTML = `
<div class="hm-sessions-hero">
<div>
<div class="hm-sessions-eyebrow">HERMES · SESSIONS</div>
<h1>${escHtml(t('engine.sessionsPageTitle'))}</h1>
<p>${escHtml(t('engine.sessionsPageDesc'))}</p>
</div>
<div class="hm-sessions-hero-actions">
${renderProfileBar()}
<button class="hm-sessions-btn" id="hm-sessions-refresh" ${busy || loading ? 'disabled' : ''}>${icon('refresh-cw', 14)}${escHtml(t('skills.refresh'))}</button>
<button class="hm-sessions-btn is-ghost" id="hm-sessions-open-chat">${icon('message-circle', 14)}${escHtml(t('engine.chatSessions'))}</button>
</div>
</div>
<div class="hm-sessions-stats">
<div><span>${escHtml(t('engine.sessionsTotal'))}</span><strong>${rows.length}</strong></div>
<div><span>${escHtml(t('engine.sessionsShown'))}</span><strong>${sessions.length}</strong></div>
<div><span>${escHtml(t('engine.sessionsProfiles'))}</span><strong>${targetProfiles().length}</strong></div>
<div><span>${escHtml(t('engine.sessionsSelected'))}</span><strong>${selected.size}</strong></div>
</div>
<div class="hm-sessions-shell">
<aside class="hm-sessions-list-panel">
<div class="hm-sessions-toolbar">
<label class="hm-sessions-search">
${icon('search', 14)}
<input id="hm-sessions-query" value="${escAttr(query)}" placeholder="${escAttr(t('engine.sessionsSearchPlaceholder'))}">
</label>
<select id="hm-sessions-source">
<option value="__all__" ${source === '__all__' ? 'selected' : ''}>${escHtml(t('engine.sessionsAllSources'))}</option>
${sources.map(src => `<option value="${escAttr(src)}" ${source === src ? 'selected' : ''}>${escHtml(getSourceLabel(src))}</option>`).join('')}
</select>
</div>
<div class="hm-sessions-bulkbar">
<button id="hm-sessions-select-all">${icon(allVisibleSelected ? 'x' : 'check', 13)}${escHtml(allVisibleSelected ? t('engine.chatSelectNone') : t('engine.chatSelectAll'))}</button>
<button id="hm-sessions-bulk-delete" class="is-danger" ${selected.size ? '' : 'disabled'}>${icon('trash', 13)}${escHtml(t('engine.chatBulkDelete'))}</button>
</div>
<div class="hm-sessions-list">
${loading ? `<div class="hm-sessions-loading">${escHtml(t('engine.chatLoading'))}</div>` : ''}
${!loading && !sessions.length ? `<div class="hm-sessions-empty">${escHtml(t('engine.sessionsEmpty'))}</div>` : ''}
${sessions.map(renderSessionRow).join('')}
</div>
</aside>
${renderDetail(detail)}
</div>
`
bind()
}
async function openCurrentInChat() {
const session = currentSession()
if (!session) return
try {
busy = true
draw()
if (session.profile !== store.state.activeProfile) {
await store.switchProfile(session.profile)
}
if (!store.state.sessions.some(s => s.id === session.id)) {
await store.loadSessions()
}
await store.switchSession(session.id)
window.location.hash = '#/h/chat'
} catch (err) {
toast(String(err?.message || err), 'error')
} finally {
busy = false
draw()
}
}
async function deleteOne(session) {
if (!session) return
const ok = await showConfirm(t('engine.chatConfirmDelete'))
if (!ok) return
try {
if (session.profile === store.state.activeProfile && store.state.streaming && session.id === store.state.runningSessionId) {
throw new Error('RUNNING_SESSION')
}
await api.hermesSessionDelete(session.id, session.profile)
rows = rows.filter(s => sessionKey(s) !== sessionKey(session))
selected.delete(sessionKey(session))
selectedKey = null
if (session.profile === store.state.activeProfile) await store.loadSessions()
toast(t('engine.chatSessionDeleted'), 'success')
} catch (err) {
toast(t('engine.chatDeleteFailed') + ': ' + (err?.message || err), 'error')
}
draw()
}
function bind() {
el.querySelector('#hm-sessions-refresh')?.addEventListener('click', async () => {
busy = true
draw()
try { await loadRows() }
finally { busy = false; draw() }
})
el.querySelector('#hm-sessions-open-chat')?.addEventListener('click', () => { window.location.hash = '#/h/chat' })
el.querySelector('#hm-session-open-chat')?.addEventListener('click', openCurrentInChat)
el.querySelector('#hm-sessions-query')?.addEventListener('input', (e) => {
query = e.target.value
selectedKey = currentSessions()[0] ? sessionKey(currentSessions()[0]) : null
draw()
})
el.querySelector('#hm-sessions-source')?.addEventListener('change', (e) => {
source = e.target.value
selectedKey = currentSessions()[0] ? sessionKey(currentSessions()[0]) : null
draw()
})
el.querySelector('#hm-sessions-profile')?.addEventListener('change', async (e) => {
profileScope = e.target.value
selected.clear()
selectedKey = null
await loadRows()
})
el.querySelectorAll('[data-session-key]').forEach(row => {
row.addEventListener('click', async (e) => {
const key = row.dataset.sessionKey
if (!key) return
if (e.target.closest('[data-check-id]')) {
if (selected.has(key)) selected.delete(key)
else selected.add(key)
draw()
return
}
selectedKey = key
draw()
await loadDetail(key)
})
})
el.querySelector('#hm-sessions-select-all')?.addEventListener('click', () => {
const sessions = currentSessions()
const allVisibleSelected = sessions.length > 0 && sessions.every(s => selected.has(sessionKey(s)))
if (allVisibleSelected) sessions.forEach(s => selected.delete(sessionKey(s)))
else sessions.forEach(s => selected.add(sessionKey(s)))
draw()
})
el.querySelector('#hm-sessions-bulk-delete')?.addEventListener('click', async () => {
if (!selected.size) return
const targets = [...selected].map(findByKey).filter(Boolean)
const ok = await showConfirm(t('engine.chatConfirmBulkDelete').replace('{n}', String(targets.length)))
if (!ok) return
const deleted = []
const failed = []
for (const session of targets) {
try {
if (session.profile === store.state.activeProfile && store.state.streaming && session.id === store.state.runningSessionId) {
throw new Error('RUNNING_SESSION')
}
await api.hermesSessionDelete(session.id, session.profile)
deleted.push(sessionKey(session))
} catch (err) {
failed.push({ session, err })
}
}
rows = rows.filter(s => !deleted.includes(sessionKey(s)))
selected.clear()
if (deleted.length && targets.some(s => s.profile === store.state.activeProfile)) await store.loadSessions()
if (deleted.length && !failed.length) {
toast(t('engine.chatBulkDeleted').replace('{n}', String(deleted.length)), 'success')
} else if (deleted.length) {
toast(t('engine.chatBulkPartial').replace('{n}', String(deleted.length)).replace('{f}', String(failed.length)), 'warning')
} else {
toast(t('engine.chatBulkFailed'), 'error')
}
draw()
})
el.querySelector('#hm-session-pin')?.addEventListener('click', () => {
const session = currentSession()
if (!session || session.profile !== store.state.activeProfile) return
store.togglePinned(session.id)
draw()
})
el.querySelector('#hm-session-delete')?.addEventListener('click', async () => {
await deleteOne(currentSession())
})
}
async function init() {
await store.loadProfiles().catch(() => {})
profileScope = store.state.activeProfile || 'default'
await loadRows()
}
requestAnimationFrame(() => { draw(); init() })
const observer = new MutationObserver(() => {
if (!el.isConnected) {
unsubscribe()
observer.disconnect()
}
})
requestAnimationFrame(() => { if (el.parentNode) observer.observe(el.parentNode, { childList: true }) })
return el
}

View File

@@ -33,6 +33,7 @@ let hermesGroups = { apiKeyIntl: [], apiKeyCn: [], aggregators: [], oauth: [], e
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
// 状态
let phase = 'detect' // detect | install | configure | gateway | complete

View File

@@ -1,37 +1,115 @@
/**
* Hermes Agent Skills 浏览器
* 从 ~/.hermes/skills/ 读取技能文件,按分类展示,支持搜索和详情查看
* Hermes Agent Skills browser (editorial luxury re-write)
*
* Mirrors the official `hermes-web-ui` Skills view:
* GET /api/hermes/skills → { categories: [...] }
* PUT /api/hermes/skills/toggle → enable/disable
* GET /api/hermes/skills/:cat/:skill/files → attached files
* GET /api/hermes/skills/<path> → file content
*
* Layout:
* ┌ hero ───────────────────────────────────────────────────┐
* │ eyebrow + big-serif title + search + skill count │
* ├─ sidebar (categories + skills) ┬─ detail (markdown + files)
* │ collapsible, toggle switches │ breadcrumb when viewing
* │ │ an attached file
* └────────────────────────────────┴──────────────────────────┘
*
* Extras beyond the official UI:
* - Collapsible categories (persist in memory only)
* - File browser with breadcrumb + back button (Vue parity)
* - Inline toggle switches use stable loading state per skill
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function escHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* Minimal, dependency-free Markdown renderer. Matches the feature-set used
* across Hermes pages (memory/skills) so the look is consistent. Supports:
* - fenced code blocks (```lang\ncode```)
* - inline `code`, **bold**, *italic*
* - `# / ## / ### / ####` headings
* - unordered list (`- item`) → `<li>`
* - `[text](url)` → `<a>`
* Anything else is escaped and rendered as plain text with `<br>` for newlines.
*/
function mdToHtml(text) {
return text
if (!text) return ''
// First pass: extract code blocks so inner contents aren't mangled by other
// replacers. We keep a placeholder token and restore at the end.
const blocks = []
let out = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
const idx = blocks.push({ lang, code }) - 1
return `\u0000CODEBLOCK_${idx}\u0000`
})
out = out
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^#### (.+)$/gm, '<h5>$1</h5>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
.replace(/^(?:\s*[-*]\s+(.+))(?:\n\s*[-*]\s+(.+))*/gm, (m) =>
'<ul>' + m.trim().split(/\n\s*[-*]\s+/).map(li => `<li>${li.replace(/^[-*]\s+/, '')}</li>`).join('') + '</ul>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/\n/g, '<br>')
// Restore code blocks.
out = out.replace(/\u0000CODEBLOCK_(\d+)\u0000/g, (_, i) => {
const { lang, code } = blocks[Number(i)]
return `<pre><code class="lang-${escHtml(lang)}">${escHtml(code)}</code></pre>`
})
return `<p>${out}</p>`
}
const ICONS = {
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="11" height="11"><polyline points="6 9 12 15 18 9"/></svg>',
back: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><polyline points="15 18 9 12 15 6"/></svg>',
file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" width="13" height="13"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" width="13" height="13"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
empty: '<svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" opacity="0.35"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
}
/** Cross-platform basename (handles `/` and `\\`). */
function basename(p) {
if (!p) return ''
const s = String(p).replace(/\\/g, '/')
const idx = s.lastIndexOf('/')
return idx >= 0 ? s.slice(idx + 1) : s
}
export function render() {
const el = document.createElement('div')
el.className = 'hermes-skills-page'
el.dataset.engine = 'hermes'
let categories = []
// --- State ---
let categories = [] // [{ category, description, skills: [...] }]
let loading = true
let searchQuery = ''
let activeSkill = null // { category, file, name, path }
let collapsed = new Set() // collapsed category names
let toggling = new Set() // slugs currently being toggled
let activeSkill = null // the selected `{ category, file, name, slug, description, path, isDir, enabled }`
let skillContent = ''
let loadingDetail = false
let files = [] // attached files (excluding SKILL.md)
let viewingFile = null // relative path when browsing an attached file
let fileContent = ''
let loadingFile = false
// ============================================================ loaders
async function loadSkills() {
loading = true
draw()
@@ -40,6 +118,7 @@ export function render() {
} catch (e) {
console.error('Failed to load skills:', e)
categories = []
toast(t('engine.skillsLoadFailed') + ': ' + (e?.message || e), 'error')
}
loading = false
draw()
@@ -48,96 +127,330 @@ export function render() {
async function loadDetail(skill) {
activeSkill = skill
loadingDetail = true
viewingFile = null
fileContent = ''
files = []
skillContent = ''
draw()
try {
skillContent = await api.hermesSkillDetail(skill.path)
} catch (e) {
skillContent = `⚠️ ${e.message || e}`
}
// Kick off attached-file listing in parallel when the skill lives in a
// directory (`isDir = true`). Legacy flat skills have no attached files.
const contentPromise = api.hermesSkillDetail(skill.path)
.then(c => { skillContent = c })
.catch(e => { skillContent = `⚠️ ${t('engine.skillsLoadFailed')}: ${e?.message || e}` })
const filesPromise = skill.isDir && skill.category && skill.category !== '_root'
? api.hermesSkillFiles(skill.category, skill.slug || skill.file)
.then(list => { files = (list || []).filter(f => !f.isDir) })
.catch(() => { files = [] })
: Promise.resolve()
await Promise.all([contentPromise, filesPromise])
loadingDetail = false
draw()
}
async function openFile(relPath) {
if (!activeSkill?.isDir || !activeSkill.category) return
viewingFile = relPath
loadingFile = true
fileContent = ''
draw()
try {
const dir = activeSkill.skill_dir ||
(activeSkill.path ? activeSkill.path.replace(/[\\/]SKILL\.md$/i, '') : '')
const sep = /\\/.test(dir) && !/\//.test(dir) ? '\\' : '/'
const full = dir ? `${dir}${sep}${relPath.replace(/\//g, sep)}` : relPath
fileContent = await api.hermesSkillDetail(full)
} catch (e) {
fileContent = `⚠️ ${t('engine.skillsFileLoadFailed')}: ${e?.message || e}`
}
loadingFile = false
draw()
}
function backToSkill() {
viewingFile = null
fileContent = ''
draw()
}
async function handleToggle(skill, nextEnabled) {
if (toggling.has(skill.slug)) return
toggling.add(skill.slug)
draw()
try {
await api.hermesSkillToggle(skill.slug, nextEnabled)
skill.enabled = nextEnabled
toast(
nextEnabled ? t('engine.skillsEnabled') : t('engine.skillsDisabled'),
'success',
)
} catch (e) {
toast(t('engine.skillsToggleFailed') + ': ' + (e?.message || e), 'error')
} finally {
toggling.delete(skill.slug)
draw()
}
}
// ============================================================ derived
function filteredCategories() {
if (!searchQuery) return categories
const q = searchQuery.toLowerCase()
return categories.map(cat => ({
...cat,
skills: cat.skills.filter(s =>
s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
)
})).filter(cat => cat.skills.length > 0)
(s.name || '').toLowerCase().includes(q) ||
(s.slug || '').toLowerCase().includes(q) ||
(s.description || '').toLowerCase().includes(q),
),
})).filter(cat => cat.skills.length > 0 || (cat.category || '').toLowerCase().includes(q))
}
function totalSkillCount() {
return categories.reduce((sum, cat) => sum + cat.skills.length, 0)
}
function draw() {
function enabledSkillCount() {
return categories.reduce(
(sum, cat) => sum + cat.skills.filter(s => s.enabled !== false).length,
0,
)
}
// ============================================================ render
function renderSkillItem(cat, s) {
const isActive = activeSkill?.path === s.path
const isToggling = toggling.has(s.slug)
const isEnabled = s.enabled !== false
return `
<button class="hm-skill-item ${isActive ? 'is-active' : ''} ${!isEnabled ? 'is-disabled' : ''}"
data-path="${escHtml(s.path)}">
<div class="hm-skill-info">
<div class="hm-skill-name">${escHtml(s.name)}</div>
${s.description ? `<div class="hm-skill-desc">${escHtml(s.description)}</div>` : ''}
</div>
<label class="hm-switch ${isEnabled ? 'is-on' : ''} ${isToggling ? 'is-busy' : ''}"
data-slug="${escHtml(s.slug)}" data-category="${escHtml(cat.category)}"
title="${isEnabled ? t('engine.skillsDisable') : t('engine.skillsEnable')}">
<span class="hm-switch-track"></span>
<span class="hm-switch-thumb"></span>
</label>
</button>
`
}
function renderCategory(cat) {
const name = cat.category === '_root' ? t('engine.skillsUncategorized') : cat.category
const isCollapsed = collapsed.has(cat.category)
return `
<div class="hm-skill-category">
<button class="hm-skill-cat-header ${isCollapsed ? 'is-collapsed' : ''}" data-cat="${escHtml(cat.category)}">
<span class="hm-skill-cat-arrow">${ICONS.chevron}</span>
<span class="hm-skill-cat-name">${escHtml(name)}</span>
<span class="hm-skill-cat-count">${cat.skills.length}</span>
</button>
${!isCollapsed ? `
${cat.description ? `<div class="hm-skill-cat-desc">${escHtml(cat.description)}</div>` : ''}
<div class="hm-skill-cat-items">
${cat.skills.map(s => renderSkillItem(cat, s)).join('')}
</div>
` : ''}
</div>
`
}
function renderSidebar() {
const filtered = filteredCategories()
el.innerHTML = `
<div class="hm-skills-header">
<span class="hm-skills-header-title">${t('engine.hermesSkillsTitle')}</span>
<div class="hm-skills-header-right">
<input type="text" id="hm-skills-search" class="hm-skills-header-search" placeholder="${t('engine.skillsSearch')}" value="${escHtml(searchQuery)}">
<span class="hm-skills-count">${totalSkillCount()} ${t('engine.skillsTotal')}</span>
return `
<aside class="hm-skills-sidebar">
<div class="hm-skills-sidebar-search">
<span class="hm-skills-search-icon">${ICONS.search}</span>
<input type="text" id="hm-skills-search" class="hm-skills-search-input"
placeholder="${t('engine.skillsSearch')}" value="${escHtml(searchQuery)}">
</div>
<div class="hm-skills-sidebar-scroll">
${loading ? `
<div class="hm-skills-loading">
<div class="hm-skel" style="height:18px;width:60%;margin-bottom:10px"></div>
<div class="hm-skel" style="height:14px;width:85%;margin-bottom:6px"></div>
<div class="hm-skel" style="height:14px;width:70%;margin-bottom:6px"></div>
<div class="hm-skel" style="height:14px;width:90%"></div>
</div>
` : ''}
${!loading && filtered.length === 0 ? `
<div class="hm-skills-empty">
${searchQuery ? t('engine.skillsNoMatch') : t('engine.skillsEmpty')}
</div>
` : ''}
${!loading ? filtered.map(renderCategory).join('') : ''}
</div>
</aside>
`
}
function renderEmpty() {
return `
<div class="hm-skills-detail-empty">
${ICONS.empty}
<div class="hm-skills-detail-empty-title">${t('engine.skillsSelectHint')}</div>
<div class="hm-skills-detail-empty-sub">${t('engine.skillsSelectSub')}</div>
</div>
`
}
function renderDetail() {
if (!activeSkill) return renderEmpty()
if (loadingDetail) {
return `
<div class="hm-skills-detail-body">
<div class="hm-skel" style="height:24px;width:40%;margin-bottom:18px"></div>
<div class="hm-skel" style="height:14px;width:100%;margin-bottom:8px"></div>
<div class="hm-skel" style="height:14px;width:95%;margin-bottom:8px"></div>
<div class="hm-skel" style="height:14px;width:70%"></div>
</div>
`
}
// --- File view (attached file of a skill) ---
if (viewingFile) {
return `
<div class="hm-skills-detail-breadcrumb">
<button class="hm-skills-back-btn" id="hm-skills-back">
${ICONS.back}<span>${t('engine.skillsBackTo')} ${escHtml(activeSkill.name)}</span>
</button>
<span class="hm-skills-breadcrumb-sep">/</span>
<span class="hm-skills-breadcrumb-path">${escHtml(viewingFile)}</span>
</div>
<div class="hm-skills-detail-body">
${loadingFile
? `<div class="hm-skills-loading">${t('engine.skillsLoading')}</div>`
: `<div class="hm-skills-markdown">${mdToHtml(fileContent)}</div>`}
</div>
`
}
// --- Skill content view ---
return `
<div class="hm-skills-detail-head">
<div class="hm-skills-detail-title">
${activeSkill.category && activeSkill.category !== '_root' ? `
<span class="hm-skills-title-cat">${escHtml(activeSkill.category)}</span>
<span class="hm-skills-title-sep">/</span>
` : ''}
<span class="hm-skills-title-name">${escHtml(activeSkill.name)}</span>
${activeSkill.enabled === false
? `<span class="hm-pill hm-pill--muted hm-skills-status">${t('engine.skillsDisabledTag')}</span>`
: `<span class="hm-pill hm-pill--ok hm-skills-status">${t('engine.skillsEnabledTag')}</span>`}
</div>
<div class="hm-skills-detail-sub">
${activeSkill.isDir ? ICONS.folder : ICONS.file}
<span>${escHtml(activeSkill.file)}</span>
</div>
</div>
<div class="hm-skills-layout">
<div class="hm-skills-list-panel">
<div class="hm-skills-list-scroll">
${loading ? `<div class="hm-skills-loading">${t('engine.skillsLoading')}</div>` : ''}
${!loading && filtered.length === 0 ? `<div class="hm-skills-empty">${t('engine.skillsEmpty')}</div>` : ''}
${!loading ? filtered.map(cat => `
<div class="hm-skills-category">
<div class="hm-skills-cat-header">
<span class="hm-skills-cat-name">${escHtml(cat.category === '_root' ? t('engine.skillsUncategorized') : cat.category)}</span>
<span class="hm-skills-cat-count">${cat.skills.length}</span>
</div>
${cat.skills.map(s => `
<div class="hm-skills-item ${activeSkill?.path === s.path ? 'active' : ''}" data-path="${escHtml(s.path)}">
<div class="hm-skills-item-name">${escHtml(s.name)}</div>
${s.description ? `<div class="hm-skills-item-desc">${escHtml(s.description)}</div>` : ''}
</div>
`).join('')}
</div>
`).join('') : ''}
<div class="hm-skills-detail-body">
<div class="hm-skills-markdown">${mdToHtml(skillContent)}</div>
</div>
${files.length > 0 ? `
<div class="hm-skills-files">
<div class="hm-skills-files-header">
<span class="hm-skills-files-label">${t('engine.skillsAttachedFiles')}</span>
<span class="hm-skills-files-count">${files.length}</span>
</div>
<div class="hm-skills-files-list">
${files.map(f => `
<button class="hm-skills-file-chip" data-file="${escHtml(f.path)}" title="${escHtml(f.path)}">
${f.isDir ? ICONS.folder : ICONS.file}
<span>${escHtml(basename(f.path))}</span>
</button>
`).join('')}
</div>
</div>
<div class="hm-skills-detail-panel">
${!activeSkill ? `<div class="hm-skills-detail-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
<span>${t('engine.skillsSelectHint')}</span>
</div>` : ''}
${activeSkill && loadingDetail ? `<div class="hm-skills-detail-loading">${t('engine.skillsLoading')}</div>` : ''}
${activeSkill && !loadingDetail ? `
<div class="hm-skills-detail-header">
<h2>${escHtml(activeSkill.name)}</h2>
<span class="hm-skills-detail-file">${escHtml(activeSkill.file)}</span>
</div>
<div class="hm-skills-detail-content markdown-body">${mdToHtml(skillContent)}</div>
` : ''}
` : ''}
`
}
function draw() {
const enabled = enabledSkillCount()
const total = totalSkillCount()
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">
<span class="hm-dot hm-dot--idle"></span>
${t('engine.skillsEyebrow')}
</div>
<h1 class="hm-hero-h1">${t('engine.hermesSkillsTitle')}</h1>
<div class="hm-hero-sub">~/.hermes/skills/
${!loading ? `<span class="hm-skills-count-inline"> · ${enabled}/${total} ${t('engine.skillsActive')}</span>` : ''}
</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-skills-refresh" ${loading ? 'disabled' : ''}>
${ICONS.refresh} ${t('engine.skillsRefresh')}
</button>
</div>
</div>
<div class="hm-skills-layout">
${renderSidebar()}
<section class="hm-skills-main">${renderDetail()}</section>
</div>
`
bind()
}
// ============================================================ bindings
function bind() {
el.querySelector('#hm-skills-search')?.addEventListener('input', (e) => {
searchQuery = e.target.value
draw()
})
el.querySelectorAll('.hm-skills-item').forEach(item => {
item.addEventListener('click', () => {
el.querySelector('#hm-skills-refresh')?.addEventListener('click', () => loadSkills())
el.querySelectorAll('.hm-skill-cat-header').forEach(btn => {
btn.addEventListener('click', () => {
const cat = btn.dataset.cat
if (collapsed.has(cat)) collapsed.delete(cat)
else collapsed.add(cat)
draw()
})
})
el.querySelectorAll('.hm-skill-item').forEach(item => {
item.addEventListener('click', (evt) => {
// Toggle switch clicks should NOT open the skill detail.
if (evt.target.closest('.hm-switch')) return
const skillPath = item.dataset.path
// Find the skill object
for (const cat of categories) {
const s = cat.skills.find(s => s.path === skillPath)
if (s) { loadDetail(s); return }
const s = cat.skills.find(x => x.path === skillPath)
if (s) { loadDetail({ ...s, category: cat.category }); return }
}
})
})
el.querySelectorAll('.hm-switch').forEach(sw => {
sw.addEventListener('click', (evt) => {
evt.stopPropagation()
if (sw.classList.contains('is-busy')) return
const slug = sw.dataset.slug
const catName = sw.dataset.category
const cat = categories.find(c => c.category === catName)
const skill = cat?.skills.find(s => s.slug === slug)
if (!skill) return
handleToggle(skill, skill.enabled === false)
})
})
el.querySelector('#hm-skills-back')?.addEventListener('click', backToSkill)
el.querySelectorAll('.hm-skills-file-chip').forEach(chip => {
chip.addEventListener('click', () => openFile(chip.dataset.file))
})
}
loadSkills()

View File

@@ -0,0 +1,401 @@
import { api } from '../../../lib/tauri-api.js'
import { t } from '../../../lib/i18n.js'
import { icon } from '../../../lib/icons.js'
const DAY_MS = 24 * 60 * 60 * 1000
function escHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function toNumber(value) {
const n = Number(value || 0)
return Number.isFinite(n) ? n : 0
}
function formatTokens(value) {
const n = toNumber(value)
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(Math.round(n))
}
function formatCost(value) {
const n = toNumber(value)
if (!n) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
function toTimestamp(session) {
const direct = toNumber(session?.started_at)
if (direct > 0) return direct
const raw = session?.created_at || session?.updated_at || ''
const ms = Date.parse(raw)
return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
}
function toDateKey(timestampSeconds) {
return new Date(timestampSeconds * 1000).toISOString().slice(0, 10)
}
function aggregateSessions(sessions) {
const rows = Array.isArray(sessions) ? sessions.slice() : []
const totalInputTokens = rows.reduce((sum, s) => sum + toNumber(s.input_tokens), 0)
const totalOutputTokens = rows.reduce((sum, s) => sum + toNumber(s.output_tokens), 0)
const totalTokens = totalInputTokens + totalOutputTokens
const totalCacheTokens = rows.reduce((sum, s) => sum + toNumber(s.cache_read_tokens), 0)
const estimatedCost = rows.reduce((sum, s) => {
const cost = s.actual_cost_usd ?? s.estimated_cost_usd ?? 0
return sum + toNumber(cost)
}, 0)
const modelMap = new Map()
let oldestTs = 0
for (const s of rows) {
const model = s.model || t('usage.unknownModel')
if (!modelMap.has(model)) {
modelMap.set(model, {
model,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
totalTokens: 0,
sessions: 0,
})
}
const entry = modelMap.get(model)
entry.inputTokens += toNumber(s.input_tokens)
entry.outputTokens += toNumber(s.output_tokens)
entry.cacheTokens += toNumber(s.cache_read_tokens)
entry.totalTokens += toNumber(s.input_tokens) + toNumber(s.output_tokens)
entry.sessions += 1
const ts = toTimestamp(s)
if (ts > 0 && (!oldestTs || ts < oldestTs)) oldestTs = ts
}
const now = new Date()
const dailyMap = new Map()
for (let i = 29; i >= 0; i--) {
const d = new Date(now)
d.setDate(d.getDate() - i)
const key = d.toISOString().slice(0, 10)
dailyMap.set(key, { date: key, tokens: 0, cache: 0, sessions: 0, cost: 0 })
}
for (const s of rows) {
const ts = toTimestamp(s)
if (!ts) continue
const key = toDateKey(ts)
const entry = dailyMap.get(key)
if (!entry) continue
entry.tokens += toNumber(s.input_tokens) + toNumber(s.output_tokens)
entry.cache += toNumber(s.cache_read_tokens)
entry.sessions += 1
entry.cost += toNumber(s.actual_cost_usd ?? s.estimated_cost_usd ?? 0)
}
const dailyUsage = [...dailyMap.values()]
const modelUsage = [...modelMap.values()].sort((a, b) => b.totalTokens - a.totalTokens)
const days = oldestTs ? Math.max(1, Math.ceil((Date.now() - oldestTs * 1000) / DAY_MS)) : 1
return {
sessions: rows,
totalInputTokens,
totalOutputTokens,
totalTokens,
totalSessions: rows.length,
totalCacheTokens,
cacheHitRate: totalInputTokens > 0 ? (totalCacheTokens / totalInputTokens) * 100 : null,
estimatedCost,
modelUsage,
dailyUsage,
avgSessionsPerDay: rows.length / days,
}
}
function analyticsToUsage(data) {
const totals = data?.totals || {}
const totalInputTokens = toNumber(totals.total_input)
const totalOutputTokens = toNumber(totals.total_output)
const totalTokens = totalInputTokens + totalOutputTokens
const totalCacheTokens = toNumber(totals.total_cache_read) + toNumber(totals.total_cache_write)
const totalSessions = toNumber(totals.total_sessions)
const estimatedCost = toNumber(totals.total_actual_cost || totals.total_estimated_cost)
const periodDays = Math.max(1, toNumber(data?.period_days) || 30)
const modelUsage = (Array.isArray(data?.by_model) ? data.by_model : []).map(model => {
const inputTokens = toNumber(model.input_tokens)
const outputTokens = toNumber(model.output_tokens)
return {
model: model.model || t('usage.unknownModel'),
inputTokens,
outputTokens,
cacheTokens: toNumber(model.cache_read_tokens),
totalTokens: inputTokens + outputTokens,
sessions: toNumber(model.sessions),
}
}).sort((a, b) => b.totalTokens - a.totalTokens)
const dailyUsage = (Array.isArray(data?.daily) ? data.daily : []).map(day => ({
date: day.day || day.date || '',
tokens: toNumber(day.input_tokens) + toNumber(day.output_tokens),
cache: toNumber(day.cache_read_tokens),
sessions: toNumber(day.sessions),
cost: toNumber(day.actual_cost || day.estimated_cost),
})).filter(day => day.date)
return {
sessions: [],
totalInputTokens,
totalOutputTokens,
totalTokens,
totalSessions,
totalCacheTokens,
cacheHitRate: totalInputTokens > 0 ? (totalCacheTokens / totalInputTokens) * 100 : null,
estimatedCost,
modelUsage,
dailyUsage,
avgSessionsPerDay: totalSessions / periodDays,
}
}
function renderTrendSvg(dailyUsage) {
if (!dailyUsage.length) return ''
const width = 780
const height = 220
const padLeft = 12
const padRight = 12
const padTop = 12
const padBottom = 28
const usableWidth = width - padLeft - padRight
const usableHeight = height - padTop - padBottom
const maxTokens = Math.max(...dailyUsage.map(d => d.tokens), 1)
const stepX = dailyUsage.length > 1 ? usableWidth / (dailyUsage.length - 1) : usableWidth
const baseline = height - padBottom
const barWidth = Math.max(8, Math.min(18, usableWidth / Math.max(dailyUsage.length, 1) - 5))
const points = dailyUsage.map((d, index) => {
const x = padLeft + stepX * index
const y = baseline - (d.tokens / maxTokens) * usableHeight
return { x, y, d }
})
const grid = [0.25, 0.5, 0.75, 1].map(scale => {
const y = baseline - usableHeight * scale
return `<line x1="${padLeft}" y1="${y.toFixed(2)}" x2="${width - padRight}" y2="${y.toFixed(2)}" class="hm-usage-trend-grid" />`
}).join('')
const areaPath = points.length
? `M ${points[0].x.toFixed(2)} ${baseline.toFixed(2)} L ${points.map(p => `${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' L ')} L ${points[points.length - 1].x.toFixed(2)} ${baseline.toFixed(2)} Z`
: ''
const linePoints = points.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
const bars = points.map(point => {
const h = Math.max(2, baseline - point.y)
return `<rect class="hm-usage-trend-bar" x="${(point.x - barWidth / 2).toFixed(2)}" y="${point.y.toFixed(2)}" width="${barWidth.toFixed(2)}" height="${h.toFixed(2)}" rx="3">
<title>${escHtml(point.d.date)} · ${formatTokens(point.d.tokens)} ${escHtml(t('usage.tokens'))} · ${point.d.sessions} ${escHtml(t('usage.sessions'))}</title>
</rect>`
}).join('')
const dots = points.map(point => `<circle class="hm-usage-trend-dot" cx="${point.x.toFixed(2)}" cy="${point.y.toFixed(2)}" r="2.6" />`).join('')
return `
<svg class="hm-usage-trend-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="hm-usage-trend-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(202, 138, 4, 0.34)" />
<stop offset="100%" stop-color="rgba(202, 138, 4, 0.02)" />
</linearGradient>
</defs>
${grid}
<path d="${areaPath}" class="hm-usage-trend-area" />
${bars}
<polyline class="hm-usage-trend-line" points="${linePoints}" />
${dots}
</svg>
`
}
function renderStatCard(label, value, sub, tone = '') {
return `
<article class="hm-usage-stat-card ${tone}">
<div class="hm-usage-stat-label">${escHtml(label)}</div>
<div class="hm-usage-stat-value">${escHtml(value)}</div>
<div class="hm-usage-stat-sub">${escHtml(sub || '')}</div>
</article>
`
}
function renderContent(usage) {
const strongestModel = usage.modelUsage[0]?.totalTokens || 1
const modelRows = usage.modelUsage.length
? usage.modelUsage.slice(0, 10).map(model => `
<div class="hm-usage-model-row">
<div class="hm-usage-model-name" title="${escHtml(model.model)}">${escHtml(model.model)}</div>
<div class="hm-usage-model-track">
<div class="hm-usage-model-bar" style="width:${Math.max(2, (model.totalTokens / strongestModel) * 100).toFixed(2)}%"></div>
</div>
<div class="hm-usage-model-meta">${escHtml(formatTokens(model.totalTokens))}</div>
</div>
`).join('')
: `<div class="hm-usage-empty-inline">${escHtml(t('usage.noData'))}</div>`
const trendRows = [...usage.dailyUsage].reverse().slice(0, 30).map(day => `
<tr>
<td>${escHtml(day.date)}</td>
<td>${escHtml(formatTokens(day.tokens))}</td>
<td>${escHtml(formatTokens(day.cache))}</td>
<td>${escHtml(String(day.sessions))}</td>
<td>${escHtml(formatCost(day.cost))}</td>
</tr>
`).join('')
const rangeStart = usage.dailyUsage[0]?.date.slice(5) || '—'
const rangeEnd = usage.dailyUsage[usage.dailyUsage.length - 1]?.date.slice(5) || '—'
return `
<div class="hm-usage-stat-grid">
${renderStatCard(
t('usage.totalTokens'),
formatTokens(usage.totalTokens),
`${formatTokens(usage.totalInputTokens)} ${t('usage.inputTokens')} / ${formatTokens(usage.totalOutputTokens)} ${t('usage.outputTokens')}`,
'is-accent'
)}
${renderStatCard(
t('usage.totalSessions'),
String(usage.totalSessions),
t('usage.avgPerDay').replace('{n}', usage.avgSessionsPerDay.toFixed(1))
)}
${renderStatCard(
t('usage.estimatedCost'),
formatCost(usage.estimatedCost),
usage.modelUsage[0]?.model || t('usage.unknownModel')
)}
${renderStatCard(
t('usage.cacheHitRate'),
usage.cacheHitRate == null ? '--' : usage.cacheHitRate.toFixed(1) + '%',
`${formatTokens(usage.totalCacheTokens)} ${t('usage.tokens')}`,
'is-muted'
)}
</div>
<section class="hm-usage-card">
<div class="hm-usage-card-head">
<h2 class="hm-usage-card-title">${escHtml(t('usage.modelBreakdown'))}</h2>
</div>
<div class="hm-usage-model-list">${modelRows}</div>
</section>
<section class="hm-usage-card hm-usage-card--trend">
<div class="hm-usage-card-head">
<h2 class="hm-usage-card-title">${escHtml(t('usage.dailyTrend'))}</h2>
</div>
<div class="hm-usage-trend-wrap">${renderTrendSvg(usage.dailyUsage)}</div>
<div class="hm-usage-trend-range">
<span>${escHtml(rangeStart)}</span>
<span>${escHtml(rangeEnd)}</span>
</div>
<div class="hm-usage-table-wrap">
<table class="hm-usage-table">
<thead>
<tr>
<th>${escHtml(t('usage.date'))}</th>
<th>${escHtml(t('usage.tokens'))}</th>
<th>${escHtml(t('usage.cache'))}</th>
<th>${escHtml(t('usage.sessions'))}</th>
<th>${escHtml(t('usage.cost'))}</th>
</tr>
</thead>
<tbody>${trendRows}</tbody>
</table>
</div>
</section>
`
}
export function render() {
const el = document.createElement('div')
el.className = 'page hm-usage-page'
el.dataset.engine = 'hermes'
let loading = true
let sessions = []
let analytics = null
let error = ''
let alive = true
function draw() {
const usage = analytics ? analyticsToUsage(analytics) : aggregateSessions(sessions)
el.innerHTML = `
<section class="hm-usage-hero">
<div class="hm-usage-hero-copy">
<div class="hm-usage-eyebrow">HERMES AGENT · ANALYTICS</div>
<h1 class="hm-usage-title">${escHtml(t('usage.title'))}</h1>
<p class="hm-usage-desc">${escHtml(t('usage.desc'))}</p>
</div>
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-usage-refresh" id="hm-usage-refresh" ${loading ? 'disabled' : ''}>
${icon('refresh-cw', 14)}
<span>${escHtml(t('usage.refresh'))}</span>
</button>
</section>
<div class="hm-usage-body">
${loading && !usage.totalSessions ? `
<div class="hm-usage-loading">${escHtml(t('common.loading'))}</div>
` : error ? `
<div class="hm-usage-error-card">
<div class="hm-usage-error-title">${escHtml(t('usage.loadFailed'))}</div>
<div class="hm-usage-error-text">${escHtml(error)}</div>
<button class="hm-btn hm-btn--primary hm-btn--sm" id="hm-usage-retry">${escHtml(t('usage.retry'))}</button>
</div>
` : !usage.totalSessions ? `
<div class="hm-usage-empty">${escHtml(t('usage.noData'))}</div>
` : renderContent(usage)}
</div>
`
el.querySelector('#hm-usage-refresh')?.addEventListener('click', load)
el.querySelector('#hm-usage-retry')?.addEventListener('click', load)
}
async function load() {
loading = true
error = ''
draw()
try {
analytics = await api.hermesUsageAnalytics(30)
if (!alive) return
sessions = []
} catch (err) {
if (!alive) return
try {
const rows = await api.hermesSessionsList(null, null)
if (!alive) return
sessions = Array.isArray(rows) ? rows : []
analytics = null
} catch (_) {
error = err?.message || String(err)
}
} finally {
if (!alive) return
loading = false
draw()
}
}
const mo = new MutationObserver(() => {
if (!el.isConnected) {
alive = false
mo.disconnect()
}
})
requestAnimationFrame(() => {
if (el.parentNode) mo.observe(el.parentNode, { childList: true })
})
draw()
load()
return el
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
/**
* 心甜Claw 引擎(产品宣传入口)
* ------------------------------------------------------------------
* 这不是一个本地引擎而是「心甜Claw」产品的一个产品落地页入口
* - 桌面客户端 + SaaS 后端Windows 安装即用
* - ClawPanel 里只承载宣传 + 跳转下载
*
* 因此它的 detect/boot/cleanup 都是 no-op永远 ready
* 也不与任何 Gateway / 本地进程打交道。
*/
import { t } from '../../lib/i18n.js'
// 心甜 LOGO · 采用 xintian-claw 桌面端同款六边形品牌图标
// 直接用 <img> 引用 public/ 下的 PNG避免 SVG 的 gradient id 冲突问题
const XINTIAN_ICON = `<img src="/images/xintian/logo-icon-64.png" srcset="/images/xintian/logo-icon-64.png 1x, /images/xintian/logo-icon-128.png 2x" alt="Xintian" width="16" height="16" style="display:block;object-fit:contain;">`
let _listeners = []
export default {
id: 'xintian',
name: '心甜Claw',
description: 'Xintian Claw · Worry-free AI Companion for Windows',
icon: XINTIAN_ICON,
async detect() {
// 不依赖任何本地进程永远「ready」
return { installed: true, ready: true }
},
async boot() {
// 无副作用启动
},
cleanup() {
// 无副作用清理
},
getNavItems() {
return [{
section: '',
items: [
{ route: '/x/landing', label: t('engine.xintianNavHome'), icon: 'assistant' },
],
}, {
section: '',
items: [
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
],
}]
},
getRoutes() {
return [
{ path: '/x/landing', loader: () => import('./pages/landing.js') },
// 只暴露 /about/settings 对心甜Claw 用户无意义,故不注册。
// 切回 OpenClaw / Hermes 后会重新获得面板设置入口。
{ path: '/about', loader: () => import('../../pages/about.js') },
]
},
getSetupRoute() { return '/x/landing' },
getDefaultRoute() { return '/x/landing' },
isReady() { return true },
isGatewayRunning() { return false },
isGatewayForeign() { return false },
onStateChange(fn) {
_listeners.push(fn)
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
},
onReadyChange(fn) {
_listeners.push(fn)
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
},
isFeatureAvailable() { return true },
}

View File

@@ -0,0 +1,292 @@
/**
* 心甜Claw · 产品落地页
* ------------------------------------------------------------------
* 面向 Windows 桌面客户端的产品宣传 + 下载引导页。
* 所有可见文本走 i18nengine.xt*),对外链接统一经过 openExternal()
* 在 Tauri 桌面端走 @tauri-apps/plugin-shellWeb 端回退到 window.open。
*/
import { t } from '../../../lib/i18n.js'
const WEBSITE_URL = 'https://xtclaw.xtnet.cc/'
const DOWNLOAD_URL = 'https://xtclaw.xtnet.cc/download'
const HELP_URL = 'https://xtclaw.xtnet.cc/articles'
// 新版六边形品牌图标(和 xintian-claw 桌面端同源)
const LOGO_SRC = '/images/xintian/logo-icon-128.png'
const LOGO_SRC_2X = '/images/xintian/logo-icon-256.png'
const LOGO_SRC_SM = '/images/xintian/logo-icon-64.png'
// -------- 图标库(统一 stroke 风格,对齐编辑风品牌) --------
const ICON = {
heart: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`,
sparkles: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 15.9 9 18.75 8.2 15.9a4.5 4.5 0 0 0-3.1-3.1L2.25 12l2.85-.8a4.5 4.5 0 0 0 3.1-3.1L9 5.25l.8 2.85a4.5 4.5 0 0 0 3.1 3.1L15.75 12l-2.85.8a4.5 4.5 0 0 0-3.1 3.1z"/><path d="M18.26 8.72 18 9.75l-.26-1.03a3.38 3.38 0 0 0-2.46-2.46L14.25 6l1.03-.26a3.38 3.38 0 0 0 2.46-2.46L18 2.25l.26 1.03a3.38 3.38 0 0 0 2.46 2.46L21.75 6l-1.03.26a3.38 3.38 0 0 0-2.46 2.46z"/></svg>`,
brain: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3 3 3 0 0 0-3 3v1a3 3 0 0 0-2 5.5A3 3 0 0 0 7 19a3 3 0 0 0 5 1.5 3 3 0 0 0 5-1.5 3 3 0 0 0 3-4.5A3 3 0 0 0 18 9V8a3 3 0 0 0-3-3 3 3 0 0 0-3-3z"/><path d="M12 5v15"/></svg>`,
agent: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M1 21v-2a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v2"/><path d="M20 4l1 2 2 1-2 1-1 2-1-2-2-1 2-1z"/></svg>`,
book: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><path d="M9 7h7M9 11h7"/></svg>`,
clock: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 7 12 12 15 14"/></svg>`,
skills: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
channels: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>`,
shield: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
windows: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.47 10.5 4.45v7.02H3V5.47zM10.5 12.53v7.02L3 18.53v-6zm1.12-8.24L22 3v8.47H11.62V4.29zM22 12.53V21l-10.38-1.3v-7.17H22z"/></svg>`,
download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
external: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`,
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
arrowRight: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`,
}
function esc(s) { return String(s ?? '').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
async function openExternal(url) {
if (!url) return
try {
if (window.__TAURI_INTERNALS__) {
const { open } = await import('@tauri-apps/plugin-shell')
await open(url)
return
}
} catch (_) { /* fallback */ }
try { window.open(url, '_blank', 'noopener,noreferrer') } catch (_) {}
}
/** 核心能力8 张卡片) */
function getFeatures() {
return [
{ icon: ICON.sparkles, title: t('engine.xtFeatChatTitle'), desc: t('engine.xtFeatChatDesc') },
{ icon: ICON.agent, title: t('engine.xtFeatAgentTitle'), desc: t('engine.xtFeatAgentDesc') },
{ icon: ICON.brain, title: t('engine.xtFeatMemoryTitle'), desc: t('engine.xtFeatMemoryDesc') },
{ icon: ICON.book, title: t('engine.xtFeatRagTitle'), desc: t('engine.xtFeatRagDesc') },
{ icon: ICON.clock, title: t('engine.xtFeatCronTitle'), desc: t('engine.xtFeatCronDesc') },
{ icon: ICON.skills, title: t('engine.xtFeatSkillsTitle'), desc: t('engine.xtFeatSkillsDesc') },
{ icon: ICON.channels, title: t('engine.xtFeatChannelTitle'), desc: t('engine.xtFeatChannelDesc') },
{ icon: ICON.shield, title: t('engine.xtFeatOfflineTitle'), desc: t('engine.xtFeatOfflineDesc') },
]
}
/** 定位对比3 张卡) */
function getCompareCards() {
return [
{
id: 'openclaw',
eyebrow: t('engine.xtComparePosA'),
title: 'OpenClaw',
desc: t('engine.xtCompareADesc'),
tag: t('engine.xtCompareAForWho'),
},
{
id: 'hermes',
eyebrow: t('engine.xtComparePosB'),
title: 'Hermes Agent',
desc: t('engine.xtCompareBDesc'),
tag: t('engine.xtCompareBForWho'),
},
{
id: 'xintian',
eyebrow: t('engine.xtComparePosC'),
title: t('engine.xtCompareCTitle'),
desc: t('engine.xtCompareCDesc'),
tag: t('engine.xtCompareCForWho'),
highlight: true,
},
]
}
/** 亮点清单CTA 区下方) */
function getChecklist() {
return [
t('engine.xtBulletInstall'),
t('engine.xtBulletLogin'),
t('engine.xtBulletSync'),
t('engine.xtBulletSafe'),
]
}
// -------- 渲染 --------
export async function render() {
const root = document.createElement('div')
root.className = 'page'
// Scope xintian editorial styling to this subtree only.
root.dataset.engine = 'xintian'
const features = getFeatures()
.map((f, i) => `
<article class="xt-feat" style="--xt-i:${i}">
<div class="xt-feat-ico">${f.icon}</div>
<div class="xt-feat-body">
<h3 class="xt-feat-title">${esc(f.title)}</h3>
<p class="xt-feat-desc">${esc(f.desc)}</p>
</div>
</article>
`).join('')
const compareCards = getCompareCards()
.map(c => `
<div class="xt-cmp-card${c.highlight ? ' xt-cmp-card--highlight' : ''}" data-card="${c.id}">
<div class="xt-cmp-eyebrow">${esc(c.eyebrow)}</div>
<div class="xt-cmp-title">${esc(c.title)}</div>
<p class="xt-cmp-desc">${esc(c.desc)}</p>
<div class="xt-cmp-tag">
<span class="xt-cmp-tag-dot"></span>
<span>${esc(c.tag)}</span>
</div>
${c.highlight ? `<div class="xt-cmp-ribbon">${esc(t('engine.xtCompareRecommend'))}</div>` : ''}
</div>
`).join('')
const bullets = getChecklist()
.map(b => `<li class="xt-bullet">${ICON.check}<span>${esc(b)}</span></li>`).join('')
root.innerHTML = `
<div class="xt-stage">
<!-- Decorative aurora background -->
<div class="xt-bg" aria-hidden="true">
<div class="xt-bg-blob xt-bg-blob--1"></div>
<div class="xt-bg-blob xt-bg-blob--2"></div>
<div class="xt-bg-blob xt-bg-blob--3"></div>
<div class="xt-bg-grid"></div>
</div>
<!-- 1 · Hero -->
<section class="xt-hero">
<div class="xt-hero-badge">
<span class="xt-hero-badge-dot"></span>
<span>${esc(t('engine.xtHeroEyebrow'))}</span>
</div>
<h1 class="xt-hero-title">
<span class="xt-hero-title-lead">${esc(t('engine.xtHeroTitleLead'))}</span>
<span class="xt-hero-title-main">${esc(t('engine.xtHeroTitleA'))}<em>${esc(t('engine.xtHeroTitleB'))}</em>${esc(t('engine.xtHeroTitleC'))}</span>
</h1>
<p class="xt-hero-sub">${esc(t('engine.xtHeroSub'))}</p>
<div class="xt-hero-actions">
<button class="xt-btn xt-btn--primary" data-xt-action="download">
${ICON.windows}
<span>${esc(t('engine.xtCtaDownloadWin'))}</span>
</button>
<button class="xt-btn xt-btn--ghost" data-xt-action="website">
<span>${esc(t('engine.xtCtaVisitSite'))}</span>
${ICON.external}
</button>
</div>
<div class="xt-hero-meta">
<span class="xt-hero-meta-item">${esc(t('engine.xtHeroPlatformWin'))}</span>
<span class="xt-hero-meta-sep">·</span>
<span class="xt-hero-meta-item">${esc(t('engine.xtHeroPlatformRest'))}</span>
<span class="xt-hero-meta-sep">·</span>
<span class="xt-hero-meta-item">${esc(t('engine.xtHeroFreeTrial'))}</span>
</div>
</section>
<!-- 2 · Features -->
<section class="xt-section">
<div class="xt-section-head">
<span class="xt-eyebrow">${esc(t('engine.xtFeaturesEyebrow'))}</span>
<h2 class="xt-section-title">${esc(t('engine.xtFeaturesTitle'))}</h2>
<p class="xt-section-sub">${esc(t('engine.xtFeaturesSub'))}</p>
</div>
<div class="xt-feat-grid">${features}</div>
</section>
<!-- 3 · Compare -->
<section class="xt-section xt-section--compare">
<div class="xt-section-head">
<span class="xt-eyebrow">${esc(t('engine.xtCompareEyebrow'))}</span>
<h2 class="xt-section-title">${esc(t('engine.xtCompareTitle'))}</h2>
<p class="xt-section-sub">${esc(t('engine.xtCompareSub'))}</p>
</div>
<div class="xt-cmp-grid">${compareCards}</div>
</section>
<!-- 4 · CTA block -->
<section class="xt-cta">
<div class="xt-cta-inner">
<div class="xt-cta-left">
<span class="xt-eyebrow xt-eyebrow--on-dark">${esc(t('engine.xtCtaEyebrow'))}</span>
<h2 class="xt-cta-title">${esc(t('engine.xtCtaTitle'))}</h2>
<p class="xt-cta-sub">${esc(t('engine.xtCtaSub'))}</p>
<ul class="xt-cta-bullets">${bullets}</ul>
<div class="xt-cta-actions">
<button class="xt-btn xt-btn--primary xt-btn--lg" data-xt-action="download">
${ICON.download}
<span>${esc(t('engine.xtCtaPrimary'))}</span>
</button>
<button class="xt-btn xt-btn--ghost xt-btn--ghost-dark xt-btn--lg" data-xt-action="website">
<span>${esc(t('engine.xtCtaSecondary'))}</span>
${ICON.arrowRight}
</button>
</div>
<div class="xt-cta-link" data-xt-action="website">
<span class="xt-cta-link-label">${esc(t('engine.xtCtaLinkLabel'))}</span>
<span class="xt-cta-link-url">xtclaw.xtnet.cc</span>
${ICON.external}
</div>
</div>
<!-- Decorative product preview card -->
<div class="xt-cta-right" aria-hidden="true">
<div class="xt-preview">
<div class="xt-preview-chrome">
<span class="xt-preview-dot"></span>
<span class="xt-preview-dot"></span>
<span class="xt-preview-dot"></span>
<span class="xt-preview-title">心甜Claw</span>
</div>
<div class="xt-preview-body">
<div class="xt-preview-msg xt-preview-msg--bot">
<div class="xt-preview-avatar"><img src="${LOGO_SRC_SM}" srcset="${LOGO_SRC_SM} 1x, ${LOGO_SRC} 2x" alt="Xintian" width="28" height="28"></div>
<div class="xt-preview-bubble">${esc(t('engine.xtPreviewGreet'))}</div>
</div>
<div class="xt-preview-msg xt-preview-msg--user">
<div class="xt-preview-bubble xt-preview-bubble--user">${esc(t('engine.xtPreviewUserAsk'))}</div>
</div>
<div class="xt-preview-msg xt-preview-msg--bot">
<div class="xt-preview-avatar"><img src="${LOGO_SRC_SM}" srcset="${LOGO_SRC_SM} 1x, ${LOGO_SRC} 2x" alt="Xintian" width="28" height="28"></div>
<div class="xt-preview-bubble">
<div class="xt-preview-bubble-line">${esc(t('engine.xtPreviewAnswer1'))}</div>
<div class="xt-preview-bubble-line xt-preview-bubble-line--muted">${esc(t('engine.xtPreviewAnswer2'))}</div>
<div class="xt-preview-typing"><span></span><span></span><span></span></div>
</div>
</div>
</div>
<div class="xt-preview-foot">
<span>${ICON.sparkles}</span>
<span>${esc(t('engine.xtPreviewFoot'))}</span>
</div>
</div>
</div>
</div>
</section>
<!-- 5 · Footer -->
<footer class="xt-foot">
<div class="xt-foot-brand">
<img class="xt-foot-logo" src="${LOGO_SRC_SM}" srcset="${LOGO_SRC_SM} 1x, ${LOGO_SRC} 2x" alt="Xintian Claw" width="18" height="18">
<span>${esc(t('engine.xtFootBrand'))}</span>
</div>
<div class="xt-foot-links">
<a class="xt-foot-link" data-xt-action="website">${esc(t('engine.xtFootHome'))}</a>
<span class="xt-foot-sep">·</span>
<a class="xt-foot-link" data-xt-action="download">${esc(t('engine.xtFootDownload'))}</a>
<span class="xt-foot-sep">·</span>
<a class="xt-foot-link" data-xt-action="help">${esc(t('engine.xtFootSupport'))}</a>
</div>
</footer>
</div>
`
// 事件委托:所有 [data-xt-action] 元素
root.addEventListener('click', (e) => {
const trigger = e.target.closest('[data-xt-action]')
if (!trigger) return
const action = trigger.dataset.xtAction
if (action === 'download') {
openExternal(DOWNLOAD_URL)
} else if (action === 'help') {
openExternal(HELP_URL)
} else if (action === 'website') {
openExternal(WEBSITE_URL)
}
})
return root
}
export default { render }

File diff suppressed because it is too large Load Diff

View File

@@ -82,6 +82,10 @@ export async function activateEngine(id, persist = true) {
_activeEngine = engine
// 给 <body> 设置 data-active-engine 属性供全局组件sidebar 等)做
// 引擎级样式切换e.g. Hermes 激活时 sidebar 套 editorial luxury 主题)
try { document.body.dataset.activeEngine = engine.id } catch {}
// 注册引擎路由 + 设置默认路由
const routes = engine.getRoutes()
for (const r of routes) {

View File

@@ -9,6 +9,7 @@ const PATHS = {
'x-circle': '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'info': '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
'circle': '<circle cx="12" cy="12" r="10"/>',
// 简单指示符
'check': '<polyline points="20 6 9 17 4 12"/>',

View File

@@ -404,20 +404,38 @@ export const api = {
hermesEnvReadUnmanaged: () => invoke('hermes_env_read_unmanaged'),
hermesEnvSet: (key, value) => invoke('hermes_env_set', { key, value }),
hermesEnvDelete: (key) => invoke('hermes_env_delete', { key }),
hermesEnvReveal: (key) => invoke('hermes_env_reveal', { key }),
hermesConfigRawRead: () => invoke('hermes_config_raw_read'),
hermesConfigRawWrite: (yamlText) => invoke('hermes_config_raw_write', { yamlText }),
hermesDetectEnvironments: () => invoke('hermes_detect_environments'),
hermesSetGatewayUrl: (url) => invoke('hermes_set_gateway_url', { url: url || null }),
updateHermes: () => invoke('update_hermes'),
uninstallHermes: (cleanConfig = false) => invoke('uninstall_hermes', { cleanConfig }),
// Hermes Sessions / Logs / Skills / Memory
hermesSessionsList: (source, limit) => invoke('hermes_sessions_list', { source: source || null, limit: limit || null }),
hermesSessionDetail: (sessionId) => invoke('hermes_session_detail', { sessionId }),
hermesSessionDelete: (sessionId) => invoke('hermes_session_delete', { sessionId }),
hermesSessionRename: (sessionId, title) => invoke('hermes_session_rename', { sessionId, title }),
hermesSessionsList: (source, limit, profile) => invoke('hermes_sessions_list', { source: source || null, limit: limit || null, profile: profile || null }),
hermesSessionsSummaryList: (source, limit, profile) => invoke('hermes_sessions_summary_list', { source: source || null, limit: limit || null, profile: profile || null }),
hermesUsageAnalytics: (days, profile) => invoke('hermes_usage_analytics', { days: days || 30, profile: profile || null }),
hermesSessionDetail: (sessionId, profile) => invoke('hermes_session_detail', { sessionId, profile: profile || null }),
hermesSessionDelete: (sessionId, profile) => invoke('hermes_session_delete', { sessionId, profile: profile || null }),
hermesSessionRename: (sessionId, title, profile) => invoke('hermes_session_rename', { sessionId, title, profile: profile || null }),
hermesProfilesList: () => invoke('hermes_profiles_list'),
hermesProfileUse: (name) => invoke('hermes_profile_use', { name }),
hermesLogsList: () => invoke('hermes_logs_list'),
hermesLogsRead: (name, lines, level) => invoke('hermes_logs_read', { name, lines: lines || 200, level: level || null }),
hermesLogsDownload: (name, saveToDisk = isTauriRuntime()) => invoke('hermes_logs_download', { name, saveToDisk }),
hermesDashboardThemes: () => invoke('hermes_dashboard_themes'),
hermesDashboardThemeSet: (name) => invoke('hermes_dashboard_theme_set', { name }),
hermesDashboardPlugins: () => invoke('hermes_dashboard_plugins'),
hermesDashboardPluginsRescan: () => invoke('hermes_dashboard_plugins_rescan'),
hermesToolsetsList: () => invoke('hermes_toolsets_list'),
hermesCronJobsList: () => invoke('hermes_cron_jobs_list'),
hermesSkillsList: () => invoke('hermes_skills_list'),
hermesSkillDetail: (filePath) => invoke('hermes_skill_detail', { filePath }),
hermesSkillToggle: (name, enabled) => invoke('hermes_skill_toggle', { name, enabled }),
hermesSkillFiles: (category, skill) => invoke('hermes_skill_files', { category, skill }),
hermesSkillWrite: (filePath, content) => invoke('hermes_skill_write', { filePath, content }),
hermesMemoryRead: (type) => invoke('hermes_memory_read', { type: type || 'memory' }),
hermesMemoryWrite: (type, content) => invoke('hermes_memory_write', { type: type || 'memory', content }),
hermesMemoryReadAll: () => invoke('hermes_memory_read_all'),
}

View File

@@ -1,8 +1,45 @@
/**
* 主题管理(日间/夜间模式)
*
* 桌面端:除了切换 `<html data-theme>`,还会同步 Tauri 原生窗口标题栏的
* 主题Windows 下通过 DwmSetWindowAttribute 切 immersive dark mode
* 避免夜间模式下出现"应用黑、窗口栏白"的割裂观感。Web 端该步骤会安静跳过。
*/
import { isTauriRuntime } from './tauri-api.js'
const THEME_KEY = 'clawpanel-theme'
// 延迟加载 Tauri window 模块Web 构建不会真正拉取
let _tauriWindowModule = null
async function getTauriCurrentWindow() {
if (!isTauriRuntime()) return null
if (_tauriWindowModule === false) return null
if (!_tauriWindowModule) {
try {
_tauriWindowModule = await import('@tauri-apps/api/window')
} catch (_) {
_tauriWindowModule = false
return null
}
}
try {
return _tauriWindowModule.getCurrentWindow()
} catch (_) {
return null
}
}
async function syncTauriTitleBar(theme) {
const win = await getTauriCurrentWindow()
if (!win || typeof win.setTheme !== 'function') return
try {
// Tauri v2: 接受 'light' | 'dark' | nullnull = 跟随系统)
await win.setTheme(theme === 'dark' ? 'dark' : 'light')
} catch (_) {
// 某些 WebView2 版本或未授权时会抛错,静默忽略
}
}
export function initTheme() {
const saved = localStorage.getItem(THEME_KEY)
const theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
@@ -39,4 +76,6 @@ export function getTheme() {
function applyTheme(theme) {
document.documentElement.dataset.theme = theme
localStorage.setItem(THEME_KEY, theme)
// Fire-and-forget不等待 Tauri IPC 返回,避免阻塞 DOM 更新和过渡动画
syncTauriTitleBar(theme)
}

View File

@@ -4,7 +4,7 @@ export default {
switchedTo: _('已切换到 {name} 模式', 'Switched to {name} mode', '已切換到 {name} 模式', '{name} モードに切り替えました', '{name} 모드로 전환됨'),
switchFailed: _('引擎切换失败,请稍后重试', 'Engine switch failed, please try again later', '引擎切換失敗,請稍後重試', 'エンジンの切り替えに失敗しました。後でもう一度お試しください', '엔진 전환에 실패했습니다. 잠시 후 다시 시도해 주세요'),
switcherSectionLabel: _('引擎', 'Engine', '引擎', 'エンジン', '엔진', 'Động cơ', 'Motor', 'Motor', 'Движок', 'Moteur', 'Engine'),
switcherTooltip: _('点击切换引擎OpenClaw / Hermes Agent', 'Click to switch engine (OpenClaw / Hermes Agent)', '點擊切換引擎OpenClaw / Hermes Agent', 'クリックしてエンジンを切り替え (OpenClaw / Hermes Agent)', '엔진 전환하려면 클릭 (OpenClaw / Hermes Agent)', 'Nhấp để chuyển đổi engine (OpenClaw / Hermes Agent)', 'Haga clic para cambiar de motor (OpenClaw / Hermes Agent)', 'Clique para alternar o motor (OpenClaw / Hermes Agent)', 'Нажмите, чтобы переключить движок (OpenClaw / Hermes Agent)', 'Cliquez pour changer de moteur (OpenClaw / Hermes Agent)', 'Klicken, um die Engine zu wechseln (OpenClaw / Hermes Agent)'),
switcherTooltip: _('点击切换引擎', 'Click to switch engine', '點擊切換引擎', 'クリックしてエンジンを切り替え', '엔진 전환하려면 클릭', 'Nhấp để chuyển đổi engine', 'Haga clic para cambiar de motor', 'Clique para alternar o motor', 'Нажмите, чтобы переключить движок', 'Cliquez pour changer de moteur', 'Klicken, um die Engine zu wechseln'),
hermesSetupDesc: _('安装并配置 Hermes Agent', 'Install and configure Hermes Agent', '安裝並配置 Hermes Agent'),
hermesPhaseClickHint: _('点击可返回此步骤', 'Click to go back to this step', '點擊可返回此步驟', 'このステップに戻るにはクリック', '이 단계로 돌아가려면 클릭'),
hermesSetupIntro: _(
@@ -111,18 +111,55 @@ export default {
dashOpenChat: _('打开对话', 'Open Chat', '開啟對話'),
dashOpenPanel: _('打开面板', 'Open Panel', '開啟面板'),
dashOpenPanelDesc: _('Hermes 对话面板', 'Hermes Chat Panel', 'Hermes 對話面板'),
// Native Hermes dashboard launcher
dashNativePanel: _('原生 Dashboard', 'Native Dashboard', '原生 Dashboard', 'ネイティブ Dashboard', '네이티브 Dashboard'),
dashNativePanelDesc: _('也可使用原生 hermes dashboard默认 9119需自行启动', 'You can also use the native hermes dashboard (default 9119; start it manually).', '也可使用原生 hermes dashboard預設 9119需自行啟動'),
dashNativePanelOpen: _('打开 9119 →', 'Open 9119 →', '開啟 9119 →', '9119 を開く →', '9119 열기 →'),
dashNativePanelOffline: _('Gateway 未运行', 'Gateway offline', 'Gateway 未執行', 'Gateway 未実行', 'Gateway 미실행'),
dashNativePanelTooOld: _('需 v0.10.0+', 'Requires v0.10.0+', '需 v0.10.0+', 'v0.10.0+ が必要', 'v0.10.0+ 필요'),
dashNativePanelOpenFail: _('打开浏览器失败', 'Failed to open browser', '開啟瀏覽器失敗', 'ブラウザを開くのに失敗しました', '브라우저 열기 실패'),
dashOpenCron: _('定时任务', 'Cron Jobs', '定時任務'),
dashOpenSetup: _('重新配置', 'Reconfigure', '重新配置'),
dashNoModel: _('未配置', 'Not configured', '未配置'),
dashApiEndpoint: _('API 地址', 'API Endpoint', 'API 地址'),
dashModelConfig: _('模型配置', 'Model Config', '模型配置'),
dashEyebrowLoading: _('HERMES AGENT · GATEWAY', 'HERMES AGENT · GATEWAY', 'HERMES AGENT · GATEWAY'),
dashEyebrowOnline: _('HERMES AGENT · GATEWAY 在线', 'HERMES AGENT · GATEWAY ONLINE', 'HERMES AGENT · GATEWAY 在線'),
dashEyebrowOffline: _('HERMES AGENT · GATEWAY 离线', 'HERMES AGENT · GATEWAY OFFLINE', 'HERMES AGENT · GATEWAY 離線'),
dashRefresh: _('刷新', 'Refresh', '重新整理'),
dashProvider: _('服务商', 'Provider', '服務商'),
dashProviderPresets: _('服务商预设', 'Provider Presets', '服務商預設'),
dashApiBaseUrl: _('API Base URL', 'API Base URL', 'API Base URL'),
dashApiKey: _('API Key', 'API Key', 'API Key'),
dashEnvAdvancedEdit: _('.env 高级编辑 →', '.env Advanced Edit →', '.env 進階編輯 →'),
dashConnectTarget: _('连接目标', 'Connection Target', '連接目標'),
dashDetectEnv: _('探测环境', 'Detect Environments', '探測環境'),
dashDetecting: _('探测中...', 'Detecting...', '探測中...'),
dashConnLocal: _('本地', 'Local', '本地'),
dashConnWsl2: _('WSL2', 'WSL2', 'WSL2'),
dashConnDocker: _('Docker', 'Docker', 'Docker'),
dashConnCustom: _('自定义', 'Custom', '自訂'),
dashConnApply: _('应用', 'Apply', '套用'),
dashQuickSwitch: _('快速切换', 'Quick Switch', '快速切換'),
dashHermesMissing: _('Hermes 未安装', 'Hermes not installed', 'Hermes 未安裝'),
dashGatewayNotRunning: _('Gateway 未运行', 'Gateway not running', 'Gateway 未執行'),
dashNoHermesContainers: _('未发现 Hermes 容器', 'No Hermes containers found', '未發現 Hermes 容器'),
dashInteractiveSession: _('交互式会话 →', 'interactive session →', '互動式會話 →'),
dashInstallerWizard: _('安装向导 →', 'installer wizard →', '安裝精靈 →'),
dashLogsFoot: _('追踪 / 搜索 →', 'tail / search →', '追蹤 / 搜尋 →'),
dashAdvancedEdit: _('高级编辑', 'advanced edit', '進階編輯'),
dashCustomVars: _('自定义变量 →', 'custom vars →', '自訂變數 →'),
dashCliCommand: _('命令', 'Command', '命令'),
dashCliDescription: _('说明', 'Description', '說明'),
dashCliCopy: _('复制', 'Copy', '複製'),
dashGatewayStarted: _('Gateway 已启动', 'Gateway started', 'Gateway 已啟動'),
dashConfigPatched: _('config.yaml 已自动修复', 'config.yaml was auto-repaired', 'config.yaml 已自動修復'),
configModelRequired: _('请输入模型名', 'Enter a model name', '請輸入模型名稱'),
configSaved: _('配置已保存', 'Config saved', '配置已儲存'),
envDetectFailed: _('探测失败', 'Detection failed', '探測失敗'),
connWslGatewayMissing: _('WSL2 Gateway 未运行,请先在 WSL 中启动', 'WSL2 Gateway is not running. Start it in WSL first.', 'WSL2 Gateway 未執行,請先在 WSL 中啟動'),
connDockerCustomHint: _('请切换到“自定义”模式并输入容器的 Gateway URL', 'Switch to Custom mode and enter the container Gateway URL.', '請切換到「自訂」模式並輸入容器的 Gateway URL'),
connUrlRequired: _('请输入 Gateway URL', 'Enter Gateway URL', '請輸入 Gateway URL'),
// 终端命令
dashCliTitle: _('终端命令', 'Terminal Commands', '終端命令'),
dashCliDesc: _('在终端中使用以下命令管理 Hermes Agent点击复制', 'Use these commands in your terminal to manage Hermes Agent. Click to copy.', '在終端中使用以下命令管理 Hermes Agent點擊複製'),
@@ -147,11 +184,148 @@ export default {
chatPlaceholder: _('输入消息...', 'Type a message...', '輸入訊息...'),
chatSend: _('发送', 'Send', '發送'),
chatNewSession: _('新对话', 'New Chat', '新對話'),
chatNewChat: _('新建', 'New chat', '新建'),
chatThinking: _('正在思考...', 'Thinking...', '正在思考...'),
chatError: _('发送失败: {error}', 'Send failed: {error}', '發送失敗: {error}'),
chatErrorBadge: _('失败', 'Error', '失敗'),
chatGatewayOffline: _('Gateway 未运行,请先启动', 'Gateway is offline, please start it first', 'Gateway 未運行,請先啟動'),
chatGatewayOnline: _('Gateway 运行中', 'Gateway online', 'Gateway 運行中'),
// Short labels for the header pill — full sentence lives in the tooltip
chatGatewayOfflineShort: _('离线', 'Offline', '離線'),
chatGatewayOnlineShort: _('在线', 'Online', '線上'),
chatWelcome: _('你好!我是 Hermes Agent有什么可以帮你的', 'Hello! I\'m Hermes Agent, how can I help?', '你好!我是 Hermes Agent有什麼可以幫你的'),
chatEmptyHint: _('开始一段对话吧', 'Start a conversation', '開始一段對話吧'),
chatEmptyTitle: _('和 Hermes Agent 对话', 'Talk to Hermes Agent', '和 Hermes Agent 對話'),
chatEmptySub: _('输入消息开始,或用 /help 查看命令。', 'Type a message to begin, or /help for commands.', '輸入訊息開始,或用 /help 查看命令。'),
chatLoadingMessages: _('正在载入会话', 'Loading session', '正在載入會話', 'セッションを読み込み中', '세션 불러오는 중'),
chatLoadingMessagesSub: _('正在同步 Hermes 历史消息...', 'Syncing Hermes message history…', '正在同步 Hermes 歷史訊息...', 'Hermes の履歴メッセージを同期中...', 'Hermes 메시지 기록 동기화 중...'),
// 会话侧栏
chatSessions: _('会话', 'Sessions', '會話'),
chatPinned: _('置顶', 'Pinned', '釘選'),
chatLoading: _('加载中...', 'Loading...', '載入中...'),
chatNoSessions: _('暂无会话', 'No sessions yet', '暫無會話'),
chatLive: _('活跃', 'Live', '活躍'),
chatToggleSidebar: _('切换侧栏', 'Toggle sidebar', '切換側欄'),
chatShowSessions: _('显示会话', 'Show sessions', '顯示會話', 'セッションを表示', '세션 표시'),
chatHideSessions: _('隐藏会话', 'Hide sessions', '隱藏會話', 'セッションを隠す', '세션 숨기기'),
chatSessionManageHint: _('右键会话,或点 ··· / 删除 管理', 'Right-click a session, or use ··· / Delete', '右鍵會話,或點 ··· / 刪除 管理', '右クリック、または ··· / 削除で管理', '우클릭 또는 ··· / 삭제로 관리'),
chatSessionActions: _('会话操作', 'Session actions', '會話操作', 'セッション操作', '세션 작업'),
chatMoreActions: _('更多操作', 'More actions', '更多操作', 'その他の操作', '더 많은 작업'),
chatDeleteShort: _('删除', 'Delete', '刪除', '削除', '삭제'),
// 输入区
// 占位符直接吸收键位提示,避免输入框下方再来一条同义 hint 形成"套娃"
chatInputPlaceholder: _(
'输入消息... (Enter 发送Shift+Enter 换行,/ 调出命令)',
'Type a message... (Enter to send, Shift+Enter for new line, / for commands)',
'輸入訊息... (Enter 發送Shift+Enter 換行,/ 調出命令)',
'メッセージを入力... (Enter で送信、Shift+Enter で改行、/ でコマンド)',
'메시지 입력... (Enter 전송, Shift+Enter 줄 바꿈, / 명령)',
),
chatStreamingPlaceholder: _('Agent 回答中...', 'Agent is responding...', 'Agent 回答中...'),
// Token 用量条 — 来自 `hermes sessions export` 的累计字段
chatUsageIn: _('输入', 'In', '輸入', '入力', '입력'),
chatUsageOut: _('输出', 'Out', '輸出', '出力', '출력'),
chatUsageCache: _('缓存', 'Cache', '快取', 'キャッシュ', '캐시'),
chatUsageTooltip: _(
'本会话累计 token 用量与估算成本',
'Cumulative token usage and estimated cost for this session',
'本會話累計 token 用量與估算成本',
'このセッションの累積トークン使用量と推定コスト',
'이 세션의 누적 토큰 사용량 및 추정 비용',
),
// 工具调用
chatArguments: _('入参', 'Arguments', '入參'),
chatResult: _('输出', 'Result', '輸出'),
// 上下文菜单
chatPin: _('置顶会话', 'Pin session', '釘選會話'),
chatUnpin: _('取消置顶', 'Unpin session', '取消釘選'),
chatRename: _('重命名', 'Rename', '重新命名'),
chatRenameSession: _('重命名会话', 'Rename session', '重新命名會話'),
chatEnterNewTitle: _('输入新标题...', 'Enter new title…', '輸入新標題...'),
chatRenamed: _('已重命名', 'Renamed', '已重新命名'),
chatRenameFailed: _('重命名失败', 'Rename failed', '重新命名失敗'),
chatCopySessionId: _('复制会话 ID', 'Copy session ID', '複製會話 ID'),
chatCopyMessage: _('复制消息', 'Copy message', '複製訊息', 'メッセージをコピー', '메시지 복사'),
chatCopyMessageShort: _('复制', 'Copy', '複製', 'コピー', '복사'),
chatCopyCode: _('复制代码', 'Copy code', '複製程式碼', 'コードをコピー', '코드 복사'),
chatCopyFailed: _('复制失败', 'Copy failed', '複製失敗'),
chatDeleteSession: _('删除会话', 'Delete session', '刪除會話'),
chatConfirmDelete: _('确认删除此会话?此操作无法撤销。', 'Delete this session? This action cannot be undone.', '確認刪除此會話?此操作無法復原。'),
chatSessionDeleted: _('会话已删除', 'Session deleted', '會話已刪除'),
chatDeleteFailed: _('删除失败', 'Delete failed', '刪除失敗'),
chatDeleteRunningBlocked: _('此会话正在回复中,请先停止当前回复', 'This session is still responding. Stop the run first.', '此會話正在回覆中,請先停止目前回覆', 'このセッションは応答中です。先に停止してください', '이 세션은 응답 중입니다. 먼저 중지하세요'),
chatJumpBottom: _('回到底部', 'Jump to bottom', '回到底部', '一番下へ', '맨 아래로'),
// Profile / Agent 切换
chatProfileTooltip: _('切换 Hermes Profile (多 Agent)', 'Switch Hermes profile (multi-agent)', '切換 Hermes Profile (多 Agent)', 'Hermes プロファイルを切り替え', 'Hermes 프로필 전환'),
chatProfileSingle: _('当前 Profile (未检测到多 Profile)', 'Current profile (no extra profiles detected)', '目前 Profile (未偵測到多 Profile)', '現在のプロファイル', '현재 프로필'),
chatProfileMenuHead: _('Hermes Profile', 'Hermes Profile', 'Hermes Profile'),
chatProfileMenuFoot: _('每个 Profile 对应独立的 Agent / 配置 / 会话', 'Each profile is an isolated agent · config · session set', '每個 Profile 對應獨立的 Agent / 設定 / 會話', '各プロファイルは独立した Agent・設定・セッション群', '각 프로필은 독립된 Agent·설정·세션 집합'),
chatProfileRunning: _('运行中', 'Running', '運行中', '実行中', '실행 중'),
chatProfileSwitched: _('已切换到 {name}', 'Switched to {name}', '已切換到 {name}', '{name} に切り替え済み', '{name} 으로 전환됨'),
chatProfileSwitchBlocked: _('正在回复中,无法切换 Profile', 'A reply is in progress — cannot switch profile', '正在回覆中,無法切換 Profile', '応答中のためプロファイルを切り替えられません', '응답 중이라 프로필을 전환할 수 없습니다'),
// 批量选择
chatBulkSelect: _('多选会话', 'Select multiple sessions', '多選會話', 'セッションを複数選択', '여러 세션 선택'),
chatExitSelect: _('退出多选', 'Exit selection', '退出多選', '選択モード終了', '선택 모드 종료'),
chatSelect: _('选择', 'Select', '選擇'),
chatDeselect: _('取消选择', 'Deselect', '取消選擇'),
chatSelectAll: _('全选', 'Select all', '全選'),
chatSelectNone: _('清空选择', 'Clear selection', '清空選擇'),
chatSelectedCount: _('已选 {n} 项', '{n} selected', '已選 {n} 項', '{n} 件選択', '{n}개 선택됨'),
chatBulkDelete: _('批量删除', 'Delete selected', '批量刪除', '一括削除', '일괄 삭제'),
chatConfirmBulkDelete: _('确认删除 {n} 个会话?此操作无法撤销。', 'Delete {n} sessions? This action cannot be undone.', '確認刪除 {n} 個會話?此操作無法復原。', '{n} 件のセッションを削除しますか?元に戻せません。', '{n}개 세션을 삭제할까요? 되돌릴 수 없습니다.'),
chatBulkDeleted: _('已删除 {n} 个会话', '{n} sessions deleted', '已刪除 {n} 個會話', '{n} 件のセッションを削除', '{n}개 세션 삭제됨'),
chatBulkPartial: _('已删除 {n} 个,{f} 个失败/跳过', '{n} deleted, {f} failed/skipped', '已刪除 {n} 個,{f} 個失敗/跳過', '{n} 件削除、{f} 件失敗/スキップ', '{n}개 삭제, {f}개 실패/건너뜀'),
chatBulkFailed: _('批量删除失败', 'Bulk delete failed', '批量刪除失敗', '一括削除に失敗', '일괄 삭제 실패'),
// 会话浏览页
sessionsPageTitle: _('会话浏览器', 'Session Browser', '會話瀏覽器', 'セッションブラウザ', '세션 브라우저'),
sessionsPageDesc: _('跨 Profile 搜索、审阅和批量管理 Hermes 会话。', 'Search, review and batch-manage Hermes sessions across profiles.', '跨 Profile 搜尋、檢閱和批量管理 Hermes 會話。'),
sessionsSearchPlaceholder: _('搜索标题、模型、来源或消息内容...', 'Search title, model, source or message content...', '搜尋標題、模型、來源或訊息內容...'),
sessionsAllSources: _('全部来源', 'All sources', '全部來源'),
sessionsAllProfiles: _('全部 Profiles', 'All profiles', '全部 Profiles'),
sessionsProfileLoadPartial: _('{n} 个 Profile 加载失败', '{n} profiles failed to load', '{n} 個 Profile 載入失敗'),
sessionsDetailLoadFailed: _('会话详情加载失败', 'Failed to load session details', '會話詳情載入失敗'),
sessionsTotal: _('全部会话', 'Total sessions', '全部會話'),
sessionsShown: _('当前显示', 'Shown', '目前顯示'),
sessionsProfiles: _('Profiles', 'Profiles', 'Profiles'),
sessionsSelected: _('已选择', 'Selected', '已選擇'),
sessionsJustNow: _('刚刚', 'just now', '剛剛'),
sessionsMinutesAgo: _('{n} 分钟前', '{n}m ago', '{n} 分鐘前'),
sessionsHoursAgo: _('{n} 小时前', '{n}h ago', '{n} 小時前'),
sessionsUntitled: _('未命名会话', 'Untitled session', '未命名會話'),
sessionsNoPreview: _('暂无预览', 'No preview', '暫無預覽'),
sessionsEmpty: _('没有匹配的会话', 'No matching sessions', '沒有符合的會話'),
sessionsNoSelection: _('选择一个会话', 'Select a session', '選擇一個會話'),
sessionsNoSelectionDesc: _('从左侧列表选择会话,查看元数据和最近消息。', 'Pick a session from the left to inspect metadata and recent messages.', '從左側列表選擇會話,查看中繼資料和最近訊息。'),
sessionsOpenChat: _('打开聊天', 'Open chat', '開啟聊天'),
sessionsPin: _('置顶', 'Pin', '置頂'),
sessionsUnpin: _('取消置顶', 'Unpin', '取消置頂'),
sessionsMessages: _('消息数', 'Messages', '訊息數'),
sessionsTokens: _('Tokens', 'Tokens', 'Tokens'),
sessionsModel: _('模型', 'Model', '模型'),
sessionsUpdated: _('更新时间', 'Updated', '更新時間'),
sessionsMessagesNotLoaded: _('消息尚未载入,点击左侧会话后会自动同步详情。', 'Messages are not loaded yet; selecting a session syncs details automatically.', '訊息尚未載入,點擊左側會話後會自動同步詳情。'),
// Slash 命令
chatSlashTitle: _('可用命令', 'Available commands', '可用命令'),
chatSlashHelpDesc: _('显示可用命令', 'Show available commands', '顯示可用命令'),
chatSlashStatusDesc: _('查看 Gateway 与模型状态', 'Inspect gateway & model status', '查看 Gateway 與模型狀態'),
chatSlashMemoryDesc: _('打开 Agent 记忆编辑', 'Open Agent memory editor', '開啟 Agent 記憶編輯'),
chatSlashSkillsDesc: _('打开技能库', 'Open skills library', '開啟技能庫'),
chatSlashClearDesc: _('清空当前会话', 'Clear current session', '清空目前會話'),
chatSlashNewDesc: _('新建会话', 'Start a new session', '新建會話'),
chatSlashStatusTitle: _('当前状态', 'Current status', '目前狀態'),
chatSlashGateway: _('Gateway', 'Gateway', 'Gateway'),
chatSlashPort: _('端口', 'Port', '埠'),
chatSlashModel: _('模型', 'Model', '模型'),
chatSlashRedirect: _('正在跳转到 {page}...', 'Redirecting to {page}...', '正在跳轉到 {page}...'),
// 停止流式
chatStop: _('停止', 'Stop', '停止'),
chatStopped: _('已停止当前回复', 'Run stopped', '已停止目前回覆'),
// 会话搜索 (Ctrl+K)
chatSearchShortcut: _('搜索会话 (Ctrl+K)', 'Search sessions (Ctrl+K)', '搜尋會話 (Ctrl+K)'),
chatSearchPlaceholder: _('搜索会话标题或内容...', 'Search by title or message content…', '搜尋會話標題或內容...'),
chatSearchEmpty: _('没有匹配的会话', 'No matching sessions', '沒有符合的會話'),
chatSearchNavigate: _('导航', 'Navigate', '導覽'),
chatSearchOpen: _('打开', 'Open', '開啟'),
fileAccess: _('文件访问', 'File Access', '檔案存取'),
fileAccessOn: _('已开启文件系统访问Agent 可读取本机文件)', 'File system access enabled (Agent can read local files)', '已開啟檔案系統存取Agent 可讀取本機檔案)'),
fileAccessOff: _('文件系统访问已关闭', 'File system access disabled', '檔案系統存取已關閉'),
@@ -189,6 +363,37 @@ export default {
cronNameRequired: _('请输入任务名称', 'Job name is required', '請輸入任務名稱'),
cronPromptRequired: _('请输入 AI 指令', 'AI prompt is required', '請輸入 AI 指令'),
cronScheduleRequired: _('请选择执行周期', 'Schedule is required', '請選擇執行週期'),
// --- Phase 2 additions: richer job metadata ---
cronEyebrow: _('AGENT 定时任务', 'AGENT SCHEDULED JOBS', 'AGENT 定時任務'),
cronJobs: _('个任务', 'jobs', '個任務'),
cronFailed: _('失败', 'Failed', '失敗'),
cronPauseBtn: _('暂停', 'Pause', '暫停'),
cronResume: _('恢复', 'Resume', '恢復'),
cronStateRunning: _('运行中', 'running', '運行中'),
cronStatePaused: _('已暂停', 'paused', '已暫停'),
cronStateDisabled: _('已禁用', 'disabled', '已禁用'),
cronStateScheduled: _('待调度', 'scheduled', '待調度'),
cronScheduleLabel: _('执行周期', 'schedule', '執行週期'),
cronNextRun: _('下次执行', 'next run', '下次執行'),
cronLastRun: _('上次执行', 'last run', '上次執行'),
cronDeliverLabel: _('结果回传', 'deliver to', '結果回傳'),
cronRepeatLabel: _('重复', 'repeat', '重複'),
cronSkillsLabel: _('Skills', 'Skills', 'Skills'),
cronLastError: _('上次错误', 'last error', '上次錯誤'),
cronOverdue: _('已逾期', 'overdue', '已逾期'),
cronInSeconds: _('{n} 秒后', 'in {n}s', '{n} 秒後'),
cronInMinutes: _('{n} 分钟后', 'in {n}m', '{n} 分鐘後'),
cronInHours: _('{n} 小时后', 'in {n}h', '{n} 小時後'),
cronInDays: _('{n} 天后', 'in {n}d', '{n} 天後'),
cronDeliverOrigin: _('回传原聊天', 'origin chat', '回傳原聊天'),
cronDeliverLocal: _('仅本地记录', 'local only', '僅本機記錄'),
cronRepeatLimit: _('重复次数', 'repeat limit', '重複次數'),
cronRepeatLimitHint: _('留空表示无限循环', 'Leave empty for unlimited', '留空表示無限迴圈'),
cronInvalidCron: _('无效的 cron 表达式', 'Invalid cron expression', '無效的 cron 表達式'),
cronTriggered: _('任务已触发', 'Job triggered', '任務已觸發'),
cronPausedOk: _('任务已暂停', 'Job paused', '任務已暫停'),
cronResumedOk: _('任务已恢复', 'Job resumed', '任務已恢復'),
cronDeletedOk: _('任务已删除', 'Job deleted', '任務已刪除'),
// 日志页面
hermesLogsTitle: _('Agent 日志', 'Agent Logs', 'Agent 日誌'),
logsRefresh: _('刷新', 'Refresh', '重新整理'),
@@ -200,14 +405,44 @@ export default {
logsLoading: _('加载中...', 'Loading...', '載入中...'),
logsEmpty: _('暂无日志', 'No logs', '暫無日誌'),
logsLoadFailed: _('加载失败', 'Load failed', '載入失敗'),
logsEyebrow: _('AGENT 日志流', 'AGENT LOG STREAM', 'AGENT 日誌流'),
logsTailing: _('实时追踪中', 'TAILING · LIVE', '即時追蹤中'),
logsTailStart: _('追踪', 'Tail', '追蹤'),
logsTailStop: _('暂停', 'Pause', '暫停'),
logsToggleTail: _('开启/停止实时追踪(每 2 秒刷新)', 'Toggle live tail (2s poll)', '開啟/停止即時追蹤(每 2 秒重新整理)'),
logsDownload: _('下载', 'Download', '下載'),
logsDownloadOk: _('日志已保存到 {path}', 'Log saved to {path}', '日誌已儲存到 {path}'),
logsDownloadBrowserOk: _('已交给浏览器下载,请查看默认下载目录。', 'Download started in your browser; check the default downloads folder.', '已交給瀏覽器下載,請查看預設下載目錄。'),
logsDownloadFailed: _('下载失败', 'Download failed', '下載失敗'),
logsClear: _('清空当前显示', 'Clear view', '清空目前顯示'),
logsLevel: _('级别', 'Level', '級別'),
logsLinesLabel: _('行数', 'Limit', '行數'),
logsSearchLabel: _('搜索', 'Search', '搜尋'),
logsFilteredBy: _('过滤自', 'filtered by', '過濾自'),
// Skills 页面
hermesSkillsTitle: _('Agent Skills', 'Agent Skills', 'Agent Skills'),
skillsEyebrow: _('AGENT 技能库', 'AGENT SKILL LIBRARY', 'AGENT 技能庫'),
skillsTotal: _('个技能', 'skills', '個技能'),
skillsActive: _('启用中', 'active', '啟用中'),
skillsSearch: _('搜索技能...', 'Search skills...', '搜尋技能...'),
skillsLoading: _('加载中...', 'Loading...', '載入中...'),
skillsEmpty: _('暂无技能', 'No skills found', '暫無技能'),
skillsNoMatch: _('没有匹配的技能', 'No skills match your search', '沒有符合的技能'),
skillsUncategorized: _('未分类', 'Uncategorized', '未分類'),
skillsSelectHint: _('选择一个技能查看详情', 'Select a skill to view details', '選擇一個技能查看詳情'),
skillsSelectSub: _('从左侧列表点击,或使用搜索快速定位。', 'Click any item on the left, or use search to jump.', '從左側列表點擊,或使用搜尋快速定位。'),
skillsRefresh: _('刷新', 'Refresh', '重新整理'),
skillsEnable: _('启用此技能', 'Enable this skill', '啟用此技能'),
skillsDisable: _('停用此技能', 'Disable this skill', '停用此技能'),
skillsEnabled: _('技能已启用', 'Skill enabled', '技能已啟用'),
skillsDisabled: _('技能已停用', 'Skill disabled', '技能已停用'),
skillsEnabledTag: _('启用', 'Active', '啟用'),
skillsDisabledTag: _('停用', 'Disabled', '停用'),
skillsToggleFailed: _('切换失败', 'Toggle failed', '切換失敗'),
skillsLoadFailed: _('加载失败', 'Load failed', '載入失敗'),
skillsFileLoadFailed: _('文件加载失败', 'File load failed', '檔案載入失敗'),
skillsAttachedFiles: _('附带资源', 'Attached Files', '附帶資源'),
skillsBackTo: _('返回', 'Back to', '返回'),
// Memory 页面
hermesMemoryTitle: _('Agent 记忆', 'Agent Memory', 'Agent 記憶'),
memoryNotes: _('笔记', 'Notes', '筆記'),
@@ -221,9 +456,174 @@ export default {
memoryEmpty: _('暂无内容', 'No content', '暫無內容'),
memoryPlaceholder: _('使用 Markdown 格式编写...', 'Write in Markdown format...', '使用 Markdown 格式編寫...'),
memoryUnsaved: _('有未保存的更改,确定离开?', 'Unsaved changes. Leave anyway?', '有未儲存的變更,確定離開?'),
memorySaved: _('已保存', 'Saved', '已儲存'),
memorySaveHint: _('Ctrl/⌘ + S 保存 · Esc 取消', 'Ctrl/⌘ + S to save · Esc to cancel', 'Ctrl/⌘ + S 儲存 · Esc 取消'),
memoryEyebrow: _('AGENT 长期记忆', 'AGENT PERSISTENT MEMORY', 'AGENT 長期記憶'),
memorySoul: _('灵魂档案', 'Soul', '靈魂檔案'),
memoryNotesDesc: _('Agent 的笔记与事实备忘——会话间持续累积的知识。', 'Agent\'s notes and factual memories — knowledge accumulated across sessions.', 'Agent 的筆記與事實備忘——會話間持續累積的知識。'),
memoryProfileDesc: _('用户偏好、身份、背景信息——每次对话都会参考。', 'User preferences, identity, context — referenced in every conversation.', '用戶偏好、身份、背景資訊——每次對話都會參考。'),
memorySoulDesc: _('Agent 的人格、价值观、说话风格——长期塑造。', 'Agent persona, values, voice — shaped over time.', 'Agent 的人格、價值觀、說話風格——長期塑造。'),
memoryWords: _('词', 'words', '詞'),
memoryChars: _('字符', 'chars', '字元'),
memoryJustNow: _('刚刚', 'just now', '剛剛'),
memoryMinAgo: _('{n} 分钟前', '{n}m ago', '{n} 分鐘前'),
memoryHrAgo: _('{n} 小时前', '{n}h ago', '{n} 小時前'),
// 其它页面
hermesServicesTitle: _('Hermes 服务', 'Hermes Services', 'Hermes 服務'),
servicesDesc: _('集中查看 Gateway 运行状态、连接目标、健康检查与维护操作。', 'Inspect gateway status, connection target, health checks, and maintenance actions in one place.', '集中查看 Gateway 運行狀態、連接目標、健康檢查與維護操作。'),
servicesInstallState: _('安装状态', 'Install State', '安裝狀態'),
servicesInstallType: _('安装方式', 'Install Method', '安裝方式'),
servicesInstalled: _('已安装', 'Installed', '已安裝'),
servicesMissing: _('未安装', 'Not Installed', '未安裝'),
servicesUnknown: _('未知', 'Unknown', '未知'),
servicesPath: _('CLI 路径', 'CLI Path', 'CLI 路徑'),
servicesHome: _('主目录', 'Home Directory', '主目錄'),
servicesConfigFiles: _('关键配置文件', 'Key Config Files', '關鍵配置檔'),
servicesNotSet: _('未设置', 'Not set', '未設置'),
servicesCustomUrl: _('自定义 Gateway URL', 'Custom Gateway URL', '自定義 Gateway URL'),
servicesWslHint: _('请先在 WSL2 中启动 Gateway然后再切换。', 'Start the gateway in WSL2 before switching.', '請先在 WSL2 中啟動 Gateway再切換。'),
servicesDockerHint: _('Docker 场景请填写容器对外可访问的 Gateway URL。', 'For Docker, enter the externally reachable gateway URL.', 'Docker 場景請填寫容器對外可訪問的 Gateway URL。'),
servicesDetectFirst: _('请先探测环境并确保目标 Gateway 已启动。', 'Detect environments first and make sure the target gateway is running.', '請先探測環境並確認目標 Gateway 已啟動。'),
servicesHealthTitle: _('健康检查', 'Health Check', '健康檢查'),
servicesRawJson: _('查看原始 JSON', 'View Raw JSON', '查看原始 JSON'),
servicesNoHealth: _('Gateway 未运行或暂时无法返回健康数据。', 'The gateway is offline or health data is temporarily unavailable.', 'Gateway 未運行或暫時無法返回健康資料。'),
servicesMaintenance: _('维护操作', 'Maintenance', '維護操作'),
servicesUpgrade: _('升级 Hermes', 'Upgrade Hermes', '升級 Hermes'),
servicesUninstall: _('卸载 Hermes', 'Uninstall Hermes', '解除安裝 Hermes'),
servicesUninstallClean: _('卸载并清理配置', 'Uninstall and Clean Config', '解除安裝並清理配置'),
servicesOpenLogs: _('打开日志', 'Open Logs', '打開日誌'),
servicesOpenConfig: _('打开配置', 'Open Config', '打開配置'),
servicesOpenEnv: _('打开环境变量', 'Open Environment', '打開環境變數'),
servicesOpenSetup: _('返回安装向导', 'Open Setup', '返回安裝精靈'),
servicesEyebrow: _('HERMES AGENT · 服务中心', 'HERMES AGENT · SERVICES', 'HERMES AGENT · 服務中心'),
servicesReadyTag: _('就绪', 'READY', '就緒'),
servicesDefaultDistro: _('默认发行版', 'default distro', '預設發行版'),
servicesContainerCount: _('{n} 个容器', '{n} containers', '{n} 個容器'),
servicesConfirmUpgrade: _('确认升级 Hermes Agent升级期间可能短暂中断 Gateway。', 'Upgrade Hermes Agent? The gateway may be briefly interrupted during the upgrade.', '確認升級 Hermes Agent升級期間 Gateway 可能短暫中斷。'),
servicesConfirmUninstall: _('确认卸载 Hermes Agent保留现有配置文件。', 'Uninstall Hermes Agent? Existing config files will be kept.', '確認解除安裝 Hermes Agent會保留現有配置檔。'),
servicesConfirmUninstallClean: _('确认卸载 Hermes Agent 并删除配置文件?此操作不可撤销。', 'Uninstall Hermes Agent and remove config files? This cannot be undone.', '確認解除安裝 Hermes Agent 並刪除配置檔?此操作無法撤銷。'),
hermesConfigTitle: _('Hermes 配置', 'Hermes Config', 'Hermes 配置'),
hermesChannelsTitle: _('Hermes 渠道', 'Hermes Channels', 'Hermes 頻道'),
extensionsEyebrow: _('HERMES AGENT · 扩展', 'HERMES AGENT · EXTENSIONS', 'HERMES AGENT · 擴展'),
extensionsTitle: _('文档 / 插件 / 主题', 'Docs / Plugins / Themes', '文件 / 插件 / 主題'),
extensionsDesc: _('集中管理 Dashboard 扩展清单、视觉主题和使用洞察。', 'Manage dashboard extension manifests, visual themes and usage intelligence.', '集中管理 Dashboard 擴展清單、視覺主題和使用洞察。'),
extensionsRefresh: _('刷新', 'Refresh', '刷新'),
extensionsRescan: _('重扫插件', 'Rescan Plugins', '重掃插件'),
extensionsDocs: _('文档', 'Documentation', '文件'),
extensionsAnalytics: _('分析快照', 'Analytics snapshot', '分析快照'),
extensionsSessions: _('会话', 'Sessions', '會話'),
extensionsTokens: _('Tokens', 'Tokens', 'Tokens'),
extensionsCost: _('费用', 'Cost', '費用'),
extensionsThemes: _('Dashboard 主题', 'Dashboard themes', 'Dashboard 主題'),
extensionsActive: _('当前', 'active', '目前'),
extensionsNoThemes: _('未发现主题。', 'No themes discovered.', '未發現主題。'),
extensionsPlugins: _('Dashboard 插件', 'Dashboard plugins', 'Dashboard 插件'),
extensionsManifestCount: _('{n} 个清单', '{n} manifest(s)', '{n} 個清單'),
extensionsNoDescription: _('暂无描述', 'No description', '暫無描述'),
extensionsNoPlugins: _('未在 ~/.hermes/plugins 中发现 Dashboard 插件清单。', 'No dashboard plugin manifests found in ~/.hermes/plugins.', '未在 ~/.hermes/plugins 中發現 Dashboard 插件清單。'),
extensionsThemeSaved: _('Dashboard 主题已保存', 'Dashboard theme saved', 'Dashboard 主題已儲存'),
extensionsPluginsRescanned: _('插件清单已重扫', 'Plugin manifests rescanned', '插件清單已重掃'),
extensionsDocGettingStarted: _('快速开始', 'Getting Started', '快速開始'),
extensionsDocCron: _('Cron 自动化', 'Cron Automation', 'Cron 自動化'),
extensionsDocSkills: _('Skills', 'Skills', 'Skills'),
extensionsDocDashboard: _('Dashboard', 'Dashboard', 'Dashboard'),
comingSoonPhase2: _('即将在 Phase 2 中推出', 'Coming in Phase 2', '即將在 Phase 2 中推出'),
// ============================================================
// 心甜ClawXintian Claw· 产品宣传页
// ============================================================
xintianNavHome: _('产品首页', 'Home', '產品首頁', 'ホーム', '홈', 'Trang chủ', 'Inicio', 'Início', 'Главная', 'Accueil', 'Startseite'),
// Hero
xtHeroEyebrow: _('心甜Claw · 跨平台 AI 省心助手', 'Xintian Claw · Worry-free AI Companion', '心甜Claw · 跨平台 AI 省心助手', '心甜Claw · 手間いらずの AI コンパニオン', '心甜Claw · 근심 없는 AI 동반자'),
xtHeroTitleLead: _('WINDOWS 安装即用', 'READY FOR WINDOWS', 'WINDOWS 安裝即用', 'WINDOWS 用すぐに使える', 'WINDOWS에서 바로 사용'),
xtHeroTitleA: _('不只是对话,是会', 'Not just chat —', '不只是對話,是會'),
xtHeroTitleB: _('记得你', 'an AI that remembers you', '記得你'),
xtHeroTitleC: _('的 AI 管家', '.', '的 AI 管家'),
xtHeroSub: _(
'桌面客户端 + SaaS 后端 + 长期记忆 + 多渠道,一次安装,让 AI 真正长期为你干活。',
'Desktop client, SaaS backend, persistent memory, and multi-channel delivery — install once and let AI keep working for you.',
'桌面客戶端 + SaaS 後端 + 長期記憶 + 多頻道,一次安裝,讓 AI 真正長期為你幹活。',
),
xtCtaDownloadWin: _('下载 Windows 版', 'Download for Windows', '下載 Windows 版', 'Windows 版をダウンロード', 'Windows 버전 다운로드'),
xtCtaVisitSite: _('访问官网', 'Visit website', '訪問官網', '公式サイトへ', '공식 웹사이트'),
xtHeroPlatformWin: _('Windows 10 / 11 · x64', 'Windows 10 / 11 · x64', 'Windows 10 / 11 · x64'),
xtHeroPlatformRest: _('macOS / Linux 即将上线', 'macOS / Linux coming soon', 'macOS / Linux 即將上線', 'macOS / Linux 近日公開', 'macOS / Linux 곧 출시'),
xtHeroFreeTrial: _('预置 2 个免费 Agent', '2 free agents included', '預置 2 個免費 Agent', '2 つの無料エージェント付き', '무료 에이전트 2개 포함'),
// Features 区域
xtFeaturesEyebrow: _('核心能力', 'CORE CAPABILITIES', '核心能力'),
xtFeaturesTitle: _('八种能力,一个助手', 'Eight capabilities, one companion', '八種能力,一個助手'),
xtFeaturesSub: _(
'从聊天、记忆到定时自动化、多渠道通知——把 AI 做到生产可用。',
'From chat and memory to scheduled automation and multi-channel delivery — AI ready for real work.',
'從聊天、記憶到定時自動化、多頻道通知——把 AI 做到生產可用。',
),
// 8 个特性卡片
xtFeatChatTitle: _('流式对话 × 思维链', 'Streaming chat × CoT', '串流對話 × 思維鏈'),
xtFeatChatDesc: _('工具调用与思考过程全程可见Markdown / 代码 / 表格原生渲染。', 'Full visibility into tool calls and reasoning, with native Markdown / code / table rendering.', '工具調用與思考過程全程可見Markdown / 程式碼 / 表格原生渲染。'),
xtFeatAgentTitle: _('多智能体 Agent 体系', 'Multi-agent roster', '多智能體 Agent 體系'),
xtFeatAgentDesc: _('预置心甜 + 晴辰两个助手,独立人设与记忆,可随时自定义。', 'Bundled Xintian & Qingchen assistants, each with its own persona and memory — fully customizable.', '預置心甜 + 晴辰兩個助手,獨立人設與記憶,可隨時自定義。'),
xtFeatMemoryTitle: _('心甜智脑 · 长期记忆', 'Sweet Brain · Long-term memory', '心甜智腦 · 長期記憶'),
xtFeatMemoryDesc: _('事实 + 对话双层记忆,跨渠道共享,桌面说过的话微信也能想起来。', 'Dual-layer memory (facts + conversations) shared across channels — it remembers what you said, everywhere.', '事實 + 對話雙層記憶,跨頻道共享,桌面說過的話微信也能想起來。'),
xtFeatRagTitle: _('知识库 × RAG', 'Knowledge base × RAG', '知識庫 × RAG'),
xtFeatRagDesc: _('拖拽上传 PDF / Word / Markdown回答自动附带引用与跳转链接。', 'Drag-and-drop PDF / Word / Markdown — answers come with citations and jump links.', '拖放上傳 PDF / Word / Markdown回答自動附帶引用與跳轉連結。'),
xtFeatCronTitle: _('定时任务 × 后台任务', 'Scheduled & background tasks', '定時任務 × 背景任務'),
xtFeatCronDesc: _('到点自动跑,长调研一轮一轮来,进度条可暂停可恢复。', 'Cron-triggered runs and multi-round background research with pause/resume progress.', '到點自動跑,長調研一輪一輪來,進度條可暫停可恢復。'),
xtFeatSkillsTitle: _('技能中心 · SkillForge', 'Skill Hub · SkillForge', '技能中心 · SkillForge'),
xtFeatSkillsDesc: _('把常用流程打包成技能 @ 调用,内置抓取 / 日报 / 总结。', 'Package prompts into reusable skills — invoke with @, with built-in scraping, reporting, summarization.', '把常用流程打包成技能 @ 調用,內建抓取 / 日報 / 總結。'),
xtFeatChannelTitle: _('多消息渠道', 'Multi-channel delivery', '多訊息頻道'),
xtFeatChannelDesc: _('飞书 / 微信 / Telegram 等消息渠道互通,一套记忆跟你到每个对话窗。', 'Feishu / WeChat / Telegram all connected — one memory follows you to every conversation.', '飛書 / 微信 / Telegram 等訊息頻道互通,一套記憶跟你到每個對話窗。'),
xtFeatOfflineTitle: _('离线 × 本地优先', 'Offline × local-first', '離線 × 本地優先'),
xtFeatOfflineDesc: _('核心数据存本地 ~/.xintian-claw断网队列补发多后端容灾。', 'Core data stored locally at ~/.xintian-claw, offline queue + multi-backend failover.', '核心資料存本地 ~/.xintian-claw斷網佇列補發多後端容災。'),
// Compare 区域
xtCompareEyebrow: _('产品定位', 'POSITIONING', '產品定位'),
xtCompareTitle: _('同一份心甜 · 不同的打开方式', 'One Xintian, three ways to open it', '同一份心甜 · 不同的打開方式'),
xtCompareSub: _(
'根据你的身份选择最合适的入口:开发者用框架、工程师用 Python、普通用户用桌面版。',
'Pick the entrance that fits you: framework for developers, Python for engineers, desktop client for everyone else.',
'根據你的身份選擇最合適的入口:開發者用框架、工程師用 Python、普通使用者用桌面版。',
),
xtComparePosA: _('开发者 / 架构师', 'DEVELOPER / ARCHITECT', '開發者 / 架構師'),
xtCompareADesc: _('完整 Agent 框架源码,支持自托管、插件扩展,适合深度定制。', 'Full Agent framework source with self-hosting and plugin extensions — for deep customization.', '完整 Agent 框架原始碼,支援自託管、外掛擴充,適合深度自訂。'),
xtCompareAForWho: _('面向团队与工程师', 'For teams and engineers', '面向團隊與工程師'),
xtComparePosB: _('Python 开发者', 'PYTHON DEVELOPER', 'Python 開發者'),
xtCompareBDesc: _('轻量级 Agent 框架,工具调用能力强,一键 uv 安装,快速集成。', 'Lightweight Agent framework with strong tool-calling, one-click uv install, fast integration.', '輕量級 Agent 框架,工具呼叫能力強,一鍵 uv 安裝,快速整合。'),
xtCompareBForWho: _('面向 Python 工程师', 'For Python engineers', '面向 Python 工程師'),
xtComparePosC: _('所有普通用户', 'EVERYONE', '所有普通使用者'),
xtCompareCTitle: _('心甜Claw', 'Xintian Claw', '心甜Claw'),
xtCompareCDesc: _('Windows 双击安装即可用,内置 Agent 与记忆,不写一行代码也能上手。', 'Double-click install on Windows — agents and memory out of the box, zero code required.', 'Windows 雙擊安裝即可用,內建 Agent 與記憶,不寫一行程式碼也能上手。'),
xtCompareCForWho: _('面向日常使用者', 'For everyday users', '面向日常使用者'),
xtCompareRecommend: _('推荐', 'RECOMMENDED', '推薦'),
// CTA 区域
xtCtaEyebrow: _('立即开始', 'GET STARTED', '立即開始'),
xtCtaTitle: _('今天装上 · 明天就离不开', 'Install today, depend on it tomorrow', '今天裝上 · 明天就離不開'),
xtCtaSub: _(
'下载 Windows 安装包、双击运行,登录账号即可开始使用。无需配置 Python、无需命令行。',
'Download the Windows installer, double-click, sign in — ready to chat. No Python, no terminal.',
'下載 Windows 安裝包、雙擊執行,登入帳號即可開始使用。無需配置 Python、無需命令列。',
),
xtBulletInstall: _('一次安装 · 自动更新', 'One-click install · auto update', '一次安裝 · 自動更新'),
xtBulletLogin: _('微信 / 邮箱登录', 'WeChat / Email sign-in', '微信 / 信箱登入'),
xtBulletSync: _('多设备记忆同步', 'Multi-device memory sync', '多裝置記憶同步'),
xtBulletSafe: _('核心数据本地加密', 'Core data encrypted locally', '核心資料本地加密'),
xtCtaPrimary: _('立即下载 Windows 版', 'Download for Windows', '立即下載 Windows 版'),
xtCtaSecondary: _('了解更多', 'Learn more', '了解更多'),
xtCtaLinkLabel: _('官网', 'WEBSITE', '官網'),
// Preview 气泡
xtPreviewGreet: _('你好呀,今天想让我帮你处理什么?', 'Hi! How can I help you today?', '你好呀,今天想讓我幫你處理什麼?'),
xtPreviewUserAsk: _('帮我盯着这条产品线的日报', 'Track the daily report of this product line', '幫我盯著這條產品線的日報'),
xtPreviewAnswer1: _('好的,已为你创建「日报追踪」定时任务。', 'Got it — created a scheduled "Daily Report" task for you.', '好的,已為你建立「日報追蹤」定時任務。'),
xtPreviewAnswer2: _('每天 18:00 推送到飞书群,记忆也会同步。', 'Posts to the Feishu group at 18:00 daily, memory stays in sync.', '每天 18:00 推送到飛書群,記憶也會同步。'),
xtPreviewFoot: _('由 心甜智脑 长期记忆支持', 'Powered by Sweet Brain long-term memory', '由 心甜智腦 長期記憶支援'),
// Footer
xtFootBrand: _('心甜Claw · 跨平台 AI 省心助手', 'Xintian Claw · Worry-free AI Companion', '心甜Claw · 跨平台 AI 省心助手'),
xtFootHome: _('官网', 'Website', '官網'),
xtFootDownload: _('下载', 'Download', '下載'),
xtFootSupport: _('帮助中心', 'Help Center', '幫助中心'),
}

View File

@@ -35,7 +35,7 @@ export default {
readFileFailed: _('读取文件失败', 'Failed to read file', '讀取檔案失敗'),
fileSaved: _('文件已保存', 'File saved', '檔案已儲存'),
saveFailed: _('保存失败', 'Save failed', '儲存失敗'),
downloaded: _('已下载 {name}', 'Downloaded {name}', '已下載 {name}'),
downloaded: _('已交给浏览器下载 {name},请查看默认下载目录', 'Download started for {name}; check your default downloads folder', '已交給瀏覽器下載 {name},請查看預設下載目錄'),
downloadFailed: _('下载失败', 'Download failed', '下載失敗'),
exported: _('已导出: {label} → {path}', 'Exported: {label} → {path}'),
exportFailed: _('打包下载失败', 'Export failed', '打包下載失敗'),

View File

@@ -13,6 +13,7 @@ export default {
dashboard: _('仪表盘', 'Dashboard', '儀表盤', 'ダッシュボード', '대시보드', 'Bảng điều khiển', 'Panel', 'Painel', 'Панель', 'Tableau de bord'),
assistant: _('晴辰助手', 'Assistant', '', 'アシスタント', '어시스턴트', 'Trợ lý', 'Asistente', 'Assistente', 'Ассистент', '', 'Assistent'),
chat: _('实时聊天', 'Live Chat', '實時聊天', 'ライブチャット', '실시간 채팅', 'Trò chuyện', 'Chat', 'Chat', 'Чат', 'Chat', 'Live-Chat'),
sessions: _('会话浏览', 'Sessions', '會話瀏覽', 'セッション', '세션'),
services: _('服务管理', 'Services', '服務管理', 'サービス管理', '서비스 관리', 'Dịch vụ', 'Servicios', 'Serviços', 'Сервисы', '', 'Dienste'),
logs: _('日志查看', 'Logs', '日誌查看', 'ログ', '로그', 'Nhật ký', 'Registros', '', 'Журналы', 'Journaux', 'Protokolle'),
models: _('模型配置', 'Models', '模型設定', 'モデル設定', '모델 설정', 'Mô hình', 'Modelos', 'Modelos', 'Модели', 'Modèles', 'Modelle'),
@@ -27,6 +28,7 @@ export default {
usage: _('使用情况', 'Usage', '使用情況', '使用状況', '사용 현황', 'Sử dụng', 'Uso', 'Uso', 'Использование', 'Utilisation', 'Nutzung'),
skills: _('Skills', 'Skills'),
pluginHub: _('插件中心', 'Plugin Hub', '插件中心', 'プラグインハブ', '플러그인 허브', 'Trung tâm plugin', 'Centro de plugins', 'Centro de plugins', 'Центр плагинов', 'Centre de plugins', 'Plugin-Hub'),
extensions: _('扩展与主题', 'Extensions', '擴展與主題', '拡張とテーマ', '확장 및 테마'),
settings: _('面板设置', 'Settings', '面板設定', 'パネル設定', '패널 설정', 'Cài đặt', 'Configuración', 'Configurações', 'Настройки', 'Paramètres', 'Einstellungen'),
diagnose: _('连接诊断', 'Connection Diagnosis', '連線診斷', '接続診断', '연결 진단', 'Chẩn đoán kết nối', 'Diagnóstico de conexión', 'Diagnóstico de conexão', 'Диагностика подключения', 'Diagnostic de connexion', 'Verbindungsdiagnose'),
chatDebug: _('系统诊断', 'Diagnostics', '系統诊斷', 'システム診断', '시스템 진단', 'Chẩn đoán', 'Diagnóstico', 'Diagnóstico', 'Диагностика', 'Diagnostic', 'Diagnose'),

View File

@@ -21,21 +21,30 @@ export default {
errors: _('错误', 'Errors', '錯誤'),
errorRate: _('错误率', 'Error rate', '錯誤率'),
totalTokens: _('Token 总量', 'Total Tokens', 'Token 總量', '合計トークン', '총 토큰', 'Tổng token', 'Tokens totales', 'Total de tokens', 'Всего токенов', 'Tokens totaux', 'Gesamte Tokens'),
totalSessions: _('会话总数', 'Total Sessions', '會話總數'),
input: _('输入', 'input', '輸入'),
output: _('输出', 'output', '輸出'),
cost: _('费用', 'Cost', '費用', 'コスト', '비용'),
sessions: _('会话', 'Sessions', '對話'),
estimatedCost: _('预估费用', 'Estimated Cost', '預估費用'),
cacheHitRate: _('缓存命中率', 'Cache Hit Rate', '快取命中率'),
tokens: _('Tokens', 'Tokens', 'Tokens'),
cache: _('缓存', 'Cache', '快取'),
topModels: _('热门模型', 'Top Models', '熱門模型'),
topProviders: _('热门服务商', 'Top Providers', '熱門服務商'),
topTools: _('热门工具', 'Top Tools', '熱門工具'),
topAgents: _('热门 Agent', 'Top Agents', '熱門 Agent'),
topChannels: _('热门渠道', 'Top Channels', '熱門頻道'),
modelBreakdown: _('模型分布', 'Model Breakdown', '模型分佈'),
tokenBreakdown: _('Token 分类', 'Token Breakdown', 'Token 分類', 'トークン内訳'),
outputTokens: _('输出', 'Output', '輸出', '出力トークン', '출력 토큰'),
inputTokens: _('输入', 'Input', '輸入', '入力トークン', '입력 토큰'),
cacheRead: _('缓存读取', 'Cache Read', '快取讀取'),
cacheWrite: _('缓存写入', 'Cache Write', '快取写入'),
dailyTrend: _('每日趋势', 'Daily Trend', '每日趨勢'),
dailyUsage: _('每日用量', 'Daily Usage'),
date: _('日期', 'Date', '日期'),
avgPerDay: _('日均 {n}', 'Avg {n}/day', '日均 {n}'),
sessionDetail: _('会话明细', 'Session Details', '對話明細'),
recentN: _('最近 {count} 个', 'Recent {count}', '最近 {count} 個'),
times: _('{count} 次', '{count} times'),

View File

@@ -21,6 +21,7 @@ import { initFeatureGates } from './lib/feature-gates.js'
import { registerEngine, initEngineManager, getActiveEngine, getActiveEngineId, onEngineChange } from './lib/engine-manager.js'
import openclawEngine from './engines/openclaw/index.js'
import hermesEngine from './engines/hermes/index.js'
import xintianEngine from './engines/xintian/index.js'
// 样式
import './style/variables.css'
@@ -33,6 +34,9 @@ import './style/agents.css'
import './style/debug.css'
import './style/assistant.css'
import './style/ai-drawer.css'
// 引擎专属样式scope 到 [data-engine="<id>"] 子树,不影响其他引擎)
import './engines/hermes/style/hermes.css'
import './engines/xintian/style/xintian.css'
// 初始化主题 + 国际化
initTheme()
@@ -316,6 +320,7 @@ async function boot() {
// 注册引擎
registerEngine(openclawEngine)
registerEngine(hermesEngine)
registerEngine(xintianEngine)
// 初始化引擎管理器:读取 clawpanel.json 的 engineMode注册对应路由
await initEngineManager()

View File

@@ -53,12 +53,24 @@ export async function render() {
</div>
`
if (getActiveEngineId() === 'hermes') {
const activeEngineId = getActiveEngineId()
if (activeEngineId === 'xintian') {
// 心甜Claw 是产品宣传入口,不展示 OpenClaw/Hermes 的版本、安装路径与社群信息
loadXintianData(page)
} else if (activeEngineId === 'hermes') {
loadHermesData(page)
} else {
loadData(page)
}
renderCommunity(page)
// 社群二维码是 OpenClaw 专属渠道,对 xintian 用户不相关
if (activeEngineId === 'xintian') {
page.querySelector('#community-section')?.closest('.config-section')?.remove()
} else {
renderCommunity(page)
}
renderProjects(page)
renderContribute(page)
renderLinks(page)
@@ -66,6 +78,40 @@ export async function render() {
return page
}
/**
* 心甜Claw 模式下的 about 页面:只展示 ClawPanel 自身版本 + 产品卡片,
* 不涉及 OpenClaw 的版本切换与安装路径。
*/
async function loadXintianData(page) {
const cards = page.querySelector('#version-cards')
const panelVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.1.0'
const panelUpdateHtml = `<span style="color:var(--text-tertiary)">${t('about.checkingUpdate')}</span>`
checkNewVersion(cards, panelVersion)
cards.innerHTML = `
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">ClawPanel</span></div>
<div class="stat-card-value">${panelVersion}</div>
<div class="stat-card-meta" id="panel-update-meta" style="display:flex;align-items:center;gap:8px">${panelUpdateHtml}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">心甜Claw</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-md)">Windows</div>
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<a class="btn btn-primary btn-sm" href="https://xtclaw.xtnet.cc/download" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('engine.xtCtaDownloadWin')}</a>
<a class="btn btn-secondary btn-sm" href="https://xtclaw.xtnet.cc/" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('engine.xtCtaVisitSite')}</a>
</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">${t('about.sectionLinks')}</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-md)">xtclaw.xtnet.cc</div>
<div class="stat-card-meta">
<a href="https://xtclaw.xtnet.cc/articles" target="_blank" rel="noopener" style="color:var(--accent)">${t('engine.xtFootSupport')}</a>
</div>
</div>
`
}
async function loadHermesData(page) {
const cards = page.querySelector('#version-cards')
try {