Files
openless/openless-all/app/src-tauri/src/coordinator.rs
T
TRIPandsim 006cb78a93 feat(capsule): 录音胶囊样式可选(流光 Siri / Openless 默认)+ 修复 beta 构建破坏 (#887)
* feat(capsule): 录音胶囊样式可选——流光 Siri 风格 / Openless 默认风格

设置 → 录音与输入新增「胶囊样式」下拉(保留显示开关):
- UserPreferences.capsule_style('siri' 默认 / 'classic')随 capsule:state
  payload 下发,切换后下一次录音即生效
- Capsule.tsx 双样式分支:siri 保留 SiriGL 光效舞台;classic 恢复 1.3.14
  经典毛玻璃药丸(音量条 + 取消/确认按钮 + thinking 扫光 + 翻译徽章),
  取消/确认改走 lib/ipc/dictation.ts,退出动画时长按样式区分
- 预览支持 ?style=classic;i18n 五语言新增胶囊样式文案

* fix(settings): 选区润色行适配重构后的 ShortcutRecorder props

beta HEAD 在快捷键录入交互重构(e12c8dcc)合并后遗留的构建破坏:
SelectionPolishSection 仍传已删除的 alignRecordButton / modifierPresets,
tsc 直接报错。改为新 API(value 可空 + onReset 恢复默认快捷键,
等价于旧「启用」按钮),恢复 beta 可构建状态。

* fix(capsule): 评审修复——经典药丸按钮可点 + 音频线程零偏好锁

- 经典样式(Openless 默认)下胶囊窗口长期 set_ignore_cursor_events(true)
  (纯光效舞台时代遗留),✕/✓ 按钮实际不可点。现在主线程闭包按
  「classic && visible && 非选区润色 && 录音/转写/润色中」关闭鼠标穿透,
  终态 toast / 隐藏 / Siri / 选区润色保持穿透;值不变时不重复调用
  set_ignore_cursor_events
- emit_capsule 在音频回调线程 ~30Hz 被调,原先直接读 prefs(整份克隆 +
  偏好锁被 set() 跨磁盘 I/O 持有)。改为 Inner 上 AtomicU8 样式缓存,
  主线程闭包每帧从 prefs 同步(与 show_capsule 同源读取),音频线程零开销
- 恢复「录音胶囊」行的 desc 文案(beta 原有,重构时被误删)
- Capsule.tsx 退出动画注释同步为按样式区分的常量名

* docs(capsule): 注明入场帧 payload 可能滞后一帧样式(防误改回音频线程读 prefs)

---------

Co-authored-by: sim <sim@local>
2026-08-03 14:17:52 +08:00

4428 lines
176 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg_attr(
target_os = "linux",
allow(dead_code, unused_imports, unused_variables)
)]
//! Dictation coordinator.
//!
//! Mirrors the Swift `DictationCoordinator` state machine. Single owner of
//! session state. Receives hotkey edges, drives recorder + ASR + polish +
//! insertion, persists history, emits `capsule:state` events to the capsule
//! window.
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, 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, DashScopeMultimodalASR, DictionaryHotword,
ElevenLabsBatchASR, MimoBatchASR, Qwen3RealtimeASR, Qwen3RealtimeCredentials, 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::correction::apply_correction_rules;
use crate::hotkey::{HotkeyEvent, HotkeyMonitor};
use crate::insertion::TextInserter;
use crate::persistence::{
sync_style_pack_preferences, ActivityStore, CorrectionRuleStore, CredentialAccount,
CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore, StylePackStore,
};
use crate::llm_gemini::{GeminiConfig, GeminiProvider};
use crate::polish::{
openai_compatible_temperature_for_provider, ActiveLLMProvider, CodexOAuthConfig,
CodexOAuthLLMProvider, OpenAICompatibleConfig, OpenAICompatibleLLMProvider,
CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID,
};
use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
use crate::recorder::{Recorder, RecorderError};
#[cfg(target_os = "windows")]
use crate::types::PasteShortcut;
use crate::types::{
CapsulePayload, CapsuleState, CapsuleStyle, 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 asr_wiring;
mod capsule_focus;
mod dictation;
mod hotkey_loops;
mod polish_flow;
mod qa;
mod qa_session;
mod resources;
#[cfg(not(mobile))]
pub(crate) mod selection_polish;
use asr_wiring::*;
use capsule_focus::*;
use hotkey_loops::*;
use polish_flow::*;
use qa_session::*;
// less_computer_sync 命令的数据源(浮窗 webview 冷加载竞态补偿,见 dictation.rs)。
pub(crate) use dictation::less_computer_event_backlog;
pub(super) fn qa_event_target() -> &'static str {
#[cfg(target_os = "android")]
{
"main"
}
#[cfg(not(target_os = "android"))]
{
"qa"
}
}
#[cfg(test)]
use dictation::dictation_error_code;
use dictation::{
begin_session, begin_session_as, cancel_session, end_session, handle_pressed_edge,
handle_released_edge, handle_trigger_combined, request_stop_during_starting,
};
#[cfg(any(debug_assertions, test))]
use dictation::{handle_pressed, handle_released};
use qa::{
close_qa_panel, handle_qa_hotkey_pressed, handle_qa_option_edge, open_qa_panel, QaPhase,
QaSessionState,
};
#[cfg(test)]
use resources::discard_startup_resources_for_session;
use resources::{
acquire_recording_mute, cancel_active_asr, cancel_qa_asr_for_session, release_recording_mute,
selected_microphone_device_name, stop_microphone_preview_monitor,
stop_qa_recorder_for_session, store_qa_asr_for_session, store_qa_recorder_for_session,
take_asr_for_session, take_qa_asr_for_session, take_recorder_for_session, 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 诊断 v2:capsule webview 句柄取不到时的一次性门,区分「窗口压根没创建」(A0)。
static CAPSULE_WINDOW_MISSING_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>,
reassert_spaces: bool,
) {
let mut needs_fallback = true;
if capsule_show_strategy_for_platform() == CapsuleShowStrategy::NoActivate {
needs_fallback = !show_capsule_window_no_activate(app, window, reassert_spaces);
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>),
Mimo(Arc<MimoBatchASR>),
/// 百炼 Fun-ASR-Flash 录音文件识别(DashScope multimodal-generation 批量 HTTP)。
DashScopeMultimodal(Arc<DashScopeMultimodalASR>),
ElevenLabs(Arc<ElevenLabsBatchASR>),
Bailian(Arc<BailianRealtimeASR>),
/// 百炼 Qwen3-ASR-Flash 实时(OpenAI Realtime 风格 WS 协议)。
Qwen3Realtime(Arc<Qwen3RealtimeASR>),
/// 阶跃星辰 StepAudio 实时(OpenAI Realtime 风格 WS,收尾靠静音帧驱动 VAD)。
StepfunRealtime(Arc<crate::asr::StepfunRealtimeASR>),
#[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>),
/// Apple Speech(SFSpeechRecognizer)系统本地 ASR;只在 macOS 可达。
#[cfg(target_os = "macos")]
AppleSpeech(Arc<crate::asr::local::AppleSpeechAsr>),
}
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,
}
}
/// 单一分类来源:云端 ASR provider id → 协议种类。本地/无凭据引擎(local qwen3 /
/// apple speech / foundry / sherpa)由各调用点在此之前用平台 cfg 门单独处理,不进
/// 这个枚举。
///
/// **加新云端通道的唯一改动点**:在 [`active_asr_provider_kind`] 加一条 id 映射,
/// 然后 [`ActiveAsrProviderKind::preflight_credential`] /
/// [`ActiveAsrProviderKind::configured_fields`] 与各 build/dispatch 的穷尽 `match`
/// 会被编译器逐个报错逼你补齐——不会再出现「装完才发现某处漏了」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ActiveAsrProviderKind {
Bailian,
Qwen3Realtime,
StepfunRealtime,
Mimo,
DashScopeMultimodal,
ElevenLabs,
WhisperCompatible,
Volcengine,
}
/// 「能否开始一次会话」所需的凭据形态(对应 `ensure_asr_credentials` 预检门)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AsrPreflightCredential {
/// 需要 ASR API Key(endpoint/model 有默认值兜底)。
AsrApiKey,
/// 需要火山引擎 App Key + Access Key。
VolcAppKey,
}
/// 概览页「已配置 / 未配置」状态所需的字段(对应 `asr_configured_for_provider`)。
/// 语义与预检门**有意不同**:预检问「能否开始」,这里问「preset 要求的字段填齐没」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AsrConfiguredFields {
/// 只看 API Key(endpoint/model 走默认):bailian / qwen3 实时。
ApiKeyOnly,
/// API Key + endpoint + model 都要填:mimo / dashscope multimodal。
ApiKeyEndpointModel,
/// 只看 endpoint + model(不含 API Key):Whisper 兼容厂商。
EndpointModelOnly,
/// 火山引擎三件套。
VolcAppKey,
}
impl ActiveAsrProviderKind {
pub(crate) fn preflight_credential(self) -> AsrPreflightCredential {
match self {
ActiveAsrProviderKind::Bailian
| ActiveAsrProviderKind::Qwen3Realtime
| ActiveAsrProviderKind::StepfunRealtime
| ActiveAsrProviderKind::Mimo
| ActiveAsrProviderKind::DashScopeMultimodal
| ActiveAsrProviderKind::ElevenLabs
| ActiveAsrProviderKind::WhisperCompatible => AsrPreflightCredential::AsrApiKey,
ActiveAsrProviderKind::Volcengine => AsrPreflightCredential::VolcAppKey,
}
}
pub(crate) fn configured_fields(self) -> AsrConfiguredFields {
match self {
ActiveAsrProviderKind::Bailian
| ActiveAsrProviderKind::Qwen3Realtime
| ActiveAsrProviderKind::ElevenLabs => {
AsrConfiguredFields::ApiKeyOnly
}
ActiveAsrProviderKind::Mimo | ActiveAsrProviderKind::DashScopeMultimodal => {
AsrConfiguredFields::ApiKeyEndpointModel
}
// StepfunRealtime 只经 `stepfun` 的模型路由可达(隐藏 effective id),
// 「已配置」判定看真实 active `stepfun` → WhisperCompatible;此处形态
// 与之对齐,保证直接停在该 id 上也语义一致。
ActiveAsrProviderKind::WhisperCompatible
| ActiveAsrProviderKind::StepfunRealtime => AsrConfiguredFields::EndpointModelOnly,
ActiveAsrProviderKind::Volcengine => AsrConfiguredFields::VolcAppKey,
}
}
}
pub(crate) fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind {
if is_bailian_provider(id) {
ActiveAsrProviderKind::Bailian
} else if is_qwen3_realtime_provider(id) {
ActiveAsrProviderKind::Qwen3Realtime
} else if is_stepfun_realtime_provider(id) {
ActiveAsrProviderKind::StepfunRealtime
} else if is_mimo_provider(id) {
ActiveAsrProviderKind::Mimo
} else if is_dashscope_multimodal_provider(id) {
ActiveAsrProviderKind::DashScopeMultimodal
} else if is_elevenlabs_provider(id) {
ActiveAsrProviderKind::ElevenLabs
} else if is_whisper_compatible_provider(id) {
ActiveAsrProviderKind::WhisperCompatible
} else {
ActiveAsrProviderKind::Volcengine
}
}
/// 统一「阿里云百炼」入口的模型 → 底层协议 id 路由。
///
/// 三条百炼协议(fun-asr-realtime 经典实时 / qwen3-asr-flash-realtime Realtime /
/// fun-asr-flash 与 qwen-audio-3.0-asr-flash 录音文件)在 UI 上收成一个
/// provider `bailian`(一把 key),**构建时**
/// 按所选模型二次路由到具体协议客户端。凭据 / 「已配置」判定仍看真实 active
/// `bailian`(→ ApiKeyOnly,一把 key),只有这里的 build 分发用得上 effective id。
///
/// 老用户若停在别名 id(`bailian-qwen3-realtime` / `bailian-fun-asr-flash`)上,
/// 非 `bailian` 直接原样返回,各走各的旧路径——即「隐藏别名」向后兼容。
pub(crate) fn resolve_effective_asr_provider(
active_asr: &str,
model: &str,
) -> Result<String, String> {
if !is_bailian_provider(active_asr) {
if is_dashscope_multimodal_provider(active_asr) {
validate_dashscope_multimodal_model(model)?;
}
// StepFun 同款「一个入口按模型切协议」:`*-stream` 模型走实时 WS
// 客户端,其余走批式 Whisper 兼容路径。凭据 / 「已配置」判定仍看
// 真实 active `stepfun`。
if active_asr == "stepfun" && stepfun_model_is_stream(model) {
return Ok(crate::asr::stepfun_realtime::PROVIDER_ID.to_string());
}
return Ok(active_asr.to_string());
}
let model = model.trim();
if model.is_empty() || is_classic_bailian_realtime_model(model) {
Ok(crate::asr::bailian::PROVIDER_ID.to_string())
} else if model.starts_with("qwen3-asr-flash-realtime") {
Ok(crate::asr::qwen_realtime::PROVIDER_ID.to_string())
} else if crate::asr::dashscope_multimodal::protocol_for_model(model).is_some() {
Ok(crate::asr::dashscope_multimodal::PROVIDER_ID.to_string())
} else {
Err(format!(
"不支持的百炼 ASR 模型:{model}。支持 Fun-ASR、Paraformer、SenseVoice、qwen-audio-3.0-asr-flash 和 Qwen3-ASR 的实时、同步及录音文件模型"
))
}
}
fn is_classic_bailian_realtime_model(model: &str) -> bool {
model.starts_with("fun-asr-realtime")
|| model.starts_with("fun-asr-flash-8k-realtime")
|| model.starts_with("paraformer-realtime")
|| model.starts_with("paraformer-8k-realtime")
|| model.starts_with("sensevoice-realtime")
|| model.starts_with("sensevoice-8k-realtime")
}
/// StepFun 的流式模型命名恒以 `-stream` 结尾(stepaudio-2.5-asr-stream /
/// step-asr-1.1-stream),其余(含空 = 默认批式模型)走批式。
pub(crate) fn stepfun_model_is_stream(model: &str) -> bool {
model.trim().ends_with("-stream")
}
pub(crate) fn validate_dashscope_multimodal_model(model: &str) -> Result<(), String> {
let model = model.trim();
if model.is_empty() || crate::asr::dashscope_multimodal::protocol_for_model(model).is_some() {
return Ok(());
}
Err(format!("不支持的 DashScope 录音文件 ASR 模型:{model}"))
}
#[derive(Clone, Copy)]
pub(crate) enum BailianEndpointProtocol {
ClassicRealtime,
QwenRealtime,
Multimodal,
AsyncTranscription,
}
/// 统一百炼配置只需要表达区域/工作空间主机;具体协议的 scheme 与 path 由模型路由决定。
/// 这样既能复用同一个 endpoint 字段,也不会把中国区默认网关强加给新加坡或专属工作空间。
pub(crate) fn derive_bailian_endpoint(
endpoint: &str,
protocol: BailianEndpointProtocol,
) -> Result<String, String> {
let default_endpoint = match protocol {
BailianEndpointProtocol::ClassicRealtime => crate::asr::bailian::DEFAULT_ENDPOINT,
BailianEndpointProtocol::QwenRealtime => crate::asr::qwen_realtime::DEFAULT_ENDPOINT,
BailianEndpointProtocol::Multimodal => crate::asr::dashscope_multimodal::DEFAULT_ENDPOINT,
BailianEndpointProtocol::AsyncTranscription => {
crate::asr::dashscope_multimodal::ASYNC_DEFAULT_ENDPOINT
}
};
let source = if endpoint.trim().is_empty() {
default_endpoint
} else {
endpoint.trim()
};
let mut url = url::Url::parse(source).map_err(|_| "endpointInvalid".to_string())?;
if url.host_str().is_none() {
return Err("endpointInvalid".to_string());
}
let (scheme, path) = match protocol {
BailianEndpointProtocol::ClassicRealtime => ("wss", "/api-ws/v1/inference/"),
BailianEndpointProtocol::QwenRealtime => ("wss", "/api-ws/v1/realtime"),
BailianEndpointProtocol::Multimodal => (
"https",
"/api/v1/services/aigc/multimodal-generation/generation",
),
BailianEndpointProtocol::AsyncTranscription => {
("https", "/api/v1/services/audio/asr/transcription")
}
};
url.set_scheme(scheme)
.map_err(|_| "endpointInvalid".to_string())?;
url.set_path(path);
url.set_query(None);
url.set_fragment(None);
Ok(url.to_string())
}
fn batch_asr_chunk_limit_ms(provider_id: &str) -> Option<u64> {
match provider_id {
// OpenRouter 把音频 base64 进 JSON body,体积比二进制大 ~33%,长录音易撞
// body/时长上限,保守按 30s 切分(与 zhipu 同)。
"zhipu" | "openrouter" => Some(30_000),
_ => None,
}
}
pub struct Coordinator {
inner: Arc<Inner>,
}
struct Inner {
app: Mutex<Option<AppHandle>>,
history: HistoryStore,
/// 每日活动计数(热力图数据源),与 history 的保留策略解耦。
activity: ActivityStore,
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>>>,
/// 与 `asr` 同生命周期的构建时快照:本次会话实际构建的 (provider, model)。
/// store_asr_for_session 一并写入,end_session 取走落 history——比事后重读
/// 全局设置可靠:会话中途切 provider/model 不会污染归因(PR #826 review)。
asr_label: Mutex<Option<SessionResource<AsrCallLabel>>>,
/// 本地 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,
/// 当前主听写热键按下的代次。组合键撤销通道使用同一代次,避免迟到事件
/// 误取消下一次按下开启的会话。
hotkey_press_generation: AtomicU64,
/// 当前代次是否真的开出了会话;0 表示没有可撤销的会话。
hotkey_press_began_session: AtomicU64,
/// 组合键事件可能先于 Pressed 事件抵达协调器,暂存其代次供仲裁窗口消费。
/// 用队列而不是单个槽,避免主 bridge 忙于上一轮仲裁时覆盖连续按下的事件。
hotkey_combo_pending_presses: Mutex<std::collections::VecDeque<u64>>,
/// 防抖时间戳:handle_pressed_edge 入口检查与本字段的距离,< 250ms 的边沿直接
/// 丢弃(误触双击 / 微动开关回弹 / 用户连点过快造成的空转写报错)。
/// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防
/// press-release-press 三连过快。
last_hotkey_dispatch_at: Mutex<Option<std::time::Instant>>,
/// Auto 模式下这次会话「按下」的事件时刻。松手时用按下/松开的事件时间戳差值
/// 判定短按(Toggle 锁存)还是长按(Hold 松手即停)。见 dictation.rs 的
/// AUTO_HOLD_THRESHOLD。
hotkey_press_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,
/// Less Computer modifier 热键的按下代次与待处理组合键事件。
less_computer_press_generation: AtomicU64,
less_computer_combo_pending_press: AtomicU64,
/// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
side_aware_combo: Mutex<Option<crate::side_aware_combo::SideAwareComboMonitor>>,
translation_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
switch_style_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
open_app_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
/// 选区润色快捷键:modifier-only 复用 `HotkeyMonitor`,其它组合键复用
/// `ComboHotkeyMonitor`。桌面(非 mobile)专属。
#[cfg(not(mobile))]
selection_polish_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
/// 预览确认模式暂存的结果和原选区目标;仅在用户确认时才允许插入。
#[cfg(not(mobile))]
selection_polish_preview: Mutex<Option<selection_polish::PendingSelectionPolishPreview>>,
/// 翻译模式触发标志。每次 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>>,
coding_agent_modifier_hotkey: Mutex<Option<HotkeyMonitor>>,
coding_agent_combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
/// 最近一次 emit_capsule 下发的 state,纯内省/测试用途(在 app 句柄校验之前写入,
/// 因此无 GUI 的测试环境也能断言「按下热键 → 弹了哪种胶囊」)。写入是单次廉价
/// 加锁,对 ~30Hz 录音回调可忽略。
last_capsule_state: Mutex<Option<CapsuleState>>,
/// 每次 capsule payload 递增。选区润色的终态自动隐藏会带上该代数,防止旧 timer
/// 覆盖新的选区润色/语音/QA 可见状态。
capsule_event_epoch: AtomicU64,
/// 将 capsule 事件与自动隐藏线性化。这样一个旧 timer 要么在新的 payload 之前收起
/// 旧提示,要么发现代数已改变直接放弃,绝不会在新会话之后补发 Idle。
capsule_event_lock: Mutex<()>,
/// 选区润色的轻量提示仍在显示或处理中。已有语音/QA 的旧 auto-hide timer 必须在
/// 此期间让路,避免把选区润色浮窗提前收掉。
selection_polish_capsule_active: AtomicBool,
/// QA 单独的 session 状态,与 dictation 的 SessionPhase 不冲突。
qa_state: Mutex<QaSessionState>,
/// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发
/// resize / reposition。
capsule_layout: Mutex<Option<CapsuleLayoutState>>,
/// 预备态标志:按下热键即"乐观显示"胶囊(带入场动画),此时麦克风还在 cpal
/// init 窗口内、没有第一帧 PCM。为 true 时 emit_capsule 把 Recording payload 的
/// `warming` 打成 true(前端渲染"待命"光效);`level_handler` 首次触发(PCM 真的
/// 流入)后置 false,光条"点亮"进入正式录音。begin_session 每次入场重置为 true。
capsule_warming: AtomicBool,
/// 用户选择的胶囊样式缓存(0=Siri,1=Classic)。emit_capsule 在音频回调线程
/// ~30Hz 读它下发 payload.capsuleStyle;主线程闭包每帧从 prefs 同步该值——
/// 读偏好锁的代价只落在主线程(与 show_capsule 同源),音频线程零开销。
capsule_style: AtomicU8,
/// 胶囊窗口当前是否鼠标穿透(true=穿透)。经典药丸需要接收 ✕/✓ 点击时,主线程
/// 闭包把它翻 false;离开可交互状态立即恢复 true。初始 true 与 lib.rs 启动时
/// set_ignore_cursor_events(true) 保持一致。
capsule_cursor_passthrough: AtomicBool,
/// QA 用的 ASR 句柄。必须跟 active_asr_provider 保持一致,避免浮窗走不同入口。
qa_asr: Mutex<Option<SessionResource<ActiveAsr>>>,
/// QA 用的 Recorder 句柄。
qa_recorder: Mutex<Option<SessionResource<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,
#[cfg(not(mobile))]
remote_audio_sink: Mutex<Option<Arc<dyn crate::recorder::AudioConsumer>>>,
#[cfg(not(mobile))]
remote_server: Mutex<Option<crate::remote_server::RemoteServerHandle>>,
#[cfg(not(mobile))]
remote_refresh_gen: AtomicU64,
#[cfg(not(mobile))]
remote_refresh_lock: tokio::sync::Mutex<()>,
#[cfg(not(mobile))]
remote_pin: Mutex<Option<String>>,
#[cfg(not(mobile))]
remote_locale: Mutex<String>,
#[cfg(not(mobile))]
remote_no_insert: AtomicBool,
/// Less Computer 连续对话:true=浮窗里已有进行中的会话,下一轮 `claude --continue` 续上下文;
/// 关闭浮窗(dismiss)复位为 false,下次说话开新会话。
less_computer_conversation: AtomicBool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActionHotkeyKind {
SwitchStyle,
OpenApp,
}
#[cfg(target_os = "windows")]
#[derive(Debug)]
struct PreparedWindowsImeSessionSlot {
session_id: SessionId,
prepared: PreparedWindowsImeSession,
}
/// 历史音频静默重试的 ASR 资源护栏。
///
/// 重试 future 被 select 丢弃时,局部 QaAsrStart 不会再经过正常的
/// end_session 收尾;这里用 Drop 补 cancel 和本地模型释放,尤其覆盖
/// spawn_blocking 已经开始运行的本地 ASR。
struct CancellableRetranscribeGuard {
inner: Arc<Inner>,
asr: Option<ActiveAsr>,
session_id: SessionId,
}
impl CancellableRetranscribeGuard {
fn new(inner: Arc<Inner>, asr: ActiveAsr, session_id: SessionId) -> Self {
Self {
inner,
asr: Some(asr),
session_id,
}
}
fn disarm(mut self) {
self.asr.take();
}
}
impl Drop for CancellableRetranscribeGuard {
fn drop(&mut self) {
let Some(asr) = self.asr.take() else {
return;
};
let asr_for_release = asr.clone();
cancel_active_asr(asr);
dictation::schedule_cancelled_asr_release(&self.inner, &asr_for_release, self.session_id);
}
}
#[cfg(not(mobile))]
fn persist_and_commit_remote_pin(
slot: &Mutex<Option<String>>,
pin: String,
persist: impl FnOnce(&str) -> Result<(), String>,
refresh: impl FnOnce(),
) -> Result<String, String> {
persist(&pin)?;
*slot.lock() = Some(pin.clone());
refresh();
Ok(pin)
}
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"))]
{
#[cfg(target_os = "android")]
const PERSIST_DEGRADE_SUFFIX: &str = " (Android 禁止 /data/local/tmp)";
#[cfg(not(target_os = "android"))]
const PERSIST_DEGRADE_SUFFIX: &str = "";
let history = HistoryStore::new().unwrap_or_else(|e| {
log::error!(
"[coord] HistoryStore init failed: {e}; 降级为空历史记录{PERSIST_DEGRADE_SUFFIX}"
);
HistoryStore::new_fallback()
});
let prefs = PreferencesStore::new().unwrap_or_else(|e| {
log::error!(
"[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置{PERSIST_DEGRADE_SUFFIX}"
);
PreferencesStore::new_fallback()
});
let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| {
log::error!(
"[coord] StylePackStore init failed: {e}; 降级为空样式包列表{PERSIST_DEGRADE_SUFFIX}"
);
StylePackStore::new_fallback()
});
let vocab = DictionaryStore::new().unwrap_or_else(|e| {
log::error!(
"[coord] DictionaryStore init failed: {e}; 降级为空词库{PERSIST_DEGRADE_SUFFIX}"
);
DictionaryStore::new_fallback()
});
let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| {
log::error!(
"[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则{PERSIST_DEGRADE_SUFFIX}"
);
CorrectionRuleStore::new_fallback()
});
let activity = ActivityStore::load().unwrap_or_else(|e| {
log::error!("[coord] ActivityStore init failed: {e}; 活动计数降级为内存态");
ActivityStore::new_fallback()
});
Self {
inner: Arc::new(Inner {
app: Mutex::new(None),
history,
activity,
prefs,
style_packs,
vocab,
correction_rules,
inserter: TextInserter::new(),
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
asr_label: 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),
hotkey_press_generation: AtomicU64::new(0),
hotkey_press_began_session: AtomicU64::new(0),
hotkey_combo_pending_presses: Mutex::new(std::collections::VecDeque::new()),
last_hotkey_dispatch_at: Mutex::new(None),
hotkey_press_at: Mutex::new(None),
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
less_computer_press_generation: AtomicU64::new(0),
less_computer_combo_pending_press: AtomicU64::new(0),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
#[cfg(not(mobile))]
selection_polish_hotkey: Mutex::new(None),
#[cfg(not(mobile))]
selection_polish_preview: Mutex::new(None),
translation_modifier_seen: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
coding_agent_modifier_hotkey: Mutex::new(None),
coding_agent_combo_hotkey: Mutex::new(None),
last_capsule_state: Mutex::new(None),
capsule_event_epoch: AtomicU64::new(0),
capsule_event_lock: Mutex::new(()),
selection_polish_capsule_active: AtomicBool::new(false),
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
capsule_warming: AtomicBool::new(false),
capsule_style: AtomicU8::new(0),
capsule_cursor_passthrough: AtomicBool::new(true),
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),
#[cfg(not(mobile))]
remote_audio_sink: Mutex::new(None),
#[cfg(not(mobile))]
remote_server: Mutex::new(None),
#[cfg(not(mobile))]
remote_refresh_gen: AtomicU64::new(0),
#[cfg(not(mobile))]
remote_refresh_lock: tokio::sync::Mutex::new(()),
#[cfg(not(mobile))]
remote_pin: Mutex::new(None),
#[cfg(not(mobile))]
remote_locale: Mutex::new(String::from("zh-CN")),
#[cfg(not(mobile))]
remote_no_insert: AtomicBool::new(false),
less_computer_conversation: 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}; 降级为空历史记录");
HistoryStore::new_fallback()
});
let prefs = PreferencesStore::new().unwrap_or_else(|e| {
log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置");
PreferencesStore::new_fallback()
});
let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| {
log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表");
StylePackStore::new_fallback()
});
let vocab = DictionaryStore::new().unwrap_or_else(|e| {
log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库");
DictionaryStore::new_fallback()
});
let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| {
log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则");
CorrectionRuleStore::new_fallback()
});
let activity = ActivityStore::load().unwrap_or_else(|e| {
log::error!("[coord] ActivityStore init failed: {e}; 活动计数降级为内存态");
ActivityStore::new_fallback()
});
Self {
inner: Arc::new(Inner {
app: Mutex::new(None),
history,
activity,
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),
asr_label: 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),
hotkey_press_generation: AtomicU64::new(0),
hotkey_press_began_session: AtomicU64::new(0),
hotkey_combo_pending_presses: Mutex::new(std::collections::VecDeque::new()),
last_hotkey_dispatch_at: Mutex::new(None),
hotkey_press_at: Mutex::new(None),
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
less_computer_press_generation: AtomicU64::new(0),
less_computer_combo_pending_press: AtomicU64::new(0),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
#[cfg(not(mobile))]
selection_polish_hotkey: Mutex::new(None),
#[cfg(not(mobile))]
selection_polish_preview: Mutex::new(None),
translation_modifier_seen: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
coding_agent_modifier_hotkey: Mutex::new(None),
coding_agent_combo_hotkey: Mutex::new(None),
last_capsule_state: Mutex::new(None),
capsule_event_epoch: AtomicU64::new(0),
capsule_event_lock: Mutex::new(()),
selection_polish_capsule_active: AtomicBool::new(false),
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
capsule_warming: AtomicBool::new(false),
capsule_style: AtomicU8::new(0),
capsule_cursor_passthrough: AtomicBool::new(true),
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),
#[cfg(not(mobile))]
remote_audio_sink: Mutex::new(None),
#[cfg(not(mobile))]
remote_server: Mutex::new(None),
#[cfg(not(mobile))]
remote_refresh_gen: AtomicU64::new(0),
#[cfg(not(mobile))]
remote_refresh_lock: tokio::sync::Mutex::new(()),
#[cfg(not(mobile))]
remote_pin: Mutex::new(None),
#[cfg(not(mobile))]
remote_locale: Mutex::new(String::from("zh-CN")),
#[cfg(not(mobile))]
remote_no_insert: AtomicBool::new(false),
less_computer_conversation: 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;
// 预热加载完后推一次状态,前端零轮询更新「已加载」。
emit_local_asr_engine_status(&inner);
});
}
#[cfg(not(target_os = "macos"))]
{
// no-op
}
}
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
pub fn release_local_asr_engine(&self) {
self.inner.local_asr_cache.release_now();
emit_local_asr_engine_status(&self.inner);
}
pub fn local_asr_loaded_model(&self) -> Option<String> {
self.inner.local_asr_cache.loaded_model_id()
}
/// 主动把当前本地 ASR 引擎状态推给前端(keepLoadedSecs 变更等命令侧调用)。
pub fn emit_local_asr_engine_status(&self) {
emit_local_asr_engine_status(&self.inner);
}
pub fn bind_app(&self, handle: AppHandle) {
*self.inner.app.lock() = Some(handle);
}
pub fn android_insert_strategy(&self) -> crate::types::AndroidInsertStrategy {
self.inner.prefs.get().android_insert_strategy
}
pub fn android_overlay_trigger(&self) -> crate::types::AndroidOverlayTrigger {
self.inner.prefs.get().android_overlay_trigger.normalized()
}
pub fn apply_android_overlay_settings_change(
&self,
previous: &crate::types::UserPreferences,
next: &crate::types::UserPreferences,
) {
#[cfg(target_os = "android")]
{
use crate::types::android_types::{
classify_android_overlay_settings_change, AndroidOverlaySettingsAction,
};
match classify_android_overlay_settings_change(previous, next) {
AndroidOverlaySettingsAction::None => {}
AndroidOverlaySettingsAction::RefreshLayout => {
self.refresh_android_overlay_layout();
}
AndroidOverlaySettingsAction::Transition { from, to } => {
self.transition_android_overlay_trigger(from, to);
}
}
}
let _ = (previous, next);
}
pub fn transition_android_overlay_trigger(
&self,
from: crate::types::AndroidOverlayTrigger,
to: crate::types::AndroidOverlayTrigger,
) {
#[cfg(target_os = "android")]
{
use crate::types::AndroidOverlayTrigger;
fn overlay_trigger_log_name(trigger: AndroidOverlayTrigger) -> &'static str {
match trigger.normalized() {
AndroidOverlayTrigger::Background => "background",
AndroidOverlayTrigger::Keyboard => "keyboard",
AndroidOverlayTrigger::Always => "always",
}
}
if from == to {
return;
}
log::info!(
"[coord] overlay transition from={} to={}",
overlay_trigger_log_name(from),
overlay_trigger_log_name(to),
);
match (from, to) {
(
AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard,
AndroidOverlayTrigger::Always,
) => {
let _ = crate::android::replace_android_overlay();
}
(
AndroidOverlayTrigger::Always,
AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard,
) => {
let _ = crate::android::hide_android_overlay();
}
_ => {}
}
}
let _ = (from, to);
}
pub fn apply_android_overlay_on_startup(&self) {
#[cfg(target_os = "android")]
{
use crate::types::AndroidOverlayTrigger;
match self.android_overlay_trigger() {
AndroidOverlayTrigger::Always => {
let _ = crate::android::replace_android_overlay();
}
AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard => {
let _ = crate::android::hide_android_overlay();
}
}
}
}
pub fn refresh_android_overlay_layout(&self) {
#[cfg(target_os = "android")]
{
let _ = crate::android::refresh_android_overlay_layout();
}
}
/// 让所有 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();
}
/// 启动「快速 Agent」双热键 supervisor。与 QA hotkey 平行;功能默认关闭,
/// 仅在 `coding_agent_enabled` 时注册。
pub fn start_coding_agent_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-coding-agent-hotkey-supervisor".into())
.spawn(move || coding_agent_hotkey_supervisor_loop(inner))
.ok();
}
pub fn stop_coding_agent_hotkey_listener(&self) {
take_coding_agent_hotkeys_on_main_thread(&self.inner);
}
pub fn update_coding_agent_hotkey_binding(&self) {
update_coding_agent_hotkey_binding_now(&self.inner);
}
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();
}
}
#[cfg(not(mobile))]
pub fn start_selection_polish_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-selection-polish-hotkey-supervisor".into())
.spawn(move || selection_polish_hotkey_supervisor_loop(inner))
.ok();
}
#[cfg(not(mobile))]
pub fn stop_selection_polish_hotkey_listener(&self) {
take_selection_polish_hotkey_on_main_thread(&self.inner);
}
#[cfg(not(mobile))]
pub fn try_update_selection_polish_hotkey_binding(&self) -> Result<(), String> {
try_update_selection_polish_hotkey_binding(&self.inner)
}
#[cfg(not(mobile))]
pub fn update_selection_polish_hotkey_binding(&self) {
if let Err(error) = self.try_update_selection_polish_hotkey_binding() {
log::warn!("[coord] update selection polish hotkey binding failed: {error}");
}
}
/// 启动自定义组合键监听器。当 `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() {
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
log::info!("[coord] combo hotkey 已关闭(modifier-only)");
return;
}
let binding = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
log::info!("[coord] combo hotkey 已关闭(无绑定)");
return;
}
if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) {
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) {
Ok(monitor) => {
*self.inner.side_aware_combo.lock() = Some(monitor);
let bridge_inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-side-combo-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
.ok();
log::info!("[coord] side-aware combo hotkey listener installed (via update)");
}
Err(e) => {
log::warn!("[coord] update side-aware combo binding 失败: {e}");
}
}
return;
}
self.inner.side_aware_combo.lock().take();
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) {
// None = 用户主动停用:反注册全局键,立即生效。
let Some(binding) = action_hotkey_binding(&self.inner, kind) else {
take_action_hotkey_on_main_thread(&self.inner, kind);
log::info!("[coord] action hotkey {kind:?} 已停用(用户清空)");
return;
};
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);
}
/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗:隐藏窗口 + 结束连续对话
/// (下次说话开新会话,不再 --continue 续旧上下文)。
pub fn less_computer_window_dismiss(&self) {
self.inner
.less_computer_conversation
.store(false, Ordering::SeqCst);
if let Some(app) = self.inner.app.lock().clone() {
crate::hide_less_computer_window(&app);
crate::hide_less_computer_glow(&app);
}
}
/// 内联审批卡的 Approve / Deny 回执:解析等待中的 token。
pub fn less_computer_approve(&self, token: &str, approved: bool) {
dictation::resolve_less_computer_approval(token, approved);
}
/// 浮窗打字输入:文字指令直接进入 Less Computer 执行链(与语音转写同一条
/// 路径——同样的护栏钳制 / 审批循环 / 连续会话语义),跳过录音与 ASR。
pub fn less_computer_submit_text(&self, text: String) {
let text = text.trim().to_string();
if text.is_empty() {
return;
}
let inner = Arc::clone(&self.inner);
tokio::spawn(async move {
let session_id = crate::coordinator_state::new_session_id();
if let Err(e) =
dictation::run_voice_agent_transcript(&inner, session_id, text, 0).await
{
log::warn!("[less-computer] text submit run failed: {e}");
}
});
}
pub fn history(&self) -> &HistoryStore {
&self.inner.history
}
pub fn activity(&self) -> &ActivityStore {
&self.inner.activity
}
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());
let cancel_tx = spawn_esc_cancel_bridge(&self.inner);
let combo_tx = spawn_combo_abort_bridge(&self.inner, handle_trigger_combined);
#[cfg(target_os = "linux")]
let combo_tx_for_fcitx = combo_tx.clone();
match HotkeyMonitor::start(binding, tx, cancel_tx, combo_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, _selection_polish_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,
combo_tx_for_fcitx,
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, selection_polish_trigger, translation_trigger) =
modifier_shortcut_triggers(&self.inner);
monitor.update_modifier_shortcuts(
qa_trigger,
selection_polish_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 start_dictation_with_translation(&self) -> Result<(), String> {
begin_session(&self.inner).await?;
self.inner
.translation_modifier_seen
.store(true, Ordering::SeqCst);
log::info!("[coord] android overlay translation dictation started");
Ok(())
}
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 async fn stop_dictation_with_translation(&self, translation: bool) -> Result<(), String> {
if translation {
mark_translation_modifier_seen(&self.inner);
}
self.stop_dictation().await
}
pub fn cancel_dictation(&self) {
cancel_session(&self.inner);
}
#[cfg(not(mobile))]
pub fn set_remote_no_insert(&self, no_insert: bool) {
self.inner
.remote_no_insert
.store(no_insert, Ordering::SeqCst);
}
#[cfg(not(mobile))]
pub async fn start_remote_dictation(&self) -> Result<(), String> {
begin_session(&self.inner).await
}
#[cfg(not(mobile))]
pub fn feed_remote_pcm(&self, pcm: &[u8]) {
let phase = self.inner.state.lock().phase;
if phase != SessionPhase::Listening && phase != SessionPhase::Starting {
return;
}
let sink = self.inner.remote_audio_sink.lock().clone();
if let Some(consumer) = sink {
consumer.consume_pcm_chunk(pcm);
}
}
#[cfg(not(mobile))]
pub async fn stop_remote_dictation(&self) -> Result<(), String> {
if self.inner.state.lock().phase == SessionPhase::Starting {
request_stop_during_starting(&self.inner, "remote stop");
return Ok(());
}
end_session(&self.inner).await
}
#[cfg(not(mobile))]
pub fn cancel_remote_dictation(&self) {
cancel_session(&self.inner);
*self.inner.remote_audio_sink.lock() = None;
}
#[cfg(not(mobile))]
pub fn remote_input_status(&self) -> crate::remote_server::RemoteInputStatus {
let prefs = self.inner.prefs.get();
let handle = self.inner.remote_server.lock();
let running = handle.is_some();
let port = handle
.as_ref()
.map(|h| h.bound_port)
.unwrap_or(prefs.remote_input_port);
let pin = self.inner.remote_pin.lock().clone().unwrap_or_default();
let urls = if running {
crate::remote_server::access_urls(port)
} else {
Vec::new()
};
crate::remote_server::RemoteInputStatus {
running,
port,
pin,
urls,
}
}
#[cfg(not(mobile))]
pub fn regenerate_remote_pin(self: &Arc<Self>) -> Result<String, String> {
let pin = crate::remote_server::generate_pin();
let app = self
.inner
.app
.lock()
.clone()
.ok_or_else(|| "OpenLess app handle is unavailable".to_string())?;
persist_and_commit_remote_pin(
&self.inner.remote_pin,
pin,
|pin| {
crate::remote_server::save_pin(&app, pin)
.map_err(|error| format!("persist pairing PIN failed: {error}"))
},
|| self.refresh_remote_server(),
)
}
#[cfg(not(mobile))]
pub fn set_remote_locale(&self, locale: String) {
const SUPPORTED: [&str; 5] = ["zh-CN", "zh-TW", "en", "ja", "ko"];
if SUPPORTED.contains(&locale.as_str()) {
*self.inner.remote_locale.lock() = locale;
}
}
#[cfg(not(mobile))]
pub fn remote_locale(&self) -> String {
self.inner.remote_locale.lock().clone()
}
#[cfg(not(mobile))]
pub fn refresh_remote_server(self: &Arc<Self>) {
let coord = Arc::clone(self);
let gen = self.inner.remote_refresh_gen.fetch_add(1, Ordering::SeqCst) + 1;
tauri::async_runtime::spawn(async move {
let _serial = coord.inner.remote_refresh_lock.lock().await;
if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen {
return;
}
let old = coord.inner.remote_server.lock().take();
if let Some(handle) = old {
handle.shutdown().await;
}
let prefs = coord.inner.prefs.get();
let app = coord.inner.app.lock().clone();
if !prefs.remote_input_enabled {
if let Some(app) = &app {
let _ = app.emit(
"remote-input:running",
serde_json::json!({"running": false}),
);
}
return;
}
let Some(app) = app else {
return;
};
let pin = if let Some(pin) = coord.inner.remote_pin.lock().clone() {
pin
} else {
match crate::remote_server::load_or_create_pin(&app) {
Ok(pin) => {
*coord.inner.remote_pin.lock() = Some(pin.clone());
pin
}
Err(error) => {
let reason = format!("persist pairing PIN failed: {error}");
let _ = app.emit(
"remote-input:error",
serde_json::json!({"reason": reason, "port": prefs.remote_input_port}),
);
log::error!("[remote-input] {reason}");
return;
}
}
};
let port = prefs.remote_input_port;
match crate::remote_server::start(crate::remote_server::RemoteServerConfig {
port,
pin: pin.clone(),
coordinator: Arc::clone(&coord),
app: app.clone(),
})
.await
{
Ok(handle) => {
let urls = crate::remote_server::access_urls(port);
*coord.inner.remote_server.lock() = Some(handle);
let _ = app.emit(
"remote-input:running",
serde_json::json!({"running": true, "port": port, "urls": urls, "pin": pin}),
);
log::info!("[remote-input] server started on port {port}");
}
Err(e) => {
let _ = app.emit(
"remote-input:error",
serde_json::json!({"reason": e, "port": port}),
);
log::error!("[remote-input] server start failed: {e}");
}
}
});
}
pub fn switch_to_previous_style_pack(&self) {
switch_to_previous_style(&self.inner);
}
pub async fn open_qa_from_overlay(&self) -> Result<(), String> {
log::info!("[coord] overlay QA open requested");
open_qa_panel(&self.inner);
begin_qa_session(&self.inner).await
}
pub async fn finalize_qa_from_overlay(&self) -> Result<(), String> {
log::info!("[coord] overlay QA finalize requested");
finalize_dictation_as_qa_question(&self.inner).await
}
/// 返回当前听写阶段(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 async fn qa_toggle_recording(&self) {
handle_qa_option_edge(&self.inner).await;
}
pub async fn qa_submit_text(&self, text: String) -> Result<(), String> {
submit_qa_text_question(&self.inner, text).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, std::time::Instant::now(), 0).await;
handle_released(&self.inner, std::time::Instant::now()).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 = crate::types::style_pack_prompt(
&pack,
crate::types::StylePromptKind::DictationAsr,
);
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(),
&[],
// repolish 不回写历史的模型/耗时字段,调用快照就地丢弃。
&mut None,
&mut None,
)
.await
.map_err(|e| e.to_string())
}
/// 返回 (转写文本, 本次实际构建的 ASR (provider, model) 快照)。快照供命令层把
/// 「重转用了哪个模型」写回历史(构建时归因,PR #826 review)。
pub async fn retranscribe_pcm(
&self,
pcm: Vec<u8>,
) -> Result<(String, AsrCallLabel), String> {
self.retranscribe_pcm_inner(pcm, false, None).await
}
pub(super) async fn retranscribe_pcm_until_cancelled(
&self,
pcm: Vec<u8>,
) -> (Result<String, String>, Option<AsrCallLabel>) {
// 自动静默重试会重新读取当前设置并构建一条全新的 ASR 会话,因此必须把这次
// 实际构建的标签交还给调用方。即使请求最终失败,也保留“本次尝试了谁”,让
// 彻底失败的历史不会退回首次会话的旧归因。
let mut attempted_label = None;
let result = self
.retranscribe_pcm_inner(pcm, true, Some(&mut attempted_label))
.await
.map(|(text, _)| text);
(result, attempted_label)
}
async fn retranscribe_pcm_inner(
&self,
pcm: Vec<u8>,
cancel_on_drop: bool,
attempted_label: Option<&mut Option<AsrCallLabel>>,
) -> Result<(String, AsrCallLabel), String> {
let inner = &self.inner;
let active_asr = CredentialsVault::get_active_asr();
let (start, asr_call_label) = build_qa_asr_start(inner, &active_asr).await?;
if let Some(label_slot) = attempted_label {
*label_slot = Some(asr_call_label.clone());
}
let retry_guard = if cancel_on_drop {
Some(CancellableRetranscribeGuard::new(
Arc::clone(inner),
start.active_asr(),
inner.state.lock().session_id,
))
} else {
None
};
start.open_streaming_session().await?;
let consumer = start.recorder_consumer();
consumer.consume_pcm_chunk(&pcm);
let timeout = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
let audio_secs = crate::asr::pcm::pcm_duration_ms(&pcm) as f64 / 1000.0;
let elevenlabs_timeout = crate::asr::elevenlabs::transcribe_timeout(
crate::asr::pcm::pcm_duration_ms(&pcm) as f64 / 1000.0,
);
let raw = match start.active_asr() {
ActiveAsr::Volcengine(asr) => {
asr.send_last_frame().await.map_err(|e| e.to_string())?;
tokio::time::timeout(timeout, asr.await_final_result())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
}
ActiveAsr::Bailian(asr) => {
asr.send_last_frame().await.map_err(|e| e.to_string())?;
tokio::time::timeout(timeout, asr.await_final_result())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
}
ActiveAsr::Qwen3Realtime(asr) => {
asr.send_last_frame().await.map_err(|e| e.to_string())?;
tokio::time::timeout(timeout, asr.await_final_result())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
}
ActiveAsr::StepfunRealtime(asr) => {
asr.send_last_frame().await.map_err(|e| e.to_string())?;
tokio::time::timeout(timeout, asr.await_final_result())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
}
ActiveAsr::Whisper(w) => tokio::time::timeout(timeout, w.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?,
ActiveAsr::Mimo(m) => tokio::time::timeout(timeout, m.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?,
ActiveAsr::DashScopeMultimodal(m) => {
tokio::time::timeout(m.transcribe_timeout(audio_secs), m.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
}
ActiveAsr::ElevenLabs(e) => {
tokio::time::timeout(elevenlabs_timeout, e.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
}
ActiveAsr::ElevenLabs(e) => {
tokio::time::timeout(elevenlabs_timeout, e.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
}
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => {
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
local
.transcribe(windows_local_asr_transcribe_timeout(audio_secs))
.await
.map_err(|e| e.to_string())?
}
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => {
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
local
.transcribe(windows_local_asr_transcribe_timeout(audio_secs))
.await
.map_err(|e| e.to_string())?
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => {
let dur =
local_qwen_transcribe_timeout((local.buffer_duration_ms() as f64) / 1000.0);
inner.local_asr_cache.touch();
let out = tokio::time::timeout(dur, local.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?;
schedule_local_asr_release(inner);
out
}
#[cfg(target_os = "macos")]
ActiveAsr::AppleSpeech(local) => tokio::time::timeout(timeout, local.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?,
};
if let Some(guard) = retry_guard {
guard.disarm();
}
Ok((raw.text, asr_call_label))
}
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
}
// ─────────────────────────── 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>,
session_id: SessionId,
rx: mpsc::Receiver<RecorderError>,
) {
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 session_id != current_session_id {
log::warn!(
"[coord] QA recorder error from stale session {} dropped (current={}, err={})",
session_id,
current_session_id,
err
);
return;
}
log::error!("[coord] QA recorder runtime error: {err}");
finish_qa_with_error_if_current(
&inner,
session_id,
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")]
pub(super) fn insert_via_non_tsf_fallback(
inner: &Arc<Inner>,
polished: &str,
_restore_clipboard: bool,
_paste_shortcut: PasteShortcut,
) -> InsertStatus {
let prefs = inner.prefs.get();
let sendinput_options = dictation::windows_sendinput_options_from_prefs(&prefs);
let status = finish_non_tsf_insertion_fallback(
|| inner
.inserter
.insert_via_unicode_keystrokes(polished, sendinput_options),
|| 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 ───────────────────────────
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_mimo_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()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string());
(api_key, base_url, model)
}
fn read_elevenlabs_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()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_MODEL.to_string());
(api_key, base_url, model)
}
fn read_dashscope_multimodal_credentials() -> (String, String, String) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string());
let base_url = if unified_bailian_is_active() {
let endpoint = read_asr_endpoint(crate::asr::bailian::DEFAULT_ENDPOINT);
let protocol = match crate::asr::dashscope_multimodal::protocol_for_model(&model) {
Some(crate::asr::dashscope_multimodal::DashScopeBatchProtocol::AsyncTranscription) => {
BailianEndpointProtocol::AsyncTranscription
}
_ => BailianEndpointProtocol::Multimodal,
};
derive_bailian_endpoint(&endpoint, protocol).unwrap_or(endpoint)
} else {
CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_ENDPOINT.to_string())
};
(api_key, base_url, model)
}
fn read_asr_vocabulary_id() -> Option<String> {
CredentialsVault::get(CredentialAccount::AsrVocabularyId)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
}
fn read_bailian_credentials() -> BailianCredentials {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let stored_endpoint = read_asr_endpoint(crate::asr::bailian::DEFAULT_ENDPOINT);
let endpoint = if unified_bailian_is_active() {
derive_bailian_endpoint(&stored_endpoint, BailianEndpointProtocol::ClassicRealtime)
.unwrap_or(stored_endpoint)
} else {
stored_endpoint
};
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 = read_asr_vocabulary_id();
BailianCredentials {
api_key,
endpoint,
model,
vocabulary_id,
}
}
fn read_asr_endpoint(default_endpoint: &str) -> String {
CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| default_endpoint.to_string())
}
/// 统一「阿里云百炼」入口的三条协议共用同一个 `bailian` 凭据条目。存储 endpoint
/// 提供区域/工作空间主机,运行时再按所选模型推导各协议的 scheme 与 path。
/// 老用户停在别名 id(`bailian-qwen3-realtime` / `bailian-fun-asr-flash`)上时不触发,
/// 仍读自己条目里存的 endpoint。
pub(crate) fn unified_bailian_is_active() -> bool {
CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID
}
fn read_qwen3_realtime_credentials() -> Qwen3RealtimeCredentials {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let endpoint = if unified_bailian_is_active() {
let endpoint = read_asr_endpoint(crate::asr::bailian::DEFAULT_ENDPOINT);
derive_bailian_endpoint(&endpoint, BailianEndpointProtocol::QwenRealtime)
.unwrap_or(endpoint)
} else {
CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::qwen_realtime::DEFAULT_ENDPOINT.to_string())
};
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::qwen_realtime::DEFAULT_MODEL.to_string());
Qwen3RealtimeCredentials {
api_key,
endpoint,
model,
}
}
/// StepFun 实时凭据与批式共用同一组槽位(一把 key、同一个 https base、
/// 模型名区分协议);wss URL 由 client 的 `connect_url()` 从 base 派生。
/// `prompt` 由调用方按用户词典填充(实时协议接受 prompt、批式只认 hotwords)。
fn read_stepfun_realtime_credentials(
prompt: Option<String>,
) -> crate::asr::StepfunRealtimeCredentials {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::stepfun_realtime::DEFAULT_MODEL.to_string());
crate::asr::StepfunRealtimeCredentials {
api_key,
endpoint,
model,
prompt,
}
}
fn read_volc_credentials() -> VolcengineCredentials {
use crate::asr::volcengine::VolcengineAuthMode;
let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey)
.ok()
.flatten()
.unwrap_or_default();
let auth_mode = CredentialsVault::get(CredentialAccount::VolcengineAuthMode)
.ok()
.flatten()
.map(|s| VolcengineAuthMode::from_str(&s))
.unwrap_or(VolcengineAuthMode::AppIdToken);
// 密钥槽位随鉴权模式:AppIdToken 读旧版 Access Token,ApiKey 读独立的方舟 API Key,
// 两者互不污染,切换模式不会把旧模式的凭据带进新模式的握手。
let secret = match auth_mode {
VolcengineAuthMode::AppIdToken => CredentialsVault::get(CredentialAccount::VolcengineAccessKey)
.ok()
.flatten()
.unwrap_or_default(),
VolcengineAuthMode::ApiKey => CredentialsVault::get(CredentialAccount::VolcengineApiKey)
.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 {
auth_mode,
app_id,
access_token: secret,
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()
}
/// 读 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))
}
/// 构建 ASR 客户端那一刻捕获的 (provider, model) 快照。随会话资源一起存放
/// (store_asr_for_session),end_session 取走写 history。provider 是实际构建用的
/// 具体协议 id(统一百炼入口会先经 resolve_effective_asr_provider 重定向);model
/// 是构建时实际传给客户端的值(含 alias 归一化与默认回退)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AsrCallLabel {
pub provider: String,
pub model: Option<String>,
}
impl AsrCallLabel {
pub(crate) fn new(provider: impl Into<String>, model: Option<String>) -> Self {
Self {
provider: provider.into(),
model: model.filter(|m| !m.trim().is_empty()),
}
}
}
/// Volcengine resource id 进历史前的 allowlist:只放行 `volc.` 命名空间的产品标识
/// (如 volc.seedasr.sauc.duration / volc.bigasr.sauc.duration),字符集限 ASCII
/// 字母数字与 `._-`。自定义/异常值可能携带租户信息,一律不落盘(PR #826 review /
/// issue #373 的可观测性诉求)。
pub(crate) fn volc_resource_history_label(resource_id: &str) -> Option<String> {
let id = resource_id.trim();
let allowed = id.starts_with("volc.")
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
allowed.then(|| id.to_string())
}
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 temperature = openai_compatible_temperature_for_provider(
&active,
CredentialsVault::get_active_llm_temperature(),
);
let config = OpenAICompatibleConfig::new(active, "OpenLess LLM", base_url, api_key, model)
.with_thinking_enabled(llm_thinking_enabled)
.with_temperature(temperature)
.with_extra_headers(CredentialsVault::get_active_llm_extra_headers());
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 {
#[test]
fn volc_resource_history_label_allows_volc_namespace_ids() {
// issue #373 场景的两个真实 resource id 必须放行。
assert_eq!(
super::volc_resource_history_label("volc.seedasr.sauc.duration").as_deref(),
Some("volc.seedasr.sauc.duration")
);
assert_eq!(
super::volc_resource_history_label(" volc.bigasr.sauc.duration ").as_deref(),
Some("volc.bigasr.sauc.duration"),
"首尾空白应被 trim"
);
}
#[test]
fn volc_resource_history_label_rejects_non_allowlisted_values() {
// 非 volc. 命名空间 / 含异常字符 / 超长的值可能携带租户信息,一律不落历史。
assert_eq!(super::volc_resource_history_label(""), None);
assert_eq!(super::volc_resource_history_label("my-secret-tenant"), None);
assert_eq!(super::volc_resource_history_label("volc.a b"), None, "空格不在字符集");
assert_eq!(super::volc_resource_history_label("volc.引擎"), None, "非 ASCII 拒绝");
let too_long = format!("volc.{}", "x".repeat(64));
assert_eq!(super::volc_resource_history_label(&too_long), None);
}
use super::dictation::abort_recording_with_error;
use super::dictation::{handle_pressed_edge, handle_released_edge};
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 failed_remote_pin_persistence_keeps_memory_and_server_state() {
let slot = Mutex::new(Some("123456".to_string()));
let refreshed = std::sync::atomic::AtomicBool::new(false);
let result = persist_and_commit_remote_pin(
&slot,
"654321".to_string(),
|_| Err("injected persistence failure".to_string()),
|| refreshed.store(true, Ordering::SeqCst),
);
assert_eq!(result.unwrap_err(), "injected persistence failure");
assert_eq!(slot.lock().as_deref(), Some("123456"));
assert!(!refreshed.load(Ordering::SeqCst));
}
#[test]
fn successful_remote_pin_persistence_commits_memory_before_refresh() {
let slot = Mutex::new(Some("123456".to_string()));
let observed = Mutex::new(None::<String>);
let result = persist_and_commit_remote_pin(
&slot,
"654321".to_string(),
|_| Ok(()),
|| *observed.lock() = slot.lock().clone(),
);
assert_eq!(result.as_deref(), Ok("654321"));
assert_eq!(slot.lock().as_deref(), Some("654321"));
assert_eq!(observed.lock().as_deref(), Some("654321"));
}
#[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");
}
/// 复现并验证目标 2(a):按下 Less Computer 键必须弹出可见胶囊。
/// 这里直接驱动 bridge 会调用的 handler,断言 begin_session 确实下发了可见胶囊。
#[tokio::test]
async fn less_computer_press_emits_visible_capsule() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
{
let mut prefs = coordinator.inner.prefs.get();
prefs.coding_agent_enabled = true;
coordinator.inner.prefs.set(prefs).unwrap();
}
// 前置:还没弹过任何胶囊。
assert!(coordinator.inner.last_capsule_state.lock().is_none());
// 等价于「按下 Less Computer 键」:bridge_loop 收到 Pressed 后就是调这个 handler。
super::handle_less_computer_pressed(&coordinator.inner).await;
assert_eq!(
*coordinator.inner.last_capsule_state.lock(),
Some(CapsuleState::Recording),
"按下 Less Computer 键必须进入录音并弹出可见胶囊"
);
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
));
assert!(!is_whisper_compatible_provider(
crate::asr::mimo::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 openrouter_is_whisper_compatible_json_provider() {
use crate::asr::whisper::AsrRequestFormat;
// issue #582:OpenRouter 走 whisper 兼容路由,但请求体是 JSON+base64。
assert!(is_whisper_compatible_provider("openrouter"));
assert_eq!(
whisper_request_format("openrouter"),
AsrRequestFormat::OpenRouterJson
);
// 其余兼容厂商保持 multipart。
assert_eq!(
whisper_request_format("whisper"),
AsrRequestFormat::Multipart
);
assert_eq!(whisper_request_format("groq"), AsrRequestFormat::Multipart);
// OpenRouter 的 JSON 协议不吃 response_format,verbose_json 保持关闭。
assert!(!whisper_supports_verbose_json("openrouter"));
// base64 膨胀,长录音保守按 30s 切分。
assert_eq!(batch_asr_chunk_limit_ms("openrouter"), Some(30_000));
}
#[test]
fn stepfun_is_whisper_compatible_with_hotwords_vocab() {
use crate::asr::whisper::AsrRequestFormat;
// StepFun /audio/transcriptions 是标准 multipart(实测 2026-07),
// response_format 只认 json/text → verbose_json 关闭;100MB 上限
// (约 54 分钟 16k WAV)→ 无需切分。
assert!(is_whisper_compatible_provider("stepfun"));
assert_eq!(
active_asr_provider_kind("stepfun"),
ActiveAsrProviderKind::WhisperCompatible
);
assert_eq!(
whisper_request_format("stepfun"),
AsrRequestFormat::Multipart
);
assert!(!whisper_supports_verbose_json("stepfun"));
assert_eq!(batch_asr_chunk_limit_ms("stepfun"), None);
// 一入口双协议:`*-stream` 模型路由到实时 WS 客户端,其余留在批式。
assert_eq!(
resolve_effective_asr_provider("stepfun", "stepaudio-2.5-asr").unwrap(),
"stepfun"
);
assert_eq!(
resolve_effective_asr_provider("stepfun", "").unwrap(),
"stepfun"
);
assert_eq!(
resolve_effective_asr_provider("stepfun", "stepaudio-2.5-asr-stream").unwrap(),
crate::asr::stepfun_realtime::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider("stepfun", "step-asr-1.1-stream").unwrap(),
crate::asr::stepfun_realtime::PROVIDER_ID
);
assert_eq!(
active_asr_provider_kind(crate::asr::stepfun_realtime::PROVIDER_ID),
ActiveAsrProviderKind::StepfunRealtime
);
// 词典路由:StepFun 批式忽略 prompt,走一等 hotwords;其余厂商维持 prompt。
assert!(whisper_uses_hotwords("stepfun"));
assert!(!whisper_uses_hotwords("whisper"));
let phrases = vec!["阶跃星辰".to_string()];
let (prompt, hotwords) = whisper_vocab_for_provider("stepfun", phrases.clone());
assert_eq!(prompt, None);
assert_eq!(hotwords, phrases);
let (prompt, hotwords) = whisper_vocab_for_provider("groq", phrases);
assert_eq!(prompt.as_deref(), Some("阶跃星辰."));
assert!(hotwords.is_empty());
}
#[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(crate::asr::qwen_realtime::PROVIDER_ID),
ActiveAsrProviderKind::Qwen3Realtime
);
assert_eq!(
active_asr_provider_kind("whisper"),
ActiveAsrProviderKind::WhisperCompatible
);
assert_eq!(
active_asr_provider_kind(crate::asr::mimo::PROVIDER_ID),
ActiveAsrProviderKind::Mimo
);
assert_eq!(
active_asr_provider_kind(crate::asr::dashscope_multimodal::PROVIDER_ID),
ActiveAsrProviderKind::DashScopeMultimodal
);
assert_eq!(
active_asr_provider_kind(crate::asr::elevenlabs::PROVIDER_ID),
ActiveAsrProviderKind::ElevenLabs
);
assert_eq!(
active_asr_provider_kind("volcengine"),
ActiveAsrProviderKind::Volcengine
);
// 未知 id 落到 Volcengine(与构建/凭据分发的兜底一致)。
assert_eq!(
active_asr_provider_kind("some-unknown-provider"),
ActiveAsrProviderKind::Volcengine
);
}
// 锁定分类枚举派生的凭据语义:重构把 ensure_asr_credentials /
// asr_configured_for_provider 从「字符串白名单 + 静默 else」改成对这两个方法的
// 穷尽 match,这里逐 kind 钉死映射,防止未来悄悄改动某个 provider 的凭据形态。
#[test]
fn preflight_credential_maps_every_kind() {
use AsrPreflightCredential::*;
use ActiveAsrProviderKind::*;
assert_eq!(Bailian.preflight_credential(), AsrApiKey);
assert_eq!(Qwen3Realtime.preflight_credential(), AsrApiKey);
assert_eq!(Mimo.preflight_credential(), AsrApiKey);
assert_eq!(DashScopeMultimodal.preflight_credential(), AsrApiKey);
assert_eq!(ElevenLabs.preflight_credential(), AsrApiKey);
assert_eq!(WhisperCompatible.preflight_credential(), AsrApiKey);
assert_eq!(Volcengine.preflight_credential(), VolcAppKey);
}
#[test]
fn resolve_effective_asr_provider_routes_bailian_by_model() {
let bailian = crate::asr::bailian::PROVIDER_ID;
// 统一百炼:按模型名路由到底层协议 id。
assert_eq!(
resolve_effective_asr_provider(bailian, "fun-asr-realtime").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "qwen3-asr-flash-realtime").unwrap(),
crate::asr::qwen_realtime::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "qwen3-asr-flash-realtime-2026-02-10")
.unwrap(),
crate::asr::qwen_realtime::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "fun-asr-flash-2026-06-15").unwrap(),
crate::asr::dashscope_multimodal::PROVIDER_ID
);
for model in [
"fun-asr-flash-2026-09-01",
"qwen3-asr-flash",
"qwen3-asr-flash-2026-02-10",
"fun-asr",
"fun-asr-mtl-2025-08-25",
"paraformer-v2",
] {
assert_eq!(
resolve_effective_asr_provider(bailian, model).unwrap(),
crate::asr::dashscope_multimodal::PROVIDER_ID,
"unexpected route for {model}"
);
}
assert_eq!(
resolve_effective_asr_provider(bailian, "fun-asr-flash-8k-realtime").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "qwen-audio-3.0-asr-flash").unwrap(),
crate::asr::dashscope_multimodal::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "paraformer-realtime-v2").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
// 8k 实时变体同样走经典 WebSocket(降采样 + sample_rate=8000),
// 与 bailian.rs::model_is_8k 的命名空间保持一致。
assert_eq!(
resolve_effective_asr_provider(bailian, "paraformer-8k-realtime-v2").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "sensevoice-8k-realtime-v1").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
// 空模型 → 经典实时(百炼默认);未知模型应被拒绝。
assert_eq!(
resolve_effective_asr_provider(bailian, "").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
// 非百炼 provider 原样返回(隐藏别名与其它厂商各走各的旧路径)。
assert_eq!(
resolve_effective_asr_provider(crate::asr::qwen_realtime::PROVIDER_ID, "anything")
.unwrap(),
crate::asr::qwen_realtime::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider("whisper", "whisper-1").unwrap(),
"whisper"
);
}
#[test]
fn resolve_effective_asr_provider_rejects_unsupported_bailian_model() {
let error = resolve_effective_asr_provider(crate::asr::bailian::PROVIDER_ID, "unknown-asr")
.unwrap_err();
assert!(error.contains("不支持的百炼 ASR 模型"));
// qwen3-asr-flash-filetrans 仅接受公网 URL,与本地录音链路不兼容,同样拒绝。
let error =
resolve_effective_asr_provider(crate::asr::bailian::PROVIDER_ID, "qwen3-asr-flash-filetrans")
.unwrap_err();
assert!(error.contains("不支持的百炼 ASR 模型"));
}
#[test]
fn validates_only_supported_dashscope_multimodal_models() {
assert!(validate_dashscope_multimodal_model("").is_ok());
assert!(
validate_dashscope_multimodal_model("fun-asr-flash-2026-06-15").is_ok()
);
assert!(validate_dashscope_multimodal_model("qwen-audio-3.0-asr-flash").is_ok());
assert!(
validate_dashscope_multimodal_model("qwen-audio-3.0-asr-flash-streaming").is_err()
);
}
#[test]
fn derive_bailian_endpoint_preserves_region_host_and_selects_protocol_path() {
let endpoint = "https://workspace.ap-southeast-1.maas.aliyuncs.com/custom?x=1";
assert_eq!(
derive_bailian_endpoint(endpoint, BailianEndpointProtocol::ClassicRealtime).unwrap(),
"wss://workspace.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference/"
);
assert_eq!(
derive_bailian_endpoint(endpoint, BailianEndpointProtocol::QwenRealtime).unwrap(),
"wss://workspace.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/realtime"
);
assert_eq!(
derive_bailian_endpoint(endpoint, BailianEndpointProtocol::Multimodal).unwrap(),
"https://workspace.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
);
assert_eq!(
derive_bailian_endpoint(endpoint, BailianEndpointProtocol::AsyncTranscription)
.unwrap(),
"https://workspace.ap-southeast-1.maas.aliyuncs.com/api/v1/services/audio/asr/transcription"
);
}
#[test]
fn derive_bailian_endpoint_uses_protocol_default_for_empty_value() {
assert_eq!(
derive_bailian_endpoint("", BailianEndpointProtocol::QwenRealtime).unwrap(),
crate::asr::qwen_realtime::DEFAULT_ENDPOINT
);
}
#[test]
fn configured_fields_maps_every_kind() {
use AsrConfiguredFields::*;
use ActiveAsrProviderKind::*;
assert_eq!(Bailian.configured_fields(), ApiKeyOnly);
assert_eq!(Qwen3Realtime.configured_fields(), ApiKeyOnly);
assert_eq!(Mimo.configured_fields(), ApiKeyEndpointModel);
assert_eq!(DashScopeMultimodal.configured_fields(), ApiKeyEndpointModel);
assert_eq!(ElevenLabs.configured_fields(), ApiKeyOnly);
assert_eq!(WhisperCompatible.configured_fields(), EndpointModelOnly);
assert_eq!(Volcengine.configured_fields(), VolcAppKey);
}
#[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));
}
#[test]
fn windows_local_asr_timeout_floors_at_global_timeout_for_short_audio() {
assert_eq!(
windows_local_asr_transcribe_timeout(5.0),
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[test]
fn windows_local_asr_timeout_scales_with_audio_duration() {
// 65s 录音:65 × 1.0 = 65,+20 = 85s。长音频不再撞 30s 墙。
assert_eq!(
windows_local_asr_transcribe_timeout(65.0),
std::time::Duration::from_secs(85)
);
}
#[test]
fn local_qwen_timeout_floors_at_global_timeout_for_short_audio() {
// 5s 录音:5 × 0.6 = 3, +10 = 13, max(30) = 30。短录音兜底。
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(30) = 30。
// COORDINATOR_GLOBAL_TIMEOUT_SECS 提升到 30 后,短音频统一被兜底值覆盖。
assert_eq!(
local_qwen_transcribe_timeout(10.1),
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[test]
fn local_qwen_timeout_handles_zero_duration() {
// 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(30) = 30。
assert_eq!(
local_qwen_transcribe_timeout(0.0),
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[test]
fn whisper_timeout_floors_at_global_timeout_for_short_audio() {
// 10s 录音:10 × 0.5 = 5, +20 = 25, max(30) = 30。短音频兜底。
assert_eq!(
whisper_transcribe_timeout(10.0),
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[test]
fn whisper_timeout_scales_with_audio_duration() {
// 60s 录音:60 × 0.5 = 30, +20 = 50。覆盖多分片 HTTP 请求。
assert_eq!(
whisper_transcribe_timeout(60.0),
std::time::Duration::from_secs(50)
);
}
#[test]
fn whisper_timeout_ceils_partial_seconds() {
// 45.3s 录音:45.3 × 0.5 = 22.65, ceil = 23, +20 = 43, max(30) = 43。
assert_eq!(
whisper_transcribe_timeout(45.3),
std::time::Duration::from_secs(43)
);
}
#[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);
}
#[tokio::test]
async fn stale_capsule_idle_schedule_does_not_hide_newer_state() {
let coordinator = Coordinator::new();
// 旧 schedule 触发时若期间有更新的 emit,应跳过隐藏(voice agent 取消双 emit 竞争)。
emit_capsule(&coordinator.inner, CapsuleState::Done, 0.0, 0, None, None);
schedule_capsule_idle(&coordinator.inner, 30);
emit_capsule(&coordinator.inner, CapsuleState::Cancelled, 0.0, 0, None, None);
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
assert_eq!(
coordinator.inner.last_capsule_state.lock().as_ref().copied(),
Some(CapsuleState::Cancelled),
"旧 schedule 不应把更新的 Cancelled 状态提前隐藏"
);
}
#[tokio::test]
async fn capsule_idle_schedule_hides_when_no_newer_state() {
let coordinator = Coordinator::new();
emit_capsule(&coordinator.inner, CapsuleState::Done, 0.0, 0, None, None);
schedule_capsule_idle(&coordinator.inner, 30);
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
assert_eq!(
coordinator.inner.last_capsule_state.lock().as_ref().copied(),
Some(CapsuleState::Idle),
"无新 emit 时 schedule 应隐藏胶囊"
);
}
#[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, std::time::Instant::now(), 1).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, std::time::Instant::now(), 1).await;
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
fn set_auto_mode(coordinator: &Coordinator) {
coordinator
.inner
.prefs
.set(crate::types::UserPreferences {
hotkey: crate::types::HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Auto,
keys: None,
},
..Default::default()
})
.unwrap();
}
// Auto 模式短按:松手时按住时长 < 阈值 → 锁存为切换态,保持 Listening(不结束会话)。
#[tokio::test]
async fn auto_short_tap_release_latches_recording() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
// 刚按下(elapsed ≈ 0 < 350ms)→ 短按。
let pressed_at = std::time::Instant::now();
*coordinator.inner.hotkey_press_at.lock() = Some(pressed_at);
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
handle_released_edge(&coordinator.inner, pressed_at + std::time::Duration::from_millis(100)).await;
// 短按松手不结束录音,等下一次按下再停。
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
}
#[tokio::test]
async fn auto_short_tap_stays_latched_when_bridge_handles_release_late() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
let pressed_at = std::time::Instant::now();
*coordinator.inner.hotkey_press_at.lock() = Some(pressed_at);
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
// 模拟上一条会话阻塞 bridge:处理发生在物理松手很久之后。
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
handle_released_edge(
&coordinator.inner,
pressed_at + std::time::Duration::from_millis(100),
)
.await;
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Listening);
assert!(coordinator.inner.hotkey_press_at.lock().is_none());
}
// Auto 模式长按:松手时按住时长 >= 阈值 → 按住说话语义,结束会话(Listening → Idle)。
#[tokio::test]
async fn auto_long_hold_release_ends_session() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
// 按住已超过阈值 → 长按。
let pressed_at = std::time::Instant::now();
*coordinator.inner.hotkey_press_at.lock() = Some(pressed_at);
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
handle_released_edge(&coordinator.inner, pressed_at + std::time::Duration::from_millis(500)).await;
// 无 recorder / ASR 的测试会话下,end_session 直接收尾到 Idle。
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
assert!(coordinator.inner.hotkey_press_at.lock().is_none());
}
// Option+任意字母/数字键:这次按下开出来的会话必须被撤销,且随后的松手边沿不能再被当成
// Auto 短按锁存(否则录音一直开着,正是用户报的「按 Option+其他键唤起听写」)。
#[tokio::test]
async fn trigger_combined_cancels_session_started_by_this_press() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
let pressed_at = std::time::Instant::now();
*coordinator.inner.hotkey_press_at.lock() = Some(pressed_at);
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_generation
.store(1, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(1, Ordering::SeqCst);
handle_trigger_combined(&coordinator.inner, 1);
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
assert!(coordinator.inner.hotkey_press_at.lock().is_none());
// 组合键误触不算「刚用完一次听写」:不留冷却,否则紧接着真想说话的按下被吞。
assert!(coordinator.inner.session_cooldown_until.lock().is_none());
handle_released_edge(
&coordinator.inner,
pressed_at + std::time::Duration::from_millis(80),
)
.await;
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
}
// 这次按下是 toggle 停止(没开出会话)时,组合键撤销不能顺手取消正在跑的会话 ——
// 那条录音是上一次按下锁存的,取消 = 用户白说一段。
#[tokio::test]
async fn trigger_combined_leaves_session_it_did_not_start() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_generation
.store(1, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(0, Ordering::SeqCst);
handle_trigger_combined(&coordinator.inner, 1);
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
// 组合键撤销通道独立于 Released;若正常松手已经把会话收尾到 Idle,迟到的撤销
// 不能清掉正常会话的冷却/防抖,否则下一次三连按会绕过 #545 的保护。
#[tokio::test]
async fn late_trigger_combined_does_not_clear_completed_session_guards() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
let now = std::time::Instant::now();
*coordinator.inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_secs(1));
*coordinator.inner.last_hotkey_dispatch_at.lock() = Some(now);
coordinator
.inner
.hotkey_press_generation
.store(1, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(1, Ordering::SeqCst);
handle_trigger_combined(&coordinator.inner, 1);
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
assert!(coordinator.inner.session_cooldown_until.lock().is_some());
assert!(coordinator.inner.last_hotkey_dispatch_at.lock().is_some());
}
// 撤销走独立线程后,它与 Pressed/Released 那条串行 bridge 之间没有先后保证。
// 万一 Released 抢先跑完(把按住态清了、Auto 还锁存成了切换态),撤销仍然必须认出
// 这条会话是自己那次按下开的并取消掉 —— 否则组合键会留下一条停不下来的录音,
// 正是本 PR 要修的老毛病换个形式复发。
#[tokio::test]
async fn trigger_combined_still_cancels_when_released_edge_wins_the_race() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
let pressed_at = std::time::Instant::now();
*coordinator.inner.hotkey_press_at.lock() = Some(pressed_at);
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_generation
.store(1, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(1, Ordering::SeqCst);
// 先跑 Released(短按 → Auto 锁存成切换态,录音继续),撤销后到。
handle_released_edge(
&coordinator.inner,
pressed_at + std::time::Duration::from_millis(80),
)
.await;
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
handle_trigger_combined(&coordinator.inner, 1);
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
assert!(coordinator.inner.session_cooldown_until.lock().is_none());
}
#[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,
crate::types::WindowsInsertionMode::Tsf,
),
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,
crate::types::WindowsInsertionMode::Tsf,
),
Some("windowsImeTsfRequired")
);
}
#[test]
fn sendinput_only_mode_skips_tsf_required_error() {
assert_eq!(
dictation_error_code(
InsertStatus::Failed,
false,
true,
false,
crate::types::WindowsInsertionMode::SendInput,
),
None
);
}
#[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());
}
#[test]
fn selection_polish_capsule_epoch_rejects_stale_auto_hide() {
let coordinator = Coordinator::new();
let terminal_epoch =
emit_selection_polish_capsule(&coordinator.inner, CapsuleState::Done, "已替换");
assert!(selection_polish_capsule_epoch_is_current(
&coordinator.inner,
terminal_epoch
));
let next_epoch = emit_selection_polish_capsule(
&coordinator.inner,
CapsuleState::Polishing,
"正在润色...",
);
assert_ne!(terminal_epoch, next_epoch);
assert!(
!selection_polish_capsule_epoch_is_current(&coordinator.inner, terminal_epoch),
"上一轮的终态 timer 不能收起下一轮处理中提示"
);
assert!(selection_polish_capsule_epoch_is_current(
&coordinator.inner,
next_epoch
));
emit_capsule(
&coordinator.inner,
CapsuleState::Recording,
0.0,
0,
None,
None,
);
assert!(
!selection_polish_capsule_epoch_is_current(&coordinator.inner, next_epoch),
"选区终态 timer 不能在新的语音状态上调用 Idle"
);
}
}
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 / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。
/// 点 ✓ / 中途出错走这里,保留 2 秒让用户看清结果 / 错误提示。
const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000;
/// 用户主动取消(Esc / 点 ✕)时的收起延迟。取消是明确的「我不要了」意图,
/// 不需要像 Done/Error 那样停留 2 秒给用户读——立刻回 Idle,由前端 capsule-out
/// 淡出动画(520ms)负责优雅收尾,观感上「按下即消失」(对齐 Typeless)。
const CAPSULE_CANCEL_HIDE_DELAY_MS: u64 = 0;
/// 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() 永远挂起。
/// 设置为 30 秒,为云端 batch ASR(OpenRouter Whisper 等)提供足够的
/// 网络超时预算;只在 ASR 自身超时机制失效时作为最后的防线触发。
const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 30;
/// Windows 本地 batch ASR 的动态转写超时。Foundry 与 sherpa-onnx 当前使用
/// 同一预算:短音频至少 30s,长音频按整段时长向上取整后增加 20s 余量。
fn windows_local_asr_transcribe_timeout(audio_secs: f64) -> std::time::Duration {
let secs = (audio_secs.ceil() as u64)
.saturating_add(20)
.max(COORDINATOR_GLOBAL_TIMEOUT_SECS);
std::time::Duration::from_secs(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)
}
/// Whisper / OpenRouter 云端 batch ASR 的动态转写超时。OpenRouter 按 30s
/// 分片,每片是一次 HTTP round-trip;网络抖动、排队、base64 body 都会
/// 拉长耗时。公式 max(30, ceil(audio_s × 0.5) + 20):30s 是全局兜底;
/// 长录音按音频长度的 0.5 倍 + 20s 余量,覆盖多分片串行请求 + 网络波动。
fn whisper_transcribe_timeout(audio_secs: f64) -> std::time::Duration {
let secs = ((audio_secs * 0.5).ceil() as u64)
.saturating_add(20)
.max(COORDINATOR_GLOBAL_TIMEOUT_SECS);
std::time::Duration::from_secs(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) {
// 记录触发时胶囊显示的状态;到点时若期间有更新的 emit(last_capsule_state 已变),
// 说明本次状态已被后续 emit 取代,隐藏交给那次 emit 自己的 schedule——避免旧
// schedule 把新状态提前隐藏(如 voice agent 取消路径 cancel_session 与收尾双 emit)。
let expect = inner.last_capsule_state.lock().as_ref().copied();
let inner_clone = Arc::clone(inner);
async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
if inner_clone.last_capsule_state.lock().as_ref().copied() != expect {
return;
}
// 必须 dictation **和** QA 同时空闲才能隐藏胶囊。否则旧 dictation Done timer
// 的尾巴会在新 QA 录音/思考中把胶囊意外收掉(issue #118 v2 复现)。
// 选区润色进行中或出现新 payload 时,函数内部依据 capsule epoch 放弃隐藏。
hide_capsule_if_all_sessions_idle(&inner_clone);
});
}
/// 选区润色终态的短暂展示。旧的 timer 只能收起自己那一代的 payload;若用户已经
/// 触发了下一轮 selection,或在此期间开始语音/QA,会直接放弃,不碰当前 capsule。
#[cfg(not(mobile))]
fn schedule_selection_polish_capsule_idle(inner: &Arc<Inner>, event_epoch: u64, 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;
hide_selection_polish_capsule_if_current(&inner_clone, event_epoch);
});
}
// ─────────────────────────── 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);
}
}
}