Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
726aaf4
feat(selection-voice): Windows 选区语音编辑 MVP(issue #987)
HKLHaoBin Aug 23, 2026
4603e97
fix(selection-voice): 修复 Windows 编译(展平 session 模块结构)
HKLHaoBin Aug 23, 2026
caa7943
fix(selection-voice): 用 polish_text 替代 ActiveLLMProvider::complete
HKLHaoBin Aug 23, 2026
3ab67af
fix(ci): 补全 mock trait 与 SessionId 单元测试类型
HKLHaoBin Aug 23, 2026
8c76572
fix(release): feature 分支跳过 MSI 并上传 NSIS 安装包
HKLHaoBin Aug 23, 2026
007f4b7
fix(selection-voice): post-utterance intent picker and merged settings
HKLHaoBin Aug 23, 2026
4d79cca
fix(i18n): add selectionWorkspace keys for ja, ko, zh-TW
HKLHaoBin Aug 23, 2026
e0d39e6
fix(selection-voice): compare hotkey supervisor signatures as Option<…
HKLHaoBin Aug 23, 2026
349e46b
fix(coordinator): gate selection_voice_intent_prompt init to Windows
HKLHaoBin Aug 23, 2026
effc76d
fix(selection-workspace): unify voice and polish hotkey with edge pre…
HKLHaoBin Aug 23, 2026
dc907e6
fix(selection-voice): align auto/toggle hotkey modes and intent UX
HKLHaoBin Aug 23, 2026
748c734
fix(ci): gate selection-voice dictation block to Windows only
HKLHaoBin Aug 23, 2026
79beb0e
fix(selection-voice): surface end-workflow errors instead of stuck lo…
HKLHaoBin Aug 23, 2026
dd6c7fd
fix(selection-voice): annotate end-workflow async block error type as…
HKLHaoBin Aug 23, 2026
7b78997
fix(selection-voice): route translation edits through translate pipeline
HKLHaoBin Aug 23, 2026
de6643c
feat(selection-voice): use XML EditPlan format for LLM edit responses
HKLHaoBin Aug 23, 2026
dfbd682
fix(edit-plan): correct XML entity match string literals
HKLHaoBin Aug 23, 2026
c6ef139
fix(edit-plan): resolve XML parser type errors for CI
HKLHaoBin Aug 23, 2026
97f6469
fix(edit-plan): fix remaining XML parser compile errors
HKLHaoBin Aug 23, 2026
92a87c5
fix(edit-plan): fix tuple arity, map remove keys, and borrow in norma…
HKLHaoBin Aug 23, 2026
d7328ab
fix(selection-voice): unify voice edit with QA panel and improve intent
HKLHaoBin Aug 24, 2026
0757fea
feat(selection-voice): 统一语音编辑为 QA 面板预览模式并支持回退
HKLHaoBin Aug 24, 2026
158811d
fix(ci): gate selection-voice QA follow-up to Windows and fix compile…
HKLHaoBin Aug 24, 2026
b958bd3
fix(ci): stub selection module in backend-tests harness
HKLHaoBin Aug 24, 2026
99c43a9
feat(qa): route intent by interrogative and add edit-instruction mode
HKLHaoBin Aug 24, 2026
a1cb707
feat(selection-voice): 增强语音编辑功能,优化 QA 面板状态同步
HKLHaoBin Aug 24, 2026
f87f226
fix(selection-voice): stop legacy edit keywords from misrouting to QA
HKLHaoBin Aug 24, 2026
e6e52cc
fix(selection-voice): address review blockers
H-Chris233 Aug 25, 2026
a569a87
fix(selection-voice): gate QA preview cleanup to Windows
H-Chris233 Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ parking_lot = "0.12"
once_cell = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
bytes = "1"
url = "2"
raw-window-handle = "0.6"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ mod asr {

#[path = "../../src/coordinator_state.rs"]
mod coordinator_state;
mod selection {
pub fn prefetch_selection_workspace_capture() {}
}
#[path = "../../src/global_hotkey_runtime.rs"]
mod global_hotkey_runtime;
#[path = "../../src/combo_hotkey.rs"]
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ mod remote_input;
mod selection_polish;
#[cfg(not(mobile))]
mod selection_polish_preview;
#[cfg(all(not(mobile), target_os = "windows"))]
mod selection_voice;
mod settings;
#[cfg(not(mobile))]
mod sherpa_asr;
Expand Down Expand Up @@ -118,6 +120,8 @@ pub use settings::*;
pub use selection_polish::*;
#[cfg(not(mobile))]
pub use selection_polish_preview::*;
#[cfg(all(not(mobile), target_os = "windows"))]
pub use selection_voice::*;
#[cfg(not(mobile))]
#[allow(unused_imports)]
pub use sherpa_asr::*;
Expand Down
6 changes: 6 additions & 0 deletions openless-all/app/src-tauri/src/commands/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ pub async fn qa_submit_text(coord: CoordinatorState<'_>, text: String) -> Result
coord.qa_submit_text(text).await
}

/// 划词提问面板「编辑指令」复选框。
#[tauri::command]
pub fn qa_set_edit_instruction_mode(coord: CoordinatorState<'_>, enabled: bool) {
coord.qa_set_edit_instruction_mode(enabled);
}

/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗。
#[tauri::command]
pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) {
Expand Down
47 changes: 47 additions & 0 deletions openless-all/app/src-tauri/src/commands/selection_voice.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use super::*;
use crate::coordinator_state::SessionId;

#[tauri::command]
pub fn get_selection_voice_intent_prompt(
coord: CoordinatorState<'_>,
) -> Option<crate::coordinator::selection_voice_session::SelectionVoiceIntentPromptPayload> {
coord.selection_voice_intent_prompt()
}

#[tauri::command]
pub async fn confirm_selection_voice_intent_prompt(
coord: CoordinatorState<'_>,
intent: String,
) -> Result<(), String> {
coord.confirm_selection_voice_intent_prompt(intent).await
}

#[tauri::command]
pub fn cancel_selection_voice_intent_prompt(coord: CoordinatorState<'_>) {
coord.cancel_selection_voice_intent_prompt();
}

#[tauri::command]
pub fn get_selection_voice_preview(
coord: CoordinatorState<'_>,
qa_session_id: SessionId,
) -> Option<crate::coordinator::selection_voice_session::SelectionVoicePreviewPayload> {
coord.selection_voice_preview(qa_session_id)
}

#[tauri::command]
pub fn confirm_selection_voice_preview(
coord: CoordinatorState<'_>,
text: String,
qa_session_id: SessionId,
) -> Result<(), String> {
coord.confirm_selection_voice_preview(text, Some(qa_session_id))
}

#[tauri::command]
pub fn revert_selection_voice_preview(
coord: CoordinatorState<'_>,
qa_session_id: SessionId,
) -> Result<(), String> {
coord.revert_selection_voice_preview(qa_session_id)
}
59 changes: 59 additions & 0 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ mod polish_flow;
mod qa;
mod qa_session;
mod resources;
#[cfg(all(not(mobile), target_os = "windows"))]
pub(crate) mod selection_voice_session;
#[cfg(not(mobile))]
pub(crate) mod selection_polish;
mod silence_auto_stop;
Expand Down Expand Up @@ -1128,6 +1130,14 @@ struct Inner {
/// 预览确认模式暂存的结果和原选区目标;仅在用户确认时才允许插入。
#[cfg(not(mobile))]
selection_polish_preview: Mutex<Option<selection_polish::PendingSelectionPolishPreview>>,
/// 选区语音编辑会话状态(issue #987 桌面 MVP)。
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_state: Mutex<selection_voice_session::SelectionVoiceSessionState>,
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_preview: Mutex<Option<selection_voice_session::PendingSelectionVoicePreview>>,
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_intent_prompt:
Mutex<Option<selection_voice_session::PendingSelectionVoiceIntentPrompt>>,
/// 「本次会话真的要翻译」。每次 begin_session 重置为 false;hotkey 监听器在
/// Listening / Starting 阶段看到 Shift down 边沿(或安卓浮层请求)时,经
/// `arm_translation_if_effective` 判定翻译确实会生效(设了目标语言、且不等于唯一工作语言)
Expand Down Expand Up @@ -1439,6 +1449,14 @@ impl Coordinator {
selection_polish_hotkey: Mutex::new(None),
#[cfg(not(mobile))]
selection_polish_preview: Mutex::new(None),
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_state: Mutex::new(
selection_voice_session::SelectionVoiceSessionState::default(),
),
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_preview: Mutex::new(None),
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_intent_prompt: Mutex::new(None),
translation_active: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
coding_agent_modifier_hotkey: Mutex::new(None),
Expand Down Expand Up @@ -1571,6 +1589,14 @@ impl Coordinator {
selection_polish_hotkey: Mutex::new(None),
#[cfg(not(mobile))]
selection_polish_preview: Mutex::new(None),
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_state: Mutex::new(
selection_voice_session::SelectionVoiceSessionState::default(),
),
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_preview: Mutex::new(None),
#[cfg(all(not(mobile), target_os = "windows"))]
selection_voice_intent_prompt: Mutex::new(None),
translation_active: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
coding_agent_modifier_hotkey: Mutex::new(None),
Expand Down Expand Up @@ -2714,6 +2740,39 @@ impl Coordinator {
submit_qa_text_question(&self.inner, text).await
}

pub fn qa_set_edit_instruction_mode(&self, enabled: bool) {
let mut qa = self.inner.qa_state.lock();
if !qa.panel_visible {
return;
}
qa.edit_instruction_mode = enabled;
let session_id = qa.session_id;
let messages = qa.messages.clone();
let edit_apply = {
#[cfg(all(not(mobile), target_os = "windows"))]
{
self.inner.selection_voice_preview.lock().is_some()
}
#[cfg(not(all(not(mobile), target_os = "windows")))]
{
false
}
};
if let Some(app) = self.inner.app.lock().clone() {
let _ = app.emit_to(
qa_event_target(),
"qa:state",
serde_json::json!({
"kind": "answer",
"session_id": session_id,
"messages": messages,
"edit_instruction_mode": enabled,
"edit_apply_available": edit_apply,
}),
);
}
}

pub fn set_shortcut_recording_active(&self, active: bool) {
self.inner
.shortcut_recording_active
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2086,6 +2086,11 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
/// begin_session 的带参版本,voice_agent=true 时在 Starting 阶段就标记好,
/// 防止 finish_starting_session 处理 pending_stop 时丢失标志。
pub(super) async fn begin_session_as(inner: &Arc<Inner>, voice_agent: bool) -> Result<(), String> {
#[cfg(all(not(mobile), target_os = "windows"))]
if super::selection_voice_session::selection_voice_blocks_other_recording(inner) {
log::info!("[coord] dictation blocked: selection voice session active");
return Ok(());
}
let current_session_id = {
let mut state = inner.state.lock();
let Some(session_id) =
Expand Down
71 changes: 50 additions & 21 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,20 +400,55 @@ fn update_selection_polish_hotkey_on_main_thread(
#[cfg(not(mobile))]
fn selection_polish_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
while let Ok(event) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst)
|| !matches!(event, ComboHotkeyEvent::Pressed { .. })
{
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let coordinator = Coordinator {
inner: Arc::clone(&inner),
};
async_runtime::spawn(async move {
if let Err(error) = coordinator.trigger_selection_polish().await {
log::warn!("[selection-polish] combo hotkey workflow failed: {error}");
match event {
ComboHotkeyEvent::Pressed { .. } => {
crate::selection::prefetch_selection_workspace_capture();
handle_selection_workspace_hotkey_pressed(&inner);
}
ComboHotkeyEvent::Released { .. } => {
handle_selection_workspace_hotkey_released(&inner);
}
}
}
}

#[cfg(not(mobile))]
fn handle_selection_workspace_hotkey_pressed(inner: &Arc<Inner>) {
#[cfg(target_os = "windows")]
if inner.prefs.get().selection_voice_enabled {
let inner_cloned = Arc::clone(inner);
async_runtime::spawn(async move {
super::selection_voice_session::handle_selection_voice_pressed(&inner_cloned).await;
});
return;
}
let coordinator = Coordinator {
inner: Arc::clone(inner),
};
async_runtime::spawn(async move {
if let Err(error) = coordinator.trigger_selection_polish().await {
log::warn!("[selection-polish] hotkey workflow failed: {error}");
}
});
}

#[cfg(not(mobile))]
fn handle_selection_workspace_hotkey_released(inner: &Arc<Inner>) {
#[cfg(target_os = "windows")]
{
if !inner.prefs.get().selection_voice_enabled {
return;
}
let inner_cloned = Arc::clone(inner);
async_runtime::spawn(async move {
super::selection_voice_session::handle_selection_voice_released(&inner_cloned).await;
});
}
#[cfg(not(target_os = "windows"))]
let _ = inner;
}

#[cfg(not(mobile))]
Expand Down Expand Up @@ -587,7 +622,7 @@ pub(super) fn less_computer_modifier_bridge_loop(
// combo_abort_bridge_loop(见各自函数注释)。
HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {}
#[cfg(not(mobile))]
HotkeyEvent::SelectionPolishShortcutPressed => {}
HotkeyEvent::SelectionPolishShortcutPressed | HotkeyEvent::SelectionPolishShortcutReleased => {}
#[cfg(not(mobile))]
HotkeyEvent::FnRecordingPressed => {}
}
Expand Down Expand Up @@ -1614,17 +1649,11 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
}
#[cfg(not(mobile))]
HotkeyEvent::SelectionPolishShortcutPressed => {
let coordinator = Coordinator {
inner: Arc::clone(&inner_cloned),
};
// Selection Polish has no paired release edge. Run the cloud
// workflow independently so its network wait cannot stall the
// shared modifier-key bridge (Esc, dictation, QA, etc.).
async_runtime::spawn(async move {
if let Err(error) = coordinator.trigger_selection_polish().await {
log::warn!("[selection-polish] hotkey workflow failed: {error}");
}
});
handle_selection_workspace_hotkey_pressed(&inner_cloned);
}
#[cfg(not(mobile))]
HotkeyEvent::SelectionPolishShortcutReleased => {
handle_selection_workspace_hotkey_released(&inner_cloned);
}
// 非录制态不会出现(CGEventTap 仅在 recording_active 时上报);防御性忽略。
#[cfg(not(mobile))]
Expand Down
21 changes: 21 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub(super) struct QaSessionState {
pub(super) panel_visible: bool,
/// 多轮对话累积。每轮 user→assistant 加两条;关浮窗清空。
pub(super) messages: Vec<crate::types::QaChatMessage>,
/// 划词提问面板「编辑指令」开关:勾选则文字/麦克风走编辑,否则走提问。
pub(super) edit_instruction_mode: bool,
}

impl Default for QaSessionState {
Expand All @@ -48,6 +50,7 @@ impl Default for QaSessionState {
session_id: initial_session_id(),
panel_visible: false,
messages: Vec::new(),
edit_instruction_mode: false,
}
}
}
Expand Down Expand Up @@ -80,13 +83,25 @@ pub(super) async fn handle_qa_option_edge(inner: &Arc<Inner>) {
}

pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
// 选区语音 early_qa 已打开面板时,勿重置 edit_instruction_mode / 历史。
{
let qa = inner.qa_state.lock();
if qa.panel_visible {
drop(qa);
if let Some(app) = inner.app.lock().clone() {
crate::show_qa_window(&app, "idle");
}
return;
}
}
let session_id = {
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.edit_instruction_mode = false;
state.front_app = capture_frontmost_app();
// 在 show_qa_window 抢前台之前抓一下:每次 begin_qa_session 抓选区时拿这个 HWND
// 临时把焦点还回去,让 simulate_copy 跑在用户原 app 上。issue #466 focus-dance。
Expand Down Expand Up @@ -118,6 +133,8 @@ pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
"kind": "idle",
"session_id": session_id,
"messages": Vec::<crate::types::QaChatMessage>::new(),
"edit_instruction_mode": false,
"edit_apply_available": false,
}),
);
}
Expand All @@ -132,13 +149,16 @@ pub(super) fn close_qa_panel(inner: &Arc<Inner>) {
state.panel_visible = false;
state.messages.clear();
state.selection = None;
state.edit_instruction_mode = false;
state.front_app = None;
state.qa_focus_target = None;
state.phase = QaPhase::Idle;
state.cancelled = false;
// 让仍在阻塞选区捕获或 provider await 中的旧任务无法在关闭后写回状态。
state.session_id = new_session_id();
}
#[cfg(all(not(mobile), target_os = "windows"))]
super::selection_voice_session::clear_qa_bound_selection_voice_preview(inner);
if let Some(app) = inner.app.lock().clone() {
crate::hide_qa_window(&app);
}
Expand All @@ -164,5 +184,6 @@ mod tests {
assert!(st.qa_focus_target.is_none());
assert!(!st.panel_visible, "浮窗默认不可见,等用户 toggle");
assert!(st.messages.is_empty(), "新建会话历史必须为空");
assert!(!st.edit_instruction_mode, "默认不进入编辑指令模式");
}
}
Loading
Loading