Merge pull request #619 from Open-Less/refactor/split-dictation

refactor(dictation): dictation.rs 2766→425 拆为 4 个子模块(机械式·零回归)
This commit is contained in:
TRIP authored and GitHub committed 2026-06-08 13:27:57 +08:00
commit 9b14c031da
7 files changed
+2382 -2354

No files matched your search

+12 -6
View File
@@ -64,6 +64,10 @@ use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionCon
mod asr_setup; mod asr_setup;
mod capsule; mod capsule;
mod dictation; mod dictation;
mod dictation_end;
mod dictation_session;
mod dictation_streaming;
mod dictation_voice_agent;
mod hotkey_supervisors; mod hotkey_supervisors;
mod ime_insertion; mod ime_insertion;
mod llm_pipeline; mod llm_pipeline;
@@ -76,6 +80,10 @@ mod voice_agent_hotkeys;
// 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。 // 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。
pub(crate) use asr_setup::*; pub(crate) use asr_setup::*;
pub(crate) use capsule::*; 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::*;
pub(crate) use hotkey_supervisors::*; pub(crate) use hotkey_supervisors::*;
pub(crate) use ime_insertion::*; pub(crate) use ime_insertion::*;
pub(crate) use llm_pipeline::*; pub(crate) use llm_pipeline::*;
@@ -83,11 +91,9 @@ pub(crate) use qa_session::*;
pub(crate) use voice_agent_hotkeys::*; pub(crate) use voice_agent_hotkeys::*;
#[cfg(test)] #[cfg(test)]
use dictation::dictation_error_code; use dictation_session::dictation_error_code;
use dictation::{ use dictation::{handle_pressed_edge, handle_released_edge};
begin_session, cancel_session, end_session, handle_pressed_edge, handle_released_edge, use dictation_session::{begin_session, cancel_session, request_stop_during_starting};
request_stop_during_starting,
};
#[cfg(any(debug_assertions, test))] #[cfg(any(debug_assertions, test))]
use dictation::{handle_pressed, handle_released}; use dictation::{handle_pressed, handle_released};
use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState}; use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState};
@@ -815,7 +821,7 @@ impl Coordinator {
/// 内联审批卡的 Approve / Deny 回执:解析等待中的 token。 /// 内联审批卡的 Approve / Deny 回执:解析等待中的 token。
pub fn less_computer_approve(&self, token: &str, approved: bool) { pub fn less_computer_approve(&self, token: &str, approved: bool) {
dictation::resolve_less_computer_approval(token, approved); dictation_voice_agent::resolve_less_computer_approval(token, approved);
} }
pub fn history(&self) -> &HistoryStore { pub fn history(&self) -> &HistoryStore {
File diff suppressed because it is too large. Load diff
@@ -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(())
}
@@ -0,0 +1,702 @@
use std::sync::atomic::Ordering;
use std::sync::Arc;
use crate::coordinator_state::request_stop_during_starting_state;
use super::resources::*;
use super::*;
pub(crate) fn request_stop_during_starting(inner: &Arc<Inner>, reason: &str) {
{
let mut state = inner.state.lock();
if !request_stop_during_starting_state(&mut state) {
return;
}
}
log::info!("[coord] {reason} during Starting — queued");
stop_recorder_if_pending_start_stop(inner);
}
pub(crate) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
let Some(session_id) =
begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app())
else {
return Ok(());
};
if let Some(label) = state.front_app.as_deref() {
log::info!("[coord] front_app captured: {label}");
}
session_id
};
#[cfg(target_os = "windows")]
{
let prepared = inner.windows_ime.prepare_session();
let mut slots = inner.prepared_windows_ime_session.lock();
store_prepared_windows_ime_session(&mut slots, current_session_id, prepared);
}
// 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。
inner
.translation_modifier_seen
.store(false, Ordering::SeqCst);
#[cfg(any(debug_assertions, test))]
if hotkey_injection_dry_run_enabled() {
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
inner.state.lock().phase = SessionPhase::Listening;
log::info!("[coord] session started (hotkey-injection dry-run)");
return Ok(());
}
if let Err(message) = ensure_asr_credentials() {
log::warn!("[coord] ASR credential gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
return Err(message);
}
let active_asr = CredentialsVault::get_active_asr();
if let Err(message) = ensure_microphone_permission(inner) {
log::warn!("[coord] microphone permission gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
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(message);
}
// 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在
// Recorder::start 成功后再发,确保「用户看到录音条」时 mic 已经在 capture。
// 之前在这一行就 emit 会让用户看到录音条后立刻开口,但 mic 还在 cpal init
// 窗口(50-200ms)内 → 开头几个字物理上录不到。详见 issue 备注。
#[cfg(target_os = "windows")]
if foundry::is_foundry_local_whisper(&active_asr) {
let prefs = inner.prefs.get();
let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) {
prefs.foundry_local_asr_model.clone()
} else {
foundry::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let local = Arc::new(FoundryLocalWhisperAsr::new(
Arc::clone(&inner.foundry_local_runtime),
model_alias,
prefs.foundry_local_runtime_source.clone(),
language_hint,
));
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
// Windows sherpa-onnx-local:与 Foundry 同形分支,复用 Recorder /
// ActiveAsr / start_recorder_and_enter_listening。offline 模型走 batch;
// online 模型在 provider 内部 worker 中边录边解码,并通过 local-asr-token
// 推 partial 给前端胶囊。
#[cfg(target_os = "windows")]
if sherpa::is_sherpa_onnx_local(&active_asr) {
let prefs = inner.prefs.get();
let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
sherpa::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let token_handler = inner.app.lock().clone().map(|app| {
Arc::new(move |piece: String| {
if let Err(error) = app.emit("local-asr-token", piece) {
log::warn!("[sherpa-asr] emit token failed: {error}");
}
}) as crate::asr::local::sherpa_provider::SherpaTokenHandler
});
let local = match SherpaOnnxAsr::new_for_model(
Arc::clone(&inner.sherpa_onnx_runtime),
model_alias,
language_hint,
token_handler,
)
.await
{
Ok(local) => Arc::new(local),
Err(e) => {
log::error!("[coord] sherpa-onnx init failed: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
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(format!("sherpa-onnx init failed: {e}"));
}
};
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(&active_asr) {
let local = match build_local_qwen3(inner).await {
Ok(l) => l,
Err(e) => {
log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
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(format!("local ASR init failed: {e}"));
}
};
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Local(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
if is_bailian_provider(&active_asr) {
let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials()));
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Bailian(Arc::clone(&asr)),
);
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?;
if let Err(e) = asr.open_session().await {
log::error!("[coord] open Bailian ASR session failed: {e}");
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::CancelRaced => {
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::ActiveStarting => {
asr.cancel();
}
}
discard_startup_resources_for_session(inner, current_session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("ASR 连接失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::ActiveStarting => {}
StartupRaceStatus::CancelRaced => {
log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin");
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
}
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
let flushed_bytes = bridge.attach(target);
log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes");
finish_starting_session(inner, current_session_id).await;
} else if is_mimo_provider(&active_asr) {
let (api_key, base_url, model) = read_mimo_credentials();
let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model));
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Mimo(Arc::clone(&mimo)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = mimo;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
} else if is_whisper_compatible_provider(&active_asr) {
let (api_key, base_url, model) = read_whisper_credentials();
// 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や
// 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には
// 既に system prompt として注入済みだが、Whisper 出力が大きく崩れる
// と Polish でも救えない(特に CJK で顕著)。Volcengine ASR は元々
// hotword を受け取っており、UI 説明文も「ASR ホットワードと後処理
// モデルのコンテキスト両方に渡される」と明示しているので、Whisper
// 互換プロバイダにも揃えるのが筋。
let whisper_prompt =
crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner));
let whisper = Arc::new(
WhisperBatchASR::new(
api_key,
base_url,
model,
whisper_prompt,
batch_asr_chunk_limit_ms(&active_asr),
whisper_supports_verbose_json(&active_asr),
)
.with_request_format(whisper_request_format(&active_asr)),
);
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Whisper(Arc::clone(&whisper)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = whisper;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
} else {
let hotwords = enabled_hotwords(inner);
let creds = read_volc_credentials();
let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords));
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Volcengine(Arc::clone(&asr)),
);
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?;
if let Err(e) = asr.open_session().await {
log::error!("[coord] open ASR session failed: {e}");
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale ASR open_session error from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::CancelRaced => {
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::ActiveStarting => {}
}
discard_startup_resources_for_session(inner, current_session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("ASR 连接失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
// open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session
// 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。
// audit HIGH #1。
match startup_race_status_for_starting(inner, current_session_id) {
StartupRaceStatus::ActiveStarting => {}
StartupRaceStatus::CancelRaced => {
log::info!("[coord] cancel raced during ASR open_session — aborting begin");
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
StartupRaceStatus::StaleContinuation => {
log::info!(
"[coord] stale ASR open_session continuation from session {current_session_id} — ignoring"
);
asr.cancel();
discard_startup_resources_for_session(inner, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
}
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
let flushed_bytes = bridge.attach(target);
log::info!("[coord] ASR connected; flushed {flushed_bytes} deferred audio bytes");
finish_starting_session(inner, current_session_id).await;
}
Ok(())
}
pub(crate) async fn start_recorder_for_starting(
inner: &Arc<Inner>,
session_id: SessionId,
active_asr: &str,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
) -> Result<(), String> {
let inner_for_level = Arc::clone(inner);
// 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS
// transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔),
// 配合 CSS 短 transition 让每次 emit 完整可见。
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let phase = inner_for_level.state.lock().phase;
if phase != SessionPhase::Listening && phase != SessionPhase::Starting {
return;
}
let now = Instant::now();
{
let mut last = last_emit_at.lock();
if let Some(prev) = *last {
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
return;
}
}
*last = Some(now);
}
let elapsed = inner_for_level
.state
.lock()
.started_at
.elapsed()
.as_millis() as u64;
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
elapsed,
None,
None,
);
});
let microphone_device_name = selected_microphone_device_name(inner);
stop_microphone_preview_monitor(inner, "dictation recorder");
acquire_recording_mute(inner, "dictation").await;
let audio_archive_path = if inner.prefs.get().record_audio_for_debug {
// 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见
// 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。
// 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。
let prefs = inner.prefs.get();
let _ = crate::persistence::prune_recordings(
prefs.history_retention_days,
prefs.audio_recording_max_entries,
);
crate::persistence::recording_path_for_session(&session_id.to_string()).ok()
} else {
None
};
match Recorder::start(
microphone_device_name,
consumer,
level_handler,
audio_archive_path,
) {
Ok((rec, runtime_errors, archive_active)) => {
// 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript
// 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。
inner
.audio_archive_active
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
store_recorder_for_session(inner, session_id, rec);
spawn_recorder_error_monitor(inner, runtime_errors);
// 不在这里 emit Recording capsule。
// Recorder::start Ok 仅代表 cpal Stream::play 完成,不代表 audio
// 线程已经在向 consumer 推 PCM —— macOS CoreAudio AudioUnit 启动到
// 第一帧 process_callback 中间有 50–200 ms 间隙(Windows 类似)。
// 之前在这里立即 emit Recording 会让用户「看到录音条」就开口,但前几个
// 字落在 cpal init 窗口里被吞,反映为短录音漏首字(用户报告)。
//
// 现改为:level_handler 第一次被触发时才 emit Recording capsule。
// recorder.rs::process_callback 的顺序是 consume_pcm_chunk → level_handler,
// 所以 level_handler 第一次执行 == PCM 已经真实流到 consumer。从这一刻
// 起用户说什么都被录到。capsule 自然就晚 50–200 ms 出现,但出现 ==
// mic 真的在录,匹配「麦先录、UI 再弹」的预期。
//
// 原本的竞态保护交还给两条已有路径:
// - stop_recorder_if_pending_start_stop:短按时把 capsule 切到
// Transcribing;recorder 已 stop,level_handler 不会再发火。
// - level_handler 内部 phase 检查:cancel / 错误使 phase 不在
// {Starting, Listening} 时直接 return,不会在错误状态上盖
// Recording。
stop_recorder_if_pending_start_stop(inner);
log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)");
}
Err(e) => {
log::error!("[coord] recorder start failed: {e}");
cancel_asr_for_session(inner, session_id);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("录音启动失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, session_id);
release_recording_mute(inner, "dictation");
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
}
Ok(())
}
pub(crate) fn spawn_recorder_error_monitor(inner: &Arc<Inner>, rx: mpsc::Receiver<RecorderError>) {
// 捕获当前 session_id:err 来时若 id 已经不一致说明是上一 session 的迟到事件,
// 不能去 abort 当前 active 的新 session(它录得好好的)。
let captured_session_id = inner.state.lock().session_id;
let inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-recorder-error-monitor".into())
.spawn(move || {
if let Ok(err) = rx.recv() {
let current_session_id = inner.state.lock().session_id;
if captured_session_id != current_session_id {
log::warn!(
"[coord] recorder error from stale session {} dropped (current={}, err={})",
captured_session_id,
current_session_id,
err
);
return;
}
log::error!("[coord] recorder runtime error: {err}");
abort_recording_with_error(&inner, format!("录音中断: {err}"));
}
})
.ok();
}
pub(crate) fn abort_recording_with_error(inner: &Arc<Inner>, message: String) {
let Some(abort) = ({
let mut state = inner.state.lock();
begin_recording_abort_before_restore(&mut state)
}) else {
return;
};
discard_startup_resources_for_session(inner, abort.session_id);
restore_prepared_windows_ime_session(inner, abort.session_id);
{
let mut state = inner.state.lock();
publish_abort_idle_after_restore(&mut state, abort.session_id);
}
emit_capsule(
inner,
CapsuleState::Error,
0.0,
abort.elapsed,
Some(message),
None,
);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
}
pub(crate) async fn start_recorder_and_enter_listening(
inner: &Arc<Inner>,
session_id: SessionId,
active_asr: &str,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
) -> Result<(), String> {
start_recorder_for_starting(inner, session_id, active_asr, consumer).await?;
finish_starting_session(inner, session_id).await;
Ok(())
}
pub(crate) async fn finish_starting_session(inner: &Arc<Inner>, session_id: SessionId) {
// audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。
// 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle
// 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。
let outcome = {
let mut state = inner.state.lock();
finish_starting_session_state(&mut state, session_id)
};
match outcome {
BeginOutcome::StaleContinuation => {
log::info!(
"[coord] stale recorder/ASR startup continuation from session {session_id} — ignoring"
);
discard_startup_resources_for_session(inner, session_id);
restore_prepared_windows_ime_session(inner, session_id);
}
BeginOutcome::CancelRaced => {
log::info!("[coord] cancel raced during recorder/ASR startup — aborting begin");
discard_startup_resources_for_session(inner, session_id);
restore_prepared_windows_ime_session(inner, session_id);
set_phase_idle_if_session_matches(inner, session_id);
}
BeginOutcome::Started | BeginOutcome::PendingStop => {
log::info!("[coord] session started");
if matches!(outcome, BeginOutcome::PendingStop) {
log::info!("[coord] applying pending_stop edge → end_session immediately");
let _ = end_session(inner).await;
}
}
}
}
pub(crate) fn dictation_error_code(
status: InsertStatus,
polish_failed: bool,
focus_ready_for_paste: bool,
allow_non_tsf_insertion_fallback: bool,
) -> Option<&'static str> {
if !focus_ready_for_paste && status == InsertStatus::Failed {
Some("focusRestoreFailed")
} else if cfg!(target_os = "windows")
&& focus_ready_for_paste
&& !allow_non_tsf_insertion_fallback
&& status == InsertStatus::Failed
{
Some("windowsImeTsfRequired")
} else if polish_failed {
Some("polishFailed")
} else {
None
}
}
pub(crate) fn cancel_session(inner: &Arc<Inner>) {
let Some(decision) = ({
let mut state = inner.state.lock();
let phase = state.phase;
let decision = begin_cancel_session_state(&mut state);
if phase == SessionPhase::Inserting {
log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste");
}
decision
}) else {
return;
};
stop_recorder_for_session(inner, decision.session_id);
cancel_asr_for_session(inner, decision.session_id);
restore_prepared_windows_ime_session(inner, decision.session_id);
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
// 其他阶段直接转 Idle。
if decision.phase != SessionPhase::Processing {
let mut state = inner.state.lock();
finish_cancel_session_state(&mut state, decision);
// 只有真正把 phase 设为 Idle 时才设冷却(避免离场动画期间误激活)。
let now = std::time::Instant::now();
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None);
log::info!("[coord] session cancelled (was {:?})", decision.phase);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
// 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}
pub(crate) fn eligible_polish_context_turns(
sessions: Vec<DictationSession>,
active_style_pack_id: &str,
current_translation_active: bool,
) -> Vec<(String, String)> {
sessions
.into_iter()
// 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底,
// 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。
// 这条同时保证下面 filter_map 里翻译历史的 final_text 一定是真译文(而非 passthrough
// 原文)——失败 / 兜底的翻译会话 error_code 非空,已在此被滤掉。
.filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty())
// 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。
.filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id))
// 翻译历史按"下一轮是否也翻译"决定喂哪一段,既保留对话连续性又不让译文串味:
// - 当前是翻译轮 → 喂译文(final_text),保持目标语言一致;
// - 当前是普通轮 → 喂润色后的源文(polish_source),把译文剔除掉;源文缺失(解析
// 失败 / 旧历史)则整条跳过——宁可少一条上下文,也不让外语译文混进普通润色。
// - 普通历史无论当前轮是什么,都喂 final_text(本就是源语言润色结果)。
.filter_map(|s| {
if s.translation_active && !current_translation_active {
s.polish_source
.filter(|src| !src.trim().is_empty())
.map(|src| (s.raw_transcript, src))
} else {
Some((s.raw_transcript, s.final_text))
}
})
.collect()
}
@@ -0,0 +1,420 @@
use std::sync::Arc;
use crate::correction::apply_correction_rules;
use super::*;
const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12);
/// 跑流式润色路径(opt-in,跨平台)。
///
/// 平台差异:
/// - **macOS**:`switch_to_ascii` 切到 ABC 输入源(规避 CJK / 日文 IME 拦截 Unicode 事件),
/// session 结束 `restore_input_source` 切回。`type_unicode_chunk` 走 CGEvent FFI。
/// - **Windows**:`switch_to_ascii` 是 no-op(SendInput Unicode 绕过 TSF);
/// `type_unicode_chunk` 走 `SendInput(KEYEVENTF_UNICODE)`。
/// - **Linux(实验)**:`switch_to_ascii` 是 no-op;`type_unicode_chunk` 走 enigo
/// `Keyboard::text`。X11 / XTest 稳定。
///
/// 通用流程:
/// 1. `switch_to_ascii`(macOS)/ no-op(其他);失败则降级回一次性 `polish_or_passthrough`。
/// 2. 起一个 `spawn_blocking` 后台任务,从 mpsc 收 SSE delta,按 12ms flush window
/// 合并后调 `type_unicode_chunk` 模拟键盘事件落到光标处。串行有序,无竞态。
/// 3. 调 `polish_or_passthrough_streaming`,`on_delta` 把 chunk 塞进 mpsc。
/// 4. 流结束 / 失败 / 取消 → drop mpsc 发送端 → typer 任务 drain 完剩余 delta 退出 →
/// `restore_input_source` 恢复用户原输入源(macOS 才有意义,其他平台 no-op)。
/// 5. 返回 `(polished, polish_error, already_streamed)`:
/// - 成功:`(text, None, true)` — 字符已经在屏幕上,调用方应当跳过 `inserter.insert`
/// - 失败:`(raw_text, Some(reason), false)` — 流式过程出错,调用方走 raw 一次性兜底
/// - 不支持:`run_streaming_polish` 内部直接调 `polish_or_passthrough` 透明降级
///
/// **不在流式路径里做**:`apply_chinese_script_preference` / `apply_correction_rules`
/// 这两步在 v1 跳过 —— 字符已经一边流一边落出去了,不好回退。需要的话只能关 toggle 走
/// 一次性路径。
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_streaming_polish(
inner: &Arc<Inner>,
raw: &RawTranscript,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: crate::types::ChineseScriptPreference,
output_language_preference: crate::types::OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> (String, Option<String>, bool) {
log::info!(
"[coord] streaming_insert path ENTER (raw_chars={})",
raw.text.chars().count()
);
let app = inner.app.lock().clone();
let Some(app) = app else {
log::warn!("[coord] streaming_insert: no AppHandle in Inner; fall back to one-shot");
let (p, e) = polish_or_passthrough(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await;
return (p, e, false);
};
// 1. 切到 ABC 输入源。失败则降级 —— 流式路径上 CJK IME 拦截不是可恢复错误。
log::info!("[coord] streaming_insert: switching input source to ABC");
let prev_ime = match crate::unicode_keystroke::switch_to_ascii(&app).await {
Ok(prev) => {
log::info!(
"[coord] streaming_insert: switched to ABC (had_previous={})",
prev.is_some()
);
prev
}
Err(e) => {
log::warn!(
"[coord] streaming_insert: switch_to_ascii failed: {e}; fall back to one-shot"
);
let (p, err) = polish_or_passthrough(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await;
return (p, err, false);
}
};
// 2. 起 typer 后台任务:从 mpsc 收 delta,串行调 type_unicode_chunk。
// 同时累积 typed_text:屏幕上真正落字的内容,用于(a)SSE 中途失败时让 history
// 与用户实际看到的内容一致;(b)pr-agent #412 反馈 \"saved output diverges
// from what the user actually sees\"。
let (tx, rx) = std::sync::mpsc::channel::<String>();
let typer_handle = tokio::task::spawn_blocking(move || {
drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL)
});
// 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。
let inner_for_cancel = Arc::clone(inner);
let should_cancel = move || inner_for_cancel.state.lock().cancelled;
let outcome = super::polish_or_passthrough_streaming(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
move |delta: &str| {
let _ = tx.send(delta.to_string());
},
should_cancel,
)
.await;
// tx 已经被 move 进 on_delta 闭包;闭包随 polish_or_passthrough_streaming 返回
// 而 drop,typer 那侧 blocking_recv 拿到 None 自然退出。
// 4. 等 typer 把缓冲 drain 完,拿到实际落字的全文 + 第一条失败原因。
let (typed_text, typer_failure) = typer_handle.await.unwrap_or_else(|e| {
log::error!("[coord] streaming_insert: typer task join failed: {e}");
(String::new(), Some(format!("typer join: {e}")))
});
let typed_chars = typed_text.chars().count();
log::info!("[coord] streaming_insert: typer drained, typed {typed_chars} chars");
// 5. 无论流是否成功,都恢复用户原输入源。
log::info!("[coord] streaming_insert: restoring input source");
if let Err(e) = crate::unicode_keystroke::restore_input_source(&app, prev_ime).await {
log::warn!("[coord] streaming_insert: restore_input_source failed: {e}");
} else {
log::info!("[coord] streaming_insert: input source restored");
}
// 6. 把 outcome 翻译成 (polished, polish_error, already_streamed)。
match outcome {
super::StreamingPolishOutcome::Streamed(text) => {
log::info!(
"[coord] streaming_insert SUCCESS: polished_chars={} typed_chars={} typer_err={:?}",
text.chars().count(),
typed_chars,
typer_failure
);
// 边界 case:polish 成功但 typer 在第一字就失败(最常见:session 开始时
// 已处于 Secure Input;或 SendInput / enigo 拒绝)。屏幕上一字未见,
// already_streamed=true 会让上层跳过 inserter,最终用户看不到任何内容。
// 这里显式回退到一次性兜底,让正常 inserter 路径写出 polish 结果。
// pr-agent #412 反馈 \"Missing fallback\"。
if typed_chars == 0 {
if let Some(reason) = typer_failure {
log::warn!(
"[coord] streaming_insert: zero chars typed despite polish success ({reason}); falling back to one-shot inserter"
);
return (text, Some(reason), false);
}
}
// 先确定 final_text —— typer 中途失败时屏幕只有 typed_text 这一段,
// history 记完整 polish 反而会让用户复盘困惑。让 history / clipboard /
// 后续逻辑统统用 final_text,三处保持一致。
// pr-agent #412 反馈 \"Clipboard Mismatch\":之前先写 text 到剪贴板再
// 决定 typer 是否中途失败,导致 Cmd+V 粘出用户屏幕上没见过的内容。
let (final_text, polish_err) = match typer_failure {
Some(e) => (typed_text, Some(format!("typing partially failed: {e}"))),
None => (text, None),
};
// 把 final_text 写回剪贴板(默认 on,macOS/Windows 适用)。
// Linux:fcitx5 插件已直写文字到目标 app,跳过剪贴板避免破坏用户数据。
#[cfg(not(target_os = "linux"))]
if inner.prefs.get().streaming_insert_save_clipboard {
match arboard::Clipboard::new() {
Ok(mut cb) => match cb.set_text(final_text.clone()) {
Ok(()) => log::info!(
"[coord] streaming_insert: final text written to clipboard ({} chars)",
final_text.chars().count()
),
Err(e) => {
log::warn!("[coord] streaming_insert: clipboard set_text failed: {e}")
}
},
Err(e) => {
log::warn!("[coord] streaming_insert: clipboard handle init failed: {e}")
}
}
} else {
log::info!("[coord] streaming_insert: clipboard save skipped (pref off)");
}
(final_text, polish_err, true)
}
super::StreamingPolishOutcome::UnsupportedFallback => {
log::info!(
"[coord] streaming_insert: dispatch reported unsupported, fall back to one-shot"
);
let (p, e) = polish_or_passthrough(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await;
(p, e, false)
}
super::StreamingPolishOutcome::Failed(reason) => {
log::warn!(
"[coord] streaming_insert FAILED: {reason}; typed {typed_chars} chars before failure"
);
// 流式失败但已经流了一部分 chars:用户屏幕上有半截 polish。history 应当
// 跟屏幕一致 —— 记 typed_text 而不是 raw.text,否则保存内容跟用户看见的
// 内容会分叉(pr-agent #412 \"Wrong final text\" 反馈)。
// 一字都没流时 typed_text 是空串,回到 raw 一次性兜底。
if typed_chars > 0 {
(
typed_text,
Some(format!(
"streaming polish failed mid-stream after {typed_chars} chars: {reason}"
)),
true,
)
} else {
(raw.text.clone(), Some(reason), false)
}
}
}
}
fn drain_streaming_insert_deltas(
rx: std::sync::mpsc::Receiver<String>,
flush_interval: std::time::Duration,
) -> (String, Option<String>) {
drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer)
}
pub(crate) fn drain_streaming_insert_deltas_with<F>(
rx: std::sync::mpsc::Receiver<String>,
flush_interval: std::time::Duration,
mut flush_pending: F,
) -> (String, Option<String>)
where
F: FnMut(&mut String, &mut String) -> Option<String>,
{
let mut typed_text = String::new();
let mut first_failure: Option<String> = None;
let mut pending = String::new();
while let Ok(delta) = rx.recv() {
pending.push_str(&delta);
let flush_at = std::time::Instant::now() + flush_interval;
loop {
let now = std::time::Instant::now();
if now >= flush_at {
break;
}
match rx.recv_timeout(flush_at.duration_since(now)) {
Ok(delta) => pending.push_str(&delta),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
first_failure = flush_pending(&mut pending, &mut typed_text);
return (typed_text, first_failure);
}
}
}
first_failure = flush_pending(&mut pending, &mut typed_text);
if first_failure.is_some() {
// 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍
// 把 mpsc drain 完,避免发送端阻塞。
while rx.recv().is_ok() {}
break;
}
}
if first_failure.is_none() {
first_failure = flush_pending(&mut pending, &mut typed_text);
}
(typed_text, first_failure)
}
fn flush_streaming_insert_buffer(pending: &mut String, typed_text: &mut String) -> Option<String> {
flush_streaming_insert_buffer_with(
pending,
typed_text,
crate::unicode_keystroke::type_unicode_chunk,
)
}
pub(crate) fn flush_streaming_insert_buffer_with<F>(
pending: &mut String,
typed_text: &mut String,
mut type_chunk: F,
) -> Option<String>
where
F: FnMut(&str) -> Result<usize, crate::unicode_keystroke::TypeError>,
{
if pending.is_empty() {
return None;
}
let delta = std::mem::take(pending);
let delta_chars = delta.chars().count();
match type_chunk(&delta) {
Ok(typed_chars) => {
let appended = append_typed_prefix(typed_text, &delta, typed_chars);
if appended < delta_chars {
let reason = format!(
"type_unicode_chunk typed only {appended}/{delta_chars} chars without error"
);
log::error!(
"[coord] streaming_insert: {reason} at typed={} chars; \
dropping remaining deltas",
typed_text.chars().count()
);
Some(reason)
} else {
None
}
}
Err(e) => {
append_typed_prefix(typed_text, &delta, e.typed_chars());
log::error!(
"[coord] streaming_insert: type_unicode_chunk failed at typed={} chars: {e}; \
dropping remaining deltas",
typed_text.chars().count()
);
Some(e.to_string())
}
}
}
pub(crate) fn finalize_polished_text(
polished: String,
translation_active: bool,
_raw_uses_llm: bool,
mode: PolishMode,
polish_error: &Option<String>,
chinese_script_preference: crate::types::ChineseScriptPreference,
correction_rules: &[crate::types::CorrectionRule],
already_streamed: bool,
) -> String {
if already_streamed {
return polished;
}
let should_force_script = if translation_active {
polish_error.is_some()
} else {
mode == PolishMode::Raw || polish_error.is_some()
};
let polished = if should_force_script {
apply_chinese_script_preference(&polished, chinese_script_preference)
} else {
polished
};
if correction_rules.is_empty() {
polished
} else {
let corrected = apply_correction_rules(&polished, correction_rules);
if corrected != polished {
log::info!(
"[coord] correction rules adjusted final text ({} → {} chars)",
polished.chars().count(),
corrected.chars().count()
);
}
corrected
}
}
pub(crate) fn streaming_insert_eligible(
streaming_insert_enabled: bool,
translation_active: bool,
mode: PolishMode,
raw_uses_llm: bool,
) -> bool {
streaming_insert_enabled && !translation_active && (mode != PolishMode::Raw || raw_uses_llm)
}
pub(crate) fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option<String> {
if polish_failed {
// polish 失败优先告知用户,即使 insert 成功也要让用户知道这版是原文
Some("润色失败,已插入原文".to_string())
} else {
match status {
InsertStatus::Inserted => None,
InsertStatus::PasteSent => Some("已尝试粘贴".to_string()),
InsertStatus::CopiedFallback => Some(if cfg!(target_os = "windows") {
"已复制,请 Ctrl+V".to_string()
} else {
"已复制,请粘贴".to_string()
}),
InsertStatus::Failed => Some("插入失败".to_string()),
}
}
}
pub(crate) fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> usize {
let mut end = 0;
let mut appended = 0;
for (idx, ch) in delta.char_indices().take(typed_chars) {
end = idx + ch.len_utf8();
appended += 1;
}
target.push_str(&delta[..end]);
appended
}
@@ -0,0 +1,456 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use super::*;
/// Less Computer 浮窗的 Tauri 事件名(前端 LessComputerPanel 订阅)。
const LESS_COMPUTER_EVENT: &str = "less-computer:event";
/// Less Computer 内联审批:等待用户决断的 token → oneshot sender 注册表。
///
/// 无头 `claude -p` 没有 mid-run 的 `--permission-prompt-tool` 通道(v2.1.165 不支持),
/// 所以护栏拦截发生在「整轮跑完、护栏 deny 生效」之后。这个注册表是审批 UI 的实回路:
/// 后端发 `approval` 事件后把一个 oneshot 接收端挂在这里,等前端 `less_computer_approve`
/// 命令按 token 解析出用户决断(true=Approve / false=Deny)。
static LESS_COMPUTER_APPROVALS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, tokio::sync::oneshot::Sender<bool>>>,
> = std::sync::OnceLock::new();
fn less_computer_approvals(
) -> &'static std::sync::Mutex<std::collections::HashMap<String, tokio::sync::oneshot::Sender<bool>>>
{
LESS_COMPUTER_APPROVALS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
/// 前端 `less_computer_approve` 命令调到这里:按 token 解析等待中的审批。
/// token 不存在(已超时 / 已解析)时静默忽略。
pub(crate) fn resolve_less_computer_approval(token: &str, approved: bool) {
let sender = less_computer_approvals()
.lock()
.ok()
.and_then(|mut m| m.remove(token));
if let Some(tx) = sender {
let _ = tx.send(approved);
log::info!("[less-computer] 审批 token={token} approved={approved}");
} else {
log::info!("[less-computer] 审批 token={token} 已失效(超时/重复)");
}
}
/// 往 Less Computer 浮窗发一条事件(macOS only;前端按 `kind` 渲染聊天结构)。
fn emit_less_computer(inner: &Arc<Inner>, payload: serde_json::Value) {
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit_to("less-computer", LESS_COMPUTER_EVENT, payload);
}
}
/// Less Computer 收尾:把转写当作指令交给无头 Claude,结果以胶囊展示(不插入到光标)。
pub(crate) async fn run_voice_agent_transcript(
inner: &Arc<Inner>,
_session_id: SessionId,
transcript: String,
elapsed: u64,
) -> Result<(), String> {
log::info!(
"[coord] Cloud Agent 语音:指令 {} 字",
transcript.chars().count()
);
// 胶囊保留「处理中」反馈(用户熟悉的小录音条状态机);聊天浮窗承载完整对话。
emit_capsule(
inner,
CapsuleState::Polishing,
0.0,
elapsed,
Some("Claude 处理中…".to_string()),
None,
);
// 聊天浮窗:显示窗口 + 落用户气泡(语音指令转写)。macOS only(helper 内部 gating)。
if let Some(app) = inner.app.lock().clone() {
crate::show_less_computer_window(&app);
// 全屏彩虹描边已在按下键时(handle_less_computer_pressed)点亮,这里不重复。
}
// 连续对话:浮窗里已有进行中的会话 → 本轮 `claude --continue` 续上下文;否则是新会话(fresh)。
// dismiss 关窗会把标志复位为 false。
let continue_session = inner
.less_computer_conversation
.swap(true, Ordering::SeqCst);
emit_less_computer(
inner,
serde_json::json!({ "kind": "user", "text": transcript, "fresh": !continue_session }),
);
let prefs = inner.prefs.get();
// 工作目录:用户设的 workdir,否则 $HOME。--add-dir 把文件作用域限定在此。
let cwd = prefs
.coding_agent_workdir
.clone()
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(std::path::PathBuf::from));
// 运行前 git 快照(cwd 是 git 仓库才有效;非仓库无副作用),便于回滚文件改动。
if let Some(dir) = &cwd {
if let Some(sha) = crate::coding_agent::create_git_snapshot(dir) {
log::info!("[less-computer] 运行前 git 快照 {sha}(git stash apply 可回滚)");
}
}
// 钳制:语音 → shell 这条全自动路径禁止 bypassPermissions 绕过护栏(无人审、动手即生效)。
// 即便用户在偏好里设了 bypass,这里也降级为 acceptEdits(仍带 deny 护栏)。
let mode = match coding_agent_mode_from_pref(&prefs.coding_agent_permission_mode) {
crate::coding_agent::CodingAgentPermissionMode::BypassPermissions => {
log::warn!(
"[less-computer] 语音 Agent 路径禁止 bypassPermissions,已降级为 acceptEdits(保留护栏)"
);
crate::coding_agent::CodingAgentPermissionMode::AcceptEdits
}
other => other,
};
let model = prefs
.coding_agent_model
.clone()
.filter(|m| !m.trim().is_empty())
.or_else(|| Some("sonnet".to_string()));
let prompt = crate::coding_agent::autonomous_prompt(&transcript);
// 第一轮:默认护栏(高风险全 deny)。运行后若检测到护栏拦截,弹审批卡;
// 用户 Approve 则在第二轮把该高风险模式从 deny 移除 + 加进 allowed,重跑一次。
let outcome = run_less_computer_once(
inner,
&prompt,
cwd.as_deref(),
mode,
model.as_deref(),
&[],
continue_session,
)
.await;
let final_outcome = match maybe_request_approval(inner, &outcome).await {
Some(approved_pattern) => {
log::info!("[less-computer] 审批通过,放行高风险模式后重跑:{approved_pattern}");
run_less_computer_once(
inner,
&prompt,
cwd.as_deref(),
mode,
model.as_deref(),
&[approved_pattern],
continue_session,
)
.await
}
None => outcome,
};
inner.state.lock().phase = SessionPhase::Idle;
// 工作结束:熄灭全屏彩虹描边(聊天浮窗保留,等用户读完/关闭)。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
match final_outcome {
LessComputerOutcome::Done { text, cost_usd } => {
let text = text.trim().to_string();
if text.is_empty() {
let msg = "Claude 无结果(确认已登录 claude 且额度充足)".to_string();
emit_less_computer(
inner,
serde_json::json!({ "kind": "error", "message": msg }),
);
emit_capsule(inner, CapsuleState::Error, 0.0, elapsed, Some(msg), None);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err("voice agent empty".to_string());
}
log::info!("[coord] Cloud Agent 语音:返回 {} 字", text.chars().count());
emit_less_computer(
inner,
serde_json::json!({ "kind": "completed", "text": text, "costUsd": cost_usd }),
);
emit_capsule(inner, CapsuleState::Done, 0.0, elapsed, Some(text), None);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Ok(())
}
LessComputerOutcome::Failed { message } => {
log::warn!("[coord] Cloud Agent 语音失败: {message}");
emit_less_computer(
inner,
serde_json::json!({ "kind": "error", "message": message }),
);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(message),
None,
);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Err("voice agent failed".to_string())
}
LessComputerOutcome::Cancelled => {
log::info!("[coord] Cloud Agent 语音已取消");
emit_less_computer(inner, serde_json::json!({ "kind": "cancelled" }));
emit_capsule(inner, CapsuleState::Cancelled, 0.0, elapsed, None, None);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Err("voice agent cancelled".to_string())
}
}
}
/// 一轮无头 Less Computer 运行的结果。
enum LessComputerOutcome {
Done { text: String, cost_usd: Option<f64> },
Failed { message: String },
Cancelled,
}
/// 跑一轮无头 Claude(「放行 + 护栏」),把 Delta/ToolUse 实时 stream 到聊天浮窗,
/// 终局收敛为 [`LessComputerOutcome`]。`extra_allow_patterns` 为审批通过后放行的
/// 高风险子串(如 "git push --force"):从 deny 清单剔除 + 作为 `Bash(<pat>:*)` 加进 allowed。
async fn run_less_computer_once(
inner: &Arc<Inner>,
prompt: &str,
cwd: Option<&std::path::Path>,
mode: crate::coding_agent::CodingAgentPermissionMode,
model: Option<&str>,
extra_allow_patterns: &[String],
continue_session: bool,
) -> LessComputerOutcome {
// 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。
// 审批 UI 只回传命中的单个高风险子串,但同一风险有等价写法(如 --force / -f)。
// 按「风险等价组」整组放行:只放行被点那一个会让等价写法仍卡在 deny(deny 优先级高于
// allow)→ 命令仍被拦。见 guard::risk_equivalent_patterns。
let mut deny = crate::coding_agent::guard::default_deny_rules();
let approved_patterns: Vec<String> = extra_allow_patterns
.iter()
.flat_map(|p| {
let group = crate::coding_agent::guard::risk_equivalent_patterns(p);
if group.is_empty() {
vec![p.clone()]
} else {
group.into_iter().map(|s| s.to_string()).collect()
}
})
.collect();
let allow_rules: Vec<String> = approved_patterns
.iter()
.map(|p| format!("Bash({p}:*)"))
.collect();
if !allow_rules.is_empty() {
deny.retain(|d| !allow_rules.iter().any(|a| a == d));
}
let settings_json = serde_json::json!({
"permissions": { "defaultMode": mode.as_cli_arg(), "deny": deny }
});
let settings_path = std::env::temp_dir().join(format!(
"openless-less-computer-guard-{}.json",
uuid::Uuid::new_v4()
));
// fail-closed:序列化或写入失败时立即中止,绝不在「无护栏」下把无效路径交给
// `claude -p --settings`(找不到文件 = 完全裸跑)。宁可不跑也不裸跑。
let settings_bytes = match serde_json::to_vec_pretty(&settings_json) {
Ok(b) => b,
Err(e) => {
log::warn!("[less-computer] 序列化护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
};
if let Err(e) = std::fs::write(&settings_path, settings_bytes) {
log::warn!("[less-computer] 写护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
let mut req = crate::coding_agent::CodingAgentRequest::new("less-computer", prompt.to_string());
req.cwd = cwd.map(|p| p.to_path_buf());
req.model = model.map(|m| m.to_string());
req.permission_mode = mode;
// 写护栏成功后才设置:写失败已在上面 fail-closed 返回,不会带无效路径裸跑。
req.settings_json_path = Some(settings_path.clone());
// 去掉 WebFetch:无出站白名单时它是 prompt 注入 SSRF 面(诱导拉取内网/元数据端点)。
// 保留 WebSearch(走搜索引擎,不直接抓任意 URL)。
req.allowed_tools = vec![
"Bash".into(),
"Read".into(),
"Edit".into(),
"Write".into(),
"Glob".into(),
"Grep".into(),
"WebSearch".into(),
];
req.allowed_tools.extend(allow_rules);
// 真实任务(开应用、多步操作、读写文件)常超过 120s/0.5$ → 老是「运行超时」。放宽到
// 5 分钟 / 2$,给多步任务足够空间;仍有硬上限兜底,不会无限跑/烧钱。
req.max_budget_usd = Some(2.0);
req.timeout_secs = 300;
// 连续对话需要保留会话:本轮保存(供下轮 --continue),第二轮起带 --continue 续上下文。
req.session_persistence = true;
req.continue_session = continue_session;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let cancel = Arc::new(AtomicBool::new(false));
let cancel_for_runner = Arc::clone(&cancel);
let run = async_runtime::spawn(async move {
crate::coding_agent::run_claude_agent("claude", req, tx, cancel_for_runner).await
});
let cancel_for_watcher = Arc::clone(&cancel);
let inner_for_cancel = Arc::clone(inner);
let cancel_watcher = async_runtime::spawn(async move {
loop {
if cancel_for_watcher.load(Ordering::Relaxed) {
return;
}
if inner_for_cancel.state.lock().cancelled {
cancel_for_watcher.store(true, Ordering::Relaxed);
return;
}
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
}
});
let mut final_text = String::new();
let mut cost_usd: Option<f64> = None;
let mut error_msg: Option<String> = None;
let mut cancelled = false;
while let Some(ev) = rx.recv().await {
use crate::coding_agent::CodingAgentEvent as E;
match ev {
E::Started { .. } => {
emit_less_computer(inner, serde_json::json!({ "kind": "started" }));
}
E::Delta { text, .. } => {
emit_less_computer(inner, serde_json::json!({ "kind": "delta", "text": text }));
}
E::ToolUse { name, .. } => {
emit_less_computer(inner, serde_json::json!({ "kind": "tool", "name": name }));
}
E::Completed {
text, cost_usd: c, ..
} => {
final_text = text;
cost_usd = c;
}
E::Error { message, .. } => error_msg = Some(message),
E::Cancelled { .. } => cancelled = true,
}
}
let run_result = run.await;
cancel.store(true, Ordering::Relaxed);
let _ = cancel_watcher.await;
let _ = std::fs::remove_file(&settings_path);
if cancelled
|| matches!(
&run_result,
Ok(Err(crate::coding_agent::CodingAgentError::Cancelled))
)
{
return LessComputerOutcome::Cancelled;
}
let trimmed = final_text.trim().to_string();
if !trimmed.is_empty() {
LessComputerOutcome::Done {
text: trimmed,
cost_usd,
}
} else {
let message = error_msg
.or_else(|| match run_result {
Ok(Err(e)) => Some(e.to_string()),
_ => None,
})
.unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string());
LessComputerOutcome::Failed { message }
}
}
/// 护栏拦截探测 + 内联审批(best-effort)。
///
/// 无头 `claude -p`(v2.1.165)没有 mid-run 的 `--permission-prompt-tool` 通道,所以
/// 我们只能在「一轮跑完」后判断护栏是否拦了高风险动作:扫描终局文本里是否提到某个
/// 高风险模式 + 权限/拒绝/blocked 关键词。命中则发 `approval` 事件、挂一个 oneshot 等
/// 用户决断(前端 Approve/Deny → `less_computer_approve` 命令解析)。
///
/// 返回 `Some(pattern)` 表示用户 Approve 了某高风险模式 → 调用方应放行该模式重跑一轮;
/// `None` 表示无需审批 / 用户 Deny / 超时。**注意**这是「重跑放行」而非真正的 mid-run
/// 续跑——headless 下没有干净的 mid-run round-trip,详见 report。
async fn maybe_request_approval(
inner: &Arc<Inner>,
outcome: &LessComputerOutcome,
) -> Option<String> {
let text = match outcome {
LessComputerOutcome::Done { text, .. } => text.as_str(),
LessComputerOutcome::Failed { message } => message.as_str(),
LessComputerOutcome::Cancelled => return None,
};
let lowered = text.to_lowercase();
// 必须同时出现「拒绝/权限/blocked」语义 + 某个已知高风险模式,才认为是护栏拦截,
// 避免把正常提到 "rm" 的回答误判成审批请求。
let mentions_block = [
"denied",
"permission",
"not allowed",
"blocked",
"拒绝",
"权限",
"被拦",
]
.iter()
.any(|kw| lowered.contains(kw));
if !mentions_block {
return None;
}
let hit = crate::coding_agent::guard::HIGH_RISK_PATTERNS
.iter()
.find(|(pat, _)| lowered.contains(*pat))?;
let (pattern, reason) = (hit.0.to_string(), hit.1.to_string());
// 挂 oneshot 等用户决断。
let token = uuid::Uuid::new_v4().to_string();
let (tx, rx) = tokio::sync::oneshot::channel::<bool>();
if let Ok(mut map) = less_computer_approvals().lock() {
map.insert(token.clone(), tx);
}
emit_less_computer(
inner,
serde_json::json!({
"kind": "approval",
"token": token,
"command": pattern,
"reason": reason,
}),
);
// 等用户点 Approve/Deny;90s 无响应按 Deny 处理并清理注册表项。
let approved = match tokio::time::timeout(std::time::Duration::from_secs(90), rx).await {
Ok(Ok(v)) => v,
_ => {
less_computer_approvals()
.lock()
.ok()
.map(|mut m| m.remove(&token));
false
}
};
if approved {
Some(pattern)
} else {
None
}
}
/// 把 prefs 里的权限模式字符串映射成枚举;未知值回落到 acceptEdits(放行+护栏的默认)。
fn coding_agent_mode_from_pref(s: &str) -> crate::coding_agent::CodingAgentPermissionMode {
use crate::coding_agent::CodingAgentPermissionMode as M;
match s.trim() {
"plan" => M::Plan,
"default" => M::Default,
"bypassPermissions" => M::BypassPermissions,
_ => M::AcceptEdits,
}
}
@@ -1,4 +1,4 @@
use super::dictation::abort_recording_with_error; use super::dictation_session::abort_recording_with_error;
use super::*; use super::*;
use crate::types::{HotkeyMode, HotkeyTrigger}; use crate::types::{HotkeyMode, HotkeyTrigger};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;