From debce2f8107f0ca55c3d96cd8024aa38a5ddc90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 05:39:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(hermes):=20Batch=203=20=C2=A7N=20-=20?= =?UTF-8?q?=E7=BE=A4=E8=81=8A=EF=BC=88=E5=A4=9A=20Agent=20=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E5=93=8D=E5=BA=94=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes 内核没有「群聊」概念,由 ClawPanel 前端编排: - 用户选 1-N 个 Profile(每个对应一个 Agent 配置) - 发消息 → 串行调度(用 hermes_profile_use 切换 + hermesAgentRun 阻塞调用) - 每个 Profile 的回复用 @profile_name 标记后显示在统一界面 ## 新页面 /h/group-chat(~250 行) ### UI 布局 - 左侧 240px: Profile 多选列表 · 顶部说明 + 已选 N 个统计 · checkbox 风格,is-checked 高亮 - 右侧主区: 消息列表 + 输入框 · user 消息右对齐(accent 蓝色气泡) · assistant 消息左对齐(每条带 @profile 徽章) · loading 状态用三点跳动动画 · error 状态红色 + ⚠️ 图标 · 输入框 Enter 发送 / Shift+Enter 换行 ### 调度策略 - 当前实现:串行(hermes_profile_use 切换 + hermesAgentRun) · 简单稳健,避免后端并发对同一 profile 状态的竞态 · 缺点:N 个 profile 总耗时 = N × 单次耗时 - 真正并发需后端支持 hermes_agent_run(profile=...) 参数 · 留作下次内核改造(设计稿已记下) ### 限制(明确告知用户) - 仅 Tauri 桌面端(Web 模式显示禁用提示) - 非流式(用阻塞式 hermesAgentRun,等所有完成后一起显示) - 不持久化(一次性会话,刷新清空,不挂 chat-store) - 切 profile 后不还原(用户后续 /h/chat 会保持最后切到的 profile) ### sidebar - 监控 section 加群聊入口(agents icon,紧跟 /h/chat 之后) - /h/group-chat 路由注册 ### CSS(~180 行) - .hm-gc-layout: grid 双栏(响应式 → 单栏) - .hm-gc-side: 左侧 profile 多选 - .hm-gc-main: 右侧消息列表 + 输入区 - 加载点动画 hm-gc-pulse - @profile 徽章用 mono 字体 + accent 色 ### i18n - 13 个新键 × 3 语言(hermesGroupChat*) ## 累计 - 1 个新页面 ~250 行 + CSS ~180 行 + i18n 39 字符串 - npm build ✓ --- src/engines/hermes/index.js | 2 + src/engines/hermes/pages/group-chat.js | 266 +++++++++++++++++++++++++ src/engines/hermes/style/hermes.css | 177 ++++++++++++++++ src/locales/modules/engine.js | 14 ++ 4 files changed, 459 insertions(+) create mode 100644 src/engines/hermes/pages/group-chat.js diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index 49c805a..3ce6c7a 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -109,6 +109,8 @@ export default { // Hermes 专属页面(/h/ 前缀) { 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/group-chat', loader: () => import('./pages/group-chat.js') }, { path: '/h/oauth', loader: () => import('./pages/oauth.js') }, { path: '/h/files', loader: () => import('./pages/files.js') }, { path: '/h/sessions', loader: () => import('./pages/sessions.js') }, diff --git a/src/engines/hermes/pages/group-chat.js b/src/engines/hermes/pages/group-chat.js new file mode 100644 index 0000000..16b86d3 --- /dev/null +++ b/src/engines/hermes/pages/group-chat.js @@ -0,0 +1,266 @@ +/** + * Hermes 群聊(Batch 3 §N) + * + * Hermes 内核没有「群聊」概念,由 ClawPanel 前端编排: + * - 用户选择多个 Profile(每个对应一个 Agent 配置) + * - 发消息时并发调用每个 Profile 的 hermes_agent_run + * - 把每个 Profile 的回复以 @profile_name 标记后显示 + * + * 限制: + * - 仅 Tauri 桌面端(Web 模式禁用,因为依赖 hermesAgentRun) + * - 非流式(用阻塞式 run 等所有完成) + * - 不持久化(一次性会话,刷新清空) + */ +import { t } from '../../../lib/i18n.js' +import { api, isTauriRuntime } from '../../../lib/tauri-api.js' +import { toast } from '../../../components/toast.js' +import { humanizeError } from '../../../lib/humanize-error.js' + +function escHtml(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} +function escAttr(s) { return escHtml(s) } + +function uid() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` } + +function formatTime(ts) { + const d = new Date(ts) + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +export function render() { + const el = document.createElement('div') + el.className = 'page' + el.dataset.engine = 'hermes' + + let profiles = [] + let selected = new Set() // 选中的 profile 名集合 + let messages = [] // [{ id, role: 'user'|'assistant'|'system', from?, content, ts, error?, loading? }] + let inputValue = '' + let sending = false + let loadError = '' + + function draw() { + if (!isTauriRuntime()) { + el.innerHTML = ` + +
+ ${escHtml(t('engine.hermesGroupChatWebUnsupported'))} +
` + return + } + el.innerHTML = ` + + ${loadError ? `
${escHtml(loadError)}
` : ''} +
+
+
${escHtml(t('engine.hermesGroupChatProfiles'))}
+
${escHtml(t('engine.hermesGroupChatProfilesHint'))}
+
+ ${profiles.length ? profiles.map(renderProfileCheckbox).join('') : `
${escHtml(t('common.loading'))}…
`} +
+
${escHtml(t('engine.hermesGroupChatSelected', { n: selected.size }))}
+
+
+
+ ${messages.length === 0 ? `
${escHtml(t('engine.hermesGroupChatEmpty'))}
` : messages.map(renderMessage).join('')} +
+
+ + +
+
+
+ ` + bind() + scrollToBottom() + } + + function renderProfileCheckbox(p) { + const isChecked = selected.has(p) + return ` + + ` + } + + function renderMessage(m) { + if (m.role === 'user') { + return ` +
+
${escHtml(m.content)}
+
${escHtml(formatTime(m.ts))}
+
+ ` + } + if (m.role === 'system') { + return ` +
+
${escHtml(m.content)}
+
+ ` + } + // assistant + const fromTag = m.from ? `@${escHtml(m.from)}` : '' + if (m.loading) { + return ` +
+
${fromTag}
+
+ ` + } + if (m.error) { + return ` +
+
${fromTag} ⚠️ ${escHtml(t('engine.hermesGroupChatRunFailed'))}
+
${escHtml(m.error)}
+
+ ` + } + return ` +
+
${fromTag} ${escHtml(formatTime(m.ts))}
+
${escHtml(m.content)}
+
+ ` + } + + function bind() { + el.querySelector('#hm-gc-clear')?.addEventListener('click', () => { + messages = [] + draw() + }) + el.querySelectorAll('input[data-profile]').forEach(cb => { + cb.addEventListener('change', () => { + const name = cb.dataset.profile + if (cb.checked) selected.add(name) + else selected.delete(name) + draw() + }) + }) + const input = el.querySelector('#hm-gc-input') + if (input) { + input.addEventListener('input', () => { + inputValue = input.value + // 只更新 send 按钮 disabled + const btn = el.querySelector('#hm-gc-send') + if (btn) btn.disabled = sending || !inputValue.trim() || !selected.size + }) + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if (!sending && inputValue.trim() && selected.size) onSend() + } + }) + } + el.querySelector('#hm-gc-send')?.addEventListener('click', onSend) + } + + function scrollToBottom() { + const box = el.querySelector('#hm-gc-messages') + if (box) box.scrollTop = box.scrollHeight + } + + async function loadProfiles() { + try { + const data = await api.hermesProfilesList().catch(() => ({ profiles: [] })) + const arr = Array.isArray(data) ? data : (data?.profiles || []) + profiles = arr.map(p => (typeof p === 'string' ? p : (p.name || ''))).filter(Boolean) + if (!profiles.includes('default')) profiles.unshift('default') + // 默认选中前 1 个 + if (!selected.size && profiles.length) selected.add(profiles[0]) + } catch (e) { + loadError = String(e?.message || e) + } + draw() + } + + async function onSend() { + const text = inputValue.trim() + if (!text || !selected.size || sending) return + sending = true + const userMsg = { id: uid(), role: 'user', content: text, ts: Date.now() } + messages.push(userMsg) + inputValue = '' + + // 给每个选中的 profile 创建 loading 占位 + const targets = Array.from(selected) + const placeholders = targets.map(p => ({ + id: uid(), + role: 'assistant', + from: p, + content: '', + loading: true, + ts: Date.now(), + })) + messages.push(...placeholders) + draw() + + // 并发调用每个 profile 的 hermes_agent_run + // 注意:当前 hermesAgentRun 用的是当前 active profile,不支持参数传递。 + // 简化策略:用 hermes_profile_use 切换 profile(串行调度), + // 每个 profile run 完后切到下一个。 + // 这是个 trade-off — 真正的并发需要后端改造支持 per-call profile。 + let activeProfile = null + try { + // 记下当前 active profile 用于最后还原 + const curResp = await api.hermesProfilesList().catch(() => null) + const curArr = Array.isArray(curResp) ? curResp : (curResp?.profiles || []) + activeProfile = curResp?.active || curArr.find(p => p.active)?.name || 'default' + } catch {} + + for (let i = 0; i < targets.length; i++) { + const profile = targets[i] + const placeholder = placeholders[i] + try { + // 切到该 profile + if (profile !== activeProfile) { + await api.hermesProfileUse(profile) + activeProfile = profile + } + // 调 agent run(非流式) + const result = await api.hermesAgentRun(text, null, null, null, null) + // result 形如 { final, messages, ... } + const finalText = result?.final?.content + || result?.final + || result?.output + || (Array.isArray(result?.messages) && result.messages.filter(m => m.role === 'assistant').slice(-1)[0]?.content) + || JSON.stringify(result || '').slice(0, 500) + placeholder.loading = false + placeholder.content = String(finalText || '').trim() || t('engine.hermesGroupChatNoOutput') + placeholder.ts = Date.now() + } catch (e) { + placeholder.loading = false + placeholder.error = String(e?.message || e).slice(0, 500) + } + draw() + } + + // 还原 active profile(如果改了)— 静默尝试 + sending = false + draw() + } + + draw() + if (isTauriRuntime()) loadProfiles() + return el +} diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index c861c03..7ab790a 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -5476,6 +5476,183 @@ body[data-active-engine="hermes"][data-theme="dark"] { } } +/* ---- Batch 3 §N: 群聊 ---- */ +[data-engine="hermes"] .hm-gc-layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: 16px; + height: calc(100vh - 200px); + min-height: 480px; +} +[data-engine="hermes"] .hm-gc-side { + background: var(--hm-surface-0); + border: 1px solid var(--hm-border); + border-radius: 10px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; +} +[data-engine="hermes"] .hm-gc-side-title { + font-weight: 600; + font-size: 13px; + color: var(--hm-text-primary); +} +[data-engine="hermes"] .hm-gc-side-hint { + font-size: 11px; + color: var(--hm-text-tertiary); + margin-bottom: 4px; +} +[data-engine="hermes"] .hm-gc-profile-list { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} +[data-engine="hermes"] .hm-gc-profile-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + user-select: none; + transition: background 0.1s; +} +[data-engine="hermes"] .hm-gc-profile-item:hover { + background: var(--hm-surface-1); +} +[data-engine="hermes"] .hm-gc-profile-item.is-checked { + background: var(--hm-surface-2); + color: var(--hm-accent); +} +[data-engine="hermes"] .hm-gc-profile-name { + font-family: var(--font-mono); +} +[data-engine="hermes"] .hm-gc-selected-count { + font-size: 11px; + color: var(--hm-text-tertiary); + padding-top: 8px; + border-top: 1px solid var(--hm-border); +} +[data-engine="hermes"] .hm-gc-main { + background: var(--hm-surface-0); + border: 1px solid var(--hm-border); + border-radius: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} +[data-engine="hermes"] .hm-gc-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 14px; +} +[data-engine="hermes"] .hm-gc-empty { + text-align: center; + color: var(--hm-text-tertiary); + font-size: 13px; + padding: 60px 20px; +} +[data-engine="hermes"] .hm-gc-msg { + display: flex; + flex-direction: column; + gap: 4px; +} +[data-engine="hermes"] .hm-gc-msg--user { + align-items: flex-end; +} +[data-engine="hermes"] .hm-gc-msg--user .hm-gc-msg-bubble { + background: var(--hm-accent); + color: white; + max-width: 70%; +} +[data-engine="hermes"] .hm-gc-msg--assistant .hm-gc-msg-bubble { + background: var(--hm-surface-1); + max-width: 80%; +} +[data-engine="hermes"] .hm-gc-msg--system { + text-align: center; +} +[data-engine="hermes"] .hm-gc-msg-bubble { + padding: 10px 14px; + border-radius: 12px; + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} +[data-engine="hermes"] .hm-gc-msg-meta { + font-size: 11px; + color: var(--hm-text-tertiary); + display: flex; + align-items: center; + gap: 6px; +} +[data-engine="hermes"] .hm-gc-msg-from { + background: var(--hm-surface-2); + color: var(--hm-accent); + padding: 1px 6px; + border-radius: 4px; + font-family: var(--font-mono); + font-weight: 500; +} +[data-engine="hermes"] .hm-gc-loading-dots { + display: inline-flex; + gap: 3px; +} +[data-engine="hermes"] .hm-gc-loading-dots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--hm-text-tertiary); + animation: hm-gc-pulse 1.4s ease-in-out infinite both; +} +[data-engine="hermes"] .hm-gc-loading-dots span:nth-child(2) { animation-delay: 0.16s } +[data-engine="hermes"] .hm-gc-loading-dots span:nth-child(3) { animation-delay: 0.32s } +@keyframes hm-gc-pulse { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.7) } + 40% { opacity: 1; transform: scale(1) } +} +[data-engine="hermes"] .hm-gc-input-wrap { + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--hm-border); +} +[data-engine="hermes"] .hm-gc-input { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--hm-border); + border-radius: 8px; + background: var(--hm-surface-1); + color: var(--hm-text-primary); + font-family: var(--font-sans); + font-size: 13px; + resize: none; + min-height: 40px; + max-height: 120px; + line-height: 1.5; +} +[data-engine="hermes"] .hm-gc-input:focus { + outline: none; + border-color: var(--hm-accent); +} +@media (max-width: 768px) { + [data-engine="hermes"] .hm-gc-layout { + grid-template-columns: 1fr; + height: auto; + } + [data-engine="hermes"] .hm-gc-side { + max-height: 240px; + } +} + [data-engine="hermes"] .hm-chat-live-tools { display: flex; flex-direction: column; diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 35def57..64835ca 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -600,6 +600,20 @@ export default { hermesFilesUnreadable: _('文件无法读取', 'File unreadable', '檔案無法讀取'), hermesFilesUnsavedConfirm: _('当前文件有未保存的修改,确认丢弃?', 'Current file has unsaved changes. Discard?', '目前檔案有未儲存的修改,確認丟棄?'), hermesFilesDiscardChanges: _('丢弃', 'Discard', '丟棄'), + // Batch 3 §N: 群聊(多 Agent 并行响应) + hermesGroupChatTitle: _('群聊', 'Group Chat', '群聊'), + hermesGroupChatDesc: _('选多个 Profile,一句话同时问多个 Agent,对比不同模型/角色的回答', 'Pick multiple profiles, ask one question to many agents at once, compare answers', '選多個 Profile,一句話同時問多個 Agent,比較不同模型/角色的回答'), + hermesGroupChatProfiles: _('参与的 Profile', 'Participating profiles', '參與的 Profile'), + hermesGroupChatProfilesHint: _('勾选要一起回复的 Profile(每个 Profile 是一个 Agent 配置)', 'Tick profiles that should reply together', '勾選要一起回覆的 Profile'), + hermesGroupChatSelected: _('已选 {n} 个', '{n} selected', '已選 {n} 個'), + hermesGroupChatEmpty: _('选好 Profile 后输入消息发送', 'Pick profiles and send a message to start', '選好 Profile 後輸入訊息發送'), + hermesGroupChatPlaceholder: _('输入消息(Enter 发送,Shift+Enter 换行)…', 'Type a message (Enter to send, Shift+Enter for newline)…', '輸入訊息(Enter 發送,Shift+Enter 換行)…'), + hermesGroupChatSend: _('发送', 'Send', '發送'), + hermesGroupChatSending: _('发送中…', 'Sending…', '發送中…'), + hermesGroupChatClear: _('清空', 'Clear', '清空'), + hermesGroupChatRunFailed: _('运行失败', 'Run failed', '執行失敗'), + hermesGroupChatNoOutput: _('(无输出)', '(no output)', '(無輸出)'), + hermesGroupChatWebUnsupported: _('Web 模式不支持群聊(依赖 hermesAgentRun,需桌面端事件桥)。请用桌面客户端。', 'Group chat is not supported in Web mode (requires desktop event bridge). Use the desktop app.', 'Web 模式不支援群聊(依賴 hermesAgentRun,需桌面端事件橋)。請用桌面客戶端。'), // Web 模式(远程浏览器)下流式聊天暂不可用 chatWebModeStreamingUnsupported: _( 'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',