mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat(hermes): align dashboard APIs and add xintian engine
This commit is contained in:
206
docs/hermes-ui-refactor-plan.md
Normal file
206
docs/hermes-ui-refactor-plan.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Hermes UI 全面重构规划
|
||||
|
||||
> 参考:`.tmp/hermes-web-ui`(官方 Vue + Koa 实现)
|
||||
> 目标:ClawPanel Hermes 引擎视觉 + 功能与官方看齐,保留 editorial luxury 主题。
|
||||
> 作者:Cascade(AI 助手)|日期: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 流式、工具可视化、**持久化 Session(SQLite)**、**会话搜索**、多 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.js(access 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 DB,or 保留只读访问。
|
||||
|
||||
### 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`
|
||||
BIN
public/images/xintian/logo-icon-128.png
Normal file
BIN
public/images/xintian/logo-icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/images/xintian/logo-icon-256.png
Normal file
BIN
public/images/xintian/logo-icon-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/xintian/logo-icon-64.png
Normal file
BIN
public/images/xintian/logo-icon-64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -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'
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-set-theme",
|
||||
"shell:allow-open",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
// 关闭窗口时最小化到托盘,不退出应用
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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') },
|
||||
|
||||
1013
src/engines/hermes/lib/chat-store.js
Normal file
1013
src/engines/hermes/lib/chat-store.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
// 模型下拉 HTML(data-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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
177
src/engines/hermes/pages/extensions.js
Normal file
177
src/engines/hermes/pages/extensions.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>') }
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>') }
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
474
src/engines/hermes/pages/sessions.js
Normal file
474
src/engines/hermes/pages/sessions.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function escAttr(value) {
|
||||
return escHtml(value).replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>') }
|
||||
function escHtml(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.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()
|
||||
|
||||
401
src/engines/hermes/pages/usage.js
Normal file
401
src/engines/hermes/pages/usage.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
5686
src/engines/hermes/style/hermes.css
Normal file
5686
src/engines/hermes/style/hermes.css
Normal file
File diff suppressed because it is too large
Load Diff
78
src/engines/xintian/index.js
Normal file
78
src/engines/xintian/index.js
Normal 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 },
|
||||
}
|
||||
292
src/engines/xintian/pages/landing.js
Normal file
292
src/engines/xintian/pages/landing.js
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 心甜Claw · 产品落地页
|
||||
* ------------------------------------------------------------------
|
||||
* 面向 Windows 桌面客户端的产品宣传 + 下载引导页。
|
||||
* 所有可见文本走 i18n(engine.xt*),对外链接统一经过 openExternal()
|
||||
* 在 Tauri 桌面端走 @tauri-apps/plugin-shell,Web 端回退到 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, '<').replace(/>/g, '>') }
|
||||
|
||||
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 }
|
||||
1212
src/engines/xintian/style/xintian.css
Normal file
1212
src/engines/xintian/style/xintian.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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"/>',
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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' | null(null = 跟随系统)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 中推出'),
|
||||
|
||||
// ============================================================
|
||||
// 心甜Claw(Xintian 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', '幫助中心'),
|
||||
}
|
||||
|
||||
@@ -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', '打包下載失敗'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user