Merge pull request #378 from H-Chris233/fix/issue-296-coordinator-modules

拆分 coordinator 子状态机模块
This commit is contained in:
Chris233 authored and GitHub committed 2026-05-09 16:16:21 +08:00
commit feadbfa70b
4 files changed
+1394 -1335

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,124 @@
use std::sync::Arc;
use tauri::Emitter;
use crate::coordinator_state::{initial_session_id, SessionId};
use crate::selection::SelectionContext;
use crate::types::CapsuleState;
use super::{
begin_qa_session, cancel_qa_session, capture_frontmost_app, emit_capsule, end_qa_session, Inner,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum QaPhase {
Idle,
Recording,
Processing,
}
pub(super) struct QaSessionState {
pub(super) phase: QaPhase,
pub(super) cancelled: bool,
pub(super) selection: Option<SelectionContext>,
pub(super) front_app: Option<String>,
/// 用于忽略迟到的 RMS / runtime error。
pub(super) session_id: SessionId,
/// QA 浮窗是否被用户钉住(pinned)。pinned=true 时不自动隐藏。
pub(super) pinned: bool,
/// 浮窗是否对用户可见。Cmd+Shift+; 边沿 toggle 此 flag;
/// 主听写 hotkey(rightOption)边沿来时,看这个 flag 决定是走 QA 还是走 dictation。
/// 详见 issue #118 v2。
pub(super) panel_visible: bool,
/// 多轮对话累积。每轮 user→assistant 加两条;关浮窗清空。
pub(super) messages: Vec<crate::types::QaChatMessage>,
}
impl Default for QaSessionState {
fn default() -> Self {
Self {
phase: QaPhase::Idle,
cancelled: false,
selection: None,
front_app: None,
session_id: initial_session_id(),
pinned: false,
panel_visible: false,
messages: Vec::new(),
}
}
}
pub(super) async fn handle_qa_hotkey_pressed(inner: &Arc<Inner>) {
// QA hotkey(默认 Cmd+Shift+;)现在只 toggle 浮窗可见性。
// 浮窗内的录音 / 提问由 Option 边沿驱动(handle_pressed_edge → handle_qa_option_edge)。
let visible = inner.qa_state.lock().panel_visible;
log::info!("[coord] QA hotkey edge (panel_visible={visible})");
if visible {
close_qa_panel(inner);
} else {
open_qa_panel(inner);
}
}
pub(super) async fn handle_qa_option_edge(inner: &Arc<Inner>) {
let phase = inner.qa_state.lock().phase;
log::info!("[coord] QA option edge (phase={phase:?})");
match phase {
QaPhase::Idle => {
let _ = begin_qa_session(inner).await;
}
QaPhase::Recording => {
let _ = end_qa_session(inner).await;
}
// Processing 阶段再次按键忽略(避免与正在跑的 LLM 冲突)。
QaPhase::Processing => {}
}
}
pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
{
let mut state = inner.qa_state.lock();
state.panel_visible = true;
state.phase = QaPhase::Idle;
state.cancelled = false;
state.messages.clear();
state.selection = None;
state.front_app = capture_frontmost_app();
}
// 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars
// 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
if let Some(app) = inner.app.lock().clone() {
crate::show_qa_window(&app, "idle");
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "idle",
"messages": Vec::<crate::types::QaChatMessage>::new(),
}),
);
}
log::info!("[coord] QA panel opened (awaiting Option to record)");
}
pub(super) fn close_qa_panel(inner: &Arc<Inner>) {
cancel_qa_session(inner);
{
let mut state = inner.qa_state.lock();
state.panel_visible = false;
state.pinned = false;
state.messages.clear();
state.selection = None;
state.front_app = None;
state.phase = QaPhase::Idle;
state.cancelled = false;
}
if let Some(app) = inner.app.lock().clone() {
crate::hide_qa_window(&app);
}
// 胶囊一同收掉,避免浮窗关了胶囊还在显示。
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
log::info!("[coord] QA panel closed, history cleared");
}
@@ -0,0 +1,191 @@
use std::sync::Arc;
use crate::coordinator_state::{SessionId, SessionPhase};
use crate::recorder::Recorder;
use crate::types::CapsuleState;
use tauri::Manager;
use super::{emit_capsule, ActiveAsr, Inner};
pub(super) struct SessionResource<T> {
pub(super) session_id: SessionId,
resource: T,
}
impl<T> SessionResource<T> {
pub(super) fn new(session_id: SessionId, resource: T) -> Self {
Self {
session_id,
resource,
}
}
fn into_inner(self) -> T {
self.resource
}
}
pub(super) struct SharedRecordingMuteState {
guard: Option<crate::audio_mute::AudioMuteGuard>,
holders: u32,
}
impl SharedRecordingMuteState {
pub(super) fn new() -> Self {
Self {
guard: None,
holders: 0,
}
}
}
pub(super) fn take_session_resource<T>(
slot: &mut Option<SessionResource<T>>,
session_id: SessionId,
) -> Option<T> {
if slot
.as_ref()
.map(|resource| resource.session_id == session_id)
.unwrap_or(false)
{
slot.take().map(SessionResource::into_inner)
} else {
None
}
}
pub(super) fn store_asr_for_session(inner: &Arc<Inner>, session_id: SessionId, asr: ActiveAsr) {
*inner.asr.lock() = Some(SessionResource::new(session_id, asr));
}
pub(super) fn take_asr_for_session(inner: &Arc<Inner>, session_id: SessionId) -> Option<ActiveAsr> {
let mut slot = inner.asr.lock();
take_session_resource(&mut slot, session_id)
}
pub(super) fn cancel_active_asr(asr: ActiveAsr) {
match asr {
ActiveAsr::Volcengine(v) => v.cancel(),
ActiveAsr::Whisper(w) => w.cancel(),
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => local.cancel(),
}
}
pub(super) fn cancel_asr_for_session(inner: &Arc<Inner>, session_id: SessionId) {
if let Some(asr) = take_asr_for_session(inner, session_id) {
cancel_active_asr(asr);
}
}
pub(super) fn store_recorder_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
recorder: Recorder,
) {
*inner.recorder.lock() = Some(SessionResource::new(session_id, recorder));
}
pub(super) fn selected_microphone_device_name(inner: &Arc<Inner>) -> Option<String> {
let name = inner.prefs.get().microphone_device_name.trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
pub(super) fn stop_microphone_preview_monitor(inner: &Arc<Inner>, owner: &str) {
let Some(app) = inner.app.lock().as_ref().cloned() else {
return;
};
let state = app.state::<crate::commands::MicrophoneMonitorState>();
let recorder = state.lock().take();
if let Some(recorder) = recorder {
log::info!("[recorder] stopping microphone preview monitor before {owner}");
recorder.stop();
}
}
pub(super) fn acquire_recording_mute(inner: &Arc<Inner>, owner: &str) {
if !inner.prefs.get().mute_during_recording {
return;
}
let mut mute = inner.recording_mute.lock();
if mute.holders == 0 {
match crate::audio_mute::AudioMuteGuard::activate() {
Ok(guard) => {
mute.guard = Some(guard);
log::info!("[audio-mute] system output muted for recording");
}
Err(err) => {
log::warn!("[audio-mute] failed to mute output for {owner}: {err}");
return;
}
}
}
mute.holders = mute.holders.saturating_add(1);
log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders);
}
pub(super) fn release_recording_mute(inner: &Arc<Inner>, owner: &str) {
let mut mute = inner.recording_mute.lock();
if mute.holders == 0 {
return;
}
mute.holders -= 1;
log::info!("[audio-mute] released by {owner}; holders={}", mute.holders);
if mute.holders == 0 {
mute.guard.take();
log::info!("[audio-mute] system output mute restored after recording");
}
}
pub(super) fn stop_qa_recorder(inner: &Arc<Inner>) {
if let Some(rec) = inner.qa_recorder.lock().take() {
rec.stop();
release_recording_mute(inner, "qa");
}
}
pub(super) fn take_recorder_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
) -> Option<Recorder> {
let mut slot = inner.recorder.lock();
take_session_resource(&mut slot, session_id)
}
pub(super) fn stop_recorder_for_session(inner: &Arc<Inner>, session_id: SessionId) {
if let Some(recorder) = take_recorder_for_session(inner, session_id) {
recorder.stop();
release_recording_mute(inner, "dictation");
}
}
pub(super) fn discard_startup_resources_for_session(inner: &Arc<Inner>, session_id: SessionId) {
stop_recorder_for_session(inner, session_id);
cancel_asr_for_session(inner, session_id);
}
pub(super) fn stop_recorder_if_pending_start_stop(inner: &Arc<Inner>) {
let (should_stop, session_id) = {
let state = inner.state.lock();
(
state.phase == SessionPhase::Starting && state.pending_stop,
state.session_id,
)
};
if !should_stop {
return;
}
if let Some(rec) = take_recorder_for_session(inner, session_id) {
rec.stop();
release_recording_mute(inner, "dictation");
let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64;
emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None);
log::info!("[coord] stopped recorder while ASR is still connecting");
}
}