Files
openless/openless-all/app/src-tauri/src/coordinator.rs
T
TRIP 85d6ed851f Merge pull request #583 from Open-Less/fix/polish-context-isolation
fix(polish): 对话感知上下文——按风格包隔离 + 翻译会话参与润色(不串味)
2026-06-04 13:08:09 +08:00

5420 lines
207 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))]
//! Dictation coordinator.
//!
//! Mirrors the Swift `DictationCoordinator` state machine. Single owner of
//! session state. Receives hotkey edges, drives recorder + ASR + polish +
//! insertion, persists history, emits `capsule:state` events to the capsule
//! window.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Instant;
use chrono::Utc;
use ferrous_opencc::{config::BuiltinConfig, OpenCC};
use parking_lot::Mutex;
use tauri::{async_runtime, AppHandle, Emitter, Manager};
use uuid::Uuid;
#[cfg(target_os = "windows")]
use crate::asr::local::{
foundry, sherpa, FoundryLocalRuntime, FoundryLocalWhisperAsr, SherpaOnnxAsr, SherpaOnnxRuntime,
};
use crate::asr::{
BailianCredentials, BailianRealtimeASR, DictionaryHotword, RawTranscript,
VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR,
};
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor};
use crate::coordinator_state::{
begin_cancel_session_state, begin_recording_abort_before_restore, begin_session_state,
finish_cancel_session_state, finish_starting_session_state, new_session_id,
publish_abort_idle_after_restore, start_processing_if_listening, startup_race_status,
BeginOutcome, SessionId, SessionPhase, SessionState, StartupRaceStatus,
};
use crate::hotkey::{HotkeyEvent, HotkeyMonitor};
use crate::insertion::TextInserter;
use crate::persistence::{
sync_style_pack_preferences, CorrectionRuleStore, CredentialAccount, CredentialsVault,
DictionaryStore, HistoryStore, PreferencesStore, StylePackStore,
};
use crate::llm_gemini::{GeminiConfig, GeminiProvider};
use crate::polish::{
ActiveLLMProvider, CodexOAuthConfig, CodexOAuthLLMProvider, OpenAICompatibleConfig,
OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID,
};
use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
use crate::recorder::{Recorder, RecorderError};
use crate::selection::capture_selection;
#[cfg(target_os = "windows")]
use crate::types::PasteShortcut;
use crate::types::{
CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability,
HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode,
};
#[cfg(target_os = "windows")]
use crate::windows_ime_ipc::ImeSubmitTarget;
#[cfg(target_os = "windows")]
use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController};
mod dictation;
mod qa;
mod resources;
#[cfg(test)]
use dictation::dictation_error_code;
use dictation::{
begin_session, cancel_session, end_session, handle_pressed, handle_pressed_edge,
handle_released, handle_released_edge, request_stop_during_starting,
};
use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState};
#[cfg(test)]
use resources::discard_startup_resources_for_session;
use resources::{
acquire_recording_mute, cancel_active_asr, release_recording_mute,
selected_microphone_device_name, stop_microphone_preview_monitor, stop_qa_recorder,
SessionResource, SharedRecordingMuteState,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CapsuleShowStrategy {
NoActivate,
FallbackShow,
}
fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy {
// ⚠️ 如果改下面的 cfg 列表,**必须**同步更新单元测试
// `capsule_show_strategy_matches_platform_activation_contract` 的两组 cfg —
// 否则 Linux CI 直接红(PR #451 即是这种漏改)。
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
CapsuleShowStrategy::NoActivate
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
CapsuleShowStrategy::FallbackShow
}
}
static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false);
static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false);
/// 给 #470 诊断日志用的 capsule 状态短名。显式枚举每个变体到 &'static str,
/// 不走 `Debug` —— 哪天 CapsuleState 加了 `String` 字段,`:?` 会把 ASR / polish
/// 内容意外灌进日志(pr_agent 提的 forward-looking 隐患);这里只输出状态名。
fn capsule_state_log_name(state: CapsuleState) -> &'static str {
match state {
CapsuleState::Idle => "idle",
CapsuleState::Recording => "recording",
CapsuleState::Transcribing => "transcribing",
CapsuleState::Polishing => "polishing",
CapsuleState::Done => "done",
CapsuleState::Cancelled => "cancelled",
CapsuleState::Error => "error",
}
}
fn show_capsule_window_for_recording<R: tauri::Runtime>(
app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) {
let mut needs_fallback = true;
if capsule_show_strategy_for_platform() == CapsuleShowStrategy::NoActivate {
needs_fallback = !show_capsule_window_no_activate(app, window);
if needs_fallback && !CAPSULE_NO_ACTIVATE_FALLBACK_WARNED.swap(true, Ordering::SeqCst) {
// 产品取舍:no-activate 是 macOS/AeroSpace 的主路径;但如果 ns_window
// 暂不可用,仍优先保住录音反馈,不让用户以为听写没启动。fallback 可能
// 重新触发 workspace 跳转,只在 no-activate 失败时作为降级路径。
log::warn!("[capsule] no-activate show failed; falling back to window.show()");
}
}
if needs_fallback {
if let Err(e) = window.show() {
log::warn!("[capsule] show fallback failed: {e}");
}
}
}
#[derive(Clone)]
enum ActiveAsr {
Volcengine(Arc<VolcengineStreamingASR>),
Whisper(Arc<WhisperBatchASR>),
Bailian(Arc<BailianRealtimeASR>),
#[cfg(target_os = "windows")]
FoundryLocalWhisper(Arc<FoundryLocalWhisperAsr>),
/// Windows sherpa-onnx 本地 ASR(offline batch + 实验 online streaming)。
#[cfg(target_os = "windows")]
SherpaOnnxLocal(Arc<SherpaOnnxAsr>),
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
#[cfg(target_os = "macos")]
Local(Arc<crate::asr::local::LocalQwenAsr>),
}
fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool {
match asr {
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(_) => false,
// sherpa-onnx 首次加载 / 下载 / 推理的耗时类似 Foundry,不走
// COORDINATOR_GLOBAL_TIMEOUT;各 provider 自己里面控制細粒度超时。
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(_) => false,
_ => true,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActiveAsrProviderKind {
Bailian,
WhisperCompatible,
Volcengine,
}
fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind {
if is_bailian_provider(id) {
ActiveAsrProviderKind::Bailian
} else if is_whisper_compatible_provider(id) {
ActiveAsrProviderKind::WhisperCompatible
} else {
ActiveAsrProviderKind::Volcengine
}
}
fn batch_asr_chunk_limit_ms(provider_id: &str) -> Option<u64> {
match provider_id {
"zhipu" => Some(30_000),
_ => None,
}
}
pub struct Coordinator {
inner: Arc<Inner>,
}
struct Inner {
app: Mutex<Option<AppHandle>>,
history: HistoryStore,
prefs: PreferencesStore,
style_packs: StylePackStore,
vocab: DictionaryStore,
correction_rules: CorrectionRuleStore,
inserter: TextInserter,
#[cfg(target_os = "windows")]
windows_ime: WindowsImeSessionController,
#[cfg(target_os = "windows")]
prepared_windows_ime_session: Arc<Mutex<Vec<PreparedWindowsImeSessionSlot>>>,
state: Mutex<SessionState>,
asr: Mutex<Option<SessionResource<ActiveAsr>>>,
/// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
#[cfg(target_os = "windows")]
foundry_local_runtime: Arc<FoundryLocalRuntime>,
/// Windows sherpa-onnx 本地 ASR runtime。与 Foundry 同处一个
/// 位置、同一 lifecycle 语义;上层通过 `ActiveAsr::SherpaOnnxLocal` 后只调
/// runtime,不会跨模块调。
#[cfg(target_os = "windows")]
sherpa_onnx_runtime: Arc<SherpaOnnxRuntime>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
/// 当前 dictation / QA session 的 wav 归档是否真的被写到磁盘上。
/// 由 Recorder::start 返回值 (archive_active) 写入;history.append 路径读取,
/// 决定 DictationSession.has_audio_recording 字段。比单纯读 prefs.record_audio_for_debug
/// 更准确:用户开了开关但路径无法创建(权限 / 磁盘满)也算 false。
audio_archive_active: AtomicBool,
recording_mute: Mutex<SharedRecordingMuteState>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
hotkey_trigger_held: AtomicBool,
/// 防抖时间戳:handle_pressed_edge 入口检查与本字段的距离,< 250ms 的边沿直接
/// 丢弃(误触双击 / 微动开关回弹 / 用户连点过快造成的空转写报错)。
/// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防
/// press-release-press 三连过快。
last_hotkey_dispatch_at: Mutex<Option<std::time::Instant>>,
/// end_session 成功收尾后将 phase 设为 Idle 时记录的时间戳 + POST_SESSION_COOLDOWN_MS。
/// handle_pressed 在 (Toggle, Idle) 分支检查此字段:未过期则忽略该次按键,
/// 防止胶囊离场动画期间误激活新听写(issue #545)。
session_cooldown_until: Mutex<Option<std::time::Instant>>,
shortcut_recording_active: AtomicBool,
/// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
translation_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
switch_style_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
open_app_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
/// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在
/// Listening / Starting 阶段看到 Shift down 边沿时 set true。
/// end_session 在调 polish/translate 前读这个 flag + translation_target_language
/// 决定走哪条管线。详见 issue #4。
translation_modifier_seen: AtomicBool,
/// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键
/// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。
qa_hotkey: Mutex<Option<QaHotkeyMonitor>>,
/// QA 单独的 session 状态,与 dictation 的 SessionPhase 不冲突。
qa_state: Mutex<QaSessionState>,
/// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发
/// resize / reposition。
capsule_layout: Mutex<Option<CapsuleLayoutState>>,
/// QA 用的 ASR 句柄。必须跟 active_asr_provider 保持一致,避免浮窗走不同入口。
qa_asr: Mutex<Option<ActiveAsr>>,
/// QA 用的 Recorder 句柄。
qa_recorder: Mutex<Option<Recorder>>,
/// QA SSE 流取消标志。begin_qa_session 重置为 false;cancel_qa_session 设 true;
/// polish::chat_completion_history_streaming 的 loop 每帧检查,true 时 break loop
/// 避免取消后 LLM 仍 drain HTTP body 烧 token。详见 issue #161。
qa_stream_cancelled: Arc<AtomicBool>,
/// Coordinator 退出信号。各 hotkey supervisor loop 在每轮重试 sleep 之前会检查
/// 此 flag;为 true 时 loop 立刻 return。生产场景里 process exit 一并 reap 所有
/// supervisor 线程,但 integration test 和未来 RunEvent::Exit 钩子需要这条
/// 显式退出路径。审计 3.1.2。
shutdown: AtomicBool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActionHotkeyKind {
SwitchStyle,
OpenApp,
}
#[cfg(target_os = "windows")]
#[derive(Debug)]
struct PreparedWindowsImeSessionSlot {
session_id: SessionId,
prepared: PreparedWindowsImeSession,
}
impl Coordinator {
pub fn new() -> Self {
#[cfg(target_os = "windows")]
{
Self::new_with_local_runtimes(
Arc::new(FoundryLocalRuntime::new()),
Arc::new(SherpaOnnxRuntime::new()),
)
}
#[cfg(not(target_os = "windows"))]
{
let history = HistoryStore::new().unwrap_or_else(|e| {
log::error!("[coord] HistoryStore init failed: {e}; falling back to empty");
HistoryStore::new().expect("history store init")
});
let prefs = PreferencesStore::new().expect("preferences store init");
let style_packs = StylePackStore::new(&prefs).expect("style pack store init");
let vocab = DictionaryStore::new().expect("dictionary store init");
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
Self {
inner: Arc::new(Inner {
app: Mutex::new(None),
history,
prefs,
style_packs,
vocab,
correction_rules,
inserter: TextInserter::new(),
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
recorder: Mutex::new(None),
audio_archive_active: AtomicBool::new(false),
recording_mute: Mutex::new(SharedRecordingMuteState::new()),
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
last_hotkey_dispatch_at: Mutex::new(None),
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
translation_modifier_seen: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
qa_asr: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
shutdown: AtomicBool::new(false),
}),
}
}
}
/// 保留旧构造函数:现有调用点(含单元测试)只传 Foundry runtime。
/// sherpa-onnx runtime 这里创建默认 offline batch 实例;入产后(lib.rs)请走
/// `new_with_local_runtimes`,确保 Tauri State 共享同一个 Arc。
#[cfg(target_os = "windows")]
pub fn new_with_foundry_runtime(foundry_local_runtime: Arc<FoundryLocalRuntime>) -> Self {
Self::new_with_local_runtimes(foundry_local_runtime, Arc::new(SherpaOnnxRuntime::new()))
}
#[cfg(target_os = "windows")]
pub fn new_with_local_runtimes(
foundry_local_runtime: Arc<FoundryLocalRuntime>,
sherpa_onnx_runtime: Arc<SherpaOnnxRuntime>,
) -> Self {
let history = HistoryStore::new().unwrap_or_else(|e| {
log::error!("[coord] HistoryStore init failed: {e}; falling back to empty");
HistoryStore::new().expect("history store init")
});
let prefs = PreferencesStore::new().expect("preferences store init");
let style_packs = StylePackStore::new(&prefs).expect("style pack store init");
let vocab = DictionaryStore::new().expect("dictionary store init");
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
Self {
inner: Arc::new(Inner {
app: Mutex::new(None),
history,
prefs,
style_packs,
vocab,
correction_rules,
inserter: TextInserter::new(),
windows_ime: WindowsImeSessionController::new(),
prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())),
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
recorder: Mutex::new(None),
audio_archive_active: AtomicBool::new(false),
recording_mute: Mutex::new(SharedRecordingMuteState::new()),
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
last_hotkey_dispatch_at: Mutex::new(None),
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
translation_modifier_seen: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
qa_asr: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
foundry_local_runtime,
sherpa_onnx_runtime,
shutdown: AtomicBool::new(false),
}),
}
}
/// 后台预加载本地 ASR 引擎;当用户在 UI 切到 local-qwen3 provider 时调一次。
/// 加载是阻塞且数秒,所以放 spawn_blocking 里,不影响 UI 响应。
/// 模型未下载或不在 macOS 上时静默跳过。
pub fn preload_local_asr_in_background(self: &Arc<Self>) {
#[cfg(target_os = "macos")]
{
let inner = Arc::clone(&self.inner);
tauri::async_runtime::spawn(async move {
let prefs = inner.prefs.get();
let model_id =
match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) {
Some(m) => m,
None => return,
};
if !crate::asr::local::models::is_downloaded(model_id) {
log::info!(
"[coord] local ASR preload skipped: model {} not downloaded",
model_id.as_str()
);
return;
}
let dir = match crate::asr::local::models::model_dir(model_id) {
Ok(d) => d,
Err(_) => return,
};
let cache = Arc::clone(&inner.local_asr_cache);
let mid = model_id.as_str().to_string();
let _ = tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = cache.get_or_load(&mid, &dir) {
log::warn!("[coord] local ASR preload failed: {e:#}");
}
})
.await;
});
}
#[cfg(not(target_os = "macos"))]
{
// no-op
}
}
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
pub fn release_local_asr_engine(&self) {
self.inner.local_asr_cache.release_now();
}
pub fn local_asr_loaded_model(&self) -> Option<String> {
self.inner.local_asr_cache.loaded_model_id()
}
pub fn bind_app(&self, handle: AppHandle) {
*self.inner.app.lock() = Some(handle);
}
/// 让所有 hotkey supervisor loop(dictation / qa / combo / translation /
/// switch_style / open_app)在下一轮 sleep / poll 后退出。生产场景下进程退出
/// 一并 reap 所有线程,但 integration test 和未来 RunEvent::Exit 钩子需要
/// 显式退出路径。审计 3.1.2。
#[allow(dead_code)]
pub fn request_shutdown(&self) {
self.inner.shutdown.store(true, Ordering::SeqCst);
}
pub fn start_hotkey_listener(&self) {
// 起一个守护线程,反复尝试安装 hotkey hook。Accessibility 一被授予就立即生效,
// 用户不需要手动重启 OpenLess。
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-hotkey-supervisor".into())
.spawn(move || hotkey_supervisor_loop(inner))
.ok();
}
pub fn stop_hotkey_listener(&self) {
self.inner.hotkey.lock().take();
}
/// 启动 QA hotkey supervisor(issue #118)。和 `start_hotkey_listener` 平行:
/// 守护线程反复尝试注册(用户可能改了组合键),失败则 3s 后重试。
pub fn start_qa_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-qa-hotkey-supervisor".into())
.spawn(move || qa_hotkey_supervisor_loop(inner))
.ok();
}
pub fn stop_qa_hotkey_listener(&self) {
// QaHotkeyMonitor::drop 在 macOS 底层是 Carbon RemoveEventHotKey,要求主线程。
// RunEvent::Exit 回调不保证在 AppKit 主线程跑,drop 漏到 tokio worker 上会
// 触发 macOS dispatch_assert_queue_fail SIGTRAP。包到 run_on_main_thread 让
// drop 在主线程发生;AppHandle 已 None 时直接 drop(最坏 crash 也是退出时刻)。
// 详见 issue #169。
let app = self.inner.app.lock().clone();
if let Some(app) = app {
let inner = Arc::clone(&self.inner);
let _ = app.run_on_main_thread(move || {
inner.qa_hotkey.lock().take();
});
} else {
self.inner.qa_hotkey.lock().take();
}
}
/// 启动自定义组合键监听器。当 `prefs.hotkey.trigger == Custom` 时,
/// 代替 modifier-only 的 hotkey monitor。
pub fn start_combo_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-combo-hotkey-supervisor".into())
.spawn(move || combo_hotkey_supervisor_loop(inner))
.ok();
}
pub fn stop_combo_hotkey_listener(&self) {
take_combo_hotkey_on_main_thread(&self.inner);
}
pub fn start_translation_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-translation-hotkey-supervisor".into())
.spawn(move || translation_hotkey_supervisor_loop(inner))
.ok();
}
pub fn stop_translation_hotkey_listener(&self) {
take_translation_hotkey_on_main_thread(&self.inner);
}
pub fn start_switch_style_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-switch-style-hotkey-supervisor".into())
.spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::SwitchStyle))
.ok();
}
pub fn stop_switch_style_hotkey_listener(&self) {
take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::SwitchStyle);
}
pub fn start_open_app_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-open-app-hotkey-supervisor".into())
.spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::OpenApp))
.ok();
}
pub fn stop_open_app_hotkey_listener(&self) {
take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::OpenApp);
}
/// 用户在设置里改了自定义组合键时调用。
pub fn update_combo_hotkey_binding(&self) {
let prefs = self.inner.prefs.get();
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
// 修饰键单键由 HotkeyMonitor 处理,组合键 monitor 要释放。
take_combo_hotkey_on_main_thread(&self.inner);
log::info!("[coord] combo hotkey 已关闭(modifier-only)");
return;
}
let binding = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
// Custom 但没录到有效主键:清掉旧 monitor,避免旧快捷键继续生效。
take_combo_hotkey_on_main_thread(&self.inner);
log::info!("[coord] combo hotkey 已关闭(无绑定)");
return;
};
let app = self.inner.app.lock().clone();
let Some(app) = app else {
log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过");
return;
};
let inner_clone = Arc::clone(&self.inner);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
if let Some(monitor) = inner_clone.combo_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(binding_for_main.clone()) {
log::warn!("[coord] update combo hotkey binding 失败: {e}");
}
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match ComboHotkeyMonitor::start(binding_for_main, tx) {
Ok(monitor) => {
*inner_clone.combo_hotkey.lock() = Some(monitor);
log::info!(
"[coord] combo hotkey listener installed on main thread (via update)"
);
let bridge_inner = Arc::clone(&inner_clone);
std::thread::Builder::new()
.name("openless-combo-hotkey-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
.ok();
#[cfg(target_os = "linux")]
sync_custom_dictation_to_plugin(&inner_clone);
}
Err(e) => {
log::warn!("[coord] update combo hotkey binding 失败: {e}");
}
}
});
}
/// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成),
/// 然后通知活着的 monitor 重新注册;monitor 不存在时 supervisor 会自然
/// 在下一次循环里读到新的 prefs。
pub fn update_qa_hotkey_binding(&self) {
let prefs = self.inner.prefs.get();
let Some(binding) = prefs.qa_hotkey.clone() else {
// 用户把功能关了 → 直接 drop monitor。drop 也得在主线程,否则 Carbon
// unregister 会失败/UB。
let app = self.inner.app.lock().clone();
if let Some(app) = app {
let inner_clone = Arc::clone(&self.inner);
let _ = app.run_on_main_thread(move || {
inner_clone.qa_hotkey.lock().take();
});
} else {
self.inner.qa_hotkey.lock().take();
}
log::info!("[coord] QA hotkey 已关闭");
self.update_modifier_shortcut_bindings();
return;
};
if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() {
let app = self.inner.app.lock().clone();
if let Some(app) = app {
let inner_clone = Arc::clone(&self.inner);
let _ = app.run_on_main_thread(move || {
inner_clone.qa_hotkey.lock().take();
});
} else {
self.inner.qa_hotkey.lock().take();
}
self.update_modifier_shortcut_bindings();
log::info!("[coord] QA hotkey uses modifier-only listener");
return;
}
self.update_modifier_shortcut_bindings();
// global-hotkey crate 的 manager.register/unregister 必须主线程跑。
// 没在主线程会让 Carbon 句柄注册看似成功但事件不派发。
let app = self.inner.app.lock().clone();
let Some(app) = app else {
log::warn!("[coord] update QA hotkey binding: AppHandle 未 bind,跳过");
return;
};
let inner_clone = Arc::clone(&self.inner);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
// 路径 1:当前已有 monitor → 在主线程换绑定。
if let Some(monitor) = inner_clone.qa_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(binding_for_main.clone()) {
log::warn!("[coord] update QA hotkey binding 失败: {e}");
}
return;
}
// 路径 2:之前还没装上 → 主线程上重装一次(supervisor 也会重试,
// 但用户体感更快:set_qa_hotkey 命令一返回,hotkey 立即生效)。
let (tx, rx) = mpsc::channel::<QaHotkeyEvent>();
match QaHotkeyMonitor::start(binding_for_main, tx) {
Ok(monitor) => {
*inner_clone.qa_hotkey.lock() = Some(monitor);
log::info!("[coord] QA hotkey listener installed on main thread (via update)");
let bridge_inner = Arc::clone(&inner_clone);
std::thread::Builder::new()
.name("openless-qa-hotkey-bridge".into())
.spawn(move || qa_hotkey_bridge_loop(bridge_inner, rx))
.ok();
}
Err(e) => {
log::warn!("[coord] update QA hotkey binding 失败: {e}");
}
}
});
}
pub fn update_translation_hotkey_binding(&self) {
if let Err(e) = self.try_update_translation_hotkey_binding() {
log::warn!("[coord] update translation hotkey binding 失败: {e}");
}
}
pub fn try_update_translation_hotkey_binding(&self) -> Result<(), String> {
let prefs = self.inner.prefs.get();
if is_builtin_translation_shift(&prefs.translation_hotkey)
|| crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_some()
{
take_translation_hotkey_on_main_thread(&self.inner);
self.update_modifier_shortcut_bindings();
log::info!("[coord] translation hotkey uses modifier-only listener");
return Ok(());
}
self.update_modifier_shortcut_bindings();
let app = self.inner.app.lock().clone();
let Some(app) = app else {
return Err("AppHandle 未 bind,无法注册翻译快捷键".into());
};
let inner_clone = Arc::clone(&self.inner);
let binding_for_main = prefs.translation_hotkey.clone();
let (result_tx, result_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let _ = app.run_on_main_thread(move || {
let result = update_translation_hotkey_on_main_thread(inner_clone, binding_for_main);
let _ = result_tx.send(result.map_err(|e| e.to_string()));
});
match result_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(result) => result,
Err(_) => Err("注册翻译快捷键超时".into()),
}
}
pub fn update_switch_style_hotkey_binding(&self) {
self.update_action_hotkey_binding(ActionHotkeyKind::SwitchStyle);
}
pub fn update_open_app_hotkey_binding(&self) {
self.update_action_hotkey_binding(ActionHotkeyKind::OpenApp);
}
fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) {
let binding = action_hotkey_binding(&self.inner, kind);
if is_modifier_only_shortcut(&binding) {
take_action_hotkey_on_main_thread(&self.inner, kind);
log::warn!("[coord] action hotkey {kind:?} 使用了不支持的 modifier-only 绑定,已关闭");
return;
}
let app = self.inner.app.lock().clone();
let Some(app) = app else {
log::warn!("[coord] update action hotkey binding: AppHandle 未 bind,跳过");
return;
};
let inner_clone = Arc::clone(&self.inner);
let _ = app.run_on_main_thread(move || {
if let Some(monitor) = action_hotkey_slot(&inner_clone, kind).lock().as_ref() {
if let Err(e) = monitor.update_binding(binding.clone()) {
log::warn!("[coord] update action hotkey {kind:?} binding 失败: {e}");
}
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match ComboHotkeyMonitor::start(binding, tx) {
Ok(monitor) => {
*action_hotkey_slot(&inner_clone, kind).lock() = Some(monitor);
let bridge_inner = Arc::clone(&inner_clone);
std::thread::Builder::new()
.name(action_hotkey_bridge_thread_name(kind).into())
.spawn(move || action_hotkey_bridge_loop(bridge_inner, rx, kind))
.ok();
}
Err(e) => log::warn!("[coord] update action hotkey {kind:?} binding 失败: {e}"),
}
});
}
/// 给前端 Settings 渲染当前 QA 快捷键 label(如 "Cmd+Shift+;")。
/// `qa_hotkey == None` 时返回空串,UI 据此显示「未启用」。
pub fn qa_hotkey_label(&self) -> String {
self.inner
.prefs
.get()
.qa_hotkey
.as_ref()
.map(|b| b.display_label())
.unwrap_or_default()
}
/// 用户点 ✕ / 按 Esc 关 QA 浮窗时调。等价于:取消任何进行中的录音 +
/// 清空多轮对话历史 + 隐藏窗口。详见 issue #118 v2。
pub fn qa_window_dismiss(&self) {
close_qa_panel(&self.inner);
}
/// 用户点 📌 切换 pinned 状态。pinned=true 时浮窗不自动隐藏。
pub fn qa_window_pin(&self, pinned: bool) {
self.inner.qa_state.lock().pinned = pinned;
log::info!("[coord] QA window pinned={pinned}");
}
pub fn history(&self) -> &HistoryStore {
&self.inner.history
}
pub fn prefs(&self) -> &PreferencesStore {
&self.inner.prefs
}
pub fn sync_active_asr_provider_from_preferences(&self) -> Result<(), String> {
let provider = self.inner.prefs.get().active_asr_provider;
self.sync_active_asr_provider_to_vault(&provider)
}
pub fn sync_active_asr_provider_to_vault(&self, provider: &str) -> Result<(), String> {
if CredentialsVault::get_active_asr() == provider {
return Ok(());
}
CredentialsVault::set_active_asr_provider(provider).map_err(|e| e.to_string())
}
pub fn style_packs(&self) -> &StylePackStore {
&self.inner.style_packs
}
pub fn vocab(&self) -> &DictionaryStore {
&self.inner.vocab
}
pub fn correction_rules(&self) -> &CorrectionRuleStore {
&self.inner.correction_rules
}
pub fn update_hotkey_binding(&self) {
let prefs = self.inner.prefs.get();
let dictation_trigger =
crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey);
let binding = crate::types::HotkeyBinding {
trigger: dictation_trigger.unwrap_or(crate::types::HotkeyTrigger::Custom),
mode: prefs.hotkey.mode,
keys: None,
};
if dictation_trigger.is_some() {
take_combo_hotkey_on_main_thread(&self.inner);
} else {
self.update_combo_hotkey_binding();
}
self.ensure_modifier_hotkey_monitor(binding);
self.update_modifier_shortcut_bindings();
}
fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) {
if let Some(monitor) = self.inner.hotkey.lock().as_ref() {
#[cfg(target_os = "linux")]
let plugin_binding = binding.clone();
monitor.update_binding(binding);
#[cfg(target_os = "linux")]
if plugin_binding.trigger == crate::types::HotkeyTrigger::Custom {
sync_custom_dictation_to_plugin(&self.inner);
} else {
crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding);
}
return;
}
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
#[cfg(target_os = "linux")]
let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone());
match HotkeyMonitor::start(binding, tx) {
Ok(monitor) => {
let adapter = monitor.kind();
*self.inner.hotkey.lock() = Some(monitor);
*self.inner.hotkey_status.lock() = HotkeyStatus {
adapter,
state: HotkeyStatusState::Installed,
message: Some(format!("{} 已安装", adapter.display_name())),
last_error: None,
};
let inner_clone = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-hotkey-bridge".into())
.spawn(move || hotkey_bridge_loop(inner_clone, rx))
.ok();
// Linux: 启动 fcitx5 插件信号监听作为热键源。
#[cfg(target_os = "linux")]
{
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&self.inner);
let custom_key = custom_dictation_key_string(&self.inner);
crate::linux_fcitx::start_dictation_signal_listener(
fcitx_tx,
fcitx_binding.clone(),
qa_trigger,
translation_trigger,
custom_key,
);
if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom {
sync_custom_dictation_to_plugin(&self.inner);
} else {
crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding);
}
}
}
Err(e) => {
*self.inner.hotkey_status.lock() = HotkeyStatus {
adapter: HotkeyMonitor::capability().adapter,
state: HotkeyStatusState::Failed,
message: Some(e.message.clone()),
last_error: Some(e),
};
}
}
}
pub fn update_modifier_shortcut_bindings(&self) {
if let Some(monitor) = self.inner.hotkey.lock().as_ref() {
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&self.inner);
monitor.update_modifier_shortcuts(qa_trigger, translation_trigger);
}
}
pub fn hotkey_status(&self) -> HotkeyStatus {
self.inner.hotkey_status.lock().clone()
}
pub fn hotkey_capability(&self) -> HotkeyCapability {
HotkeyMonitor::capability()
}
pub async fn start_dictation(&self) -> Result<(), String> {
begin_session(&self.inner).await
}
pub async fn stop_dictation(&self) -> Result<(), String> {
if self.inner.state.lock().phase == SessionPhase::Starting {
request_stop_during_starting(&self.inner, "manual stop");
return Ok(());
}
end_session(&self.inner).await
}
pub fn cancel_dictation(&self) {
cancel_session(&self.inner);
}
/// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。
/// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start,
/// Listening → stop。可用于桌面快捷键 → CLI 转发的备用触发路径。
pub fn dictation_phase_for_cli(&self) -> SessionPhase {
self.inner.state.lock().phase
}
/// CLI 入口的 QA toggle:直接复用 modifier-only QA 热键边沿的处理函数。
/// 与 `handle_qa_hotkey_pressed` 同语义 — Idle → 开浮窗 / Recording → 收尾 /
/// Processing → 忽略。桌面快捷键 → CLI 转发的备用进入点。
pub async fn cli_toggle_qa_panel(&self) {
handle_qa_hotkey_pressed(&self.inner).await;
}
pub fn set_shortcut_recording_active(&self, active: bool) {
self.inner
.shortcut_recording_active
.store(active, Ordering::SeqCst);
if active {
reset_shortcut_held_state(&self.inner);
}
log::info!("[coord] shortcut recording active={active}");
}
pub async fn handle_window_hotkey_event(
&self,
event_type: String,
key: String,
code: String,
repeat: bool,
) -> Result<(), String> {
handle_window_hotkey_event(&self.inner, event_type, key, code, repeat).await
}
#[cfg(any(debug_assertions, test))]
pub async fn inject_hotkey_click_for_dev(&self) -> Result<(), String> {
log::info!("[coord] dev hotkey injection started");
handle_pressed(&self.inner).await;
handle_released(&self.inner).await;
cancel_session(&self.inner);
Ok(())
}
pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result<String, String> {
let hotwords = enabled_phrases(&self.inner);
let prefs = self.inner.prefs.get();
let pack = self
.inner
.style_packs
.get_or_default_active(&prefs.active_style_pack_id)
.map_err(|e| e.to_string())?;
let style_system_prompt = pack.prompt.clone();
let working_languages = prefs.working_languages;
let chinese_script_preference = prefs.chinese_script_preference;
let output_language_preference = prefs.output_language_preference;
let llm_thinking_enabled = prefs.llm_thinking_enabled;
let effective_mode = pack.base_mode;
log::info!(
"[style-pack] repolish dispatch active_pack={} kind={:?} effective_mode={:?} legacy_mode={:?} raw_chars={} prompt_chars={} hotwords={} thinking={}",
pack.id,
pack.kind,
effective_mode,
mode,
raw_text.chars().count(),
style_system_prompt.chars().count(),
hotwords.len(),
llm_thinking_enabled
);
if effective_mode == PolishMode::Raw && !raw_style_pack_uses_llm(&pack) {
log::info!(
"[style-pack] repolish bypass llm active_pack={} reason=default_builtin_raw",
pack.id
);
return Ok(raw_text);
}
// repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app;
// 当下用户调起的 app 才是相关上下文(如果可拿)。
let front_app = capture_frontmost_app();
// repolish 是用户主动对单条历史"重新润色",不应该被对话感知上下文影响——
// 用户改的就是这一条本身,不要把别的会话拿进来。所以始终走单轮路径。
polish_text(
&raw_text,
effective_mode,
&hotwords,
&style_system_prompt,
&working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app.as_deref(),
&[],
)
.await
.map_err(|e| e.to_string())
}
pub fn preview_style_pack_runtime(
&self,
style_pack: &crate::types::StylePack,
) -> crate::types::StylePackRuntimeDiagnostics {
let prefs = self.inner.prefs.get();
let hotwords = enabled_phrases(&self.inner);
let single_turn = crate::polish::assemble_polish_system_prompt(
&style_pack.prompt,
&hotwords,
&prefs.working_languages,
prefs.chinese_script_preference,
prefs.output_language_preference,
None,
false,
);
let multi_turn = crate::polish::assemble_polish_system_prompt(
&style_pack.prompt,
&hotwords,
&prefs.working_languages,
prefs.chinese_script_preference,
prefs.output_language_preference,
None,
true,
);
crate::types::StylePackRuntimeDiagnostics {
pack_id: style_pack.id.clone(),
pack_name: style_pack.name.clone(),
pack_prompt: style_pack.prompt.clone(),
pack_prompt_chars: style_pack.prompt.chars().count(),
context_premise: single_turn.context_premise.clone(),
context_premise_chars: single_turn.context_premise.chars().count(),
hotword_block: single_turn.hotword_block.clone(),
hotword_block_chars: single_turn.hotword_block.chars().count(),
history_instruction: multi_turn.history_instruction.clone(),
history_instruction_chars: multi_turn.history_instruction.chars().count(),
single_turn_prompt: single_turn.effective_system_prompt.clone(),
single_turn_prompt_chars: single_turn.effective_system_prompt.chars().count(),
multi_turn_prompt: multi_turn.effective_system_prompt.clone(),
multi_turn_prompt_chars: multi_turn.effective_system_prompt.chars().count(),
working_languages: prefs.working_languages,
hotwords,
context_window_minutes: prefs.polish_context_window_minutes,
includes_context_premise: single_turn.includes_context_premise,
includes_hotword_block: single_turn.includes_hotword_block,
includes_history_instruction: multi_turn.includes_history_instruction,
preview_omits_front_app: true,
}
}
}
fn raw_style_pack_uses_llm(pack: &crate::types::StylePack) -> bool {
!(pack.kind == crate::types::StylePackKind::Builtin
&& pack.id == crate::types::BUILTIN_STYLE_PACK_RAW_ID
&& pack.prompt == crate::types::StyleSystemPrompts::default().raw)
}
fn raw_mode_uses_llm(style_system_prompt: &str) -> bool {
style_system_prompt != crate::types::StyleSystemPrompts::default().raw
}
// ─────────────────────────── hotkey bridging ───────────────────────────
fn hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
let capability = HotkeyMonitor::capability();
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
let prefs = inner.prefs.get();
if inner.hotkey.lock().is_some() {
return;
}
// Linux: 启动前检查 fcitx5 插件是否可用
#[cfg(target_os = "linux")]
if !crate::linux_fcitx::available() {
*inner.hotkey_status.lock() = HotkeyStatus {
adapter: capability.adapter,
state: HotkeyStatusState::Failed,
message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()),
last_error: Some(crate::types::HotkeyInstallError {
code: "fcitx5_unavailable".into(),
message: "fcitx5 插件 DBus 接口无响应".into(),
}),
};
log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying...");
attempts += 1;
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
*inner.hotkey_status.lock() = HotkeyStatus {
adapter: capability.adapter,
state: HotkeyStatusState::Starting,
message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)),
last_error: None,
};
let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey)
.unwrap_or(crate::types::HotkeyTrigger::Custom);
let binding = crate::types::HotkeyBinding {
trigger,
mode: prefs.hotkey.mode,
keys: None,
};
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
#[cfg(target_os = "linux")]
let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone());
match HotkeyMonitor::start(binding, tx) {
Ok(monitor) => {
let adapter = monitor.kind();
*inner.hotkey.lock() = Some(monitor);
if let Some(monitor) = inner.hotkey.lock().as_ref() {
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner);
monitor.update_modifier_shortcuts(qa_trigger, translation_trigger);
}
*inner.hotkey_status.lock() = HotkeyStatus {
adapter,
state: HotkeyStatusState::Installed,
message: Some(format!("{} 已安装", adapter.display_name())),
last_error: None,
};
log::info!(
"[coord] hotkey listener installed (after {} attempt(s))",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-hotkey-bridge".into())
.spawn(move || hotkey_bridge_loop(inner_clone, rx))
.ok();
// Linux: 启动 fcitx5 插件信号监听作为热键源。
#[cfg(target_os = "linux")]
{
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner);
let custom_key = custom_dictation_key_string(&inner);
crate::linux_fcitx::start_dictation_signal_listener(
fcitx_tx,
fcitx_binding.clone(),
qa_trigger,
translation_trigger,
custom_key,
);
if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom {
sync_custom_dictation_to_plugin(&inner);
} else {
crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding);
}
}
return;
}
Err(e) => {
attempts += 1;
let error_message = e.message.clone();
*inner.hotkey_status.lock() = HotkeyStatus {
adapter: capability.adapter,
state: HotkeyStatusState::Failed,
message: Some(error_message.clone()),
last_error: Some(e),
};
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] hotkey listener attempt #{attempts} failed: {}; retrying in 3s",
error_message
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
// ─────────────────────────── QA hotkey supervisor ───────────────────────────
fn qa_hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
// 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。
let binding = match inner.prefs.get().qa_hotkey.clone() {
Some(b) => b,
None => {
inner.qa_hotkey.lock().take();
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
};
if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() {
inner.qa_hotkey.lock().take();
if let Some(monitor) = inner.hotkey.lock().as_ref() {
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner);
monitor.update_modifier_shortcuts(qa_trigger, translation_trigger);
}
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if inner.qa_hotkey.lock().is_some() {
// 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
// global-hotkey crate 在 macOS 走 Carbon RegisterEventHotKey,要求 manager
// 在主线程构造,否则 register() 看起来 Ok 但事件根本不会派发——这是 issue #118
// PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过
// run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。
let app = inner.app.lock().clone();
let app = match app {
Some(a) => a,
None => {
// 启动期 AppHandle 还没 bind,再等。
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
};
let (tx, rx) = mpsc::channel::<QaHotkeyEvent>();
let (init_tx, init_rx) = mpsc::sync_channel::<Result<QaHotkeyMonitor, QaHotkeyError>>(1);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
let result = QaHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
});
// run_on_main_thread 是 fire-and-forget;等主线程跑完结果回来。给 5s 上限避免
// 主线程繁忙时 supervisor 永久阻塞。
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
};
match init_result {
Ok(monitor) => {
*inner.qa_hotkey.lock() = Some(monitor);
log::info!(
"[coord] QA hotkey listener installed on main thread (after {} attempt(s))",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-qa-hotkey-bridge".into())
.spawn(move || qa_hotkey_bridge_loop(inner_clone, rx))
.ok();
attempts = 0;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!("[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试");
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
fn qa_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<QaHotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match evt {
QaHotkeyEvent::Pressed => {
async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await });
}
}
}
}
// ─────────────────────────── combo hotkey supervisor ───────────────────────────
fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
// 读当前 prefs
let prefs = inner.prefs.get();
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
// 不是 Custom → 睡着等 prefs 改动
take_combo_hotkey_on_main_thread(&inner);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let binding = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
take_combo_hotkey_on_main_thread(&inner);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if inner.combo_hotkey.lock().is_some() {
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let app = inner.app.lock().clone();
let app = match app {
Some(a) => a,
None => {
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
};
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(1);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
let result = ComboHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
});
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
};
match init_result {
Ok(monitor) => {
*inner.combo_hotkey.lock() = Some(monitor);
log::info!(
"[coord] combo hotkey listener installed on main thread (after {} attempt(s))",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-combo-hotkey-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(inner_clone, rx))
.ok();
attempts = 0;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试");
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
fn combo_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match evt {
// P0 #468/#475: 同 hotkey_bridge_loop —— Pressed/Released 必须串行 await,
// 否则 latch 竞态导致 combo 快捷键二次按键失效。
ComboHotkeyEvent::Pressed => {
async_runtime::block_on(async {
handle_pressed_edge(&inner_cloned).await;
});
}
ComboHotkeyEvent::Released => {
async_runtime::block_on(async {
handle_released_edge(&inner_cloned).await;
});
}
}
}
}
fn translation_hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
let binding = inner.prefs.get().translation_hotkey;
if is_builtin_translation_shift(&binding)
|| crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some()
{
take_translation_hotkey_on_main_thread(&inner);
if let Some(monitor) = inner.hotkey.lock().as_ref() {
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner);
monitor.update_modifier_shortcuts(qa_trigger, translation_trigger);
}
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if inner.translation_hotkey.lock().is_some() {
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let app = match inner.app.lock().clone() {
Some(a) => a,
None => {
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
};
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(1);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
let result = ComboHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
});
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
attempts += 1;
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
};
match init_result {
Ok(monitor) => {
*inner.translation_hotkey.lock() = Some(monitor);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-translation-hotkey-bridge".into())
.spawn(move || translation_hotkey_bridge_loop(inner_clone, rx))
.ok();
attempts = 0;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
fn update_translation_hotkey_on_main_thread(
inner: Arc<Inner>,
binding: crate::types::ShortcutBinding,
) -> Result<(), ComboHotkeyError> {
if let Some(monitor) = inner.translation_hotkey.lock().as_ref() {
return monitor.update_binding(binding);
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let monitor = ComboHotkeyMonitor::start(binding, tx)?;
*inner.translation_hotkey.lock() = Some(monitor);
let bridge_inner = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-translation-hotkey-bridge".into())
.spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx))
.map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn bridge thread: {e}")))?;
Ok(())
}
fn translation_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
if matches!(evt, ComboHotkeyEvent::Pressed) {
mark_translation_modifier_seen(&inner);
}
}
}
fn action_hotkey_supervisor_loop(inner: Arc<Inner>, kind: ActionHotkeyKind) {
let mut attempts: u32 = 0;
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
let binding = action_hotkey_binding(&inner, kind);
if is_modifier_only_shortcut(&binding) {
take_action_hotkey_on_main_thread(&inner, kind);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if action_hotkey_slot(&inner, kind).lock().is_some() {
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let app = match inner.app.lock().clone() {
Some(a) => a,
None => {
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
};
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(1);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
let result = ComboHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
});
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
};
match init_result {
Ok(monitor) => {
*action_hotkey_slot(&inner, kind).lock() = Some(monitor);
log::info!(
"[coord] action hotkey {kind:?} listener installed after {} attempt(s)",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name(action_hotkey_bridge_thread_name(kind).into())
.spawn(move || action_hotkey_bridge_loop(inner_clone, rx, kind))
.ok();
attempts = 0;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
fn action_hotkey_bridge_loop(
inner: Arc<Inner>,
rx: mpsc::Receiver<ComboHotkeyEvent>,
kind: ActionHotkeyKind,
) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
if matches!(evt, ComboHotkeyEvent::Pressed) {
handle_action_hotkey_pressed(&inner, kind);
}
}
}
fn handle_action_hotkey_pressed(inner: &Arc<Inner>, kind: ActionHotkeyKind) {
match kind {
ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner),
ActionHotkeyKind::OpenApp => {
if let Some(app) = inner.app.lock().clone() {
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
crate::show_main_window(&app_for_main);
});
}
}
}
}
fn switch_to_previous_style(inner: &Arc<Inner>) {
let mut prefs = inner.prefs.get();
let packs = match inner.style_packs.list() {
Ok(packs) => packs,
Err(error) => {
log::warn!("[coord] switch style hotkey failed to load style packs: {error}");
return;
}
};
let enabled: Vec<crate::types::StylePack> =
packs.into_iter().filter(|pack| pack.enabled).collect();
if enabled.len() <= 1 {
log::info!("[coord] switch style hotkey ignored: enabled style count <= 1");
return;
}
let current_index = enabled
.iter()
.position(|pack| pack.id == prefs.active_style_pack_id)
.unwrap_or(0);
let next_index = if current_index == 0 {
enabled.len() - 1
} else {
current_index - 1
};
prefs.active_style_pack_id = enabled[next_index].id.clone();
sync_style_pack_preferences(&mut prefs, &enabled);
if let Err(e) = inner.prefs.set(prefs.clone()) {
log::warn!("[coord] switch style hotkey 保存失败: {e}");
} else {
log::info!(
"[coord] switch style hotkey changed active style pack to {}",
prefs.active_style_pack_id
);
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit("prefs:changed", &prefs);
let _ = app.emit_to("main", "prefs:changed", &prefs);
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh style menu after switch style hotkey failed: {err}");
}
});
}
}
}
fn take_combo_hotkey_on_main_thread(inner: &Arc<Inner>) {
let app = inner.app.lock().clone();
if let Some(app) = app {
let inner = Arc::clone(inner);
let _ = app.run_on_main_thread(move || {
inner.combo_hotkey.lock().take();
});
} else {
inner.combo_hotkey.lock().take();
}
}
fn take_translation_hotkey_on_main_thread(inner: &Arc<Inner>) {
let app = inner.app.lock().clone();
if let Some(app) = app {
let inner = Arc::clone(inner);
let _ = app.run_on_main_thread(move || {
inner.translation_hotkey.lock().take();
});
} else {
inner.translation_hotkey.lock().take();
}
}
fn take_action_hotkey_on_main_thread(inner: &Arc<Inner>, kind: ActionHotkeyKind) {
let app = inner.app.lock().clone();
if let Some(app) = app {
let inner = Arc::clone(inner);
let _ = app.run_on_main_thread(move || {
action_hotkey_slot(&inner, kind).lock().take();
});
} else {
action_hotkey_slot(inner, kind).lock().take();
}
}
fn action_hotkey_slot(
inner: &Arc<Inner>,
kind: ActionHotkeyKind,
) -> &Mutex<Option<ComboHotkeyMonitor>> {
match kind {
ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey,
ActionHotkeyKind::OpenApp => &inner.open_app_hotkey,
}
}
fn action_hotkey_binding(
inner: &Arc<Inner>,
kind: ActionHotkeyKind,
) -> crate::types::ShortcutBinding {
let prefs = inner.prefs.get();
match kind {
ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey,
ActionHotkeyKind::OpenApp => prefs.open_app_hotkey,
}
}
fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool {
binding.modifiers.is_empty()
&& (binding.primary.eq_ignore_ascii_case("shift")
|| crate::shortcut_binding::legacy_modifier_trigger(binding).is_some())
}
fn is_unconfigured_shortcut(binding: &crate::types::ShortcutBinding) -> bool {
binding.primary.trim().is_empty()
}
fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'static str {
match kind {
ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge",
ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge",
}
}
fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool {
binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift")
}
/// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。
#[cfg(target_os = "linux")]
fn custom_dictation_key_string(inner: &Arc<Inner>) -> Option<String> {
let prefs = inner.prefs.get();
let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(&prefs.dictation_hotkey);
if key_string.is_empty() {
None
} else {
Some(key_string)
}
}
#[cfg(target_os = "linux")]
fn sync_custom_dictation_to_plugin(inner: &Arc<Inner>) {
let prefs = inner.prefs.get();
let dictation = &prefs.dictation_hotkey;
let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation);
if key_string.is_empty() {
return;
}
match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) {
Ok(()) => log::info!(
"[fcitx] Synced custom dictation trigger '{}' to plugin",
key_string
),
Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"),
}
}
fn modifier_shortcut_triggers(
inner: &Arc<Inner>,
) -> (
Option<crate::types::HotkeyTrigger>,
Option<crate::types::HotkeyTrigger>,
) {
let prefs = inner.prefs.get();
let qa_trigger = prefs
.qa_hotkey
.as_ref()
.and_then(crate::shortcut_binding::legacy_modifier_trigger);
let translation_trigger = if is_builtin_translation_shift(&prefs.translation_hotkey) {
None
} else {
crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey)
};
(qa_trigger, translation_trigger)
}
fn mark_translation_modifier_seen(inner: &Arc<Inner>) {
let phase = inner.state.lock().phase;
if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
inner
.translation_modifier_seen
.store(true, Ordering::SeqCst);
log::info!("[coord] translation modifier seen during {phase:?}");
}
}
fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match evt {
// P0 #468/#475: Pressed/Released 必须串行处理,否则在 Windows 上 WH_KEYBOARD_LL
// 边沿间隔微秒级 → 两个独立 spawn 的 task 被 work-stealing 调度器并行执行 →
// `hotkey_trigger_held` latch 翻转顺序错乱 → 下次按键被静默吞掉
// (UI 关不掉 / 录音停不下来)。改为 bridge 线程内 block_on 顺序 await,
// recv 的 FIFO 顺序就是 handler 执行顺序。
// 注意:handle_pressed_edge / handle_released_edge 内部走 .await(含网络
// 握手),会暂时阻塞本 bridge 线程;Hold 模式短按时 Released 会排队在 channel
// 里直到 begin_session 完成,但 SessionPhase::Starting 已经有
// request_stop_during_starting 兜底,begin_session 完成进 Listening 后
// bridge 立刻 recv Released → end_session,行为正确,仅有短暂 stop 延迟。
HotkeyEvent::Pressed => {
async_runtime::block_on(async {
handle_pressed_edge(&inner_cloned).await;
});
}
HotkeyEvent::Released => {
async_runtime::block_on(async {
handle_released_edge(&inner_cloned).await;
});
}
HotkeyEvent::Cancelled => {
cancel_session(&inner_cloned);
}
HotkeyEvent::TranslationModifierPressed => {
let translation_hotkey = inner_cloned.prefs.get().translation_hotkey;
if is_builtin_translation_shift(&translation_hotkey)
|| crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey)
.is_some()
{
mark_translation_modifier_seen(&inner_cloned);
}
}
HotkeyEvent::QaShortcutPressed => {
async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await });
}
}
}
}
fn reset_shortcut_held_state(inner: &Arc<Inner>) {
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
if let Some(monitor) = inner.hotkey.lock().as_ref() {
monitor.reset_held_state();
}
let prefs = inner.prefs.get();
if let Some(binding) = prefs.qa_hotkey.as_ref() {
if crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() {
if let Some(monitor) = inner.qa_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(binding.clone()) {
log::warn!("[coord] reset QA hotkey latch failed: {e}");
}
}
}
}
if !is_builtin_translation_shift(&prefs.translation_hotkey)
&& crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_none()
{
if let Some(monitor) = inner.translation_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.translation_hotkey.clone()) {
log::warn!("[coord] reset translation hotkey latch failed: {e}");
}
}
}
if !is_modifier_only_shortcut(&prefs.switch_style_hotkey) {
if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.switch_style_hotkey.clone()) {
log::warn!("[coord] reset switch-style hotkey latch failed: {e}");
}
}
}
if !is_modifier_only_shortcut(&prefs.open_app_hotkey) {
if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.open_app_hotkey.clone()) {
log::warn!("[coord] reset open-app hotkey latch failed: {e}");
}
}
}
}
async fn handle_window_hotkey_event(
inner: &Arc<Inner>,
event_type: String,
key: String,
code: String,
repeat: bool,
) -> Result<(), String> {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
return Ok(());
}
if event_type == "keydown" && key == "Escape" {
// Esc 路由(issue #161):QA 浮窗可见时优先取消 QA(不动 dictation);
// 否则走 dictation 取消通路。之前无条件 cancel_session 导致 QA 浮窗
// 按 Esc 杀的是 dictation 而 QA 流还在烧 token。
let qa_active = {
let st = inner.qa_state.lock();
st.panel_visible || st.phase != QaPhase::Idle
};
if qa_active {
close_qa_panel(inner);
} else {
cancel_session(inner);
}
return Ok(());
}
#[cfg(not(target_os = "windows"))]
{
let _ = (inner, event_type, key, code, repeat);
Ok(())
}
#[cfg(target_os = "windows")]
{
if !window_hotkey_fallback_enabled() {
if event_type == "keydown" && !repeat {
log::info!(
"[window-hotkey] ignored because Windows lifecycle owner is the low-level hook"
);
}
return Ok(());
}
let Some(trigger) =
crate::shortcut_binding::legacy_modifier_trigger(&inner.prefs.get().dictation_hotkey)
else {
return Ok(());
};
if !window_key_matches_trigger(trigger, &key, &code) {
return Ok(());
}
match event_type.as_str() {
"keydown" => {
if repeat {
return Ok(());
}
log::info!(
"[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}"
);
handle_pressed_edge(inner).await;
}
"keyup" => {
log::info!("[window-hotkey] released trigger={trigger:?} code={code}");
handle_released_edge(inner).await;
}
_ => {}
}
Ok(())
}
}
fn window_hotkey_fallback_enabled() -> bool {
crate::types::HotkeyCapability::current().explicit_fallback_available
}
#[cfg(any(target_os = "windows", test))]
fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool {
use crate::types::HotkeyTrigger;
match trigger {
HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight",
HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft",
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => {
(key == "Alt" || key == "AltGraph") && code == "AltRight"
}
HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft",
HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight",
HotkeyTrigger::Fn => key == "Control" && code == "ControlRight",
// MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback
HotkeyTrigger::MediaPlayPause => false,
// Custom 走 global-hotkey crate,不走 window hotkey fallback
HotkeyTrigger::Custom => false,
}
}
// ─────────────────────────── session lifecycle ───────────────────────────
/// QA 录音 runtime error 监听器。镜像 `spawn_recorder_error_monitor` 的语义但走 QA
/// 收尾路径(`finish_qa_with_error` 替代 `abort_recording_with_error`)。
/// 用 qa_state.session_id 守卫 stale 事件。详见 issue #168。
fn spawn_qa_recorder_error_monitor(inner: &Arc<Inner>, rx: mpsc::Receiver<RecorderError>) {
let captured_session_id = inner.qa_state.lock().session_id;
let inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-qa-recorder-error-monitor".into())
.spawn(move || {
if let Ok(err) = rx.recv() {
let current_session_id = inner.qa_state.lock().session_id;
if captured_session_id != current_session_id {
log::warn!(
"[coord] QA recorder error from stale session {} dropped (current={}, err={})",
captured_session_id,
current_session_id,
err
);
return;
}
log::error!("[coord] QA recorder runtime error: {err}");
finish_qa_with_error(&inner, format!("录音设备异常: {err}"));
}
})
.ok();
}
#[cfg(target_os = "windows")]
fn store_prepared_windows_ime_session(
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
session_id: SessionId,
prepared: PreparedWindowsImeSession,
) {
slots.retain(|slot| slot.session_id != session_id);
slots.push(PreparedWindowsImeSessionSlot {
session_id,
prepared,
});
}
#[cfg(target_os = "windows")]
fn take_matching_prepared_windows_ime_session(
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
session_id: SessionId,
) -> Option<PreparedWindowsImeSession> {
let index = slots
.iter()
.position(|slot| slot.session_id == session_id)?;
Some(slots.remove(index).prepared)
}
#[cfg(target_os = "windows")]
fn take_current_prepared_windows_ime_session_for_restore(
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
session_id: SessionId,
current_session_id: SessionId,
) -> Option<PreparedWindowsImeSession> {
let prepared = take_matching_prepared_windows_ime_session(slots, session_id)?;
if current_session_id == session_id {
Some(prepared)
} else {
None
}
}
#[cfg(target_os = "windows")]
fn restore_prepared_windows_ime_session(inner: &Arc<Inner>, session_id: SessionId) {
let state = inner.state.lock();
let prepared = {
let mut slot = inner.prepared_windows_ime_session.lock();
take_current_prepared_windows_ime_session_for_restore(
&mut slot,
session_id,
state.session_id,
)
};
if let Some(prepared) = prepared {
inner.windows_ime.restore_session(prepared);
}
}
#[cfg(not(target_os = "windows"))]
fn restore_prepared_windows_ime_session(_inner: &Arc<Inner>, _session_id: SessionId) {}
#[cfg(target_os = "windows")]
async fn insert_with_windows_ime_first(
inner: &Arc<Inner>,
session_id: SessionId,
polished: &str,
restore_clipboard: bool,
allow_non_tsf_insertion_fallback: bool,
paste_shortcut: PasteShortcut,
ime_target: Option<ImeSubmitTarget>,
) -> InsertStatus {
let prepared = {
let mut slot = inner.prepared_windows_ime_session.lock();
take_matching_prepared_windows_ime_session(&mut slot, session_id)
};
let Some(prepared) = prepared else {
log::warn!("[windows-ime] no prepared TSF session for this dictation");
if should_try_non_tsf_insertion_fallback(
allow_non_tsf_insertion_fallback,
InsertStatus::Failed,
) {
return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut);
}
log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert");
return InsertStatus::Failed;
};
let request = crate::windows_ime_ipc::ImeSubmitRequest {
session_id: Uuid::new_v4().to_string(),
text: polished.to_string(),
created_at: Utc::now().to_rfc3339(),
target: ime_target,
};
let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await {
Ok(status) => status,
Err(error) => {
log::warn!("[windows-ime] TSF submit failed: {error}");
InsertStatus::Failed
}
};
inner.windows_ime.restore_session(prepared);
if ime_status == InsertStatus::Inserted {
ime_status
} else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) {
insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut)
} else {
log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled");
InsertStatus::Failed
}
}
#[cfg(target_os = "windows")]
fn should_try_non_tsf_insertion_fallback(
allow_non_tsf_insertion_fallback: bool,
ime_status: InsertStatus,
) -> bool {
allow_non_tsf_insertion_fallback && ime_status != InsertStatus::Inserted
}
#[cfg(target_os = "windows")]
fn insert_via_non_tsf_fallback(
inner: &Arc<Inner>,
polished: &str,
_restore_clipboard: bool,
_paste_shortcut: PasteShortcut,
) -> InsertStatus {
let status = finish_non_tsf_insertion_fallback(
|| inner.inserter.insert_via_unicode_keystrokes(polished),
|| inner.inserter.copy_fallback(polished),
);
match status {
InsertStatus::Inserted => {
log::warn!(
"[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback"
);
}
InsertStatus::CopiedFallback => {
log::warn!(
"[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard"
);
}
InsertStatus::PasteSent | InsertStatus::Failed => {
log::warn!(
"[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed"
);
}
}
status
}
#[cfg(any(target_os = "windows", test))]
fn finish_non_tsf_insertion_fallback<U, C>(
mut unicode_fallback: U,
mut copy_only_fallback: C,
) -> InsertStatus
where
U: FnMut() -> InsertStatus,
C: FnMut() -> InsertStatus,
{
match unicode_fallback() {
InsertStatus::Inserted => InsertStatus::Inserted,
InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => {
match copy_only_fallback() {
InsertStatus::CopiedFallback => InsertStatus::CopiedFallback,
// TextInserter::copy_fallback is copy-only: success is CopiedFallback.
// Treat any other status as failure so this helper never invents an insert.
InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => {
InsertStatus::Failed
}
}
}
}
}
#[cfg(test)]
mod non_tsf_fallback_tests {
use super::finish_non_tsf_insertion_fallback;
use crate::types::InsertStatus;
#[test]
fn unicode_fallback_runs_before_copy_fallback() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Inserted,
|| {
copy_called = true;
InsertStatus::CopiedFallback
},
);
assert_eq!(status, InsertStatus::Inserted);
assert!(!copy_called);
}
#[test]
fn copy_fallback_runs_after_unicode_failure() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Failed,
|| {
copy_called = true;
InsertStatus::CopiedFallback
},
);
assert_eq!(status, InsertStatus::CopiedFallback);
assert!(copy_called);
}
#[test]
fn double_failure_does_not_pretend_text_was_copied() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Failed,
|| {
copy_called = true;
InsertStatus::Failed
},
);
assert_eq!(status, InsertStatus::Failed);
assert!(copy_called);
}
}
// ─────────────────────────── helpers ───────────────────────────
#[cfg(any(debug_assertions, test))]
fn hotkey_injection_dry_run_enabled() -> bool {
std::env::var_os("OPENLESS_HOTKEY_INJECTION_DRY_RUN").is_some()
}
#[cfg(any(debug_assertions, test))]
fn debug_transcript_override_text() -> Option<String> {
let path = std::env::var_os("OPENLESS_DEBUG_TRANSCRIPT_FILE")?;
let text = std::fs::read_to_string(path).ok()?;
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), String> {
use crate::permissions::{self, PermissionStatus};
#[cfg(target_os = "windows")]
{
if permissions::windows_microphone_access_explicitly_denied() {
return Err("需要麦克风权限,当前状态: Denied".to_string());
}
return Ok(());
}
let status = permissions::check_microphone();
if matches!(
status,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
return Ok(());
}
// 听写路径不抢前台焦点:缺 mic 权限时直接请求系统授权,不再先 show_main_window。
// 用户在设置页手动点“请求权限”仍走 request_microphone_from_foreground,那是显式操作。
// 这里若系统不弹框,后续会通过 capsule error 引导用户主动去权限页处理。详见 #166。
let requested = permissions::request_microphone();
if matches!(
requested,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
Ok(())
} else {
Err(format!("需要麦克风权限,当前状态: {requested:?}"))
}
}
fn ensure_asr_credentials() -> Result<(), String> {
let active_asr = CredentialsVault::get_active_asr();
// 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) macOS 平台 (b) 模型已下载。
if crate::asr::local::is_local_qwen3(&active_asr) {
#[cfg(not(target_os = "macos"))]
{
return Err("本地 ASR 当前仅支持 macOS(Windows 见 issue #256)".to_string());
}
#[cfg(target_os = "macos")]
{
return ensure_local_qwen3_model_ready();
}
}
if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) {
#[cfg(not(target_os = "windows"))]
{
return Err("Foundry Local Whisper 当前仅支持 Windows".to_string());
}
#[cfg(target_os = "windows")]
{
return Ok(());
}
}
if crate::asr::local::sherpa::is_sherpa_onnx_local(&active_asr) {
#[cfg(not(target_os = "windows"))]
{
return Err("sherpa-onnx local ASR 当前仅支持 Windows".to_string());
}
#[cfg(target_os = "windows")]
{
return Ok(());
}
}
if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
if api_key.trim().is_empty() {
return Err("请先在设置中填写 ASR 服务商 API Key".to_string());
}
return Ok(());
}
let creds = read_volc_credentials();
if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() {
Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string())
} else {
Ok(())
}
}
#[cfg(test)]
fn is_keyless_local_asr_provider(id: &str) -> bool {
if crate::asr::local::is_local_qwen3(id) {
return true;
}
#[cfg(target_os = "windows")]
{
crate::asr::local::foundry::is_foundry_local_whisper(id)
|| crate::asr::local::sherpa::is_sherpa_onnx_local(id)
}
#[cfg(not(target_os = "windows"))]
{
let _ = id;
false
}
}
#[cfg(target_os = "macos")]
fn ensure_local_qwen3_model_ready() -> Result<(), String> {
let prefs = || -> Result<crate::types::UserPreferences, String> {
// 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。
crate::persistence::PreferencesStore::new()
.map_err(|e| e.to_string())
.map(|s| s.get())
}()?;
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?;
if !crate::asr::local::models::is_downloaded(model_id) {
return Err(format!(
"本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载",
model_id.as_str()
));
}
Ok(())
}
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
fn schedule_local_asr_release(inner: &Arc<Inner>) {
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
let cache = Arc::clone(&inner.local_asr_cache);
if keep_secs == 0 {
cache.release_now();
return;
}
let dur = std::time::Duration::from_secs(keep_secs as u64);
tauri::async_runtime::spawn(async move {
tokio::time::sleep(dur).await;
cache.release_if_idle(dur);
});
}
#[cfg(target_os = "windows")]
fn foundry_local_asr_release_keep_secs(inner: &Arc<Inner>) -> u32 {
inner.prefs.get().foundry_local_asr_keep_loaded_secs
}
#[cfg(target_os = "windows")]
#[derive(Clone, Copy)]
enum AsrReleaseSession {
Dictation(SessionId),
Qa(SessionId),
}
#[cfg(target_os = "windows")]
fn asr_release_session_is_current(inner: &Arc<Inner>, session: AsrReleaseSession) -> bool {
match session {
AsrReleaseSession::Dictation(session_id) => inner.state.lock().session_id == session_id,
AsrReleaseSession::Qa(session_id) => inner.qa_state.lock().session_id == session_id,
}
}
#[cfg(target_os = "windows")]
fn schedule_foundry_local_asr_release(inner: &Arc<Inner>, session: AsrReleaseSession) {
let keep_secs = foundry_local_asr_release_keep_secs(inner);
let runtime = Arc::clone(&inner.foundry_local_runtime);
let inner = Arc::clone(inner);
tauri::async_runtime::spawn(async move {
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !asr_release_session_is_current(&inner, session) {
return;
}
if let Err(error) = runtime.release_now().await {
log::warn!("[foundry-asr] scheduled release failed: {error:#}");
}
});
}
#[cfg(target_os = "windows")]
fn sherpa_onnx_release_keep_secs(inner: &Arc<Inner>) -> u32 {
inner.prefs.get().sherpa_onnx_keep_loaded_secs
}
/// 与 `schedule_foundry_local_asr_release` 同形:session_id 老旧则不释放,
/// 避免下一轮 session 立即重加载同一个 offline batch 模型。
#[cfg(target_os = "windows")]
fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session: AsrReleaseSession) {
let keep_secs = sherpa_onnx_release_keep_secs(inner);
let runtime = Arc::clone(&inner.sherpa_onnx_runtime);
let inner = Arc::clone(inner);
tauri::async_runtime::spawn(async move {
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !asr_release_session_is_current(&inner, session) {
return;
}
if let Err(error) = runtime.release_now().await {
log::warn!("[sherpa-asr] scheduled release failed: {error:#}");
}
});
}
#[cfg(target_os = "macos")]
async fn build_local_qwen3(
inner: &Arc<Inner>,
) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
let prefs = inner.prefs.get();
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
let dir = crate::asr::local::models::model_dir(model_id)?;
let app = inner
.app
.lock()
.clone()
.ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?;
// 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载
// 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。
let cache = Arc::clone(&inner.local_asr_cache);
let mid = model_id.as_str().to_string();
let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir))
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)))
}
/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露
/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。
/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。
///
/// 注:DashScope 的 Qwen3-ASR-Flash 不在此列——它用 MultiModalConversation
/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要
/// 单独 ASR 客户端,留给 V2。
fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq")
}
/// 该 provider 的 `/audio/transcriptions` 是否支持 `response_format=verbose_json`
/// 并返回带 `no_speech_prob` / `avg_logprob` / `compression_ratio` 的 segments,
/// 用于幻听过滤。
///
/// - `whisper`(OpenAI)/ `groq`:原生 Whisper,完整支持,过滤有效。
/// - `siliconflow`:模型是 SenseVoice / TeleSpeech,文档无 `response_format`,
/// 发送 verbose_json 可能被拒,**保持关闭**走旧的 `json`。
/// - `zhipu`(GLM-ASR):虽接受 verbose_json,但不产出上述指标,过滤是空转;
/// 为最小化行为变更,这里也**保持关闭**,仅对确证有收益的 whisper/groq 开启。
fn whisper_supports_verbose_json(provider_id: &str) -> bool {
matches!(provider_id, "whisper" | "groq")
}
fn is_bailian_provider(id: &str) -> bool {
id == crate::asr::bailian::PROVIDER_ID
}
fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String {
if text.is_empty() {
return String::new();
}
let config = match pref {
ChineseScriptPreference::Simplified => Some(BuiltinConfig::T2s),
ChineseScriptPreference::Traditional => Some(BuiltinConfig::S2t),
ChineseScriptPreference::Auto => None,
};
let Some(config) = config else {
return text.to_string();
};
match OpenCC::from_config(config) {
Ok(converter) => converter.convert(text),
Err(err) => {
log::warn!("[coord] OpenCC init failed, skip script conversion: {err}");
text.to_string()
}
}
}
enum QaAsrStart {
Volcengine {
asr: Arc<VolcengineStreamingASR>,
bridge: Arc<DeferredAsrBridge>,
},
Bailian {
asr: Arc<BailianRealtimeASR>,
bridge: Arc<DeferredAsrBridge>,
},
Ready {
active: ActiveAsr,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
},
}
impl QaAsrStart {
fn active_asr(&self) -> ActiveAsr {
match self {
QaAsrStart::Volcengine { asr, .. } => ActiveAsr::Volcengine(Arc::clone(asr)),
QaAsrStart::Bailian { asr, .. } => ActiveAsr::Bailian(Arc::clone(asr)),
QaAsrStart::Ready { active, .. } => active.clone(),
}
}
fn recorder_consumer(&self) -> Arc<dyn crate::recorder::AudioConsumer> {
match self {
QaAsrStart::Volcengine { bridge, .. } => Arc::clone(bridge) as _,
QaAsrStart::Bailian { bridge, .. } => Arc::clone(bridge) as _,
QaAsrStart::Ready { consumer, .. } => Arc::clone(consumer),
}
}
async fn open_streaming_session(&self) -> Result<(), String> {
match self {
QaAsrStart::Volcengine { asr, bridge } => {
asr.open_session().await.map_err(|e| e.to_string())?;
let target: Arc<dyn crate::asr::AudioConsumer> = Arc::clone(asr) as _;
let flushed = bridge.attach(target);
log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes");
Ok(())
}
QaAsrStart::Bailian { asr, bridge } => {
asr.open_session().await.map_err(|e| e.to_string())?;
let target: Arc<dyn crate::asr::AudioConsumer> = Arc::clone(asr) as _;
let flushed = bridge.attach(target);
log::info!(
"[coord] QA Bailian ASR connected; flushed {flushed} deferred audio bytes"
);
Ok(())
}
QaAsrStart::Ready { .. } => Ok(()),
}
}
}
async fn build_qa_asr_start(inner: &Arc<Inner>, active_asr: &str) -> Result<QaAsrStart, String> {
#[cfg(target_os = "windows")]
if foundry::is_foundry_local_whisper(active_asr) {
let prefs = inner.prefs.get();
let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) {
prefs.foundry_local_asr_model.clone()
} else {
foundry::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let local = Arc::new(FoundryLocalWhisperAsr::new(
Arc::clone(&inner.foundry_local_runtime),
model_alias,
prefs.foundry_local_runtime_source.clone(),
language_hint,
));
let active = ActiveAsr::FoundryLocalWhisper(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "windows")]
if sherpa::is_sherpa_onnx_local(active_asr) {
let prefs = inner.prefs.get();
let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
sherpa::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let token_handler = inner.app.lock().clone().map(|app| {
Arc::new(move |piece: String| {
if let Err(error) = app.emit("local-asr-token", piece) {
log::warn!("[sherpa-asr] emit token failed: {error}");
}
}) as crate::asr::local::sherpa_provider::SherpaTokenHandler
});
let local = SherpaOnnxAsr::new_for_model(
Arc::clone(&inner.sherpa_onnx_runtime),
model_alias,
language_hint,
token_handler,
)
.await
.map_err(|e| format!("sherpa-onnx init failed: {e}"))?;
let local = Arc::new(local);
let active = ActiveAsr::SherpaOnnxLocal(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(active_asr) {
let local = build_local_qwen3(inner)
.await
.map_err(|e| format!("local ASR init failed: {e}"))?;
let active = ActiveAsr::Local(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
match active_asr_provider_kind(active_asr) {
ActiveAsrProviderKind::Bailian => Ok(QaAsrStart::Bailian {
asr: Arc::new(BailianRealtimeASR::new(read_bailian_credentials())),
bridge: Arc::new(DeferredAsrBridge::new()),
}),
ActiveAsrProviderKind::WhisperCompatible => {
let (api_key, base_url, model) = read_whisper_credentials();
let whisper_prompt =
crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner));
let whisper = Arc::new(WhisperBatchASR::new(
api_key,
base_url,
model,
whisper_prompt,
batch_asr_chunk_limit_ms(active_asr),
whisper_supports_verbose_json(active_asr),
));
let active = ActiveAsr::Whisper(Arc::clone(&whisper));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = whisper;
Ok(QaAsrStart::Ready { active, consumer })
}
ActiveAsrProviderKind::Volcengine => Ok(QaAsrStart::Volcengine {
asr: Arc::new(VolcengineStreamingASR::new(
read_volc_credentials(),
enabled_hotwords(inner),
)),
bridge: Arc::new(DeferredAsrBridge::new()),
}),
}
}
/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
/// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、
/// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。
pub enum StreamingPolishOutcome {
/// 流式润色成功,`String` 是已经一边流一边交给 `on_delta` 的全部文本(用于写
/// history、做词条命中统计)。调用方不应再 `inserter.insert(&text)`,因为字符
/// 已经通过键盘事件落到光标处。
Streamed(String),
/// 当前配置不支持流式:用户没开 streaming_insert / Gemini provider / Codex
/// provider / Raw 模式 / 翻译模式 / 不是 macOS。调用方应回到现有的
/// `polish_or_passthrough` 一次性路径,跟历史行为完全一致。
UnsupportedFallback,
/// 流式过程中失败(HTTP / 解析 / 空流等)。`String` 是失败原因,调用方应当
/// 走 raw 兜底(同 `polish_or_passthrough` 失败分支的语义)。
Failed(String),
}
/// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方
/// 透明降级。不修改任何持久化 / 焦点 / 光标状态。
///
/// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际
/// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。
/// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。
pub async fn polish_or_passthrough_streaming<F, C>(
raw: &RawTranscript,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
on_delta: F,
should_cancel: C,
) -> StreamingPolishOutcome
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) {
log::info!("[coord] streaming polish skipped: mode=Raw, fall back to one-shot");
return StreamingPolishOutcome::UnsupportedFallback;
}
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
log::info!(
"[coord] streaming polish skipped: active LLM provider=gemini (v1 not implemented), fall back to one-shot"
);
return StreamingPolishOutcome::UnsupportedFallback;
}
let provider = match build_active_llm_provider(llm_thinking_enabled) {
Ok(p) => p,
Err(e) => {
log::error!("[coord] streaming polish: build provider failed: {e}");
return StreamingPolishOutcome::Failed(e.to_string());
}
};
if !provider.supports_streaming_polish() {
log::info!(
"[coord] streaming polish skipped: provider does not support streaming (likely codex OAuth), fall back to one-shot"
);
return StreamingPolishOutcome::UnsupportedFallback;
}
log::info!(
"[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}",
mode,
raw.text.chars().count(),
prior_turns.len()
);
match provider
.polish_streaming(
&raw.text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
on_delta,
should_cancel,
)
.await
{
Ok(text) => {
log::info!(
"[coord] streaming polish OK: final_chars={}",
text.chars().count()
);
StreamingPolishOutcome::Streamed(text)
}
Err(e) => {
let reason = e.to_string();
log::error!("[coord] streaming polish FAILED: {reason}");
StreamingPolishOutcome::Failed(reason)
}
}
}
async fn polish_or_passthrough(
raw: &RawTranscript,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> (String, Option<String>) {
if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) {
return (raw.text.clone(), None);
}
match polish_text(
&raw.text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await
{
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] polish failed, falling back to raw: {reason}");
(raw.text.clone(), Some(reason))
}
}
}
async fn polish_text(
raw: &str,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> anyhow::Result<String> {
// 谷歌 Gemini 分支:所有 LLM provider 共用 ark.* 凭据槽,唯独 Gemini 走原生
// generateContent / 自带 thinkingConfig 控制;其余 provider 走 OpenAI
// 兼容协议,并在该路径里按 provider/channel 下发对应的思考开关。
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
let (api_key, model, base_url) = read_gemini_credentials()?;
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
return Ok(provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
Ok(provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await?)
}
/// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底——
/// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。
async fn translate_text(
raw: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
) -> anyhow::Result<String> {
// 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑。
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
let (api_key, model, base_url) = read_gemini_credentials()?;
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
return Ok(provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
Ok(provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await?)
}
/// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。
/// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。
const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]";
const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]";
/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文"
/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物,
/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。
fn build_polish_translate_system_prompt(target_language: &str) -> String {
let base = crate::polish::prompts::translate_system_prompt(target_language);
format!(
"{base}\n\n\
# 额外输出:润色后的源文(仅用于对话上下文,不展示给用户)\n\
在译文之前,先把上面的原始转写**按它本来的语言**润色一遍:去掉口癖(嗯 / 那个 / um)、\
补必要标点、纠正明显的识别错误,但**不翻译、不改写风格、不增删意思**。\n\n\
# 输出格式(覆盖上面\u{201C}只输出译文\u{201D}的说明,严格遵守)\n\
严格按下面两段输出,两个标记必须原样出现、各占一行,标记之外不要有任何多余文字:\n\
{src}\n\
(这里放润色后的源文,保持原语言)\n\
{tgt}\n\
(这里放翻译成\u{300C}{lang}\u{300D}的译文)",
base = base,
src = POLISH_TRANSLATE_SRC_MARKER,
tgt = POLISH_TRANSLATE_TGT_MARKER,
lang = target_language,
)
}
/// 解析"润色+翻译"单次调用输出 → Some((润色后源文, 译文))。
/// 找到译文标记且译文非空 → Some((源文, 译文)):源文标记缺失 / 源文段为空时源文为 None,
/// 译文取标记之后的干净正文。**没有译文标记、或译文段为空(模型截断 / 只吐了标记)→ None**,
/// 表示没拿到可信译文,交由调用方退回专用翻译——避免把空串当"成功译文"插进光标而丢字。
fn split_polish_translate_output(raw: &str) -> Option<(Option<String>, String)> {
let tgt_idx = raw.find(POLISH_TRANSLATE_TGT_MARKER)?;
let translation = raw[tgt_idx + POLISH_TRANSLATE_TGT_MARKER.len()..]
.trim()
.to_string();
if translation.is_empty() {
return None;
}
let before_tgt = &raw[..tgt_idx];
let source = before_tgt
.find(POLISH_TRANSLATE_SRC_MARKER)
.map(|i| {
before_tgt[i + POLISH_TRANSLATE_SRC_MARKER.len()..]
.trim()
.to_string()
})
.filter(|s| !s.is_empty());
Some((source, translation))
}
/// 翻译路径——单次 LLM 调用同时润色源文 + 翻译。和 polish 一样失败时返回原文 + 失败原因,
/// 避免"不丢字"约定被违反(CLAUDE.md)。返回 (要插入的译文, 润色后源文供上下文用, 失败原因)。
#[allow(clippy::too_many_arguments)]
async fn polish_and_translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> (String, Option<String>, Option<String>) {
let system_prompt = build_polish_translate_system_prompt(target_language);
match polish_text(
&raw.text,
mode,
hotwords,
&system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await
{
Ok(out) => match split_polish_translate_output(&out) {
Some((source, translation)) => (translation, source, None),
None => {
// 模型没按两段格式输出:退回专用翻译拿一段干净译文,避免把畸形输出插进光标。
// 此时无可信源文,这条翻译历史不参与后续普通润色上下文。
log::warn!(
"[coord] polish+translate output missing markers; falling back to plain translate"
);
match translate_text(
&raw.text,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
)
.await
{
Ok(translation) => (translation, None, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] fallback translate failed, using raw: {reason}");
(raw.text.clone(), None, Some(reason))
}
}
}
},
Err(e) => {
let reason = e.to_string();
log::error!("[coord] polish+translate failed, falling back to raw: {reason}");
(raw.text.clone(), None, Some(reason))
}
}
}
fn read_whisper_credentials() -> (String, String, String) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "whisper-1".to_string());
(api_key, base_url, model)
}
fn read_bailian_credentials() -> BailianCredentials {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string());
let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty());
BailianCredentials {
api_key,
endpoint,
model,
vocabulary_id,
}
}
fn read_volc_credentials() -> VolcengineCredentials {
let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey)
.ok()
.flatten()
.unwrap_or_default();
let access_token = CredentialsVault::get(CredentialAccount::VolcengineAccessKey)
.ok()
.flatten()
.unwrap_or_default();
let resource_id = CredentialsVault::get(CredentialAccount::VolcengineResourceId)
.ok()
.flatten()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string());
VolcengineCredentials {
app_id,
access_token,
resource_id,
}
}
fn enabled_hotwords(inner: &Arc<Inner>) -> Vec<DictionaryHotword> {
inner
.vocab
.list()
.unwrap_or_default()
.into_iter()
.map(|e| DictionaryHotword {
phrase: e.phrase,
enabled: e.enabled,
})
.collect()
}
// ─────────────────────────── QA session lifecycle ───────────────────────────
/// 划词语音问答会话(issue #118)。
///
/// 与 dictation 完全分离:
/// - 不进 SessionPhase(互不抢锁)
/// - 不写 history.json(除非 prefs.qa_save_history=true 才旁路写一条 placeholder)
/// - 用独立的 qa_recorder + qa_asr,复用现有 Volcengine ASR 通路
async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
{
let mut state = inner.qa_state.lock();
if !state.panel_visible {
// 防御:浮窗没开就被叫到这里说明路由错了,直接退出。
return Ok(());
}
if state.phase != QaPhase::Idle {
return Ok(());
}
state.phase = QaPhase::Recording;
state.cancelled = false;
state.session_id = new_session_id();
state.front_app = capture_frontmost_app();
state.selection = None;
}
// 重置 SSE 取消标志:上一轮可能 set 过的 true 留着会让本轮流式立即 break。
inner.qa_stream_cancelled.store(false, Ordering::SeqCst);
// 抓选区。每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。
//
// - macOS:浮窗走 orderFrontRegardless,不成为 key window,原 app 仍是 frontmost,
// AX/Cmd+C fallback 都能拿到。
// - Windows:#466 修复后 show_qa_window_no_activate 主动抓焦点,QA 此刻已是前台,
// simulate_copy 会跑在 QA 自己 webview 上 → 抓不到。focus-dance 上半场:把焦点临时
// 还给"用户原 app 的 HWND"。
//
// 多轮场景的目标刷新:用户开 QA 后可能 Alt+Tab 切到别的 app 选新文字。如果还死认
// open_qa_panel 时记下的初始 HWND,会把焦点抢回错的 app(pr_agent stale-focus 关注点)。
// 策略:每轮先看当前前台是不是本进程的窗口(QA / capsule / main)—— 是 → 用户没切
// 走,沿用 saved;不是 → 用户切到了真正的外部 app,刷新 saved 为当前 HWND。
// 抓完选区后下半场再把焦点交还 QA,让 ESC/X 继续可用。
#[cfg(target_os = "windows")]
{
// 合并两次 lock:原来分 lock #1 写 + lock #2 读,两者之间 close_qa_panel 在别的
// 线程把 qa_focus_target 清成 None 会被覆盖回旧 HWND。Cloud 评审指出的 TOCTOU。
// 单次加锁里既写最新外部前台、再读出来交给后面的 restore_focus_target_if_possible
// —— capture_external_focus_target() 内部只调 GetForegroundWindow / pid 查询,
// 不会反向取 qa_state 锁,持锁期间调用安全。
let saved_target = {
let mut state = inner.qa_state.lock();
if let Some(current_external) = capture_external_focus_target() {
state.qa_focus_target = Some(current_external);
}
state.qa_focus_target
};
let _ = restore_focus_target_if_possible(saved_target);
}
let selection = capture_selection();
#[cfg(target_os = "windows")]
if let Some(app) = inner.app.lock().clone() {
crate::refocus_qa_window(&app);
}
let selection_preview_text = selection.as_ref().map(|s| s.text.clone());
inner.qa_state.lock().selection = selection.clone();
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "recording",
"selection_preview": selection_preview_text,
"messages": messages,
}),
);
}
// 2. QA 与 dictation 使用同一个 active ASR 入口。不要回退火山,否则用户配置
// 百炼 / Whisper / 本地 ASR 后,浮窗仍会偷偷走另一套凭据。
let active_asr = CredentialsVault::get_active_asr();
if let Err(message) = ensure_asr_credentials() {
log::warn!("[coord] QA: active ASR credentials missing: {message}");
finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}"));
return Err(message);
}
if let Err(message) = ensure_microphone_permission(inner) {
log::warn!("[coord] QA: microphone permission gate failed: {message}");
finish_qa_with_error(inner, message.clone());
return Err(message);
}
let qa_asr = match build_qa_asr_start(inner, &active_asr).await {
Ok(qa_asr) => qa_asr,
Err(message) => {
log::error!("[coord] QA active ASR init failed: {message}");
finish_qa_with_error(inner, format!("ASR 初始化失败: {message}"));
return Err(message);
}
};
let consumer = qa_asr.recorder_consumer();
*inner.qa_asr.lock() = Some(qa_asr.active_asr());
// QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图,
// 这里发一份事件给 "qa" label 用就够了。
let inner_for_level = Arc::clone(inner);
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let phase = inner_for_level.qa_state.lock().phase;
if phase != QaPhase::Recording {
return;
}
let now = Instant::now();
{
let mut last = last_emit_at.lock();
if let Some(prev) = *last {
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
return;
}
}
*last = Some(now);
}
if let Some(app) = inner_for_level.app.lock().clone() {
let _ = app.emit_to("qa", "qa:level", serde_json::json!({ "level": level }));
}
// 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
0,
None,
None,
);
});
let microphone_device_name = selected_microphone_device_name(inner);
stop_microphone_preview_monitor(inner, "QA recorder");
acquire_recording_mute(inner, "qa").await;
// QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。
// 调试 QA 麦克风请用主听写路径。
match Recorder::start(microphone_device_name, consumer, level_handler, None) {
Ok((rec, runtime_errors, archive_active)) => {
// QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation
// 接力时读到上一个 QA session 的过期值。
inner
.audio_archive_active
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
*inner.qa_recorder.lock() = Some(rec);
// QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时
// 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。
spawn_qa_recorder_error_monitor(inner, runtime_errors);
}
Err(e) => {
log::error!("[coord] QA recorder start failed: {e}");
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
release_recording_mute(inner, "qa");
finish_qa_with_error(inner, format!("录音启动失败: {e}"));
return Err(e.to_string());
}
}
if let Err(e) = qa_asr.open_streaming_session().await {
log::error!("[coord] QA: open ASR session failed: {e}");
stop_qa_recorder(inner);
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
finish_qa_with_error(inner, format!("ASR 连接失败: {e}"));
return Err(e);
}
// cancel race:在 await 期间用户可能 dismiss 了浮窗。
if inner.qa_state.lock().cancelled {
log::info!("[coord] QA cancel raced during open_session — aborting begin");
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
stop_qa_recorder(inner);
inner.qa_state.lock().phase = QaPhase::Idle;
return Ok(());
}
// 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…"
// 的视觉反馈跟主听写完全一致。
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
Ok(())
}
async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
{
let mut state = inner.qa_state.lock();
if state.phase != QaPhase::Recording {
return Ok(());
}
state.phase = QaPhase::Processing;
}
// 胶囊进入 Transcribing:用户视觉上看到"识别中"。
emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None);
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit_to("qa", "qa:state", serde_json::json!({ "kind": "loading" }));
}
stop_qa_recorder(inner);
let asr = match inner.qa_asr.lock().take() {
Some(a) => a,
None => {
inner.qa_state.lock().phase = QaPhase::Idle;
return Ok(());
}
};
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
let qa_session_id = inner.qa_state.lock().session_id;
let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr);
let raw = match asr {
ActiveAsr::Volcengine(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] QA: send last frame failed: {e}");
}
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: await final failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: 全局超时 {} 秒 - 强制恢复",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
finish_qa_with_error(inner, "识别超时".to_string());
return Err("global timeout".to_string());
}
}
}
ActiveAsr::Bailian(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] QA: Bailian send last frame failed: {e}");
}
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: Bailian await final failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: Bailian 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
finish_qa_with_error(inner, "识别超时".to_string());
return Err("bailian global timeout".to_string());
}
}
}
ActiveAsr::Whisper(w) => {
debug_assert!(uses_global_timeout);
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, w.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: whisper transcribe failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: whisper 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
finish_qa_with_error(inner, "识别超时".to_string());
return Err("whisper global timeout".to_string());
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => {
debug_assert!(!uses_global_timeout);
match local
.transcribe(foundry_audio_transcribe_timeout_duration())
.await
{
Ok(r) => {
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
r
}
Err(e) => {
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
if inner.qa_state.lock().cancelled {
log::info!(
"[coord] QA Foundry Local Whisper transcribe cancelled — discarding transcript"
);
finish_qa_idle_silently(inner);
return Ok(());
}
log::error!("[coord] QA Foundry Local Whisper transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => {
debug_assert!(!uses_global_timeout);
match local
.transcribe(sherpa_audio_transcribe_timeout_duration())
.await
{
Ok(r) => {
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
r
}
Err(e) => {
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
if inner.qa_state.lock().cancelled {
log::info!(
"[coord] QA sherpa-onnx transcribe cancelled — discarding transcript"
);
finish_qa_idle_silently(inner);
return Ok(());
}
log::error!("[coord] QA sherpa-onnx transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
}
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => {
debug_assert!(uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
log::info!(
"[coord] QA local Qwen3-ASR transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
inner.local_asr_cache.touch();
schedule_local_asr_release(inner);
match result {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA local Qwen3-ASR transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA local Qwen3-ASR transcribe timeout after {}s",
timeout_duration.as_secs()
);
finish_qa_with_error(inner, "本地识别超时".to_string());
return Err("local qwen transcribe timeout".to_string());
}
}
}
};
// cancel race:用户在 transcribe 中按 Esc / dismiss → 静默退出。
if inner.qa_state.lock().cancelled {
log::info!("[coord] QA cancel detected after ASR — discarding transcript");
finish_qa_idle_silently(inner);
return Ok(());
}
let question = raw.text.trim().to_string();
if question.is_empty() {
// 静默录音:不调 LLM,不弹错误,直接关浮窗。
log::info!("[coord] QA: empty transcript → silent dismiss");
finish_qa_idle_silently(inner);
return Ok(());
}
// 拼这一轮的 user 消息:第一轮(messages 还空)把选区原文嵌进去;
// 之后的轮次只送提问,让 LLM 顺着上下文回答。详见 issue #118 v2。
let user_content = {
let st = inner.qa_state.lock();
let is_first_turn = st.messages.is_empty();
let sel_text = st
.selection
.as_ref()
.map(|s| s.text.clone())
.unwrap_or_default();
if is_first_turn && !sel_text.trim().is_empty() {
format!(
"# 选区原文\n{}\n\n# 我的问题\n{}",
sel_text.trim(),
question
)
} else {
question.clone()
}
};
inner
.qa_state
.lock()
.messages
.push(crate::types::QaChatMessage {
role: "user".to_string(),
content: user_content,
});
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "thinking",
"messages": messages,
}),
);
}
// 胶囊:思考阶段(复用 dictation 的 Polishing 状态——视觉上是"润色中",QA 借用一下)。
emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None);
let prefs = inner.prefs.get();
let working_languages = prefs.working_languages.clone();
let chinese_script_preference = prefs.chinese_script_preference;
let output_language_preference = prefs.output_language_preference;
let llm_thinking_enabled = prefs.llm_thinking_enabled;
let (messages_for_llm, front_app) = {
let st = inner.qa_state.lock();
(st.messages.clone(), st.front_app.clone())
};
// 流式回调:每个 SSE delta 立刻推一帧 qa:state{kind:"answer_delta"} 给前端,
// 浮窗里气泡边收边长。最终的 messages 由 answer 事件统一下发(保证一致性)。
//
// session_id 守卫(issue #161):闭包捕获本会话 id;用户取消 → 关浮窗 → 开新浮窗
// 开新一轮时,旧的 in-flight LLM 流仍可能 emit chunk,必须在 emit 前比对当前
// qa_state.session_id == 捕获 id,否则跳过——避免旧会话的字漏进新气泡。
let captured_session_id = inner.qa_state.lock().session_id;
let inner_for_delta = Arc::clone(inner);
let on_delta = move |chunk: &str| {
let cur_id = inner_for_delta.qa_state.lock().session_id;
if cur_id != captured_session_id {
return; // 旧 session 漏来的 chunk,丢弃
}
if let Some(app) = inner_for_delta.app.lock().clone() {
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "answer_delta",
"chunk": chunk,
}),
);
}
};
// SSE 流取消旗标:cancel_qa_session / close_qa_panel 会 set true,
// polish 的 SSE loop 每帧检查 → break,释放 HTTP body。详见 issue #161。
let cancel_flag = Arc::clone(&inner.qa_stream_cancelled);
let should_cancel = move || cancel_flag.load(Ordering::Relaxed);
let answer = match answer_chat_dispatch(
&messages_for_llm,
&working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app.as_deref(),
on_delta,
should_cancel,
)
.await
{
Ok(s) => s,
Err(e) => {
log::error!("[coord] QA: LLM answer failed: {e}");
// 把刚 push 的 user 消息回滚,避免 retry 重复
inner.qa_state.lock().messages.pop();
finish_qa_with_error(inner, format!("回答失败: {e}"));
return Err(e.to_string());
}
};
if inner.qa_state.lock().cancelled {
log::info!("[coord] QA cancel detected before answer — discarding");
// 同样回滚未配对的 user 消息
inner.qa_state.lock().messages.pop();
finish_qa_idle_silently(inner);
return Ok(());
}
inner
.qa_state
.lock()
.messages
.push(crate::types::QaChatMessage {
role: "assistant".to_string(),
content: answer.clone(),
});
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "answer",
"messages": messages,
}),
);
}
// 胶囊直接收掉。QA 不走 insertion,没"已粘贴 N 字"语义;浮窗里答案就是用户的反馈。
// (之前用 Done 状态会被 capsule UI 错误地渲染上一次 dictation 残留的 message/insertedChars。)
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
// 可选:写一条 history(QA 类型)。当前 DictationSession schema 不能直接表达
// "QuestionAnswer" 类型,因此简单做法:勾选 qa_save_history 时写一条
// mode=Raw、error_code=Some("qaSession") 的 placeholder,避免污染 schema 同时
// 让用户能在历史里翻到这次问答的字面值。详见 issue #118。
if prefs.qa_save_history {
let session = DictationSession {
id: Uuid::new_v4().to_string(),
created_at: Utc::now().to_rfc3339(),
raw_transcript: question.clone(),
final_text: answer.clone(),
mode: PolishMode::Raw,
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: front_app.clone(),
insert_status: InsertStatus::CopiedFallback,
error_code: Some("qaSession".to_string()),
duration_ms: Some(raw.duration_ms),
dictionary_entry_count: None,
has_audio_recording: None,
};
let prefs_snapshot = inner.prefs.get();
if let Err(e) = inner.history.append_with_retention(
session,
prefs_snapshot.history_retention_days,
prefs_snapshot.history_max_entries,
) {
log::error!("[coord] QA history append failed: {e}");
}
}
inner.qa_state.lock().phase = QaPhase::Idle;
Ok(())
}
/// 把出错状态送到前端浮窗 + 胶囊错误闪一下 + 复位 phase。
/// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去
/// 让前端继续渲染历史对话。
fn finish_qa_with_error(inner: &Arc<Inner>, message: String) {
stop_qa_recorder(inner);
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "error",
"error": message,
"messages": messages,
}),
);
}
emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(message), None);
schedule_capsule_idle(inner, 1500);
let mut state = inner.qa_state.lock();
state.phase = QaPhase::Idle;
state.cancelled = false;
}
/// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户
/// Esc/X 或再按 QA hotkey 时才关);多轮对话历史保留。胶囊也即刻收掉。
fn finish_qa_idle_silently(inner: &Arc<Inner>) {
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "idle",
"messages": messages,
}),
);
}
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
let mut state = inner.qa_state.lock();
state.phase = QaPhase::Idle;
state.cancelled = false;
state.selection = None;
}
fn cancel_qa_session(inner: &Arc<Inner>) {
let phase = inner.qa_state.lock().phase;
if phase == QaPhase::Idle {
return;
}
inner.qa_state.lock().cancelled = true;
// SSE 流取消旗标——polish::chat_completion_history_streaming 的 loop 每帧检查
// 这个 flag,true 时立即 break 不再 drain HTTP body,避免取消后 LLM 仍烧 token。
// 详见 issue #161。
inner.qa_stream_cancelled.store(true, Ordering::SeqCst);
stop_qa_recorder(inner);
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
// Processing 阶段保持 phase 让 end_qa_session 自然走完 cancel 检查;
// 否则直接复位。
if phase != QaPhase::Processing {
inner.qa_state.lock().phase = QaPhase::Idle;
}
log::info!("[coord] QA session cancelled (was {phase:?})");
}
async fn answer_chat_dispatch<F, C>(
messages: &[crate::types::QaChatMessage],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
) -> anyhow::Result<String>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
// 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑,
// QA 流式回答走 Gemini 原生 :streamGenerateContent?alt=sse。
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
let (api_key, model, base_url) = read_gemini_credentials()?;
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
return Ok(provider
.answer_chat_streaming(
messages,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
on_delta,
should_cancel,
)
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
Ok(provider
.answer_chat_streaming(
messages,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
on_delta,
should_cancel,
)
.await?)
}
/// 读 Gemini 凭据。所有 LLM provider 共用 ark.* 槽位(persistence 没做 per-provider
/// 隔离),所以这里也是从 `ArkApiKey` / `ArkModelId` / `ArkEndpoint` 三个槽读,
/// 但回退默认值改成谷歌的:base_url 默认 `https://generativelanguage.googleapis.com/v1beta`,
/// 模型默认 `gemini-2.5-flash`。Settings.tsx::onLlmProviderChange 在用户切到 gemini
/// 时会强制把 endpoint/model 覆盖为这两个默认值,所以 99% 情况下槽里读出来就是
/// 这两个;这里的 `unwrap_or_else` 是给极端情况兜底(如旧版本切换 bug 留下的脏数据)。
///
/// base_url 末尾去掉 `/`,让 `llm_gemini::generate_content_url` 拼接稳定。
/// 不去 `/chat/completions` 后缀——OpenAI 兼容路径才会有那个后缀,原生 Gemini 不会。
fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "gemini-2.5-flash".to_string());
let base_url = CredentialsVault::get(CredentialAccount::ArkEndpoint)?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
if api_key.trim().is_empty() {
anyhow::bail!("API Key 为空");
}
let base_url = base_url.trim_end_matches('/').to_string();
Ok((api_key, model, base_url))
}
fn build_active_llm_provider(llm_thinking_enabled: bool) -> anyhow::Result<ActiveLLMProvider> {
let active = CredentialsVault::get_active_llm();
let model =
CredentialsVault::get(CredentialAccount::ArkModelId)?.filter(|s| !s.trim().is_empty());
if active == CODEX_OAUTH_PROVIDER_ID {
let config =
CodexOAuthConfig::new(model.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string()))
.with_thinking_enabled(llm_thinking_enabled);
return Ok(ActiveLLMProvider::Codex(CodexOAuthLLMProvider::new(config)));
}
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = model.unwrap_or_else(|| "deepseek-v3-2".to_string());
let endpoint = resolve_ark_endpoint(&api_key)?;
let base_url = endpoint
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let config = OpenAICompatibleConfig::new(active, "OpenLess LLM", base_url, api_key, model)
.with_thinking_enabled(llm_thinking_enabled);
Ok(ActiveLLMProvider::OpenAI(OpenAICompatibleLLMProvider::new(
config,
)))
}
fn resolve_ark_endpoint(api_key: &str) -> anyhow::Result<String> {
let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty());
resolve_ark_endpoint_with_policy(api_key, endpoint)
}
fn resolve_ark_endpoint_with_policy(
api_key: &str,
endpoint: Option<String>,
) -> anyhow::Result<String> {
if api_key.trim().is_empty() && endpoint.is_none() {
anyhow::bail!("API Key 为空");
}
Ok(endpoint
.unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string()))
}
#[cfg(test)]
mod tests {
use super::dictation::abort_recording_with_error;
use super::*;
use crate::types::{HotkeyMode, HotkeyTrigger};
use once_cell::sync::Lazy;
static ENV_LOCK: Lazy<tokio::sync::Mutex<()>> = Lazy::new(|| tokio::sync::Mutex::new(()));
fn session_id(n: u128) -> SessionId {
Uuid::from_u128(n)
}
#[test]
fn split_polish_translate_parses_both_sections() {
let out = format!(
"{POLISH_TRANSLATE_SRC_MARKER}\n你好,世界。\n{POLISH_TRANSLATE_TGT_MARKER}\nHello, world."
);
let (source, translation) = split_polish_translate_output(&out).expect("both markers");
assert_eq!(source.as_deref(), Some("你好,世界。"));
assert_eq!(translation, "Hello, world.");
}
#[test]
fn split_polish_translate_no_translation_marker_returns_none_for_fallback() {
// 完全没有译文标记 → None,调用方据此退回专用翻译拿干净译文。
assert_eq!(split_polish_translate_output(" Hello, world. "), None);
}
#[test]
fn split_polish_translate_empty_translation_returns_none_for_fallback() {
// 有译文标记但内容为空(截断 / 只吐标记)→ None,避免空串当成功译文插入光标。
let out =
format!("{POLISH_TRANSLATE_SRC_MARKER}\n你好。\n{POLISH_TRANSLATE_TGT_MARKER}\n ");
assert_eq!(split_polish_translate_output(&out), None);
}
#[test]
fn split_polish_translate_only_translation_marker_keeps_clean_translation() {
let out = format!("noise{POLISH_TRANSLATE_TGT_MARKER}\nHola");
let (source, translation) = split_polish_translate_output(&out).expect("tgt marker");
assert_eq!(source, None);
assert_eq!(translation, "Hola");
}
#[test]
fn split_polish_translate_empty_source_section_is_none() {
let out = format!("{POLISH_TRANSLATE_SRC_MARKER}\n \n{POLISH_TRANSLATE_TGT_MARKER}\nHi");
let (source, translation) = split_polish_translate_output(&out).expect("tgt marker");
assert_eq!(source, None);
assert_eq!(translation, "Hi");
}
#[test]
fn build_polish_translate_prompt_contains_markers_and_target() {
let p = build_polish_translate_system_prompt("日本語");
assert!(p.contains(POLISH_TRANSLATE_SRC_MARKER));
assert!(p.contains(POLISH_TRANSLATE_TGT_MARKER));
assert!(p.contains("日本語"));
}
#[tokio::test]
async fn hotkey_injection_gate_logs_pressed_and_cancels() {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Info)
.is_test(false)
.try_init();
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
coordinator.inject_hotkey_click_for_dev().await.unwrap();
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
#[tokio::test]
async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
let old_session_id = coordinator.inner.state.lock().session_id;
{
let mut state = coordinator.inner.state.lock();
state.pending_stop = true;
state.cancelled = true;
}
coordinator.start_dictation().await.unwrap();
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Listening);
assert!(!state.pending_stop);
assert!(!state.cancelled);
assert_ne!(state.session_id, old_session_id);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
#[tokio::test]
async fn begin_session_ignores_non_idle_phase() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
let old_session_id = {
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Processing;
state.session_id = session_id(99);
state.session_id
};
coordinator.start_dictation().await.unwrap();
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Processing);
assert_eq!(state.session_id, old_session_id);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
#[test]
fn window_key_matcher_mirrors_windows_trigger_aliases() {
let cases = [
(HotkeyTrigger::RightControl, "Control", "ControlRight"),
(HotkeyTrigger::LeftControl, "Control", "ControlLeft"),
(HotkeyTrigger::RightOption, "Alt", "AltRight"),
(HotkeyTrigger::RightAlt, "AltGraph", "AltRight"),
(HotkeyTrigger::RightCommand, "Meta", "MetaRight"),
(HotkeyTrigger::LeftOption, "Alt", "AltLeft"),
// Mirrors Windows trigger_to_vk_code aliases.
(HotkeyTrigger::Fn, "Control", "ControlRight"),
];
for (trigger, key, code) in cases {
assert!(
window_key_matches_trigger(trigger, key, code),
"{trigger:?} should match {key}/{code}"
);
}
assert!(!window_key_matches_trigger(
HotkeyTrigger::RightControl,
"Control",
"ControlLeft"
));
assert!(!window_key_matches_trigger(
HotkeyTrigger::LeftOption,
"Alt",
"AltRight"
));
assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn"));
}
#[test]
fn windows_local_providers_are_keyless_and_not_whisper_compatible() {
#[cfg(target_os = "windows")]
assert!(is_keyless_local_asr_provider(
crate::asr::local::foundry::PROVIDER_ID
));
#[cfg(target_os = "windows")]
assert!(is_keyless_local_asr_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
#[cfg(not(target_os = "windows"))]
assert!(!is_keyless_local_asr_provider(
crate::asr::local::foundry::PROVIDER_ID
));
#[cfg(not(target_os = "windows"))]
assert!(!is_keyless_local_asr_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
assert!(!is_whisper_compatible_provider(
crate::asr::local::foundry::PROVIDER_ID
));
assert!(!is_whisper_compatible_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
}
#[test]
fn verbose_json_enabled_only_for_whisper_family() {
// verbose_json + 幻听过滤只对返回完整 Whisper 指标的 provider 开启。
assert!(whisper_supports_verbose_json("whisper"));
assert!(whisper_supports_verbose_json("groq"));
// SiliconFlow(SenseVoice/TeleSpeech) / Zhipu(GLM-ASR) 保持旧的 json 行为。
assert!(!whisper_supports_verbose_json("siliconflow"));
assert!(!whisper_supports_verbose_json("zhipu"));
}
#[test]
fn qa_asr_provider_kind_tracks_active_provider() {
assert_eq!(
active_asr_provider_kind(crate::asr::bailian::PROVIDER_ID),
ActiveAsrProviderKind::Bailian
);
assert_eq!(
active_asr_provider_kind("whisper"),
ActiveAsrProviderKind::WhisperCompatible
);
assert_eq!(
active_asr_provider_kind("volcengine"),
ActiveAsrProviderKind::Volcengine
);
}
#[cfg(target_os = "windows")]
#[test]
fn coordinator_shares_app_foundry_runtime() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(Arc::clone(&runtime));
assert!(Arc::ptr_eq(
&runtime,
&coordinator.inner.foundry_local_runtime
));
}
#[cfg(target_os = "windows")]
#[test]
fn foundry_transcribe_skips_global_timeout_for_first_run_provisioning() {
let provider = Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new(
Arc::new(crate::asr::local::FoundryLocalRuntime::new()),
crate::asr::local::foundry::DEFAULT_MODEL_ALIAS.to_string(),
"auto".to_string(),
None,
));
let active_asr = ActiveAsr::FoundryLocalWhisper(provider);
assert!(!asr_transcribe_uses_global_timeout(&active_asr));
}
#[cfg(target_os = "windows")]
#[test]
fn foundry_audio_transcribe_timeout_is_separate_from_prepare() {
let timeout = foundry_audio_transcribe_timeout_duration();
assert_eq!(
timeout,
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[test]
fn local_qwen_timeout_floors_at_global_timeout_for_short_audio() {
// 5s 录音:5 × 0.6 = 3, +10 = 13, max(15) = 15。短录音保留 15s 兜底。
assert_eq!(
local_qwen_transcribe_timeout(5.0),
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[test]
fn local_qwen_timeout_scales_with_audio_duration() {
// 60s 录音:60 × 0.6 = 36, +10 = 46s。覆盖 RTF ≈ 0.5 的边界。
assert_eq!(
local_qwen_transcribe_timeout(60.0),
std::time::Duration::from_secs(46)
);
}
#[test]
fn local_qwen_timeout_ceils_partial_seconds() {
// 10.1s 录音:10.1 × 0.6 = 6.06, ceil = 7, +10 = 17, max(15) = 17。
assert_eq!(
local_qwen_transcribe_timeout(10.1),
std::time::Duration::from_secs(17)
);
}
#[test]
fn local_qwen_timeout_handles_zero_duration() {
// 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(15) = 15。
assert_eq!(
local_qwen_transcribe_timeout(0.0),
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[cfg(target_os = "windows")]
#[test]
fn foundry_release_uses_foundry_keep_loaded_preference() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(runtime);
let mut prefs = coordinator.inner.prefs.get();
prefs.local_asr_keep_loaded_secs = 3;
prefs.foundry_local_asr_keep_loaded_secs = 7;
coordinator.inner.prefs.set(prefs).unwrap();
assert_eq!(foundry_local_asr_release_keep_secs(&coordinator.inner), 7);
}
#[cfg(target_os = "windows")]
#[test]
fn foundry_release_guard_rejects_stale_dictation_session() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(runtime);
let old_session_id = coordinator.inner.state.lock().session_id;
assert!(asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Dictation(old_session_id)
));
coordinator.inner.state.lock().session_id = new_session_id();
assert!(!asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Dictation(old_session_id)
));
}
#[cfg(target_os = "windows")]
#[test]
fn local_asr_release_guard_rejects_stale_qa_session() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(runtime);
let old_session_id = coordinator.inner.qa_state.lock().session_id;
assert!(asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Qa(old_session_id)
));
coordinator.inner.qa_state.lock().session_id = new_session_id();
assert!(!asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Qa(old_session_id)
));
}
#[test]
fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() {
assert_eq!(
resolve_ark_endpoint_with_policy("", None)
.unwrap_err()
.to_string(),
"API Key 为空"
);
}
#[test]
fn resolve_ark_endpoint_allows_blank_key_with_custom_endpoint() {
let endpoint = resolve_ark_endpoint_with_policy(
"",
Some("https://example.com/v1/chat/completions".to_string()),
)
.unwrap();
assert_eq!(endpoint, "https://example.com/v1/chat/completions");
}
#[test]
fn deferred_asr_bridge_flushes_startup_audio_before_live_chunks() {
#[derive(Default)]
struct RecordingConsumer {
bytes: Mutex<Vec<u8>>,
}
impl crate::asr::AudioConsumer for RecordingConsumer {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
self.bytes.lock().extend_from_slice(pcm);
}
}
let bridge = DeferredAsrBridge::new();
crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[1, 2]);
crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[3, 4]);
let target = Arc::new(RecordingConsumer::default());
let target_for_attach: Arc<dyn crate::asr::AudioConsumer> = target.clone();
assert_eq!(bridge.attach(target_for_attach), 4);
crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[5, 6]);
assert_eq!(&*target.bytes.lock(), &[1, 2, 3, 4, 5, 6]);
}
#[tokio::test]
async fn manual_stop_during_starting_is_queued() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Starting;
state.pending_stop = false;
}
coordinator.stop_dictation().await.unwrap();
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Starting);
assert!(state.pending_stop);
}
#[tokio::test]
async fn stop_dictation_from_listening_without_asr_returns_idle() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Listening;
state.session_id = session_id(123);
}
coordinator.stop_dictation().await.unwrap();
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
}
#[test]
fn cancel_session_state_machine_is_table_driven() {
let cases = [
(SessionPhase::Idle, SessionPhase::Idle, false),
(SessionPhase::Starting, SessionPhase::Idle, true),
(SessionPhase::Listening, SessionPhase::Idle, true),
(SessionPhase::Processing, SessionPhase::Processing, true),
(SessionPhase::Inserting, SessionPhase::Inserting, false),
];
for (initial, expected_phase, expected_cancelled) in cases {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = initial;
state.cancelled = false;
state.focus_target = Some(1);
}
coordinator.cancel_dictation();
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, expected_phase, "initial={initial:?}");
assert_eq!(state.cancelled, expected_cancelled, "initial={initial:?}");
if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) {
assert!(state.focus_target.is_none(), "initial={initial:?}");
}
}
}
#[test]
fn recorder_runtime_error_aborts_active_session() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Listening;
state.cancelled = false;
}
abort_recording_with_error(&coordinator.inner, "录音中断: stream failed".to_string());
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Idle);
assert!(state.cancelled);
assert!(coordinator.inner.recorder.lock().is_none());
assert!(coordinator.inner.asr.lock().is_none());
}
#[test]
fn abort_recording_keeps_session_non_idle_until_restore_can_run() {
let mut state = SessionState::default();
state.phase = SessionPhase::Listening;
state.cancelled = false;
state.session_id = session_id(7);
let abort = begin_recording_abort_before_restore(&mut state).unwrap();
assert_eq!(abort.session_id, session_id(7));
assert!(state.cancelled);
assert_eq!(state.phase, SessionPhase::Listening);
publish_abort_idle_after_restore(&mut state, abort.session_id);
assert_eq!(state.phase, SessionPhase::Idle);
}
#[tokio::test]
async fn pressed_edge_during_inserting_does_not_start_new_session() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Inserting;
state.session_id = session_id(41);
}
handle_pressed_edge(&coordinator.inner).await;
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Inserting);
assert_eq!(state.session_id, session_id(41));
}
#[tokio::test]
async fn repeated_pressed_edge_during_hold_session_does_not_restart() {
let coordinator = Coordinator::new();
coordinator
.inner
.prefs
.set(crate::types::UserPreferences {
hotkey: crate::types::HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Hold,
keys: None,
},
..Default::default()
})
.unwrap();
coordinator.inner.state.lock().phase = SessionPhase::Listening;
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
handle_pressed_edge(&coordinator.inner).await;
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
#[test]
fn enabling_shortcut_recording_clears_dictation_hold_latch() {
let coordinator = Coordinator::new();
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
coordinator.set_shortcut_recording_active(true);
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
#[test]
fn window_hotkey_fallback_is_disabled_when_no_explicit_fallback_is_advertised() {
assert_eq!(
window_hotkey_fallback_enabled(),
crate::types::HotkeyCapability::current().explicit_fallback_available
);
}
#[test]
fn capsule_show_strategy_matches_platform_activation_contract() {
// 平台列表必须与 capsule_show_strategy_for_platform 的 cfg 完全一致:
// 改实现里的 #[cfg] 时,一并改这两个 #[cfg],否则 Linux CI 直接红
// (fcitx5 PR #451 把 Linux 加进 NoActivate 但漏改本测试,CI 失败)。
#[cfg(any(target_os = "macos", target_os = "windows"))]
assert_eq!(
capsule_show_strategy_for_platform(),
CapsuleShowStrategy::NoActivate
);
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
assert_eq!(
capsule_show_strategy_for_platform(),
CapsuleShowStrategy::FallbackShow
);
}
#[test]
#[cfg(target_os = "windows")]
fn prepared_windows_ime_slot_is_taken_only_for_matching_session() {
let mut slots = vec![PreparedWindowsImeSessionSlot {
session_id: session_id(2),
prepared: PreparedWindowsImeSession::unavailable(),
}];
assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_none());
assert_eq!(
slots.iter().map(|slot| slot.session_id).collect::<Vec<_>>(),
vec![session_id(2)]
);
assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(2)).is_some());
assert!(slots.is_empty());
}
#[test]
#[cfg(target_os = "windows")]
fn prepared_windows_ime_sessions_keep_overlapping_snapshots() {
let mut slots = Vec::new();
store_prepared_windows_ime_session(
&mut slots,
session_id(1),
PreparedWindowsImeSession::unavailable(),
);
store_prepared_windows_ime_session(
&mut slots,
session_id(2),
PreparedWindowsImeSession::unavailable(),
);
assert_eq!(
slots.iter().map(|slot| slot.session_id).collect::<Vec<_>>(),
vec![session_id(1), session_id(2)]
);
assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_some());
assert_eq!(
slots.iter().map(|slot| slot.session_id).collect::<Vec<_>>(),
vec![session_id(2)]
);
}
#[test]
#[cfg(target_os = "windows")]
fn stale_prepared_windows_ime_restore_discards_old_snapshot_without_restoring() {
let mut slots = Vec::new();
store_prepared_windows_ime_session(
&mut slots,
session_id(1),
PreparedWindowsImeSession::unavailable(),
);
store_prepared_windows_ime_session(
&mut slots,
session_id(2),
PreparedWindowsImeSession::unavailable(),
);
assert!(take_current_prepared_windows_ime_session_for_restore(
&mut slots,
session_id(1),
session_id(2)
)
.is_none());
assert_eq!(
slots.iter().map(|slot| slot.session_id).collect::<Vec<_>>(),
vec![session_id(2)]
);
}
#[test]
#[cfg(target_os = "windows")]
fn non_tsf_insertion_fallback_gate_blocks_only_when_disabled() {
assert!(should_try_non_tsf_insertion_fallback(
true,
InsertStatus::CopiedFallback
));
assert!(should_try_non_tsf_insertion_fallback(
true,
InsertStatus::Failed
));
assert!(!should_try_non_tsf_insertion_fallback(
true,
InsertStatus::Inserted
));
assert!(!should_try_non_tsf_insertion_fallback(
false,
InsertStatus::CopiedFallback
));
assert!(!should_try_non_tsf_insertion_fallback(
false,
InsertStatus::Failed
));
}
#[test]
fn focus_restore_failure_uses_specific_error_code_when_insert_fails() {
assert_eq!(
dictation_error_code(InsertStatus::Failed, false, false, false),
Some("focusRestoreFailed")
);
}
#[test]
#[cfg(target_os = "windows")]
fn missing_windows_hwnd_is_not_present() {
use windows::Win32::Foundation::HWND;
assert!(!windows_hwnd_is_present(HWND::default()));
}
#[test]
#[cfg(target_os = "windows")]
fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() {
assert_eq!(
dictation_error_code(InsertStatus::Failed, false, true, false),
Some("windowsImeTsfRequired")
);
}
#[test]
fn startup_race_check_treats_newer_session_as_stale() {
let mut state = SessionState::default();
state.phase = SessionPhase::Starting;
state.cancelled = false;
state.session_id = session_id(2);
assert_eq!(
startup_race_status(&state, session_id(1)),
StartupRaceStatus::StaleContinuation
);
}
#[test]
fn startup_race_check_is_table_driven_for_begin_session_edges() {
let cases = [
(
SessionPhase::Starting,
false,
session_id(7),
StartupRaceStatus::ActiveStarting,
),
(
SessionPhase::Starting,
true,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Idle,
false,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Listening,
false,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Starting,
false,
session_id(8),
StartupRaceStatus::StaleContinuation,
),
];
for (phase, cancelled, actual_session_id, expected) in cases {
let mut state = SessionState::default();
state.phase = phase;
state.cancelled = cancelled;
state.session_id = actual_session_id;
assert_eq!(
startup_race_status(&state, session_id(7)),
expected,
"phase={phase:?} cancelled={cancelled} actual_session={actual_session_id}"
);
}
}
#[test]
fn begin_recording_abort_is_noop_after_prior_cancel_or_idle() {
let cases = [
(SessionPhase::Idle, false),
(SessionPhase::Processing, false),
(SessionPhase::Listening, true),
];
for (phase, cancelled) in cases {
let mut state = SessionState::default();
state.phase = phase;
state.cancelled = cancelled;
assert!(begin_recording_abort_before_restore(&mut state).is_none());
assert_eq!(state.phase, phase);
assert_eq!(state.cancelled, cancelled);
}
}
#[test]
fn stale_startup_cleanup_keeps_newer_asr_resource() {
let coordinator = Coordinator::new();
let newer_asr = Arc::new(WhisperBatchASR::new(
"key".to_string(),
"http://localhost".to_string(),
"model".to_string(),
None,
None,
false,
));
*coordinator.inner.asr.lock() = Some(SessionResource::new(
session_id(2),
ActiveAsr::Whisper(Arc::clone(&newer_asr)),
));
discard_startup_resources_for_session(&coordinator.inner, session_id(1));
assert_eq!(
coordinator
.inner
.asr
.lock()
.as_ref()
.map(|resource| resource.session_id),
Some(session_id(2))
);
discard_startup_resources_for_session(&coordinator.inner, session_id(2));
assert!(coordinator.inner.asr.lock().is_none());
}
}
fn enabled_phrases(inner: &Arc<Inner>) -> Vec<String> {
inner
.vocab
.list()
.unwrap_or_default()
.into_iter()
.filter(|e| e.enabled)
.map(|e| e.phrase)
.collect()
}
/// 终止态(Done / Cancelled / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。
/// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。
const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000;
/// Toggle 模式下,end_session 将 phase 设为 Idle 后在此时间内禁止新的 begin_session。
/// 避免用户三连按时第 3 次按下误激活新听写(此时胶囊仍在离场动画周期内)。
/// 值取 capsule EXIT_ANIM_MS (360ms) + 余量 ≈ 600ms。
const POST_SESSION_COOLDOWN_MS: u64 = 600;
/// Coordinator 全局超时保护:防止 ASR await_final_result() 永远挂起。
/// 设置为 15 秒(比 ASR 的 12 秒 FINAL_RESULT_TIMEOUT 稍长),
/// 只在 ASR 超时机制失效时作为最后的防线触发。
const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 15;
#[cfg(target_os = "windows")]
fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration {
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
}
/// 本地 Qwen3-ASR 的动态转写超时。固定 15 秒在长录音(≥ 30s)+ 慢机器
/// (RTF ≈ 0.3–0.5)上必然超时把整段内容丢掉。改用 max(15, ceil(audio_s
/// × 0.6) + 10):基础保留 15s 兜住短录音;长录音按音频长度的 0.6 倍 +
/// 10s 余量,覆盖 RTF ≤ 0.5 的机器。
fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration {
let secs = ((audio_secs * 0.6).ceil() as u64)
.saturating_add(10)
.max(COORDINATOR_GLOBAL_TIMEOUT_SECS);
std::time::Duration::from_secs(secs)
}
/// sherpa-onnx offline batch 暂与 Foundry 同档;后续按 Windows 真机 CPU/模型
/// 实测结果再调整。
#[cfg(target_os = "windows")]
fn sherpa_audio_transcribe_timeout_duration() -> std::time::Duration {
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
}
/// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。
/// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在
/// 「准备做下一步副作用前」用。
fn startup_race_status_for_starting(
inner: &Arc<Inner>,
captured_session_id: SessionId,
) -> StartupRaceStatus {
let state = inner.state.lock();
startup_race_status(&state, captured_session_id)
}
fn set_phase_idle_if_session_matches(inner: &Arc<Inner>, session_id: SessionId) {
let mut state = inner.state.lock();
if state.session_id == session_id {
state.phase = SessionPhase::Idle;
}
}
fn schedule_capsule_idle(inner: &Arc<Inner>, delay_ms: u64) {
let inner_clone = Arc::clone(inner);
async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
// 必须 dictation **和** QA 同时空闲才能隐藏胶囊。否则旧 dictation Done timer
// 的尾巴会在新 QA 录音/思考中把胶囊意外收掉(issue #118 v2 复现)。
let dictation_idle = inner_clone.state.lock().phase == SessionPhase::Idle;
let qa_idle = inner_clone.qa_state.lock().phase == QaPhase::Idle;
if dictation_idle && qa_idle {
emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None);
}
});
}
/// 与 capture_focus_target 类似,但前台窗口属于本进程(即用户停在 QA / capsule / main
/// 等自家窗口)时返回 None,让 caller 区分"用户没切到别处" vs "用户切到了另一个真正的
/// 外部 app"。issue #466 多轮场景下用来刷新 qa_focus_target。
#[cfg(target_os = "windows")]
fn capture_external_focus_target() -> Option<usize> {
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId};
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.0.is_null() {
return None;
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, Some(&mut pid));
if pid == GetCurrentProcessId() {
return None;
}
Some(hwnd.0 as usize)
}
}
#[cfg(not(target_os = "windows"))]
fn capture_external_focus_target() -> Option<usize> {
None
}
#[cfg(target_os = "windows")]
fn capture_focus_target() -> Option<usize> {
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
let foreground = unsafe { GetForegroundWindow() };
if foreground.0.is_null() {
None
} else {
Some(foreground.0 as usize)
}
}
#[cfg(not(target_os = "windows"))]
fn capture_focus_target() -> Option<usize> {
None
}
/// 捕获用户开始 dictation 时的前台 app 标签("localizedName (bundle.id)"),用作 LLM
/// polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。
///
/// macOS 走 NSWorkspace.frontmostApplication(公开 API,无需额外权限);
/// Windows 复用前台 HWND 拿窗口标题;Linux/其他平台返回 None。
#[cfg(target_os = "macos")]
fn capture_frontmost_app() -> Option<String> {
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject};
unsafe {
let cls = AnyClass::get("NSWorkspace")?;
let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace];
if workspace.is_null() {
return None;
}
let app: *mut AnyObject = msg_send![workspace, frontmostApplication];
if app.is_null() {
return None;
}
let name_obj: *mut AnyObject = msg_send![app, localizedName];
let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier];
let name = nsstring_to_string(name_obj);
let bundle = nsstring_to_string(bundle_obj);
match (name, bundle) {
(Some(n), Some(b)) => Some(format!("{n} ({b})")),
(Some(n), None) => Some(n),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
}
#[cfg(target_os = "macos")]
unsafe fn nsstring_to_string(ns_string: *mut objc2::runtime::AnyObject) -> Option<String> {
use objc2::msg_send;
if ns_string.is_null() {
return None;
}
let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] };
if utf8.is_null() {
return None;
}
let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) };
let s = cstr.to_string_lossy().into_owned();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[cfg(target_os = "windows")]
fn capture_frontmost_app() -> Option<String> {
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW,
};
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.0.is_null() {
return None;
}
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return None;
}
let mut buf = vec![0u16; (len + 1) as usize];
let copied = GetWindowTextW(hwnd, &mut buf);
if copied <= 0 {
return None;
}
let title = String::from_utf16_lossy(&buf[..copied as usize]);
if title.is_empty() {
None
} else {
Some(title)
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn capture_frontmost_app() -> Option<String> {
None
}
#[cfg(target_os = "windows")]
fn restore_focus_target_if_possible(target: Option<usize>) -> bool {
use std::ffi::c_void;
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE,
};
let Some(raw_target) = target else {
log::warn!("[coord] no original Windows insertion target captured");
return false;
};
let hwnd = HWND(raw_target as *mut c_void);
if hwnd.0.is_null() {
return false;
}
if !unsafe { IsWindow(hwnd).as_bool() } {
log::warn!("[coord] original Windows insertion target is no longer a valid window");
return false;
}
let foreground = unsafe { GetForegroundWindow() };
if foreground == hwnd {
return true;
}
if unsafe { IsIconic(hwnd).as_bool() } {
let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) };
}
let _ = unsafe { SetForegroundWindow(hwnd) };
std::thread::sleep(std::time::Duration::from_millis(60));
let foreground = unsafe { GetForegroundWindow() };
if foreground != hwnd {
log::warn!("[coord] failed to restore original Windows insertion target before paste");
return false;
}
true
}
#[cfg(not(target_os = "windows"))]
fn restore_focus_target_if_possible(_target: Option<usize>) -> bool {
true
}
#[cfg(target_os = "windows")]
fn windows_hwnd_is_present(hwnd: windows::Win32::Foundation::HWND) -> bool {
hwnd != windows::Win32::Foundation::HWND::default()
}
#[cfg(target_os = "windows")]
fn capture_ime_submit_target() -> Option<ImeSubmitTarget> {
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO,
};
let foreground = unsafe { GetForegroundWindow() };
if !windows_hwnd_is_present(foreground) {
return None;
}
let mut foreground_process_id = 0;
let foreground_thread_id =
unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) };
if foreground_thread_id == 0 {
return None;
}
let mut gui_info = GUITHREADINFO {
cbSize: std::mem::size_of::<GUITHREADINFO>() as u32,
..Default::default()
};
let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() }
&& windows_hwnd_is_present(gui_info.hwndFocus)
{
gui_info.hwndFocus
} else {
foreground
};
let mut process_id = 0;
let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) };
if process_id == 0 || thread_id == 0 {
return None;
}
Some(ImeSubmitTarget {
process_id,
thread_id,
})
}
// Windows topmost overlay 的已知 OS 级限制(issue #457):
// `SetWindowPos(HWND_TOPMOST)` 让 capsule 在普通桌面合成、最大化窗口、borderless
// windowed fullscreen 上正常叠加;但**对独占全屏(exclusive fullscreen)DirectX /
// OpenGL 应用无效** —— 那条路径绕过桌面合成器,标准 topmost 窗口不参与合成 →
// 用户看不见 capsule。这是 OS 层面的限制,用户空间无法绕过(除非接入 DirectX
// overlay,工程量与风险都不在 surgical 修复范围内)。
//
// 用户侧 workaround:把游戏切到 borderless windowed fullscreen(Minecraft Java 默认
// 即是;F11 在不同版本表现不一致,按设置里的「全屏」选项决定)。
//
// 相关 UIPI 限制:若游戏以管理员身份运行而 OpenLess 不是,`WH_KEYBOARD_LL` 收不到
// 游戏的按键 → hotkey 完全不触发。这里跟 SetWindowPos 路径无关,但同源不可绕过。
#[cfg(target_os = "windows")]
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) -> bool {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
SWP_SHOWWINDOW, SW_SHOWNOACTIVATE,
};
let Ok(handle) = window.window_handle() else {
return false;
};
let RawWindowHandle::Win32(raw) = handle.as_raw() else {
return false;
};
let hwnd = HWND(raw.hwnd.get() as *mut _);
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
let _ = unsafe {
SetWindowPos(
hwnd,
HWND_TOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
)
};
true
}
#[cfg(target_os = "macos")]
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) -> bool {
use objc2::msg_send;
use objc2::runtime::AnyObject;
let Ok(handle) = window.ns_window() else {
return false;
};
let ns_window = handle as *mut AnyObject;
if ns_window.is_null() {
return false;
}
// emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能再调用
// window.show()/set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回
// OpenLess 主窗口所在空间。先让胶囊加入所有 Spaces,再用
// orderFrontRegardless 做无激活展示。
if let Err(e) = window.set_visible_on_all_workspaces(true) {
log::warn!("[capsule] set visible on all macOS Spaces failed: {e}");
}
unsafe {
const NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES: usize = 1 << 0;
const NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY: usize = 1 << 8;
let behavior: usize = msg_send![ns_window, collectionBehavior];
let behavior = behavior
| NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES
| NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY;
let _: () = msg_send![ns_window, setCollectionBehavior: behavior];
let _: () = msg_send![ns_window, orderFrontRegardless];
}
true
}
#[cfg(target_os = "linux")]
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
true
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
false
}
#[cfg(target_os = "windows")]
fn hide_capsule_window_if_present() {
use std::iter::once;
use windows::core::PCWSTR;
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE,
SWP_NOMOVE, SWP_NOSIZE, SW_HIDE,
};
let title: Vec<u16> = "OpenLess Capsule".encode_utf16().chain(once(0)).collect();
let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } {
Ok(hwnd) => hwnd,
Err(_) => return,
};
if hwnd == HWND::default() || hwnd.0.is_null() {
return;
}
let _ = unsafe { ShowWindow(hwnd, SW_HIDE) };
let _ = unsafe {
SetWindowPos(
hwnd,
HWND_NOTOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW,
)
};
}
#[cfg(not(target_os = "windows"))]
fn hide_capsule_window_if_present() {}
fn emit_capsule(
inner: &Arc<Inner>,
state: CapsuleState,
level: f32,
elapsed_ms: u64,
message: Option<String>,
inserted_chars: Option<u32>,
) {
let app_opt = inner.app.lock().clone();
let Some(app) = app_opt else { return };
let translation = inner.translation_modifier_seen.load(Ordering::SeqCst);
let payload = CapsulePayload {
state,
level,
elapsed_ms,
message,
inserted_chars,
translation,
};
// visible / translation 是「这一帧 capsule:state event 的 payload」内容 ——
// 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread
// 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。
let visible = !matches!(state, CapsuleState::Idle);
// Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。
// 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。
#[cfg(target_os = "linux")]
{
use std::sync::Mutex;
static LAST_AUX: Mutex<Option<String>> = Mutex::new(None);
let aux = match state {
CapsuleState::Idle => None,
CapsuleState::Recording => Some("🎤 收音中..."),
CapsuleState::Transcribing => Some("🔄 识别中..."),
CapsuleState::Polishing => Some("✨ 润色中..."),
CapsuleState::Done => Some("✅ 已插入"),
CapsuleState::Cancelled => Some("— 已取消"),
CapsuleState::Error => Some("❌ 出错"),
};
let mut last = LAST_AUX.lock().unwrap();
if aux != last.as_deref() {
*last = aux.map(String::from);
// 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。
// 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。
static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
// fetch_add 返回旧值,所以 latest_gen > gen+1 才表示"在我之后又发生了变更"。
let gen = RETRY_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
match aux {
Some(t) => {
log::info!("[capsule] set_aux_down: {t} gen={gen}");
let text = t.to_string();
std::thread::spawn(move || {
let current = LAST_AUX.lock().unwrap().clone();
if current.as_deref() != Some(&text) {
log::info!(
"[capsule] set_aux_down skipped: state changed to {current:?}"
);
return;
}
if let Err(e) = crate::linux_fcitx::set_aux_down(&text) {
log::warn!("[capsule] set_aux_down failed: {e}");
}
});
// 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。
if matches!(
state,
CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error
) {
let text = t.to_string();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(3));
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
if latest_gen > gen + 1 {
return;
}
let current = LAST_AUX.lock().unwrap().clone();
if current.as_deref() != Some(&text) {
return;
}
log::info!("[capsule] auto-clear terminal state: {text}");
let _ = crate::linux_fcitx::set_aux_down("");
*LAST_AUX.lock().unwrap() = None;
});
}
}
None => {
log::info!("[capsule] clear_aux_down gen={gen}");
std::thread::spawn(move || {
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
if latest_gen > gen + 1 {
log::info!(
"[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}"
);
return;
}
let current = LAST_AUX.lock().unwrap().clone();
if current.is_some() {
log::info!(
"[capsule] clear_aux_down skipped: state changed to {current:?}"
);
return;
}
if let Err(e) = crate::linux_fcitx::clear_aux_down() {
log::warn!("[capsule] clear_aux_down failed: {e}");
}
});
}
}
}
}
// emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该
// 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP
// 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程;
// app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。
//
// show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置,
// 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref
// 闪烁。pr_agent 关注点 — 见 audit follow-up。
let inner_for_main = Arc::clone(inner);
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
let Some(window) = app_for_main.get_webview_window("capsule") else {
return;
};
let show_capsule = inner_for_main.prefs.get().show_capsule;
// Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。
// 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。
#[cfg(target_os = "linux")]
{
return;
}
#[cfg(not(target_os = "linux"))]
{
// 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast
// (schedule_capsule_idle 之后会回 Idle 隐藏)。
// Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的
// `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支
// 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。
maybe_position_capsule_bottom_center(&inner_for_main, &window, translation);
if show_capsule && visible {
// 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule
// 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触
// 发 / state 一直 Idle 这几类常见 root cause。issue #470。
if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) {
log::info!(
"[capsule] first show this session: show_capsule=true visible=true state={}",
capsule_state_log_name(state)
);
}
show_capsule_window_for_recording(&app_for_main, &window);
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。
// 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。
#[cfg(target_os = "macos")]
crate::restore_main_window_key_if_active(&app_for_main);
} else {
// show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况:
// 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者
// 不必再让用户"去打开设置确认"。issue #470。
if !show_capsule
&& visible
&& !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst)
{
log::info!(
"[capsule] suppressed by user toggle: show_capsule=false visible=true state={}",
capsule_state_log_name(state)
);
}
hide_capsule_window_if_present();
let _ = window.hide();
}
}
});
let _ = app.emit_to("capsule", "capsule:state", payload);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CapsuleLayoutState {
translation_active: bool,
monitor_x: i32,
monitor_y: i32,
monitor_width: u32,
monitor_height: u32,
scale_bits: u64,
}
/// 返回胶囊「应该摆放到的显示器」的标识信息。
///
/// 它看的显示器必须和 `position_capsule_bottom_center` 实际定位用的一致:
/// Windows 看「正在输入的 App 所在显示器」,其它平台看胶囊自己的显示器。
/// 这是「是否需要重新定位」去重缓存(`maybe_position_capsule_bottom_center`)
/// 的 key,如果这里看错了显示器,就会出现「输入焦点移到另一块屏、胶囊却没
/// 跟过去」的 bug。
fn capsule_layout_snapshot<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
translation_active: bool,
) -> Option<CapsuleLayoutState> {
// Windows:以「正在输入的 App 所在显示器」为基准。若用胶囊自己的
// current_monitor,输入焦点切到另一块屏时胶囊仍在原屏 → 误判「没变化」
// → 跳过重新定位。
#[cfg(target_os = "windows")]
{
if let Some(mon) = crate::foreground_window_monitor() {
return Some(CapsuleLayoutState {
translation_active,
monitor_x: mon.left,
monitor_y: mon.top,
monitor_width: (mon.right - mon.left).max(0) as u32,
monitor_height: (mon.bottom - mon.top).max(0) as u32,
scale_bits: mon.scale.to_bits(),
});
}
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor。
}
let monitor = window.current_monitor().ok().flatten()?;
Some(CapsuleLayoutState {
translation_active,
monitor_x: monitor.position().x,
monitor_y: monitor.position().y,
monitor_width: monitor.size().width,
monitor_height: monitor.size().height,
scale_bits: monitor.scale_factor().to_bits(),
})
}
fn maybe_position_capsule_bottom_center<R: tauri::Runtime>(
inner: &Arc<Inner>,
window: &tauri::WebviewWindow<R>,
translation_active: bool,
) {
let Some(next) = capsule_layout_snapshot(window, translation_active) else {
return;
};
{
let last = inner.capsule_layout.lock();
if last.as_ref() == Some(&next) {
return;
}
}
if crate::position_capsule_bottom_center(window, translation_active).is_ok() {
let mut last = inner.capsule_layout.lock();
*last = Some(next);
}
}
// ─────────────────────────── audio bridge ───────────────────────────
struct DeferredAsrBridge {
state: Mutex<DeferredAsrState>,
}
struct DeferredAsrState {
target: Option<Arc<dyn crate::asr::AudioConsumer>>,
pending_audio: Vec<u8>,
attaching: bool,
}
impl DeferredAsrBridge {
fn new() -> Self {
Self {
state: Mutex::new(DeferredAsrState {
target: None,
pending_audio: Vec::new(),
attaching: false,
}),
}
}
fn attach(&self, target: Arc<dyn crate::asr::AudioConsumer>) -> usize {
let mut flushed_bytes = 0;
{
let mut state = self.state.lock();
state.attaching = true;
}
loop {
let pending = {
let mut state = self.state.lock();
if state.pending_audio.is_empty() {
state.target = Some(Arc::clone(&target));
state.attaching = false;
return flushed_bytes;
}
std::mem::take(&mut state.pending_audio)
};
flushed_bytes += pending.len();
target.consume_pcm_chunk(&pending);
}
}
}
impl crate::recorder::AudioConsumer for DeferredAsrBridge {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
let target = {
let mut state = self.state.lock();
if state.attaching {
state.pending_audio.extend_from_slice(pcm);
return;
}
if let Some(target) = state.target.as_ref() {
Some(Arc::clone(target))
} else {
state.pending_audio.extend_from_slice(pcm);
None
}
};
if let Some(target) = target {
target.consume_pcm_chunk(pcm);
}
}
}