mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 12:50:14 +08:00
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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user