fix(coordinator): 会话状态机修复 — Starting 阶段补偿热键边沿 + Esc 取消支持 Processing (#76)

closes #51, #52

## #51 — Starting 阶段忽略热键边沿
握手 ASR 期间(phase=Starting)的"停止"边沿(toggle 第二次按 / hold 松开)
之前被直接 _ => {} 掉,会话锁死直到用户再按一次或重启 app。

修:
- SessionState 加 pending_stop: bool
- handle_pressed Toggle/Starting → pending_stop = true
- handle_released Hold/Starting → pending_stop = true
- begin_session 转 phase=Listening 时 mem::replace 取出 pending_stop,
  若为 true 立即 await end_session

## #52 — Esc 取消在 Processing 阶段无效
用户按 Esc 时若 end_session 已进入 Processing(润色 / 插入进行中),cancel
信号被忽略,文本仍插入到光标位置 → 数据无意写入用户当前 app。

修:
- SessionState 加 cancelled: bool
- cancel_session 设 cancelled=true(不再仅 Idle 之外 = 转 Idle)
  Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾
- end_session 在 ASR 完成后、polish 之前 + polish 完成后、insert 之前
  各检查一次 cancelled,命中即跳过 insert/history.append

新会话 begin_session 入口清两个 flag,避免遗留触发奇怪行为。

Co-authored-by: baiqing <lbx12309@icloud.com>
This commit is contained in:
TRIPandbaiqing authored and GitHub committed 2026-04-30 13:36:39 +08:00
1 parent fea402dd15
commit 1e101a3147
1 file changed
+62 -5
+62 -5
View File
@@ -47,6 +47,12 @@ enum ActiveAsr {
struct SessionState {
phase: SessionPhase,
started_at: Instant,
/// Starting 阶段(ASR 握手中)按下 stop 边沿(toggle 第二次按 / hold 松开)→
/// 等握手完成 phase=Listening 后立刻 end_session,不丢边沿。issue #51。
pending_stop: bool,
/// 用户在 Processing 阶段按 Esc 取消:end_session 在 polish/insert 检查点跳过插入 +
/// 跳过 history.append。issue #52。
cancelled: bool,
}
impl Default for SessionState {
@@ -54,6 +60,8 @@ impl Default for SessionState {
Self {
phase: SessionPhase::Idle,
started_at: Instant::now(),
pending_stop: false,
cancelled: false,
}
}
}
@@ -261,6 +269,12 @@ async fn handle_pressed(inner: &Arc<Inner>) {
(HotkeyMode::Hold, SessionPhase::Idle) => {
let _ = begin_session(inner).await;
}
// Toggle 模式 Starting 阶段第二次按 → 用户想停。
// 不能直接 end_session(ASR session 还没建好),存边沿,握手完成后立即触发。
(HotkeyMode::Toggle, SessionPhase::Starting) => {
inner.state.lock().pending_stop = true;
log::info!("[coord] toggle stop edge during Starting — queued");
}
_ => {}
}
}
@@ -270,8 +284,16 @@ async fn handle_released(inner: &Arc<Inner>) {
let phase = inner.state.lock().phase;
log::info!("[coord] hotkey released (mode={mode:?}, phase={phase:?})");
if mode == HotkeyMode::Hold {
if phase == SessionPhase::Listening {
let _ = end_session(inner).await;
match phase {
SessionPhase::Listening => {
let _ = end_session(inner).await;
}
// Hold 模式 Starting 阶段松开 → 用户想停。同上:握手完成后再 end。
SessionPhase::Starting => {
inner.state.lock().pending_stop = true;
log::info!("[coord] hold release edge during Starting — queued");
}
_ => {}
}
}
}
@@ -286,6 +308,9 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
}
state.phase = SessionPhase::Starting;
state.started_at = Instant::now();
// 新会话清掉旧 pending_stop / cancelled,避免上一会话遗留触发奇怪行为
state.pending_stop = false;
state.cancelled = false;
}
#[cfg(any(debug_assertions, test))]
@@ -384,8 +409,18 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
match Recorder::start(consumer, level_handler) {
Ok(rec) => {
*inner.recorder.lock() = Some(rec);
inner.state.lock().phase = SessionPhase::Listening;
// 转 Listening 同时检查 Starting 期间是否积累了 pending_stop 边沿。
// hold 模式快速松开 / toggle 快速双击会到这里:握手刚完就要立即停。
let should_stop_immediately = {
let mut state = inner.state.lock();
state.phase = SessionPhase::Listening;
std::mem::replace(&mut state.pending_stop, false)
};
log::info!("[coord] session started (asr={})", active_asr);
if should_stop_immediately {
log::info!("[coord] applying pending_stop edge → end_session immediately");
let _ = end_session(inner).await;
}
}
Err(e) => {
log::error!("[coord] recorder start failed: {e}");
@@ -476,6 +511,13 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
},
};
// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
if inner.state.lock().cancelled {
log::info!("[coord] cancel detected after ASR — discarding transcript");
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
let prefs = inner.prefs.get();
@@ -483,6 +525,13 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let hotword_strs = enabled_phrases(inner);
let polished = polish_or_passthrough(&raw, mode, &hotword_strs).await;
// Polish 完成后再 check 一次:即使 polish 已经返回,只要还没插入,仍可丢弃。
if inner.state.lock().cancelled {
log::info!("[coord] cancel detected after polish — discarding output (chars={})", polished.chars().count());
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
let status = inner.inserter.insert(&polished);
let inserted_chars = polished.chars().count() as u32;
@@ -557,6 +606,10 @@ fn cancel_session(inner: &Arc<Inner>) {
if phase == SessionPhase::Idle {
return;
}
// Processing 阶段 cancel 不能直接干掉 in-flight polish task(已经 await 了),
// 但可以打 cancelled 标记,让 end_session 在插入前检查并丢弃结果。
inner.state.lock().cancelled = true;
if let Some(rec) = inner.recorder.lock().take() {
rec.stop();
}
@@ -566,9 +619,13 @@ fn cancel_session(inner: &Arc<Inner>) {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
inner.state.lock().phase = SessionPhase::Idle;
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
// 其他阶段直接转 Idle。
if phase != SessionPhase::Processing {
inner.state.lock().phase = SessionPhase::Idle;
}
emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None);
log::info!("[coord] session cancelled");
log::info!("[coord] session cancelled (was {phase:?})");
}
// ─────────────────────────── helpers ───────────────────────────