feat(客户端): 新增 Groq 供应商,按住说话不再接受 Shift,悬浮窗改为启动后预热
Groq 既可作 AI 整理供应商,也可作语音识别服务,两项都需要海外网络。 按住说话禁用 Shift 并把默认键改为右 Ctrl:长按右 Shift 会触发 Windows 筛选键, 此后松开按键的动作收不到,录音停不下来,而这恰好就是按住说话的使用方式。 悬浮窗在启动后的空闲时段预建渲染器,按键时先显示再探测目标窗口,两次窗口扫描 合并成一次;供应商请求统一走带 User-Agent 的共享客户端,403 不再一律报成密钥被拒。
This commit is contained in:
1 parent
f7f37252fb
commit
63c66ca04b
46 files changed
+1275
-325
No files matched your search
@@ -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<ContextDetector>) -> Result<Value, Strin
|
||||
let ctx = detector.capture("probe");
|
||||
let completed_at = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
Ok(probe_result_for_context(&ctx, started_at, completed_at))
|
||||
}
|
||||
|
||||
/// 录音启动只捕获一次前台窗口,同时返回提示词路由所需的完整上下文与插字探测结果。
|
||||
/// 过去前端连续调用 get_active_app_context + get_probe_result,底层 UI Automation 会
|
||||
/// 对同一个窗口完整扫描两遍,既增加热键延迟,也给两次捕获之间的焦点变化留下竞态。
|
||||
#[tauri::command]
|
||||
pub fn get_recording_context(detector: State<ContextDetector>) -> Result<Value, String> {
|
||||
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<ContextDetector>) -> Result<Value, Strin
|
||||
|
||||
let probe_id = started_at; // use timestamp as probe ID
|
||||
|
||||
let result = serde_json::json!({
|
||||
serde_json::json!({
|
||||
"editable": editable,
|
||||
"hwnd": ctx.hwnd,
|
||||
"focusHwnd": ctx.focus_hwnd,
|
||||
"hwnd": &ctx.hwnd,
|
||||
"focusHwnd": &ctx.focus_hwnd,
|
||||
"pid": ctx.pid,
|
||||
"tid": ctx.tid,
|
||||
"process": ctx.process_name,
|
||||
"process": &ctx.process_name,
|
||||
"detail": format!("class={} focusClass={} hasCaret={}", ctx.window_class, ctx.focus_class, ctx.has_caret),
|
||||
"hasCaret": ctx.has_caret,
|
||||
"windowClass": ctx.window_class,
|
||||
"focusClass": ctx.focus_class,
|
||||
"controlType": ctx.control_type,
|
||||
"automationId": ctx.automation_id,
|
||||
"windowClass": &ctx.window_class,
|
||||
"focusClass": &ctx.focus_class,
|
||||
"controlType": &ctx.control_type,
|
||||
"automationId": &ctx.automation_id,
|
||||
"isValuePatternAvailable": ctx.is_value_pattern_available,
|
||||
"isKeyboardFocusable": ctx.is_keyboard_focusable,
|
||||
"isEnabled": ctx.is_enabled,
|
||||
@@ -118,9 +144,7 @@ pub fn get_probe_result(detector: State<ContextDetector>) -> Result<Value, Strin
|
||||
"startedAt": started_at,
|
||||
"completedAt": completed_at,
|
||||
"isCurrentAppProcess": is_current_app_process,
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -16,7 +16,9 @@ pub fn shortcuts_changed(
|
||||
) {
|
||||
// Read settings
|
||||
let ptt_setting = storage.get("shortcutPTT", None);
|
||||
let ptt_str = ptt_setting.as_str().unwrap_or("ShiftRight");
|
||||
// 兜底键与 keyboard::DEFAULT_PTT_SETTING、storage 种子、前端 defaults.ts 一致。
|
||||
// 绝不能是 Shift:长按右 Shift 会触发 Windows 筛选键,录音就停不下来了。
|
||||
let ptt_str = ptt_setting.as_str().unwrap_or("ControlRight");
|
||||
let hf_val = storage.get("shortcutHandsFree", None);
|
||||
let hf_key = hf_val.as_str().unwrap_or("AltRight");
|
||||
|
||||
@@ -41,11 +43,13 @@ pub fn register_all_global_shortcuts(app: &AppHandle, storage: &Storage) {
|
||||
let hf_val = storage.get("shortcutHandsFree", None);
|
||||
let hf_key = hf_val.as_str().unwrap_or("AltRight").to_string();
|
||||
if !hf_key.is_empty() && hf_key.contains('+') {
|
||||
if let Err(e) = gs.on_shortcut(hf_key.as_str(), move |app, _shortcut, _event| {
|
||||
let _ = app.emit(
|
||||
"toggle-hands-free",
|
||||
serde_json::json!({ "source": "globalShortcut" }),
|
||||
);
|
||||
if let Err(e) = gs.on_shortcut(hf_key.as_str(), move |app, _shortcut, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
let _ = app.emit(
|
||||
"toggle-hands-free",
|
||||
serde_json::json!({ "source": "globalShortcut" }),
|
||||
);
|
||||
}
|
||||
}) {
|
||||
log::warn!("Failed to register hands-free shortcut '{}': {}", hf_key, e);
|
||||
} else {
|
||||
|
||||
@@ -423,11 +423,52 @@ pub async fn download_update(app: AppHandle, url: String, sha512: Option<String>
|
||||
}
|
||||
}
|
||||
|
||||
// 新包已经校验通过,把同目录里其它版本的安装包删掉。
|
||||
// 旧流程下载完立刻安装,包只存在几秒;现在它按设计等到用户点更新或退出时才用,
|
||||
// 于是每升一次版就在 %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 不提供"安装完成后运行",所以自己接管:起一个不依赖当前进程存活的
|
||||
|
||||
@@ -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<u32>,
|
||||
@@ -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]
|
||||
|
||||
@@ -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::<WindowState>();
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,8 +8,10 @@ use std::time::Instant;
|
||||
|
||||
const SCOPE: &str = "ai/openai-compat";
|
||||
|
||||
static HTTP_CLIENT: once_cell::sync::Lazy<reqwest::Client> =
|
||||
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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<u8> {
|
||||
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<String> {
|
||||
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<u8>, language: Option<&str>) -> Result<reqwest::multipart::Form, String> {
|
||||
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<AsrResult, String> {
|
||||
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!({})), "");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<reqwest::Client> = 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AiResult, String> {
|
||||
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<AiResult, Strin
|
||||
#[tauri::command]
|
||||
pub async fn test_ai_connection(config: AiProviderConfig) -> Result<TestResult, String> {
|
||||
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<AsrResu
|
||||
)
|
||||
.await
|
||||
}
|
||||
"groq_whisper" => {
|
||||
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<TestResult
|
||||
"qwen" | "aliyun" | "qwen_realtime" => 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))),
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -25,6 +25,8 @@ interface SegmentedProps<T> {
|
||||
size?: 'sm' | 'md'
|
||||
/** 整组禁用(如切换计算后端时正在重载模型) */
|
||||
disabled?: boolean
|
||||
/** Whether option color changes should animate. */
|
||||
animated?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ export function Segmented<T extends string | number>({
|
||||
onChange,
|
||||
size = 'md',
|
||||
disabled,
|
||||
animated = true,
|
||||
className,
|
||||
}: SegmentedProps<T>) {
|
||||
return (
|
||||
@@ -43,7 +46,7 @@ export function Segmented<T extends string | number>({
|
||||
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<T extends string | number>({
|
||||
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}
|
||||
|
||||
@@ -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'),
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -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 })}
|
||||
</p>
|
||||
)}
|
||||
{/* 密钥入口放在密钥这一格里。英文文档发布前,设置文档只对中文界面显示。 */}
|
||||
{/* 密钥入口放在密钥这一格里。
|
||||
这里原来还有一个「怎么申请密钥?看配置文档」的链接,去掉了:AI 服务
|
||||
要做的只有「去控制台复制一把 Key」,上面那个控制台链接就是最短路径,
|
||||
再挂一篇文档反而暗示这件事很复杂。
|
||||
(语音识别那边保留了,但只对豆包显示 —— 只有它要在新旧两代控制台之间选。)*/}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
{draftProvider.consoleUrl && (
|
||||
<button
|
||||
@@ -988,12 +991,6 @@ export default function AIProviderSection() {
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{getLocale() === 'zh-CN' && (
|
||||
<button type="button" onClick={() => void shellOpen(DOC_URL)} className={linkClass}>
|
||||
{t('ai.howToGetKey')}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function AsrTestSection({ workMode }: { workMode: WorkMode }) {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{/* flex-wrap:最小窗口下标题 + 两个按钮挤一行会溢出 */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold">{t('asrTest.title')}</h2>
|
||||
{/* 这张卡只测 ASR。服务器模式下请求里写着 disable_ai: true,本地和云 API 也
|
||||
@@ -190,14 +190,13 @@ export default function AsrTestSection({ workMode }: { workMode: WorkMode }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => void handlePlay()}>
|
||||
<Button size="sm" variant="outline" className="gap-1.5" onClick={() => void handlePlay()}>
|
||||
{playing ? <Pause className="h-3.5 w-3.5" aria-hidden /> : <Play className="h-3.5 w-3.5" aria-hidden />}
|
||||
{playing ? t('asrTest.pause') : t('asrTest.play')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
onClick={() => void handleTest()}
|
||||
disabled={testing || draftDirty}
|
||||
>
|
||||
|
||||
@@ -753,7 +753,12 @@ export default function CloudAPISection() {
|
||||
{t('asr.openConsole', { platform: platformInfo.label })}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
{getLocale() === 'zh-CN' && (
|
||||
{/* 配置文档只对豆包显示。别的平台都是「去控制台复制一把 API Key」,
|
||||
上面那个控制台链接已经足够;只有豆包要在新旧两代控制台之间做选择
|
||||
(新版只给 API Key,旧版还要 Access Token + App ID),光看界面讲不清,
|
||||
确实需要一篇文档。给所有平台都挂上等于让用户为一件不复杂的事去读文档。
|
||||
英文文档发布前,这个链接只对中文界面显示。 */}
|
||||
{draftPlatform === 'doubao' && getLocale() === 'zh-CN' && (
|
||||
<button type="button" onClick={() => void shellOpen(DOC_URL)} className={linkClass}>
|
||||
{t('asr.howToGetKey')}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
|
||||
@@ -9,8 +9,10 @@ import { Tooltip } from '@/components/ui/tooltip'
|
||||
import { listMicrophones } from '@/services/audio'
|
||||
import { refreshRecorderSettings } from '@/services/recorder'
|
||||
import { getPresetShortcuts, getSetting, setSetting } from '@/services/store'
|
||||
import { getDefault } from '@/services/defaults'
|
||||
import { drawBars, resetWaveform } from '@/services/waveform'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Segmented } from '@/components/ui/segmented'
|
||||
import AppSection from './AppSection'
|
||||
import BackupSection from './BackupSection'
|
||||
import MicrophoneSection from './MicrophoneSection'
|
||||
@@ -54,7 +56,8 @@ export default function GeneralSettingsPage() {
|
||||
const [micError, setMicError] = useState('')
|
||||
const [muteSystemAudio, setMuteSystemAudio] = useState(false)
|
||||
const [protectClipboard, setProtectClipboard] = useState(true)
|
||||
const [pttKey, setPttKey] = useState('ShiftRight')
|
||||
// 兜底值统一从 defaults 取,不在这里写第二份字面量
|
||||
const [pttKey, setPttKey] = useState(() => getDefault<string>('shortcutPTT', ''))
|
||||
const [handsFreeKey, setHandsFreeKey] = useState('AltRight')
|
||||
const [historyEnabled, setHistoryEnabled] = useState(true)
|
||||
const [audioRetentionEnabled, setAudioRetentionEnabled] = useState(true)
|
||||
@@ -107,7 +110,7 @@ export default function GeneralSettingsPage() {
|
||||
})()
|
||||
getLanguagePreference().then(setLanguagePreference).catch(() => { })
|
||||
getSetting('selectedMic', '').then(setSelectedMic)
|
||||
getSetting('shortcutPTT', 'ShiftRight').then((value) => setPttKey(value as string))
|
||||
getSetting<string>('shortcutPTT').then((value) => setPttKey(value))
|
||||
getSetting('shortcutHandsFree', 'AltRight').then((value) => setHandsFreeKey(value as string))
|
||||
listMicrophones().then(setMics).catch(() => { })
|
||||
return () => { cancelled = true }
|
||||
@@ -217,14 +220,21 @@ export default function GeneralSettingsPage() {
|
||||
这张卡自己走 t(),所以切换后它连自己的标题一起变,用户能立刻确认生效了。 */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 className="text-lg font-semibold">{t('settings.general.language.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.general.language.hint')}</p>
|
||||
<div className="mt-4 flex gap-2" style={ready ? undefined : { visibility: 'hidden' }}>
|
||||
{LANGUAGE_OPTIONS.map((opt) => (
|
||||
<button key={opt.value} type="button" onClick={() => void handleLanguageChange(opt.value)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${animate ? 'transition-colors' : ''} ${languagePreference === opt.value ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-card text-foreground hover:bg-accent'}`}
|
||||
>{t(opt.labelKey)}</button>
|
||||
))}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold">{t('settings.general.language.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.general.language.hint')}</p>
|
||||
</div>
|
||||
<div className="shrink-0" style={ready ? undefined : { visibility: 'hidden' }}>
|
||||
<Segmented
|
||||
label={t('settings.general.language.title')}
|
||||
value={languagePreference}
|
||||
options={LANGUAGE_OPTIONS.map((opt) => ({ value: opt.value, label: t(opt.labelKey) }))}
|
||||
onChange={(value) => void handleLanguageChange(value)}
|
||||
animated={animate}
|
||||
className="justify-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -316,14 +326,17 @@ export default function GeneralSettingsPage() {
|
||||
<Switch checked={audioRetentionEnabled} onChange={() => void toggleAudioRetention()} noAnimation={!animate} hidden={!ready} />
|
||||
</div>
|
||||
{audioRetentionEnabled && (
|
||||
<div className="mt-4">
|
||||
<div className="mt-4 flex flex-col gap-2 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-sm text-muted-foreground">{t('settings.audio.retentionLabel')}</label>
|
||||
<div className="mt-2 flex gap-2" style={ready ? undefined : { visibility: 'hidden' }}>
|
||||
{([{ value: 7, labelKey: 'settings.retention.7d' }, { value: 30, labelKey: 'settings.retention.1m' }, { value: 90, labelKey: 'settings.retention.3m' }, { value: -1, labelKey: 'settings.retention.forever' }] as const satisfies readonly { value: number; labelKey: TranslationKey }[]).map((opt) => (
|
||||
<button key={opt.value} type="button" onClick={() => void handleAudioRetentionDaysChange(opt.value)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${animate ? 'transition-colors' : ''} ${audioRetentionDays === opt.value ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-card text-foreground hover:bg-accent'}`}
|
||||
>{t(opt.labelKey)}</button>
|
||||
))}
|
||||
<div className="shrink-0" style={ready ? undefined : { visibility: 'hidden' }}>
|
||||
<Segmented
|
||||
label={t('settings.audio.retentionLabel')}
|
||||
value={audioRetentionDays}
|
||||
options={([{ value: 7, labelKey: 'settings.retention.7d' }, { value: 30, labelKey: 'settings.retention.1m' }, { value: 90, labelKey: 'settings.retention.3m' }, { value: -1, labelKey: 'settings.retention.forever' }] as const satisfies readonly { value: number; labelKey: TranslationKey }[]).map((opt) => ({ value: opt.value, label: t(opt.labelKey) }))}
|
||||
onChange={(value) => void handleAudioRetentionDaysChange(value)}
|
||||
animated={animate}
|
||||
className="justify-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -332,17 +345,20 @@ export default function GeneralSettingsPage() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('settings.log.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.log.desc')}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="flex gap-2" style={ready ? undefined : { visibility: 'hidden' }}>
|
||||
{([{ value: 7, labelKey: 'settings.retention.7d' }, { value: 15, labelKey: 'settings.retention.15d' }, { value: 30, labelKey: 'settings.retention.1m' }, { value: 90, labelKey: 'settings.retention.3m' }] as const satisfies readonly { value: number; labelKey: TranslationKey }[]).map((opt) => (
|
||||
<button key={opt.value} type="button" onClick={() => void handleLogRetentionDaysChange(opt.value)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${animate ? 'transition-colors' : ''} ${logRetentionDays === opt.value ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-card text-foreground hover:bg-accent'}`}
|
||||
>{t(opt.labelKey)}</button>
|
||||
))}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold">{t('settings.log.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.log.desc')}</p>
|
||||
</div>
|
||||
<div className="shrink-0" style={ready ? undefined : { visibility: 'hidden' }}>
|
||||
<Segmented
|
||||
label={t('settings.log.title')}
|
||||
value={logRetentionDays}
|
||||
options={([{ value: 7, labelKey: 'settings.retention.7d' }, { value: 15, labelKey: 'settings.retention.15d' }, { value: 30, labelKey: 'settings.retention.1m' }, { value: 90, labelKey: 'settings.retention.3m' }] as const satisfies readonly { value: number; labelKey: TranslationKey }[]).map((opt) => ({ value: opt.value, label: t(opt.labelKey) }))}
|
||||
onChange={(value) => void handleLogRetentionDaysChange(value)}
|
||||
animated={animate}
|
||||
className="justify-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -602,7 +602,7 @@ export default function LocalModeSection() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<span id="download-source-label" className="text-sm text-muted-foreground">{t('local.downloadSource')}</span>
|
||||
<Segmented
|
||||
labelledBy="download-source-label"
|
||||
@@ -610,6 +610,7 @@ export default function LocalModeSection() {
|
||||
value={effectiveSource}
|
||||
options={sourceOptions.map((src) => ({ value: src, label: sourceLabel(src) }))}
|
||||
onChange={(src) => { setDownloadSource(src); void setSetting('localAsr.downloadSource', src) }}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -908,50 +909,52 @@ export function LocalModeAdvancedSection() {
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 id="local-language-heading" className="mb-3 text-lg font-semibold">{t('local.languageTitle')}</h2>
|
||||
<Segmented
|
||||
labelledBy="local-language-heading"
|
||||
value={asrLanguage}
|
||||
options={[
|
||||
{ value: 'auto', label: t('common.auto') },
|
||||
{ value: 'zh', label: t('local.lang.zh') },
|
||||
{ value: 'en', label: t('local.lang.en') },
|
||||
]}
|
||||
onChange={(value) => { setAsrLanguage(value); void setSetting('localAsr.language', value) }}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{/* 说清作用范围与真实收益:这个值只影响本地识别(作为 RunOptions.language
|
||||
传给引擎,auto = 让模型自己检测),云 API 模式没有这个选项,服务器模式
|
||||
有它自己的一份。固定语种的实际价值是压掉短句上的语种误判 —— 实测
|
||||
Qwen3 1.7B 会把 3 秒普通话判成粤语,见 gguf_asr.rs 的 language_detect_report。 */}
|
||||
{t('local.languageNote')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 id="local-language-heading" className="text-lg font-semibold">{t('local.languageTitle')}</h2>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('local.languageNote')}</p>
|
||||
</div>
|
||||
<Segmented
|
||||
labelledBy="local-language-heading"
|
||||
value={asrLanguage}
|
||||
options={[
|
||||
{ value: 'auto', label: t('common.auto') },
|
||||
{ value: 'zh', label: t('local.lang.zh') },
|
||||
{ value: 'en', label: t('local.lang.en') },
|
||||
]}
|
||||
onChange={(value) => { setAsrLanguage(value); void setSetting('localAsr.language', value) }}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 id="accelerator-heading" className="text-lg font-semibold">{t('local.backendTitle')}</h2>
|
||||
<span className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-1 text-xs ${diagnosticsState === 'ready' && hasGpu
|
||||
? 'border-success/30 bg-success/10 text-success-strong'
|
||||
: 'border-border bg-muted/40 text-muted-foreground'
|
||||
}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${diagnosticsState === 'ready' && hasGpu ? 'bg-success' : 'bg-muted-foreground'}`} aria-hidden />
|
||||
{diagnosticsState === 'loading' ? t('local.backendChecking') : diagnosticsState === 'error' ? t('local.backendCheckFailed') : hasGpu ? t('local.backendGpuReady') : t('local.backendCpuOnly')}
|
||||
</span>
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<h2 id="accelerator-heading" className="text-lg font-semibold">{t('local.backendTitle')}</h2>
|
||||
<span className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-1 text-xs ${diagnosticsState === 'ready' && hasGpu
|
||||
? 'border-success/30 bg-success/10 text-success-strong'
|
||||
: 'border-border bg-muted/40 text-muted-foreground'
|
||||
}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${diagnosticsState === 'ready' && hasGpu ? 'bg-success' : 'bg-muted-foreground'}`} aria-hidden />
|
||||
{diagnosticsState === 'loading' ? t('local.backendChecking') : diagnosticsState === 'error' ? t('local.backendCheckFailed') : hasGpu ? t('local.backendGpuReady') : t('local.backendCpuOnly')}
|
||||
</span>
|
||||
</div>
|
||||
<Segmented
|
||||
labelledBy="accelerator-heading"
|
||||
value={accelerator}
|
||||
disabled={rebinding}
|
||||
options={[
|
||||
{ value: 'auto', label: t('common.auto') },
|
||||
{ value: 'gpu', label: 'GPU' },
|
||||
{ value: 'cpu', label: 'CPU' },
|
||||
]}
|
||||
onChange={(value) => void handleSelectAccelerator(value)}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
<Segmented
|
||||
labelledBy="accelerator-heading"
|
||||
value={accelerator}
|
||||
disabled={rebinding}
|
||||
options={[
|
||||
{ value: 'auto', label: t('common.auto') },
|
||||
{ value: 'gpu', label: 'GPU' },
|
||||
{ value: 'cpu', label: 'CPU' },
|
||||
]}
|
||||
onChange={(value) => void handleSelectAccelerator(value)}
|
||||
/>
|
||||
{diagnosticsState === 'ready' && hasGpu && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{gpuSummary}
|
||||
@@ -974,30 +977,31 @@ export function LocalModeAdvancedSection() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 id="unload-idle-heading" className="mb-1 text-lg font-semibold">{t('local.unloadTitle')}</h2>
|
||||
{/* 这四个选项是在"占着内存"和"下次说话要等模型重新加载"之间取舍,
|
||||
原来的说明只讲"会怎样",不讲"你该不该关心" */}
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{t('local.unloadDesc')}
|
||||
</p>
|
||||
<Segmented
|
||||
labelledBy="unload-idle-heading"
|
||||
value={unloadIdleMinutes}
|
||||
options={[
|
||||
{ value: 0, label: t('local.unload.never') },
|
||||
{ value: 10, label: t('local.unload.10m') },
|
||||
{ value: 30, label: t('local.unload.30m') },
|
||||
{ value: 60, label: t('local.unload.1h') },
|
||||
]}
|
||||
onChange={(value) => void handleSelectUnloadIdle(value)}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{unloadIdleMinutes === 0
|
||||
? t('local.unloadNever')
|
||||
: t('local.unloadAfter', {
|
||||
duration: unloadIdleMinutes === 60 ? t('local.unload.1h') : t('local.minutes', { count: unloadIdleMinutes }),
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 id="unload-idle-heading" className="text-lg font-semibold">{t('local.unloadTitle')}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('local.unloadDesc')}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{unloadIdleMinutes === 0
|
||||
? t('local.unloadNever')
|
||||
: t('local.unloadAfter', {
|
||||
duration: unloadIdleMinutes === 60 ? t('local.unload.1h') : t('local.minutes', { count: unloadIdleMinutes }),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Segmented
|
||||
labelledBy="unload-idle-heading"
|
||||
value={unloadIdleMinutes}
|
||||
options={[
|
||||
{ value: 0, label: t('local.unload.never') },
|
||||
{ value: 10, label: t('local.unload.10m') },
|
||||
{ value: 30, label: t('local.unload.30m') },
|
||||
{ value: 60, label: t('local.unload.1h') },
|
||||
]}
|
||||
onChange={(value) => void handleSelectUnloadIdle(value)}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -230,23 +230,23 @@ export default function ServerSection() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 id="server-language-heading" className="mb-3 text-lg font-semibold">{t('server.languageTitle')}</h2>
|
||||
<Segmented
|
||||
labelledBy="server-language-heading"
|
||||
value={asrLanguage}
|
||||
options={[
|
||||
{ value: 'auto', label: t('common.auto') },
|
||||
{ value: 'zh', label: t('local.lang.zh') },
|
||||
{ value: 'en', label: t('local.lang.en') },
|
||||
]}
|
||||
onChange={(value) => { setAsrLanguage(value); void setSetting('server.language', value) }}
|
||||
/>
|
||||
{/* 说清作用范围:这个值只跟着服务器模式走(随每次识别发给服务端,
|
||||
由 asr.py 的 _resolve_language 映射成 Chinese/English,auto = 让模型自检)。
|
||||
本地模式有它自己的一份,云 API 模式没有这个选项。 */}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('server.languageNote')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 id="server-language-heading" className="text-lg font-semibold">{t('server.languageTitle')}</h2>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('server.languageNote')}</p>
|
||||
</div>
|
||||
<Segmented
|
||||
labelledBy="server-language-heading"
|
||||
value={asrLanguage}
|
||||
options={[
|
||||
{ value: 'auto', label: t('common.auto') },
|
||||
{ value: 'zh', label: t('local.lang.zh') },
|
||||
{ value: 'en', label: t('local.lang.en') },
|
||||
]}
|
||||
onChange={(value) => { setAsrLanguage(value); void setSetting('server.language', value) }}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getModeStatus, refreshModeStatus, subscribeModeStatus } from '@/stores/
|
||||
// 左下角的引擎指示里,服务器模式用的是信号图标而不是 Server——那里报的是"此刻通不通",
|
||||
// 有一条真在跑的连接可报;这张卡不做这种断言(连接状态由卡组右上角那枚徽标负责)。
|
||||
// 两处对本地 / 云 API 用同一个图标,对服务器模式刻意不同,因为它们说的不是同一件事。
|
||||
import { Cpu, Cloud, Server, Check, type LucideIcon } from 'lucide-react'
|
||||
import { Cpu, Cloud, Server, CheckCircle2, type LucideIcon } from 'lucide-react'
|
||||
import type { WorkMode } from '@/services/transcription'
|
||||
import type { TranslationKey } from '@/i18n'
|
||||
import { useT } from '@/i18n/useT'
|
||||
@@ -139,9 +139,14 @@ export default function WorkModeSection({ value, onChange }: Props) {
|
||||
aria-hidden
|
||||
/>
|
||||
{/* 选中态原来只有颜色(1px 边框 + 图标变色 + 5% 的淡底)。加一个对勾,
|
||||
让"当前是哪个"不依赖颜色感知 */}
|
||||
<div className="flex items-center gap-1 pr-7 text-sm font-medium">
|
||||
{isActive && <Check className="h-3.5 w-3.5 shrink-0 text-primary" aria-hidden />}
|
||||
让"当前是哪个"不依赖颜色感知。
|
||||
用 CheckCircle2 + success-strong 而不是裸 Check + primary,是为了和
|
||||
「语音识别服务」卡片上的「使用中」标记完全一致 —— 两页都是在选一张卡,
|
||||
同一个含义不该长成两个样子。别顺手把它统一回 primary。
|
||||
图标保持 aria-hidden:选中状态已经由外层 role="radio" 的 aria-checked
|
||||
如实报给读屏,再给图标加一个 label 只会让它念两遍。 */}
|
||||
<div className="flex items-center gap-1.5 pr-7 text-sm font-medium">
|
||||
{isActive && <CheckCircle2 className="h-4 w-4 shrink-0 text-success-strong" aria-hidden />}
|
||||
{t(m.labelKey)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t(m.descKey)}</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
ASR_PLATFORMS,
|
||||
ASR_PROVIDERS,
|
||||
asrAvailabilityLabel,
|
||||
describeAsrMissing,
|
||||
effectiveAsrCredentials,
|
||||
emptyAsrProfile,
|
||||
@@ -29,7 +30,17 @@ describe('ASR_PROVIDERS 结构性不变量', () => {
|
||||
expect(p.label.trim()).not.toBe('')
|
||||
expect(p.model.trim()).not.toBe('')
|
||||
expect(p.blurb.trim()).not.toBe('')
|
||||
expect(p.availability).toBe('mainland_china')
|
||||
expect(['mainland_china', 'global']).toContain(p.availability)
|
||||
}
|
||||
})
|
||||
|
||||
// 原来这里断言的是「每一家都是 mainland_china」。那在只有一个取值时看着像不变量,
|
||||
// 其实只是把当时的事实写死了 —— 加入海外供应商后它拦的不是 bug,是新功能。
|
||||
// 真正该守的是:每个 availability 都有对应的展示文案。少一个分支不会报错,
|
||||
// 只会让卡片上那行地区说明变成空白。
|
||||
it('每种地区都有对应的展示文案', () => {
|
||||
for (const p of ASR_PROVIDERS) {
|
||||
expect(asrAvailabilityLabel(p).trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ export const AI_PROVIDERS: AiProvider[] = [
|
||||
defaultUrl: 'https://api.xiaomimimo.com/v1',
|
||||
defaultModels: ['mimo-v2.5', 'mimo-v2.5-pro'],
|
||||
},
|
||||
{
|
||||
value: 'groq',
|
||||
// 品牌名,中英文都写 Groq,不需要 locale 键(同 DeepSeek 的处理)
|
||||
label: 'Groq',
|
||||
// 末段已经是 /v1,normalize_base_url 会原样保留、不再追加版本段
|
||||
defaultUrl: 'https://api.groq.com/openai/v1',
|
||||
defaultModels: ['openai/gpt-oss-20b', 'openai/gpt-oss-120b', 'llama-3.3-70b-versatile'],
|
||||
consoleUrl: 'https://console.groq.com/keys',
|
||||
},
|
||||
{
|
||||
value: 'openai_compat',
|
||||
get label() { return t('aiProvider.openaiCompat') },
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
/** 凭据归属的平台。同平台的服务用同一种密钥形态。 */
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export type AsrPlatform = 'doubao' | 'qwen' | 'mimo'
|
||||
export type AsrPlatform = 'doubao' | 'qwen' | 'mimo' | 'groq'
|
||||
|
||||
export interface AsrPlatformInfo {
|
||||
label: string
|
||||
@@ -34,6 +34,7 @@ export const ASR_PLATFORMS: Record<AsrPlatform, AsrPlatformInfo> = {
|
||||
doubao: { get label() { return t('asrPlatform.doubao') }, consoleUrl: 'https://console.volcengine.com/speech/app' },
|
||||
qwen: { get label() { return t('asrPlatform.qwen') }, consoleUrl: 'https://bailian.console.aliyun.com' },
|
||||
mimo: { get label() { return t('asrPlatform.mimo') }, consoleUrl: 'https://xiaoai.mi.com' },
|
||||
groq: { label: 'Groq', consoleUrl: 'https://console.groq.com/keys' },
|
||||
}
|
||||
|
||||
export interface AsrProviderEntry {
|
||||
@@ -46,8 +47,12 @@ export interface AsrProviderEntry {
|
||||
platform: AsrPlatform
|
||||
/** 一句定位,说清「什么时候选它」 */
|
||||
blurb: string
|
||||
/** 当前内置端点与控制台所面向的账号地区。 */
|
||||
availability: 'mainland_china'
|
||||
/**
|
||||
* 当前内置端点与控制台所面向的账号地区。
|
||||
* `global` 是海外端点(Groq),国内直连可能不稳定,卡片上要如实标出来 ——
|
||||
* 否则用户会把「连不上」当成自己密钥填错。
|
||||
*/
|
||||
availability: 'mainland_china' | 'global'
|
||||
/** 边说边出字(实时字幕) */
|
||||
streaming?: boolean
|
||||
/** 识别与 AI 整理一步完成,不需要单独配 AI 服务 */
|
||||
@@ -115,6 +120,14 @@ export const ASR_PROVIDERS: AsrProviderEntry[] = [
|
||||
availability: 'mainland_china',
|
||||
get blurb() { return t('asrProvider.mimoBlurb') },
|
||||
},
|
||||
{
|
||||
id: 'groq_whisper',
|
||||
get label() { return t('asrProvider.groq') },
|
||||
model: 'whisper-large-v3-turbo',
|
||||
platform: 'groq',
|
||||
availability: 'global',
|
||||
get blurb() { return t('asrProvider.groqBlurb') },
|
||||
},
|
||||
]
|
||||
|
||||
export function findAsrProvider(id: string): AsrProviderEntry | undefined {
|
||||
@@ -122,9 +135,11 @@ export function findAsrProvider(id: string): AsrProviderEntry | undefined {
|
||||
}
|
||||
|
||||
export function asrAvailabilityLabel(entry: AsrProviderEntry): string {
|
||||
return entry.availability === 'mainland_china'
|
||||
? t('asrProvider.regionMainlandChina')
|
||||
: ''
|
||||
switch (entry.availability) {
|
||||
case 'mainland_china': return t('asrProvider.regionMainlandChina')
|
||||
case 'global': return t('asrProvider.regionGlobal')
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一平台下有哪些可选服务 */
|
||||
|
||||
@@ -69,6 +69,7 @@ const HISTORY_FAILURE_KEYS: Record<HistoryFailReasonCode, TranslationKey> = {
|
||||
provider_timeout: 'err.provider.timeout',
|
||||
provider_unreachable: 'err.provider.unreachable',
|
||||
provider_bad_key: 'err.provider.badKey',
|
||||
provider_forbidden: 'err.provider.forbidden',
|
||||
provider_rate_limit: 'err.provider.rateLimit',
|
||||
provider_no_model: 'err.provider.noModel',
|
||||
provider_failed: 'record.providerFailed',
|
||||
|
||||
@@ -81,17 +81,18 @@
|
||||
"shortcut.error.invalidFormat": "That shortcut isn't valid. Record it again.",
|
||||
"shortcut.error.unsupportedKey": "{key} isn't supported yet. Try another combination.",
|
||||
"shortcut.error.metaAlone": "The Windows key can't be used on its own for push to talk. Add another modifier.",
|
||||
"shortcut.error.plainKeyAlone": "Letters, digits and navigation keys can't be used on their own. Combine them with Ctrl, Win, Alt or Shift.",
|
||||
"shortcut.error.plainKeyAlone": "Letters, digits and navigation keys can't be used on their own. Combine them with Ctrl, Win or Alt.",
|
||||
"shortcut.error.mouseCombo": "Mouse buttons can't be combined with keyboard keys yet. Use one on its own.",
|
||||
"shortcut.error.sameModifierFamily": "You can't use the left and right side of the same modifier together. Keep one.",
|
||||
"shortcut.error.tooManyMainKeys": "A combination can include only one non-modifier key",
|
||||
"shortcut.error.needModifier": "A regular combination needs at least one Ctrl, Win, Alt or Shift",
|
||||
"shortcut.error.needModifier": "A regular combination needs at least one Ctrl, Win or Alt",
|
||||
"shortcut.error.needTwoModifiers": "A modifier-only combination needs at least two keys",
|
||||
"shortcut.error.reservedCtrlAltDel": "Ctrl + Alt + Delete is reserved by Windows. Pick another one.",
|
||||
"shortcut.error.reservedWindows": "Windows-key combinations with a main key are reserved by the system. Use a modifier-only combination such as Ctrl + Win, or pick different keys.",
|
||||
"shortcut.error.reservedAlt": "That Alt system combination closes or switches windows, or opens a system menu. Pick another one.",
|
||||
"shortcut.error.reservedCtrlEsc": "That Ctrl + Esc system combination opens the Start menu or Task Manager. Pick another one.",
|
||||
"shortcut.warning.shiftRightFilterKeys": "Holding Right Shift for about 8 seconds may trigger Windows Filter Keys and prevent recording from stopping when you release it. Disable the Filter Keys shortcut or use another key such as Right Alt.",
|
||||
"shortcut.warning.shiftNoLongerSupported": "Hold to talk no longer accepts Shift: holding Right Shift for about 8 seconds triggers Windows Filter Keys, which stops the release from being registered. Your current setting still works, but Right Alt, Right Ctrl or Caps Lock is a better choice.",
|
||||
"shortcut.error.shiftReserved": "Shift can't be used for hold to talk: holding it triggers Windows Filter Keys and tapping it repeatedly triggers Sticky Keys. Use Right Alt, Right Ctrl, Caps Lock or F1-F12 instead.",
|
||||
"common.back": "Back",
|
||||
"common.next": "Next",
|
||||
"common.skip": "Skip",
|
||||
@@ -110,7 +111,7 @@
|
||||
"welcome.hotkeyConfirmed": "Hotkey confirmed. You're good to continue.",
|
||||
"welcome.hotkeyPrompt": "The default is {key} — press it to confirm, or press any other key to change it",
|
||||
"welcome.keyPosition": "Where it is",
|
||||
"welcome.supportedKeys": "Supported keys: left/right Alt, Ctrl and Shift, Space, Caps Lock, F1–F12",
|
||||
"welcome.supportedKeys": "Supported keys: left/right Alt and Ctrl, Space, Caps Lock, F1–F12",
|
||||
"welcome.changeHint": "To change it, just press another key",
|
||||
"welcome.tryTitle": "Give it a try",
|
||||
"welcome.handsFreeShortcut": "Hands-free shortcut",
|
||||
@@ -674,7 +675,6 @@
|
||||
"ai.keyPlaceholder": "Paste your API key",
|
||||
"ai.keyReused": "Reusing the URL and key from your previous {provider} config. Change the model as needed.",
|
||||
"ai.openConsole": "Open the {provider} console",
|
||||
"ai.howToGetKey": "How do I get a key? See the setup docs",
|
||||
"ai.model": "Model",
|
||||
"ai.modelPlaceholder": "model name",
|
||||
"ai.addAnother": "Add another",
|
||||
@@ -693,6 +693,7 @@
|
||||
"err.provider.timeout": "The provider took too long to respond. Try again shortly.",
|
||||
"err.provider.unreachable": "Couldn't reach the provider. Check your network, and whether you need a proxy to get there.",
|
||||
"err.provider.badKey": "The key was rejected. Check that it was copied in full with no stray spaces, and that the service is enabled in the provider's console.",
|
||||
"err.provider.forbidden": "The service refused the request (403) without pointing at the key. Usually that means it isn't offered in your region, or this account doesn't have the model enabled.",
|
||||
"err.provider.rateLimit": "The provider rate-limited you, or you're out of quota. Check your balance and call limits in their console.",
|
||||
"err.provider.noModel": "The provider says this model doesn't exist or isn't enabled for you. Pick another provider, or request access to the model in their console.",
|
||||
"err.download.network": "The download stopped — couldn't reach the source. Try another download source, or download manually.",
|
||||
@@ -733,7 +734,10 @@
|
||||
"asrProvider.omniFlashBlurb": "Same idea, faster and cheaper, slightly lower quality.",
|
||||
"asrProvider.mimo": "Xiaomi MiMo",
|
||||
"asrProvider.mimoBlurb": "An alternative. Needs a Xiaomi API key.",
|
||||
"asrProvider.groq": "Groq Whisper",
|
||||
"asrProvider.groqBlurb": "Multilingual transcription with fast responses. Requires access to the international internet.",
|
||||
"asrProvider.regionMainlandChina": "For mainland China accounts.",
|
||||
"asrProvider.regionGlobal": "For international accounts.",
|
||||
"asrProvider.missingKey": "no API key yet",
|
||||
"server.asrNotReady": "{prefix}. It responds, but speech recognition isn't ready on that server, so you can't dictate with it yet. Check whether the ASR model loaded on the server.",
|
||||
"server.noAi": "{prefix}. Connected. This server only does speech recognition, with AI cleanup off — you'll get the raw transcript, unpolished.",
|
||||
@@ -1053,14 +1057,15 @@
|
||||
"update.installingDesc": "SayIt will close automatically while the update is installed.",
|
||||
"update.doNotClose": "Don't close SayIt",
|
||||
"update.aboutTooltip": "About · v{version} is downloaded, open to install",
|
||||
"release.0.1.6.1": "When recognition produces no text, History now explains why, such as exhausted provider credit, an unavailable resource, or a dropped connection",
|
||||
"release.0.1.6.2": "Fixed Doubao sessions being reported as no speech when the server closed the connection midway through recognition",
|
||||
"release.0.1.6.3": "Fixed hold-to-talk ending after about 1.7 seconds when bound to a side or middle mouse button",
|
||||
"release.0.1.6.4": "Text insertion now tries another method when security software blocks the first one, instead of making you copy manually",
|
||||
"release.0.1.6.5": "AI and speech recognition services can now all be tested at once",
|
||||
"release.0.1.6.6": "Recognition and AI failures now include more diagnostic detail, making reported problems easier to investigate",
|
||||
"release.0.1.6.7": "History details now open with the Expand details button again instead of opening on hover",
|
||||
"release.0.1.6.8": "The processing overlay no longer shows the Press Esc to cancel hint; the shortcut itself still works",
|
||||
"release.0.1.7.1": "The interface is now available in English and follows your system language by default. You can also choose Auto / 中文 / English under Settings → General; this changes interface text only, not the language spoken recognition expects",
|
||||
"release.0.1.7.2": "Two English models were added to local recognition: Parakeet Unified EN, the fastest and most accurate option for English but English only, and Nemotron 3.5 ASR, which covers 32 languages for mixed-language speech",
|
||||
"release.0.1.7.3": "Added Groq, available for AI cleanup and as a speech recognition service: recognition uses whisper-large-v3-turbo, which is multilingual and fast. Both require access to the international internet",
|
||||
"release.0.1.7.4": "Updates no longer interrupt you: a new version downloads quietly in the background, then the About icon in the sidebar turns green so you can install it when convenient. Skipping it costs nothing either, since SayIt finishes the update the next time you close the app",
|
||||
"release.0.1.7.5": "Push-to-talk no longer accepts Shift, and the default key is now right Ctrl: holding Shift triggers the Windows Filter Keys shortcut, after which recording does not stop when you let go. Existing Shift bindings keep working",
|
||||
"release.0.1.7.6": "Fixed updates never arriving after you pointed the backend address at your own server, which is where the update check used to look",
|
||||
"release.0.1.7.7": "Fixed every provider 403 being reported as a rejected key: the more common causes are the service not being offered in your region, or the model not being enabled for your account",
|
||||
"release.0.1.7.8": "Dictation starts faster: the overlay is prepared shortly after launch, pressing the shortcut shows it before the target window is probed, and the two window scans were merged into one. Hands-free now switches on key down instead of waiting for the release",
|
||||
"release.0.1.7.9": "With AI cleanup off, half-width commas and question marks that follow a Chinese character are converted to their full-width form, so punctuation width stays consistent",
|
||||
"recorder.serverDisconnected": "The server isn't connected",
|
||||
"recorder.modelNotDownloaded": "Download the selected model in Settings first",
|
||||
"recorder.serviceNotReady": "The service isn't ready",
|
||||
|
||||
@@ -81,17 +81,18 @@
|
||||
"shortcut.error.invalidFormat": "快捷键格式无效,请重新录制",
|
||||
"shortcut.error.unsupportedKey": "暂不支持按键 {key},请换一个组合",
|
||||
"shortcut.error.metaAlone": "Windows 键不能单独用于按住说话,请再搭配一个修饰键",
|
||||
"shortcut.error.plainKeyAlone": "字母、数字或导航键不能单独占用,请搭配 Ctrl、Win、Alt 或 Shift",
|
||||
"shortcut.error.plainKeyAlone": "字母、数字或导航键不能单独占用,请搭配 Ctrl、Win 或 Alt",
|
||||
"shortcut.error.mouseCombo": "鼠标按键暂不支持与键盘组合,请单独使用",
|
||||
"shortcut.error.sameModifierFamily": "同一类修饰键不能同时使用左右两侧,请保留其中一个",
|
||||
"shortcut.error.tooManyMainKeys": "组合键最多只能包含一个非修饰键",
|
||||
"shortcut.error.needModifier": "普通组合键至少需要一个 Ctrl、Win、Alt 或 Shift",
|
||||
"shortcut.error.needModifier": "普通组合键至少需要一个 Ctrl、Win 或 Alt",
|
||||
"shortcut.error.needTwoModifiers": "纯修饰键组合至少需要两个按键",
|
||||
"shortcut.error.reservedCtrlAltDel": "Ctrl + Alt + Delete 是系统保留组合,请更换",
|
||||
"shortcut.error.reservedWindows": "带 Windows 键的系统组合由 Windows 保留,请改用 Ctrl + Win 这类纯修饰键组合或更换按键",
|
||||
"shortcut.error.reservedAlt": "该 Alt 系统组合会关闭、切换窗口或打开系统菜单,请更换",
|
||||
"shortcut.error.reservedCtrlEsc": "该 Ctrl + Esc 系统组合会打开开始菜单或任务管理器,请更换",
|
||||
"shortcut.warning.shiftRightFilterKeys": "长按右 Shift 约 8 秒可能触发 Windows「筛选键」,导致松开后录音无法立即结束。建议关闭筛选键快捷方式,或改用右 Alt 等按键。",
|
||||
"shortcut.warning.shiftNoLongerSupported": "「按住说话」已不再支持 Shift:长按右 Shift 约 8 秒会触发 Windows「筛选键」,导致松开后录音停不下来。当前设置仍可用,建议改用右 Alt、右 Ctrl 或 CapsLock。",
|
||||
"shortcut.error.shiftReserved": "Shift 不能用于「按住说话」:长按会触发 Windows 筛选键、连按会触发粘滞键。请改用右 Alt、右 Ctrl、CapsLock 或 F1–F12。",
|
||||
"common.back": "上一步",
|
||||
"common.next": "下一步",
|
||||
"common.skip": "跳过",
|
||||
@@ -110,7 +111,7 @@
|
||||
"welcome.hotkeyConfirmed": "热键已确认,你可以继续下一步",
|
||||
"welcome.hotkeyPrompt": "默认热键为 {key},按下即可确认;也可以按其他按键更换",
|
||||
"welcome.keyPosition": "按键位置",
|
||||
"welcome.supportedKeys": "支持的按键:左/右 Alt、左/右 Ctrl、左/右 Shift、空格、CapsLock、F1–F12",
|
||||
"welcome.supportedKeys": "支持的按键:左/右 Alt、左/右 Ctrl、空格、CapsLock、F1–F12",
|
||||
"welcome.changeHint": "如需更换,直接按下其他按键即可",
|
||||
"welcome.tryTitle": "试一试",
|
||||
"welcome.handsFreeShortcut": "免提快捷键",
|
||||
@@ -674,7 +675,6 @@
|
||||
"ai.keyPlaceholder": "粘贴 API Key",
|
||||
"ai.keyReused": "已沿用「{provider}」上一份配置的地址和密钥,改个模型名即可。",
|
||||
"ai.openConsole": "打开{provider}控制台",
|
||||
"ai.howToGetKey": "怎么申请密钥?看配置文档",
|
||||
"ai.model": "模型",
|
||||
"ai.modelPlaceholder": "模型名",
|
||||
"ai.addAnother": "再加一个",
|
||||
@@ -693,6 +693,7 @@
|
||||
"err.provider.timeout": "等待供应商响应超时,稍后再试。",
|
||||
"err.provider.unreachable": "连不上供应商的服务。检查本机网络,以及是否需要代理才能访问。",
|
||||
"err.provider.badKey": "密钥被拒绝。确认复制完整、没有多余空格,且对应的服务已在供应商控制台开通。",
|
||||
"err.provider.forbidden": "服务端拒绝了这次请求(403),且没有说是密钥的问题。常见原因是这家服务不向你所在的地区提供,或这个账号没有开通对应模型。",
|
||||
"err.provider.rateLimit": "被供应商限流或额度不足。检查控制台的余额与调用配额。",
|
||||
"err.provider.noModel": "供应商说这个模型不存在或未对你开通。换一个供应商,或到控制台申请该模型的权限。",
|
||||
"err.download.network": "下载中断,没能连上下载源。换一个下载源重试,或走手动下载。",
|
||||
@@ -733,7 +734,10 @@
|
||||
"asrProvider.omniFlashBlurb": "同上,更快更便宜,质量略低。",
|
||||
"asrProvider.mimo": "小米 MiMo",
|
||||
"asrProvider.mimoBlurb": "备选,需要小米的 API Key。",
|
||||
"asrProvider.groq": "Groq Whisper",
|
||||
"asrProvider.groqBlurb": "多语种转写,响应快;需要海外网络环境。",
|
||||
"asrProvider.regionMainlandChina": "面向中国大陆账号。",
|
||||
"asrProvider.regionGlobal": "面向海外账号。",
|
||||
"asrProvider.missingKey": "还没填 API Key",
|
||||
"server.asrNotReady": "{prefix},能连上,但这台服务器的语音识别没有就绪,现在还不能用来口述。检查服务端的 ASR 模型是否加载成功。",
|
||||
"server.noAi": "{prefix},连接成功。这台服务器只提供语音识别,没有开启 AI 整理——口述出来的是原始转写,不会润色。",
|
||||
@@ -1053,14 +1057,15 @@
|
||||
"update.installingDesc": "应用将自动关闭并完成安装,请稍候。",
|
||||
"update.doNotClose": "请勿关闭应用",
|
||||
"update.aboutTooltip": "关于 · 新版本 v{version} 已下载好,点进去安装",
|
||||
"release.0.1.6.1": "识别没有出字时,历史记录的详情里会说明原因,例如供应商额度用尽、资源未开通或连接中断",
|
||||
"release.0.1.6.2": "修复豆包在服务端中途断开连接时,本次识别被当成「没有检测到声音」的问题",
|
||||
"release.0.1.6.3": "修复用鼠标侧键或中键「按住说话」时,按住约 1.7 秒后录音会自动结束的问题",
|
||||
"release.0.1.6.4": "文本插入被安全软件拦截时会自动换一种方式,此前这种情况只能手动复制",
|
||||
"release.0.1.6.5": "AI 服务与语音识别服务支持一键测试全部,不必逐张点开",
|
||||
"release.0.1.6.6": "识别与 AI 服务失败时会记录更完整的诊断信息,反馈问题时更容易定位原因",
|
||||
"release.0.1.6.7": "历史记录的详情改回点「展开详情」按钮开合,不再鼠标悬停自动展开",
|
||||
"release.0.1.6.8": "悬浮窗在处理中不再显示「按 Esc 取消」那行小字,取消功能本身不变",
|
||||
"release.0.1.7.1": "界面支持英文,默认跟随系统语言,也可以在设置 → 通用里手动选「跟随系统 / 中文 / English」;这里只改界面文字,不影响语音识别的语种",
|
||||
"release.0.1.7.2": "本地识别新增两个英文模型:Parakeet Unified EN(英文里最快也最准,只支持英文)与 Nemotron 3.5 ASR(覆盖 32 个语种,适合多语言混着说)",
|
||||
"release.0.1.7.3": "新增 Groq,可用于「AI 整理」,也可作为语音识别服务:语音识别使用 whisper-large-v3-turbo,多语种转写、响应快;两项都需要海外网络环境",
|
||||
"release.0.1.7.4": "更新不再打断你:发现新版本后在后台静默下载,下载好之后侧栏「关于」图标会变绿提示,点进去按「立即安装」即可;不想现在装也不会漏掉,下次关闭 SayIt 时会自动装好",
|
||||
"release.0.1.7.5": "「按住说话」不再接受 Shift,默认键改为右 Ctrl:长按 Shift 会触发 Windows 筛选键,让松开手指后录音停不下来;已经绑了 Shift 的设置继续可用",
|
||||
"release.0.1.7.6": "修复把后端地址改成自建服务器后收不到更新的问题:此前检查更新会去问你填的那个地址",
|
||||
"release.0.1.7.7": "修复供应商返回 403 时一律被报成「密钥被拒绝」的问题:更常见的成因是这家服务不向你所在的地区提供,或这个账号没有开通对应模型",
|
||||
"release.0.1.7.8": "口述启动更快:悬浮窗在应用启动后就提前准备好,按下快捷键先出界面再探测目标窗口,对目标窗口的两次扫描也合并成一次;免提模式改为按下即生效,不再等到松开",
|
||||
"release.0.1.7.9": "关闭 AI 整理时,紧跟汉字的半角逗号、问号会自动改为全角,一段话里不再宽窄混用",
|
||||
"recorder.serverDisconnected": "服务器未连接",
|
||||
"recorder.modelNotDownloaded": "模型未下载,请先在设置里下载",
|
||||
"recorder.serviceNotReady": "服务未就绪",
|
||||
|
||||
@@ -67,6 +67,27 @@ describe('describeProviderError', () => {
|
||||
expect(describeProviderError(new Error('HTTP 429 rate limit')).message).toContain('限流')
|
||||
})
|
||||
|
||||
// 实测:Groq 在中国大陆 IP 上返回 403 {"error":{"message":"Forbidden"}},
|
||||
// 而假密钥、真密钥、完全不带鉴权头三种情况的响应**完全相同** —— 请求在边缘节点
|
||||
// 就被拒了,密钥从未被验证。归成「密钥被拒绝」会把用户引去反复重建密钥。
|
||||
it('403 不报成密钥问题,而是指出可能是地区或权限', () => {
|
||||
// 实际链路:Rust 已经分好类,前端只按错误码取文案
|
||||
const tagged = describeProviderError('sayit_error:provider_forbidden:API error 403 Forbidden [http=403]')
|
||||
expect(tagged.code).toBe('provider_forbidden')
|
||||
expect(tagged.message).not.toContain('密钥被拒绝')
|
||||
expect(tagged.action).toBe('switch_source')
|
||||
|
||||
// 退路:拿不到错误码时按文本分类,也不能落回「密钥被拒绝」。
|
||||
// 这里刻意用 `[http=403]` —— 那是 diag::http_summary 的格式,
|
||||
// 曾经因为正则认不出来而被归成"认不出的错误"。
|
||||
const raw = describeProviderError(new Error('API error 403 Forbidden [http=403]: {"error":{"message":"Forbidden"}}'))
|
||||
expect(raw.code).toBe('provider_forbidden')
|
||||
})
|
||||
|
||||
it('403 但服务端明确说密钥无效时,仍然算密钥问题', () => {
|
||||
expect(describeProviderError(new Error('HTTP 403: Invalid API key')).code).toBe('provider_bad_key')
|
||||
})
|
||||
|
||||
it('模型未开通给出换供应商的方向', () => {
|
||||
expect(describeProviderError(new Error('model not found')).message).toContain('模型')
|
||||
})
|
||||
|
||||
@@ -27,7 +27,10 @@ describe('PTT 物理组合键', () => {
|
||||
})
|
||||
|
||||
it('兼容旧单键,并接受普通组合与纯修饰组合', () => {
|
||||
expect(isValidPTTShortcut('ShiftRight')).toBe(true)
|
||||
// 这里原来用 ShiftRight 当旧单键的样例,现在 Shift 已被 PTT 禁用(见下方专门的用例),
|
||||
// 换成同样是"旧单键"的右 Alt / CapsLock
|
||||
expect(isValidPTTShortcut('AltRight')).toBe(true)
|
||||
expect(isValidPTTShortcut('CapsLock')).toBe(true)
|
||||
expect(isValidPTTShortcut('MButton')).toBe(true)
|
||||
expect(isValidPTTShortcut('ControlLeft+KeyK')).toBe(true)
|
||||
expect(isValidPTTShortcut('ControlLeft+MetaLeft')).toBe(true)
|
||||
@@ -48,11 +51,30 @@ describe('PTT 物理组合键', () => {
|
||||
expect(isValidPTTShortcut('ControlLeft+MetaLeft')).toBe(true)
|
||||
})
|
||||
|
||||
it('提示右 Shift 的筛选键风险,但仍允许保存', () => {
|
||||
expect(getPTTShortcutWarning('ShiftRight')).toContain('筛选键')
|
||||
expect(getPTTShortcutWarning('ControlLeft+ShiftRight')).toContain('筛选键')
|
||||
// 原来是「提示风险但仍允许保存」。警告挡不住任何人:用户照样绑了右 Shift,
|
||||
// 照样踩到筛选键让录音停不下来。现在改成硬拦。
|
||||
it('按住说话一律拒绝 Shift,单键和组合成员都算', () => {
|
||||
expect(isValidPTTShortcut('ShiftRight')).toBe(false)
|
||||
expect(isValidPTTShortcut('ShiftLeft')).toBe(false)
|
||||
expect(isValidPTTShortcut('ControlLeft+ShiftRight')).toBe(false)
|
||||
expect(isValidPTTShortcut('ShiftLeft+KeyK')).toBe(false)
|
||||
expect(getPTTShortcutValidationError('ShiftRight')).toContain('Shift')
|
||||
// 别的修饰键不受影响
|
||||
expect(isValidPTTShortcut('AltRight')).toBe(true)
|
||||
expect(isValidPTTShortcut('ControlLeft+KeyK')).toBe(true)
|
||||
})
|
||||
|
||||
// 老用户可能在 Shift 还能选的时候绑上了它:旧绑定继续生效,但要在设置里提示改绑。
|
||||
// 升级时静默换掉用户的说话键,或者让它突然失效,都比留一行提示更糟。
|
||||
it('已保存的 Shift 旧配置给出改绑提示', () => {
|
||||
expect(getPTTShortcutWarning('ShiftRight')).toContain('Shift')
|
||||
expect(getPTTShortcutWarning('ControlLeft+ShiftLeft')).toContain('Shift')
|
||||
expect(getPTTShortcutWarning('AltRight')).toBeNull()
|
||||
expect(isValidPTTShortcut('ShiftRight')).toBe(true)
|
||||
})
|
||||
|
||||
// Shift 只在「按住说话」里有问题(要长按、会连按)。免提这类按一下的快捷键照旧可用。
|
||||
it('按一下的快捷键不受 Shift 限制', () => {
|
||||
expect(getAcceleratorShortcutValidationError('CommandOrControl+Shift+K')).toBeNull()
|
||||
})
|
||||
|
||||
it('通用组合键同样拒绝 Windows 保留快捷键', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ const ASR_DISPLAY_MODEL_MAP: Record<string, string> = {
|
||||
doubao_v2: 'Doubao-Seed-ASR-2.0',
|
||||
qwen: 'qwen3-asr-flash',
|
||||
mimo: 'mimo-v2.5-asr',
|
||||
groq_whisper: 'whisper-large-v3-turbo',
|
||||
...QWEN_OMNI_MODEL_MAP,
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export type FriendlyErrorCode =
|
||||
| 'provider_timeout'
|
||||
| 'provider_unreachable'
|
||||
| 'provider_bad_key'
|
||||
| 'provider_forbidden'
|
||||
| 'provider_rate_limit'
|
||||
| 'provider_no_model'
|
||||
| 'download_network'
|
||||
@@ -57,6 +58,7 @@ const FRIENDLY_ERROR_CODES = new Set<FriendlyErrorCode>([
|
||||
'provider_timeout',
|
||||
'provider_unreachable',
|
||||
'provider_bad_key',
|
||||
'provider_forbidden',
|
||||
'provider_rate_limit',
|
||||
'provider_no_model',
|
||||
'download_network',
|
||||
@@ -85,9 +87,17 @@ function decodeError(error: unknown): DecodedError {
|
||||
: { code: null, text }
|
||||
}
|
||||
|
||||
/** 从各种异常文本里抠出 HTTP 状态码(`HTTP 401` / `status: 403` / `(404)`) */
|
||||
/**
|
||||
* 从各种异常文本里抠出 HTTP 状态码(`HTTP 401` / `status: 403` / `http=403` / `(404)`)。
|
||||
*
|
||||
* `http=403` 这一种是**我们自己**的格式:Rust 侧 `diag::http_summary` 写的就是
|
||||
* `[http=403 x-request-id=…]`。原来的正则把 `[:=]?` 只挂在 status 那个分支上,
|
||||
* 于是 `http=403` 认不出来 —— 我们自己产出的诊断串,自己的解析器读不懂。
|
||||
* 平时不显形是因为 Rust 会把稳定错误码一起带过来(走 stableCode 分支),
|
||||
* 只有拿不到错误码、退回按文本分类时才会暴露成"认不出的错误"。
|
||||
*/
|
||||
function extractHttpStatus(text: string): number | null {
|
||||
const match = text.match(/\b(?:HTTP|status(?:\s*code)?\s*[:=]?)\s*(\d{3})\b/i)
|
||||
const match = text.match(/\b(?:HTTP|status(?:\s*code)?)\s*[:=]?\s*(\d{3})\b/i)
|
||||
|| text.match(/\((\d{3})\)/)
|
||||
if (!match) return null
|
||||
const code = Number(match[1])
|
||||
@@ -175,7 +185,10 @@ export function describeProviderError(error: unknown): FriendlyError {
|
||||
action: 'retry',
|
||||
}
|
||||
}
|
||||
if (stableCode === 'provider_bad_key' || status === 401 || status === 403 || /invalid.*(key|token)|unauthorized|认证失败|鉴权/i.test(text)) { // i18n-allow: 匹配底层中文错误串
|
||||
// 401 才是明确的鉴权失败。403 单独一类(见下方分支):403 的常见成因是
|
||||
// 「这个请求根本不该到这儿」——CDN 按地区拒绝、账号没开通该模型,响应里
|
||||
// 一个字都不提密钥。把它报成密钥问题只会让用户反复重建密钥。
|
||||
if (stableCode === 'provider_bad_key' || status === 401 || /invalid.*(key|token)|unauthorized|认证失败|鉴权/i.test(text)) { // i18n-allow: 匹配底层中文错误串
|
||||
return {
|
||||
code: 'provider_bad_key',
|
||||
message: t('err.provider.badKey'),
|
||||
@@ -191,6 +204,17 @@ export function describeProviderError(error: unknown): FriendlyError {
|
||||
action: 'retry',
|
||||
}
|
||||
}
|
||||
// 排在限流之后:403 + quota 字样更可能是配额问题。
|
||||
// action 用 switch_source 而不是 retry —— 地区不可用重试一万次也是同一个结果,
|
||||
// 能解决问题的动作是换一家供应商。
|
||||
if (stableCode === 'provider_forbidden' || status === 403) {
|
||||
return {
|
||||
code: 'provider_forbidden',
|
||||
message: t('err.provider.forbidden'),
|
||||
detail: text,
|
||||
action: 'switch_source',
|
||||
}
|
||||
}
|
||||
if (stableCode === 'provider_no_model' || status === 404 || /model.*not.*(found|exist)/i.test(text)) {
|
||||
return {
|
||||
code: 'provider_no_model',
|
||||
|
||||
@@ -76,6 +76,29 @@ export const PTT_MODIFIER_CODES = [
|
||||
const PTT_MODIFIER_SET = new Set<string>(PTT_MODIFIER_CODES)
|
||||
const PTT_MOUSE_CODES = new Set(['XButton1', 'XButton2', 'MButton'])
|
||||
|
||||
/**
|
||||
* 不允许出现在「按住说话」里的按键。
|
||||
*
|
||||
* Shift 与 Windows 的辅助功能快捷方式直接冲突,而冲突的触发条件恰好就是 PTT 的
|
||||
* 使用方式:
|
||||
* · 长按右 Shift 约 8 秒 → 筛选键。它一旦弹出,我们收不到那次「松开」,
|
||||
* 录音不会随手指抬起而结束(这是之前只给警告、用户仍然踩到的那个问题)。
|
||||
* · 连按 Shift 五次 → 粘滞键。短句口述本来就是一串快速的按下松开。
|
||||
* PTT 必须被按住数秒、还经常被连续短按,两条正好全中。
|
||||
*
|
||||
* 所以 Shift 不做成"可以选但给个警告"——警告只能让用户在出问题之后回来读一遍,
|
||||
* 挡不住任何人。左右都禁:单键和组合成员都算,`Ctrl+右Shift` 一样会触发筛选键。
|
||||
*
|
||||
* **只约束 PTT。** 免提、预设切换那类"按一下"的快捷键(走 accelerator 那条校验)
|
||||
* 照旧可以用 Shift —— 它们不长按也不连按,两个触发条件都碰不到。
|
||||
*/
|
||||
const PTT_FORBIDDEN_CODES = new Set(['ShiftLeft', 'ShiftRight'])
|
||||
|
||||
/** 这个按键能不能用于「按住说话」。 */
|
||||
export function isPTTForbiddenCode(code: string): boolean {
|
||||
return PTT_FORBIDDEN_CODES.has(code)
|
||||
}
|
||||
|
||||
// i18n-allow-start: Windows VK 源标签;展示层由 getSingleKeyDisplay 翻译覆盖
|
||||
const PTT_EXTRA_KEY_DEFS: SingleKeyDef[] = [
|
||||
{ setting: 'MetaLeft', vk: 0x5b, label: '左 Win' },
|
||||
@@ -230,10 +253,19 @@ export function pttShortcutHasModifier(setting: string): boolean {
|
||||
return parsePTTShortcut(setting).some(isPTTModifierCode)
|
||||
}
|
||||
|
||||
/** 按住说话可用、但可能与 Windows 辅助功能冲突的配置提示。 */
|
||||
/**
|
||||
* 已保存的按住说话配置需要提醒用户的地方。
|
||||
*
|
||||
* 目前只有一种:老用户在 Shift 还能选的时候绑上了它。
|
||||
*
|
||||
* 为什么不在升级时自动改掉:那是在用户不知情的情况下换掉他的说话键,比留着更糟。
|
||||
* 也不在这里直接停掉它——那等于升级后按住说话突然没反应,而用户未必会想到来设置页。
|
||||
* 所以旧绑定继续生效,只是在设置里常驻一行提示让他换。新绑定由
|
||||
* getPTTShortcutValidationError 硬拦。
|
||||
*/
|
||||
export function getPTTShortcutWarning(setting: string): string | null {
|
||||
return parsePTTShortcut(canonicalizePTTShortcut(setting)).includes('ShiftRight')
|
||||
? t('shortcut.warning.shiftRightFilterKeys')
|
||||
return parsePTTShortcut(canonicalizePTTShortcut(setting)).some(isPTTForbiddenCode)
|
||||
? t('shortcut.warning.shiftNoLongerSupported')
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -283,8 +315,24 @@ export function getAcceleratorShortcutValidationError(accelerator: string): stri
|
||||
return null
|
||||
}
|
||||
|
||||
export interface PTTValidationOptions {
|
||||
/**
|
||||
* 是否放行「历史上曾经能选、现在已禁用」的按键(目前只有 Shift)。
|
||||
*
|
||||
* 读取**已保存的配置**时必须传 true。否则老用户的 Shift 绑定会被判成非法,
|
||||
* 而几个调用点碰到非法值的做法是回落到默认键 —— 用户一个设置都没改,
|
||||
* 说话键却自己变了,这比让他继续用 Shift 更糟。
|
||||
*
|
||||
* 校验**用户新输入**时保持 false(默认),这样 Shift 绑不上去。
|
||||
*/
|
||||
allowLegacyReservedKeys?: boolean
|
||||
}
|
||||
|
||||
/** 返回本地化的错误提示;null 表示可保存。 */
|
||||
export function getPTTShortcutValidationError(setting: string): string | null {
|
||||
export function getPTTShortcutValidationError(
|
||||
setting: string,
|
||||
options: PTTValidationOptions = {},
|
||||
): string | null {
|
||||
const rawCodes = setting.split('+').map((part) => part.trim())
|
||||
const codes = rawCodes.filter(Boolean)
|
||||
if (codes.length === 0) return t('shortcut.error.empty')
|
||||
@@ -295,6 +343,11 @@ export function getPTTShortcutValidationError(setting: string): string | null {
|
||||
const unsupported = codes.find((code) => !(code in PTT_CODE_TO_VK))
|
||||
if (unsupported) return t('shortcut.error.unsupportedKey', { key: unsupported })
|
||||
|
||||
// 排在单键/组合的分支之前:Shift 无论单独用还是当组合成员都不行
|
||||
if (!options.allowLegacyReservedKeys && codes.some(isPTTForbiddenCode)) {
|
||||
return t('shortcut.error.shiftReserved')
|
||||
}
|
||||
|
||||
if (codes.length === 1) {
|
||||
const code = codes[0]
|
||||
if (code === 'MetaLeft' || code === 'MetaRight') {
|
||||
@@ -343,8 +396,11 @@ export function getPTTShortcutValidationError(setting: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function isValidPTTShortcut(setting: string): boolean {
|
||||
return getPTTShortcutValidationError(setting) === null
|
||||
export function isValidPTTShortcut(
|
||||
setting: string,
|
||||
options: PTTValidationOptions = {},
|
||||
): boolean {
|
||||
return getPTTShortcutValidationError(setting, options) === null
|
||||
}
|
||||
|
||||
function pttMainCodeToAccelerator(code: string): string {
|
||||
|
||||
@@ -1,11 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
convertChineseNumbers,
|
||||
normalizeChinesePunctuation,
|
||||
restoreHotwordSpacing,
|
||||
stripTrailingPunctuation,
|
||||
replacePunctuationWithSpace,
|
||||
} from '../textPostProcess'
|
||||
|
||||
describe('normalizeChinesePunctuation', () => {
|
||||
// Whisper(Groq 的 whisper-large-v3-turbo)转写中文时混用两种宽度:句号给全角,
|
||||
// 逗号和问号给半角。实测改 prompt 无效,只能在文本层归一。
|
||||
it('汉字后面的半角标点转成全角', () => {
|
||||
expect(normalizeChinesePunctuation('今天天气不错,我们出去走走吧,顺便买点东西。你觉得怎么样?'))
|
||||
.toBe('今天天气不错,我们出去走走吧,顺便买点东西。你觉得怎么样?')
|
||||
expect(normalizeChinesePunctuation('太好了!')).toBe('太好了!')
|
||||
expect(normalizeChinesePunctuation('注意:两点;三点')).toBe('注意:两点;三点')
|
||||
})
|
||||
|
||||
// 下面这几条是这个函数存在的全部风险所在:判据写松一点就会破坏内容。
|
||||
it('数字里的逗号与小数点绝不动', () => {
|
||||
expect(normalizeChinesePunctuation('一共 3,000 元')).toBe('一共 3,000 元')
|
||||
expect(normalizeChinesePunctuation('圆周率是 3.14')).toBe('圆周率是 3.14')
|
||||
})
|
||||
|
||||
// 已知缺口,且是刻意留的:标点夹在「数字/字母」和汉字之间时不转。
|
||||
// 想覆盖 `100,很便宜` 就必然会把 `3:2中文`、`3,000元` 这类也一起改掉,
|
||||
// 而少一个全角逗号只是观感问题,改坏数字是内容损坏。宁可漏改。
|
||||
it('前一个字符不是汉字时一律不动,即便后面是中文', () => {
|
||||
expect(normalizeChinesePunctuation('版本 v1.2,已发布')).toBe('版本 v1.2,已发布')
|
||||
expect(normalizeChinesePunctuation('比例 3:2中文')).toBe('比例 3:2中文')
|
||||
})
|
||||
|
||||
it('英文句子里的标点不动', () => {
|
||||
expect(normalizeChinesePunctuation('Hello, world! Are you ok?'))
|
||||
.toBe('Hello, world! Are you ok?')
|
||||
expect(normalizeChinesePunctuation('中文A,B')).toBe('中文A,B')
|
||||
})
|
||||
|
||||
it('网址与句号一律不动(句号不在转换表里)', () => {
|
||||
expect(normalizeChinesePunctuation('访问 example.com 就行')).toBe('访问 example.com 就行')
|
||||
// 半角句号即便紧跟汉字也保持原样:小数/域名/文件名的代价高于收益
|
||||
expect(normalizeChinesePunctuation('好的.')).toBe('好的.')
|
||||
})
|
||||
|
||||
it('本来就是全角、或没有汉字时是空操作', () => {
|
||||
expect(normalizeChinesePunctuation('今天不错,我们走吧。')).toBe('今天不错,我们走吧。')
|
||||
expect(normalizeChinesePunctuation('')).toBe('')
|
||||
expect(normalizeChinesePunctuation(',开头的逗号没有前字符')).toBe(',开头的逗号没有前字符')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreHotwordSpacing', () => {
|
||||
it('还原被拆开加空格的热词', () => {
|
||||
expect(restoreHotwordSpacing('这是 Say It 输入法', ['SayIt'])).toBe('这是 SayIt 输入法')
|
||||
|
||||
@@ -123,6 +123,14 @@ export function getProbeResult() {
|
||||
return invoke<Record<string, unknown>>('get_probe_result')
|
||||
}
|
||||
|
||||
/** 一次原生捕获同时返回录音目标上下文与插字探测结果。 */
|
||||
export function getRecordingContext() {
|
||||
return invoke<{
|
||||
appContext: Record<string, unknown>
|
||||
probe: Record<string, unknown>
|
||||
}>('get_recording_context')
|
||||
}
|
||||
|
||||
export function getActiveAppContext() {
|
||||
return invoke<Record<string, unknown> | null>('get_active_app_context')
|
||||
}
|
||||
|
||||
@@ -21,7 +21,14 @@ export const DEFAULTS: Record<string, unknown> = {
|
||||
workMode: 'server', // 可选: 'server' | 'cloud_api' | 'local'
|
||||
|
||||
// ── 快捷键 ──
|
||||
shortcutPTT: 'ShiftRight', // 按住说话。旧单键保持 DOM code;组合键使用物理 code 格式,如 'ControlLeft+MetaLeft' 或 'ControlLeft+KeyK'
|
||||
// 按住说话。旧单键保持 DOM code;组合键使用物理 code 格式,如 'ControlLeft+MetaLeft' 或 'ControlLeft+KeyK'。
|
||||
// ⚠️ 默认键不能用 Shift:长按右 Shift 约 8 秒会触发 Windows 筛选键,导致松开后录音停不下来
|
||||
// (详见 lib/shortcutKeys.ts 的 PTT_FORBIDDEN_CODES)。这里曾经是 'ShiftRight',
|
||||
// 于是每台新装的机器开箱就绑在那个键上。右 Ctrl 位置相近、没有辅助功能陷阱,
|
||||
// 也不和免提的默认键(右 Alt)撞。
|
||||
// ⚠️ 改这里要同步 Rust:src-tauri/src/storage/mod.rs 的种子默认值、main.rs 的兜底、
|
||||
// keyboard/mod.rs 的 PttKeyConfig::fallback()。
|
||||
shortcutPTT: 'ControlRight',
|
||||
shortcutHandsFree: 'AltRight', // 免提模式。默认右 Alt 单键。也支持组合键格式如 'Control+Shift+S'
|
||||
|
||||
// ── 麦克风 ──
|
||||
|
||||
@@ -83,6 +83,7 @@ function classifyHistoryProviderFailure(message: string): HistoryFailReasonCode
|
||||
case 'provider_timeout':
|
||||
case 'provider_unreachable':
|
||||
case 'provider_bad_key':
|
||||
case 'provider_forbidden':
|
||||
case 'provider_rate_limit':
|
||||
case 'provider_no_model':
|
||||
return code
|
||||
@@ -282,11 +283,11 @@ export class RecorderOrchestrator {
|
||||
private notReadyMessage(): string {
|
||||
switch (this.provider.mode) {
|
||||
case 'server':
|
||||
return t('recorder.serverDisconnected')
|
||||
return t('recorder.serverDisconnected')
|
||||
case 'local':
|
||||
return t('recorder.modelNotDownloaded')
|
||||
return t('recorder.modelNotDownloaded')
|
||||
default:
|
||||
return t('recorder.serviceNotReady')
|
||||
return t('recorder.serviceNotReady')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +302,7 @@ export class RecorderOrchestrator {
|
||||
this.cachedPresets = presets
|
||||
}
|
||||
if (!target) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to switch cleanup preset: preset not found', { presetId })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to switch cleanup preset: preset not found', { presetId })
|
||||
return
|
||||
}
|
||||
// 立即生效:更新录音器缓存 + 通知 UI + 悬浮窗提示(均无需等待 IPC)
|
||||
@@ -357,13 +358,13 @@ export class RecorderOrchestrator {
|
||||
}
|
||||
if (mode === 'dismiss_fallback') {
|
||||
if (token === 0 || token !== this.activeFallbackToken) {
|
||||
addRuntimeEvent('info', 'recorder', 'Ignored stale Esc fallback dismissal', {
|
||||
addRuntimeEvent('info', 'recorder', 'Ignored stale Esc fallback dismissal', {
|
||||
token,
|
||||
activeFallbackToken: this.activeFallbackToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
addRuntimeEvent('info', 'recorder', 'Fallback card dismissed with Esc', { token })
|
||||
addRuntimeEvent('info', 'recorder', 'Fallback card dismissed with Esc', { token })
|
||||
this.activeFallbackToken = 0
|
||||
this.overlayService.hide()
|
||||
}
|
||||
@@ -446,7 +447,7 @@ export class RecorderOrchestrator {
|
||||
// 9-minute warning from Rust keyboard hook (PTT hold mode)
|
||||
bridge.onPTTTimeoutWarning(() => {
|
||||
if (this.state === 'recording') {
|
||||
addRuntimeEvent('warn', 'recorder', 'Recording is approaching the five-minute limit')
|
||||
addRuntimeEvent('warn', 'recorder', 'Recording is approaching the five-minute limit')
|
||||
this.overlayService.showTimeoutWarning()
|
||||
}
|
||||
})
|
||||
@@ -823,7 +824,7 @@ export class RecorderOrchestrator {
|
||||
const savedPath = await saveRecordingAudio(recordId, audioChunks)
|
||||
if (savedPath) historyArtifact.audioFilePath = savedPath
|
||||
} catch (err) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio for empty result', { error: String(err) })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio for empty result', { error: String(err) })
|
||||
}
|
||||
// 保存文件后必须重新验代;失效时同时清文件和可能已存在的记录。
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
@@ -845,8 +846,8 @@ export class RecorderOrchestrator {
|
||||
isEmpty: true,
|
||||
// 调用本身成功,只是没出字。这一句和上面的报错要能区分开:
|
||||
// 前者多半真没说话,后者是调用失败
|
||||
failReason: t('recorder.noTranscript'),
|
||||
failReasonCode: 'no_transcript',
|
||||
failReason: t('recorder.noTranscript'),
|
||||
failReasonCode: 'no_transcript',
|
||||
audioFilePath: historyArtifact.audioFilePath,
|
||||
...historyMeta,
|
||||
})
|
||||
@@ -860,7 +861,7 @@ export class RecorderOrchestrator {
|
||||
} catch (err) {
|
||||
if (historyArtifact) await this.discardCanceledHistory(historyArtifact)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write empty result to history', { error: String(err) })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write empty result to history', { error: String(err) })
|
||||
}
|
||||
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
@@ -882,7 +883,7 @@ export class RecorderOrchestrator {
|
||||
const lateContext = this.consumeTimedOutProcessingContext()
|
||||
if (!lateContext) return
|
||||
|
||||
addRuntimeEvent('warn', 'recorder', 'Received a late final result for a timed-out session', {
|
||||
addRuntimeEvent('warn', 'recorder', 'Received a late final result for a timed-out session', {
|
||||
timedOutAt: lateContext.timedOutAt,
|
||||
lateByMs: Date.now() - lateContext.timedOutAt,
|
||||
durationSec: result.durationSec,
|
||||
@@ -903,7 +904,7 @@ export class RecorderOrchestrator {
|
||||
return
|
||||
}
|
||||
if (this.finalHandledInCurrentRun) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Ignored duplicate final result')
|
||||
addRuntimeEvent('warn', 'recorder', 'Ignored duplicate final result')
|
||||
return
|
||||
}
|
||||
this.finalHandledInCurrentRun = true
|
||||
@@ -948,7 +949,7 @@ export class RecorderOrchestrator {
|
||||
onError: (msg) => {
|
||||
const runId = this.activeRunId
|
||||
if (!this.isRunCurrent(runId) || (this.state !== 'recording' && this.state !== 'processing')) {
|
||||
addRuntimeEvent('warn', 'backend', 'Ignored error callback from a stale session', { msg, state: this.state, runId })
|
||||
addRuntimeEvent('warn', 'backend', 'Ignored error callback from a stale session', { msg, state: this.state, runId })
|
||||
return
|
||||
}
|
||||
const friendlyFailure = describeProviderError(msg)
|
||||
@@ -991,7 +992,7 @@ export class RecorderOrchestrator {
|
||||
const savedPath = await saveRecordingAudio(recordId, audioChunks)
|
||||
if (savedPath) historyArtifact.audioFilePath = savedPath
|
||||
} catch (err) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio during error recovery', { error: String(err) })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio during error recovery', { error: String(err) })
|
||||
}
|
||||
if (!this.isRunCurrent(runId)) {
|
||||
await this.discardCanceledHistory(historyArtifact)
|
||||
@@ -1026,7 +1027,7 @@ export class RecorderOrchestrator {
|
||||
} catch (err) {
|
||||
if (historyArtifact) await this.discardCanceledHistory(historyArtifact)
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write error history entry', { error: String(err) })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write error history entry', { error: String(err) })
|
||||
}
|
||||
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
@@ -1239,22 +1240,18 @@ export class RecorderOrchestrator {
|
||||
this.pendingStopWhileStarting = false
|
||||
this.timedOutProcessingContext = null
|
||||
|
||||
// 先给用户即时视觉反馈;overlay 不聚焦,因此后续原生上下文捕获仍指向原目标窗口。
|
||||
// 首次 WebView 已在启动空闲期预热,正常情况下这里只剩一次轻量 show + emit。
|
||||
this.overlayService.showWaiting()
|
||||
|
||||
const targetCapture = captureActiveInsertionTarget(undefined, {
|
||||
preserveExistingOnFailure: true,
|
||||
})
|
||||
let activeAppContext: ActiveAppContext | null = null
|
||||
try {
|
||||
activeAppContext = await bridge.getActiveAppContext()
|
||||
} catch {
|
||||
activeAppContext = null
|
||||
}
|
||||
this.currentActiveAppContext = activeAppContext
|
||||
|
||||
// Capture probe result NOW (while the target window is still focused).
|
||||
// This is critical for hands-free mode where the user may switch windows
|
||||
// during recording. The probe captures hwnd/focusHwnd of the target.
|
||||
try {
|
||||
this.cachedProbeResult = await this.pasteService.getProbeResult()
|
||||
const recordingContext = await bridge.getRecordingContext()
|
||||
activeAppContext = recordingContext.appContext as unknown as ActiveAppContext
|
||||
this.cachedProbeResult = recordingContext.probe as unknown as ProbeResult
|
||||
addRuntimeEvent('info', 'recorder', 'Insertion probe cached at recording start', {
|
||||
probeId: this.cachedProbeResult.probeId,
|
||||
hwnd: this.cachedProbeResult.hwnd,
|
||||
@@ -1264,8 +1261,10 @@ export class RecorderOrchestrator {
|
||||
verdict: this.cachedProbeResult.verdict,
|
||||
})
|
||||
} catch {
|
||||
activeAppContext = null
|
||||
this.cachedProbeResult = null
|
||||
}
|
||||
this.currentActiveAppContext = activeAppContext
|
||||
|
||||
this.currentPromptResolution = resolvePromptRouting({
|
||||
appContext: activeAppContext,
|
||||
@@ -1301,10 +1300,6 @@ export class RecorderOrchestrator {
|
||||
summary: this.currentPromptResolution.summary,
|
||||
})
|
||||
|
||||
// Show overlay immediately in "waiting/preparing" state
|
||||
this.overlayService.clearFallbackHideTimer()
|
||||
this.overlayService.showWaiting()
|
||||
|
||||
this.finalHandledInCurrentRun = false
|
||||
this.audioSentSamples = 0
|
||||
this.wallTimeAtStopSec = 0
|
||||
@@ -1330,12 +1325,12 @@ export class RecorderOrchestrator {
|
||||
if (!this.handsFreeMode) return
|
||||
this.handsFreeAutoStopId = setTimeout(() => {
|
||||
if (this.state === 'recording' && this.handsFreeMode) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Hands-free recording is approaching the five-minute limit')
|
||||
addRuntimeEvent('warn', 'recorder', 'Hands-free recording is approaching the five-minute limit')
|
||||
this.overlayService.showTimeoutWarning()
|
||||
// Auto-stop after 1 more minute
|
||||
this.handsFreeAutoStopId = setTimeout(() => {
|
||||
if (this.state === 'recording' && this.handsFreeMode) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Hands-free recording reached five minutes and stopped automatically')
|
||||
addRuntimeEvent('warn', 'recorder', 'Hands-free recording reached five minutes and stopped automatically')
|
||||
void this.stopRecording()
|
||||
}
|
||||
}, RECORDING_COUNTDOWN_SEC * 1000)
|
||||
@@ -1379,7 +1374,7 @@ export class RecorderOrchestrator {
|
||||
(buffer) => {
|
||||
if (!this.isRunCurrent(runId)) return
|
||||
if (this.audioSentSamples === 0) {
|
||||
console.log('[ptt-diag] first onData buffer', {
|
||||
console.log('[ptt-diag] first onData buffer', {
|
||||
byteLength: buffer.byteLength,
|
||||
samples: buffer.byteLength / 2,
|
||||
})
|
||||
@@ -1725,7 +1720,7 @@ export class RecorderOrchestrator {
|
||||
const savedPath = await saveRecordingAudio(recordId, audioChunks)
|
||||
if (savedPath) audioFilePath = savedPath
|
||||
} catch (err) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio for timeout recovery', { error: String(err) })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio for timeout recovery', { error: String(err) })
|
||||
}
|
||||
}
|
||||
historyArtifact = { recordId, audioFilePath }
|
||||
@@ -1754,12 +1749,12 @@ export class RecorderOrchestrator {
|
||||
}
|
||||
void bridge.emit('history-updated')
|
||||
this.finishRun(timedOutCtx.runId)
|
||||
addRuntimeEvent('info', 'recorder', 'Timed-out recording saved to history for retry', { audioSec: timedOutCtx.audioDurationSec })
|
||||
addRuntimeEvent('info', 'recorder', 'Timed-out recording saved to history for retry', { audioSec: timedOutCtx.audioDurationSec })
|
||||
} catch (err) {
|
||||
if (historyArtifact) await this.discardCanceledHistory(historyArtifact)
|
||||
if (!this.isRunCurrent(timedOutCtx.runId)) return
|
||||
this.finishRun(timedOutCtx.runId)
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write timeout recovery history entry', { error: String(err) })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to write timeout recovery history entry', { error: String(err) })
|
||||
}
|
||||
})()
|
||||
}, LATE_FINAL_GRACE_MS)
|
||||
@@ -1808,7 +1803,7 @@ export class RecorderOrchestrator {
|
||||
return
|
||||
}
|
||||
|
||||
addRuntimeEvent('info', 'ptt', 'Ignored toggle request', {
|
||||
addRuntimeEvent('info', 'ptt', 'Ignored toggle request', {
|
||||
isHandsFree,
|
||||
ignoreReason: 'state_not_toggleable',
|
||||
recorderState: this.state,
|
||||
@@ -1876,7 +1871,7 @@ export class RecorderOrchestrator {
|
||||
const elapsedMs = Date.now() - this.lastPTTUpAt
|
||||
if (elapsedMs >= MODIFIER_PTT_RELEASE_GUARD_MS) return
|
||||
const waitMs = MODIFIER_PTT_RELEASE_GUARD_MS - elapsedMs
|
||||
addRuntimeEvent('info', 'recorder', 'Waiting for modifier keys to settle before inserting text', {
|
||||
addRuntimeEvent('info', 'recorder', 'Waiting for modifier keys to settle before inserting text', {
|
||||
waitMs,
|
||||
lastPTTUpAt: this.lastPTTUpAt,
|
||||
})
|
||||
@@ -1933,6 +1928,7 @@ export class RecorderOrchestrator {
|
||||
doubao_v2: 'Doubao-Seed-ASR-2.0',
|
||||
qwen: 'qwen3-asr-flash',
|
||||
mimo: 'mimo-v2.5-asr',
|
||||
groq_whisper: 'whisper-large-v3-turbo',
|
||||
qwen_omni_35_plus: 'qwen3.5-omni-plus-realtime',
|
||||
qwen_omni_35_flash: 'qwen3.5-omni-flash-realtime',
|
||||
qwen_omni_flash: 'qwen3-omni-flash-realtime',
|
||||
@@ -2038,7 +2034,7 @@ export class RecorderOrchestrator {
|
||||
const savedPath = await saveRecordingAudio(recordId, context.audioChunks)
|
||||
if (savedPath) historyArtifact.audioFilePath = savedPath
|
||||
} catch (err) {
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio file', { error: String(err) })
|
||||
addRuntimeEvent('warn', 'recorder', 'Failed to save audio file', { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2070,8 +2066,8 @@ export class RecorderOrchestrator {
|
||||
failReason: hasText
|
||||
? undefined
|
||||
: result.asrText?.trim()
|
||||
? t('recorder.emptyAfterProcessing')
|
||||
: t('recorder.noTranscript'),
|
||||
? t('recorder.emptyAfterProcessing')
|
||||
: t('recorder.noTranscript'),
|
||||
failReasonCode: hasText
|
||||
? undefined
|
||||
: result.asrText?.trim()
|
||||
|
||||
@@ -64,6 +64,7 @@ export type HistoryFailReasonCode =
|
||||
| 'provider_timeout'
|
||||
| 'provider_unreachable'
|
||||
| 'provider_bad_key'
|
||||
| 'provider_forbidden'
|
||||
| 'provider_rate_limit'
|
||||
| 'provider_no_model'
|
||||
| 'provider_failed'
|
||||
|
||||
@@ -273,6 +273,64 @@ export function restoreHotwordSpacing(text: string, hotwords: string[]): string
|
||||
return result
|
||||
}
|
||||
|
||||
// ── 中文标点宽度规范化 ──
|
||||
|
||||
/**
|
||||
* 半角 → 全角的对应表。
|
||||
*
|
||||
* **刻意不含句号 `.`**:它的歧义代价太高。小数(3.14)、版本号(v1.2)、域名
|
||||
* (example.com)、文件名里全是半角句点,一旦误转就是破坏内容;而实测
|
||||
* whisper-large-v3-turbo 的中文句号本来就输出全角 `。`,不需要我们兜。
|
||||
* 宁可漏一个也不能错改一个。
|
||||
*/
|
||||
// i18n-allow-start: 标点符号本身就是这个函数处理的数据
|
||||
const HALF_TO_FULL_WIDTH: Record<string, string> = {
|
||||
',': ',',
|
||||
'?': '?',
|
||||
'!': '!',
|
||||
';': ';',
|
||||
':': ':',
|
||||
}
|
||||
|
||||
/** 汉字区间。与 check-i18n.mjs 用的 CJK 判定保持一致。 */
|
||||
const CJK_CHAR = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/
|
||||
// i18n-allow-end
|
||||
|
||||
/**
|
||||
* 把紧跟在汉字后面的半角标点转成全角。
|
||||
*
|
||||
* 为什么需要:Whisper(Groq 的 whisper-large-v3-turbo)转写中文时**混用两种宽度** ——
|
||||
* 句号给全角 `。`,逗号和问号给半角 `,` `?`。实测四种 prompt 加不给 prompt 输出
|
||||
* 一字不差,所以这是模型自身行为,靠请求参数改不动,只能在文本层归一。
|
||||
*
|
||||
* 判据只有一条:**前一个字符是汉字**。这一条把所有危险场景挡在外面 ——
|
||||
* · `3,000` / `3.14`:前一个字符是数字,不动
|
||||
* · `Hello, world`:前一个字符是字母,不动
|
||||
* · `example.com` / `v1.2`:同上,且句号根本不在表里
|
||||
* · `中文A,B`:逗号前是 `A`,不动
|
||||
* 而 `今天不错,我们走吧` 里两个逗号前面都是汉字,转成全角。
|
||||
*
|
||||
* 已知缺口(刻意保留):标点夹在「数字/字母」和汉字之间时不转,例如
|
||||
* `一共100,很便宜` 里那个逗号会留半角。想覆盖它就必须放宽到「后一个字符是汉字」,
|
||||
* 而那会连 `比例3:2中文`、`3,000元` 一起改掉。少一个全角逗号只是观感问题,
|
||||
* 改坏数字是内容损坏 —— 宁可漏改。
|
||||
*
|
||||
* 中文供应商(豆包 / 千问 / MiMo)本来就输出全角,纯英文文本里没有汉字,
|
||||
* 所以对它们这个函数是彻底的空操作 —— 这也是它不需要做成开关的原因:
|
||||
* 它不是排版偏好,是把一种明确的错误宽度纠正过来。
|
||||
*/
|
||||
export function normalizeChinesePunctuation(text: string): string {
|
||||
if (!text) return text
|
||||
let result = ''
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]
|
||||
const full = HALF_TO_FULL_WIDTH[ch]
|
||||
// 回看用的是原文的前一个字符:转换不会改变「是不是汉字」,所以不必看已产出的结果
|
||||
result += full && CJK_CHAR.test(text[i - 1] ?? '') ? full : ch
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 去除每行末尾的标点符号(及尾随空白)。 */
|
||||
export function stripTrailingPunctuation(text: string): string {
|
||||
if (!text) return text
|
||||
@@ -344,7 +402,8 @@ export interface ApplyTransformsOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口。顺序:智能分段 → 数字规范化 → 用户替换规则 → 去句末标点 → 标点转空格。
|
||||
* 统一入口。顺序:中文标点宽度归一 → 智能分段 → 数字规范化 → 用户替换规则
|
||||
* → 去句末标点 → 标点转空格。
|
||||
* 其中除「用户替换规则」外都属于「格式规范」,仅在 rawAsr(无 AI 整理)时执行。
|
||||
*/
|
||||
export async function applyTextTransforms(
|
||||
@@ -356,6 +415,10 @@ export async function applyTextTransforms(
|
||||
// AI 整理过的文本,格式交给 AI;我们只保留文本替换。
|
||||
const ownFormat = options.rawAsr ?? true
|
||||
let result = text
|
||||
// 排在最前面:后面几步都在读标点做判断(分段找句末、去句末标点),
|
||||
// 让它们看到统一宽度的输入,比让每一步各自兼容两种宽度可靠。
|
||||
// 没有对应开关:它纠正的是一种明确的错误宽度,不是可选的排版偏好。
|
||||
if (ownFormat) result = normalizeChinesePunctuation(result)
|
||||
if (ownFormat && opts.autoSegment) result = segmentAsrText(result)
|
||||
if (ownFormat && opts.normalizeNumbers) result = convertChineseNumbers(result)
|
||||
result = await applyTextReplacements(result)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { getPTTPhysicalKeyStates } from './bridge'
|
||||
import { getSetting } from './store'
|
||||
import { getDefault } from './defaults'
|
||||
import {
|
||||
canonicalizePTTShortcut,
|
||||
isPTTModifierCode,
|
||||
@@ -180,9 +181,19 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// 免提键(HF):在 keydown 时阻止默认行为,keyup 时触发 toggle
|
||||
// 免提键(HF):首个 keydown 立即触发;后续 repeat down 不重复切换。
|
||||
if (hfCode && event.code === hfCode && !pttCodes.includes(hfCode)) {
|
||||
hfKeyDown = true
|
||||
if (!hfKeyDown) {
|
||||
hfKeyDown = true
|
||||
console.log('[webview-kb] toggle-hands-free (webview fallback)', {
|
||||
code: event.code,
|
||||
hfSetting,
|
||||
})
|
||||
void emit('toggle-hands-free', {
|
||||
source: 'webview_fallback',
|
||||
vk: SETTING_TO_VK[hfSetting] || 0,
|
||||
})
|
||||
}
|
||||
if (isModifierSetting(hfSetting)) event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -218,18 +229,10 @@ function handleKeyUp(event: KeyboardEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// 免提键:keyup 时 emit toggle-hands-free
|
||||
// 免提键:keyup 只重新布防,下一次物理按下才能再次切换。
|
||||
if (hfCode && event.code === hfCode && hfKeyDown && !pttCodes.includes(hfCode)) {
|
||||
hfKeyDown = false
|
||||
if (isModifierSetting(hfSetting)) event.preventDefault()
|
||||
console.log('[webview-kb] toggle-hands-free (webview fallback)', {
|
||||
code: event.code,
|
||||
hfSetting,
|
||||
})
|
||||
void emit('toggle-hands-free', {
|
||||
source: 'webview_fallback',
|
||||
vk: SETTING_TO_VK[hfSetting] || 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -257,22 +260,28 @@ function handleWindowBlur() {
|
||||
/** 刷新 PTT 设置(设置页面改键后调用) */
|
||||
export async function refreshPTTSetting() {
|
||||
try {
|
||||
const setting = await getSetting('shortcutPTT', 'ShiftRight')
|
||||
const loadedSetting = String(setting ?? 'ShiftRight')
|
||||
// 不传字面量兜底值:默认键只在 services/defaults.ts 里定义一处,getSetting 会去读它。
|
||||
const setting = await getSetting<string>('shortcutPTT')
|
||||
const loadedSetting = String(setting ?? '')
|
||||
if (!loadedSetting) {
|
||||
pttSetting = ''
|
||||
pttCodes = []
|
||||
} else {
|
||||
const canonical = canonicalizePTTShortcut(loadedSetting)
|
||||
pttSetting = isValidPTTShortcut(canonical) ? canonical : 'ShiftRight'
|
||||
// allowLegacyReservedKeys:老用户存的可能是 Shift。那类绑定不再允许新设,
|
||||
// 但已经存在的必须照原样监听 —— 判成非法就会走下面的回落,等于用户没改设置
|
||||
// 却换了说话键,而且会开始响应一个他没绑过的键。
|
||||
pttSetting = isValidPTTShortcut(canonical, { allowLegacyReservedKeys: true })
|
||||
? canonical
|
||||
: getDefault<string>('shortcutPTT', '')
|
||||
pttCodes = parsePTTShortcut(pttSetting)
|
||||
if (pttSetting !== canonical) {
|
||||
console.warn('[webview-kb] invalid PTT setting, using ShiftRight:', loadedSetting)
|
||||
console.warn('[webview-kb] invalid PTT setting, falling back to the default:', loadedSetting)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
pttSetting = 'ShiftRight'
|
||||
pttCodes = ['ShiftRight']
|
||||
pttSetting = getDefault<string>('shortcutPTT', '')
|
||||
pttCodes = parsePTTShortcut(pttSetting)
|
||||
console.warn('[webview-kb] failed to load PTT setting, using fallback:', error)
|
||||
}
|
||||
releasePTT('setting_refreshed')
|
||||
|
||||
@@ -65,6 +65,7 @@ function cloudProviderShort(provider: string): string {
|
||||
case 'qwen_omni_flash':
|
||||
case 'qwen_omni_turbo': return t('modeStatus.qwenOmni')
|
||||
case 'mimo': return 'MiMo'
|
||||
case 'groq_whisper': return 'Groq'
|
||||
default: return provider
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user