diff --git a/docs/hermes-ui-refactor-plan.md b/docs/hermes-ui-refactor-plan.md new file mode 100644 index 0000000..7094507 --- /dev/null +++ b/docs/hermes-ui-refactor-plan.md @@ -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` diff --git a/public/images/xintian/logo-icon-128.png b/public/images/xintian/logo-icon-128.png new file mode 100644 index 0000000..5a53a67 Binary files /dev/null and b/public/images/xintian/logo-icon-128.png differ diff --git a/public/images/xintian/logo-icon-256.png b/public/images/xintian/logo-icon-256.png new file mode 100644 index 0000000..920706d Binary files /dev/null and b/public/images/xintian/logo-icon-256.png differ diff --git a/public/images/xintian/logo-icon-64.png b/public/images/xintian/logo-icon-64.png new file mode 100644 index 0000000..579c096 Binary files /dev/null and b/public/images/xintian/logo-icon-64.png differ diff --git a/scripts/dev-api.js b/scripts/dev-api.js index a85faed..45e6642 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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' diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e90a679..2b8a729 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -4,6 +4,7 @@ "windows": ["main"], "permissions": [ "core:default", + "core:window:allow-set-theme", "shell:allow-open", "autostart:allow-enable", "autostart:allow-disable", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 0ced069..3deb26d 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index ee3eec7..ad56cb5 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2745,15 +2745,20 @@ pub async fn hermes_agent_run( pub async fn hermes_sessions_list( source: Option, limit: Option, + profile: Option, ) -> Result { - let mut args = vec!["sessions", "export", "-"]; - let source_owned; - if let Some(s) = &source { - source_owned = s.clone(); - args.push("--source"); - args.push(&source_owned); + let mut args: Vec = Vec::new(); + if let Some(p) = profile.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + args.push("--profile".into()); + args.push(p.to_string()); } - let output = match run_silent("hermes", &args) { + args.extend(["sessions", "export", "-"].iter().map(|s| s.to_string())); + if let Some(s) = source.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + args.push("--source".into()); + args.push(s.to_string()); + } + let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let output = match run_silent("hermes", &refs) { Ok(s) => s, Err(_) => return Ok(serde_json::json!([])), }; @@ -2764,6 +2769,25 @@ pub async fn hermes_sessions_list( continue; } if let Ok(obj) = serde_json::from_str::(t) { + // Extra numeric fields for Usage analytics. Carry through as-is so + // the frontend can aggregate without another round-trip. Missing + // fields fall back to 0 / null rather than breaking the shape. + // + // `started_at` is a POSIX seconds timestamp produced by the + // official Hermes CLI export. We also surface it under that name + // (matching the web UI contract) so the Usage store can group + // sessions by day without needing a separate parse. + let started_at = obj + .get("started_at") + .and_then(|v| v.as_u64()) + .unwrap_or_else(|| { + // Fallback: parse `created_at` as ISO8601 → epoch seconds. + obj.get("created_at") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.timestamp() as u64) + .unwrap_or(0) + }); sessions.push(serde_json::json!({ "id": obj.get("session_id").or(obj.get("id")).and_then(|v| v.as_str()).unwrap_or(""), "title": obj.get("title").or(obj.get("name")).and_then(|v| v.as_str()).unwrap_or(""), @@ -2772,6 +2796,14 @@ pub async fn hermes_sessions_list( "created_at": obj.get("created_at").or(obj.get("createdAt")).and_then(|v| v.as_str()).unwrap_or(""), "updated_at": obj.get("updated_at").or(obj.get("updatedAt")).and_then(|v| v.as_str()).unwrap_or(""), "message_count": obj.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0), + // --- Usage analytics fields --- + "started_at": started_at, + "input_tokens": obj.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0), + "output_tokens": obj.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0), + "cache_read_tokens": obj.get("cache_read_tokens").and_then(|v| v.as_u64()).unwrap_or(0), + "cache_write_tokens": obj.get("cache_write_tokens").and_then(|v| v.as_u64()).unwrap_or(0), + "estimated_cost_usd": obj.get("estimated_cost_usd").and_then(|v| v.as_f64()), + "actual_cost_usd": obj.get("actual_cost_usd").and_then(|v| v.as_f64()), })); } } @@ -2789,9 +2821,254 @@ pub async fn hermes_sessions_list( } #[tauri::command] -pub async fn hermes_session_detail(session_id: String) -> Result { - let output = run_silent("hermes", &["sessions", "export", "-"]) - .map_err(|e| format!("Failed to read sessions: {e}"))?; +pub async fn hermes_sessions_summary_list( + source: Option, + limit: Option, + profile: Option, +) -> Result { + let lim = limit.unwrap_or(80).clamp(1, 500); + let mut args: Vec = Vec::new(); + if let Some(p) = profile.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + args.push("--profile".into()); + args.push(p.to_string()); + } + args.extend( + ["sessions", "list", "--limit"] + .iter() + .map(|s| s.to_string()), + ); + args.push(lim.to_string()); + if let Some(s) = source.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + args.push("--source".into()); + args.push(s.to_string()); + } + let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let output = match run_silent("hermes", &refs) { + Ok(s) => s, + Err(_) => return Ok(serde_json::json!([])), + }; + let sep = regex::Regex::new(r"\s{2,}").map_err(|e| e.to_string())?; + let mut has_titles = false; + let mut sessions: Vec = Vec::new(); + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed == "No sessions found." || trimmed.starts_with('─') { + continue; + } + if trimmed.contains("Title") && trimmed.contains("Preview") && trimmed.contains("ID") { + has_titles = true; + continue; + } + if trimmed.contains("Preview") && trimmed.contains("Last Active") && trimmed.contains("ID") + { + has_titles = false; + continue; + } + let cols: Vec<&str> = sep + .split(trimmed) + .filter(|s| !s.trim().is_empty()) + .collect(); + if cols.len() < 3 { + continue; + } + let id = cols.last().copied().unwrap_or("").trim(); + if id.is_empty() { + continue; + } + let (title, preview, last_active, parsed_source) = if has_titles { + let title = cols.first().copied().unwrap_or("").trim(); + let preview = cols.get(1).copied().unwrap_or("").trim(); + let last_active = cols.get(2).copied().unwrap_or("").trim(); + ( + if title == "—" { "" } else { title }, + preview, + last_active, + source.as_deref().unwrap_or(""), + ) + } else { + let preview = cols.first().copied().unwrap_or("").trim(); + let last_active = cols.get(1).copied().unwrap_or("").trim(); + let parsed_source = cols + .get(2) + .copied() + .unwrap_or(source.as_deref().unwrap_or("")) + .trim(); + ("", preview, last_active, parsed_source) + }; + sessions.push(serde_json::json!({ + "id": id, + "title": title, + "source": parsed_source, + "model": "", + "created_at": "", + "updated_at": "", + "last_active_label": last_active, + "preview": preview, + "message_count": 0, + "input_tokens": 0, + "output_tokens": 0, + })); + } + Ok(Value::Array(sessions)) +} + +#[tauri::command] +pub async fn hermes_usage_analytics( + days: Option, + profile: Option, +) -> Result { + let days = days.unwrap_or(30).clamp(1, 365); + let cutoff = chrono::Utc::now().timestamp() - (days as i64 * 86_400); + let sessions = hermes_sessions_list(None, None, profile).await?; + let mut total_input: u64 = 0; + let mut total_output: u64 = 0; + let mut total_cache_read: u64 = 0; + let mut total_cache_write: u64 = 0; + let mut total_estimated_cost = 0.0_f64; + let mut total_actual_cost = 0.0_f64; + let mut total_sessions: u64 = 0; + let mut daily: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + let mut by_model: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + if let Some(arr) = sessions.as_array() { + for s in arr { + let started = s.get("started_at").and_then(|v| v.as_i64()).unwrap_or(0); + if started > 0 && started < cutoff { + continue; + } + let input = s.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let output = s.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let cache_read = s + .get("cache_read_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cache_write = s + .get("cache_write_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let estimated = s + .get("estimated_cost_usd") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let actual = s + .get("actual_cost_usd") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + total_input += input; + total_output += output; + total_cache_read += cache_read; + total_cache_write += cache_write; + total_estimated_cost += estimated; + total_actual_cost += actual; + total_sessions += 1; + let day = if started > 0 { + chrono::DateTime::from_timestamp(started, 0) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "unknown".into()) + } else { + "unknown".into() + }; + let d = daily.entry(day.clone()).or_insert_with(|| { + let mut m = serde_json::Map::new(); + m.insert("day".into(), Value::String(day)); + m.insert("input_tokens".into(), Value::from(0_u64)); + m.insert("output_tokens".into(), Value::from(0_u64)); + m.insert("cache_read_tokens".into(), Value::from(0_u64)); + m.insert("estimated_cost".into(), Value::from(0.0)); + m.insert("actual_cost".into(), Value::from(0.0)); + m.insert("sessions".into(), Value::from(0_u64)); + m + }); + *d.get_mut("input_tokens").unwrap() = + Value::from(d["input_tokens"].as_u64().unwrap_or(0) + input); + *d.get_mut("output_tokens").unwrap() = + Value::from(d["output_tokens"].as_u64().unwrap_or(0) + output); + *d.get_mut("cache_read_tokens").unwrap() = + Value::from(d["cache_read_tokens"].as_u64().unwrap_or(0) + cache_read); + *d.get_mut("estimated_cost").unwrap() = + Value::from(d["estimated_cost"].as_f64().unwrap_or(0.0) + estimated); + *d.get_mut("actual_cost").unwrap() = + Value::from(d["actual_cost"].as_f64().unwrap_or(0.0) + actual); + *d.get_mut("sessions").unwrap() = Value::from(d["sessions"].as_u64().unwrap_or(0) + 1); + let model = s + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if !model.is_empty() { + let model_key = model.clone(); + let m = by_model.entry(model_key.clone()).or_insert_with(|| { + let mut row = serde_json::Map::new(); + row.insert("model".into(), Value::String(model_key)); + row.insert("input_tokens".into(), Value::from(0_u64)); + row.insert("output_tokens".into(), Value::from(0_u64)); + row.insert("estimated_cost".into(), Value::from(0.0)); + row.insert("sessions".into(), Value::from(0_u64)); + row + }); + *m.get_mut("input_tokens").unwrap() = + Value::from(m["input_tokens"].as_u64().unwrap_or(0) + input); + *m.get_mut("output_tokens").unwrap() = + Value::from(m["output_tokens"].as_u64().unwrap_or(0) + output); + *m.get_mut("estimated_cost").unwrap() = + Value::from(m["estimated_cost"].as_f64().unwrap_or(0.0) + estimated); + *m.get_mut("sessions").unwrap() = + Value::from(m["sessions"].as_u64().unwrap_or(0) + 1); + } + } + } + let mut models: Vec = by_model.into_values().map(Value::Object).collect(); + models.sort_by(|a, b| { + let at = a["input_tokens"].as_u64().unwrap_or(0) + a["output_tokens"].as_u64().unwrap_or(0); + let bt = b["input_tokens"].as_u64().unwrap_or(0) + b["output_tokens"].as_u64().unwrap_or(0); + bt.cmp(&at) + }); + Ok(serde_json::json!({ + "daily": daily.into_values().map(Value::Object).collect::>(), + "by_model": models, + "totals": { + "total_input": total_input, + "total_output": total_output, + "total_cache_read": total_cache_read, + "total_cache_write": total_cache_write, + "total_estimated_cost": total_estimated_cost, + "total_actual_cost": total_actual_cost, + "total_sessions": total_sessions, + "total_api_calls": 0, + }, + "period_days": days, + "skills": { + "summary": { + "total_skill_loads": 0, + "total_skill_edits": 0, + "total_skill_actions": 0, + "distinct_skills_used": 0, + }, + "top_skills": [], + }, + })) +} + +#[tauri::command] +pub async fn hermes_session_detail( + session_id: String, + profile: Option, +) -> Result { + let mut args: Vec = Vec::new(); + if let Some(p) = profile.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + args.push("--profile".into()); + args.push(p.to_string()); + } + args.extend( + ["sessions", "export", "-", "--session-id"] + .iter() + .map(|s| s.to_string()), + ); + args.push(session_id.clone()); + let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let output = + run_silent("hermes", &refs).map_err(|e| format!("Failed to read sessions: {e}"))?; for line in output.lines() { let t = line.trim(); if t.is_empty() { @@ -2837,14 +3114,113 @@ pub async fn hermes_session_detail(session_id: String) -> Result } #[tauri::command] -pub async fn hermes_session_delete(session_id: String) -> Result { - run_silent("hermes", &["sessions", "delete", &session_id, "--yes"])?; +pub async fn hermes_session_delete( + session_id: String, + profile: Option, +) -> Result { + let mut args: Vec = Vec::new(); + if let Some(p) = profile.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + args.push("--profile".into()); + args.push(p.to_string()); + } + args.extend(["sessions", "delete"].iter().map(|s| s.to_string())); + args.push(session_id); + args.push("--yes".into()); + let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + run_silent("hermes", &refs)?; Ok("ok".into()) } #[tauri::command] -pub async fn hermes_session_rename(session_id: String, title: String) -> Result { - run_silent("hermes", &["sessions", "rename", &session_id, &title])?; +pub async fn hermes_session_rename( + session_id: String, + title: String, + profile: Option, +) -> Result { + let mut args: Vec = Vec::new(); + if let Some(p) = profile.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + args.push("--profile".into()); + args.push(p.to_string()); + } + args.extend(["sessions", "rename"].iter().map(|s| s.to_string())); + args.push(session_id); + args.push(title); + let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + run_silent("hermes", &refs)?; + Ok("ok".into()) +} + +#[tauri::command] +pub async fn hermes_profiles_list() -> Result { + let output = match run_silent("hermes", &["profile", "list"]) { + Ok(s) => s, + Err(_) => return Ok(serde_json::json!({ "active": "default", "profiles": [] })), + }; + let mut active = "default".to_string(); + let mut profiles: Vec = Vec::new(); + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() + || trimmed.contains("Profile") + || trimmed.starts_with('─') + || trimmed.starts_with('-') + { + continue; + } + let is_active = trimmed.starts_with('◆'); + let row = trimmed.trim_start_matches('◆').trim(); + let parts: Vec<&str> = row.split_whitespace().collect(); + if parts.len() < 3 { + continue; + } + let name = parts[0]; + if name != "default" + && !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + { + continue; + } + let gateway_idx = parts + .iter() + .position(|p| *p == "running" || *p == "stopped") + .unwrap_or(2); + if gateway_idx <= 1 || gateway_idx >= parts.len() { + continue; + } + let model = parts[1..gateway_idx].join(" "); + let gateway = parts[gateway_idx]; + let alias = parts.get(gateway_idx + 1).copied().unwrap_or("—"); + if is_active { + active = name.to_string(); + } + profiles.push(serde_json::json!({ + "name": name, + "active": is_active, + "model": if model == "—" { "" } else { &model }, + "gatewayRunning": gateway == "running", + "alias": if alias == "—" { "" } else { alias }, + })); + } + if !profiles + .iter() + .any(|p| p.get("active").and_then(|v| v.as_bool()).unwrap_or(false)) + { + if let Some(p) = profiles + .iter_mut() + .find(|p| p.get("name").and_then(|v| v.as_str()) == Some("default")) + { + if let Some(obj) = p.as_object_mut() { + obj.insert("active".to_string(), Value::Bool(true)); + } + } + } + Ok(serde_json::json!({ "active": active, "profiles": profiles })) +} + +#[tauri::command] +pub async fn hermes_profile_use(name: String) -> Result { + run_silent("hermes", &["profile", "use", &name])?; Ok("ok".into()) } @@ -3014,84 +3390,219 @@ fn parse_log_line(line: &str) -> ParsedLogLine { } } +/// Extract the first `# Heading` or the first long prose line from Markdown, +/// used as a skill's canonical name/description. Mirrors hermes-web-ui's +/// `extractDescription()` behaviour — first non-empty/non-heading line, +/// truncated to 200 chars. +fn md_first_heading(content: &str) -> Option { + content + .lines() + .find(|l| l.starts_with("# ")) + .map(|l| l[2..].trim().to_string()) +} + +fn md_first_description(content: &str) -> String { + content + .lines() + .find(|l| !l.starts_with('#') && !l.trim().is_empty() && l.trim().len() > 10) + .map(|l| { + let s = l.trim(); + if s.len() > 200 { + format!("{}...", &s[..200]) + } else { + s.to_string() + } + }) + .unwrap_or_default() +} + +/// Read `config.yaml` and return the list of `skills.disabled` entries. +/// Gracefully handles missing file / missing section → empty list. +/// +/// The disable mechanism matches upstream `hermes-web-ui`: +/// +/// ```yaml +/// skills: +/// disabled: +/// - web_search +/// - file_tools +/// ``` +fn read_disabled_skills() -> Vec { + let config_path = hermes_home().join("config.yaml"); + let raw = match std::fs::read_to_string(&config_path) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + let mut disabled: Vec = Vec::new(); + let mut in_skills = false; + let mut in_disabled = false; + for line in raw.lines() { + // Strip trailing comments. + let line = match line.find('#') { + Some(i) => &line[..i], + None => line, + }; + let trimmed_full = line.trim_end(); + if trimmed_full.is_empty() { + continue; + } + let indent = trimmed_full.len() - trimmed_full.trim_start().len(); + let body = trimmed_full.trim_start(); + + if indent == 0 { + in_skills = body.starts_with("skills:"); + in_disabled = false; + } else if in_skills && indent == 2 && body.starts_with("disabled:") { + in_disabled = true; + } else if in_skills && in_disabled && indent >= 4 && body.starts_with("- ") { + // Strip the `- ` prefix and any surrounding quotes. + let name = body + .trim_start_matches("- ") + .trim() + .trim_matches('"') + .trim_matches('\''); + if !name.is_empty() { + disabled.push(name.to_string()); + } + } else if indent <= 2 { + // Left the disabled list. + in_disabled = false; + } + } + disabled +} + +/// Shape returned to the frontend — kept compatible with the previous +/// version (file/name/description/path) while adding `enabled` and the +/// optional `isDir`/`category` fields that `hermes-web-ui` also uses. #[tauri::command] pub async fn hermes_skills_list() -> Result { let skills_dir = hermes_home().join("skills"); if !skills_dir.exists() { return Ok(serde_json::json!([])); } + let disabled_names = read_disabled_skills(); + let is_enabled = |name: &str| -> bool { !disabled_names.iter().any(|d| d == name) }; + let mut categories: Vec = Vec::new(); let entries = std::fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills dir: {e}"))?; + for entry in entries.flatten() { let ft = match entry.file_type() { Ok(t) => t, Err(_) => continue, }; - let name = entry.file_name().to_string_lossy().to_string(); + let cat_name = entry.file_name().to_string_lossy().to_string(); + if cat_name.starts_with('.') { + continue; + } + if ft.is_dir() { - let cat_dir = skills_dir.join(&name); + let cat_dir = skills_dir.join(&cat_name); + + // Category description from optional DESCRIPTION.md + let cat_desc = std::fs::read_to_string(cat_dir.join("DESCRIPTION.md")) + .ok() + .map(|c| { + md_first_heading(&c) + .unwrap_or_else(|| c.trim().lines().next().unwrap_or("").to_string()) + }) + .unwrap_or_default(); + let mut skills: Vec = Vec::new(); if let Ok(files) = std::fs::read_dir(&cat_dir) { for f in files.flatten() { let fname = f.file_name().to_string_lossy().to_string(); - if !fname.ends_with(".md") { + let fpath = cat_dir.join(&fname); + let ftype = match f.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + + // v0.14.1 structured skill: //SKILL.md + if ftype.is_dir() { + let skill_md = fpath.join("SKILL.md"); + if !skill_md.exists() { + continue; + } + let content = std::fs::read_to_string(&skill_md).unwrap_or_default(); + let display = md_first_heading(&content).unwrap_or_else(|| fname.clone()); + let desc = md_first_description(&content); + skills.push(serde_json::json!({ + "file": fname.clone(), + "name": display, + "slug": fname.clone(), + "description": desc, + "path": skill_md.to_string_lossy(), + "skill_dir": fpath.to_string_lossy(), + "isDir": true, + "enabled": is_enabled(&fname), + })); + continue; + } + + // Legacy flat skill: /.md + if !fname.ends_with(".md") || fname == "DESCRIPTION.md" { continue; } - let fpath = cat_dir.join(&fname); let content = std::fs::read_to_string(&fpath).unwrap_or_default(); - let skill_name = content - .lines() - .find(|l| l.starts_with("# ")) - .map(|l| l[2..].trim().to_string()) - .unwrap_or_else(|| fname.trim_end_matches(".md").to_string()); - let description = content - .lines() - .find(|l| { - !l.starts_with('#') && !l.trim().is_empty() && l.trim().len() > 10 - }) - .map(|l| { - let s = l.trim(); - if s.len() > 200 { - format!("{}...", &s[..200]) - } else { - s.to_string() - } - }) - .unwrap_or_default(); + let slug = fname.trim_end_matches(".md").to_string(); + let display = md_first_heading(&content).unwrap_or_else(|| slug.clone()); + let desc = md_first_description(&content); skills.push(serde_json::json!({ "file": fname, - "name": skill_name, - "description": description, + "name": display, + "slug": slug.clone(), + "description": desc, "path": fpath.to_string_lossy(), + "isDir": false, + "enabled": is_enabled(&slug), })); } } if !skills.is_empty() { + skills.sort_by(|a, b| { + a["name"] + .as_str() + .unwrap_or("") + .cmp(b["name"].as_str().unwrap_or("")) + }); categories.push(serde_json::json!({ - "category": name, + "category": cat_name, + "description": cat_desc, "skills": skills, })); } - } else if name.ends_with(".md") { - let fpath = skills_dir.join(&name); + } else if cat_name.ends_with(".md") && cat_name != "DESCRIPTION.md" { + // Uncategorized top-level skill file. + let fpath = skills_dir.join(&cat_name); let content = std::fs::read_to_string(&fpath).unwrap_or_default(); - let skill_name = content - .lines() - .find(|l| l.starts_with("# ")) - .map(|l| l[2..].trim().to_string()) - .unwrap_or_else(|| name.trim_end_matches(".md").to_string()); + let slug = cat_name.trim_end_matches(".md").to_string(); + let display = md_first_heading(&content).unwrap_or_else(|| slug.clone()); categories.push(serde_json::json!({ "category": "_root", + "description": "", "skills": [{ - "file": name, - "name": skill_name, - "description": "", + "file": cat_name, + "name": display, + "slug": slug.clone(), + "description": md_first_description(&content), "path": fpath.to_string_lossy(), + "isDir": false, + "enabled": is_enabled(&slug), }], })); } } + + categories.sort_by(|a, b| { + a["category"] + .as_str() + .unwrap_or("") + .cmp(b["category"].as_str().unwrap_or("")) + }); + Ok(Value::Array(categories)) } @@ -3111,14 +3622,262 @@ pub async fn hermes_skill_detail(file_path: String) -> Result { std::fs::read_to_string(&canonical).map_err(|e| format!("Failed to read skill: {e}")) } +// ============================================================================ +// Skills — enable/disable toggle (Phase 3) +// ============================================================================ + +/// Toggle a skill's enabled state by mutating `config.yaml`'s +/// `skills.disabled` list. Matches the behaviour of hermes-web-ui's +/// `PUT /api/hermes/skills/toggle`. +/// +/// * `enabled = true` → remove `name` from disabled list +/// * `enabled = false` → add `name` to disabled list +/// +/// A `config.yaml.bak-` backup is written before any mutation so +/// users can always recover a broken config. +#[tauri::command] +pub async fn hermes_skill_toggle(name: String, enabled: bool) -> Result { + if name.is_empty() { + return Err("Skill name is required".into()); + } + let config_path = hermes_home().join("config.yaml"); + let raw = std::fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read config.yaml: {e}"))?; + + // Write a timestamped backup before any mutation. + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let backup_path = hermes_home().join(format!("config.yaml.bak-{ts}")); + let _ = std::fs::write(&backup_path, &raw); + + let patched = patch_yaml_toggle_skill(&raw, &name, enabled); + std::fs::write(&config_path, &patched) + .map_err(|e| format!("Failed to write config.yaml: {e}"))?; + + Ok(serde_json::json!({ + "ok": true, + "skill": name, + "enabled": enabled, + "backup": backup_path.to_string_lossy(), + })) +} + +/// YAML patcher: add/remove `name` from `skills.disabled[]`. +/// +/// Careful to preserve line ordering + indentation + other sections so that +/// user-edited comments and custom keys survive round-trips. +fn patch_yaml_toggle_skill(raw: &str, name: &str, enabled: bool) -> String { + let mut lines: Vec = raw.lines().map(str::to_string).collect(); + + // Find `skills:` top-level key. + let skills_idx = lines.iter().position(|l| { + let trimmed = l.trim_end(); + let indent = trimmed.len() - trimmed.trim_start().len(); + indent == 0 && trimmed.trim_start().starts_with("skills:") + }); + + // If no `skills:` block exists yet, synthesize one. + if skills_idx.is_none() { + if enabled { + // Already enabled (not in any disabled list). Nothing to do. + return raw.to_string(); + } + // Append a new skills.disabled block. + if !raw.is_empty() && !raw.ends_with('\n') { + lines.push(String::new()); + } + lines.push("skills:".to_string()); + lines.push(" disabled:".to_string()); + lines.push(format!(" - {name}")); + lines.push(String::new()); + return lines.join("\n"); + } + + let skills_idx = skills_idx.unwrap(); + + // Find `disabled:` under skills. + let mut disabled_idx: Option = None; + let mut i = skills_idx + 1; + while i < lines.len() { + let trimmed = lines[i].trim_end(); + let indent = trimmed.len() - trimmed.trim_start().len(); + if !trimmed.is_empty() && indent == 0 { + break; // left the skills block + } + if indent == 2 && trimmed.trim_start().starts_with("disabled:") { + disabled_idx = Some(i); + break; + } + i += 1; + } + + // Create a `disabled:` list if absent. + if disabled_idx.is_none() { + if enabled { + // Already not disabled — nothing to do. + return raw.to_string(); + } + let insert_at = skills_idx + 1; + lines.insert(insert_at, " disabled:".to_string()); + lines.insert(insert_at + 1, format!(" - {name}")); + return lines.join("\n"); + } + + let disabled_idx = disabled_idx.unwrap(); + + // Collect existing list item line indices + their values. + let mut item_rows: Vec<(usize, String)> = Vec::new(); + let mut j = disabled_idx + 1; + while j < lines.len() { + let trimmed = lines[j].trim_end(); + let indent = trimmed.len() - trimmed.trim_start().len(); + if !trimmed.is_empty() && indent < 4 { + break; + } + let body = trimmed.trim_start(); + if body.starts_with("- ") { + let v = body + .trim_start_matches("- ") + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + item_rows.push((j, v)); + } + j += 1; + } + + let has_item = item_rows.iter().any(|(_, v)| v == name); + + if enabled { + // Remove all rows that match. + if !has_item { + return raw.to_string(); + } + let to_remove: Vec = item_rows + .iter() + .filter(|(_, v)| v == name) + .map(|(i, _)| *i) + .collect(); + for idx in to_remove.iter().rev() { + lines.remove(*idx); + } + } else { + if has_item { + return raw.to_string(); + } + // Insert right after the `disabled:` key line or at the end of + // existing items — whichever produces stable ordering. + let insert_at = item_rows + .last() + .map(|(i, _)| *i + 1) + .unwrap_or(disabled_idx + 1); + lines.insert(insert_at, format!(" - {name}")); + } + + lines.join("\n") +} + +/// Recursively list all files inside a skill directory. Returns an array +/// of `{ path, name, isDir }` where `path` is relative to `~/.hermes/`. +/// Skips the top-level `SKILL.md` because the UI already renders it +/// separately in the detail pane. +#[tauri::command] +pub async fn hermes_skill_files(category: String, skill: String) -> Result { + let skills_root = hermes_home().join("skills"); + let skill_dir = skills_root.join(&category).join(&skill); + if !skill_dir.exists() || !skill_dir.is_dir() { + return Ok(serde_json::json!([])); + } + + let mut out: Vec = Vec::new(); + fn walk(root: &PathBuf, rel_base: &str, out: &mut Vec) { + let entries = match std::fs::read_dir(root) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let rel = if rel_base.is_empty() { + name.clone() + } else { + format!("{rel_base}/{name}") + }; + let full = root.join(&name); + let is_dir = full.is_dir(); + // Skip the flagship SKILL.md at the root level. + if rel_base.is_empty() && name == "SKILL.md" { + continue; + } + out.push(serde_json::json!({ + "path": rel, + "name": name, + "isDir": is_dir, + })); + if is_dir { + walk(&full, &rel, out); + } + } + } + walk(&skill_dir, "", &mut out); + out.sort_by(|a, b| { + a["path"] + .as_str() + .unwrap_or("") + .cmp(b["path"].as_str().unwrap_or("")) + }); + Ok(Value::Array(out)) +} + +/// Write (create/update) a skill file. Path must be inside +/// `~/.hermes/skills/`. Intermediate directories are auto-created. +#[tauri::command] +pub async fn hermes_skill_write(file_path: String, content: String) -> Result { + let skills_dir = hermes_home().join("skills"); + let target = PathBuf::from(&file_path); + + // Ensure the target lives under the skills directory. We compare + // absolute-normalized paths to allow writing *new* files (which cannot + // be canonicalized yet) while still rejecting traversal. + let skills_canon = skills_dir + .canonicalize() + .map_err(|e| format!("Skills dir not accessible: {e}"))?; + let target_abs = if target.is_absolute() { + target.clone() + } else { + skills_dir.join(&target) + }; + let parent = target_abs + .parent() + .ok_or_else(|| "Invalid target path".to_string())?; + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create dir: {e}"))?; + let parent_canon = parent + .canonicalize() + .map_err(|e| format!("Path error: {e}"))?; + if !parent_canon.starts_with(&skills_canon) { + return Err("Access denied".into()); + } + std::fs::write(&target_abs, &content).map_err(|e| format!("Failed to write skill: {e}"))?; + Ok("ok".into()) +} + +/// Resolve `memory|user|soul` to its filename inside `~/.hermes/memories/`. +fn memory_file_name(kind: &str) -> Option<&'static str> { + match kind { + "memory" => Some("MEMORY.md"), + "user" => Some("USER.md"), + "soul" => Some("SOUL.md"), + _ => None, + } +} + #[tauri::command] pub async fn hermes_memory_read(r#type: Option) -> Result { let kind = r#type.as_deref().unwrap_or("memory"); - let file_name = if kind == "user" { - "USER.md" - } else { - "MEMORY.md" - }; + let file_name = memory_file_name(kind) + .ok_or_else(|| format!("Invalid memory kind '{kind}' (expected memory|user|soul)"))?; let file_path = hermes_home().join("memories").join(file_name); if !file_path.exists() { return Ok(String::new()); @@ -3132,18 +3891,102 @@ pub async fn hermes_memory_write( content: String, ) -> Result { let kind = r#type.as_deref().unwrap_or("memory"); + let file_name = memory_file_name(kind) + .ok_or_else(|| format!("Invalid memory kind '{kind}' (expected memory|user|soul)"))?; let mem_dir = hermes_home().join("memories"); std::fs::create_dir_all(&mem_dir).map_err(|e| format!("Failed to create dir: {e}"))?; - let file_name = if kind == "user" { - "USER.md" - } else { - "MEMORY.md" - }; let file_path = mem_dir.join(file_name); std::fs::write(&file_path, &content).map_err(|e| format!("Failed to write memory: {e}"))?; Ok("ok".into()) } +/// Read all memory sections (memory/user/soul) in one call, returning content +/// + last-modified UNIX timestamp (seconds) for each. A missing file yields an +/// empty string and `None` mtime — the caller shows "not yet written" state. +/// +/// Shape matches `hermes-web-ui`'s `GET /api/hermes/memory` response so the +/// frontend can mirror the official UI's three-column layout. +#[tauri::command] +pub async fn hermes_memory_read_all() -> Result { + let mem_dir = hermes_home().join("memories"); + let section = |kind: &str| -> (String, Option) { + let name = match memory_file_name(kind) { + Some(n) => n, + None => return (String::new(), None), + }; + let path = mem_dir.join(name); + if !path.exists() { + return (String::new(), None); + } + let content = std::fs::read_to_string(&path).unwrap_or_default(); + let mtime = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + (content, mtime) + }; + let (memory, memory_mtime) = section("memory"); + let (user, user_mtime) = section("user"); + let (soul, soul_mtime) = section("soul"); + Ok(serde_json::json!({ + "memory": memory, + "user": user, + "soul": soul, + "memory_mtime": memory_mtime, + "user_mtime": user_mtime, + "soul_mtime": soul_mtime, + })) +} + +fn downloads_dir_fallback() -> PathBuf { + dirs::download_dir() + .or_else(dirs::home_dir) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn safe_download_filename(name: &str) -> String { + name.chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => c, + }) + .collect() +} + +/// Read an entire log file and save it to the user's Downloads/ClawPanel +/// directory. We refuse path traversal and only allow files whose canonical +/// path lives inside `~/.hermes/logs/`. +#[tauri::command] +pub async fn hermes_logs_download(name: String) -> Result { + // Reject traversal before any disk access. + if name.is_empty() || name.contains("..") || name.contains('/') || name.contains('\\') { + return Err("Invalid log file name".into()); + } + let logs_dir = hermes_home().join("logs"); + let file_path = logs_dir.join(&name); + // Canonicalize both sides to ensure symlinks/relative segments can't + // escape the logs directory. + let canon_dir = logs_dir + .canonicalize() + .map_err(|e| format!("Logs dir not found: {e}"))?; + let canon_file = file_path + .canonicalize() + .map_err(|e| format!("Log file not found: {e}"))?; + if !canon_file.starts_with(&canon_dir) { + return Err("Access denied".into()); + } + let content = + std::fs::read_to_string(&canon_file).map_err(|e| format!("Failed to read log: {e}"))?; + let out_dir = downloads_dir_fallback().join("ClawPanel"); + std::fs::create_dir_all(&out_dir).map_err(|e| format!("Failed to create download dir: {e}"))?; + let out_path = out_dir.join(safe_download_filename(&name)); + std::fs::write(&out_path, content).map_err(|e| format!("Failed to save log: {e}"))?; + Ok(serde_json::json!({ + "path": out_path.to_string_lossy().to_string(), + })) +} + // ============================================================================ // api_server guardian (Step 5 / G7) // @@ -3511,6 +4354,255 @@ pub fn hermes_env_delete(key: String) -> Result<(), String> { Ok(()) } +#[tauri::command] +pub fn hermes_config_raw_read() -> Result { + let path = hermes_home().join("config.yaml"); + let yaml = std::fs::read_to_string(&path).unwrap_or_default(); + Ok(serde_json::json!({ "yaml": yaml })) +} + +#[tauri::command] +pub fn hermes_config_raw_write(yaml_text: String) -> Result { + let path = hermes_home().join("config.yaml"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?; + } + if path.exists() { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let backup = path.with_extension(format!("yaml.bak-{ts}")); + let _ = std::fs::copy(&path, backup); + } + std::fs::write(&path, yaml_text).map_err(|e| format!("Failed to write config.yaml: {e}"))?; + Ok(serde_json::json!({ "ok": true })) +} + +#[tauri::command] +pub fn hermes_env_reveal(key: String) -> Result { + let key = key.trim().to_string(); + if key.is_empty() { + return Err("Key cannot be empty".into()); + } + let env_path = hermes_home().join(".env"); + let raw = + std::fs::read_to_string(&env_path).map_err(|e| format!("Failed to read .env: {e}"))?; + for (k, v, _) in parse_env_file_lines(&raw) { + if k == key { + return Ok(serde_json::json!({ "key": key, "value": v })); + } + } + Err(format!("{key} not found in .env")) +} + +fn hermes_dashboard_theme_name(raw: &str) -> String { + let mut in_dashboard = false; + for line in raw.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + continue; + } + let indent = line.len() - line.trim_start().len(); + if indent == 0 { + in_dashboard = t == "dashboard:" || t.starts_with("dashboard:"); + if t.starts_with("dashboard:") && t != "dashboard:" { + return t + .trim_start_matches("dashboard:") + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + } + continue; + } + if in_dashboard && t.starts_with("theme:") { + return t + .trim_start_matches("theme:") + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + } + } + "default".into() +} + +fn patch_dashboard_theme(raw: &str, name: &str) -> String { + let mut out: Vec = Vec::new(); + let mut in_dashboard = false; + let mut dashboard_seen = false; + let mut theme_written = false; + for line in raw.lines() { + let t = line.trim(); + let indent = line.len() - line.trim_start().len(); + if indent == 0 && !t.is_empty() && !t.starts_with('#') { + if in_dashboard && !theme_written { + out.push(format!(" theme: {name}")); + theme_written = true; + } + in_dashboard = t == "dashboard:" || t.starts_with("dashboard:"); + if in_dashboard { + dashboard_seen = true; + } + } + if in_dashboard && indent > 0 && t.starts_with("theme:") { + out.push(format!("{}theme: {name}", " ".repeat(indent))); + theme_written = true; + continue; + } + out.push(line.to_string()); + } + if in_dashboard && !theme_written { + out.push(format!(" theme: {name}")); + } + if !dashboard_seen { + if out.last().map(|s| !s.is_empty()).unwrap_or(false) { + out.push(String::new()); + } + out.push("dashboard:".into()); + out.push(format!(" theme: {name}")); + } + let mut content = out.join("\n"); + if !content.ends_with('\n') { + content.push('\n'); + } + content +} + +#[tauri::command] +pub fn hermes_dashboard_themes() -> Result { + let config_raw = std::fs::read_to_string(hermes_home().join("config.yaml")).unwrap_or_default(); + let active = hermes_dashboard_theme_name(&config_raw); + let mut themes = vec![ + serde_json::json!({ "name": "default", "label": "Default", "description": "Hermes default dashboard theme" }), + serde_json::json!({ "name": "midnight", "label": "Midnight", "description": "Dark blue dashboard theme" }), + serde_json::json!({ "name": "ember", "label": "Ember", "description": "Warm dashboard theme" }), + serde_json::json!({ "name": "mono", "label": "Mono", "description": "Monochrome dashboard theme" }), + serde_json::json!({ "name": "cyberpunk", "label": "Cyberpunk", "description": "Neon dashboard theme" }), + serde_json::json!({ "name": "rose", "label": "Rose", "description": "Soft rose dashboard theme" }), + ]; + let dir = hermes_home().join("dashboard-themes"); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + let ext_ok = path + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.eq_ignore_ascii_case("yaml") || s.eq_ignore_ascii_case("yml")) + .unwrap_or(false); + if !ext_ok { + continue; + } + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + themes.push(serde_json::json!({ + "name": name, + "label": name, + "description": "User dashboard theme", + })); + } + } + } + Ok(serde_json::json!({ "themes": themes, "active": active })) +} + +#[tauri::command] +pub fn hermes_dashboard_theme_set(name: String) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + return Err("Theme name cannot be empty".into()); + } + let path = hermes_home().join("config.yaml"); + let raw = std::fs::read_to_string(&path).unwrap_or_default(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?; + } + std::fs::write(&path, patch_dashboard_theme(&raw, &name)) + .map_err(|e| format!("Failed to write config.yaml: {e}"))?; + Ok(serde_json::json!({ "ok": true, "theme": name })) +} + +fn scan_dashboard_plugins() -> Vec { + let mut plugins = Vec::new(); + let mut seen = std::collections::HashSet::::new(); + let roots = [hermes_home().join("plugins")]; + for root in roots { + if let Ok(entries) = std::fs::read_dir(root) { + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let manifest = dir.join("dashboard").join("manifest.json"); + if !manifest.exists() { + continue; + } + let raw = match std::fs::read_to_string(&manifest) { + Ok(s) => s, + Err(_) => continue, + }; + let data: Value = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(_) => continue, + }; + let name = data + .get("name") + .and_then(|v| v.as_str()) + .or_else(|| dir.file_name().and_then(|s| s.to_str())) + .unwrap_or(""); + if name.is_empty() || !seen.insert(name.to_string()) { + continue; + } + let tab = data.get("tab").cloned().unwrap_or_else( + || serde_json::json!({ "path": format!("/{name}"), "position": "end" }), + ); + plugins.push(serde_json::json!({ + "name": name, + "label": data.get("label").and_then(|v| v.as_str()).unwrap_or(name), + "description": data.get("description").and_then(|v| v.as_str()).unwrap_or(""), + "icon": data.get("icon").and_then(|v| v.as_str()).unwrap_or("Puzzle"), + "version": data.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"), + "tab": tab, + "slots": data.get("slots").cloned().unwrap_or_else(|| serde_json::json!([])), + "entry": data.get("entry").and_then(|v| v.as_str()).unwrap_or("dist/index.js"), + "css": data.get("css").cloned().unwrap_or(Value::Null), + "has_api": data.get("api").is_some(), + "source": "user", + })); + } + } + } + plugins +} + +#[tauri::command] +pub fn hermes_dashboard_plugins() -> Result { + Ok(Value::Array(scan_dashboard_plugins())) +} + +#[tauri::command] +pub fn hermes_dashboard_plugins_rescan() -> Result { + let plugins = scan_dashboard_plugins(); + Ok(serde_json::json!({ "ok": true, "count": plugins.len() })) +} + +#[tauri::command] +pub fn hermes_toolsets_list() -> Result { + let output = run_silent("hermes", &["tools", "list", "--platform", "cli"]).unwrap_or_default(); + Ok(serde_json::json!({ "raw": output })) +} + +#[tauri::command] +pub fn hermes_cron_jobs_list() -> Result { + let path = hermes_home().join("cron").join("jobs.json"); + if !path.exists() { + return Ok(Value::Array(Vec::new())); + } + let raw = + std::fs::read_to_string(&path).map_err(|e| format!("Failed to read cron jobs: {e}"))?; + serde_json::from_str::(&raw).map_err(|e| format!("Failed to parse cron jobs: {e}")) +} + // ============================================================================ // Unit tests for the pure YAML helpers (no filesystem I/O). // ============================================================================ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf8b30f..c490285 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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| { // 关闭窗口时最小化到托盘,不退出应用 diff --git a/src/components/sidebar.js b/src/components/sidebar.js index cf35852..ae2f903 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -88,7 +88,9 @@ const ICONS = { agents: '', gateway: '', memory: '', + inbox: '', extensions: '', + package: '', about: '', assistant: '', security: '', diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index da1ab59..bdd3748 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -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') }, diff --git a/src/engines/hermes/lib/chat-store.js b/src/engines/hermes/lib/chat-store.js new file mode 100644 index 0000000..c0c120a --- /dev/null +++ b/src/engines/hermes/lib/chat-store.js @@ -0,0 +1,1013 @@ +/** + * Hermes Chat Store — reactive state for sessions, messages and streaming. + * + * Mirrors the shape of `hermes-web-ui`'s Pinia `chat` store in a dependency- + * free, vanilla JS pub/sub style. A single instance is exported (`chatStore`); + * the page subscribes via `chatStore.subscribe(listener)` and receives a + * notification on every mutation. + * + * Responsibilities: + * - Load sessions from the backend (via `api.hermesSessionsList`) and merge + * with local-only sessions that haven't been flushed yet. + * - Load + map a session's messages (role/content/tool details). + * - Handle streaming via Tauri's `hermes-run-*` events, accumulating delta + * text into an assistant message and tracking live tool calls. + * - Persist session summaries + per-session messages to `localStorage` so + * reopening the page renders instantly while server data revalidates. + * - Manage pinned sessions + collapsed groups (UI prefs). + * + * Non-responsibilities (left for the page): + * - Rendering (the store never touches the DOM). + * - File attachment uploads (kept out of scope for Phase 4). + * - Full tmux-like run resume (Tauri events are in-process and reliable). + */ +import { api } from '../../../lib/tauri-api.js' + +// ---------- constants ---------- + +const STORAGE_PROFILE = 'hermes_chat_profile_v1' +const STORAGE_SESSIONS_PREFIX = 'hermes_chat_sessions_v2_' +const STORAGE_ACTIVE_PREFIX = 'hermes_chat_active_v2_' +const STORAGE_PINNED_PREFIX = 'hermes_chat_pinned_' +const STORAGE_COLLAPSED_PREFIX = 'hermes_chat_collapsed_groups_' +const STORAGE_MSGS_PREFIX = 'hermes_chat_msgs_v2_' +const LIVE_BADGE_WINDOW_MS = 5 * 60 * 1000 // 5 min + +const SOURCE_LABELS = { + telegram: 'Telegram', + api_server: 'API Server', + cli: 'CLI', + discord: 'Discord', + slack: 'Slack', + matrix: 'Matrix', + whatsapp: 'WhatsApp', + signal: 'Signal', + email: 'Email', + sms: 'SMS', + dingtalk: 'DingTalk', + feishu: 'Feishu', + wecom: 'WeCom', + weixin: 'WeChat', + bluebubbles: 'iMessage', + mattermost: 'Mattermost', + cron: 'Cron', +} + +export function getSourceLabel(source) { + if (!source) return '' + return SOURCE_LABELS[source] || source +} + +// ---------- helpers ---------- + +function uid() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) +} + +function safeGet(key) { + try { return localStorage.getItem(key) } catch { return null } +} +function safeSet(key, value) { + try { localStorage.setItem(key, value) } catch {} +} +function safeRemove(key) { + try { localStorage.removeItem(key) } catch {} +} + +function loadJson(key) { + try { + const raw = safeGet(key) + return raw ? JSON.parse(raw) : null + } catch { return null } +} +function saveJson(key, value) { + try { safeSet(key, JSON.stringify(value)) } catch {} +} + +function profileKey(profile) { + return encodeURIComponent(profile || 'default') +} + +function parseEpochMs(value) { + if (typeof value === 'number') { + // Seconds vs milliseconds heuristic. + return value < 1e12 ? Math.round(value * 1000) : Math.round(value) + } + if (typeof value === 'string' && value.trim()) { + const t = Date.parse(value) + return Number.isFinite(t) ? t : 0 + } + return 0 +} + +// ---------- message mapping ---------- + +/** + * Convert Hermes CLI-exported messages (mixed roles + tool_calls) into the + * flat display list we render. Matches `hermes-web-ui`'s `mapHermesMessages`. + */ +function mapHermesMessages(msgs) { + if (!Array.isArray(msgs)) return [] + + const toolNameMap = new Map() + const toolArgsMap = new Map() + for (const m of msgs) { + if (m.role === 'assistant' && Array.isArray(m.tool_calls)) { + for (const tc of m.tool_calls) { + if (tc.id) { + if (tc.function?.name) toolNameMap.set(tc.id, tc.function.name) + if (tc.function?.arguments) toolArgsMap.set(tc.id, tc.function.arguments) + } + } + } + } + + const out = [] + for (const m of msgs) { + const ts = parseEpochMs(m.timestamp || m.created_at) + + // Assistant message whose only payload is tool_calls — emit placeholder + // tool messages, the actual tool responses will fill them in. + if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length && !(m.content || '').trim()) { + for (const tc of m.tool_calls) { + out.push({ + id: String(m.id) + '_' + tc.id, + role: 'tool', + content: '', + timestamp: ts, + toolName: tc.function?.name || 'tool', + toolArgs: tc.function?.arguments || undefined, + toolStatus: 'done', + }) + } + continue + } + + if (m.role === 'tool') { + const tcId = m.tool_call_id || '' + const toolName = m.tool_name || toolNameMap.get(tcId) || 'tool' + const toolArgs = toolArgsMap.get(tcId) || undefined + let preview = '' + if (m.content) { + try { + const parsed = JSON.parse(m.content) + preview = parsed.url || parsed.title || parsed.preview || parsed.summary || '' + } catch { + preview = String(m.content).slice(0, 80) + } + } + const phIdx = out.findIndex(x => x.role === 'tool' && x.toolName === toolName && !x.toolResult && x.id.includes('_' + tcId)) + if (phIdx !== -1) out.splice(phIdx, 1) + out.push({ + id: String(m.id), + role: 'tool', + content: '', + timestamp: ts, + toolName, + toolArgs, + toolPreview: typeof preview === 'string' ? (preview.slice(0, 100) || undefined) : undefined, + toolResult: m.content || undefined, + toolStatus: 'done', + }) + continue + } + + // Plain user/assistant/system message. + out.push({ + id: String(m.id || uid()), + role: m.role || 'assistant', + content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content || ''), + timestamp: ts, + }) + } + return out +} + +/** Convert a backend session summary into the store's canonical shape. */ +function mapSessionSummary(s) { + return { + id: s.id || s.session_id || '', + title: s.title || '', + source: s.source || '', + model: s.model || '', + messageCount: s.message_count || 0, + createdAt: parseEpochMs(s.created_at || s.started_at), + updatedAt: parseEpochMs(s.updated_at || s.last_active || s.ended_at || s.created_at || s.started_at), + endedAt: s.ended_at != null ? parseEpochMs(s.ended_at) : null, + lastActiveAt: s.last_active != null ? parseEpochMs(s.last_active) : undefined, + // Usage analytics — surfaced from `hermes sessions export` JSONL + // (Rust command at hermes.rs::hermes_sessions_list). Match the Hermes + // CLI naming so other consumers (Usage page) can reuse the same fields. + inputTokens: Number(s.input_tokens || 0), + outputTokens: Number(s.output_tokens || 0), + cacheReadTokens: Number(s.cache_read_tokens || 0), + cacheWriteTokens: Number(s.cache_write_tokens || 0), + estimatedCostUsd: typeof s.estimated_cost_usd === 'number' ? s.estimated_cost_usd : null, + messages: [], + } +} + +// ---------- Tauri event bridge ---------- + +let _listenFn = null +async function tauriListen(event, cb) { + if (!_listenFn) { + const mod = await import('@tauri-apps/api/event') + _listenFn = mod.listen + } + return _listenFn(event, cb) +} + +// ---------- store implementation ---------- + +function createStore() { + // --- state --- + const state = { + sessions: [], + activeSessionId: null, + loading: false, + loadingMessages: false, + streaming: false, + runningSessionId: null, + pendingAssistantId: null, // id of the currently streaming assistant message + error: null, + profiles: [], + activeProfile: safeGet(STORAGE_PROFILE) || 'default', + loadingProfiles: false, + + // Live tool calls for the current run (shown in the streaming indicator). + liveTools: [], // [{ id, name, status, preview, args, result }] + + // UI prefs (persisted). + pinned: new Set(loadJson(STORAGE_PINNED_PREFIX + profileKey(safeGet(STORAGE_PROFILE) || 'default')) || []), + collapsed: new Set(loadJson(STORAGE_COLLAPSED_PREFIX + profileKey(safeGet(STORAGE_PROFILE) || 'default')) || []), + } + + // --- subscription --- + // + // Uses rAF-batched notify so a burst of mutations (e.g. streaming delta + + // tool events) produces a single redraw per frame instead of one per event. + // This avoids the visual stutter + scroll jitter seen in Phase 4. + const listeners = new Set() + let scheduled = false + function subscribe(fn) { + listeners.add(fn) + return () => listeners.delete(fn) + } + function flushNotify() { + scheduled = false + for (const fn of listeners) { + try { fn(state) } catch (e) { console.error('chatStore listener error:', e) } + } + } + function notify() { + if (scheduled) return + scheduled = true + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(flushNotify) + } else { + setTimeout(flushNotify, 0) + } + } + /** Force an immediate, unbatched notification (used by deterministic tests). */ + function notifySync() { + scheduled = false + flushNotify() + } + + // --- persistence --- + const sessionsKey = () => STORAGE_SESSIONS_PREFIX + profileKey(state.activeProfile) + const activeKey = () => STORAGE_ACTIVE_PREFIX + profileKey(state.activeProfile) + const pinnedKey = () => STORAGE_PINNED_PREFIX + profileKey(state.activeProfile) + const collapsedKey = () => STORAGE_COLLAPSED_PREFIX + profileKey(state.activeProfile) + const messagesKey = (sid) => STORAGE_MSGS_PREFIX + profileKey(state.activeProfile) + '_' + sid + + function persistSessions() { + saveJson(sessionsKey(), state.sessions.map(s => ({ ...s, messages: [] }))) + } + function persistActiveMessages() { + persistSessionMessages(state.activeSessionId) + } + function persistSessionMessages(sessionId) { + const sid = sessionId + if (!sid) return + const s = state.sessions.find(x => x.id === sid) + if (s) saveJson(messagesKey(sid), s.messages) + } + function loadSessionsCache() { + const cached = loadJson(sessionsKey()) + if (Array.isArray(cached) && cached.length) { + state.sessions = cached + const savedActive = safeGet(activeKey()) + const target = savedActive && cached.find(s => s.id === savedActive) + if (target) { + const msgs = loadJson(messagesKey(target.id)) + if (Array.isArray(msgs)) target.messages = msgs + state.activeSessionId = target.id + } + } + } + + function loadProfilePrefs() { + state.pinned = new Set(loadJson(pinnedKey()) || []) + state.collapsed = new Set(loadJson(collapsedKey()) || []) + } + + function savePinned() { saveJson(pinnedKey(), [...state.pinned]) } + function saveCollapsed() { saveJson(collapsedKey(), [...state.collapsed]) } + + // --- derived queries --- + function activeSession() { + return state.sessions.find(s => s.id === state.activeSessionId) || null + } + + function isSessionLive(sessionId) { + if (state.streaming && sessionId === state.runningSessionId) return true + const s = state.sessions.find(x => x.id === sessionId) + if (!s?.lastActiveAt || s.endedAt != null) return false + return Date.now() - s.lastActiveAt <= LIVE_BADGE_WINDOW_MS + } + + /** Group sessions by source. Pinned ones go in a separate bucket. */ + function groupedSessions() { + const pinnedList = state.sessions + .filter(s => state.pinned.has(s.id)) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) + + const bySource = new Map() + for (const s of state.sessions) { + if (state.pinned.has(s.id)) continue + const key = s.source || '' + if (!bySource.has(key)) bySource.set(key, []) + bySource.get(key).push(s) + } + + const sortKey = (src) => { + if (src === 'api_server') return -1 + if (src === '') return 0 + if (src === 'cron') return 999 + return 1 + } + + const keys = [...bySource.keys()].sort((a, b) => { + const ka = sortKey(a) + const kb = sortKey(b) + if (ka !== kb) return ka - kb + return a.localeCompare(b) + }) + + const groups = keys.map(src => ({ + source: src, + label: src ? getSourceLabel(src) : 'Local', + sessions: bySource.get(src).slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)), + })) + + return { pinned: pinnedList, groups } + } + + // --- actions --- + async function loadSessions() { + state.loading = true + notify() + try { + const list = await api.hermesSessionsList() + const fresh = (Array.isArray(list) ? list : []).map(mapSessionSummary) + const freshIds = new Set(fresh.map(s => s.id)) + + // Preserve cached messages for sessions still present on the server. + const prevMsgs = new Map(state.sessions.map(s => [s.id, s.messages])) + for (const s of fresh) { + const prev = prevMsgs.get(s.id) + if (prev?.length) s.messages = prev + } + + // Keep local-only sessions (not yet flushed to the backend). + const localOnly = state.sessions.filter(s => s.source === '__local__' && !freshIds.has(s.id)) + state.sessions = [...localOnly, ...fresh] + persistSessions() + + if (!state.activeSessionId || !state.sessions.some(s => s.id === state.activeSessionId)) { + if (state.sessions.length) { + await switchSession(state.sessions[0].id) + } else { + createLocalSession() + } + } else { + // Refresh active session messages. + await refreshActiveMessages() + } + } catch (e) { + state.error = e?.message || String(e) + } finally { + state.loading = false + notify() + } + } + + async function loadProfiles() { + state.loadingProfiles = true + notify() + try { + const data = await api.hermesProfilesList() + const profiles = Array.isArray(data?.profiles) ? data.profiles : [] + state.profiles = profiles + const active = data?.active || profiles.find(p => p.active)?.name || state.activeProfile || 'default' + if (active !== state.activeProfile) { + state.activeProfile = active + safeSet(STORAGE_PROFILE, active) + state.sessions = [] + state.activeSessionId = null + loadProfilePrefs() + loadSessionsCache() + } + } finally { + state.loadingProfiles = false + notify() + } + } + + async function switchProfile(name) { + if (!name || name === state.activeProfile || state.streaming) return + await api.hermesProfileUse(name) + state.activeProfile = name + safeSet(STORAGE_PROFILE, name) + state.sessions = [] + state.activeSessionId = null + state.liveTools = [] + loadProfilePrefs() + loadSessionsCache() + notify() + await loadProfiles() + await loadSessions() + } + + async function refreshActiveMessages() { + const sid = state.activeSessionId + if (!sid) return + const target = state.sessions.find(s => s.id === sid) + if (!target) return + // Skip remote fetch for local-only sessions — the backend doesn't know them. + if (target.source === '__local__') return + + try { + const detail = await api.hermesSessionDetail(sid) + if (!detail) return + const mapped = mapHermesMessages(detail.messages || []) + + // Heuristic: only overwrite if server view has >= user turns + content + const local = target.messages || [] + const localUsers = local.filter(m => m.role === 'user').length + const serverUsers = mapped.filter(m => m.role === 'user').length + const localAsstLen = [...local].reverse().find(m => m.role === 'assistant')?.content?.length || 0 + const serverAsstLen = [...mapped].reverse().find(m => m.role === 'assistant')?.content?.length || 0 + const serverIsAhead = serverUsers > localUsers || (serverUsers === localUsers && serverAsstLen >= localAsstLen) + if (serverIsAhead) { + target.messages = mapped + if (detail.title) target.title = detail.title + persistActiveMessages() + } + } catch { + // Session may not exist on server yet (local-only) — that's fine. + } + } + + function createLocalSession() { + const s = { + id: uid(), + title: '', + source: '__local__', + model: '', + messageCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + endedAt: null, + lastActiveAt: undefined, + messages: [], + } + state.sessions.unshift(s) + state.activeSessionId = s.id + safeSet(activeKey(), s.id) + persistSessions() + notify() + return s + } + + async function switchSession(sessionId) { + state.activeSessionId = sessionId + safeSet(activeKey(), sessionId) + const target = state.sessions.find(s => s.id === sessionId) + if (!target) { notify(); return } + + // Instant render: hydrate from cache if messages are empty. + if (!target.messages?.length) { + const cached = loadJson(messagesKey(sessionId)) + if (Array.isArray(cached) && cached.length) target.messages = cached + } + + const needsBlocking = !target.messages?.length && target.source !== '__local__' + if (needsBlocking) state.loadingMessages = true + notify() + + await refreshActiveMessages() + state.loadingMessages = false + notify() + } + + function newChat() { + if (state.streaming) return + createLocalSession() + } + + async function deleteSession(sessionId) { + if (state.streaming && sessionId === state.runningSessionId) { + throw new Error('RUNNING_SESSION') + } + const target = state.sessions.find(s => s.id === sessionId) + if (target && target.source !== '__local__') { + await api.hermesSessionDelete(sessionId) + } + state.sessions = state.sessions.filter(s => s.id !== sessionId) + state.pinned.delete(sessionId) + savePinned() + safeRemove(messagesKey(sessionId)) + persistSessions() + + if (state.activeSessionId === sessionId) { + if (state.sessions.length) { + await switchSession(state.sessions[0].id) + return + } + createLocalSession() + return + } + notify() + } + + /** + * Delete multiple sessions sequentially. The Hermes CLI doesn't expose a + * batch endpoint, so we call `hermesSessionDelete` one-by-one for backend- + * backed sessions and remove local-only ones in memory. The currently + * streaming session (if any) is reported in `skipped` instead of failing. + * + * Returns `{ deleted, skipped, failed }`. + */ + async function bulkDeleteSessions(sessionIds) { + const ids = Array.from(new Set((sessionIds || []).filter(Boolean))) + const deleted = [] + const skipped = [] + const failed = [] + for (const sid of ids) { + if (state.streaming && sid === state.runningSessionId) { + skipped.push(sid) + continue + } + const target = state.sessions.find(s => s.id === sid) + if (!target) { + skipped.push(sid) + continue + } + try { + if (target.source !== '__local__') { + await api.hermesSessionDelete(sid) + } + deleted.push(sid) + } catch (e) { + failed.push({ id: sid, error: e?.message || String(e) }) + } + } + if (deleted.length) { + const deletedSet = new Set(deleted) + state.sessions = state.sessions.filter(s => !deletedSet.has(s.id)) + for (const sid of deleted) { + state.pinned.delete(sid) + safeRemove(messagesKey(sid)) + } + savePinned() + persistSessions() + if (state.activeSessionId && deletedSet.has(state.activeSessionId)) { + if (state.sessions.length) { + await switchSession(state.sessions[0].id) + } else { + createLocalSession() + } + } else { + notify() + } + } else { + notify() + } + return { deleted, skipped, failed } + } + + async function renameSession(sessionId, title) { + const trimmed = (title || '').trim() + if (!trimmed) return false + const target = state.sessions.find(s => s.id === sessionId) + if (!target) return false + // Remote-only if the session is persisted. + if (target.source !== '__local__') { + try { await api.hermesSessionRename(sessionId, trimmed) } + catch { return false } + } + target.title = trimmed + target.updatedAt = Date.now() + persistSessions() + notify() + return true + } + + function togglePinned(sessionId) { + if (state.pinned.has(sessionId)) state.pinned.delete(sessionId) + else state.pinned.add(sessionId) + savePinned() + notify() + } + + function toggleCollapsed(source) { + if (state.collapsed.has(source)) state.collapsed.delete(source) + else state.collapsed.add(source) + saveCollapsed() + notify() + } + + // ---------- streaming ---------- + + const unlisteners = [] + async function attachStreamListeners(runSessionId) { + detachStreamListeners() + const runSession = () => state.sessions.find(x => x.id === runSessionId) || null + const u1 = await tauriListen('hermes-run-delta', (e) => { + const delta = e?.payload?.delta || '' + if (!delta) return + const s = runSession() + if (!s) return + let msg = s.messages.find(m => m.id === state.pendingAssistantId) + if (!msg) { + msg = { id: uid(), role: 'assistant', content: '', timestamp: Date.now(), isStreaming: true } + s.messages.push(msg) + state.pendingAssistantId = msg.id + } + msg.content += delta + notify() + }) + const u2 = await tauriListen('hermes-run-tool', (e) => { + const evt = e?.payload || {} + const evtType = evt.event || '' + const toolName = evt.tool || evt.tool_name || evt.name || 'tool' + const preview = evt.preview || evt.detail || evt.message || '' + const extract = (obj, keys) => { + for (const k of keys) { + if (obj[k] != null && obj[k] !== '') return obj[k] + } + return null + } + if (evtType === 'tool.started') { + const input = extract(evt, ['input', 'args', 'arguments', 'parameters', 'params', 'data']) + state.liveTools.push({ + id: uid(), + name: toolName, + status: 'running', + preview, + args: input, + result: null, + error: null, + }) + } else if (evtType === 'tool.completed') { + const t = state.liveTools.find(x => x.name === toolName && x.status === 'running') + if (t) { + t.status = evt.error ? 'error' : 'done' + t.preview = evt.error ? (typeof evt.error === 'string' ? evt.error : 'failed') : preview + t.result = extract(evt, ['output', 'result', 'content', 'data', 'response']) + if (evt.error) t.error = typeof evt.error === 'string' ? evt.error : JSON.stringify(evt.error) + if (!t.args) t.args = extract(evt, ['input', 'args', 'arguments', 'parameters', 'params']) + } + } else if (evtType === 'tool.error') { + const t = state.liveTools.find(x => x.name === toolName && x.status === 'running') + if (t) { + t.status = 'error' + t.preview = preview || 'failed' + t.error = evt.error || preview || 'unknown' + } + } else if (evtType === 'tool.progress') { + const t = state.liveTools.find(x => x.name === toolName && x.status === 'running') + if (t && preview) t.preview = preview + } + notify() + }) + const u3 = await tauriListen('hermes-run-done', () => { + const s = runSession() + if (!s) { cleanupAfterRun(); return } + + // Commit finished tool calls as messages in the transcript. + if (state.liveTools.length) { + for (const t of state.liveTools) { + s.messages.push({ + id: uid(), + role: 'tool', + content: '', + timestamp: Date.now(), + toolName: t.name, + toolPreview: t.preview || undefined, + toolArgs: stringifyMaybe(t.args), + toolResult: stringifyMaybe(t.result), + toolStatus: t.error ? 'error' : 'done', + }) + } + } + + // Finalize the streaming assistant message. + const msg = s.messages.find(m => m.id === state.pendingAssistantId) + if (msg) { + delete msg.isStreaming + if (!msg.content.trim()) msg.content = '(empty)' + } + + // Update session metadata. + s.updatedAt = Date.now() + s.lastActiveAt = Date.now() + updateSessionTitleFromFirstUser(s) + + persistSessionMessages(s.id) + persistSessions() + cleanupAfterRun() + }) + const u4 = await tauriListen('hermes-run-error', (e) => { + const err = e?.payload?.error || 'unknown error' + const s = runSession() + if (s) { + s.messages.push({ + id: uid(), + role: 'system', + content: `⚠️ Agent run failed: ${err}`, + timestamp: Date.now(), + }) + persistSessionMessages(s.id) + } + cleanupAfterRun() + }) + unlisteners.push(u1, u2, u3, u4) + } + + function detachStreamListeners() { + for (const u of unlisteners) { + try { u() } catch {} + } + unlisteners.length = 0 + } + + function cleanupAfterRun() { + state.streaming = false + state.runningSessionId = null + state.pendingAssistantId = null + state.liveTools = [] + detachStreamListeners() + notify() + // After streaming finishes the server has updated the session's + // input_tokens / output_tokens / estimated_cost_usd aggregates. Refresh + // the list so the input bar's usage pills reflect the new turn — this + // is fire-and-forget; failures fall through silently. + loadSessions().catch(() => {}) + } + + /** + * User-triggered cancel of the streaming run. + * + * The backend `hermes_agent_run` command doesn't expose a server-side + * cancel (SSE loop runs to completion), so we: + * 1. Detach local event listeners — any remaining deltas are ignored. + * 2. Convert the in-flight assistant message to its current content + + * an explicit " (stopped)" suffix. + * 3. Flip `streaming` off so the UI switches the Stop button back to + * Send. + * + * The server still finishes its run in the background (typically within + * a few seconds) — on next `refreshActiveMessages` the authoritative + * server transcript overwrites our local tail, which is fine. + */ + function stopStreaming() { + if (!state.streaming) return + const s = state.sessions.find(x => x.id === state.runningSessionId) || activeSession() + if (s) { + const msg = s.messages.find(m => m.id === state.pendingAssistantId) + if (msg) { + delete msg.isStreaming + if (!msg.content.trim()) { + msg.content = '_(stopped)_' + } else if (!msg.content.endsWith('(stopped)')) { + msg.content = msg.content.trimEnd() + ' _(stopped)_' + } + } + // Commit any finished tool calls we already know about so they aren't + // lost when we detach listeners. + for (const t of state.liveTools) { + if (t.status === 'done' || t.status === 'error') { + s.messages.push({ + id: uid(), + role: 'tool', + content: '', + timestamp: Date.now(), + toolName: t.name, + toolPreview: t.preview || undefined, + toolArgs: stringifyMaybe(t.args), + toolResult: stringifyMaybe(t.result), + toolStatus: t.error ? 'error' : 'done', + }) + } + } + s.updatedAt = Date.now() + persistSessionMessages(s.id) + persistSessions() + } + cleanupAfterRun() + } + + function updateSessionTitleFromFirstUser(s) { + if (s.title) return + const firstUser = s.messages.find(m => m.role === 'user') + if (firstUser?.content) { + const raw = firstUser.content.replace(/\n+/g, ' ').trim() + s.title = raw.slice(0, 40) + (raw.length > 40 ? '…' : '') + } + } + + function stringifyMaybe(val) { + if (val == null) return undefined + if (typeof val === 'string') return val + try { return JSON.stringify(val) } catch { return String(val) } + } + + async function sendMessage(content, opts = {}) { + const text = (content || '').trim() + if (!text || state.streaming) return + let s = activeSession() + if (!s) { + s = createLocalSession() + } + + // Append user message. + s.messages.push({ + id: uid(), + role: 'user', + content: text, + timestamp: Date.now(), + }) + updateSessionTitleFromFirstUser(s) + s.updatedAt = Date.now() + s.lastActiveAt = Date.now() + persistActiveMessages() + persistSessions() + + state.streaming = true + state.runningSessionId = s.id + state.liveTools = [] + state.pendingAssistantId = null + notify() + + try { + const history = s.messages + .filter(m => (m.role === 'user' || m.role === 'assistant') && (m.content || '').trim()) + .slice(0, -1) + .map(m => ({ role: m.role, content: m.content })) + + await attachStreamListeners(s.id) + await api.hermesAgentRun(text, s.id, history.length ? history : null, opts.instructions || null) + } catch (e) { + s.messages.push({ + id: uid(), + role: 'system', + content: `⚠️ ${e?.message || e}`, + timestamp: Date.now(), + }) + persistSessionMessages(s.id) + cleanupAfterRun() + } + } + + /** Utility: push an inline assistant message (used by /slash local replies). */ + function pushLocalAssistant(content) { + const s = activeSession() + if (!s) return + s.messages.push({ id: uid(), role: 'assistant', content, timestamp: Date.now() }) + updateSessionTitleFromFirstUser(s) + s.updatedAt = Date.now() + persistActiveMessages() + persistSessions() + notify() + } + + function pushLocalUser(content) { + const s = activeSession() + if (!s) return + s.messages.push({ id: uid(), role: 'user', content, timestamp: Date.now() }) + updateSessionTitleFromFirstUser(s) + s.updatedAt = Date.now() + persistActiveMessages() + persistSessions() + notify() + } + + function clearActive() { + const s = activeSession() + if (!s) return + s.messages = [] + s.title = '' + persistActiveMessages() + persistSessions() + notify() + } + + /** + * Fuzzy search across loaded sessions. Returns up to `limit` hits sorted + * by match strength. We only search in-memory data (title + cached first + * user message) — no network round-trip — so this is instant even with + * hundreds of sessions. + */ + function searchSessions(query, limit = 20) { + const q = (query || '').trim() + if (!q) return [] + const hits = [] + for (const s of state.sessions) { + const m = fuzzyMatchSession(s, q) + if (m) hits.push({ session: s, score: m.score, snippet: m.snippet }) + } + hits.sort((a, b) => b.score - a.score || (b.session.updatedAt || 0) - (a.session.updatedAt || 0)) + return hits.slice(0, limit) + } + + // ---------- bootstrap ---------- + + loadSessionsCache() + + return { + // readonly state access + get state() { return state }, + activeSession, + isSessionLive, + groupedSessions, + subscribe, + + // actions + loadSessions, + refreshActiveMessages, + switchSession, + newChat, + deleteSession, + bulkDeleteSessions, + renameSession, + togglePinned, + toggleCollapsed, + sendMessage, + stopStreaming, + pushLocalAssistant, + pushLocalUser, + clearActive, + searchSessions, + loadProfiles, + switchProfile, + + // lifecycle + detachStreamListeners, + notifySync, + } +} + +/** + * Fuzzy score a single session against `query`. Used by `store.searchSessions`. + * Returns `null` when nothing matches, or `{ score, snippet }` otherwise. + * + * Scoring weights: + * - title substring hit → +20 (strongest) + * - first-user content → +10 (with highlight window snippet) + * - id prefix → +5 + * - model name → +3 + */ +function fuzzyMatchSession(session, query) { + const q = query.toLowerCase() + const title = (session.title || '').toLowerCase() + const model = (session.model || '').toLowerCase() + const id = session.id.toLowerCase() + const firstUser = (session.messages || []).find(m => m.role === 'user')?.content || '' + const preview = firstUser.slice(0, 240).toLowerCase() + + let score = 0 + let snippet = '' + if (title.includes(q)) { score += 20; snippet = session.title } + if (preview.includes(q)) { + const idx = preview.indexOf(q) + const start = Math.max(0, idx - 20) + const end = Math.min(preview.length, idx + q.length + 40) + const raw = firstUser.slice(start, end) + if (!snippet) snippet = (start > 0 ? '…' : '') + raw + (end < firstUser.length ? '…' : '') + score += 10 + } + if (model.includes(q)) score += 3 + if (id.startsWith(q)) score += 5 + return score > 0 ? { score, snippet: snippet || session.title || '(untitled)' } : null +} + +// Single-instance singleton (same shape as Pinia). +let _store = null +export function getChatStore() { + if (!_store) _store = createStore() + return _store +} diff --git a/src/engines/hermes/pages/channels.js b/src/engines/hermes/pages/channels.js index 2fc1df4..9921594 100644 --- a/src/engines/hermes/pages/channels.js +++ b/src/engines/hermes/pages/channels.js @@ -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 = `
diff --git a/src/engines/hermes/pages/chat.js b/src/engines/hermes/pages/chat.js index f1131aa..de654ae 100644 --- a/src/engines/hermes/pages/chat.js +++ b/src/engines/hermes/pages/chat.js @@ -1,627 +1,1436 @@ /** - * Hermes Agent 对话页面 - * 通过 /v1/runs + SSE 事件流驱动,支持工具调用可视化和流式文本 - * 支持多会话管理、/xxx 快捷指令 + * Hermes Chat — editorial luxury re-write (Phase 4). + * + * Layout matches the official `hermes-web-ui`'s ChatPanel: + * ┌────────────────┬──────────────────────────────────────────────┐ + * │ SessionList │ Header: title · source · new-chat button │ + * │ (groups + ├──────────────────────────────────────────────┤ + * │ pinned + │ MessageList (user / assistant / tool) │ + * │ live badge) │ │ + * │ ├──────────────────────────────────────────────┤ + * │ │ ChatInput (textarea + slash menu + send) │ + * └────────────────┴──────────────────────────────────────────────┘ + * + * State lives in `chat-store.js`; this module only does DOM + events. */ import { t } from '../../../lib/i18n.js' import { api } from '../../../lib/tauri-api.js' -import { PROVIDER_PRESETS } from '../../../lib/model-presets.js' +import { toast } from '../../../components/toast.js' +import { showConfirm } from '../../../components/modal.js' +import { getChatStore, getSourceLabel } from '../lib/chat-store.js' -const STORAGE_KEY = 'hermes_chat_sessions' -const FILE_ACCESS_KEY = 'hermes_chat_file_access' -const SLASH_COMMANDS = [ - { cmd: '/help', desc: '显示可用命令' }, - { cmd: '/status', desc: '查看 Agent 状态' }, - { cmd: '/memory', desc: '管理记忆' }, - { cmd: '/skills', desc: '查看技能列表' }, - { cmd: '/clear', desc: '清空当前会话' }, - { cmd: '/new', desc: '新建会话' }, -] +// ----------------------------------------------------------- helpers -const TOOL_ICONS = { - web_search: '🔍', browse: '🌐', web_browse: '🌐', google: '🔍', - code: '💻', execute_code: '💻', run_code: '💻', python: '🐍', - terminal: '⌨️', shell: '⌨️', bash: '⌨️', command: '⌨️', - file: '📁', read_file: '📁', write_file: '📝', - memory: '🧠', recall: '🧠', - default: '🔧', -} -function toolIcon(name) { - const n = (name || '').toLowerCase() - for (const [k, v] of Object.entries(TOOL_ICONS)) { - if (n.includes(k)) return v - } - return TOOL_ICONS.default +function escHtml(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>') } +function escAttr(s) { + return String(s ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +function sanitizeMarkdownUrl(url) { + const raw = String(url || '').trim() + if (!raw) return '#' + if (raw.startsWith('#')) return raw + if (raw.startsWith('/') && !raw.startsWith('//')) return raw + try { + const u = new URL(raw, window.location.origin) + if (['http:', 'https:', 'mailto:'].includes(u.protocol)) return raw + } catch {} + return '#' +} + +/** Minimal Markdown → HTML (supports fenced code, bold/italic, headings, lists, links). */ function mdToHtml(text) { - return text + if (!text) return '' + const blocks = [] + let out = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { + const idx = blocks.push({ lang, code }) - 1 + return `\u0000CB_${idx}\u0000` + }) + out = out .replace(/&/g, '&').replace(//g, '>') - .replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/(?$1') + .replace(/^#### (.+)$/gm, '
$1
') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/^(?:\s*[-*]\s+(.+))(?:\n\s*[-*]\s+(.+))*/gm, (m) => + '
    ' + m.trim().split(/\n\s*[-*]\s+/).map(li => `
  • ${li.replace(/^[-*]\s+/, '')}
  • `).join('') + '
') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => + `${label}`) + .replace(/\n{2,}/g, '

') .replace(/\n/g, '
') + out = out.replace(/\u0000CB_(\d+)\u0000/g, (_, i) => { + const { lang, code } = blocks[Number(i)] + return `

${escHtml(code)}
` + }) + return `

${out}

` } -function escHtml(s) { - return s.replace(/&/g, '&').replace(//g, '>') -} -function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) } -// Lazy Tauri event listen (avoid top-level await for vite build) -let _listenFn = null -async function tauriListen(event, cb) { - if (!_listenFn) { - const mod = await import('@tauri-apps/api/event') - _listenFn = mod.listen +/** Pretty-print JSON-ish tool payload; fallback to raw string. */ +function prettyJson(val) { + if (val == null || val === '') return '' + if (typeof val === 'string') { + const s = val.trim() + if (s.startsWith('{') || s.startsWith('[')) { + try { return JSON.stringify(JSON.parse(s), null, 2) } catch {} + } + return val } - return _listenFn(event, cb) + try { return JSON.stringify(val, null, 2) } catch { return String(val) } } -// --- Session persistence --- -function loadSessions() { - try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') } catch { return [] } +function formatTime(ts) { + if (!ts) return '' + const d = new Date(ts) + if (!Number.isFinite(d.getTime())) return '' + const now = new Date() + if (d.toDateString() === now.toDateString()) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } + const mo = d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + return mo } -function saveSessions(sessions) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)) + +function sessionDisplayTitle(s) { + return s.title || t('engine.chatNewSession') } -function sessionTitle(s) { - if (s.title) return s.title - const first = s.messages.find(m => m.role === 'user') - return first ? first.content.slice(0, 30) : t('engine.chatNewSession') + +/** Compact token formatter — `1234567 → "1.2M"`, `12345 → "12.3k"`, `42 → "42"`. */ +function formatTokens(n) { + if (!Number.isFinite(n) || n <= 0) return '0' + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k' + return String(Math.round(n)) } +/** USD cost formatter — `0.0042 → "$0.0042"`, `0.51 → "$0.51"`, `12.3 → "$12.30"`. */ +function formatCost(usd) { + if (typeof usd !== 'number' || !Number.isFinite(usd) || usd <= 0) return '' + if (usd < 0.01) return '$' + usd.toFixed(4) + if (usd < 1) return '$' + usd.toFixed(3) + return '$' + usd.toFixed(2) +} + +async function copyText(text) { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch {} + try { + const ta = document.createElement('textarea') + ta.value = text + ta.setAttribute('readonly', '') + ta.style.position = 'fixed' + ta.style.left = '-9999px' + document.body.appendChild(ta) + ta.select() + const ok = document.execCommand('copy') + ta.remove() + return ok + } catch { + return false + } +} + +// ----------------------------------------------------------- icons + +const ICONS = { + plus: '', + chevron: '', + menu: '', + more: '', + close: '', + send: '', + stop: '', + pin: '', + spinner: '', + copy: '', + trash: '', + layers: '', + check: '', + checkboxOff: '', + checkboxOn: '', + tool: '', + refresh: '', + sidebar: '', +} + +const SLASH_COMMANDS = [ + { cmd: '/help', desc: 'chatSlashHelpDesc' }, + { cmd: '/status', desc: 'chatSlashStatusDesc' }, + { cmd: '/memory', desc: 'chatSlashMemoryDesc' }, + { cmd: '/skills', desc: 'chatSlashSkillsDesc' }, + { cmd: '/clear', desc: 'chatSlashClearDesc' }, + { cmd: '/new', desc: 'chatSlashNewDesc' }, +] + +// ----------------------------------------------------------- rename modal + +/** + * Lightweight rename modal (used by sidebar context menu). Returns the new + * title on confirm, or `null` on cancel. Mirrors `showConfirm`'s pattern + * so we don't need Vue-style reactivity. + */ +function showRenameModal(current) { + return new Promise((resolve) => { + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + overlay.innerHTML = ` + + ` + document.body.appendChild(overlay) + const input = overlay.querySelector('.hm-chat-rename-input') + input?.focus() + input?.select() + + const close = (v) => { overlay.remove(); resolve(v) } + const confirm = () => { + const v = input?.value.trim() || '' + if (!v) { input?.focus(); return } + close(v) + } + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(null) + }) + overlay.querySelector('[data-act="cancel"]').onclick = () => close(null) + overlay.querySelector('[data-act="ok"]').onclick = confirm + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); confirm() } + else if (e.key === 'Escape') close(null) + }) + }) +} + +// ----------------------------------------------------------- context menu + +function showContextMenu(x, y, items) { + const existing = document.querySelector('.hm-chat-ctxmenu') + if (existing) existing.remove() + const menu = document.createElement('div') + menu.className = 'hm-chat-ctxmenu' + menu.innerHTML = items.map((it, i) => ` + + `).join('') + + document.body.appendChild(menu) + // Position + clamp to viewport. + const rect = menu.getBoundingClientRect() + const vw = window.innerWidth, vh = window.innerHeight + menu.style.left = Math.min(x, vw - rect.width - 8) + 'px' + menu.style.top = Math.min(y, vh - rect.height - 8) + 'px' + + const close = () => { + menu.remove() + document.removeEventListener('click', onDocClick, true) + document.removeEventListener('keydown', onKey) + } + const onDocClick = (e) => { + if (!menu.contains(e.target)) close() + } + const onKey = (e) => { if (e.key === 'Escape') close() } + setTimeout(() => { + document.addEventListener('click', onDocClick, true) + document.addEventListener('keydown', onKey) + }, 0) + menu.addEventListener('click', (e) => { + const btn = e.target.closest('.hm-chat-ctxmenu-item') + if (!btn) return + const idx = Number(btn.dataset.idx) + close() + items[idx]?.action?.() + }) +} + +// ----------------------------------------------------------- main render + export function render() { const el = document.createElement('div') - el.className = 'page hermes-chat-page' + el.className = 'hermes-chat-page' + el.dataset.engine = 'hermes' - let sessions = loadSessions() - let activeId = sessions[0]?.id || null - let streaming = false - let gwOnline = false + const store = getChatStore() + + // Local UI-only state (not in store). + let sidebarOpen = !window.matchMedia('(max-width: 768px)').matches + const expandedToolIds = new Set() // tool message ids (persist across redraws) let showSlash = false let slashFilter = '' - let currentModel = '' // 当前模型名 - let modelList = [] // 已获取的模型列表 - let showModelDropdown = false - let fileAccessEnabled = localStorage.getItem(FILE_ACCESS_KEY) === 'true' + let gwOnline = false + let currentModel = '' + const mobileQuery = window.matchMedia('(max-width: 720px)') - // 流式状态 - let pendingText = '' // 累积的 delta 文本 - let activeTools = [] // 当前活跃的工具调用 [{ name, status, detail, input, output, error }] - let unlisteners = [] // Tauri 事件监听取消函数 + // Input state must live outside the textarea DOM node because every draw() + // rebuilds innerHTML. Without this, typing `/` would wipe the composed text + // when the slash menu triggers a redraw. + let inputValue = '' + let inputFocused = false + let inputCaret = 0 // caret position restored after re-render + let lastActiveSessionId = store.state.activeSessionId + let forceScrollBottom = true - function active() { return sessions.find(s => s.id === activeId) } + // Multi-select for batch session deletion. When non-null, the sidebar + // switches into "selection mode": a checkbox appears on every row and + // selecting items doesn't switch sessions. + let selectionMode = false + const selected = new Set() - function newSession() { - const s = { id: genId(), title: '', messages: [], createdAt: Date.now() } - sessions.unshift(s) - activeId = s.id - saveSessions(sessions) - } + // Profile switcher dropdown (for Hermes multi-profile / multi-agent). + let profileMenuOpen = false - if (!sessions.length) newSession() + // Session search modal state. `null` means closed. + // { query: string, selectedIdx: number } + let searchState = null - async function init() { - try { - const info = await api.checkHermes() - gwOnline = !!info?.gatewayRunning - } catch (_) {} - // Load current model config - try { - const cfg = await api.hermesReadConfig() - if (cfg?.model) currentModel = cfg.model - if (cfg?.base_url && cfg?.api_key) { - // Pre-fetch model list for quick switch - try { - const base = cfg.base_url.replace(/\/+$/, '').replace(/\/(chat\/completions|completions|responses|messages|models)\/?$/, '') - const resp = await fetch(base + '/models', { headers: { 'Authorization': `Bearer ${cfg.api_key}` }, signal: AbortSignal.timeout(8000) }) - if (resp.ok) { - const data = await resp.json() - modelList = (data.data || []).map(m => m.id).filter(Boolean).sort() - } - } catch (_) {} - } - } catch (_) {} + // --- initial session load + model meta --- + store.loadSessions().then(() => draw()) + store.loadProfiles().then(() => draw()).catch(() => {}) + api.checkHermes().then(info => { + gwOnline = !!info?.gatewayRunning + currentModel = info?.model || '' draw() - } + }).catch(() => {}) - // --- 工具调用卡片渲染 --- - function formatToolData(data) { - if (!data) return '' - if (typeof data === 'string') { - // 尝试解析 JSON 以美化显示 - try { const obj = JSON.parse(data); return JSON.stringify(obj, null, 2) } catch { return data } - } - return JSON.stringify(data, null, 2) - } + // ----------------------------------------------------------- subscription - function renderToolCard(t, collapsed = true) { - const icon = toolIcon(t.name) - const statusCls = t.status === 'complete' ? 'done' : t.status === 'error' ? 'err' : 'active' - const statusText = t.status === 'complete' ? '✓ 完成' : t.status === 'error' ? '✗ 失败' : '⟳ 运行中' - const detail = t.detail && t.detail !== '失败' && t.detail !== '完成' ? ` — ${escHtml(t.detail)}` : '' - const inputStr = formatToolData(t.input) - const outputStr = formatToolData(t.output) - const errorStr = t.error ? (typeof t.error === 'string' ? t.error : JSON.stringify(t.error)) : '' - // fallback: 用 raw 快照显示原始事件数据 - const rawStr = (!inputStr && !outputStr && !errorStr) ? formatToolData(t._raw || t._rawCompleted) : '' - const hasDetails = inputStr || outputStr || errorStr || rawStr - const cardId = 'tc-' + genId() - let detailsHtml = '' - if (hasDetails) { - detailsHtml = `
- ${inputStr ? `
${escHtml(inputStr)}
` : ''} - ${errorStr ? `
${escHtml(errorStr)}
` : ''} - ${outputStr ? `
${escHtml(outputStr)}
` : ''} - ${rawStr ? `
${escHtml(rawStr)}
` : ''} -
` - } - return `
-
${icon} ${escHtml(t.name)}${statusText}${detail}${hasDetails ? `` : ''}
- ${detailsHtml} -
` - } + // Store subscription → `draw()` on mutation. rAF-batched inside the store + // so a burst of events (streaming deltas) collapses into a single redraw. + const unsubscribe = store.subscribe(() => draw()) - // --- 增量更新流式区域(避免全量 draw 导致闪烁)--- - function updateStreamArea() { - const msgsEl = el.querySelector('#hm-chat-msgs') - if (!msgsEl) return - let streamEl = msgsEl.querySelector('.hm-stream-area') - if (!streaming) { - if (streamEl) streamEl.remove() - return - } - if (!streamEl) { - streamEl = document.createElement('div') - streamEl.className = 'hm-stream-area' - msgsEl.appendChild(streamEl) - } - const toolsHtml = activeTools.map(t => renderToolCard(t, false)).join('') - const textHtml = pendingText - ? `
${mdToHtml(pendingText)}
` - : (activeTools.length === 0 ? `
${t('engine.chatThinking')}
` : '') - streamEl.innerHTML = toolsHtml + textHtml - msgsEl.scrollTop = msgsEl.scrollHeight - } + // Teardown + mount-observer are set up near the end of render() (after + // `onGlobalKey` is defined). We avoid attaching a MutationObserver here + // to prevent a double-teardown path. - // --- Draw --- - function draw() { - const cur = active() - const msgs = cur?.messages || [] - el.innerHTML = ` -
-
-
- ${t('engine.hermesChatTitle')} - + // ----------------------------------------------------------- rendering + + function renderSessionItem(s) { + const isActive = s.id === store.state.activeSessionId + const isLive = store.isSessionLive(s.id) + const isPinned = store.state.pinned.has(s.id) + const isSelected = selected.has(s.id) + // IMPORTANT: outer wrapper is a `
`, NOT a ` + ` : ''} +
+
+ ${isLive ? `` : ''} + ${isPinned ? `` : ''} + ${escHtml(sessionDisplayTitle(s))} + ${isLive ? `${escHtml(t('engine.chatLive'))}` : ''}
-
- ${sessions.map(s => ` -
- ${escHtml(sessionTitle(s))} - -
- `).join('')} +
+ ${s.model ? `${escHtml(s.model)}` : ''} + ${escHtml(formatTime(s.updatedAt || s.createdAt))}
-
-
- ${t('engine.configModel')}: -
- - ${showModelDropdown && modelList.length ? `
${modelList.map(m => `
${escHtml(m)}
`).join('')}
` : ''} -
- + - ${t('engine.dashModelConfig')} →
-
- ${msgs.length === 0 ? `
${t('engine.chatEmptyHint')}
` : ''} - ${msgs.map(m => renderMessage(m)).join('')} + `} +
+ ` + } + + function visibleSessionIds() { + return store.state.sessions.map(s => s.id) + } + + function renderProfileSwitcher() { + const profiles = store.state.profiles || [] + const active = store.state.activeProfile || 'default' + if (!profiles.length) { + // Fallback: even when CLI doesn't expose profiles, surface the active + // one so the user knows what they're talking to. + return ` + + ` + } + return ` + + ${profileMenuOpen ? ` + + ` : ''} + ` + } + + function renderSidebar() { + const { pinned, groups } = store.groupedSessions() + const sessionsEmpty = store.state.sessions.length === 0 + const allIds = visibleSessionIds() + const allSelected = selectionMode && allIds.length > 0 && allIds.every(id => selected.has(id)) + return ` + + ` + } + + function renderToolMessage(m) { + const expanded = expandedToolIds.has(m.id) + const hasDetails = !!(m.toolArgs || m.toolResult) + return ` +
+
+ ${hasDetails + ? `${ICONS.chevron}` + : `${ICONS.tool}`} + ${escHtml(m.toolName || 'tool')} + ${!expanded && m.toolPreview ? `${escHtml(m.toolPreview)}` : ''} + ${m.toolStatus === 'running' ? `` : ''} + ${m.toolStatus === 'error' ? `${escHtml(t('engine.chatErrorBadge'))}` : ''} +
+ ${expanded && hasDetails ? ` +
+ ${m.toolArgs ? ` +
+
${escHtml(t('engine.chatArguments'))}
+
${escHtml(prettyJson(m.toolArgs))}
+
+ ` : ''} + ${m.toolResult ? ` +
+
${escHtml(t('engine.chatResult'))}
+
${escHtml(prettyJson(m.toolResult))}
+
+ ` : ''} +
+ ` : ''} +
+ ` + } + + function renderMessage(m) { + if (m.role === 'tool') return renderToolMessage(m) + if (m.role === 'system') { + return ` +
+
+
${mdToHtml(m.content)}
+
+
+ ` + } + const isUser = m.role === 'user' + const canCopy = !!(m.content || '').trim() + return ` +
+
+ ${!isUser ? `` : ''} +
+
+
${mdToHtml(m.content)}${m.isStreaming && !m.content ? '' : ''}
+
+
` - bind() - if (streaming) updateStreamArea() - scrollToBottom() } - function renderMessage(m) { - const isUser = m.role === 'user' - // 工具摘要行(存储在 messages 中的已完成工具记录) - if (m.role === 'tool-summary') { - return `
${m.tools.map(t => renderToolCard(t, true)).join('')}
` + function renderLiveTools() { + if (!store.state.streaming) return '' + const tools = store.state.liveTools + return ` +
+
+ + ${escHtml(t('engine.chatThinking'))} +
+ ${tools.length ? ` +
+ ${tools.slice().reverse().map(tc => ` +
+ ${ICONS.tool} + ${escHtml(tc.name)} + ${tc.preview ? `${escHtml(tc.preview)}` : ''} + ${tc.status === 'running' ? `` : ''} + ${tc.status === 'error' ? `${escHtml(t('engine.chatErrorBadge'))}` : ''} +
+ `).join('')} +
+ ` : ''} +
+ ` + } + + function renderMessages() { + const s = store.activeSession() + if (!s) { + return `
${escHtml(t('engine.chatNewSession'))}
` } - return `
-
${isUser ? escHtml(m.content) : mdToHtml(m.content)}
-
` + if (store.state.loadingMessages) { + return ` +
+
${escHtml(t('engine.chatLoadingMessages'))}
+
${escHtml(t('engine.chatLoadingMessagesSub'))}
+
+ ` + } + if (!s.messages.length && !store.state.streaming) { + return ` +
+
${escHtml(t('engine.chatEmptyTitle'))}
+
${escHtml(t('engine.chatEmptySub'))}
+
+ ` + } + return s.messages.map(renderMessage).join('') + renderLiveTools() } function renderSlashMenu() { - const cmds = SLASH_COMMANDS.filter(c => !slashFilter || c.cmd.includes(slashFilter)) - if (!cmds.length) return '' - return `
${cmds.map(c => - `
${c.cmd}${c.desc}
` - ).join('')}
` + if (!showSlash) return '' + const filtered = SLASH_COMMANDS.filter(c => !slashFilter || c.cmd.includes(slashFilter)) + if (!filtered.length) return '' + return ` +
+ ${filtered.map(c => ` + + `).join('')} +
+ ` } - function scrollToBottom() { - const msgsEl = el.querySelector('#hm-chat-msgs') - if (msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight + function renderInput() { + const active = store.activeSession() + const streaming = store.state.streaming + const placeholder = streaming + ? t('engine.chatStreamingPlaceholder') + : t('engine.chatInputPlaceholder') + // NOTE: textarea is NOT disabled during streaming — the user should still + // be able to compose the next message while the agent is thinking. The + // Send button is hidden/swapped instead. + // The keyboard shortcut hint now lives inside the placeholder so we + // don't render a duplicate row beneath the textarea (the prior layout + // looked like "套娃" — same hint shown twice). Slash menu still pops + // up above when the user types `/`. + // + // Token usage strip — only when there's an active session with real + // usage. Mirrors hermes-web-ui's input-top-bar (sans context-length + // bar, which requires a server-side endpoint we don't have). + const totalIn = active?.inputTokens || 0 + const totalOut = active?.outputTokens || 0 + const totalCache = (active?.cacheReadTokens || 0) + (active?.cacheWriteTokens || 0) + const cost = active?.estimatedCostUsd + const showUsage = !!active && (totalIn + totalOut + totalCache) > 0 + return ` +
+ ${renderSlashMenu()} + ${showUsage ? ` +
+ + ${escHtml(t('engine.chatUsageIn'))} + ${formatTokens(totalIn)} + + + ${escHtml(t('engine.chatUsageOut'))} + ${formatTokens(totalOut)} + + ${totalCache > 0 ? ` + + ${escHtml(t('engine.chatUsageCache'))} + ${formatTokens(totalCache)} + ` : ''} + ${cost ? ` + + ${escHtml(formatCost(cost))} + ` : ''} +
` : ''} +
+ +
+ ${streaming + ? `` + : ``} +
+
+
+ ` } - // 事件委托:工具卡片展开/折叠(对静态和动态流式卡片都生效) - el.addEventListener('click', (e) => { - const header = e.target.closest('.hm-tool-card-header') - if (!header) return - const card = header.closest('.hm-tool-card') - const details = card?.querySelector('.hm-tool-details') - const toggle = header.querySelector('.hm-tool-toggle') - if (details) { - const open = details.style.display !== 'none' - details.style.display = open ? 'none' : 'block' - if (toggle) toggle.textContent = open ? '▶' : '▼' + function renderHeader() { + const active = store.activeSession() + const title = active ? sessionDisplayTitle(active) : t('engine.chatNewSession') + const source = active?.source && active.source !== '__local__' ? getSourceLabel(active.source) : '' + return ` +
+
+ +
+ ${escHtml(title)} + ${source ? `${escHtml(source)}` : ''} +
+
+
+
+ + GATEWAY + ${escHtml(gwOnline ? t('engine.chatGatewayOnlineShort') : t('engine.chatGatewayOfflineShort'))} + ${currentModel ? `${escHtml(currentModel)}` : ''} +
+ + + +
+
+ ` + } + + // ----------------------------------------------------------- draw + + function draw() { + const scrollTop = el.querySelector('.hm-chat-messages')?.scrollTop + const wasNearBottom = isMessagesNearBottom() + const activeSessionId = store.state.activeSessionId + const activeChanged = activeSessionId !== lastActiveSessionId + if (activeChanged) { + lastActiveSessionId = activeSessionId + forceScrollBottom = true } - }) + + el.innerHTML = ` +
+
+ ${renderSidebar()} +
+ ${renderHeader()} +
+ ${renderMessages()} +
+ + ${renderInput()} +
+
+ ` + bind() + + // Restore / auto-scroll. + const msgsEl = el.querySelector('.hm-chat-messages') + if (msgsEl) { + if (forceScrollBottom || wasNearBottom) { + msgsEl.scrollTop = msgsEl.scrollHeight + forceScrollBottom = false + } else if (scrollTop != null) { + msgsEl.scrollTop = scrollTop + } + updateJumpButton() + } + + // Restore textarea focus + caret position after every redraw so typing + // remains smooth even when store mutations trigger a full DOM rebuild. + const input = el.querySelector('#hm-chat-input') + if (input) { + if (inputFocused) { + input.focus() + try { + const pos = Math.min(inputCaret, inputValue.length) + input.setSelectionRange(pos, pos) + } catch { /* selection unsupported for the current state */ } + } + autoResize(input) + } + + // Draw search modal on top if open. + drawSearchModal() + } + + function isMessagesNearBottom(threshold = 120) { + const m = el.querySelector('.hm-chat-messages') + if (!m) return true + return m.scrollHeight - m.scrollTop - m.clientHeight < threshold + } + + function updateJumpButton() { + const btn = el.querySelector('#hm-chat-jump-bottom') + if (!btn) return + btn.classList.toggle('is-visible', !isMessagesNearBottom(180)) + } + + // ----------------------------------------------------------- event binding + + function toggleSelected(sid) { + if (!sid) return + if (selected.has(sid)) selected.delete(sid) + else selected.add(sid) + draw() + } function bind() { - // Model quick-switch - el.querySelector('#hm-chat-model')?.addEventListener('click', () => { - if (modelList.length) { showModelDropdown = !showModelDropdown; draw() } + // --- Sidebar header --- + el.querySelector('.hm-chat-new-btn')?.addEventListener('click', () => { + store.newChat() }) - el.querySelectorAll('.hm-chat-model-opt').forEach(opt => { - opt.addEventListener('click', async () => { - const m = opt.dataset.model - if (m && m !== currentModel) { - try { - await api.hermesUpdateModel(m) - currentModel = m - } catch (_) {} - } - showModelDropdown = false; draw() + el.querySelector('#hm-chat-toggle-sidebar')?.addEventListener('click', () => { + sidebarOpen = !sidebarOpen + draw() + }) + el.querySelector('#hm-chat-sidebar-backdrop')?.addEventListener('click', () => { + sidebarOpen = false + draw() + }) + const msgsEl = el.querySelector('#hm-chat-messages') + msgsEl?.addEventListener('scroll', updateJumpButton) + el.querySelector('#hm-chat-jump-bottom')?.addEventListener('click', () => { + if (!msgsEl) return + msgsEl.scrollTop = msgsEl.scrollHeight + updateJumpButton() + }) + + // --- Group collapse --- + el.querySelectorAll('.hm-chat-group-head[data-group]').forEach(btn => { + btn.addEventListener('click', (e) => { + // Don't collapse when clicking static-header style. + if (btn.classList.contains('hm-chat-group-head--static')) return + const src = btn.dataset.group + store.toggleCollapsed(src) }) }) - document.addEventListener('click', (e) => { - if (showModelDropdown && !e.target.closest('#hm-chat-model') && !e.target.closest('#hm-chat-model-dd')) { - showModelDropdown = false; draw() - } + + // --- Session select --- + el.querySelectorAll('.hm-chat-session-item').forEach(item => { + item.addEventListener('click', (e) => { + if (e.target.closest('.hm-chat-session-action')) return + const sid = item.dataset.sid + if (!sid) return + if (selectionMode) { + toggleSelected(sid) + return + } + if (sid !== store.state.activeSessionId) { + forceScrollBottom = true + store.switchSession(sid) + if (mobileQuery.matches) sidebarOpen = false + } + }) + item.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + if (e.target.closest('.hm-chat-session-action')) return + e.preventDefault() + const sid = item.dataset.sid + if (!sid) return + if (selectionMode) { + toggleSelected(sid) + return + } + if (sid !== store.state.activeSessionId) { + forceScrollBottom = true + store.switchSession(sid) + if (mobileQuery.matches) sidebarOpen = false + } + }) + item.addEventListener('contextmenu', (e) => { + e.preventDefault() + const sid = item.dataset.sid + openSessionContextMenu(e.clientX, e.clientY, sid) + }) }) - // File access toggle - el.querySelector('#hm-file-access-btn')?.addEventListener('click', () => { - fileAccessEnabled = !fileAccessEnabled - localStorage.setItem(FILE_ACCESS_KEY, fileAccessEnabled ? 'true' : 'false') + + // --- Selection mode controls --- + el.querySelector('#hm-chat-select-toggle')?.addEventListener('click', () => { + selectionMode = !selectionMode + if (!selectionMode) selected.clear() + profileMenuOpen = false + draw() + }) + el.querySelectorAll('[data-sid-check]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation() + toggleSelected(btn.dataset.sidCheck) + }) + }) + el.querySelector('#hm-chat-bulk-select-all')?.addEventListener('click', () => { + const ids = visibleSessionIds() + const allSelected = ids.length > 0 && ids.every(id => selected.has(id)) + if (allSelected) selected.clear() + else for (const id of ids) selected.add(id) + draw() + }) + el.querySelector('#hm-chat-bulk-delete')?.addEventListener('click', async () => { + if (selected.size === 0) return + const ok = await showConfirm(t('engine.chatConfirmBulkDelete').replace('{n}', String(selected.size))) + if (!ok) return + const ids = Array.from(selected) + const result = await store.bulkDeleteSessions(ids) + selected.clear() + const skipped = result.skipped.length + const failed = result.failed.length + const deleted = result.deleted.length + if (deleted > 0 && failed === 0 && skipped === 0) { + toast(t('engine.chatBulkDeleted').replace('{n}', String(deleted)), 'success') + } else if (deleted > 0) { + toast(t('engine.chatBulkPartial') + .replace('{n}', String(deleted)) + .replace('{f}', String(failed + skipped)), 'success') + } else { + toast(t('engine.chatBulkFailed'), 'error') + } + if (failed === 0) selectionMode = false draw() }) - // Session sidebar - el.querySelector('.hm-new-btn')?.addEventListener('click', () => { newSession(); draw() }) - el.querySelectorAll('.hm-session-item').forEach(item => { - item.addEventListener('click', (e) => { - if (e.target.closest('.hm-session-del')) return - activeId = item.dataset.sid - draw() + // --- Profile switcher --- + el.querySelector('#hm-chat-profile-toggle')?.addEventListener('click', (e) => { + const btn = e.currentTarget + if (btn?.disabled) return + profileMenuOpen = !profileMenuOpen + draw() + }) + el.querySelectorAll('[data-profile]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation() + const name = btn.dataset.profile + profileMenuOpen = false + if (!name || name === store.state.activeProfile) { + draw() + return + } + if (store.state.streaming) { + toast(t('engine.chatProfileSwitchBlocked'), 'error') + draw() + return + } + try { + await store.switchProfile(name) + toast(t('engine.chatProfileSwitched').replace('{name}', name), 'success') + } catch (err) { + toast((err?.message || String(err)), 'error') + } }) }) - el.querySelectorAll('.hm-session-del').forEach(btn => { + + el.querySelectorAll('.hm-chat-session-menu').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation() - const sid = btn.dataset.del - sessions = sessions.filter(s => s.id !== sid) - if (activeId === sid) { - if (!sessions.length) newSession() - activeId = sessions[0].id + const sid = btn.dataset.sidMenu + const rect = btn.getBoundingClientRect() + openSessionContextMenu(rect.left, rect.bottom + 4, sid) + }) + }) + + // --- Session delete --- + el.querySelectorAll('.hm-chat-session-del').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation() + const sid = btn.dataset.sidDel + const ok = await showConfirm(t('engine.chatConfirmDelete')) + if (!ok) return + try { + await store.deleteSession(sid) + toast(t('engine.chatSessionDeleted'), 'success') + } catch (err) { + const msg = err?.message === 'RUNNING_SESSION' ? t('engine.chatDeleteRunningBlocked') : (err?.message || err) + toast(t('engine.chatDeleteFailed') + ': ' + msg, 'error') } - saveSessions(sessions) + }) + }) + + el.querySelectorAll('[data-copy-mid]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation() + const mid = btn.dataset.copyMid + const s = store.activeSession() + const msg = s?.messages.find(m => m.id === mid) + if (!msg?.content) return + const ok = await copyText(msg.content) + toast(ok ? t('common.copied') : t('engine.chatCopyFailed'), ok ? 'success' : 'error') + }) + }) + + el.querySelectorAll('.hm-chat-code-copy').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation() + const code = btn.closest('pre')?.querySelector('code')?.textContent || '' + if (!code) return + const ok = await copyText(code) + toast(ok ? t('common.copied') : t('engine.chatCopyFailed'), ok ? 'success' : 'error') + }) + }) + + // --- Tool message expand --- + el.querySelectorAll('[data-tool-toggle]').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.toolToggle + if (expandedToolIds.has(id)) expandedToolIds.delete(id) + else expandedToolIds.add(id) draw() }) }) - // Slash menu clicks - el.querySelectorAll('.hm-slash-item').forEach(item => { + // --- Header actions --- + el.querySelector('#hm-chat-new-chat')?.addEventListener('click', () => { + forceScrollBottom = true + store.newChat() + }) + el.querySelector('#hm-chat-search-open')?.addEventListener('click', () => openSearch()) + el.querySelector('#hm-chat-copy-id')?.addEventListener('click', async () => { + const s = store.activeSession() + if (!s) return + try { + const ok = await copyText(s.id) + toast(ok ? t('common.copied') : t('engine.chatCopyFailed'), ok ? 'success' : 'error') + } catch { toast(t('engine.chatCopyFailed'), 'error') } + }) + + // --- Input --- + // + // We track the composed text in `inputValue` (outside the DOM) so it + // survives redraws triggered by streaming updates or slash-menu toggles. + // The textarea's `value` is authoritative only between events; on the + // next draw() the markup re-seeds it from `inputValue`. + const input = el.querySelector('#hm-chat-input') + if (input) { + // Event ordering: focus / blur → keydown → input. We update the state + // on BOTH input (value) and selectionchange proxies (keydown/keyup) to + // keep caret restore accurate. + input.addEventListener('focus', () => { inputFocused = true }) + input.addEventListener('blur', () => { inputFocused = false }) + input.addEventListener('keyup', () => { inputCaret = input.selectionStart || 0 }) + input.addEventListener('click', () => { inputCaret = input.selectionStart || 0 }) + + input.addEventListener('keydown', (e) => { + if (e.isComposing || e.keyCode === 229) return + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + return + } + if (e.key === 'Escape' && showSlash) { + showSlash = false + draw() + } + }) + + input.addEventListener('input', () => { + inputValue = input.value + inputCaret = input.selectionStart || inputValue.length + const wasShowing = showSlash + if (inputValue.startsWith('/') && !inputValue.includes(' ')) { + showSlash = true + slashFilter = inputValue + } else if (showSlash) { + showSlash = false + } + // Only call draw() when the slash menu visibility actually changes — + // otherwise a plain keystroke would trigger an expensive full rebuild. + if (wasShowing !== showSlash || (showSlash && slashFilter !== inputValue)) { + draw() + } else { + autoResize(input) + } + }) + } + + el.querySelector('#hm-chat-send')?.addEventListener('click', handleSend) + el.querySelector('#hm-chat-stop')?.addEventListener('click', () => { + store.stopStreaming() + toast(t('engine.chatStopped'), 'success') + }) + + el.querySelectorAll('.hm-chat-slash-item').forEach(item => { item.addEventListener('click', () => { - const input = el.querySelector('#hm-chat-input') - if (input) { input.value = item.dataset.cmd + ' '; input.focus() } + const cmd = item.dataset.cmd + inputValue = cmd + ' ' + inputCaret = inputValue.length + inputFocused = true showSlash = false draw() }) }) + } - // Send - el.querySelector('.hm-chat-send')?.addEventListener('click', sendMessage) - const input = el.querySelector('#hm-chat-input') - if (input) { - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() } - if (e.key === 'Escape') { showSlash = false; draw() } - }) - input.addEventListener('input', () => { - input.style.height = 'auto' - input.style.height = Math.min(input.scrollHeight, 120) + 'px' - const val = input.value - if (val.startsWith('/') && !val.includes(' ')) { - showSlash = true; slashFilter = val - const parent = input.closest('.hermes-chat-input-area')?.querySelector('[style*="position:relative"]') - if (parent) { - const existing = parent.querySelector('.hm-slash-menu') - if (existing) existing.remove() - const cmds = SLASH_COMMANDS.filter(c => c.cmd.includes(val)) - if (cmds.length) { - const div = document.createElement('div') - div.className = 'hm-slash-menu' - div.innerHTML = cmds.map(c => - `
${c.cmd}${c.desc}
` - ).join('') - div.querySelectorAll('.hm-slash-item').forEach(item => { - item.addEventListener('click', () => { - input.value = item.dataset.cmd + ' ' - input.focus() - showSlash = false - div.remove() - }) - }) - parent.prepend(div) - } + function autoResize(input) { + input.style.height = 'auto' + input.style.height = Math.min(input.scrollHeight, 160) + 'px' + } + + function openSessionContextMenu(x, y, sid) { + const s = store.state.sessions.find(sess => sess.id === sid) + if (!s) return + const isPinned = store.state.pinned.has(sid) + showContextMenu(x, y, [ + { + label: isPinned ? t('engine.chatUnpin') : t('engine.chatPin'), + icon: ICONS.pin, + action: () => store.togglePinned(sid), + }, + { + label: t('engine.chatRename'), + action: async () => { + const next = await showRenameModal(s.title) + if (next == null) return + const ok = await store.renameSession(sid, next) + if (ok) toast(t('engine.chatRenamed'), 'success') + else toast(t('engine.chatRenameFailed'), 'error') + }, + }, + { + label: t('engine.chatCopySessionId'), + icon: ICONS.copy, + action: async () => { + try { + const ok = await copyText(sid) + toast(ok ? t('common.copied') : t('engine.chatCopyFailed'), ok ? 'success' : 'error') + } catch { toast(t('engine.chatCopyFailed'), 'error') } + }, + }, + { + label: t('engine.chatDeleteSession'), + icon: ICONS.trash, + danger: true, + action: async () => { + const ok = await showConfirm(t('engine.chatConfirmDelete')) + if (!ok) return + try { + await store.deleteSession(sid) + toast(t('engine.chatSessionDeleted'), 'success') + } catch (err) { + const msg = err?.message === 'RUNNING_SESSION' ? t('engine.chatDeleteRunningBlocked') : (err?.message || err) + toast(t('engine.chatDeleteFailed') + ': ' + msg, 'error') } - } else if (showSlash) { - showSlash = false - el.querySelector('.hm-slash-menu')?.remove() - } - }) - input.focus() - } + }, + }, + ]) } - // --- 清理事件监听 --- - function cleanupListeners() { - for (const fn of unlisteners) fn() - unlisteners = [] + // ----------------------------------------------------------- slash handlers + + /** + * Reset the composed input state and redraw. Called after a send, slash + * command, or `/clear`, `/new` shortcut. + */ + function resetInput() { + inputValue = '' + inputCaret = 0 + showSlash = false + slashFilter = '' } - // --- 设置 Tauri 事件监听 --- - async function setupRunListeners() { - cleanupListeners() - const u1 = await tauriListen('hermes-run-delta', (e) => { - pendingText += e.payload?.delta || '' - updateStreamArea() - }) - const u2 = await tauriListen('hermes-run-tool', (e) => { - const evt = e.payload || {} - const evtType = evt.event || '' - const toolName = evt.tool || evt.tool_name || evt.name || 'tool' - const preview = evt.preview || evt.detail || evt.message || '' - // 提取 input/output 时兼容多种字段名 - const extractData = (obj, keys) => { - for (const k of keys) { - if (obj[k] != null && obj[k] !== '') return obj[k] - } - return null - } - // 构建去掉元字段后的 raw 快照,作为 fallback - const rawSnapshot = (exclude) => { - const copy = {} - for (const [k, v] of Object.entries(evt)) { - if (!exclude.includes(k) && v != null && v !== '') copy[k] = v - } - return Object.keys(copy).length ? copy : null - } - if (evtType === 'tool.started') { - const inputData = extractData(evt, ['input', 'args', 'arguments', 'parameters', 'params', 'data']) - activeTools.push({ name: toolName, status: 'active', detail: preview, input: inputData, output: null, error: null, _raw: rawSnapshot(['event', 'tool', 'tool_name', 'name']) }) - } else if (evtType === 'tool.completed') { - const t = activeTools.find(t => t.name === toolName && t.status === 'active') - if (t) { - t.status = evt.error ? 'error' : 'complete' - t.detail = evt.error ? '失败' : (evt.duration ? `${evt.duration}s` : '完成') - t.output = extractData(evt, ['output', 'result', 'content', 'data', 'response']) - if (evt.error) t.error = typeof evt.error === 'string' ? evt.error : JSON.stringify(evt.error) - // 合并 started 时可能没有的 input - if (!t.input) t.input = extractData(evt, ['input', 'args', 'arguments', 'parameters', 'params']) - t._rawCompleted = rawSnapshot(['event', 'tool', 'tool_name', 'name', 'error', 'duration']) - } - } else if (evtType === 'tool.error') { - const t = activeTools.find(t => t.name === toolName && t.status === 'active') - if (t) { - t.status = 'error' - t.detail = preview || '失败' - t.error = evt.error || preview || '未知错误' - } - } else if (evtType === 'tool.progress') { - const t = activeTools.find(t => t.name === toolName && t.status === 'active') - if (t && preview) t.detail = preview - } - updateStreamArea() - }) - const u3 = await tauriListen('hermes-run-done', (e) => { - const cur = active() - if (!cur) return - const output = e.payload?.output || pendingText || '(empty)' - // 存储工具摘要(含输入输出详情) - if (activeTools.length > 0) { - cur.messages.push({ role: 'tool-summary', tools: activeTools.map(t => ({ - name: t.name, status: t.status, detail: t.detail, - input: t.input, output: t.output, error: t.error, - _raw: t._raw, _rawCompleted: t._rawCompleted - })) }) - } - cur.messages.push({ role: 'assistant', content: output }) - streaming = false - pendingText = '' - activeTools = [] - saveSessions(sessions) - cleanupListeners() - draw() - }) - const u4 = await tauriListen('hermes-run-error', (e) => { - const cur = active() - if (!cur) return - const err = e.payload?.error || 'unknown error' - cur.messages.push({ role: 'assistant', content: `⚠️ Agent 运行失败: ${escHtml(err)}` }) - streaming = false - pendingText = '' - activeTools = [] - saveSessions(sessions) - cleanupListeners() - draw() - }) - unlisteners.push(u1, u2, u3, u4) - } + async function handleSend() { + const text = inputValue.trim() + if (!text || store.state.streaming) return - async function sendMessage() { - const input = el.querySelector('#hm-chat-input') - const text = input?.value?.trim() - if (!text || streaming) return - - const cur = active() - if (!cur) return - - // 本地命令处理(不走 Gateway) + // Local slash commands short-circuit before going to the agent. if (text === '/clear') { - cur.messages = []; cur.title = '' - saveSessions(sessions) - input.value = ''; draw(); return + store.clearActive() + resetInput(); draw(); return } if (text === '/new') { - newSession(); input.value = ''; draw(); return + store.newChat() + resetInput(); draw(); return } if (text === '/help') { - cur.messages.push({ role: 'user', content: text }) - cur.messages.push({ role: 'assistant', content: - '**可用命令:**\n' + - '`/help` — 显示此帮助\n' + - '`/status` — 查看 Gateway 状态\n' + - '`/memory` — 管理 Agent 记忆\n' + - '`/skills` — 查看可用技能\n' + - '`/clear` — 清空当前会话\n' + - '`/new` — 新建会话\n\n' + - '直接输入问题即可与 Hermes Agent 对话。' - }) - saveSessions(sessions) - input.value = ''; draw(); return + store.pushLocalUser(text) + store.pushLocalAssistant( + [ + `**${t('engine.chatSlashTitle')}**`, + '', + '`/help` — ' + t('engine.chatSlashHelpDesc'), + '`/status` — ' + t('engine.chatSlashStatusDesc'), + '`/memory` — ' + t('engine.chatSlashMemoryDesc'), + '`/skills` — ' + t('engine.chatSlashSkillsDesc'), + '`/clear` — ' + t('engine.chatSlashClearDesc'), + '`/new` — ' + t('engine.chatSlashNewDesc'), + ].join('\n') + ) + resetInput(); draw(); return } if (text === '/status') { - input.value = '' - cur.messages.push({ role: 'user', content: text }) + store.pushLocalUser(text) try { const info = await api.checkHermes() - const gw = info?.gatewayRunning ? '✅ 运行中' : '❌ 未运行' - const model = info?.model || '-' + const gw = info?.gatewayRunning ? '✅' : '❌' const port = info?.gatewayPort || 8642 - cur.messages.push({ role: 'assistant', content: - `**Gateway 状态:** ${gw}\n**端口:** ${port}\n**模型:** ${model}` - }) + const model = info?.model || '—' + store.pushLocalAssistant([ + `**${t('engine.chatSlashStatusTitle')}**`, + '', + `- ${t('engine.chatSlashGateway')}: ${gw}`, + `- ${t('engine.chatSlashPort')}: \`${port}\``, + `- ${t('engine.chatSlashModel')}: \`${model}\``, + ].join('\n')) } catch (e) { - cur.messages.push({ role: 'assistant', content: `⚠️ 获取状态失败: ${e}` }) + store.pushLocalAssistant('⚠️ ' + (e?.message || e)) } - saveSessions(sessions) - draw(); return + resetInput(); draw(); return + } + if (text === '/memory' || text === '/skills') { + store.pushLocalUser(text) + const target = text === '/memory' ? '/h/memory' : '/h/skills' + store.pushLocalAssistant( + t('engine.chatSlashRedirect').replace('{page}', `\`${target}\``) + ) + window.location.hash = '#' + target + resetInput(); draw(); return } - cur.messages.push({ role: 'user', content: text }) - if (!cur.title && cur.messages.length === 1) { - cur.title = text.slice(0, 30) - } - input.value = '' - input.style.height = 'auto' - streaming = true - pendingText = '' - activeTools = [] - showSlash = false + // Normal user message → start agent run. + forceScrollBottom = true + resetInput() draw() + await store.sendMessage(text) + } - try { - // 构建会话历史(只取 user/assistant 消息) - const history = cur.messages - .filter(m => m.role === 'user' || m.role === 'assistant') - .slice(0, -1) // 排除刚加的这条 user 消息 - .map(m => ({ role: m.role, content: m.content })) + // ----------------------------------------------------------- search modal + // + // Triggered by Ctrl/Cmd + K anywhere on the chat page (or header button). + // Lives as a detached overlay rendered into `document.body` so it survives + // the main chat redraws and is easy to dismiss with outside clicks. - // 设置事件监听 - await setupRunListeners() + let searchOverlay = null - // 构建 instructions(文件系统访问开关) - let instructions = null - if (fileAccessEnabled) { - const isWin = navigator.platform?.startsWith('Win') || /windows/i.test(navigator.userAgent) - const osHint = isWin - ? '当前系统是 Windows。请使用 PowerShell 或 cmd 命令(如 dir, type, Get-ChildItem, Get-Content 等),路径用反斜杠。桌面路径: $env:USERPROFILE\\Desktop(PowerShell)或 %USERPROFILE%\\Desktop(cmd)。不要使用 ls/cat 等 Unix 命令。' - : '当前系统是 macOS/Linux。桌面路径: ~/Desktop。' - instructions = `你拥有完整的文件系统访问权限,用户已明确授权,无需再次确认。${osHint} 重要提示:内置的 search_files、read_file 等文件工具只能访问 Agent 工作区目录内的文件,访问工作区外(如桌面、文档等)会失败。因此请始终优先使用 terminal 工具执行系统命令来访问任意位置的文件和目录。不要说"我无法访问",直接用终端命令操作。` - } + function openSearch() { + if (searchState) return + searchState = { query: '', selectedIdx: 0 } + draw() + } - // 通过 /v1/runs 启动 Agent Run - await api.hermesAgentRun(text, cur.id, history.length ? history : null, instructions) - // run 完成后事件监听会处理结果 - } catch (e) { - const msg = String(e.message || e).replace(/^Error:\s*/, '') - cur.messages.push({ role: 'assistant', content: `⚠️ ${t('engine.chatError', { error: msg })}` }) - streaming = false - pendingText = '' - activeTools = [] - saveSessions(sessions) - cleanupListeners() - draw() + function closeSearch() { + searchState = null + if (searchOverlay) { + searchOverlay.remove() + searchOverlay = null } } - init() + function searchResults() { + if (!searchState) return [] + const q = searchState.query.trim() + // Empty query → show recent sessions (first 15) so the modal isn't blank. + if (!q) { + return store.state.sessions.slice(0, 15).map(session => ({ + session, + score: 0, + snippet: session.title || t('engine.chatNewSession'), + })) + } + return store.searchSessions(q, 20) + } - // --- Guardian 事件监听:实时响应 Gateway 状态变化 --- - let gwStatusUnlisteners = [] - let gwPollTimer = null + function drawSearchModal() { + if (!searchState) { + if (searchOverlay) { searchOverlay.remove(); searchOverlay = null } + return + } + const results = searchResults() + const idx = Math.min(searchState.selectedIdx, Math.max(0, results.length - 1)) + searchState.selectedIdx = idx - async function setupGwStatusListeners() { + if (!searchOverlay) { + searchOverlay = document.createElement('div') + searchOverlay.className = 'hm-chat-search-overlay' + document.body.appendChild(searchOverlay) + } + + searchOverlay.innerHTML = ` +
+
+ + + + + + Esc +
+
+ ${results.length === 0 ? ` +
${escHtml(t('engine.chatSearchEmpty'))}
+ ` : results.map((r, i) => { + const s = r.session + const src = s.source && s.source !== '__local__' ? getSourceLabel(s.source) : '' + return ` + + ` + }).join('')} +
+
+ ${escHtml(t('engine.chatSearchNavigate'))} + Enter ${escHtml(t('engine.chatSearchOpen'))} +
+
+ ` + + const inputEl = searchOverlay.querySelector('#hm-chat-search-input') + inputEl?.focus() try { - const unlisten = await tauriListen('hermes-gateway-status', (evt) => { - const wasOnline = gwOnline - gwOnline = !!evt.payload?.running - if (wasOnline !== gwOnline) draw() + const pos = searchState.query.length + inputEl?.setSelectionRange(pos, pos) + } catch {} + + inputEl?.addEventListener('input', () => { + searchState.query = inputEl.value + searchState.selectedIdx = 0 + drawSearchModal() + }) + + searchOverlay.addEventListener('mousedown', (e) => { + if (e.target === searchOverlay) closeSearch() + }, { once: true }) + + searchOverlay.querySelectorAll('.hm-chat-search-item').forEach(btn => { + btn.addEventListener('click', () => { + const sid = btn.dataset.sid + selectSearchResult(sid) }) - gwStatusUnlisteners.push(unlisten) - } catch (_) {} - - // 定期轮询作为补充(10s) - gwPollTimer = setInterval(async () => { - if (streaming) return - try { - const info = await api.checkHermes() - const wasOnline = gwOnline - gwOnline = !!info?.gatewayRunning - if (wasOnline !== gwOnline) draw() - } catch (_) {} - }, 10000) + btn.addEventListener('mouseenter', () => { + searchState.selectedIdx = Number(btn.dataset.idx) + // Cheap class swap instead of full redraw. + searchOverlay.querySelectorAll('.hm-chat-search-item').forEach(b => + b.classList.toggle('is-active', Number(b.dataset.idx) === searchState.selectedIdx)) + }) + }) } - setupGwStatusListeners() - // 页面卸载时清理 - const gwCleanup = () => { - gwStatusUnlisteners.forEach(fn => fn()) - gwStatusUnlisteners = [] - if (gwPollTimer) { clearInterval(gwPollTimer); gwPollTimer = null } - cleanupListeners() + function selectSearchResult(sid) { + if (!sid) return + forceScrollBottom = true + store.switchSession(sid) + if (mobileQuery.matches) sidebarOpen = false + closeSearch() } - const chatDetachObserver = new MutationObserver(() => { - if (!el.isConnected) { gwCleanup(); chatDetachObserver.disconnect() } + + // --- Global keyboard: Ctrl/Cmd+K opens search, keys navigate when open --- + function onGlobalKey(e) { + if (!el.isConnected) return + const isMac = /Mac|iPhone|iPad/i.test(navigator.platform) + const mod = isMac ? e.metaKey : e.ctrlKey + if (mod && (e.key === 'k' || e.key === 'K')) { + e.preventDefault() + if (searchState) closeSearch() + else openSearch() + return + } + if (!searchState) return + if (e.key === 'Escape') { + e.preventDefault() + closeSearch() + } else if (e.key === 'ArrowDown') { + e.preventDefault() + const results = searchResults() + if (!results.length) return + searchState.selectedIdx = (searchState.selectedIdx + 1) % results.length + drawSearchModal() + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const results = searchResults() + if (!results.length) return + searchState.selectedIdx = (searchState.selectedIdx - 1 + results.length) % results.length + drawSearchModal() + } else if (e.key === 'Enter') { + const results = searchResults() + const hit = results[searchState.selectedIdx] + if (hit) { + e.preventDefault() + selectSearchResult(hit.session.id) + } + } + } + document.addEventListener('keydown', onGlobalKey) + + // Close profile menu on outside click (capture so menu's own click handlers + // still get to run before we close). + function onGlobalClick(e) { + if (!profileMenuOpen) return + if (!el.isConnected) return + const wrap = el.querySelector('.hm-chat-sidebar-profile') + if (wrap && wrap.contains(e.target)) return + profileMenuOpen = false + draw() + } + document.addEventListener('click', onGlobalClick) + + // Detach the global listener + close modal on unmount. A single + // MutationObserver watches our parent; when `el` is detached, we run the + // full teardown (stream listeners, subscription, search modal, keydown). + const teardown = () => { + document.removeEventListener('keydown', onGlobalKey) + document.removeEventListener('click', onGlobalClick) + closeSearch() + unsubscribe() + store.detachStreamListeners() + } + const mountObserver = new MutationObserver(() => { + if (!el.isConnected) { teardown(); mountObserver.disconnect() } }) requestAnimationFrame(() => { - if (el.parentNode) chatDetachObserver.observe(el.parentNode, { childList: true }) + if (el.parentNode) mountObserver.observe(el.parentNode, { childList: true }) }) + // Seed the initial draw (before store load resolves). + draw() return el } diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 716d45e..fadefa0 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -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 = ` - -
- ${t('engine.comingSoonPhase2')} -
- ` + 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, '"') + } + + function draw() { + el.innerHTML = ` +
+
+
HERMES AGENT · CONFIG
+

${t('engine.hermesConfigTitle')}

+
~/.hermes/config.yaml
+
+
+ + +
+
+ +
+
+
config.yaml
+
+ ${saving ? 'saving…' : loading ? 'loading…' : 'raw yaml editor'} +
+
+
+ ${error ? `
${esc(error)}
` : ''} + +
+
+ ` + 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 } diff --git a/src/engines/hermes/pages/cron.js b/src/engines/hermes/pages/cron.js index b7bd419..4a92357 100644 --- a/src/engines/hermes/pages/cron.js +++ b/src/engines/hermes/pages/cron.js @@ -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 = ` -