Files
clawpanel/src/lib/tts.js
晴天 64f4668522 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 ✓
2026-05-14 05:19:02 +08:00

114 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
}