mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(hermes): Batch 2 §H - Profiles 管理 UI + Dashboard API 通用代理
校对稿要点:「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 ✓
This commit is contained in:
@@ -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 不能为空')
|
||||
|
||||
@@ -3832,6 +3832,72 @@ pub async fn hermes_session_export(session_id: String) -> Result<Value, String>
|
||||
resp.json::<Value>().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<String>,
|
||||
headers: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
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::<Value>(&resp_body)
|
||||
.unwrap_or_else(|_| Value::String(resp_body)))
|
||||
}
|
||||
|
||||
/// Batch 3 §K: 多模态附件结构
|
||||
///
|
||||
/// 前端传过来的附件描述(图片用 base64 直传)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') },
|
||||
|
||||
199
src/engines/hermes/pages/profiles.js
Normal file
199
src/engines/hermes/pages/profiles.js
Normal file
@@ -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, '>').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 = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">${escHtml(t('engine.hermesProfilesTitle'))}</h1>
|
||||
<p class="page-desc">${escHtml(t('engine.hermesProfilesDesc'))}</p>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-secondary btn-sm" id="hm-profiles-refresh">${escHtml(t('hermesLazyDeps.refresh'))}</button>
|
||||
<button class="btn btn-primary btn-sm" id="hm-profiles-create">+ ${escHtml(t('engine.hermesProfileNew'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hm-profiles-content">
|
||||
${loading ? `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>` : ''}
|
||||
${error ? `<div style="color:var(--error);padding:20px">${escHtml(error)}</div>` : ''}
|
||||
${(!loading && !error && !profiles.length) ? `
|
||||
<div class="empty-state empty-compact">
|
||||
<div class="empty-icon">📁</div>
|
||||
<div class="empty-title">${escHtml(t('engine.hermesProfilesEmpty'))}</div>
|
||||
</div>` : ''}
|
||||
${(!loading && profiles.length) ? `
|
||||
<div class="lazy-deps-grid">
|
||||
${profiles.map(renderProfileCard).join('')}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
}
|
||||
|
||||
function renderProfileCard(p) {
|
||||
const isActive = !!p.active
|
||||
const desc = p.description ? `<div class="lazy-deps-card-meta" title="${escAttr(p.description)}">${escHtml(p.description)}</div>` : ''
|
||||
return `
|
||||
<div class="lazy-deps-card">
|
||||
<div class="lazy-deps-card-head">
|
||||
<div class="lazy-deps-card-title" title="${escAttr(p.name)}">${escHtml(p.name)}</div>
|
||||
${isActive ? `<span class="lazy-deps-badge ok">${escHtml(t('engine.hermesProfileActive'))}</span>` : ''}
|
||||
</div>
|
||||
${desc}
|
||||
<div class="lazy-deps-card-actions" style="gap:6px">
|
||||
${isActive ? '' : `<button class="btn btn-secondary btn-sm" data-action="switch" data-name="${escAttr(p.name)}">${escHtml(t('engine.hermesProfileSwitch'))}</button>`}
|
||||
<button class="btn btn-secondary btn-sm" data-action="rename" data-name="${escAttr(p.name)}">${escHtml(t('engine.hermesProfileRename'))}</button>
|
||||
${isActive ? '' : `<button class="btn btn-secondary btn-sm" data-action="delete" data-name="${escAttr(p.name)}" style="color:var(--error)">${escHtml(t('engine.hermesProfileDelete'))}</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',
|
||||
|
||||
Reference in New Issue
Block a user