mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-02 06:09:52 +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:
@@ -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 token(dashboard 可能未启动)".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)
|
||||
};
|
||||
|
||||
// body(POST/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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user