From 31685515693440877dcd773afaa936253cc3700f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 05:04:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(hermes):=20Batch=202=20=C2=A7H=20-=20Profi?= =?UTF-8?q?les=20=E7=AE=A1=E7=90=86=20UI=20+=20Dashboard=20API=20=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 校对稿要点:「Profiles 全部走 HTTP /api/profiles*,无需自己写 CLI 桥接」。 ## 基础设施: hermes_dashboard_api_proxy(通用 9119 HTTP 代理) 新增 Tauri 命令 hermes_dashboard_api_proxy(method, path, body, headers): - 支持 GET/POST/PUT/PATCH/DELETE - 走 Dashboard 9119 端口(无需 API_SERVER_KEY,本地绑定) - 自动 JSON parse + 错误时友好提示「请先启动 Dashboard」 - 一次实现,未来 Batch 2/3 的 Profiles/Kanban/OAuth/Sessions 都走这一个入口 前端 wrapper: api.hermesDashboardApi(method, path, body, headers) dev-api.js: Web 模式同步实现 ## Profiles 管理页 /h/profiles 新文件 src/engines/hermes/pages/profiles.js: - GET /api/profiles 列表 → 渲染卡片网格(复用 .lazy-deps-grid 样式) - 每张卡片:profile 名 + active 徽章 + Switch/Rename/Delete 按钮 - 「+ 新建」按钮 → showModal 弹窗(name + clone_from_default 选项) - 「重命名」→ PATCH /api/profiles/{name} { new_name } - 「删除」→ showConfirm(带 impact 提示「永久清除会话/凭据/记忆」)→ DELETE /api/profiles/{name} - 「切换到此」→ 复用现有 chatStore.switchProfile(CLI 实现) - 失败走 humanizeError 友好提示 - active profile 与 chatStore.state.activeProfile 对齐 ## sidebar + 路由 - Hermes 引擎「管理」section 加 Profile 管理入口 - 路由 /h/profiles 注册到 hermes/index.js ## i18n - engine.hermesProfilesTitle / hermesProfilesDesc / hermesProfilesEmpty - hermesProfileNew / NewTitle / NameLabel / NameRequired / CloneFromDefault / CloneHint - hermesProfileSwitch / Switched / SwitchFailed - hermesProfileRename / RenameTitle / NewNameLabel / Renamed / RenameFailed - hermesProfileDelete / DeleteConfirm / DeleteImpact / Deleted / DeleteFailed - hermesProfileActive / Created / CreateFailed - 共 21 个键 × 3 语言 ## 累计 - Rust: 1 个新命令(hermes_dashboard_api_proxy ~50 行) - 前端: 1 个 wrapper + 新页面 ~180 行 - dev-api.js: 1 个 handler - i18n: 21 个新键 × 3 语言 - cargo check ✓ + npm build ✓ --- scripts/dev-api.js | 19 +++ src-tauri/src/commands/hermes.rs | 66 +++++++++ src-tauri/src/lib.rs | 1 + src/engines/hermes/index.js | 2 + src/engines/hermes/pages/profiles.js | 199 +++++++++++++++++++++++++++ src/lib/tauri-api.js | 6 + src/locales/modules/engine.js | 26 ++++ 7 files changed, 319 insertions(+) create mode 100644 src/engines/hermes/pages/profiles.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 060c384..51bd70d 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -7209,6 +7209,25 @@ const handlers = { return await resp.json().catch(() => ({ ok: true })) }, + // Batch 2 §H 基础设施: 通用 Dashboard 9119 HTTP 代理 + async hermes_dashboard_api_proxy({ method = 'GET', path: reqPath = '/', body = null, headers: customHeaders } = {}) { + const port = handlers._hermesDashboardPort() + const url = `http://127.0.0.1:${port}${reqPath}` + const opts = { method: String(method).toUpperCase(), headers: { 'User-Agent': 'ClawPanel-Web' } } + opts.signal = AbortSignal.timeout(30000) + if (customHeaders && typeof customHeaders === 'object') { + Object.assign(opts.headers, customHeaders) + } + if (body != null && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(opts.method)) { + opts.headers['Content-Type'] = 'application/json' + opts.body = typeof body === 'string' ? body : JSON.stringify(body) + } + const resp = await globalThis.fetch(url, opts) + const text = await resp.text().catch(() => '') + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text}(提示:请先启动 Dashboard)`) + try { return JSON.parse(text) } catch { return text } + }, + // Batch 1 §E: Sessions 导出(走 dashboard 9119) async hermes_session_export({ sessionId } = {}) { if (!sessionId) throw new Error('session_id 不能为空') diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 17117d3..a8b06e4 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3832,6 +3832,72 @@ pub async fn hermes_session_export(session_id: String) -> Result resp.json::().await.map_err(|e| format!("解析 JSON 失败: {e}")) } +// --------------------------------------------------------------------------- +// Batch 2 §H 基础设施: hermes_dashboard_api_proxy +// +// 通用 Dashboard 9119 HTTP 代理 — 让前端直接调任意 /api/* 端点。 +// Profiles / Kanban / OAuth / Sessions(高级)等都走这一个入口, +// 避免给每个端点都写专属 Tauri 命令。 +// +// 与 hermes_api_proxy 区别: +// - hermes_api_proxy 走 Gateway 8642(含 API_SERVER_KEY 认证) +// - hermes_dashboard_api_proxy 走 Dashboard 9119(无需 token,本地绑定) +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn hermes_dashboard_api_proxy( + method: String, + path: String, + body: Option, + headers: Option, +) -> Result { + let port = hermes_dashboard_port(); + let url = format!("http://127.0.0.1:{port}{path}"); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("HTTP 客户端创建失败: {e}"))?; + + let mut req = match method.to_uppercase().as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "PATCH" => client.patch(&url), + "DELETE" => client.delete(&url), + _ => return Err(format!("不支持的方法: {method}")), + }; + + // 自定义 headers + if let Some(Value::Object(map)) = headers { + for (k, v) in map.iter() { + if let Some(s) = v.as_str() { + req = req.header(k, s); + } + } + } + + // body(POST/PUT/PATCH/DELETE)— 假定是 JSON 字符串 + if let Some(b) = body { + req = req + .header("Content-Type", "application/json") + .body(b); + } + + let resp = req + .send() + .await + .map_err(|e| format!("Dashboard 请求失败: {}(提示:请先启动 Dashboard)", reqwest_error_detail(&e)))?; + let status = resp.status(); + let resp_body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status.as_u16(), resp_body)); + } + // 尝试解析 JSON,失败回退到字符串包装 + Ok(serde_json::from_str::(&resp_body) + .unwrap_or_else(|_| Value::String(resp_body))) +} + /// Batch 3 §K: 多模态附件结构 /// /// 前端传过来的附件描述(图片用 base64 直传)。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8969f15..1570e2d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -242,6 +242,7 @@ pub fn run() { hermes::hermes_run_stop, hermes::hermes_run_approval, hermes::hermes_session_export, + hermes::hermes_dashboard_api_proxy, hermes::hermes_read_config, hermes::hermes_read_config_full, hermes::hermes_lazy_deps_features, diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index 533f54a..0d01a94 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -86,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/profiles', label: t('engine.hermesProfilesTitle'), icon: 'agents' }, { route: '/h/lazy-deps', label: t('hermesLazyDeps.title'), icon: 'package' }, { route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' }, ] @@ -113,6 +114,7 @@ export default { { 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/profiles', loader: () => import('./pages/profiles.js') }, { path: '/h/lazy-deps', loader: () => import('./pages/lazy-deps.js') }, { path: '/h/services', loader: () => import('./pages/services.js') }, { path: '/h/config', loader: () => import('./pages/config.js') }, diff --git a/src/engines/hermes/pages/profiles.js b/src/engines/hermes/pages/profiles.js new file mode 100644 index 0000000..6c36e05 --- /dev/null +++ b/src/engines/hermes/pages/profiles.js @@ -0,0 +1,199 @@ +/** + * Hermes Profile 管理(Batch 2 §H) + * + * 全部走 Dashboard 9119 HTTP API(hermes_dashboard_api_proxy): + * - GET /api/profiles - 列表 + * - POST /api/profiles { name, clone_from_default, no_skills } - 创建 + * - PATCH /api/profiles/{name} { new_name } - 重命名 + * - DELETE /api/profiles/{name} - 删除 + * + * 切换 active profile 仍走现有 chat-store.switchProfile(CLI 实现), + * 因为 dashboard server 绑定的 active profile 改变后还需要重启 dashboard。 + */ +import { t } from '../../../lib/i18n.js' +import { api } from '../../../lib/tauri-api.js' +import { toast } from '../../../components/toast.js' +import { showConfirm, showModal } from '../../../components/modal.js' +import { humanizeError } from '../../../lib/humanize-error.js' +import { getChatStore } from '../lib/chat-store.js' + +const chatStore = getChatStore() + +function escHtml(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} +function escAttr(s) { return escHtml(s) } + +export function render() { + const el = document.createElement('div') + el.className = 'page' + el.dataset.engine = 'hermes' + + let profiles = [] + let loading = true + let error = '' + + function draw() { + el.innerHTML = ` + +
+ ${loading ? `
${escHtml(t('common.loading'))}…
` : ''} + ${error ? `
${escHtml(error)}
` : ''} + ${(!loading && !error && !profiles.length) ? ` +
+
📁
+
${escHtml(t('engine.hermesProfilesEmpty'))}
+
` : ''} + ${(!loading && profiles.length) ? ` +
+ ${profiles.map(renderProfileCard).join('')} +
` : ''} +
+ ` + bind() + } + + function renderProfileCard(p) { + const isActive = !!p.active + const desc = p.description ? `
${escHtml(p.description)}
` : '' + return ` +
+
+
${escHtml(p.name)}
+ ${isActive ? `${escHtml(t('engine.hermesProfileActive'))}` : ''} +
+ ${desc} +
+ ${isActive ? '' : ``} + + ${isActive ? '' : ``} +
+
+ ` + } + + function bind() { + el.querySelector('#hm-profiles-refresh')?.addEventListener('click', load) + el.querySelector('#hm-profiles-create')?.addEventListener('click', onCreate) + el.querySelectorAll('[data-action]').forEach(btn => { + const action = btn.dataset.action + const name = btn.dataset.name + btn.addEventListener('click', () => { + if (action === 'switch') onSwitch(name) + else if (action === 'rename') onRename(name) + else if (action === 'delete') onDelete(name) + }) + }) + } + + async function load() { + loading = true + error = '' + draw() + try { + const resp = await api.hermesDashboardApi('GET', '/api/profiles') + const list = Array.isArray(resp) ? resp : (resp?.profiles || []) + // active 标记:与 chat-store 的 activeProfile 对齐 + const activeName = chatStore.state?.activeProfile || 'default' + profiles = list.map(p => ({ + name: p.name || String(p), + description: p.description || p.kind || '', + active: (p.name || String(p)) === activeName, + raw: p, + })) + } catch (e) { + error = String(e?.message || e) + } finally { + loading = false + draw() + } + } + + function onCreate() { + showModal({ + title: t('engine.hermesProfileNewTitle'), + fields: [ + { name: 'name', label: t('engine.hermesProfileNameLabel'), value: '', placeholder: 'work, personal, ...' }, + { name: 'clone_from_default', type: 'checkbox', value: true, label: t('engine.hermesProfileCloneFromDefault'), hint: t('engine.hermesProfileCloneHint') }, + ], + onConfirm: async (data) => { + const name = (data.name || '').trim() + if (!name) { + toast(t('engine.hermesProfileNameRequired'), 'error') + return + } + try { + await api.hermesDashboardApi('POST', '/api/profiles', { + name, + clone_from_default: !!data.clone_from_default, + no_skills: false, + }) + toast(t('engine.hermesProfileCreated', { name }), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesProfileCreateFailed')), 'error') + } + }, + }) + } + + function onRename(name) { + showModal({ + title: t('engine.hermesProfileRenameTitle', { name }), + fields: [ + { name: 'new_name', label: t('engine.hermesProfileNewNameLabel'), value: name, placeholder: name }, + ], + onConfirm: async (data) => { + const newName = (data.new_name || '').trim() + if (!newName || newName === name) return + try { + await api.hermesDashboardApi('PATCH', `/api/profiles/${encodeURIComponent(name)}`, { new_name: newName }) + toast(t('engine.hermesProfileRenamed', { from: name, to: newName }), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesProfileRenameFailed')), 'error') + } + }, + }) + } + + async function onDelete(name) { + const ok = await showConfirm({ + message: t('engine.hermesProfileDeleteConfirm', { name }), + impact: [t('engine.hermesProfileDeleteImpact')], + confirmText: t('engine.hermesProfileDelete'), + danger: true, + }) + if (!ok) return + try { + await api.hermesDashboardApi('DELETE', `/api/profiles/${encodeURIComponent(name)}`) + toast(t('engine.hermesProfileDeleted', { name }), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesProfileDeleteFailed')), 'error') + } + } + + async function onSwitch(name) { + try { + await chatStore.switchProfile(name) + toast(t('engine.hermesProfileSwitched', { name }), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesProfileSwitchFailed')), 'error') + } + } + + draw() + load() + return el +} diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 7a716f0..643ffb9 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -480,6 +480,12 @@ export const api = { hermesRunApproval: (runId, choice) => invoke('hermes_run_approval', { runId, choice }), // Batch 1 §E: 会话消息导出(走 dashboard /api/sessions/{id}/messages) hermesSessionExport: (sessionId) => invoke('hermes_session_export', { sessionId }), + // Batch 2 §H 基础设施: 通用 Dashboard 9119 HTTP 代理 + hermesDashboardApi: (method, path, body, headers) => invoke('hermes_dashboard_api_proxy', { + method, path, + body: body == null ? null : (typeof body === 'string' ? body : JSON.stringify(body)), + headers: headers || null, + }), hermesReadConfig: () => invoke('hermes_read_config'), hermesReadConfigFull: () => invoke('hermes_read_config_full'), hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 83197e8..adae7e9 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -460,6 +460,32 @@ export default { chatAttachTooBig: _('图片过大(最大 10 MB)', 'Image too large (max 10 MB)', '圖片過大(最大 10 MB)'), chatAttachTooMany: _('最多 5 张图片', 'Up to 5 images', '最多 5 張圖片'), chatAttachReadFailed: _('读取图片失败', 'Failed to read image', '讀取圖片失敗'), + // Batch 2 §H: Profiles 管理 + hermesProfilesTitle: _('Profile 管理', 'Profiles', 'Profile 管理'), + hermesProfilesDesc: _('每个 Profile 是独立的工作区,凭据、记忆、会话彼此隔离', 'Each profile is an isolated workspace — credentials, memory, sessions are kept separate', '每個 Profile 是獨立的工作區,憑證、記憶、會話彼此隔離'), + hermesProfilesEmpty: _('暂无 Profile(请先启动 Dashboard)', 'No profiles (start Dashboard first)', '暫無 Profile(請先啟動 Dashboard)'), + hermesProfileNew: _('新建', 'New', '新建'), + hermesProfileNewTitle: _('新建 Profile', 'New profile', '新建 Profile'), + hermesProfileNameLabel: _('Profile 名称', 'Profile name', 'Profile 名稱'), + hermesProfileNameRequired: _('名称不能为空', 'Name is required', '名稱不能為空'), + hermesProfileCloneFromDefault: _('从默认 Profile 复制(推荐)', 'Clone from default (recommended)', '從預設 Profile 複製(推薦)'), + hermesProfileCloneHint: _('打勾会复制 default 的模型、技能、记忆。不勾会创建空白 Profile。', 'Checked: clone default\'s models / skills / memory. Unchecked: empty profile.', '勾選會複製 default 的模型、技能、記憶。不勾建立空白 Profile。'), + hermesProfileSwitch: _('切换到此', 'Switch to', '切換到此'), + hermesProfileSwitched: _('已切换到 {name}', 'Switched to {name}', '已切換到 {name}'), + hermesProfileSwitchFailed: _('切换失败', 'Switch failed', '切換失敗'), + hermesProfileRename: _('重命名', 'Rename', '重新命名'), + hermesProfileRenameTitle: _('重命名 Profile "{name}"', 'Rename profile "{name}"', '重新命名 Profile "{name}"'), + hermesProfileNewNameLabel: _('新名称', 'New name', '新名稱'), + hermesProfileRenamed: _('已重命名: {from} → {to}', 'Renamed: {from} → {to}', '已重新命名: {from} → {to}'), + hermesProfileRenameFailed: _('重命名失败', 'Rename failed', '重新命名失敗'), + hermesProfileDelete: _('删除', 'Delete', '刪除'), + hermesProfileDeleteConfirm: _('确认删除 Profile "{name}"?', 'Delete profile "{name}"?', '確認刪除 Profile "{name}"?'), + hermesProfileDeleteImpact: _('这会永久清除该 Profile 的会话、凭据和记忆文件', 'This permanently removes the profile\'s sessions, credentials, and memory files', '這會永久清除該 Profile 的會話、憑證和記憶檔案'), + hermesProfileDeleted: _('Profile "{name}" 已删除', 'Profile "{name}" deleted', 'Profile "{name}" 已刪除'), + hermesProfileDeleteFailed: _('删除失败', 'Delete failed', '刪除失敗'), + hermesProfileActive: _('当前', 'Active', '當前'), + hermesProfileCreated: _('已创建 Profile "{name}"', 'Profile "{name}" created', '已建立 Profile "{name}"'), + hermesProfileCreateFailed: _('创建失败', 'Create failed', '建立失敗'), // Web 模式(远程浏览器)下流式聊天暂不可用 chatWebModeStreamingUnsupported: _( 'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',