5420 lines
207 KiB
Rust
5420 lines
207 KiB
Rust
#![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);
|
||
}
|
||
}
|
||
}
|