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

113
src/lib/tts.js Normal file
View File

@@ -0,0 +1,113 @@
/**
* Batch 3 §P: TTS — 浏览器 Web Speech API100% 离线,无需后端)
*
* 校对发现Hermes 内核没有 HTTP TTS 端点(只有 lazy_deps 注册的 tts.edge / tts.elevenlabs 包),
* 浏览器原生 speechSynthesis 已经够用,跨平台 + 无延迟 + 无成本。
*
* 用法:
* speak('Hello world') // 自动检测语言
* speak('你好', 'zh-CN') // 指定语言
* stopSpeaking() // 停止当前播放
* isSpeaking() // 查询状态
* isSupported() // 浏览器是否支持
*/
let currentUtterance = null
/**
* 自动检测语言(最简启发式)— 中文/英文/日韩
*/
function detectLang(text) {
const s = String(text || '').slice(0, 200)
if (/[\u4e00-\u9fff]/.test(s)) return 'zh-CN' // 简中
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(s)) return 'ja-JP'
if (/[\uac00-\ud7af]/.test(s)) return 'ko-KR'
return 'en-US'
}
/**
* 选择最合适的 voice
*/
function pickVoice(lang) {
if (typeof speechSynthesis === 'undefined') return null
const voices = speechSynthesis.getVoices()
if (!voices?.length) return null
// 精确匹配 > 前缀匹配 > 默认
return voices.find(v => v.lang === lang)
|| voices.find(v => v.lang.startsWith(lang.split('-')[0]))
|| voices.find(v => v.default)
|| voices[0]
}
export function isSupported() {
return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
}
export function isSpeaking() {
return isSupported() && (speechSynthesis.speaking || speechSynthesis.pending)
}
export function stopSpeaking() {
if (!isSupported()) return
try {
speechSynthesis.cancel()
} catch {}
currentUtterance = null
}
/**
* 播放文本。返回 Promise 在播放结束/失败/取消时 resolve。
* 重复调用会先 cancel 之前的。
*/
export function speak(text, lang = null) {
if (!isSupported()) return Promise.reject(new Error('TTS_NOT_SUPPORTED'))
const cleaned = String(text || '').trim()
if (!cleaned) return Promise.resolve()
// 取消之前的
stopSpeaking()
return new Promise((resolve, reject) => {
const u = new SpeechSynthesisUtterance(cleaned)
u.lang = lang || detectLang(cleaned)
const voice = pickVoice(u.lang)
if (voice) u.voice = voice
u.rate = 1.0
u.pitch = 1.0
u.volume = 1.0
u.onend = () => { currentUtterance = null; resolve() }
u.onerror = (e) => { currentUtterance = null; reject(e?.error || new Error('TTS_ERROR')) }
currentUtterance = u
// Chrome bugvoices 异步加载,第一次可能空。先触发加载。
if (!speechSynthesis.getVoices().length) {
const onChange = () => {
speechSynthesis.removeEventListener('voiceschanged', onChange)
const v = pickVoice(u.lang)
if (v) u.voice = v
speechSynthesis.speak(u)
}
speechSynthesis.addEventListener('voiceschanged', onChange)
// 兜底100ms 后无论如何 speak有些浏览器不触发 voiceschanged
setTimeout(() => {
if (currentUtterance === u && !isSpeaking()) {
speechSynthesis.removeEventListener('voiceschanged', onChange)
speechSynthesis.speak(u)
}
}, 100)
} else {
speechSynthesis.speak(u)
}
})
}
/**
* 切换播放:相同文本再按 → 停止;否则 → 播放
*/
export function toggle(text, lang = null) {
if (isSpeaking() && currentUtterance && currentUtterance.text === text) {
stopSpeaking()
return Promise.resolve()
}
return speak(text, lang)
}