diff --git a/client/src-tauri/src/commands/paste.rs b/client/src-tauri/src/commands/paste.rs
index c8dfad3..b02a6fb 100644
--- a/client/src-tauri/src/commands/paste.rs
+++ b/client/src-tauri/src/commands/paste.rs
@@ -1,7 +1,7 @@
use serde::Serialize;
use serde_json::Value;
use tauri::{AppHandle, State};
-use crate::context::ContextDetector;
+use crate::context::{AppContext, ContextDetector};
use crate::inject;
use crate::window::WindowState;
@@ -76,8 +76,34 @@ pub fn get_probe_result(detector: State) -> Result) -> Result {
+ let started_at = chrono::Utc::now().timestamp_millis();
+ let ctx = detector.capture("recording_start");
+ let completed_at = chrono::Utc::now().timestamp_millis();
+ let app_context = serde_json::to_value(&ctx).map_err(|e| e.to_string())?;
+ let probe = probe_result_for_context(&ctx, started_at, completed_at);
+
+ Ok(serde_json::json!({
+ "appContext": app_context,
+ "probe": probe,
+ }))
+}
+
+fn probe_result_for_context(
+ ctx: &AppContext,
+ started_at: i64,
+ completed_at: i64,
+) -> Value {
+
// Determine editability using the same heuristic as inject
- let editable = crate::inject::is_likely_editable_pub(&ctx);
+ let editable = crate::inject::is_likely_editable_pub(ctx);
// Check if the target is our own process
let is_current_app_process = {
@@ -96,19 +122,19 @@ pub fn get_probe_result(detector: State) -> Result) -> Result
}
}
+ // 新包已经校验通过,把同目录里其它版本的安装包删掉。
+ // 旧流程下载完立刻安装,包只存在几秒;现在它按设计等到用户点更新或退出时才用,
+ // 于是每升一次版就在 %TEMP% 里多躺一个十几 MB 的安装包,没人清。
+ prune_stale_packages(&temp_dir, &filename);
+
emit_update_progress(&app, downloaded, total.max(downloaded), "completed", None);
Ok(file_path.to_string_lossy().to_string())
}
+/// 删掉更新目录里除 keep 之外的所有文件。
+///
+/// 只在新包**校验通过之后**调用:下载失败时磁盘上那个旧包可能还是待安装的有效包,
+/// 提前清理等于把用户已经下好的更新弄丢,还得重下一遍。
+/// 删不掉(被占用等)只记日志不报错 —— 清理失败不该让一次成功的下载变成失败。
+fn prune_stale_packages(dir: &std::path::Path, keep: &str) {
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ return;
+ };
+ let mut removed = 0usize;
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+ let matches_keep = path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .is_some_and(|n| n == keep);
+ if matches_keep {
+ continue;
+ }
+ match std::fs::remove_file(&path) {
+ Ok(()) => removed += 1,
+ Err(e) => write_log_line(&format!(
+ "[update] could not remove the stale package {}: {}",
+ path.display(),
+ e
+ )),
+ }
+ }
+ if removed > 0 {
+ write_log_line(&format!("[update] removed {} stale update package(s)", removed));
+ }
+}
+
/// 拉起 NSIS 安装程序(/S 静默)。
///
/// 静默模式下 NSIS 不提供"安装完成后运行",所以自己接管:起一个不依赖当前进程存活的
diff --git a/client/src-tauri/src/keyboard/mod.rs b/client/src-tauri/src/keyboard/mod.rs
index 8967bcc..8d803eb 100644
--- a/client/src-tauri/src/keyboard/mod.rs
+++ b/client/src-tauri/src/keyboard/mod.rs
@@ -235,15 +235,31 @@ impl PttKeyConfig {
fn fallback() -> Self {
Self {
- setting: "ShiftRight".to_string(),
- vk_codes: vec![0xA1],
+ setting: DEFAULT_PTT_SETTING.to_string(),
+ vk_codes: vec![DEFAULT_PTT_VK],
modifier_mask: 1,
}
}
}
+/// 「按住说话」的默认键,同时也是配置非法时的回退。
+///
+/// **不能是 Shift**:长按右 Shift 约 8 秒会触发 Windows 筛选键,那之后我们收不到
+/// 这次「松开」,录音不会随手指抬起而结束。这里曾经是 ShiftRight,于是每台新装的
+/// 机器开箱就绑在那个键上(前端也一样,见 services/defaults.ts)。
+/// 右 Ctrl 位置相近、没有辅助功能陷阱,也不和免提的默认键(右 Alt)撞。
+///
+/// ⚠️ 改这里要同步:src/services/defaults.ts、storage/mod.rs 的种子默认值、
+/// main.rs 读设置时的兜底。
+const DEFAULT_PTT_SETTING: &str = "ControlRight";
+const DEFAULT_PTT_VK: u32 = 0xA3;
+
/// 解析向后兼容的 PTT 物理键格式。旧单键仍是单成员;组合成员用 `+` 分隔。
-/// 非法或未知非空值安全回退到 ShiftRight,避免整串被误当成 AltRight。
+///
+/// 非法或未知非空值安全回退到默认键,避免整串被误当成别的键。
+/// 注意这里**不禁止 Shift**:老用户存的可能就是 ShiftRight,那些绑定必须照原样生效。
+/// 「不许再新设 Shift」是前端校验的事(lib/shortcutKeys.ts),不是解析器的事 ——
+/// 在这里连解析都拒掉,等于用户没改设置说话键就自己变了。
fn ptt_key_config(setting: &str) -> PttKeyConfig {
if setting.is_empty() {
return PttKeyConfig::disabled();
@@ -378,8 +394,8 @@ struct HookSharedState {
/// hook 生命周期标记,供独立成员状态看门狗在重配/停止时退出。
ptt_hook_alive: AtomicBool,
hands_free_active: AtomicBool,
- /// 免提键是否处于"已按下"状态。只有先收到过真实(未被 synthetic 过滤)的 keydown,
- /// 随后的 keyup 才允许触发 toggle。用于挡掉远程桌面等场景下的"孤儿 keyup"幻影按键。
+ /// 免提键是否处于"已按下"状态。首个真实 keydown 触发 toggle,repeat down 被忽略;
+ /// 配对 keyup(包括被标成 synthetic 的 up)负责清零,让下一次按下可以再次触发。
hf_key_down: AtomicBool,
/// VK codes for hands-free toggle key (empty = not using hook for hands-free)
hf_vk_codes: Vec,
@@ -427,6 +443,16 @@ fn begin_ptt_press(active_generation: &AtomicU64, generation: &AtomicU64) -> Opt
.map(|_| gen)
}
+/// 免提键只在一次物理按下的首个 keydown 触发;Windows 随后的 repeat down 被忽略。
+fn begin_hf_press(key_down: &AtomicBool) -> bool {
+ !key_down.swap(true, Ordering::SeqCst)
+}
+
+/// 清除免提按下状态。返回 true 表示确实存在一枚需要配对消费的 keyup。
+fn end_hf_press(key_down: &AtomicBool) -> bool {
+ key_down.swap(false, Ordering::SeqCst)
+}
+
/// 幂等认领一次释放,但暂不允许下一次 down。认领者必须在 up 成功入队后调用
/// `complete_ptt_release`;dispatcher 断开时也 complete,优先避免 native 状态永久卡住。
fn claim_ptt_release(
@@ -1517,15 +1543,10 @@ unsafe extern "system" fn low_level_mouse_proc(
}
if is_hf_key {
- // 免提:按下即吞,抬起触发 toggle(需先配对过真实按下,防孤儿抬起误触)。
+ // 免提在首个按下时立即触发;hf_key_down 过滤鼠标驱动可能产生的重复 down。
if is_down {
consumed = true;
- state.hf_key_down.store(true, Ordering::SeqCst);
- }
- if is_up {
- let had_down = state.hf_key_down.swap(false, Ordering::SeqCst);
- if had_down {
- consumed = true;
+ if begin_hf_press(&state.hf_key_down) {
HOOK_ACTION_TX.with(|tx| {
if let Some(sender) = tx.borrow().as_ref() {
if sender.send(HookAction::HfToggle { vk }).is_err() {
@@ -1535,6 +1556,9 @@ unsafe extern "system" fn low_level_mouse_proc(
});
}
}
+ if is_up && end_hf_press(&state.hf_key_down) {
+ consumed = true;
+ }
}
}
});
@@ -1639,6 +1663,12 @@ unsafe extern "system" fn low_level_keyboard_proc(
if is_kup {
HOOK_STATE.with(|s| {
if let Some(state) = s.borrow().as_ref() {
+ // 真实 HF down 的配对 up 偶尔会被远程桌面/驱动标成 synthetic。
+ // 即使不把它当作一次触发,也必须清掉按下状态,否则下一次真实按下
+ // 会被误认成 repeat 而没有反应。
+ if state.hf_vk_codes.contains(&vk) {
+ consume_paired_up = end_hf_press(&state.hf_key_down);
+ }
if let Some(member_index) = ptt_member_index(&state.ptt_vk_codes, vk) {
let member_bit = 1_u64 << member_index;
consume_paired_up = state
@@ -1839,27 +1869,14 @@ unsafe extern "system" fn low_level_keyboard_proc(
}
}
- // 免提键:keyup 时触发 toggle(避免和 keydown repeat 冲突)。
- // ⚠️ 必须校验配对的 keydown:远程桌面等场景下,鼠标点击跨会话边界会让
- // Windows 合成幻影 Alt——其 keydown 常带 INJECTED 标志被 synthetic 过滤器挡掉,
- // 但残留的 keyup 会漏进来。若在 keyup 上无条件 toggle,就会被这种"孤儿 keyup"
- // 误触发(且一次点击可能爆发多次)。因此只有先记录过真实 keydown(hf_key_down=true)
- // 的 keyup 才 toggle。
+ // 免提键:首次 keydown 立即触发,后续 repeat down 忽略;keyup 只负责重新布防。
+ // 合成 keydown 仍在上方被过滤,所以远程桌面的幻影 Alt 不会触发录音。
if is_hf_key {
let is_up = msg == WM_KEYUP || msg == WM_SYSKEYUP;
let is_down = msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN;
if is_down {
consumed = true; // 吞掉 keydown 防止系统处理
- state.hf_key_down.store(true, Ordering::SeqCst);
- }
- if is_up {
- // Alt 类免提键的 keyup 也吞掉,避免激活前台菜单栏(与 PTT 同理)。
- if is_alt_key {
- consumed = true;
- }
- // 只有配对过真实 keydown 才 toggle;孤儿 keyup(幻影)直接忽略。
- let had_down = state.hf_key_down.swap(false, Ordering::SeqCst);
- if had_down {
+ if begin_hf_press(&state.hf_key_down) {
HOOK_ACTION_TX.with(|tx| {
if let Some(sender) = tx.borrow().as_ref() {
if sender.send(HookAction::HfToggle { vk }).is_err() {
@@ -1869,6 +1886,12 @@ unsafe extern "system" fn low_level_keyboard_proc(
});
}
}
+ if is_up {
+ // 吞掉与首个 down 配对的 up,避免向目标程序留下孤立的按键事件。
+ if end_hf_press(&state.hf_key_down) || is_alt_key {
+ consumed = true;
+ }
+ }
}
}
});
@@ -1884,13 +1907,24 @@ unsafe extern "system" fn low_level_keyboard_proc(
#[cfg(test)]
mod tests {
use super::{
- begin_ptt_press, claim_ptt_release, complete_ptt_release, is_mouse_button_setting,
- is_mouse_vk, press_ptt_member, ptt_key_config, release_ptt_member,
- should_consume_combo_main_down, SINGLE_KEY_TABLE,
+ begin_hf_press, begin_ptt_press, claim_ptt_release, complete_ptt_release, end_hf_press,
+ is_mouse_button_setting, is_mouse_vk, press_ptt_member, ptt_key_config, release_ptt_member,
+ should_consume_combo_main_down, DEFAULT_PTT_SETTING, DEFAULT_PTT_VK, SINGLE_KEY_TABLE,
};
#[cfg(windows)]
use super::queue_ptt_release;
- use std::sync::atomic::{AtomicU64, Ordering};
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+
+ #[test]
+ fn hands_free_triggers_on_first_down_and_rearms_on_up() {
+ let key_down = AtomicBool::new(false);
+
+ assert!(begin_hf_press(&key_down));
+ assert!(!begin_hf_press(&key_down), "repeat down must not toggle again");
+ assert!(end_hf_press(&key_down));
+ assert!(!end_hf_press(&key_down), "orphan up is ignored");
+ assert!(begin_hf_press(&key_down), "next physical press is re-armed");
+ }
#[test]
fn ptt_release_is_idempotent_for_normal_or_synthetic_keyup() {
@@ -1981,15 +2015,35 @@ mod tests {
}
#[test]
- fn invalid_ptt_setting_safely_falls_back_to_shift_right() {
+ fn invalid_ptt_setting_safely_falls_back_to_the_default_key() {
let config = ptt_key_config("ControlLeft+UnknownKey");
- assert_eq!(config.setting, "ShiftRight");
- assert_eq!(config.vk_codes, vec![0xA1]);
+ assert_eq!(config.setting, DEFAULT_PTT_SETTING);
+ assert_eq!(config.vk_codes, vec![DEFAULT_PTT_VK]);
assert_eq!(config.modifier_mask, 0b1);
let duplicate_family = ptt_key_config("ControlLeft+ControlRight+KeyK");
- assert_eq!(duplicate_family.setting, "ShiftRight");
- assert_eq!(duplicate_family.vk_codes, vec![0xA1]);
+ assert_eq!(duplicate_family.setting, DEFAULT_PTT_SETTING);
+ assert_eq!(duplicate_family.vk_codes, vec![DEFAULT_PTT_VK]);
+ }
+
+ /// 默认键绝不能是 Shift:那正是会触发 Windows 筛选键、让录音停不下来的键。
+ /// 有人把默认值改回去时,这条会拦住。
+ #[test]
+ fn default_ptt_key_is_never_shift() {
+ assert!(!DEFAULT_PTT_SETTING.contains("Shift"));
+ assert_ne!(DEFAULT_PTT_VK, 0xA0);
+ assert_ne!(DEFAULT_PTT_VK, 0xA1);
+ // 默认键必须自身可解析,否则 fallback 会陷入"回退到一个非法值"
+ assert_eq!(ptt_key_config(DEFAULT_PTT_SETTING).setting, DEFAULT_PTT_SETTING);
+ }
+
+ /// 老用户存的 ShiftRight 必须继续被解析、继续生效。
+ /// 前端不再让人新设 Shift,但已经存在的绑定不能因为升级就失灵。
+ #[test]
+ fn legacy_shift_binding_still_parses() {
+ let legacy = ptt_key_config("ShiftRight");
+ assert_eq!(legacy.setting, "ShiftRight");
+ assert_eq!(legacy.vk_codes, vec![0xA1]);
}
#[test]
diff --git a/client/src-tauri/src/main.rs b/client/src-tauri/src/main.rs
index 4aafc9f..665abd2 100644
--- a/client/src-tauri/src/main.rs
+++ b/client/src-tauri/src/main.rs
@@ -249,7 +249,8 @@ fn main() {
// Read PTT setting before moving storage into managed state
let ptt_setting_val = storage.get("shortcutPTT", None);
- let ptt_str = ptt_setting_val.as_str().unwrap_or("ShiftRight").to_string();
+ // 兜底键与 storage 种子、前端 defaults.ts 保持一致;绝不能是 Shift(会触发筛选键)
+ let ptt_str = ptt_setting_val.as_str().unwrap_or("ControlRight").to_string();
let hf_setting_val = storage.get("shortcutHandsFree", None);
let hf_str = hf_setting_val.as_str().unwrap_or("AltRight").to_string();
log::info!("PTT setting from DB: raw={:?} parsed={:?}", ptt_setting_val, ptt_str);
@@ -501,6 +502,20 @@ fn main() {
commands::shortcuts::register_all_global_shortcuts(app.handle(), storage.inner());
}
+ // 把首次 overlay WebView 的创建成本挪到启动后的空闲时段。延迟执行避免与主窗
+ // 首屏、托盘和键盘钩子争抢启动资源;用户若在此之前已开始口述,预热会看到
+ // overlay 已存在或正在显示并安全跳过。
+ {
+ let overlay_app = app.handle().clone();
+ let _ = thread::Builder::new()
+ .name("overlay-prewarm".to_string())
+ .spawn(move || {
+ thread::sleep(std::time::Duration::from_millis(1_500));
+ let window_state = overlay_app.state::();
+ window_state.prewarm_overlay(&overlay_app);
+ });
+ }
+
// 首次安装时自动启用开机自启(仅执行一次)
{
use tauri_plugin_autostart::ManagerExt;
@@ -566,6 +581,7 @@ fn main() {
// Paste / Context
commands::paste::paste_text,
commands::paste::get_probe_result,
+ commands::paste::get_recording_context,
commands::paste::get_active_app_context,
commands::paste::copy_text,
// System
diff --git a/client/src-tauri/src/providers/ai_ollama.rs b/client/src-tauri/src/providers/ai_ollama.rs
index 7fdd0c9..c47bdc6 100644
--- a/client/src-tauri/src/providers/ai_ollama.rs
+++ b/client/src-tauri/src/providers/ai_ollama.rs
@@ -39,7 +39,7 @@ pub async fn polish(
"prompt": combined
});
- let client = reqwest::Client::new();
+ let client = super::http_client::shared();
let start = Instant::now();
diag::log(
@@ -137,7 +137,7 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult {
"prompt": prompt
});
- let client = reqwest::Client::new();
+ let client = super::http_client::shared();
let start = Instant::now();
let result = client
diff --git a/client/src-tauri/src/providers/ai_openai_compat.rs b/client/src-tauri/src/providers/ai_openai_compat.rs
index af32766..b96ab52 100644
--- a/client/src-tauri/src/providers/ai_openai_compat.rs
+++ b/client/src-tauri/src/providers/ai_openai_compat.rs
@@ -8,8 +8,10 @@ use std::time::Instant;
const SCOPE: &str = "ai/openai-compat";
-static HTTP_CLIENT: once_cell::sync::Lazy =
- once_cell::sync::Lazy::new(reqwest::Client::new);
+/// 共享客户端(带 User-Agent,缺了会被 nginx/WAF 网关拦成 403,见 `http_client`)
+fn http() -> &'static reqwest::Client {
+ super::http_client::shared()
+}
/// 按供应商关闭校对场景不需要的思考模式。
fn configure_thinking(body: &mut serde_json::Value, config: &AiProviderConfig) {
@@ -93,7 +95,7 @@ pub async fn polish(
),
);
- let mut req = HTTP_CLIENT
+ let mut req = http()
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_key))
.header("Content-Type", "application/json");
@@ -198,10 +200,17 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult {
let system_prompt = "Reply with OK only. Do not output anything else.";
let user_prompt = "Connection test";
+ // max_tokens 不能贴着 "OK" 两个字省:
+ // 1) 有网关直接规定下限 —— 实测某内网 LiteLLM 网关上的 gpt-5 系模型给 10 会返回
+ // HTTP 500 `integer_below_min_value ... Expected a value >= 16`,看起来像服务坏了,
+ // 其实是我们把上限压得太低;
+ // 2) 推理型模型会先花掉一部分 output token 想事情,额度太小时 content 是空的,
+ // 测试就会显示「连接成功,回复:(空)」,等于白测。
+ // 64 足够覆盖这两种情况,代价可以忽略。
let mut body = serde_json::json!({
"model": config.model,
"temperature": 0,
- "max_tokens": 10,
+ "max_tokens": 64,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_prompt }
@@ -211,7 +220,7 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult {
let start = Instant::now();
- let mut req = HTTP_CLIENT
+ let mut req = http()
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_key))
.header("Content-Type", "application/json");
diff --git a/client/src-tauri/src/providers/asr_doubao.rs b/client/src-tauri/src/providers/asr_doubao.rs
index 98ede62..921ea1d 100644
--- a/client/src-tauri/src/providers/asr_doubao.rs
+++ b/client/src-tauri/src/providers/asr_doubao.rs
@@ -112,7 +112,7 @@ pub async fn transcribe(
"request": request_params
});
- let client = reqwest::Client::new();
+ let client = super::http_client::shared();
let start = Instant::now();
let mut req = client
@@ -273,7 +273,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult {
"request": { "model_name": "bigmodel" }
});
- let client = reqwest::Client::new();
+ let client = super::http_client::shared();
let start = Instant::now();
let mut req = client
diff --git a/client/src-tauri/src/providers/asr_groq.rs b/client/src-tauri/src/providers/asr_groq.rs
new file mode 100644
index 0000000..086a82b
--- /dev/null
+++ b/client/src-tauri/src/providers/asr_groq.rs
@@ -0,0 +1,363 @@
+// Groq Whisper ASR — whisper-large-v3-turbo
+//
+// 走 OpenAI 的 `POST /audio/transcriptions`:**multipart 上传音频文件**,
+// 这是本项目第一个用这种形态的 ASR 供应商。其余几家(豆包 / 千问 / MiMo)要么是
+// WebSocket 协议,要么是 chat/completions + base64 data URL,都不是这个端点。
+//
+// 与 asr_mimo.rs 的差异:
+// · 鉴权是 Bearer(MiMo 是 api-key 头);
+// · 音频以 multipart 的 file 字段上传,不是塞进 JSON 的 data URL —— 十几秒的音频
+// 用 base64 塞 JSON 会膨胀 1/3,multipart 是原始字节;
+// · 响应是 `{"text": "..."}`,不是 OpenAI chat 的 choices[0].message.content。
+//
+// 端点写死在这里而不做成可配置项:ASR 侧的约定是「供应商只能从内置清单里选」,
+// 因为每家的协议都要一份专门实现,能填任意地址只会让用户以为随便填个地址就能用。
+
+use super::diag;
+use super::types::{AsrProviderConfig, AsrResult, TestResult};
+use std::time::Instant;
+
+const API_URL: &str = "https://api.groq.com/openai/v1/audio/transcriptions";
+const MODEL: &str = "whisper-large-v3-turbo";
+const SCOPE: &str = "groq/asr";
+
+/// 标点引导。**这不是可选的润色,缺了它短句中文转写会一个标点都没有。**
+///
+/// Whisper 的 `prompt` 不是指令,而是「上文样例」:解码时模型会模仿它的书写风格。
+/// 所以想要标点,prompt 自己就必须是**带标点的句子** —— 写成「请加标点」毫无作用。
+///
+/// 实测(dev-scripts/groq-asr-punct-probe.mjs,打的是真实接口):
+/// · 中文短句、不带 prompt → "语音输入法测试成功" 一个标点都没有
+/// · 中文短句、带这个 prompt → "语音输入法测试成功。"
+/// · 英文 → 两种情况都带标点,加了不变差
+///
+/// 为什么写成中英混排:默认语言是 auto。实测纯中文 prompt 并不会把英文音频带偏
+/// (输出仍是纯英文),而混排能让两种语言都拿到标点,且都不会被改写成另一种语言。
+///
+/// **它管不了全角还是半角。** 另一轮实测(groq-asr-comma-probe.mjs,用一段真的需要
+/// 逗号的中文语音)显示:四种 prompt 连同完全不给 prompt,输出一字不差 ——
+/// 逗号和问号是半角、句号是全角,混着来。那是模型自身行为,改 prompt 没有任何影响。
+/// 半角转全角在前端 textPostProcess.ts 的 normalizeChinesePunctuation 里做。
+///
+/// 另外验证过静音不会把这段文字当成转写结果吐回来(那会被直接插进用户的文档)。
+const PUNCTUATION_PROMPT: &str = "以下是中文转写,使用全角标点,例如逗号、句号和问号。The following is an English transcript with half-width punctuation, such as commas, periods, and question marks.";
+
+/// 将 16kHz 单声道 16-bit PCM 封装为 WAV 容器。
+///
+/// 前端一路传下来的都是裸 PCM(见 types.rs 的 CloudTranscribeRequest),而
+/// /audio/transcriptions 按文件名与内容嗅探格式,裸 PCM 它认不出来。
+/// 这 18 行与 asr_mimo.rs 里那份是同一个 WAV 头,刻意各留一份:抽成公共函数后
+/// 任何一家改采样格式都会牵动另一家,而它们本来毫无关系。
+fn pcm_to_wav(pcm: &[u8], sr: u32) -> Vec {
+ let ds = pcm.len() as u32;
+ let mut w = Vec::with_capacity(44 + pcm.len());
+ w.extend_from_slice(b"RIFF");
+ w.extend_from_slice(&(36 + ds).to_le_bytes());
+ w.extend_from_slice(b"WAVEfmt ");
+ w.extend_from_slice(&16u32.to_le_bytes());
+ w.extend_from_slice(&1u16.to_le_bytes());
+ w.extend_from_slice(&1u16.to_le_bytes());
+ w.extend_from_slice(&sr.to_le_bytes());
+ w.extend_from_slice(&(sr * 2).to_le_bytes());
+ w.extend_from_slice(&2u16.to_le_bytes());
+ w.extend_from_slice(&16u16.to_le_bytes());
+ w.extend_from_slice(b"data");
+ w.extend_from_slice(&ds.to_le_bytes());
+ w.extend_from_slice(pcm);
+ w
+}
+
+/// 识别语言:设置里存的是 auto|zh|en,Whisper 只接受 ISO-639-1。
+///
+/// auto 必须**整个省略 language 字段**,不能传字符串 "auto" —— Whisper 会把它当成
+/// 一个不存在的语言代码,行为不确定(可能报 400,也可能默默按英语转写)。
+/// 返回 None 表示让服务端自己检测。
+fn resolve_language(config: &AsrProviderConfig) -> Option {
+ let raw = config
+ .extra
+ .get("language")
+ .and_then(|v| v.as_str())
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .unwrap_or("auto");
+ match raw {
+ "auto" => None,
+ other => Some(other.to_string()),
+ }
+}
+
+fn build_form(wav: Vec, language: Option<&str>) -> Result {
+ let part = reqwest::multipart::Part::bytes(wav)
+ .file_name("audio.wav")
+ .mime_str("audio/wav")
+ .map_err(|e| diag::fail(SCOPE, "build_form", format!("Failed to build the audio part: {}", e)))?;
+
+ let mut form = reqwest::multipart::Form::new()
+ .part("file", part)
+ .text("model", MODEL)
+ .text("response_format", "json")
+ .text("prompt", PUNCTUATION_PROMPT);
+ if let Some(lang) = language {
+ form = form.text("language", lang.to_string());
+ }
+ Ok(form)
+}
+
+/// 响应形状是 `{"text": "..."}`,比 chat/completions 简单得多。
+fn extract_text(data: &serde_json::Value) -> String {
+ data.get("text")
+ .and_then(|t| t.as_str())
+ .unwrap_or_default()
+ .trim()
+ .to_string()
+}
+
+pub async fn transcribe(
+ audio_pcm_b64: &str,
+ sample_rate: u32,
+ config: &AsrProviderConfig,
+ _hotwords: &[String],
+) -> Result {
+ let pcm = base64::Engine::decode(
+ &base64::engine::general_purpose::STANDARD,
+ audio_pcm_b64,
+ )
+ .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("Failed to decode base64 audio: {}", e)))?;
+
+ if pcm.is_empty() {
+ diag::empty_result(SCOPE, "Input audio was empty; provider request was skipped");
+ return Ok(AsrResult { text: String::new(), elapsed_ms: 0 });
+ }
+
+ let audio_sec = pcm.len() as f64 / (sample_rate.max(1) as f64 * 2.0);
+ let language = resolve_language(config);
+ let wav = pcm_to_wav(&pcm, sample_rate);
+
+ diag::log(
+ SCOPE,
+ "start",
+ &format!(
+ "model={} wav_bytes={} audio_sec={:.1} rate={} language={}",
+ MODEL,
+ wav.len(),
+ audio_sec,
+ sample_rate,
+ language.as_deref().unwrap_or("auto(omitted)")
+ ),
+ );
+
+ let form = build_form(wav, language.as_deref())?;
+ let client = super::http_client::shared();
+ let start = Instant::now();
+
+ let resp = client
+ .post(API_URL)
+ .header("Authorization", format!("Bearer {}", config.api_key))
+ .multipart(form)
+ .timeout(std::time::Duration::from_secs(60))
+ .send()
+ .await
+ .map_err(|e| diag::fail(SCOPE, "http_send", format!("Request failed: {}", e)))?;
+
+ let elapsed_ms = start.elapsed().as_millis() as u64;
+ let http_summary = diag::http_summary(resp.status(), resp.headers());
+
+ if !resp.status().is_success() {
+ let status = resp.status();
+ let body = resp.text().await.unwrap_or_default();
+ return Err(diag::fail(
+ SCOPE,
+ "http_status",
+ format!(
+ "Groq ASR error {} [{}]: {}",
+ status,
+ http_summary,
+ diag::truncate(&body, 300)
+ ),
+ ));
+ }
+
+ let body_text = resp
+ .text()
+ .await
+ .map_err(|e| diag::fail(SCOPE, "read_body", format!("Failed to read response: {}", e)))?;
+ let data: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
+ diag::fail(
+ SCOPE,
+ "parse_json",
+ format!(
+ "Failed to parse response: {} [{}] response excerpt: {}",
+ e,
+ http_summary,
+ diag::truncate(&body_text, 200)
+ ),
+ )
+ })?;
+
+ let text = extract_text(&data);
+ if text.is_empty() {
+ // 空结果走成功路径,前端只会显示「未检测到有效声音」。不留这条日志的话,
+ // 「真的没说话」和「这次调用其实失败了」就再也分不开(见 pitfalls #15)。
+ diag::empty_result(
+ SCOPE,
+ &format!(
+ "Response contained no transcript audio_sec={:.1} elapsed={}ms [{}] {}",
+ audio_sec,
+ elapsed_ms,
+ http_summary,
+ diag::describe_json(&body_text)
+ ),
+ );
+ } else {
+ diag::ok(SCOPE, elapsed_ms, text.chars().count());
+ }
+
+ Ok(AsrResult { text, elapsed_ms })
+}
+
+pub async fn test_connection(config: &AsrProviderConfig) -> TestResult {
+ // 0.5s 静音:Whisper 会返回空文本但 HTTP 200,足以验证密钥与网络是否可用。
+ // 音频再短会被服务端以「too short」拒掉,那就分不清是密钥问题还是音频问题了。
+ let silence = vec![0u8; 16000];
+ let wav = pcm_to_wav(&silence, 16000);
+
+ let form = match build_form(wav, None) {
+ Ok(f) => f,
+ Err(e) => {
+ return TestResult {
+ ok: false,
+ message: e,
+ elapsed_ms: 0,
+ detail: String::new(),
+ }
+ }
+ };
+
+ let client = super::http_client::shared();
+ let start = Instant::now();
+
+ let result = client
+ .post(API_URL)
+ .header("Authorization", format!("Bearer {}", config.api_key))
+ .multipart(form)
+ .timeout(std::time::Duration::from_secs(15))
+ .send()
+ .await;
+
+ let elapsed_ms = start.elapsed().as_millis() as u64;
+
+ match result {
+ Ok(resp) if resp.status().is_success() => TestResult {
+ ok: true,
+ message: format!("Connection successful ({}ms)", elapsed_ms),
+ elapsed_ms,
+ detail: String::new(),
+ },
+ Ok(resp) => {
+ let status = resp.status();
+ let summary = diag::http_summary(status, resp.headers());
+ let body = resp.text().await.unwrap_or_default();
+ TestResult {
+ ok: false,
+ message: diag::fail(
+ "groq/asr-test",
+ "http_status",
+ format!(
+ "API error {} [{}]: {}",
+ status,
+ summary,
+ diag::truncate(&body, 100)
+ ),
+ ),
+ elapsed_ms,
+ detail: String::new(),
+ }
+ }
+ Err(e) => TestResult {
+ ok: false,
+ message: diag::fail("groq/asr-test", "http_send", format!("Connection failed: {}", e)),
+ elapsed_ms,
+ detail: String::new(),
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn config_with_language(value: serde_json::Value) -> AsrProviderConfig {
+ AsrProviderConfig {
+ provider: "groq_whisper".to_string(),
+ api_key: String::new(),
+ app_id: String::new(),
+ extra: value,
+ }
+ }
+
+ /// auto 必须省略 language 字段,而不是把 "auto" 当语言代码发出去。
+ #[test]
+ fn auto_language_is_omitted() {
+ assert_eq!(resolve_language(&config_with_language(serde_json::json!({}))), None);
+ assert_eq!(
+ resolve_language(&config_with_language(serde_json::json!({"language": "auto"}))),
+ None
+ );
+ assert_eq!(
+ resolve_language(&config_with_language(serde_json::json!({"language": " "}))),
+ None
+ );
+ }
+
+ #[test]
+ fn explicit_language_is_passed_through() {
+ assert_eq!(
+ resolve_language(&config_with_language(serde_json::json!({"language": "zh"}))),
+ Some("zh".to_string())
+ );
+ }
+
+ /// WAV 头必须是 44 字节,且长度字段要跟数据长度对得上 —— 写错的话服务端只会回
+ /// 一个含义模糊的 400,很难往回定位到这里。
+ #[test]
+ fn wav_header_is_well_formed() {
+ let pcm = vec![0u8; 320];
+ let wav = pcm_to_wav(&pcm, 16000);
+ assert_eq!(wav.len(), 44 + pcm.len());
+ assert_eq!(&wav[0..4], b"RIFF");
+ assert_eq!(&wav[8..12], b"WAVE");
+ let riff_size = u32::from_le_bytes([wav[4], wav[5], wav[6], wav[7]]);
+ assert_eq!(riff_size as usize, 36 + pcm.len());
+ let data_size = u32::from_le_bytes([wav[40], wav[41], wav[42], wav[43]]);
+ assert_eq!(data_size as usize, pcm.len());
+ }
+
+ /// 这个 prompt 是靠「示范」生效的:它自己不带标点就一点作用都没有,
+ /// 而少了其中一种文字,auto 路径下那种语言就会失去引导。
+ /// 有人图省事把它改短时,这条会拦住。
+ ///
+ /// 注意它**不负责**全角/半角 —— 实测改 prompt 对宽度毫无影响,
+ /// 那件事在前端 normalizeChinesePunctuation 里做。
+ #[test]
+ fn punctuation_prompt_demonstrates_punctuation_in_both_scripts() {
+ assert!(PUNCTUATION_PROMPT.contains('。'), "缺中文句号,中文转写会没有标点");
+ assert!(PUNCTUATION_PROMPT.contains(','), "缺中文逗号");
+ assert!(PUNCTUATION_PROMPT.contains('.'), "缺英文句号");
+ assert!(PUNCTUATION_PROMPT.contains(','), "缺英文逗号");
+ assert!(
+ PUNCTUATION_PROMPT.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)),
+ "缺中文字符"
+ );
+ assert!(
+ PUNCTUATION_PROMPT.chars().any(|c| c.is_ascii_alphabetic()),
+ "缺英文字符"
+ );
+ }
+
+ #[test]
+ fn extracts_text_field() {
+ let data = serde_json::json!({ "text": " hello there " });
+ assert_eq!(extract_text(&data), "hello there");
+ assert_eq!(extract_text(&serde_json::json!({})), "");
+ }
+}
diff --git a/client/src-tauri/src/providers/asr_mimo.rs b/client/src-tauri/src/providers/asr_mimo.rs
index f28eefe..3aab0aa 100644
--- a/client/src-tauri/src/providers/asr_mimo.rs
+++ b/client/src-tauri/src/providers/asr_mimo.rs
@@ -116,7 +116,7 @@ pub async fn transcribe(
),
);
- let client = reqwest::Client::new();
+ let client = super::http_client::shared();
let start = Instant::now();
let resp = client
@@ -191,7 +191,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult {
let data_url = format!("data:audio/wav;base64,{}", wav_b64);
let body = build_body(&data_url, "auto");
- let client = reqwest::Client::new();
+ let client = super::http_client::shared();
let start = Instant::now();
let result = client
diff --git a/client/src-tauri/src/providers/asr_qwen.rs b/client/src-tauri/src/providers/asr_qwen.rs
index 162efbc..0a74761 100644
--- a/client/src-tauri/src/providers/asr_qwen.rs
+++ b/client/src-tauri/src/providers/asr_qwen.rs
@@ -151,7 +151,7 @@ pub async fn transcribe(
}
});
- let client = reqwest::Client::new();
+ let client = super::http_client::shared();
let start = Instant::now();
let resp = client
@@ -260,7 +260,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult {
}
});
- let client = reqwest::Client::new();
+ let client = super::http_client::shared();
let start = Instant::now();
let result = client
diff --git a/client/src-tauri/src/providers/diag.rs b/client/src-tauri/src/providers/diag.rs
index 2026fe5..2b480b3 100644
--- a/client/src-tauri/src/providers/diag.rs
+++ b/client/src-tauri/src/providers/diag.rs
@@ -90,9 +90,15 @@ fn contains_http_status(message: &str, expected: &[u16]) -> bool {
fn classify_failure(stage: &str, message: &str) -> &'static str {
let lower = message.to_ascii_lowercase();
- if contains_http_status(message, &[401, 403])
+ // 401 才是明确的鉴权失败。**403 不是** —— 它更常见的成因是"这个请求根本不该到这儿":
+ // CDN 按 IP 地区拒绝、账号没开通该模型、被 WAF 拦下,响应里一个字都不提密钥。
+ // 这两个状态码曾经一起归成 provider_bad_key,于是国内直连 Groq(Cloudflare 边缘
+ // 按 IP 拒,返回 403 {"error":{"message":"Forbidden"}},连鉴权头都没被读过)
+ // 被报成「密钥被拒绝,确认复制完整、没有多余空格」—— 用户只会一遍遍重建密钥。
+ if contains_http_status(message, &[401])
|| lower.contains("unauthorized")
|| lower.contains("invalid api key")
+ || lower.contains("invalid_api_key")
|| message.contains("认证失败")
|| message.contains("鉴权")
{
@@ -106,6 +112,10 @@ fn classify_failure(stage: &str, message: &str) -> &'static str {
{
return "provider_rate_limit";
}
+ // 排在限流之后:403 + quota 字样更可能是配额问题而不是权限问题
+ if contains_http_status(message, &[403]) {
+ return "provider_forbidden";
+ }
if contains_http_status(message, &[404])
|| lower.contains("model not found")
|| lower.contains("model does not exist")
@@ -245,6 +255,32 @@ mod tests {
assert_eq!(classify_failure("connect", "socket closed"), "provider_unreachable");
}
+ /// 关键回归:403 不许再报成「密钥被拒绝」。
+ ///
+ /// 实测 Groq 在中国大陆 IP 上的响应就是这一行,且假密钥、真密钥、完全不带鉴权头
+ /// 三种情况返回的内容**完全相同** —— 说明请求在边缘节点就被拒了,密钥从未被验证。
+ /// 归成密钥问题会把用户引去反复重建密钥。
+ #[test]
+ fn forbidden_is_not_reported_as_a_bad_key() {
+ assert_eq!(
+ classify_failure(
+ "http_status",
+ r#"API error 403 Forbidden [http=403]: {"error":{"message":"Forbidden"}}"#
+ ),
+ "provider_forbidden"
+ );
+ // 403 但服务端明确提了密钥无效,仍然算密钥问题
+ assert_eq!(
+ classify_failure("http_status", "HTTP 403: Invalid API Key"),
+ "provider_bad_key"
+ );
+ // 403 但说的是配额,归到限流而不是权限
+ assert_eq!(
+ classify_failure("http_status", "HTTP 403: quota exceeded"),
+ "provider_rate_limit"
+ );
+ }
+
/// 关键回归:按字节切中文会 panic。历史上 doubao / ollama / qwen / mimo 四处
/// 各有一份 `&s[..n]` 的实现,只要供应商回一段中文报错就崩 —— 而那正是出错的时刻。
#[test]
diff --git a/client/src-tauri/src/providers/http_client.rs b/client/src-tauri/src/providers/http_client.rs
new file mode 100644
index 0000000..e65f2da
--- /dev/null
+++ b/client/src-tauri/src/providers/http_client.rs
@@ -0,0 +1,26 @@
+// 所有 provider 共用的 HTTP 客户端。
+//
+// 为什么一定要带 User-Agent:**reqwest 默认一个 UA 都不发**,而 nginx / WAF 类网关
+// 普遍把「无 UA」当爬虫拦掉。实测 https://test-ai-api.nwcdcloud.cn/v1(LiteLLM 网关):
+// 同一个地址 + 密钥 + 模型,带 UA 返回 200,去掉 UA 头一律 403 并回一段 nginx 的 HTML
+// 错误页 —— 请求在网关就被拒了,鉴权根本没执行。我们自己的 ALB 上有同一条规则
+// (见 pitfalls 第 1 条)。这类 403 的响应里一个字都不提 UA,所以只会被归成
+// 「地区不可用 / 模型没开通」,用户换密钥、换模型都修不掉。
+//
+// 因此:provider 里**不要再写 `reqwest::Client::new()`**,一律用这里的单例。
+// 附带好处是连接池和 TLS 会话得以复用,不必每次调用重建一个客户端。
+pub const USER_AGENT: &str = concat!("SayIt/", env!("CARGO_PKG_VERSION"));
+
+static SHARED: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| {
+ reqwest::Client::builder()
+ .user_agent(USER_AGENT)
+ .build()
+ // 构建失败只可能是 TLS 后端初始化不了,此时退回默认客户端也不会更糟,
+ // 至少不要让整个转写功能因为拿不到客户端而不可用。
+ .unwrap_or_else(|_| reqwest::Client::new())
+});
+
+/// 带 User-Agent 的共享客户端。超时仍按各调用点的 `.timeout()` 单独设置。
+pub fn shared() -> &'static reqwest::Client {
+ &SHARED
+}
diff --git a/client/src-tauri/src/providers/mod.rs b/client/src-tauri/src/providers/mod.rs
index a194295..75deaec 100644
--- a/client/src-tauri/src/providers/mod.rs
+++ b/client/src-tauri/src/providers/mod.rs
@@ -3,6 +3,7 @@
pub mod types;
pub mod prompt;
pub mod diag;
+pub mod http_client;
pub mod ai_openai_compat;
pub mod ai_ollama;
pub mod asr_doubao;
@@ -11,6 +12,7 @@ pub mod asr_doubao_realtime;
pub mod asr_qwen;
pub mod asr_qwen_omni;
pub mod asr_mimo;
+pub mod asr_groq;
pub mod asr_qwen_realtime;
pub mod doubao_auth;
pub mod doubao_protocol;
diff --git a/client/src-tauri/src/providers/registry.rs b/client/src-tauri/src/providers/registry.rs
index 7969369..428d6fb 100644
--- a/client/src-tauri/src/providers/registry.rs
+++ b/client/src-tauri/src/providers/registry.rs
@@ -1,7 +1,7 @@
// 供应商注册表 — Tauri commands 入口
use super::types::*;
-use super::{ai_openai_compat, ai_ollama, asr_doubao, asr_doubao_stream, asr_qwen, asr_qwen_omni, asr_mimo};
+use super::{ai_openai_compat, ai_ollama, asr_doubao, asr_doubao_stream, asr_qwen, asr_qwen_omni, asr_mimo, asr_groq};
use crate::error_protocol;
/// 云端 AI 校对(Tauri command)
@@ -9,7 +9,9 @@ use crate::error_protocol;
pub async fn cloud_polish(request: CloudPolishRequest) -> Result {
let config = &request.ai_config;
match config.provider.as_str() {
- "openai_compat" | "deepseek" | "doubao" | "qwen" | "mimo" => {
+ // Groq 是标准的 OpenAI 兼容 chat/completions,直接复用通用实现,
+ // 不需要单独的文件(base_url 已带 /v1,normalize_base_url 会原样保留)。
+ "openai_compat" | "deepseek" | "doubao" | "qwen" | "mimo" | "groq" => {
ai_openai_compat::polish(
&request.text,
config,
@@ -33,7 +35,7 @@ pub async fn cloud_polish(request: CloudPolishRequest) -> Result Result {
match config.provider.as_str() {
- "openai_compat" | "deepseek" | "doubao" | "qwen" | "mimo" => {
+ "openai_compat" | "deepseek" | "doubao" | "qwen" | "mimo" | "groq" => {
Ok(ai_openai_compat::test_connection(&config).await)
}
"ollama" => {
@@ -93,6 +95,15 @@ pub async fn cloud_transcribe(request: CloudTranscribeRequest) -> Result {
+ asr_groq::transcribe(
+ &request.audio_b64,
+ request.sample_rate,
+ config,
+ &request.hotwords,
+ )
+ .await
+ }
other => Err(error_protocol::encode("connect_failed", format!("ASR provider \"{}\" is not implemented", other))),
}
}
@@ -106,6 +117,7 @@ pub async fn test_asr_connection(config: AsrProviderConfig) -> Result Ok(asr_qwen::test_connection(&config).await),
"qwen_omni" => Ok(asr_qwen_omni::test_connection(&config).await),
"mimo" => Ok(asr_mimo::test_connection(&config).await),
+ "groq_whisper" => Ok(asr_groq::test_connection(&config).await),
other => Err(error_protocol::encode("connect_failed", format!("ASR provider \"{}\" is not implemented", other))),
}
}
diff --git a/client/src-tauri/src/storage/mod.rs b/client/src-tauri/src/storage/mod.rs
index 90cf9f7..333db52 100644
--- a/client/src-tauri/src/storage/mod.rs
+++ b/client/src-tauri/src/storage/mod.rs
@@ -6,7 +6,10 @@ use std::sync::Mutex;
/// Default settings values (mirrors electron-app/electron/store.ts)
const DEFAULT_SETTINGS: &[(&str, &str)] = &[
- ("shortcutPTT", r#""ShiftRight""#),
+ // 按住说话的默认键。不能是 Shift:长按右 Shift 会触发 Windows 筛选键,
+ // 导致松开后录音停不下来。与 src/services/defaults.ts、keyboard/mod.rs 的
+ // DEFAULT_PTT_SETTING 保持一致。
+ ("shortcutPTT", r#""ControlRight""#),
("shortcutPTTCombo", r#""Alt+Q""#),
("shortcutHandsFree", r#""AltRight""#),
("autoLaunch", "true"),
diff --git a/client/src-tauri/src/window/mod.rs b/client/src-tauri/src/window/mod.rs
index d141da7..052309a 100644
--- a/client/src-tauri/src/window/mod.rs
+++ b/client/src-tauri/src/window/mod.rs
@@ -102,6 +102,25 @@ impl WindowState {
}
}
+ /// 在应用启动后的空闲时段提前创建隐藏的 overlay WebView。
+ ///
+ /// 第一次口述若等到热键按下后才创建 WebView,页面加载和 React 初始化会额外占用
+ /// 数百毫秒。预热只把 renderer 准备好,不显示窗口;真正 present 时仍会重新捕获
+ /// 当前前台显示器、应用最终布局并置顶。
+ pub fn prewarm_overlay(&self, app: &AppHandle) {
+ let _lifecycle_guard = self.overlay_lifecycle.lock().unwrap();
+ if self.active_show_id.load(Ordering::SeqCst) != 0
+ || app.get_webview_window("overlay").is_some()
+ {
+ return;
+ }
+
+ let layout = self.overlay_layout.lock().unwrap().clone();
+ let base_width = *self.overlay_base_width.lock().unwrap();
+ write_log_line("[overlay-health] idle prewarm begin");
+ self.create_overlay(app, layout, base_width, false);
+ }
+
/// 原子地保存状态、显示原生窗口并请求 Overlay 确认本次渲染。
pub fn present_overlay(&self, app: &AppHandle, data: Value) -> u64 {
self.apply_payload_layout(&data);
@@ -432,7 +451,7 @@ impl WindowState {
"[overlay-health] handle missing show_id={} — creating",
show_id,
));
- self.create_overlay(app, layout, base_width);
+ self.create_overlay(app, layout, base_width, true);
return false;
};
@@ -547,10 +566,16 @@ impl WindowState {
let layout = self.overlay_layout.lock().unwrap().clone();
let base_width = *self.overlay_base_width.lock().unwrap();
- self.create_overlay(app, layout, base_width);
+ self.create_overlay(app, layout, base_width, true);
}
- fn create_overlay(&self, app: &AppHandle, layout: OverlayLayout, base_width: f64) {
+ fn create_overlay(
+ &self,
+ app: &AppHandle,
+ layout: OverlayLayout,
+ base_width: f64,
+ show_immediately: bool,
+ ) {
self.reset_renderer_lifecycle();
let bounds = calc_overlay_bounds(app, &layout, base_width);
let builder = WebviewWindowBuilder::new(
@@ -578,11 +603,16 @@ impl WindowState {
Ok(overlay) => {
let position_error = self.apply_native_layout(app, &overlay, &layout);
set_overlay_interactivity(&overlay, layout.is_interactive());
- let show_error = overlay.show().err().map(|error| format!("{:?}", error));
+ let show_error = if show_immediately {
+ overlay.show().err().map(|error| format!("{:?}", error))
+ } else {
+ None
+ };
write_log_line(&format!(
- "[overlay-health] create dispatched show_id={} generation={} initial_bounds={:?} position_error={:?} show_error={:?} shell_visible={} diagnostic={}",
+ "[overlay-health] create dispatched show_id={} generation={} prewarm={} initial_bounds={:?} position_error={:?} show_error={:?} shell_visible={} diagnostic={}",
self.active_show_id.load(Ordering::SeqCst),
self.active_generation.load(Ordering::SeqCst),
+ !show_immediately,
bounds,
position_error,
show_error,
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
index acc5b66..9162c4b 100644
--- a/client/src/components/ui/button.tsx
+++ b/client/src/components/ui/button.tsx
@@ -15,10 +15,10 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: "h-10 px-4 py-2",
- sm: "h-9 rounded-md px-3",
- lg: "h-11 rounded-md px-8",
- icon: "h-10 w-10",
+ default: "h-9 px-3.5 py-1.5",
+ sm: "h-8 rounded-md px-3",
+ lg: "h-10 rounded-md px-6",
+ icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
diff --git a/client/src/components/ui/segmented.tsx b/client/src/components/ui/segmented.tsx
index c08253d..8024862 100644
--- a/client/src/components/ui/segmented.tsx
+++ b/client/src/components/ui/segmented.tsx
@@ -25,6 +25,8 @@ interface SegmentedProps {
size?: 'sm' | 'md'
/** 整组禁用(如切换计算后端时正在重载模型) */
disabled?: boolean
+ /** Whether option color changes should animate. */
+ animated?: boolean
className?: string
}
@@ -36,6 +38,7 @@ export function Segmented({
onChange,
size = 'md',
disabled,
+ animated = true,
className,
}: SegmentedProps) {
return (
@@ -43,7 +46,7 @@ export function Segmented({
role="radiogroup"
aria-label={label}
aria-labelledby={labelledBy}
- className={cn('flex flex-wrap gap-2', className)}
+ className={cn('flex flex-wrap items-center gap-1', className)}
>
{options.map((option) => {
const isActive = option.value === value
@@ -56,11 +59,13 @@ export function Segmented({
disabled={disabled || option.disabled}
onClick={() => onChange(option.value)}
className={cn(
- 'rounded-md border transition-colors disabled:pointer-events-none disabled:opacity-50',
- size === 'sm' ? 'px-3 py-1 text-xs' : 'px-4 py-1.5 text-sm',
+ 'inline-flex items-center justify-center rounded-lg font-medium outline-none disabled:pointer-events-none disabled:opacity-50',
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
+ animated && 'transition-colors',
+ size === 'sm' ? 'h-7 px-2.5 text-xs' : 'h-8 px-3 text-sm',
isActive
- ? 'border-primary bg-primary font-medium text-primary-foreground'
- : 'border-border bg-card text-foreground hover:bg-accent',
+ ? 'bg-foreground/[0.08] text-foreground hover:bg-foreground/[0.13]'
+ : 'bg-transparent text-muted-foreground hover:bg-foreground/[0.05] hover:text-foreground',
)}
>
{option.label}
diff --git a/client/src/components/ui/switch.tsx b/client/src/components/ui/switch.tsx
index 8fe0b2d..6d33511 100644
--- a/client/src/components/ui/switch.tsx
+++ b/client/src/components/ui/switch.tsx
@@ -20,7 +20,7 @@ interface SwitchProps {
/**
* 统一 Switch 开关组件
- * default: h-6 w-11 / sm: h-4 w-7
+ * default: h-5 w-9 / sm: h-4 w-7
*/
export function Switch({ checked, onChange, label, labelledBy, disabled, className, size = 'default', noAnimation, hidden }: SwitchProps) {
const sm = size === 'sm'
@@ -39,7 +39,7 @@ export function Switch({ checked, onChange, label, labelledBy, disabled, classNa
className={cn(
'relative shrink-0 rounded-full',
!noAnimation && 'transition-colors',
- sm ? 'h-4 w-7' : 'h-6 w-11',
+ sm ? 'h-4 w-7' : 'h-5 w-9',
checked ? 'bg-primary' : 'bg-muted',
)}
>
@@ -47,8 +47,8 @@ export function Switch({ checked, onChange, label, labelledBy, disabled, classNa
className={cn(
'absolute left-0.5 top-0.5 rounded-full bg-card shadow',
!noAnimation && 'transition-transform',
- sm ? 'h-3 w-3' : 'h-5 w-5',
- checked && (sm ? 'translate-x-3' : 'translate-x-5'),
+ sm ? 'h-3 w-3' : 'h-4 w-4',
+ checked && (sm ? 'translate-x-3' : 'translate-x-4'),
)}
/>
diff --git a/client/src/features/settings/AIProviderSection.tsx b/client/src/features/settings/AIProviderSection.tsx
index c888ceb..f61daf7 100644
--- a/client/src/features/settings/AIProviderSection.tsx
+++ b/client/src/features/settings/AIProviderSection.tsx
@@ -243,7 +243,6 @@ function describeStatus(profile: AiProfile, checking: boolean): CardStatus {
}
}
-const DOC_URL = 'https://my.feishu.cn/wiki/EEdswP97PijkmAkSr4HcuiVlnxf'
// ⓘ 里只放"选哪家"这一件事。"点一张即启用"写在卡头副标题上,不重复说;
// 卡上标签的含义由标签自己和它的 tooltip 承担。
@@ -976,7 +975,11 @@ export default function AIProviderSection() {
{t('ai.keyReused', { provider: draftProvider.label })}
)}
- {/* 密钥入口放在密钥这一格里。英文文档发布前,设置文档只对中文界面显示。 */}
+ {/* 密钥入口放在密钥这一格里。
+ 这里原来还有一个「怎么申请密钥?看配置文档」的链接,去掉了:AI 服务
+ 要做的只有「去控制台复制一把 Key」,上面那个控制台链接就是最短路径,
+ 再挂一篇文档反而暗示这件事很复杂。
+ (语音识别那边保留了,但只对豆包显示 —— 只有它要在新旧两代控制台之间选。)*/}
{draftProvider.consoleUrl && (
)}
- {getLocale() === 'zh-CN' && (
-
- )}
)}
diff --git a/client/src/features/settings/AsrTestSection.tsx b/client/src/features/settings/AsrTestSection.tsx
index 203e61c..b3ba8c8 100644
--- a/client/src/features/settings/AsrTestSection.tsx
+++ b/client/src/features/settings/AsrTestSection.tsx
@@ -179,7 +179,7 @@ export default function AsrTestSection({ workMode }: { workMode: WorkMode }) {
{/* flex-wrap:最小窗口下标题 + 两个按钮挤一行会溢出 */}
-
+
{t('asrTest.title')}
{/* 这张卡只测 ASR。服务器模式下请求里写着 disable_ai: true,本地和云 API 也
@@ -190,14 +190,13 @@ export default function AsrTestSection({ workMode }: { workMode: WorkMode }) {
-