refactor(coordinator): 抽出 qa_session 子模块
机械式行为保持拆分:从 coordinator.rs 搬出划词语音问答会话生命周期 —— QA 录音 error 监听、begin/end_qa_session、错误/静默/取消收尾。与 qa.rs(热键/面板状态)互补。 end_qa_session 跨 await 持/放 qa_state 锁、驱动 SSE 流与窗口焦点 marshaling 原样保留。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a21e46cbb9
commit
682603077a
2 files changed
+692
-680
No files matched your search
@@ -68,6 +68,7 @@ mod hotkey_supervisors;
|
||||
mod ime_insertion;
|
||||
mod llm_pipeline;
|
||||
mod qa;
|
||||
mod qa_session;
|
||||
mod resources;
|
||||
mod voice_agent_hotkeys;
|
||||
|
||||
@@ -78,6 +79,7 @@ pub(crate) use capsule::*;
|
||||
pub(crate) use hotkey_supervisors::*;
|
||||
pub(crate) use ime_insertion::*;
|
||||
pub(crate) use llm_pipeline::*;
|
||||
pub(crate) use qa_session::*;
|
||||
pub(crate) use voice_agent_hotkeys::*;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1102,33 +1104,6 @@ impl Coordinator {
|
||||
|
||||
// ─────────────────────────── session lifecycle ───────────────────────────
|
||||
|
||||
/// QA 录音 runtime error 监听器。镜像 `spawn_recorder_error_monitor` 的语义但走 QA
|
||||
/// 收尾路径(`finish_qa_with_error` 替代 `abort_recording_with_error`)。
|
||||
/// 用 qa_state.session_id 守卫 stale 事件。详见 issue #168。
|
||||
fn spawn_qa_recorder_error_monitor(inner: &Arc<Inner>, rx: mpsc::Receiver<RecorderError>) {
|
||||
let captured_session_id = inner.qa_state.lock().session_id;
|
||||
let inner = Arc::clone(inner);
|
||||
std::thread::Builder::new()
|
||||
.name("openless-qa-recorder-error-monitor".into())
|
||||
.spawn(move || {
|
||||
if let Ok(err) = rx.recv() {
|
||||
let current_session_id = inner.qa_state.lock().session_id;
|
||||
if captured_session_id != current_session_id {
|
||||
log::warn!(
|
||||
"[coord] QA recorder error from stale session {} dropped (current={}, err={})",
|
||||
captured_session_id,
|
||||
current_session_id,
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
log::error!("[coord] QA recorder runtime error: {err}");
|
||||
finish_qa_with_error(&inner, format!("录音设备异常: {err}"));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// ─────────────────────────── helpers ───────────────────────────
|
||||
|
||||
#[cfg(any(debug_assertions, test))]
|
||||
@@ -1148,659 +1123,6 @@ fn debug_transcript_override_text() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── QA session lifecycle ───────────────────────────
|
||||
|
||||
/// 划词语音问答会话(issue #118)。
|
||||
///
|
||||
/// 与 dictation 完全分离:
|
||||
/// - 不进 SessionPhase(互不抢锁)
|
||||
/// - 不写 history.json(除非 prefs.qa_save_history=true 才旁路写一条 placeholder)
|
||||
/// - 用独立的 qa_recorder + qa_asr,复用现有 Volcengine ASR 通路
|
||||
async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
if !state.panel_visible {
|
||||
// 防御:浮窗没开就被叫到这里说明路由错了,直接退出。
|
||||
return Ok(());
|
||||
}
|
||||
if state.phase != QaPhase::Idle {
|
||||
return Ok(());
|
||||
}
|
||||
state.phase = QaPhase::Recording;
|
||||
state.cancelled = false;
|
||||
state.session_id = new_session_id();
|
||||
state.front_app = capture_frontmost_app();
|
||||
state.selection = None;
|
||||
}
|
||||
// 重置 SSE 取消标志:上一轮可能 set 过的 true 留着会让本轮流式立即 break。
|
||||
inner.qa_stream_cancelled.store(false, Ordering::SeqCst);
|
||||
|
||||
// 抓选区。每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。
|
||||
//
|
||||
// - macOS:浮窗走 orderFrontRegardless,不成为 key window,原 app 仍是 frontmost,
|
||||
// AX/Cmd+C fallback 都能拿到。
|
||||
// - Windows:#466 修复后 show_qa_window_no_activate 主动抓焦点,QA 此刻已是前台,
|
||||
// simulate_copy 会跑在 QA 自己 webview 上 → 抓不到。focus-dance 上半场:把焦点临时
|
||||
// 还给"用户原 app 的 HWND"。
|
||||
//
|
||||
// 多轮场景的目标刷新:用户开 QA 后可能 Alt+Tab 切到别的 app 选新文字。如果还死认
|
||||
// open_qa_panel 时记下的初始 HWND,会把焦点抢回错的 app(pr_agent stale-focus 关注点)。
|
||||
// 策略:每轮先看当前前台是不是本进程的窗口(QA / capsule / main)—— 是 → 用户没切
|
||||
// 走,沿用 saved;不是 → 用户切到了真正的外部 app,刷新 saved 为当前 HWND。
|
||||
// 抓完选区后下半场再把焦点交还 QA,让 ESC/X 继续可用。
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 合并两次 lock:原来分 lock #1 写 + lock #2 读,两者之间 close_qa_panel 在别的
|
||||
// 线程把 qa_focus_target 清成 None 会被覆盖回旧 HWND。Cloud 评审指出的 TOCTOU。
|
||||
// 单次加锁里既写最新外部前台、再读出来交给后面的 restore_focus_target_if_possible
|
||||
// —— capture_external_focus_target() 内部只调 GetForegroundWindow / pid 查询,
|
||||
// 不会反向取 qa_state 锁,持锁期间调用安全。
|
||||
let saved_target = {
|
||||
let mut state = inner.qa_state.lock();
|
||||
if let Some(current_external) = capture_external_focus_target() {
|
||||
state.qa_focus_target = Some(current_external);
|
||||
}
|
||||
state.qa_focus_target
|
||||
};
|
||||
let _ = restore_focus_target_if_possible(saved_target);
|
||||
}
|
||||
let selection = capture_selection();
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
crate::refocus_qa_window(&app);
|
||||
}
|
||||
let selection_preview_text = selection.as_ref().map(|s| s.text.clone());
|
||||
inner.qa_state.lock().selection = selection.clone();
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "recording",
|
||||
"selection_preview": selection_preview_text,
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. QA 与 dictation 使用同一个 active ASR 入口。不要回退火山,否则用户配置
|
||||
// 百炼 / Whisper / 本地 ASR 后,浮窗仍会偷偷走另一套凭据。
|
||||
let active_asr = CredentialsVault::get_active_asr();
|
||||
if let Err(message) = ensure_asr_credentials() {
|
||||
log::warn!("[coord] QA: active ASR credentials missing: {message}");
|
||||
finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}"));
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
if let Err(message) = ensure_microphone_permission(inner) {
|
||||
log::warn!("[coord] QA: microphone permission gate failed: {message}");
|
||||
finish_qa_with_error(inner, message.clone());
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
let qa_asr = match build_qa_asr_start(inner, &active_asr).await {
|
||||
Ok(qa_asr) => qa_asr,
|
||||
Err(message) => {
|
||||
log::error!("[coord] QA active ASR init failed: {message}");
|
||||
finish_qa_with_error(inner, format!("ASR 初始化失败: {message}"));
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let consumer = qa_asr.recorder_consumer();
|
||||
*inner.qa_asr.lock() = Some(qa_asr.active_asr());
|
||||
|
||||
// QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图,
|
||||
// 这里发一份事件给 "qa" label 用就够了。
|
||||
let inner_for_level = Arc::clone(inner);
|
||||
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
|
||||
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
|
||||
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
|
||||
let phase = inner_for_level.qa_state.lock().phase;
|
||||
if phase != QaPhase::Recording {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
{
|
||||
let mut last = last_emit_at.lock();
|
||||
if let Some(prev) = *last {
|
||||
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
*last = Some(now);
|
||||
}
|
||||
if let Some(app) = inner_for_level.app.lock().clone() {
|
||||
let _ = app.emit_to("qa", "qa:level", serde_json::json!({ "level": level }));
|
||||
}
|
||||
// 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。
|
||||
emit_capsule(
|
||||
&inner_for_level,
|
||||
CapsuleState::Recording,
|
||||
level,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
});
|
||||
|
||||
let microphone_device_name = selected_microphone_device_name(inner);
|
||||
stop_microphone_preview_monitor(inner, "QA recorder");
|
||||
acquire_recording_mute(inner, "qa").await;
|
||||
// QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。
|
||||
// 调试 QA 麦克风请用主听写路径。
|
||||
match Recorder::start(microphone_device_name, consumer, level_handler, None) {
|
||||
Ok((rec, runtime_errors, archive_active)) => {
|
||||
// QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation
|
||||
// 接力时读到上一个 QA session 的过期值。
|
||||
inner
|
||||
.audio_archive_active
|
||||
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
|
||||
*inner.qa_recorder.lock() = Some(rec);
|
||||
// QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时
|
||||
// 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。
|
||||
spawn_qa_recorder_error_monitor(inner, runtime_errors);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[coord] QA recorder start failed: {e}");
|
||||
if let Some(asr) = inner.qa_asr.lock().take() {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
release_recording_mute(inner, "qa");
|
||||
finish_qa_with_error(inner, format!("录音启动失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = qa_asr.open_streaming_session().await {
|
||||
log::error!("[coord] QA: open ASR session failed: {e}");
|
||||
stop_qa_recorder(inner);
|
||||
if let Some(asr) = inner.qa_asr.lock().take() {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
finish_qa_with_error(inner, format!("ASR 连接失败: {e}"));
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// cancel race:在 await 期间用户可能 dismiss 了浮窗。
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!("[coord] QA cancel raced during open_session — aborting begin");
|
||||
if let Some(asr) = inner.qa_asr.lock().take() {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
stop_qa_recorder(inner);
|
||||
inner.qa_state.lock().phase = QaPhase::Idle;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…"
|
||||
// 的视觉反馈跟主听写完全一致。
|
||||
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
if state.phase != QaPhase::Recording {
|
||||
return Ok(());
|
||||
}
|
||||
state.phase = QaPhase::Processing;
|
||||
}
|
||||
|
||||
// 胶囊进入 Transcribing:用户视觉上看到"识别中"。
|
||||
emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None);
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let _ = app.emit_to("qa", "qa:state", serde_json::json!({ "kind": "loading" }));
|
||||
}
|
||||
|
||||
stop_qa_recorder(inner);
|
||||
|
||||
let asr = match inner.qa_asr.lock().take() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
inner.qa_state.lock().phase = QaPhase::Idle;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
|
||||
let qa_session_id = inner.qa_state.lock().session_id;
|
||||
let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr);
|
||||
let raw = match asr {
|
||||
ActiveAsr::Volcengine(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] QA: send last frame failed: {e}");
|
||||
}
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] QA: await final failed: {e}");
|
||||
finish_qa_with_error(inner, format!("识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA: 全局超时 {} 秒 - 强制恢复",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
asr.cancel();
|
||||
finish_qa_with_error(inner, "识别超时".to_string());
|
||||
return Err("global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Bailian(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] QA: Bailian send last frame failed: {e}");
|
||||
}
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] QA: Bailian await final failed: {e}");
|
||||
finish_qa_with_error(inner, format!("识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA: Bailian 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
asr.cancel();
|
||||
finish_qa_with_error(inner, "识别超时".to_string());
|
||||
return Err("bailian global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Whisper(w) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, w.transcribe()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] QA: whisper transcribe failed: {e}");
|
||||
finish_qa_with_error(inner, format!("识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA: whisper 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
finish_qa_with_error(inner, "识别超时".to_string());
|
||||
return Err("whisper global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
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] QA: MiMo ASR transcribe failed: {e}");
|
||||
finish_qa_with_error(inner, format!("识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA: MiMo ASR 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
finish_qa_with_error(inner, "识别超时".to_string());
|
||||
return Err("mimo global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::FoundryLocalWhisper(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
match local
|
||||
.transcribe(foundry_audio_transcribe_timeout_duration())
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!(
|
||||
"[coord] QA Foundry Local Whisper transcribe cancelled — discarding transcript"
|
||||
);
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("[coord] QA Foundry Local Whisper transcribe failed: {e:#}");
|
||||
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::SherpaOnnxLocal(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
match local
|
||||
.transcribe(sherpa_audio_transcribe_timeout_duration())
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!(
|
||||
"[coord] QA sherpa-onnx transcribe cancelled — discarding transcript"
|
||||
);
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("[coord] QA sherpa-onnx transcribe failed: {e:#}");
|
||||
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
ActiveAsr::Local(local) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
||||
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
|
||||
log::info!(
|
||||
"[coord] QA local Qwen3-ASR transcribe: audio={:.2}s timeout={}s",
|
||||
audio_secs,
|
||||
timeout_duration.as_secs()
|
||||
);
|
||||
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
|
||||
inner.local_asr_cache.touch();
|
||||
schedule_local_asr_release(inner);
|
||||
match result {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] QA local Qwen3-ASR transcribe failed: {e:#}");
|
||||
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA local Qwen3-ASR transcribe timeout after {}s",
|
||||
timeout_duration.as_secs()
|
||||
);
|
||||
finish_qa_with_error(inner, "本地识别超时".to_string());
|
||||
return Err("local qwen transcribe timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// cancel race:用户在 transcribe 中按 Esc / dismiss → 静默退出。
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!("[coord] QA cancel detected after ASR — discarding transcript");
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let question = raw.text.trim().to_string();
|
||||
if question.is_empty() {
|
||||
// 静默录音:不调 LLM,不弹错误,直接关浮窗。
|
||||
log::info!("[coord] QA: empty transcript → silent dismiss");
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 拼这一轮的 user 消息:第一轮(messages 还空)把选区原文嵌进去;
|
||||
// 之后的轮次只送提问,让 LLM 顺着上下文回答。详见 issue #118 v2。
|
||||
let user_content = {
|
||||
let st = inner.qa_state.lock();
|
||||
let is_first_turn = st.messages.is_empty();
|
||||
let sel_text = st
|
||||
.selection
|
||||
.as_ref()
|
||||
.map(|s| s.text.clone())
|
||||
.unwrap_or_default();
|
||||
if is_first_turn && !sel_text.trim().is_empty() {
|
||||
format!(
|
||||
"# 选区原文\n{}\n\n# 我的问题\n{}",
|
||||
sel_text.trim(),
|
||||
question
|
||||
)
|
||||
} else {
|
||||
question.clone()
|
||||
}
|
||||
};
|
||||
|
||||
inner
|
||||
.qa_state
|
||||
.lock()
|
||||
.messages
|
||||
.push(crate::types::QaChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: user_content,
|
||||
});
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "thinking",
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 胶囊:思考阶段(复用 dictation 的 Polishing 状态——视觉上是"润色中",QA 借用一下)。
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None);
|
||||
|
||||
let prefs = inner.prefs.get();
|
||||
let working_languages = prefs.working_languages.clone();
|
||||
let chinese_script_preference = prefs.chinese_script_preference;
|
||||
let output_language_preference = prefs.output_language_preference;
|
||||
let llm_thinking_enabled = prefs.llm_thinking_enabled;
|
||||
let (messages_for_llm, front_app) = {
|
||||
let st = inner.qa_state.lock();
|
||||
(st.messages.clone(), st.front_app.clone())
|
||||
};
|
||||
|
||||
// 流式回调:每个 SSE delta 立刻推一帧 qa:state{kind:"answer_delta"} 给前端,
|
||||
// 浮窗里气泡边收边长。最终的 messages 由 answer 事件统一下发(保证一致性)。
|
||||
//
|
||||
// session_id 守卫(issue #161):闭包捕获本会话 id;用户取消 → 关浮窗 → 开新浮窗
|
||||
// 开新一轮时,旧的 in-flight LLM 流仍可能 emit chunk,必须在 emit 前比对当前
|
||||
// qa_state.session_id == 捕获 id,否则跳过——避免旧会话的字漏进新气泡。
|
||||
let captured_session_id = inner.qa_state.lock().session_id;
|
||||
let inner_for_delta = Arc::clone(inner);
|
||||
let on_delta = move |chunk: &str| {
|
||||
let cur_id = inner_for_delta.qa_state.lock().session_id;
|
||||
if cur_id != captured_session_id {
|
||||
return; // 旧 session 漏来的 chunk,丢弃
|
||||
}
|
||||
if let Some(app) = inner_for_delta.app.lock().clone() {
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "answer_delta",
|
||||
"chunk": chunk,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// SSE 流取消旗标:cancel_qa_session / close_qa_panel 会 set true,
|
||||
// polish 的 SSE loop 每帧检查 → break,释放 HTTP body。详见 issue #161。
|
||||
let cancel_flag = Arc::clone(&inner.qa_stream_cancelled);
|
||||
let should_cancel = move || cancel_flag.load(Ordering::Relaxed);
|
||||
|
||||
let answer = match answer_chat_dispatch(
|
||||
&messages_for_llm,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
on_delta,
|
||||
should_cancel,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("[coord] QA: LLM answer failed: {e}");
|
||||
// 把刚 push 的 user 消息回滚,避免 retry 重复
|
||||
inner.qa_state.lock().messages.pop();
|
||||
finish_qa_with_error(inner, format!("回答失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!("[coord] QA cancel detected before answer — discarding");
|
||||
// 同样回滚未配对的 user 消息
|
||||
inner.qa_state.lock().messages.pop();
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
inner
|
||||
.qa_state
|
||||
.lock()
|
||||
.messages
|
||||
.push(crate::types::QaChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: answer.clone(),
|
||||
});
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "answer",
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 胶囊直接收掉。QA 不走 insertion,没"已粘贴 N 字"语义;浮窗里答案就是用户的反馈。
|
||||
// (之前用 Done 状态会被 capsule UI 错误地渲染上一次 dictation 残留的 message/insertedChars。)
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
|
||||
// 可选:写一条 history(QA 类型)。当前 DictationSession schema 不能直接表达
|
||||
// "QuestionAnswer" 类型,因此简单做法:勾选 qa_save_history 时写一条
|
||||
// mode=Raw、error_code=Some("qaSession") 的 placeholder,避免污染 schema 同时
|
||||
// 让用户能在历史里翻到这次问答的字面值。详见 issue #118。
|
||||
if prefs.qa_save_history {
|
||||
let session = DictationSession {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
raw_transcript: question.clone(),
|
||||
final_text: answer.clone(),
|
||||
mode: PolishMode::Raw,
|
||||
style_pack_id: None,
|
||||
translation_active: false,
|
||||
polish_source: None,
|
||||
app_bundle_id: None,
|
||||
app_name: front_app.clone(),
|
||||
insert_status: InsertStatus::CopiedFallback,
|
||||
error_code: Some("qaSession".to_string()),
|
||||
duration_ms: Some(raw.duration_ms),
|
||||
dictionary_entry_count: None,
|
||||
has_audio_recording: None,
|
||||
};
|
||||
let prefs_snapshot = inner.prefs.get();
|
||||
if let Err(e) = inner.history.append_with_retention(
|
||||
session,
|
||||
prefs_snapshot.history_retention_days,
|
||||
prefs_snapshot.history_max_entries,
|
||||
) {
|
||||
log::error!("[coord] QA history append failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
inner.qa_state.lock().phase = QaPhase::Idle;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把出错状态送到前端浮窗 + 胶囊错误闪一下 + 复位 phase。
|
||||
/// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去
|
||||
/// 让前端继续渲染历史对话。
|
||||
fn finish_qa_with_error(inner: &Arc<Inner>, message: String) {
|
||||
stop_qa_recorder(inner);
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "error",
|
||||
"error": message,
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(message), None);
|
||||
schedule_capsule_idle(inner, 1500);
|
||||
let mut state = inner.qa_state.lock();
|
||||
state.phase = QaPhase::Idle;
|
||||
state.cancelled = false;
|
||||
}
|
||||
|
||||
/// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户
|
||||
/// Esc/X 或再按 QA hotkey 时才关);多轮对话历史保留。胶囊也即刻收掉。
|
||||
fn finish_qa_idle_silently(inner: &Arc<Inner>) {
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "idle",
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
let mut state = inner.qa_state.lock();
|
||||
state.phase = QaPhase::Idle;
|
||||
state.cancelled = false;
|
||||
state.selection = None;
|
||||
}
|
||||
|
||||
fn cancel_qa_session(inner: &Arc<Inner>) {
|
||||
let phase = inner.qa_state.lock().phase;
|
||||
if phase == QaPhase::Idle {
|
||||
return;
|
||||
}
|
||||
inner.qa_state.lock().cancelled = true;
|
||||
// SSE 流取消旗标——polish::chat_completion_history_streaming 的 loop 每帧检查
|
||||
// 这个 flag,true 时立即 break 不再 drain HTTP body,避免取消后 LLM 仍烧 token。
|
||||
// 详见 issue #161。
|
||||
inner.qa_stream_cancelled.store(true, Ordering::SeqCst);
|
||||
stop_qa_recorder(inner);
|
||||
if let Some(asr) = inner.qa_asr.lock().take() {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
// Processing 阶段保持 phase 让 end_qa_session 自然走完 cancel 检查;
|
||||
// 否则直接复位。
|
||||
if phase != QaPhase::Processing {
|
||||
inner.qa_state.lock().phase = QaPhase::Idle;
|
||||
}
|
||||
log::info!("[coord] QA session cancelled (was {phase:?})");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::dictation::abort_recording_with_error;
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
//! 划词语音问答(QA)会话生命周期:录音 error 监听、begin/end、错误/静默/取消收尾。
|
||||
//!
|
||||
//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。与 `qa.rs`(热键/面板状态)
|
||||
//! 互补:本文件持 QA 流程主体。end_qa_session 跨 await 持/放 qa_state 锁、驱动 SSE
|
||||
//! 流与窗口焦点 marshaling,是全文件最并发敏感的函数,只搬不改。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// QA 录音 runtime error 监听器。镜像 `spawn_recorder_error_monitor` 的语义但走 QA
|
||||
/// 收尾路径(`finish_qa_with_error` 替代 `abort_recording_with_error`)。
|
||||
/// 用 qa_state.session_id 守卫 stale 事件。详见 issue #168。
|
||||
pub(crate) fn spawn_qa_recorder_error_monitor(
|
||||
inner: &Arc<Inner>,
|
||||
rx: mpsc::Receiver<RecorderError>,
|
||||
) {
|
||||
let captured_session_id = inner.qa_state.lock().session_id;
|
||||
let inner = Arc::clone(inner);
|
||||
std::thread::Builder::new()
|
||||
.name("openless-qa-recorder-error-monitor".into())
|
||||
.spawn(move || {
|
||||
if let Ok(err) = rx.recv() {
|
||||
let current_session_id = inner.qa_state.lock().session_id;
|
||||
if captured_session_id != current_session_id {
|
||||
log::warn!(
|
||||
"[coord] QA recorder error from stale session {} dropped (current={}, err={})",
|
||||
captured_session_id,
|
||||
current_session_id,
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
log::error!("[coord] QA recorder runtime error: {err}");
|
||||
finish_qa_with_error(&inner, format!("录音设备异常: {err}"));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// ─────────────────────────── QA session lifecycle ───────────────────────────
|
||||
|
||||
/// 划词语音问答会话(issue #118)。
|
||||
///
|
||||
/// 与 dictation 完全分离:
|
||||
/// - 不进 SessionPhase(互不抢锁)
|
||||
/// - 不写 history.json(除非 prefs.qa_save_history=true 才旁路写一条 placeholder)
|
||||
/// - 用独立的 qa_recorder + qa_asr,复用现有 Volcengine ASR 通路
|
||||
pub(crate) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
if !state.panel_visible {
|
||||
// 防御:浮窗没开就被叫到这里说明路由错了,直接退出。
|
||||
return Ok(());
|
||||
}
|
||||
if state.phase != QaPhase::Idle {
|
||||
return Ok(());
|
||||
}
|
||||
state.phase = QaPhase::Recording;
|
||||
state.cancelled = false;
|
||||
state.session_id = new_session_id();
|
||||
state.front_app = capture_frontmost_app();
|
||||
state.selection = None;
|
||||
}
|
||||
// 重置 SSE 取消标志:上一轮可能 set 过的 true 留着会让本轮流式立即 break。
|
||||
inner.qa_stream_cancelled.store(false, Ordering::SeqCst);
|
||||
|
||||
// 抓选区。每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。
|
||||
//
|
||||
// - macOS:浮窗走 orderFrontRegardless,不成为 key window,原 app 仍是 frontmost,
|
||||
// AX/Cmd+C fallback 都能拿到。
|
||||
// - Windows:#466 修复后 show_qa_window_no_activate 主动抓焦点,QA 此刻已是前台,
|
||||
// simulate_copy 会跑在 QA 自己 webview 上 → 抓不到。focus-dance 上半场:把焦点临时
|
||||
// 还给"用户原 app 的 HWND"。
|
||||
//
|
||||
// 多轮场景的目标刷新:用户开 QA 后可能 Alt+Tab 切到别的 app 选新文字。如果还死认
|
||||
// open_qa_panel 时记下的初始 HWND,会把焦点抢回错的 app(pr_agent stale-focus 关注点)。
|
||||
// 策略:每轮先看当前前台是不是本进程的窗口(QA / capsule / main)—— 是 → 用户没切
|
||||
// 走,沿用 saved;不是 → 用户切到了真正的外部 app,刷新 saved 为当前 HWND。
|
||||
// 抓完选区后下半场再把焦点交还 QA,让 ESC/X 继续可用。
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 合并两次 lock:原来分 lock #1 写 + lock #2 读,两者之间 close_qa_panel 在别的
|
||||
// 线程把 qa_focus_target 清成 None 会被覆盖回旧 HWND。Cloud 评审指出的 TOCTOU。
|
||||
// 单次加锁里既写最新外部前台、再读出来交给后面的 restore_focus_target_if_possible
|
||||
// —— capture_external_focus_target() 内部只调 GetForegroundWindow / pid 查询,
|
||||
// 不会反向取 qa_state 锁,持锁期间调用安全。
|
||||
let saved_target = {
|
||||
let mut state = inner.qa_state.lock();
|
||||
if let Some(current_external) = capture_external_focus_target() {
|
||||
state.qa_focus_target = Some(current_external);
|
||||
}
|
||||
state.qa_focus_target
|
||||
};
|
||||
let _ = restore_focus_target_if_possible(saved_target);
|
||||
}
|
||||
let selection = capture_selection();
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
crate::refocus_qa_window(&app);
|
||||
}
|
||||
let selection_preview_text = selection.as_ref().map(|s| s.text.clone());
|
||||
inner.qa_state.lock().selection = selection.clone();
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "recording",
|
||||
"selection_preview": selection_preview_text,
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. QA 与 dictation 使用同一个 active ASR 入口。不要回退火山,否则用户配置
|
||||
// 百炼 / Whisper / 本地 ASR 后,浮窗仍会偷偷走另一套凭据。
|
||||
let active_asr = CredentialsVault::get_active_asr();
|
||||
if let Err(message) = ensure_asr_credentials() {
|
||||
log::warn!("[coord] QA: active ASR credentials missing: {message}");
|
||||
finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}"));
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
if let Err(message) = ensure_microphone_permission(inner) {
|
||||
log::warn!("[coord] QA: microphone permission gate failed: {message}");
|
||||
finish_qa_with_error(inner, message.clone());
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
let qa_asr = match build_qa_asr_start(inner, &active_asr).await {
|
||||
Ok(qa_asr) => qa_asr,
|
||||
Err(message) => {
|
||||
log::error!("[coord] QA active ASR init failed: {message}");
|
||||
finish_qa_with_error(inner, format!("ASR 初始化失败: {message}"));
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let consumer = qa_asr.recorder_consumer();
|
||||
*inner.qa_asr.lock() = Some(qa_asr.active_asr());
|
||||
|
||||
// QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图,
|
||||
// 这里发一份事件给 "qa" label 用就够了。
|
||||
let inner_for_level = Arc::clone(inner);
|
||||
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
|
||||
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
|
||||
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
|
||||
let phase = inner_for_level.qa_state.lock().phase;
|
||||
if phase != QaPhase::Recording {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
{
|
||||
let mut last = last_emit_at.lock();
|
||||
if let Some(prev) = *last {
|
||||
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
*last = Some(now);
|
||||
}
|
||||
if let Some(app) = inner_for_level.app.lock().clone() {
|
||||
let _ = app.emit_to("qa", "qa:level", serde_json::json!({ "level": level }));
|
||||
}
|
||||
// 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。
|
||||
emit_capsule(
|
||||
&inner_for_level,
|
||||
CapsuleState::Recording,
|
||||
level,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
});
|
||||
|
||||
let microphone_device_name = selected_microphone_device_name(inner);
|
||||
stop_microphone_preview_monitor(inner, "QA recorder");
|
||||
acquire_recording_mute(inner, "qa").await;
|
||||
// QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。
|
||||
// 调试 QA 麦克风请用主听写路径。
|
||||
match Recorder::start(microphone_device_name, consumer, level_handler, None) {
|
||||
Ok((rec, runtime_errors, archive_active)) => {
|
||||
// QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation
|
||||
// 接力时读到上一个 QA session 的过期值。
|
||||
inner
|
||||
.audio_archive_active
|
||||
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
|
||||
*inner.qa_recorder.lock() = Some(rec);
|
||||
// QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时
|
||||
// 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。
|
||||
spawn_qa_recorder_error_monitor(inner, runtime_errors);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[coord] QA recorder start failed: {e}");
|
||||
if let Some(asr) = inner.qa_asr.lock().take() {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
release_recording_mute(inner, "qa");
|
||||
finish_qa_with_error(inner, format!("录音启动失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = qa_asr.open_streaming_session().await {
|
||||
log::error!("[coord] QA: open ASR session failed: {e}");
|
||||
stop_qa_recorder(inner);
|
||||
if let Some(asr) = inner.qa_asr.lock().take() {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
finish_qa_with_error(inner, format!("ASR 连接失败: {e}"));
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// cancel race:在 await 期间用户可能 dismiss 了浮窗。
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!("[coord] QA cancel raced during open_session — aborting begin");
|
||||
if let Some(asr) = inner.qa_asr.lock().take() {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
stop_qa_recorder(inner);
|
||||
inner.qa_state.lock().phase = QaPhase::Idle;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…"
|
||||
// 的视觉反馈跟主听写完全一致。
|
||||
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
if state.phase != QaPhase::Recording {
|
||||
return Ok(());
|
||||
}
|
||||
state.phase = QaPhase::Processing;
|
||||
}
|
||||
|
||||
// 胶囊进入 Transcribing:用户视觉上看到"识别中"。
|
||||
emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None);
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let _ = app.emit_to("qa", "qa:state", serde_json::json!({ "kind": "loading" }));
|
||||
}
|
||||
|
||||
stop_qa_recorder(inner);
|
||||
|
||||
let asr = match inner.qa_asr.lock().take() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
inner.qa_state.lock().phase = QaPhase::Idle;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
|
||||
let qa_session_id = inner.qa_state.lock().session_id;
|
||||
let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr);
|
||||
let raw = match asr {
|
||||
ActiveAsr::Volcengine(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] QA: send last frame failed: {e}");
|
||||
}
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] QA: await final failed: {e}");
|
||||
finish_qa_with_error(inner, format!("识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA: 全局超时 {} 秒 - 强制恢复",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
asr.cancel();
|
||||
finish_qa_with_error(inner, "识别超时".to_string());
|
||||
return Err("global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Bailian(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] QA: Bailian send last frame failed: {e}");
|
||||
}
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] QA: Bailian await final failed: {e}");
|
||||
finish_qa_with_error(inner, format!("识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA: Bailian 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
asr.cancel();
|
||||
finish_qa_with_error(inner, "识别超时".to_string());
|
||||
return Err("bailian global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Whisper(w) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, w.transcribe()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] QA: whisper transcribe failed: {e}");
|
||||
finish_qa_with_error(inner, format!("识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA: whisper 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
finish_qa_with_error(inner, "识别超时".to_string());
|
||||
return Err("whisper global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
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] QA: MiMo ASR transcribe failed: {e}");
|
||||
finish_qa_with_error(inner, format!("识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA: MiMo ASR 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
finish_qa_with_error(inner, "识别超时".to_string());
|
||||
return Err("mimo global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::FoundryLocalWhisper(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
match local
|
||||
.transcribe(foundry_audio_transcribe_timeout_duration())
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!(
|
||||
"[coord] QA Foundry Local Whisper transcribe cancelled — discarding transcript"
|
||||
);
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("[coord] QA Foundry Local Whisper transcribe failed: {e:#}");
|
||||
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::SherpaOnnxLocal(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
match local
|
||||
.transcribe(sherpa_audio_transcribe_timeout_duration())
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!(
|
||||
"[coord] QA sherpa-onnx transcribe cancelled — discarding transcript"
|
||||
);
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("[coord] QA sherpa-onnx transcribe failed: {e:#}");
|
||||
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
ActiveAsr::Local(local) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
||||
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
|
||||
log::info!(
|
||||
"[coord] QA local Qwen3-ASR transcribe: audio={:.2}s timeout={}s",
|
||||
audio_secs,
|
||||
timeout_duration.as_secs()
|
||||
);
|
||||
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
|
||||
inner.local_asr_cache.touch();
|
||||
schedule_local_asr_release(inner);
|
||||
match result {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] QA local Qwen3-ASR transcribe failed: {e:#}");
|
||||
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] QA local Qwen3-ASR transcribe timeout after {}s",
|
||||
timeout_duration.as_secs()
|
||||
);
|
||||
finish_qa_with_error(inner, "本地识别超时".to_string());
|
||||
return Err("local qwen transcribe timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// cancel race:用户在 transcribe 中按 Esc / dismiss → 静默退出。
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!("[coord] QA cancel detected after ASR — discarding transcript");
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let question = raw.text.trim().to_string();
|
||||
if question.is_empty() {
|
||||
// 静默录音:不调 LLM,不弹错误,直接关浮窗。
|
||||
log::info!("[coord] QA: empty transcript → silent dismiss");
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 拼这一轮的 user 消息:第一轮(messages 还空)把选区原文嵌进去;
|
||||
// 之后的轮次只送提问,让 LLM 顺着上下文回答。详见 issue #118 v2。
|
||||
let user_content = {
|
||||
let st = inner.qa_state.lock();
|
||||
let is_first_turn = st.messages.is_empty();
|
||||
let sel_text = st
|
||||
.selection
|
||||
.as_ref()
|
||||
.map(|s| s.text.clone())
|
||||
.unwrap_or_default();
|
||||
if is_first_turn && !sel_text.trim().is_empty() {
|
||||
format!(
|
||||
"# 选区原文\n{}\n\n# 我的问题\n{}",
|
||||
sel_text.trim(),
|
||||
question
|
||||
)
|
||||
} else {
|
||||
question.clone()
|
||||
}
|
||||
};
|
||||
|
||||
inner
|
||||
.qa_state
|
||||
.lock()
|
||||
.messages
|
||||
.push(crate::types::QaChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: user_content,
|
||||
});
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "thinking",
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 胶囊:思考阶段(复用 dictation 的 Polishing 状态——视觉上是"润色中",QA 借用一下)。
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None);
|
||||
|
||||
let prefs = inner.prefs.get();
|
||||
let working_languages = prefs.working_languages.clone();
|
||||
let chinese_script_preference = prefs.chinese_script_preference;
|
||||
let output_language_preference = prefs.output_language_preference;
|
||||
let llm_thinking_enabled = prefs.llm_thinking_enabled;
|
||||
let (messages_for_llm, front_app) = {
|
||||
let st = inner.qa_state.lock();
|
||||
(st.messages.clone(), st.front_app.clone())
|
||||
};
|
||||
|
||||
// 流式回调:每个 SSE delta 立刻推一帧 qa:state{kind:"answer_delta"} 给前端,
|
||||
// 浮窗里气泡边收边长。最终的 messages 由 answer 事件统一下发(保证一致性)。
|
||||
//
|
||||
// session_id 守卫(issue #161):闭包捕获本会话 id;用户取消 → 关浮窗 → 开新浮窗
|
||||
// 开新一轮时,旧的 in-flight LLM 流仍可能 emit chunk,必须在 emit 前比对当前
|
||||
// qa_state.session_id == 捕获 id,否则跳过——避免旧会话的字漏进新气泡。
|
||||
let captured_session_id = inner.qa_state.lock().session_id;
|
||||
let inner_for_delta = Arc::clone(inner);
|
||||
let on_delta = move |chunk: &str| {
|
||||
let cur_id = inner_for_delta.qa_state.lock().session_id;
|
||||
if cur_id != captured_session_id {
|
||||
return; // 旧 session 漏来的 chunk,丢弃
|
||||
}
|
||||
if let Some(app) = inner_for_delta.app.lock().clone() {
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "answer_delta",
|
||||
"chunk": chunk,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// SSE 流取消旗标:cancel_qa_session / close_qa_panel 会 set true,
|
||||
// polish 的 SSE loop 每帧检查 → break,释放 HTTP body。详见 issue #161。
|
||||
let cancel_flag = Arc::clone(&inner.qa_stream_cancelled);
|
||||
let should_cancel = move || cancel_flag.load(Ordering::Relaxed);
|
||||
|
||||
let answer = match answer_chat_dispatch(
|
||||
&messages_for_llm,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
on_delta,
|
||||
should_cancel,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("[coord] QA: LLM answer failed: {e}");
|
||||
// 把刚 push 的 user 消息回滚,避免 retry 重复
|
||||
inner.qa_state.lock().messages.pop();
|
||||
finish_qa_with_error(inner, format!("回答失败: {e}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
if inner.qa_state.lock().cancelled {
|
||||
log::info!("[coord] QA cancel detected before answer — discarding");
|
||||
// 同样回滚未配对的 user 消息
|
||||
inner.qa_state.lock().messages.pop();
|
||||
finish_qa_idle_silently(inner);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
inner
|
||||
.qa_state
|
||||
.lock()
|
||||
.messages
|
||||
.push(crate::types::QaChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: answer.clone(),
|
||||
});
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "answer",
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 胶囊直接收掉。QA 不走 insertion,没"已粘贴 N 字"语义;浮窗里答案就是用户的反馈。
|
||||
// (之前用 Done 状态会被 capsule UI 错误地渲染上一次 dictation 残留的 message/insertedChars。)
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
|
||||
// 可选:写一条 history(QA 类型)。当前 DictationSession schema 不能直接表达
|
||||
// "QuestionAnswer" 类型,因此简单做法:勾选 qa_save_history 时写一条
|
||||
// mode=Raw、error_code=Some("qaSession") 的 placeholder,避免污染 schema 同时
|
||||
// 让用户能在历史里翻到这次问答的字面值。详见 issue #118。
|
||||
if prefs.qa_save_history {
|
||||
let session = DictationSession {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
raw_transcript: question.clone(),
|
||||
final_text: answer.clone(),
|
||||
mode: PolishMode::Raw,
|
||||
style_pack_id: None,
|
||||
translation_active: false,
|
||||
polish_source: None,
|
||||
app_bundle_id: None,
|
||||
app_name: front_app.clone(),
|
||||
insert_status: InsertStatus::CopiedFallback,
|
||||
error_code: Some("qaSession".to_string()),
|
||||
duration_ms: Some(raw.duration_ms),
|
||||
dictionary_entry_count: None,
|
||||
has_audio_recording: None,
|
||||
};
|
||||
let prefs_snapshot = inner.prefs.get();
|
||||
if let Err(e) = inner.history.append_with_retention(
|
||||
session,
|
||||
prefs_snapshot.history_retention_days,
|
||||
prefs_snapshot.history_max_entries,
|
||||
) {
|
||||
log::error!("[coord] QA history append failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
inner.qa_state.lock().phase = QaPhase::Idle;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把出错状态送到前端浮窗 + 胶囊错误闪一下 + 复位 phase。
|
||||
/// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去
|
||||
/// 让前端继续渲染历史对话。
|
||||
pub(crate) fn finish_qa_with_error(inner: &Arc<Inner>, message: String) {
|
||||
stop_qa_recorder(inner);
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "error",
|
||||
"error": message,
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(message), None);
|
||||
schedule_capsule_idle(inner, 1500);
|
||||
let mut state = inner.qa_state.lock();
|
||||
state.phase = QaPhase::Idle;
|
||||
state.cancelled = false;
|
||||
}
|
||||
|
||||
/// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户
|
||||
/// Esc/X 或再按 QA hotkey 时才关);多轮对话历史保留。胶囊也即刻收掉。
|
||||
pub(crate) fn finish_qa_idle_silently(inner: &Arc<Inner>) {
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let messages = inner.qa_state.lock().messages.clone();
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "idle",
|
||||
"messages": messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
let mut state = inner.qa_state.lock();
|
||||
state.phase = QaPhase::Idle;
|
||||
state.cancelled = false;
|
||||
state.selection = None;
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_qa_session(inner: &Arc<Inner>) {
|
||||
let phase = inner.qa_state.lock().phase;
|
||||
if phase == QaPhase::Idle {
|
||||
return;
|
||||
}
|
||||
inner.qa_state.lock().cancelled = true;
|
||||
// SSE 流取消旗标——polish::chat_completion_history_streaming 的 loop 每帧检查
|
||||
// 这个 flag,true 时立即 break 不再 drain HTTP body,避免取消后 LLM 仍烧 token。
|
||||
// 详见 issue #161。
|
||||
inner.qa_stream_cancelled.store(true, Ordering::SeqCst);
|
||||
stop_qa_recorder(inner);
|
||||
if let Some(asr) = inner.qa_asr.lock().take() {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
// Processing 阶段保持 phase 让 end_qa_session 自然走完 cancel 检查;
|
||||
// 否则直接复位。
|
||||
if phase != QaPhase::Processing {
|
||||
inner.qa_state.lock().phase = QaPhase::Idle;
|
||||
}
|
||||
log::info!("[coord] QA session cancelled (was {phase:?})");
|
||||
}
|
||||
Reference in new issue
Block a user