feat(hermes): Batch 3 §N - 群聊(多 Agent 并行响应)

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 ✓
This commit is contained in:
晴天
2026-05-14 05:39:36 +08:00
parent 129d8c0ac1
commit debce2f810
4 changed files with 459 additions and 0 deletions

View File

@@ -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') },

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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 = `
<div class="page-header">
<div>
<h1 class="page-title">${escHtml(t('engine.hermesGroupChatTitle'))}</h1>
</div>
</div>
<div style="padding:32px;text-align:center;color:var(--text-tertiary)">
${escHtml(t('engine.hermesGroupChatWebUnsupported'))}
</div>`
return
}
el.innerHTML = `
<div class="page-header">
<div>
<h1 class="page-title">${escHtml(t('engine.hermesGroupChatTitle'))}</h1>
<p class="page-desc">${escHtml(t('engine.hermesGroupChatDesc'))}</p>
</div>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" id="hm-gc-clear" ${!messages.length || sending ? 'disabled' : ''}>${escHtml(t('engine.hermesGroupChatClear'))}</button>
</div>
</div>
${loadError ? `<div style="color:var(--error);padding:16px">${escHtml(loadError)}</div>` : ''}
<div class="hm-gc-layout">
<div class="hm-gc-side">
<div class="hm-gc-side-title">${escHtml(t('engine.hermesGroupChatProfiles'))}</div>
<div class="hm-gc-side-hint">${escHtml(t('engine.hermesGroupChatProfilesHint'))}</div>
<div class="hm-gc-profile-list">
${profiles.length ? profiles.map(renderProfileCheckbox).join('') : `<div style="color:var(--text-tertiary);font-size:12px;padding:8px 0">${escHtml(t('common.loading'))}…</div>`}
</div>
<div class="hm-gc-selected-count">${escHtml(t('engine.hermesGroupChatSelected', { n: selected.size }))}</div>
</div>
<div class="hm-gc-main">
<div class="hm-gc-messages" id="hm-gc-messages">
${messages.length === 0 ? `<div class="hm-gc-empty">${escHtml(t('engine.hermesGroupChatEmpty'))}</div>` : messages.map(renderMessage).join('')}
</div>
<div class="hm-gc-input-wrap">
<textarea class="hm-gc-input" id="hm-gc-input"
placeholder="${escAttr(t('engine.hermesGroupChatPlaceholder'))}"
${sending ? 'disabled' : ''}>${escHtml(inputValue)}</textarea>
<button class="btn btn-primary btn-sm" id="hm-gc-send"
${sending || !inputValue.trim() || !selected.size ? 'disabled' : ''}>
${escHtml(sending ? t('engine.hermesGroupChatSending') : t('engine.hermesGroupChatSend'))}
</button>
</div>
</div>
</div>
`
bind()
scrollToBottom()
}
function renderProfileCheckbox(p) {
const isChecked = selected.has(p)
return `
<label class="hm-gc-profile-item ${isChecked ? 'is-checked' : ''}">
<input type="checkbox" data-profile="${escAttr(p)}" ${isChecked ? 'checked' : ''} ${sending ? 'disabled' : ''}>
<span class="hm-gc-profile-name">${escHtml(p)}</span>
</label>
`
}
function renderMessage(m) {
if (m.role === 'user') {
return `
<div class="hm-gc-msg hm-gc-msg--user">
<div class="hm-gc-msg-bubble">${escHtml(m.content)}</div>
<div class="hm-gc-msg-meta">${escHtml(formatTime(m.ts))}</div>
</div>
`
}
if (m.role === 'system') {
return `
<div class="hm-gc-msg hm-gc-msg--system">
<div class="hm-gc-msg-meta">${escHtml(m.content)}</div>
</div>
`
}
// assistant
const fromTag = m.from ? `<span class="hm-gc-msg-from">@${escHtml(m.from)}</span>` : ''
if (m.loading) {
return `
<div class="hm-gc-msg hm-gc-msg--assistant">
<div class="hm-gc-msg-meta">${fromTag} <span class="hm-gc-loading-dots"><span></span><span></span><span></span></span></div>
</div>
`
}
if (m.error) {
return `
<div class="hm-gc-msg hm-gc-msg--assistant hm-gc-msg--error">
<div class="hm-gc-msg-meta">${fromTag} <span style="color:var(--error)">⚠️ ${escHtml(t('engine.hermesGroupChatRunFailed'))}</span></div>
<div class="hm-gc-msg-bubble" style="color:var(--error)">${escHtml(m.error)}</div>
</div>
`
}
return `
<div class="hm-gc-msg hm-gc-msg--assistant">
<div class="hm-gc-msg-meta">${fromTag} <span>${escHtml(formatTime(m.ts))}</span></div>
<div class="hm-gc-msg-bubble">${escHtml(m.content)}</div>
</div>
`
}
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
}

View File

@@ -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;

View File

@@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',