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

@@ -3882,6 +3882,67 @@ pub async fn hermes_session_export(session_id: String) -> Result<Value, String>
// - hermes_dashboard_api_proxy 走 Dashboard 9119无需 token本地绑定
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Dashboard session token 缓存
//
// Hermes Dashboard 9119 大部分 /api/* 端点需要 token 鉴权_require_token
// token 来源:进程启动时 secrets.token_urlsafe(32) 生成,注入到 SPA HTML 的
// <script>window.__HERMES_SESSION_TOKEN__="..."</script>
// 没有公开获取 API只能 GET / 抓 HTML 提取。
//
// 缓存策略:
// - 全局静态 Mutex<Option<String>> 保存
// - 401 时 invalidate 重抓一次dashboard 进程重启会重生成 token
// ---------------------------------------------------------------------------
use std::sync::Mutex;
static DASHBOARD_SESSION_TOKEN: Mutex<Option<String>> = Mutex::new(None);
async fn fetch_dashboard_session_token(port: u16) -> Result<String, String> {
let url = format!("http://127.0.0.1:{port}/");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("拉 dashboard 首页失败: {}", reqwest_error_detail(&e)))?;
if !resp.status().is_success() {
return Err(format!("dashboard 首页 HTTP {}", resp.status().as_u16()));
}
let html = resp.text().await.unwrap_or_default();
// 正则匹配 window.__HERMES_SESSION_TOKEN__="..."
// 用简单的字符串搜索避免引入 regex crate已有 regex 依赖但保持简单)
let needle = "window.__HERMES_SESSION_TOKEN__=\"";
if let Some(start) = html.find(needle) {
let after = &html[start + needle.len()..];
if let Some(end) = after.find('"') {
let token = &after[..end];
if !token.is_empty() {
return Ok(token.to_string());
}
}
}
Err("无法从 dashboard HTML 提取 session tokendashboard 可能未启动)".to_string())
}
async fn dashboard_session_token(port: u16, force_refresh: bool) -> Result<String, String> {
if !force_refresh {
if let Ok(guard) = DASHBOARD_SESSION_TOKEN.lock() {
if let Some(t) = guard.as_ref() {
return Ok(t.clone());
}
}
}
let token = fetch_dashboard_session_token(port).await?;
if let Ok(mut guard) = DASHBOARD_SESSION_TOKEN.lock() {
*guard = Some(token.clone());
}
Ok(token)
}
#[tauri::command]
pub async fn hermes_dashboard_api_proxy(
method: String,
@@ -3897,41 +3958,63 @@ pub async fn hermes_dashboard_api_proxy(
.build()
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
let mut req = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"PATCH" => client.patch(&url),
"DELETE" => client.delete(&url),
_ => return Err(format!("不支持的方法: {method}")),
};
// 自定义 headers
if let Some(Value::Object(map)) = headers {
for (k, v) in map.iter() {
if let Some(s) = v.as_str() {
req = req.header(k, s);
let build_request = |token_opt: Option<&str>| -> Result<reqwest::RequestBuilder, String> {
let mut req = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"PATCH" => client.patch(&url),
"DELETE" => client.delete(&url),
_ => return Err(format!("不支持的方法: {method}")),
};
// 自动注入 session token
if let Some(tok) = token_opt {
req = req.header("X-Hermes-Session-Token", tok);
}
// 自定义 headers
if let Some(Value::Object(map)) = headers.as_ref() {
for (k, v) in map.iter() {
if let Some(s) = v.as_str() {
req = req.header(k, s);
}
}
}
}
// body
if let Some(b) = body.as_ref() {
req = req
.header("Content-Type", "application/json")
.body(b.clone());
}
Ok(req)
};
// bodyPOST/PUT/PATCH/DELETE— 假定是 JSON 字符串
if let Some(b) = body {
req = req
.header("Content-Type", "application/json")
.body(b);
}
let resp = req
// 拿缓存的 token首次为空让 send 触发 401 再抓)
let mut token = dashboard_session_token(port, false).await.ok();
let resp = build_request(token.as_deref())?
.send()
.await
.map_err(|e| format!("Dashboard 请求失败: {}(提示:请先启动 Dashboard", reqwest_error_detail(&e)))?;
let status = resp.status();
if status.as_u16() == 401 {
// token 失效或没拿到 — 强制刷新 + 重试一次
token = Some(dashboard_session_token(port, true).await?);
let retry = build_request(token.as_deref())?
.send()
.await
.map_err(|e| format!("Dashboard 重试失败: {}", reqwest_error_detail(&e)))?;
let retry_status = retry.status();
let body = retry.text().await.unwrap_or_default();
if !retry_status.is_success() {
return Err(format!("HTTP {}: {}", retry_status.as_u16(), body));
}
return Ok(serde_json::from_str::<Value>(&body).unwrap_or_else(|_| Value::String(body)));
}
let resp_body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("HTTP {}: {}", status.as_u16(), resp_body));
}
// 尝试解析 JSON失败回退到字符串包装
Ok(serde_json::from_str::<Value>(&resp_body)
.unwrap_or_else(|_| Value::String(resp_body)))
}