refactor(dictation): 抽出 dictation_end 子模块(end_session 并发敏感整体搬,不动锁/await)

This commit is contained in:
吕柏青 committed 2026-06-08 09:32:54 +08:00
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(())
}