refactor(dictation): 抽出 dictation_end 子模块(end_session 并发敏感整体搬,不动锁/await)
This commit is contained in:
1 parent
f6775b89c8
commit
ed48cf40fc
3 files changed
+788
-781
No files matched your search
@@ -64,6 +64,7 @@ use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionCon
|
||||
mod asr_setup;
|
||||
mod capsule;
|
||||
mod dictation;
|
||||
mod dictation_end;
|
||||
mod dictation_session;
|
||||
mod dictation_streaming;
|
||||
mod dictation_voice_agent;
|
||||
@@ -79,6 +80,7 @@ mod voice_agent_hotkeys;
|
||||
// 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。
|
||||
pub(crate) use asr_setup::*;
|
||||
pub(crate) use capsule::*;
|
||||
pub(crate) use dictation_end::*;
|
||||
pub(crate) use dictation_session::*;
|
||||
pub(crate) use dictation_streaming::*;
|
||||
pub(crate) use dictation_voice_agent::*;
|
||||
@@ -90,7 +92,7 @@ pub(crate) use voice_agent_hotkeys::*;
|
||||
|
||||
#[cfg(test)]
|
||||
use dictation_session::dictation_error_code;
|
||||
use dictation::{end_session, handle_pressed_edge, handle_released_edge};
|
||||
use dictation::{handle_pressed_edge, handle_released_edge};
|
||||
use dictation_session::{begin_session, cancel_session, request_stop_during_starting};
|
||||
#[cfg(any(debug_assertions, test))]
|
||||
use dictation::{handle_pressed, handle_released};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::correction::apply_correction_rules;
|
||||
use crate::types::HotkeyMode;
|
||||
|
||||
use super::qa::handle_qa_option_edge;
|
||||
use super::resources::*;
|
||||
use super::*;
|
||||
|
||||
/// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 ——
|
||||
@@ -126,784 +124,6 @@ pub(super) async fn handle_released(inner: &Arc<Inner>) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
let current_session_id = {
|
||||
let mut state = inner.state.lock();
|
||||
let Some(session_id) = start_processing_if_listening(&mut state) else {
|
||||
return Ok(());
|
||||
};
|
||||
session_id
|
||||
};
|
||||
|
||||
let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64;
|
||||
emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None);
|
||||
|
||||
if let Some(rec) = take_recorder_for_session(inner, current_session_id) {
|
||||
rec.stop();
|
||||
release_recording_mute(inner, "dictation");
|
||||
}
|
||||
|
||||
let asr_opt = take_asr_for_session(inner, current_session_id);
|
||||
let asr = match asr_opt {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr);
|
||||
let raw = match asr {
|
||||
ActiveAsr::Volcengine(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] send last frame failed: {e}");
|
||||
}
|
||||
// 添加全局超时保护:防止 await_final_result() 永远挂起
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] await final failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
// 全局超时:最后的防线
|
||||
log::error!(
|
||||
"[coord] 全局超时 {} 秒 - 强制恢复",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
// 清理 ASR session,避免资源泄漏
|
||||
asr.cancel();
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Whisper(w) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
// Whisper 也添加类似的超时保护
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, w.transcribe()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] whisper transcribe failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] whisper 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("whisper global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Mimo(m) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, m.transcribe()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] MiMo ASR transcribe failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] MiMo ASR 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("mimo global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Bailian(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] Bailian send last frame failed: {e}");
|
||||
}
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] Bailian await final failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] Bailian 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
asr.cancel();
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("bailian global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::FoundryLocalWhisper(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
match local
|
||||
.transcribe(foundry_audio_transcribe_timeout_duration())
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
schedule_foundry_local_asr_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
if inner.state.lock().cancelled {
|
||||
log::info!(
|
||||
"[coord] Foundry Local Whisper transcribe cancelled — discarding transcript"
|
||||
);
|
||||
schedule_foundry_local_asr_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
set_phase_idle_if_session_matches(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}");
|
||||
schedule_foundry_local_asr_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("本地识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Windows sherpa-onnx offline batch:停止录音后整段转写,再复用现有
|
||||
// polish / insert / history 收尾路径。
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::SherpaOnnxLocal(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
match local
|
||||
.transcribe(sherpa_audio_transcribe_timeout_duration())
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
schedule_sherpa_onnx_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
if inner.state.lock().cancelled {
|
||||
log::info!(
|
||||
"[coord] sherpa-onnx transcribe cancelled — discarding transcript"
|
||||
);
|
||||
schedule_sherpa_onnx_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
set_phase_idle_if_session_matches(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("[coord] sherpa-onnx transcribe failed: {e:#}");
|
||||
schedule_sherpa_onnx_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("本地识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
ActiveAsr::Local(local) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
// 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3
|
||||
// 提前完成。但 transcribe 本身受音频长度影响:用户实测 RTF ≈ 0.3,慢机
|
||||
// 可达 0.5;15s 固定超时在 ≥ 30s 录音上会把整段结果丢掉。改用动态
|
||||
// 超时 max(15, ceil(audio_s × 0.6) + 10),公式与单测见
|
||||
// `local_qwen_transcribe_timeout`。
|
||||
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
||||
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
|
||||
log::info!(
|
||||
"[coord] local Qwen3-ASR transcribe: audio={:.2}s timeout={}s",
|
||||
audio_secs,
|
||||
timeout_duration.as_secs()
|
||||
);
|
||||
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
|
||||
inner.local_asr_cache.touch();
|
||||
schedule_local_asr_release(inner);
|
||||
match result {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("本地识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)",
|
||||
timeout_duration.as_secs(),
|
||||
audio_secs
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("local global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
|
||||
// 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。
|
||||
if inner.state.lock().cancelled {
|
||||
log::info!("[coord] cancel detected after ASR — discarding transcript");
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
// PR #387 的「cancel 后清 focus_target」契约要在 Processing 路径上也成立。
|
||||
// cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让
|
||||
// 这里收尾),但此前的 end_session 没把 focus_target 清掉。logic-review
|
||||
// 2026-05-10 P3 (🚩) 把这条补完。
|
||||
{
|
||||
let mut state = inner.state.lock();
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ASR 返回空转写护栏(来自 PR #66):写一条 emptyTranscript 失败历史 + 错误胶囊,
|
||||
// 与 main 上其它 error 路径保持一致(带 schedule_capsule_idle 让胶囊自动消失)。
|
||||
let mut raw = raw;
|
||||
|
||||
#[cfg(any(debug_assertions, test))]
|
||||
if raw.text.trim().is_empty() {
|
||||
if let Some(debug_text) = debug_transcript_override_text() {
|
||||
log::info!(
|
||||
"[coord] using debug transcript override (chars={})",
|
||||
debug_text.chars().count()
|
||||
);
|
||||
raw.text = debug_text;
|
||||
}
|
||||
}
|
||||
|
||||
if raw.text.trim().is_empty() {
|
||||
let session = DictationSession {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
raw_transcript: raw.text.clone(),
|
||||
final_text: String::new(),
|
||||
mode: inner.prefs.get().default_mode,
|
||||
style_pack_id: None,
|
||||
translation_active: false,
|
||||
polish_source: None,
|
||||
app_bundle_id: None,
|
||||
app_name: None,
|
||||
insert_status: InsertStatus::Failed,
|
||||
error_code: Some("emptyTranscript".to_string()),
|
||||
duration_ms: Some(raw.duration_ms),
|
||||
dictionary_entry_count: Some(enabled_phrases(inner).len() as u32),
|
||||
// empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想
|
||||
// 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent
|
||||
// "Missing Audio" 反馈。
|
||||
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
|
||||
};
|
||||
let prefs_snapshot = inner.prefs.get();
|
||||
if let Err(e) = inner.history.append_with_retention(
|
||||
session,
|
||||
prefs_snapshot.history_retention_days,
|
||||
prefs_snapshot.history_max_entries,
|
||||
) {
|
||||
log::error!("[coord] history append failed: {e}");
|
||||
}
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("没有识别到语音".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("ASR returned empty transcript".to_string());
|
||||
}
|
||||
|
||||
let correction_rules = match inner.correction_rules.list() {
|
||||
Ok(rules) => rules,
|
||||
Err(e) => {
|
||||
log::warn!("[coord] load correction rules failed: {e}; continue without correction");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let front_app = inner.state.lock().front_app.clone();
|
||||
if !correction_rules.is_empty() {
|
||||
let corrected = apply_correction_rules(&raw.text, &correction_rules);
|
||||
if corrected != raw.text {
|
||||
log::info!(
|
||||
"[coord] correction rules adjusted raw transcript ({} → {} chars)",
|
||||
raw.text.chars().count(),
|
||||
corrected.chars().count()
|
||||
);
|
||||
raw.text = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。
|
||||
if inner.state.lock().voice_agent {
|
||||
return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed)
|
||||
.await;
|
||||
}
|
||||
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
|
||||
|
||||
let prefs = inner.prefs.get();
|
||||
let pack = match inner
|
||||
.style_packs
|
||||
.get_or_default_active(&prefs.active_style_pack_id)
|
||||
{
|
||||
Ok(pack) => pack,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"[coord] active style pack unavailable, falling back to builtin light: {error}"
|
||||
);
|
||||
crate::types::builtin_style_pack_for_mode(PolishMode::Light)
|
||||
}
|
||||
};
|
||||
let mode = pack.base_mode;
|
||||
let hotword_strs = enabled_phrases(inner);
|
||||
let working_languages = prefs.working_languages.clone();
|
||||
let chinese_script_preference = prefs.chinese_script_preference;
|
||||
let output_language_preference = prefs.output_language_preference;
|
||||
let llm_thinking_enabled = prefs.llm_thinking_enabled;
|
||||
let style_system_prompt = pack.prompt.clone();
|
||||
let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack);
|
||||
let translation_target = prefs.translation_target_language.trim().to_string();
|
||||
let translation_active =
|
||||
inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty();
|
||||
log::info!(
|
||||
"[style-pack] runtime dispatch session_id={} active_pack={} kind={:?} mode={:?} raw_chars={} prompt_chars={} raw_uses_llm={} translation_active={} hotwords={} working_languages={:?}",
|
||||
current_session_id,
|
||||
pack.id,
|
||||
pack.kind,
|
||||
mode,
|
||||
raw.text.chars().count(),
|
||||
style_system_prompt.chars().count(),
|
||||
raw_uses_llm,
|
||||
translation_active,
|
||||
hotword_strs.len(),
|
||||
working_languages
|
||||
);
|
||||
// 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。翻译现在也走"润色+翻译"单次
|
||||
// LLM 调用,所以翻译路径同样需要上下文;只有 Raw 且不走 LLM 才没意义。窗口=0 时为空 Vec。
|
||||
// 只复用同一 active style pack 的历史;翻译历史按当前是否翻译决定喂译文还是润色后源文
|
||||
// (见 eligible_polish_context_turns)。
|
||||
let polish_context_window_minutes = prefs.polish_context_window_minutes;
|
||||
let prior_turns: Vec<(String, String)> = if (translation_active
|
||||
|| mode != PolishMode::Raw
|
||||
|| raw_uses_llm)
|
||||
&& polish_context_window_minutes > 0
|
||||
{
|
||||
match inner
|
||||
.history
|
||||
.recent_within_minutes(polish_context_window_minutes)
|
||||
{
|
||||
Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id, translation_active),
|
||||
Err(e) => {
|
||||
log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。
|
||||
// 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。
|
||||
let streaming_eligible = streaming_insert_eligible(
|
||||
prefs.streaming_insert,
|
||||
translation_active,
|
||||
mode,
|
||||
raw_uses_llm,
|
||||
);
|
||||
log::info!(
|
||||
"[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}"
|
||||
);
|
||||
|
||||
// Linux: emit_capsule(Polishing) 已通过 fcitx5 auxDown 显示 "✨ 润色中...",
|
||||
// 无需在此重复调用。
|
||||
|
||||
// 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值,
|
||||
// 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。
|
||||
let mut polish_source: Option<String> = None;
|
||||
let (polished, polish_error, already_streamed) = if translation_active {
|
||||
log::info!(
|
||||
"[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}",
|
||||
translation_target,
|
||||
working_languages,
|
||||
front_app
|
||||
);
|
||||
let (p, src, e) = polish_and_translate_or_passthrough(
|
||||
&raw,
|
||||
&translation_target,
|
||||
mode,
|
||||
&hotword_strs,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&prior_turns,
|
||||
)
|
||||
.await;
|
||||
polish_source = src;
|
||||
(p, e, false)
|
||||
} else if streaming_eligible {
|
||||
run_streaming_polish(
|
||||
inner,
|
||||
&raw,
|
||||
mode,
|
||||
&hotword_strs,
|
||||
&style_system_prompt,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&prior_turns,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
let (p, e) = polish_or_passthrough(
|
||||
&raw,
|
||||
mode,
|
||||
&hotword_strs,
|
||||
&style_system_prompt,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&prior_turns,
|
||||
)
|
||||
.await;
|
||||
(p, e, false)
|
||||
};
|
||||
|
||||
let polished = finalize_polished_text(
|
||||
polished,
|
||||
translation_active,
|
||||
raw_uses_llm,
|
||||
mode,
|
||||
&polish_error,
|
||||
chinese_script_preference,
|
||||
&correction_rules,
|
||||
already_streamed,
|
||||
);
|
||||
// 原子化最后一次 cancel 检查 + 转 Inserting:
|
||||
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
|
||||
// cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复,
|
||||
// 之前 check 与 inserter.insert 之间有窗口期。
|
||||
//
|
||||
// 流式路径例外:`already_streamed = true` 表示字符已经一边流一边落到光标了,
|
||||
// 撤销不掉。即使 cancel 旗在中途被立起来,也只能尊重「已经发生」的事实,进入
|
||||
// Inserting 状态完成 history / vocab 等收尾工作。
|
||||
let proceed_to_insert = {
|
||||
let mut state = inner.state.lock();
|
||||
if state.cancelled && !already_streamed {
|
||||
state.phase = SessionPhase::Idle;
|
||||
false
|
||||
} else {
|
||||
state.phase = SessionPhase::Inserting;
|
||||
true
|
||||
}
|
||||
};
|
||||
if !proceed_to_insert {
|
||||
log::info!(
|
||||
"[coord] cancel detected before insert — discarding output (chars={})",
|
||||
polished.chars().count()
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let focus_target = inner.state.lock().focus_target;
|
||||
let focus_ready_for_paste = restore_focus_target_if_possible(focus_target);
|
||||
let prefs = inner.prefs.get();
|
||||
let restore_clipboard = prefs.restore_clipboard_after_paste;
|
||||
let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback;
|
||||
let paste_shortcut = prefs.paste_shortcut;
|
||||
// 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。
|
||||
let status = if already_streamed {
|
||||
log::info!(
|
||||
"[coord] insertion skipped: {} chars already streamed via unicode_keystroke (polish_error={:?})",
|
||||
polished.chars().count(),
|
||||
polish_error
|
||||
);
|
||||
InsertStatus::Inserted
|
||||
} else if focus_ready_for_paste {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ime_target = capture_ime_submit_target();
|
||||
insert_with_windows_ime_first(
|
||||
inner,
|
||||
current_session_id,
|
||||
&polished,
|
||||
restore_clipboard,
|
||||
allow_non_tsf_insertion_fallback,
|
||||
paste_shortcut,
|
||||
ime_target,
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
inner
|
||||
.inserter
|
||||
.insert(&polished, restore_clipboard, paste_shortcut)
|
||||
}
|
||||
} else {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。
|
||||
inner
|
||||
.inserter
|
||||
.insert(&polished, restore_clipboard, paste_shortcut)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
log::warn!(
|
||||
"[coord] original insertion target is not foreground; copied output without paste"
|
||||
);
|
||||
if allow_non_tsf_insertion_fallback {
|
||||
inner.inserter.copy_fallback(&polished)
|
||||
} else {
|
||||
InsertStatus::Failed
|
||||
}
|
||||
}
|
||||
};
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
let inserted_chars = polished.chars().count() as u32;
|
||||
|
||||
// 累计每条 enabled 词条在最终文本中的命中次数。
|
||||
// 用 polished(最终插入的文本)扫描,与用户实际看到的输出一致。
|
||||
let total_hits: u64 = match inner.vocab.record_hits(&polished) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
log::error!("[coord] record_hits failed: {e}");
|
||||
0
|
||||
}
|
||||
};
|
||||
// 词汇本页面在打开时通常需要立即看到 hits 增长,否则用户得手动切走再切回来才刷新。
|
||||
// 命中数 > 0 时通知前端:Vocab 页面订阅 vocab:updated 即时 listVocab() 重新加载。
|
||||
if total_hits > 0 {
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let _ = app.emit("vocab:updated", total_hits);
|
||||
}
|
||||
}
|
||||
|
||||
// polish 失败时在 history 里标记 polishFailed,让用户能在历史详情看到为什么这次输出
|
||||
// 不是预期的 mode 风格。即使失败也不丢词 — final_text 仍是原文(保留"用户的话不丢"语义)。
|
||||
let error_code = dictation_error_code(
|
||||
status,
|
||||
polish_error.is_some(),
|
||||
focus_ready_for_paste,
|
||||
allow_non_tsf_insertion_fallback,
|
||||
)
|
||||
.map(str::to_string);
|
||||
let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired");
|
||||
|
||||
// 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `<session_id>.wav`
|
||||
// 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。
|
||||
let history_session_id = current_session_id.to_string();
|
||||
let history_created_at = Utc::now().to_rfc3339();
|
||||
let prefs_snapshot = inner.prefs.get();
|
||||
let session = DictationSession {
|
||||
id: history_session_id.clone(),
|
||||
created_at: history_created_at.clone(),
|
||||
raw_transcript: raw.text.clone(),
|
||||
final_text: polished.clone(),
|
||||
mode,
|
||||
style_pack_id: Some(pack.id.clone()),
|
||||
translation_active,
|
||||
polish_source,
|
||||
app_bundle_id: None,
|
||||
app_name: None,
|
||||
insert_status: status,
|
||||
error_code,
|
||||
duration_ms: Some(raw.duration_ms),
|
||||
// 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次),
|
||||
// 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。
|
||||
dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32),
|
||||
// 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关——
|
||||
// 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。
|
||||
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
|
||||
};
|
||||
if let Err(e) = inner.history.append_with_retention(
|
||||
session,
|
||||
prefs_snapshot.history_retention_days,
|
||||
prefs_snapshot.history_max_entries,
|
||||
) {
|
||||
log::error!("[coord] history append failed: {e}");
|
||||
}
|
||||
let done_message = if tsf_required_insert_failed {
|
||||
Some("TSF 未上屏,已禁止非 TSF 兜底".to_string())
|
||||
} else {
|
||||
default_done_message(status, polish_error.is_some())
|
||||
};
|
||||
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Done,
|
||||
0.0,
|
||||
elapsed,
|
||||
done_message,
|
||||
Some(inserted_chars),
|
||||
);
|
||||
|
||||
{
|
||||
let mut state = inner.state.lock();
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
// Toggle 模式冷却:设冷却时间戳,POST_SESSION_COOLDOWN_MS 内禁止新的 activate。
|
||||
// 覆盖胶囊离场动画周期,避免三连按第 3 次误激活(issue #545)。
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
*inner.session_cooldown_until.lock() =
|
||||
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
|
||||
}
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::batch_asr_chunk_limit_ms;
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::correction::apply_correction_rules;
|
||||
|
||||
use super::resources::*;
|
||||
use super::*;
|
||||
|
||||
pub(crate) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
let current_session_id = {
|
||||
let mut state = inner.state.lock();
|
||||
let Some(session_id) = start_processing_if_listening(&mut state) else {
|
||||
return Ok(());
|
||||
};
|
||||
session_id
|
||||
};
|
||||
|
||||
let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64;
|
||||
emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None);
|
||||
|
||||
if let Some(rec) = take_recorder_for_session(inner, current_session_id) {
|
||||
rec.stop();
|
||||
release_recording_mute(inner, "dictation");
|
||||
}
|
||||
|
||||
let asr_opt = take_asr_for_session(inner, current_session_id);
|
||||
let asr = match asr_opt {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr);
|
||||
let raw = match asr {
|
||||
ActiveAsr::Volcengine(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] send last frame failed: {e}");
|
||||
}
|
||||
// 添加全局超时保护:防止 await_final_result() 永远挂起
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] await final failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
// 全局超时:最后的防线
|
||||
log::error!(
|
||||
"[coord] 全局超时 {} 秒 - 强制恢复",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
// 清理 ASR session,避免资源泄漏
|
||||
asr.cancel();
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Whisper(w) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
// Whisper 也添加类似的超时保护
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, w.transcribe()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] whisper transcribe failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] whisper 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("whisper global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Mimo(m) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, m.transcribe()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] MiMo ASR transcribe failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] MiMo ASR 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("mimo global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Bailian(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] Bailian send last frame failed: {e}");
|
||||
}
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] Bailian await final failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] Bailian 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
asr.cancel();
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("bailian global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::FoundryLocalWhisper(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
match local
|
||||
.transcribe(foundry_audio_transcribe_timeout_duration())
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
schedule_foundry_local_asr_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
if inner.state.lock().cancelled {
|
||||
log::info!(
|
||||
"[coord] Foundry Local Whisper transcribe cancelled — discarding transcript"
|
||||
);
|
||||
schedule_foundry_local_asr_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
set_phase_idle_if_session_matches(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}");
|
||||
schedule_foundry_local_asr_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("本地识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Windows sherpa-onnx offline batch:停止录音后整段转写,再复用现有
|
||||
// polish / insert / history 收尾路径。
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::SherpaOnnxLocal(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
match local
|
||||
.transcribe(sherpa_audio_transcribe_timeout_duration())
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
schedule_sherpa_onnx_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
if inner.state.lock().cancelled {
|
||||
log::info!(
|
||||
"[coord] sherpa-onnx transcribe cancelled — discarding transcript"
|
||||
);
|
||||
schedule_sherpa_onnx_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
set_phase_idle_if_session_matches(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("[coord] sherpa-onnx transcribe failed: {e:#}");
|
||||
schedule_sherpa_onnx_release(
|
||||
inner,
|
||||
AsrReleaseSession::Dictation(current_session_id),
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("本地识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
ActiveAsr::Local(local) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
// 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3
|
||||
// 提前完成。但 transcribe 本身受音频长度影响:用户实测 RTF ≈ 0.3,慢机
|
||||
// 可达 0.5;15s 固定超时在 ≥ 30s 录音上会把整段结果丢掉。改用动态
|
||||
// 超时 max(15, ceil(audio_s × 0.6) + 10),公式与单测见
|
||||
// `local_qwen_transcribe_timeout`。
|
||||
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
||||
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
|
||||
log::info!(
|
||||
"[coord] local Qwen3-ASR transcribe: audio={:.2}s timeout={}s",
|
||||
audio_secs,
|
||||
timeout_duration.as_secs()
|
||||
);
|
||||
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
|
||||
inner.local_asr_cache.touch();
|
||||
schedule_local_asr_release(inner);
|
||||
match result {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("本地识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)",
|
||||
timeout_duration.as_secs(),
|
||||
audio_secs
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("local global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
|
||||
// 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。
|
||||
if inner.state.lock().cancelled {
|
||||
log::info!("[coord] cancel detected after ASR — discarding transcript");
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
// PR #387 的「cancel 后清 focus_target」契约要在 Processing 路径上也成立。
|
||||
// cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让
|
||||
// 这里收尾),但此前的 end_session 没把 focus_target 清掉。logic-review
|
||||
// 2026-05-10 P3 (🚩) 把这条补完。
|
||||
{
|
||||
let mut state = inner.state.lock();
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ASR 返回空转写护栏(来自 PR #66):写一条 emptyTranscript 失败历史 + 错误胶囊,
|
||||
// 与 main 上其它 error 路径保持一致(带 schedule_capsule_idle 让胶囊自动消失)。
|
||||
let mut raw = raw;
|
||||
|
||||
#[cfg(any(debug_assertions, test))]
|
||||
if raw.text.trim().is_empty() {
|
||||
if let Some(debug_text) = debug_transcript_override_text() {
|
||||
log::info!(
|
||||
"[coord] using debug transcript override (chars={})",
|
||||
debug_text.chars().count()
|
||||
);
|
||||
raw.text = debug_text;
|
||||
}
|
||||
}
|
||||
|
||||
if raw.text.trim().is_empty() {
|
||||
let session = DictationSession {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
raw_transcript: raw.text.clone(),
|
||||
final_text: String::new(),
|
||||
mode: inner.prefs.get().default_mode,
|
||||
style_pack_id: None,
|
||||
translation_active: false,
|
||||
polish_source: None,
|
||||
app_bundle_id: None,
|
||||
app_name: None,
|
||||
insert_status: InsertStatus::Failed,
|
||||
error_code: Some("emptyTranscript".to_string()),
|
||||
duration_ms: Some(raw.duration_ms),
|
||||
dictionary_entry_count: Some(enabled_phrases(inner).len() as u32),
|
||||
// empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想
|
||||
// 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent
|
||||
// "Missing Audio" 反馈。
|
||||
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
|
||||
};
|
||||
let prefs_snapshot = inner.prefs.get();
|
||||
if let Err(e) = inner.history.append_with_retention(
|
||||
session,
|
||||
prefs_snapshot.history_retention_days,
|
||||
prefs_snapshot.history_max_entries,
|
||||
) {
|
||||
log::error!("[coord] history append failed: {e}");
|
||||
}
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("没有识别到语音".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("ASR returned empty transcript".to_string());
|
||||
}
|
||||
|
||||
let correction_rules = match inner.correction_rules.list() {
|
||||
Ok(rules) => rules,
|
||||
Err(e) => {
|
||||
log::warn!("[coord] load correction rules failed: {e}; continue without correction");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let front_app = inner.state.lock().front_app.clone();
|
||||
if !correction_rules.is_empty() {
|
||||
let corrected = apply_correction_rules(&raw.text, &correction_rules);
|
||||
if corrected != raw.text {
|
||||
log::info!(
|
||||
"[coord] correction rules adjusted raw transcript ({} → {} chars)",
|
||||
raw.text.chars().count(),
|
||||
corrected.chars().count()
|
||||
);
|
||||
raw.text = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。
|
||||
if inner.state.lock().voice_agent {
|
||||
return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed)
|
||||
.await;
|
||||
}
|
||||
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
|
||||
|
||||
let prefs = inner.prefs.get();
|
||||
let pack = match inner
|
||||
.style_packs
|
||||
.get_or_default_active(&prefs.active_style_pack_id)
|
||||
{
|
||||
Ok(pack) => pack,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"[coord] active style pack unavailable, falling back to builtin light: {error}"
|
||||
);
|
||||
crate::types::builtin_style_pack_for_mode(PolishMode::Light)
|
||||
}
|
||||
};
|
||||
let mode = pack.base_mode;
|
||||
let hotword_strs = enabled_phrases(inner);
|
||||
let working_languages = prefs.working_languages.clone();
|
||||
let chinese_script_preference = prefs.chinese_script_preference;
|
||||
let output_language_preference = prefs.output_language_preference;
|
||||
let llm_thinking_enabled = prefs.llm_thinking_enabled;
|
||||
let style_system_prompt = pack.prompt.clone();
|
||||
let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack);
|
||||
let translation_target = prefs.translation_target_language.trim().to_string();
|
||||
let translation_active =
|
||||
inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty();
|
||||
log::info!(
|
||||
"[style-pack] runtime dispatch session_id={} active_pack={} kind={:?} mode={:?} raw_chars={} prompt_chars={} raw_uses_llm={} translation_active={} hotwords={} working_languages={:?}",
|
||||
current_session_id,
|
||||
pack.id,
|
||||
pack.kind,
|
||||
mode,
|
||||
raw.text.chars().count(),
|
||||
style_system_prompt.chars().count(),
|
||||
raw_uses_llm,
|
||||
translation_active,
|
||||
hotword_strs.len(),
|
||||
working_languages
|
||||
);
|
||||
// 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。翻译现在也走"润色+翻译"单次
|
||||
// LLM 调用,所以翻译路径同样需要上下文;只有 Raw 且不走 LLM 才没意义。窗口=0 时为空 Vec。
|
||||
// 只复用同一 active style pack 的历史;翻译历史按当前是否翻译决定喂译文还是润色后源文
|
||||
// (见 eligible_polish_context_turns)。
|
||||
let polish_context_window_minutes = prefs.polish_context_window_minutes;
|
||||
let prior_turns: Vec<(String, String)> = if (translation_active
|
||||
|| mode != PolishMode::Raw
|
||||
|| raw_uses_llm)
|
||||
&& polish_context_window_minutes > 0
|
||||
{
|
||||
match inner
|
||||
.history
|
||||
.recent_within_minutes(polish_context_window_minutes)
|
||||
{
|
||||
Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id, translation_active),
|
||||
Err(e) => {
|
||||
log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。
|
||||
// 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。
|
||||
let streaming_eligible = streaming_insert_eligible(
|
||||
prefs.streaming_insert,
|
||||
translation_active,
|
||||
mode,
|
||||
raw_uses_llm,
|
||||
);
|
||||
log::info!(
|
||||
"[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}"
|
||||
);
|
||||
|
||||
// Linux: emit_capsule(Polishing) 已通过 fcitx5 auxDown 显示 "✨ 润色中...",
|
||||
// 无需在此重复调用。
|
||||
|
||||
// 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值,
|
||||
// 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。
|
||||
let mut polish_source: Option<String> = None;
|
||||
let (polished, polish_error, already_streamed) = if translation_active {
|
||||
log::info!(
|
||||
"[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}",
|
||||
translation_target,
|
||||
working_languages,
|
||||
front_app
|
||||
);
|
||||
let (p, src, e) = polish_and_translate_or_passthrough(
|
||||
&raw,
|
||||
&translation_target,
|
||||
mode,
|
||||
&hotword_strs,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&prior_turns,
|
||||
)
|
||||
.await;
|
||||
polish_source = src;
|
||||
(p, e, false)
|
||||
} else if streaming_eligible {
|
||||
run_streaming_polish(
|
||||
inner,
|
||||
&raw,
|
||||
mode,
|
||||
&hotword_strs,
|
||||
&style_system_prompt,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&prior_turns,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
let (p, e) = polish_or_passthrough(
|
||||
&raw,
|
||||
mode,
|
||||
&hotword_strs,
|
||||
&style_system_prompt,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&prior_turns,
|
||||
)
|
||||
.await;
|
||||
(p, e, false)
|
||||
};
|
||||
|
||||
let polished = finalize_polished_text(
|
||||
polished,
|
||||
translation_active,
|
||||
raw_uses_llm,
|
||||
mode,
|
||||
&polish_error,
|
||||
chinese_script_preference,
|
||||
&correction_rules,
|
||||
already_streamed,
|
||||
);
|
||||
// 原子化最后一次 cancel 检查 + 转 Inserting:
|
||||
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
|
||||
// cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复,
|
||||
// 之前 check 与 inserter.insert 之间有窗口期。
|
||||
//
|
||||
// 流式路径例外:`already_streamed = true` 表示字符已经一边流一边落到光标了,
|
||||
// 撤销不掉。即使 cancel 旗在中途被立起来,也只能尊重「已经发生」的事实,进入
|
||||
// Inserting 状态完成 history / vocab 等收尾工作。
|
||||
let proceed_to_insert = {
|
||||
let mut state = inner.state.lock();
|
||||
if state.cancelled && !already_streamed {
|
||||
state.phase = SessionPhase::Idle;
|
||||
false
|
||||
} else {
|
||||
state.phase = SessionPhase::Inserting;
|
||||
true
|
||||
}
|
||||
};
|
||||
if !proceed_to_insert {
|
||||
log::info!(
|
||||
"[coord] cancel detected before insert — discarding output (chars={})",
|
||||
polished.chars().count()
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let focus_target = inner.state.lock().focus_target;
|
||||
let focus_ready_for_paste = restore_focus_target_if_possible(focus_target);
|
||||
let prefs = inner.prefs.get();
|
||||
let restore_clipboard = prefs.restore_clipboard_after_paste;
|
||||
let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback;
|
||||
let paste_shortcut = prefs.paste_shortcut;
|
||||
// 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。
|
||||
let status = if already_streamed {
|
||||
log::info!(
|
||||
"[coord] insertion skipped: {} chars already streamed via unicode_keystroke (polish_error={:?})",
|
||||
polished.chars().count(),
|
||||
polish_error
|
||||
);
|
||||
InsertStatus::Inserted
|
||||
} else if focus_ready_for_paste {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ime_target = capture_ime_submit_target();
|
||||
insert_with_windows_ime_first(
|
||||
inner,
|
||||
current_session_id,
|
||||
&polished,
|
||||
restore_clipboard,
|
||||
allow_non_tsf_insertion_fallback,
|
||||
paste_shortcut,
|
||||
ime_target,
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
inner
|
||||
.inserter
|
||||
.insert(&polished, restore_clipboard, paste_shortcut)
|
||||
}
|
||||
} else {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。
|
||||
inner
|
||||
.inserter
|
||||
.insert(&polished, restore_clipboard, paste_shortcut)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
log::warn!(
|
||||
"[coord] original insertion target is not foreground; copied output without paste"
|
||||
);
|
||||
if allow_non_tsf_insertion_fallback {
|
||||
inner.inserter.copy_fallback(&polished)
|
||||
} else {
|
||||
InsertStatus::Failed
|
||||
}
|
||||
}
|
||||
};
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
let inserted_chars = polished.chars().count() as u32;
|
||||
|
||||
// 累计每条 enabled 词条在最终文本中的命中次数。
|
||||
// 用 polished(最终插入的文本)扫描,与用户实际看到的输出一致。
|
||||
let total_hits: u64 = match inner.vocab.record_hits(&polished) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
log::error!("[coord] record_hits failed: {e}");
|
||||
0
|
||||
}
|
||||
};
|
||||
// 词汇本页面在打开时通常需要立即看到 hits 增长,否则用户得手动切走再切回来才刷新。
|
||||
// 命中数 > 0 时通知前端:Vocab 页面订阅 vocab:updated 即时 listVocab() 重新加载。
|
||||
if total_hits > 0 {
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let _ = app.emit("vocab:updated", total_hits);
|
||||
}
|
||||
}
|
||||
|
||||
// polish 失败时在 history 里标记 polishFailed,让用户能在历史详情看到为什么这次输出
|
||||
// 不是预期的 mode 风格。即使失败也不丢词 — final_text 仍是原文(保留"用户的话不丢"语义)。
|
||||
let error_code = dictation_error_code(
|
||||
status,
|
||||
polish_error.is_some(),
|
||||
focus_ready_for_paste,
|
||||
allow_non_tsf_insertion_fallback,
|
||||
)
|
||||
.map(str::to_string);
|
||||
let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired");
|
||||
|
||||
// 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `<session_id>.wav`
|
||||
// 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。
|
||||
let history_session_id = current_session_id.to_string();
|
||||
let history_created_at = Utc::now().to_rfc3339();
|
||||
let prefs_snapshot = inner.prefs.get();
|
||||
let session = DictationSession {
|
||||
id: history_session_id.clone(),
|
||||
created_at: history_created_at.clone(),
|
||||
raw_transcript: raw.text.clone(),
|
||||
final_text: polished.clone(),
|
||||
mode,
|
||||
style_pack_id: Some(pack.id.clone()),
|
||||
translation_active,
|
||||
polish_source,
|
||||
app_bundle_id: None,
|
||||
app_name: None,
|
||||
insert_status: status,
|
||||
error_code,
|
||||
duration_ms: Some(raw.duration_ms),
|
||||
// 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次),
|
||||
// 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。
|
||||
dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32),
|
||||
// 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关——
|
||||
// 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。
|
||||
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
|
||||
};
|
||||
if let Err(e) = inner.history.append_with_retention(
|
||||
session,
|
||||
prefs_snapshot.history_retention_days,
|
||||
prefs_snapshot.history_max_entries,
|
||||
) {
|
||||
log::error!("[coord] history append failed: {e}");
|
||||
}
|
||||
let done_message = if tsf_required_insert_failed {
|
||||
Some("TSF 未上屏,已禁止非 TSF 兜底".to_string())
|
||||
} else {
|
||||
default_done_message(status, polish_error.is_some())
|
||||
};
|
||||
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Done,
|
||||
0.0,
|
||||
elapsed,
|
||||
done_message,
|
||||
Some(inserted_chars),
|
||||
);
|
||||
|
||||
{
|
||||
let mut state = inner.state.lock();
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
// Toggle 模式冷却:设冷却时间戳,POST_SESSION_COOLDOWN_MS 内禁止新的 activate。
|
||||
// 覆盖胶囊离场动画周期,避免三连按第 3 次误激活(issue #545)。
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
*inner.session_cooldown_until.lock() =
|
||||
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
|
||||
}
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user