feat(hermes): Batch 3 §P TTS + Dashboard session token 自动注入

核查发现:
- §P TTS: Hermes 内核没有 HTTP TTS 端点(只有 lazy_deps 的 tts.edge/elevenlabs PyPI 包) → 改用浏览器 Web Speech API(100% 离线、跨平台)
- Dashboard token: 9119 大部分 /api/* 需要 token 鉴权,token 注入到 SPA HTML 的 window.__HERMES_SESSION_TOKEN__ → 必须从 HTML 抓

## §P TTS — 浏览器原生(src/lib/tts.js)

新工具模块 src/lib/tts.js(~110 行):
- speak(text, lang?) - 异步播放,返回 Promise
- toggle(text, lang?) - 重复播放则停止
- stopSpeaking() / isSpeaking() / isSupported()
- 自动语言检测:中/英/日/韩
- pickVoice 启发式:精确匹配 > 前缀匹配 > 默认
- Chrome bug 兼容:voiceschanged 异步加载 + 100ms 兜底

### chat.js 接入
- assistant 消息 footer 复制按钮旁加 🔊 朗读按钮(图标 speaker)
- 仅 tts.isSupported() 时渲染(不支持的浏览器隐藏)
- 点击 toggle,简化文本(去 markdown 代码块、url)
- 失败 toast 提示

### i18n
- chatSpeak / chatSpeakShort / chatSpeakFailed × 3 语言

### CSS
- .hm-chat-msg-tts 复用 copy 按钮风格

## Dashboard session token 自动注入器(解锁 §J/§O/§Q)

校对发现:dashboard 9119 大部分 /api/* 端点 _require_token 保护,
token 是进程启动时 secrets.token_urlsafe(32) 生成,没有公开获取 API,
只能 GET / 抓 SPA HTML 提取 window.__HERMES_SESSION_TOKEN__="..."。

### Rust 后端增强 hermes_dashboard_api_proxy
- 模块级 DASHBOARD_SESSION_TOKEN: Mutex<Option<String>> 缓存
- fetch_dashboard_session_token(port): GET / + 字符串搜索 needle 提取 token
- dashboard_session_token(port, force_refresh): 缓存 + 强刷接口
- proxy 实现:
  1. 拿缓存 token,注入 X-Hermes-Session-Token header
  2. 发请求;若 401 → 强刷 token 重试一次
  3. 仍失败抛 HTTP 错
- build_request 闭包复用避免重复代码

### Web 模式 dev-api 同步
- _fetchDashboardToken(port) 抓 HTML 正则提取
- _getDashboardToken(port, forceRefresh) 模块级缓存
- hermes_dashboard_api_proxy 401 重试逻辑

## 影响
- 已用 dashboard_api_proxy 的 §H Profiles + §M Kanban 立即受益(之前 401 会失败)
- §J dashboard plugin 可见性、§Q OAuth 现在可直接复用
- §O Terminal 走 WS,token 注入路径不同,但 _getDashboardToken 可复用

## 累计
- Rust: ~60 行(token 抓取 + 缓存 + 重试)
- 前端: tts.js 新文件 110 行 + chat.js TTS 按钮 + dev-api token 注入
- i18n: 3 个新键 × 3 语言
- cargo check ✓ + npm build ✓
This commit is contained in:
晴天
2026-05-14 05:19:02 +08:00
parent 6a45c12d67
commit 64f4668522
6 changed files with 293 additions and 33 deletions

View File

@@ -7225,20 +7225,42 @@ const handlers = {
return await resp.json().catch(() => ({ ok: true }))
},
// Batch 2 §H 基础设施: 通用 Dashboard 9119 HTTP 代理
// Batch 2 §H 基础设施: 通用 Dashboard 9119 HTTP 代理(含 session token 注入)
// _dashboardToken 模块级缓存401 时刷新重试
async _fetchDashboardToken(port) {
const resp = await globalThis.fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`dashboard 首页 HTTP ${resp.status}`)
const html = await resp.text()
const m = html.match(/window\.__HERMES_SESSION_TOKEN__="([^"]+)"/)
if (!m) throw new Error('无法从 dashboard HTML 提取 session token')
handlers._dashboardToken = m[1]
return m[1]
},
async _getDashboardToken(port, forceRefresh = false) {
if (!forceRefresh && handlers._dashboardToken) return handlers._dashboardToken
return await handlers._fetchDashboardToken(port)
},
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)
const buildOpts = (token) => {
const opts = { method: String(method).toUpperCase(), headers: { 'User-Agent': 'ClawPanel-Web' } }
opts.signal = AbortSignal.timeout(30000)
if (token) opts.headers['X-Hermes-Session-Token'] = token
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)
}
return opts
}
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)
let token = await handlers._getDashboardToken(port, false).catch(() => null)
let resp = await globalThis.fetch(url, buildOpts(token))
if (resp.status === 401) {
// 强制刷新 + 重试
token = await handlers._getDashboardToken(port, true)
resp = await globalThis.fetch(url, buildOpts(token))
}
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 }