From 20756f7e93c6fa6133395933cbeea4968edf8cd0 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Tue, 12 May 2026 10:32:57 +0800 Subject: [PATCH 01/55] Keep streamed output consistent across SSE edge cases Streaming LLM output can split UTF-8 codepoints across HTTP chunks, and unicode typing can fail after a prefix has already reached the focused app. The stream path now decodes SSE bytes incrementally and records only confirmed typed prefixes so history and clipboard match what appeared on screen. Constraint: Issue #413 requires fixing both streaming polish and QA SSE paths plus partial-delta typing loss. Rejected: Decode each response chunk independently | valid UTF-8 can be split by HTTP frame boundaries. Rejected: Keep full delta on typing error | it records text that may never have reached the target app. Confidence: high Scope-risk: moderate Directive: Do not re-enable mutating final postprocess for already_streamed text without proving screen/history/clipboard consistency. Tested: cargo test --manifest-path src-tauri/Cargo.toml --lib Tested: cargo check --manifest-path src-tauri/Cargo.toml Tested: git diff --check --- .../src-tauri/src/coordinator/dictation.rs | 178 +++++++++---- openless-all/app/src-tauri/src/polish.rs | 236 ++++++++++++++++-- .../app/src-tauri/src/unicode_keystroke.rs | 149 +++++++++-- 3 files changed, 482 insertions(+), 81 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 58f6f44f..dc2a37b3 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::coordinator_state::request_stop_during_starting_state; use crate::correction::apply_correction_rules; -use crate::types::HotkeyMode; +use crate::types::{CorrectionRule, HotkeyMode}; use super::qa::handle_qa_option_edge; use super::resources::*; @@ -38,6 +38,50 @@ const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(25 /// **不在流式路径里做**:`apply_chinese_script_preference` / `apply_correction_rules` /// 这两步在 v1 跳过 —— 字符已经一边流一边落出去了,不好回退。需要的话只能关 toggle 走 /// 一次性路径。 +fn append_typed_prefix(typed_text: &mut String, delta: &str, typed_chars: usize) { + typed_text.extend(delta.chars().take(typed_chars)); +} + +fn finalize_dictation_text( + mut text: String, + already_streamed: bool, + translation_active: bool, + mode: PolishMode, + polish_failed: bool, + chinese_script_preference: crate::types::ChineseScriptPreference, + correction_rules: &[CorrectionRule], +) -> String { + if already_streamed { + return text; + } + + // 仅在“ASR 直出文本”场景做强制简繁收敛,避免误伤成功的翻译/常规 LLM 输出: + // - 非翻译模式:mode=Raw(本来就不走润色)或润色失败回退 raw + // - 翻译模式:仅翻译失败回退 raw 时才收敛 + let should_force_script = if translation_active { + polish_failed + } else { + mode == PolishMode::Raw || polish_failed + }; + if should_force_script { + text = apply_chinese_script_preference(&text, chinese_script_preference); + } + + if correction_rules.is_empty() { + return text; + } + + let corrected = apply_correction_rules(&text, correction_rules); + if corrected != text { + log::info!( + "[coord] correction rules adjusted final text ({} → {} chars)", + text.chars().count(), + corrected.chars().count() + ); + } + corrected +} + #[allow(clippy::too_many_arguments)] async fn run_streaming_polish( inner: &Arc, @@ -120,10 +164,12 @@ async fn run_streaming_polish( continue; } match crate::unicode_keystroke::type_unicode_chunk(&delta) { - Ok(()) => { - typed_text.push_str(&delta); + Ok(typed_chars) => { + append_typed_prefix(&mut typed_text, &delta, typed_chars); } Err(e) => { + let typed_chars = e.typed_chars(); + append_typed_prefix(&mut typed_text, &delta, typed_chars); log::error!( "[coord] streaming_insert: type_unicode_chunk failed at typed={} chars: {e}; \ dropping remaining deltas", @@ -202,10 +248,7 @@ async fn run_streaming_polish( // 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}")), - ), + Some(e) => (typed_text, Some(format!("typing partially failed: {e}"))), None => (text, None), }; // 把 final_text 写回剪贴板(默认 on,可关)。一次性路径天然走剪贴板, @@ -217,23 +260,23 @@ async fn run_streaming_polish( "[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 set_text failed: {e}") + } }, - Err(e) => log::warn!( - "[coord] streaming_insert: clipboard handle init 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)" - ); + 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"); + log::info!( + "[coord] streaming_insert: dispatch reported unsupported, fall back to one-shot" + ); let (p, e) = polish_or_passthrough( raw, mode, @@ -1304,32 +1347,15 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { (p, e, false) }; - // 仅在“ASR 直出文本”场景做强制简繁收敛,避免误伤成功的翻译/常规 LLM 输出: - // - 非翻译模式:mode=Raw(本来就不走润色)或润色失败回退 raw - // - 翻译模式:仅翻译失败回退 raw 时才收敛 - 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 - }; - let 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 - }; + let polished = finalize_dictation_text( + polished, + already_streamed, + translation_active, + mode, + polish_error.is_some(), + chinese_script_preference, + &correction_rules, + ); // 原子化最后一次 cancel 检查 + 转 Inserting: // 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting, @@ -1540,3 +1566,69 @@ pub(super) fn cancel_session(inner: &Arc) { log::info!("[coord] session cancelled (was {:?})", decision.phase); schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); } + +#[cfg(test)] +mod tests { + use super::{append_typed_prefix, finalize_dictation_text}; + use crate::types::PolishMode; + + #[test] + fn append_typed_prefix_truncates_by_chars_not_bytes() { + let mut typed = String::from("已"); + append_typed_prefix(&mut typed, "你好🙂abc", 3); + assert_eq!(typed, "已你好🙂"); + } + + #[test] + fn append_typed_prefix_ignores_overlarge_count() { + let mut typed = String::new(); + append_typed_prefix(&mut typed, "好", 99); + assert_eq!(typed, "好"); + } + + #[test] + fn streamed_text_skips_mutating_final_postprocess() { + let rules = vec![crate::types::CorrectionRule { + id: "r1".into(), + pattern: "错词".into(), + replacement: "正词".into(), + enabled: true, + created_at: "2026-01-01T00:00:00Z".into(), + }]; + + let text = finalize_dictation_text( + "錯詞".to_string(), + true, + false, + PolishMode::Raw, + true, + crate::types::ChineseScriptPreference::Simplified, + &rules, + ); + + assert_eq!(text, "錯詞"); + } + + #[test] + fn non_streamed_text_still_applies_final_postprocess() { + let rules = vec![crate::types::CorrectionRule { + id: "r1".into(), + pattern: "错词".into(), + replacement: "正词".into(), + enabled: true, + created_at: "2026-01-01T00:00:00Z".into(), + }]; + + let text = finalize_dictation_text( + "錯詞".to_string(), + false, + false, + PolishMode::Raw, + false, + crate::types::ChineseScriptPreference::Simplified, + &rules, + ); + + assert_eq!(text, "正词"); + } +} diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index e42bdb53..ef1fbd7d 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -587,12 +587,15 @@ impl OpenAICompatibleLLMProvider { // 一个 chunk() 可能包含半帧或多帧;用 buffer 累积后再按 `\n\n` 切。 let mut response = response; let mut buffer = String::new(); + let mut utf8_pending: Vec = Vec::new(); let mut full_text = String::new(); + let mut cancelled = false; loop { // 取消旗标:用户取消 / 关浮窗时立即 break,不再 drain HTTP body。 // 否则 reqwest 会读完整个流(包括 LLM 后续 token)烧 quota。详见 issue #161。 if should_cancel() { log::info!("[llm] stream cancelled by caller; breaking SSE loop"); + cancelled = true; break; } let chunk_opt = response @@ -600,9 +603,7 @@ impl OpenAICompatibleLLMProvider { .await .map_err(|e| LLMError::Network(e.to_string()))?; let Some(chunk) = chunk_opt else { break }; - let s = std::str::from_utf8(&chunk) - .map_err(|e| LLMError::Network(format!("non-utf8 SSE chunk: {e}")))?; - buffer.push_str(s); + append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?; while let Some(idx) = buffer.find("\n\n") { let event = buffer[..idx].to_string(); @@ -637,6 +638,9 @@ impl OpenAICompatibleLLMProvider { } } } + if !cancelled { + finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?; + } log::info!( "[llm] HTTP 200 stream done; total chars={}", @@ -709,8 +713,10 @@ impl OpenAICompatibleLLMProvider { let mut response = response; let mut buffer = String::new(); + let mut utf8_pending: Vec = Vec::new(); let mut full_text = String::new(); let mut delta_count: u64 = 0; + let mut cancelled = false; loop { if should_cancel() { log::info!( @@ -718,6 +724,7 @@ impl OpenAICompatibleLLMProvider { delta_count, full_text.chars().count() ); + cancelled = true; break; } let chunk_opt = response @@ -725,9 +732,7 @@ impl OpenAICompatibleLLMProvider { .await .map_err(|e| LLMError::Network(e.to_string()))?; let Some(chunk) = chunk_opt else { break }; - let s = std::str::from_utf8(&chunk) - .map_err(|e| LLMError::Network(format!("non-utf8 SSE chunk: {e}")))?; - buffer.push_str(s); + append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?; while let Some(idx) = buffer.find("\n\n") { let event = buffer[..idx].to_string(); @@ -763,6 +768,9 @@ impl OpenAICompatibleLLMProvider { } } } + if !cancelled { + finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?; + } log::info!( "[llm] polish stream done; total deltas={} chars={}", @@ -1065,11 +1073,14 @@ impl CodexOAuthLLMProvider { let mut response = response; let mut buffer = String::new(); + let mut utf8_pending: Vec = Vec::new(); let mut full_text = String::new(); let mut final_text = String::new(); + let mut cancelled = false; loop { if should_cancel() { log::info!("[llm] codex stream cancelled by caller; breaking SSE loop"); + cancelled = true; break; } let chunk_opt = response @@ -1077,9 +1088,7 @@ impl CodexOAuthLLMProvider { .await .map_err(|e| LLMError::Network(e.to_string()))?; let Some(chunk) = chunk_opt else { break }; - let s = std::str::from_utf8(&chunk) - .map_err(|e| LLMError::Network(format!("non-utf8 SSE chunk: {e}")))?; - buffer.push_str(s); + append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?; while let Some(idx) = buffer.find("\n\n") { let event = buffer[..idx].to_string(); @@ -1087,6 +1096,9 @@ impl CodexOAuthLLMProvider { handle_codex_sse_event(&event, &mut full_text, &mut final_text, &on_delta); } } + if !cancelled { + finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?; + } if !buffer.trim().is_empty() { handle_codex_sse_event(&buffer, &mut full_text, &mut final_text, &on_delta); } @@ -1108,6 +1120,51 @@ impl CodexOAuthLLMProvider { } } +fn append_utf8_sse_chunk( + buffer: &mut String, + pending: &mut Vec, + chunk: &[u8], +) -> Result<(), LLMError> { + pending.extend_from_slice(chunk); + drain_complete_utf8(buffer, pending) +} + +fn finish_utf8_sse_chunks(buffer: &mut String, pending: &mut Vec) -> Result<(), LLMError> { + drain_complete_utf8(buffer, pending)?; + if pending.is_empty() { + Ok(()) + } else { + Err(LLMError::Network( + "non-utf8 SSE chunk: stream ended in the middle of a UTF-8 codepoint".to_string(), + )) + } +} + +fn drain_complete_utf8(buffer: &mut String, pending: &mut Vec) -> Result<(), LLMError> { + loop { + match std::str::from_utf8(pending) { + Ok(s) => { + buffer.push_str(s); + pending.clear(); + return Ok(()); + } + Err(e) => { + let valid_up_to = e.valid_up_to(); + if valid_up_to > 0 { + let valid = std::str::from_utf8(&pending[..valid_up_to]).expect("valid prefix"); + buffer.push_str(valid); + pending.drain(..valid_up_to); + continue; + } + if e.error_len().is_none() { + return Ok(()); + } + return Err(LLMError::Network(format!("non-utf8 SSE chunk: {e}"))); + } + } + } +} + /// Slice up to `end` bytes off `s`, but don't split a UTF-8 codepoint. pub(crate) fn safe_str_slice(s: &str, end: usize) -> &str { if end >= s.len() { @@ -2145,6 +2202,136 @@ mod tests { format!("{}.{}.sig", header, payload) } + #[test] + fn utf8_sse_decoder_preserves_multibyte_split_across_chunks() { + let mut buffer = String::new(); + let mut pending = Vec::new(); + let event = "data: {\"choices\":[{\"delta\":{\"content\":\"你好🙂\"}}]}\n\n"; + let bytes = event.as_bytes(); + let split = event.find("好").expect("contains CJK char") + 1; + + append_utf8_sse_chunk(&mut buffer, &mut pending, &bytes[..split]).unwrap(); + assert!(!pending.is_empty()); + assert!(!buffer.contains('好')); + + append_utf8_sse_chunk(&mut buffer, &mut pending, &bytes[split..]).unwrap(); + finish_utf8_sse_chunks(&mut buffer, &mut pending).unwrap(); + assert_eq!(buffer, event); + assert!(pending.is_empty()); + } + + #[test] + fn utf8_sse_decoder_rejects_invalid_byte() { + let mut buffer = String::new(); + let mut pending = Vec::new(); + let err = append_utf8_sse_chunk(&mut buffer, &mut pending, b"data: \xff\n\n") + .expect_err("invalid byte should fail"); + assert!(err.to_string().contains("non-utf8 SSE chunk")); + } + + #[test] + fn utf8_sse_decoder_rejects_unfinished_codepoint_on_finish() { + let mut buffer = String::new(); + let mut pending = Vec::new(); + append_utf8_sse_chunk(&mut buffer, &mut pending, &[0xE4]).unwrap(); + let err = finish_utf8_sse_chunks(&mut buffer, &mut pending) + .expect_err("unfinished codepoint should fail at EOF"); + assert!(err.to_string().contains("middle of a UTF-8 codepoint")); + } + + #[tokio::test] + async fn polish_streaming_handles_multibyte_split_in_http_chunk() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let event = "data: {\"choices\":[{\"delta\":{\"content\":\"你🙂好\"}}]}\n\n"; + let split = split_inside(event, "🙂"); + let first = event.as_bytes()[..split].to_vec(); + let second = event.as_bytes()[split..].to_vec(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let request_text = String::from_utf8_lossy(&request); + assert!(request_text.starts_with("POST /chat/completions HTTP/1.1")); + write_chunked_sse_response(&mut stream, &[&first, &second]); + }); + + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "ark", + "Ark", + format!("http://{}", addr), + "", + "test-model", + )); + let deltas = StdMutex::new(String::new()); + let output = provider + .polish_streaming( + "原文", + PolishMode::Raw, + &[], + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + &[], + |delta| deltas.lock().unwrap().push_str(delta), + || false, + ) + .await + .unwrap(); + + assert_eq!(output, "你🙂好"); + assert_eq!(*deltas.lock().unwrap(), "你🙂好"); + server.join().unwrap(); + } + + #[tokio::test] + async fn qa_streaming_handles_multibyte_split_in_http_chunk() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let event = "data: {\"choices\":[{\"delta\":{\"content\":\"答🙂案\"}}]}\n\n"; + let split = split_inside(event, "🙂"); + let first = event.as_bytes()[..split].to_vec(); + let second = event.as_bytes()[split..].to_vec(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let request_text = String::from_utf8_lossy(&request); + assert!(request_text.starts_with("POST /chat/completions HTTP/1.1")); + write_chunked_sse_response(&mut stream, &[&first, &second]); + }); + + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "ark", + "Ark", + format!("http://{}", addr), + "", + "test-model", + )); + let messages = vec![QaChatMessage { + role: "user".into(), + content: "问题".into(), + }]; + let deltas = StdMutex::new(String::new()); + let output = provider + .answer_chat_streaming( + &messages, + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + |delta| deltas.lock().unwrap().push_str(delta), + || false, + ) + .await + .unwrap(); + + assert_eq!(output, "答🙂案"); + assert_eq!(*deltas.lock().unwrap(), "答🙂案"); + server.join().unwrap(); + } + fn base64_url_no_pad(input: &str) -> String { const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; @@ -2196,6 +2383,24 @@ mod tests { request } + fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) { + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + for chunk in chunks { + write!(stream, "{:X}\r\n", chunk.len()).unwrap(); + stream.write_all(chunk).unwrap(); + stream.write_all(b"\r\n").unwrap(); + } + stream.write_all(b"0\r\n\r\n").unwrap(); + } + + fn split_inside(haystack: &str, needle: &str) -> usize { + haystack.find(needle).expect("needle exists") + 1 + } + // ──────────────── 对话感知 polish 的 chat 消息构造 ──────────────── // 用户的核心顾虑:让 LLM 拿到上下文但**不要把上下文吐出来**。 // 这里的不变量保证「不复读」靠两层防御: @@ -2690,16 +2895,15 @@ mod tests { assert!(!request_text.contains(r#""temperature":"#)); let body = concat!( - "data: {\"type\":\"response.output_text.delta\",\"delta\":\"最终\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"最终🙂\"}\n\n", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"文本。\"}\n\n", "data: {\"type\":\"response.completed\",\"response\":{\"output\":[]}}\n\n" ); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body + let split = split_inside(body, "🙂"); + write_chunked_sse_response( + &mut stream, + &[&body.as_bytes()[..split], &body.as_bytes()[split..]], ); - stream.write_all(response.as_bytes()).unwrap(); }); let provider = CodexOAuthLLMProvider::new( @@ -2721,7 +2925,7 @@ mod tests { .await .unwrap(); - assert_eq!(output, "最终文本。"); + assert_eq!(output, "最终🙂文本。"); server.join().unwrap(); let _ = std::fs::remove_file(auth_path); } diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index e9be34ba..fd7d78c0 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -2,7 +2,7 @@ //! //! 公开 API 三件套: //! - `type_unicode_chunk(text)` —— 阻塞地把一段文字逐 codepoint 当作键盘事件发出去, -//! 不动剪贴板。各平台用各自的原语。 +//! 不动剪贴板。各平台用各自的原语;返回确认成功发送的字符数。 //! - `switch_to_ascii(app)` —— 仅 macOS 有效;切到 ABC 输入源以绕过 CJK / 日文 IME //! 对 Unicode 字符串事件的拦截。Windows / Linux 上是 no-op。 //! - `restore_input_source(app, prev)` —— 配对调用,恢复 macOS 上的原输入源。 @@ -41,6 +41,13 @@ use tauri::{AppHandle, Runtime}; #[derive(Debug, thiserror::Error)] pub enum TypeError { + #[allow(dead_code)] + #[error("{source} after {typed_chars} chars were sent")] + Partial { + typed_chars: usize, + #[source] + source: Box, + }, #[cfg(target_os = "macos")] #[error("CGEventSourceCreate returned null")] SourceAllocFailed, @@ -61,6 +68,15 @@ pub enum TypeError { EnigoText(String), } +impl TypeError { + pub fn typed_chars(&self) -> usize { + match self { + TypeError::Partial { typed_chars, .. } => *typed_chars, + _ => 0, + } + } +} + #[derive(Debug, thiserror::Error)] pub enum TisError { #[error("dispatch to main thread failed: {0}")] @@ -91,18 +107,33 @@ mod macos_impl { unsafe impl Send for PreviousInputSource {} unsafe impl Sync for PreviousInputSource {} - pub fn type_unicode_chunk(text: &str) -> Result<(), TypeError> { + pub fn type_unicode_chunk(text: &str) -> Result { if text.is_empty() { - return Ok(()); + return Ok(0); } if is_secure_input_enabled() { return Err(TypeError::SecureInputActive); } + let mut typed_chars = 0; for ch in text.chars() { - send_one_codepoint(ch)?; + if let Err(e) = send_one_codepoint(ch) { + return Err(partial_or_original(typed_chars, e)); + } + typed_chars += 1; std::thread::sleep(INTER_KEYSTROKE_DELAY); } - Ok(()) + Ok(typed_chars) + } + + fn partial_or_original(typed_chars: usize, source: TypeError) -> TypeError { + if typed_chars == 0 { + source + } else { + TypeError::Partial { + typed_chars, + source: Box::new(source), + } + } } fn send_one_codepoint(ch: char) -> Result<(), TypeError> { @@ -299,16 +330,36 @@ mod windows_impl { /// 兜底跟 macOS 对齐。 const INTER_KEYSTROKE_DELAY: Duration = Duration::from_millis(1); - pub fn type_unicode_chunk(text: &str) -> Result<(), TypeError> { + pub fn type_unicode_chunk(text: &str) -> Result { if text.is_empty() { - return Ok(()); + return Ok(0); } - for unit in text.encode_utf16() { - send_utf16_unit(unit, false)?; - send_utf16_unit(unit, true)?; + let mut typed_chars = 0; + for ch in text.chars() { + let mut buf = [0u16; 2]; + for unit in ch.encode_utf16(&mut buf) { + if let Err(e) = send_utf16_unit(*unit, false) { + return Err(partial_or_original(typed_chars, e)); + } + if let Err(e) = send_utf16_unit(*unit, true) { + return Err(partial_or_original(typed_chars, e)); + } + } + typed_chars += 1; std::thread::sleep(INTER_KEYSTROKE_DELAY); } - Ok(()) + Ok(typed_chars) + } + + fn partial_or_original(typed_chars: usize, source: TypeError) -> TypeError { + if typed_chars == 0 { + source + } else { + TypeError::Partial { + typed_chars, + source: Box::new(source), + } + } } fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), TypeError> { @@ -366,21 +417,34 @@ mod linux_impl { pub struct PreviousInputSource; - /// 用 enigo 一次 `text()` 把整段 chunk 发出去。X11 上走 XTest 稳定;Wayland 上 - /// 看 compositor 是否给 libei 权限,stock GNOME-Wayland 通常拒绝 —— 此处 - /// 失败返回 `EnigoInit` / `EnigoText`,调用方应回落到一次性。 + /// 用 enigo 逐字符发出 chunk。X11 上走 XTest 稳定;Wayland 上看 compositor 是否 + /// 给 libei 权限,stock GNOME-Wayland 通常拒绝 —— 失败时尽量返回已成功字符数, + /// 让调用方的 history / clipboard 与实际落屏内容一致。 /// /// 不处理 fcitx / ibus 输入法切换 —— Linux 输入法栈与 X11 合成事件的交互非常 /// 碎片化,v1 实验阶段直接交给用户保证当前输入源是英文键盘。 - pub fn type_unicode_chunk(text: &str) -> Result<(), TypeError> { + pub fn type_unicode_chunk(text: &str) -> Result { if text.is_empty() { - return Ok(()); + return Ok(0); } let mut enigo = Enigo::new(&Settings::default()).map_err(|e| TypeError::EnigoInit(e.to_string()))?; - enigo - .text(text) - .map_err(|e| TypeError::EnigoText(e.to_string())) + let mut typed_chars = 0; + for ch in text.chars() { + if let Err(e) = enigo.text(&ch.to_string()) { + let source = TypeError::EnigoText(e.to_string()); + return if typed_chars == 0 { + Err(source) + } else { + Err(TypeError::Partial { + typed_chars, + source: Box::new(source), + }) + }; + } + typed_chars += 1; + } + Ok(typed_chars) } pub async fn switch_to_ascii( @@ -397,17 +461,58 @@ mod linux_impl { } } +#[cfg(test)] +mod tests { + use super::TypeError; + + #[test] + fn type_error_partial_reports_typed_chars() { + let err = TypeError::Partial { + typed_chars: 2, + source: Box::new(platform_error()), + }; + + assert_eq!(err.typed_chars(), 2); + } + + #[test] + fn plain_type_error_reports_zero_typed_chars() { + assert_eq!(platform_error().typed_chars(), 0); + } + + #[cfg(target_os = "macos")] + fn platform_error() -> TypeError { + TypeError::EventAllocFailed + } + + #[cfg(target_os = "windows")] + fn platform_error() -> TypeError { + TypeError::SendInputFailed("fail".into()) + } + + #[cfg(target_os = "linux")] + fn platform_error() -> TypeError { + TypeError::EnigoText("fail".into()) + } +} + // ═══════════════════════════════════════════════════════════════════════════ // 公共导出(按 cfg 分发到对应实现) // ═══════════════════════════════════════════════════════════════════════════ #[cfg(target_os = "macos")] #[allow(unused_imports)] -pub use macos_impl::{restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource}; +pub use macos_impl::{ + restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource, +}; #[cfg(target_os = "windows")] #[allow(unused_imports)] -pub use windows_impl::{restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource}; +pub use windows_impl::{ + restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource, +}; #[cfg(target_os = "linux")] #[allow(unused_imports)] -pub use linux_impl::{restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource}; +pub use linux_impl::{ + restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource, +}; From 6baca19e055e121533affb8cc4fe632a82165e20 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Tue, 12 May 2026 12:48:31 +0800 Subject: [PATCH 02/55] refactor(settings): split Settings.tsx into per-section modules + simplify Advanced labels (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings.tsx (2835 → ~2150 lines) split by logical responsibility: each tab now lives in src/pages/settings/. Pure mechanical extraction — no behavior change. - settings/shared.tsx — SettingRow, Toggle, inputStyle, AsrPresetId - settings/AdvancedSection.tsx - settings/ShortcutsSection.tsx - settings/PermissionsSection.tsx (incl. PermissionPill / HotkeyStatusPill / WindowsImeStatusPill) - settings/LanguageSection.tsx - settings/AboutUpdateControl.tsx - Settings.tsx keeps RecordingSection + ProvidersSection (their helper tangle is large; follow-up PR), re-exports Toggle / AboutUpdateControl so SettingsModal's existing imports keep working. Advanced settings labels: shortened verbose copy across zh-CN / zh-TW / en / ja / ko. "启用流式输入" → "流式输入", "同步写入剪贴板" → "同步到剪贴板", desc / hint blocks trimmed; the toggle already conveys enable/disable so the verb prefix was redundant. Verified: tsc clean, vite build green, cargo check green, cargo test --lib 222/222 passing. Co-authored-by: baiqing --- openless-all/app/src/i18n/en.ts | 14 +- openless-all/app/src/i18n/ja.ts | 14 +- openless-all/app/src/i18n/ko.ts | 14 +- openless-all/app/src/i18n/zh-CN.ts | 14 +- openless-all/app/src/i18n/zh-TW.ts | 14 +- openless-all/app/src/pages/Settings.tsx | 709 +----------------- .../src/pages/settings/AboutUpdateControl.tsx | 38 + .../src/pages/settings/AdvancedSection.tsx | 252 +++++++ .../src/pages/settings/LanguageSection.tsx | 60 ++ .../src/pages/settings/PermissionsSection.tsx | 206 +++++ .../src/pages/settings/ShortcutsSection.tsx | 122 +++ .../app/src/pages/settings/shared.tsx | 67 ++ 12 files changed, 793 insertions(+), 731 deletions(-) create mode 100644 openless-all/app/src/pages/settings/AboutUpdateControl.tsx create mode 100644 openless-all/app/src/pages/settings/AdvancedSection.tsx create mode 100644 openless-all/app/src/pages/settings/LanguageSection.tsx create mode 100644 openless-all/app/src/pages/settings/PermissionsSection.tsx create mode 100644 openless-all/app/src/pages/settings/ShortcutsSection.tsx create mode 100644 openless-all/app/src/pages/settings/shared.tsx diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 8cdb2016..79ba8934 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -487,16 +487,16 @@ export const en: typeof zhCN = { streamingInsertTitle: 'Streaming insertion', streamingInsertTitleLinux: 'Streaming insertion (experimental)', streamingInsertDesc: - 'When on, polished output streams to the cursor character by character as the LLM SSE arrives — perceived latency drops noticeably. Requires an OpenAI-compatible LLM provider (Ark / DeepSeek / etc.), not Raw / translation mode, and focus that is not a Secure Input field (password / 1Password). When any condition fails the path transparently falls back to one-shot paste — identical to having the toggle off.', - streamingInsertLabel: 'Enable streaming insertion', + 'Streams polished text to the cursor character by character, noticeably lowering perceived latency. Falls back to one-shot paste when the prerequisites are not met (non OpenAI-compatible LLM, Raw / translation mode, or Secure Input focus).', + streamingInsertLabel: 'Streaming insertion', streamingInsertHintMac: - 'During streaming the input source is temporarily switched to ABC (so CJK / Japanese IMEs cannot intercept the Unicode keystrokes) and restored when the session ends.', + 'Temporarily switches the input source to ABC so CJK IMEs cannot intercept keystrokes; restored on session end.', streamingInsertHintWindows: - 'Uses SendInput Unicode to type characters directly, bypassing TSF / IME — no input-method switching needed.', + 'SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.', streamingInsertHintLinux: - 'Experimental: uses enigo + XTest to synthesize keystrokes. Stable on X11; on Wayland it depends on whether the compositor grants libei access — failures fall back automatically to one-shot insertion.', - streamingInsertSaveClipboardLabel: 'Also copy to clipboard', - streamingInsertSaveClipboardHint: 'After a successful streaming insert, write the final text to the system clipboard so Cmd+V can paste it again. Off = streaming never touches the clipboard.', + 'enigo + XTest synthesize keystrokes. Stable on X11; on Wayland it depends on the compositor — failures fall back automatically.', + streamingInsertSaveClipboardLabel: 'Copy to clipboard', + streamingInsertSaveClipboardHint: 'After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.', localAsrTitle: 'Local ASR models (experimental)', localAsrDesc: 'Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.', localAsrWarningShort: 'Local inference is slower; under-spec hardware may drop words.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index ff51143d..5a62f800 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -489,16 +489,16 @@ export const ja: typeof zhCN = { streamingInsertTitle: 'ストリーミング入力', streamingInsertTitleLinux: 'ストリーミング入力(実験的)', streamingInsertDesc: - 'ON にすると、整形済みテキストが SSE の到着に合わせて 1 文字ずつカーソル位置に挿入され、体感遅延が大幅に短くなります。条件:OpenAI 互換 LLM プロバイダ(Ark / DeepSeek 等)+ Raw / 翻訳モード以外 + フォーカスがパスワード欄など Secure Input 制御でないこと。いずれかの条件を満たさない場合は自動で従来の一括貼り付けに戻り、OFF と同じ挙動になります。', - streamingInsertLabel: 'ストリーミング入力を有効化', + '整形済みテキストを SSE 到着に合わせて 1 文字ずつカーソルへ流し込み、体感遅延を大幅に低減します。条件を満たさない場合(OpenAI 互換以外の LLM、Raw / 翻訳モード、Secure Input フォーカス)は一括貼り付けへ自動フォールバックします。', + streamingInsertLabel: 'ストリーミング入力', streamingInsertHintMac: - 'ストリーミング中は一時的に ABC 入力ソースへ切り替わります(中国語 / 日本語 IME による Unicode 文字傍受を回避)。セッション終了時に自動で元に戻ります。', + 'ストリーミング中は一時的に ABC 入力ソースへ切替(CJK IME による傍受を回避)。セッション終了時に自動で元へ戻ります。', streamingInsertHintWindows: - 'SendInput Unicode で文字イベントを直接送信し、TSF / IME を迂回します。入力ソースの切り替えは不要です。', + 'SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。', streamingInsertHintLinux: - '実験的:enigo + XTest によるキーボードイベント合成。X11 では安定動作、Wayland では compositor が libei を許可するかどうかに依存し、失敗時は自動的に一括挿入にフォールバックします。', - streamingInsertSaveClipboardLabel: 'クリップボードにも保存', - streamingInsertSaveClipboardHint: 'ストリーミング入力成功後、最終テキストをシステムクリップボードに書き込んで Cmd+V で再貼り付けできるようにします。OFF にすると、ストリーミング処理はクリップボードに一切触れません。', + 'enigo + XTest でキー合成。X11 は安定、Wayland は compositor 依存で失敗時は自動フォールバック。', + streamingInsertSaveClipboardLabel: 'クリップボードに保存', + streamingInsertSaveClipboardHint: '挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。', localAsrTitle: 'ローカル ASR モデル(実験的)', localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。', localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 35dfb31c..715a0d7f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -489,16 +489,16 @@ export const ko: typeof zhCN = { streamingInsertTitle: '스트리밍 입력', streamingInsertTitleLinux: '스트리밍 입력 (실험적)', streamingInsertDesc: - '활성화하면 정제된 텍스트가 SSE 도착에 맞춰 글자 단위로 커서에 입력되어 체감 지연이 크게 줄어듭니다. 조건: OpenAI 호환 LLM 제공자 (Ark / DeepSeek 등) + Raw / 번역 모드가 아님 + 포커스가 비밀번호 입력란 등 Secure Input 컨트롤이 아님. 조건 중 하나라도 충족되지 않으면 기존 일괄 붙여넣기 경로로 자동 폴백되어, OFF 와 동일한 동작을 합니다.', - streamingInsertLabel: '스트리밍 입력 사용', + '정제된 텍스트가 SSE 도착에 맞춰 글자 단위로 커서에 입력되어 체감 지연이 크게 줄어듭니다. 조건을 충족하지 못하면 (OpenAI 호환이 아닌 LLM, Raw / 번역 모드, Secure Input 포커스) 일괄 붙여넣기로 자동 폴백됩니다.', + streamingInsertLabel: '스트리밍 입력', streamingInsertHintMac: - '스트리밍 중에는 입력 소스가 일시적으로 ABC 로 전환됩니다 (중국어 / 일본어 IME 의 Unicode 문자 가로채기 방지). 세션 종료 시 자동으로 원래 입력 소스로 복귀합니다.', + '스트리밍 중 입력 소스를 ABC 로 임시 전환 (CJK IME 가로채기 방지). 세션 종료 시 자동 복원.', streamingInsertHintWindows: - 'SendInput Unicode 로 문자 이벤트를 직접 전송하여 TSF / IME 를 우회합니다. 입력 소스 전환이 필요하지 않습니다.', + 'SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.', streamingInsertHintLinux: - '실험적: enigo + XTest 로 키보드 이벤트를 합성합니다. X11 에서는 안정적이며, Wayland 에서는 compositor 가 libei 를 허용하는지에 따라 다르고, 실패 시 자동으로 일괄 삽입으로 폴백됩니다.', - streamingInsertSaveClipboardLabel: '클립보드에도 저장', - streamingInsertSaveClipboardHint: '스트리밍 입력 성공 후 최종 텍스트를 시스템 클립보드에 기록하여 Cmd+V 로 재붙여넣기를 할 수 있도록 합니다. 끄면 스트리밍 동안 클립보드를 일절 건드리지 않습니다.', + 'enigo + XTest 로 키 합성. X11 안정; Wayland 는 compositor 의존이며 실패 시 자동 폴백.', + streamingInsertSaveClipboardLabel: '클립보드에 저장', + streamingInsertSaveClipboardHint: '삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.', localAsrTitle: '로컬 ASR 모델 (실험적)', localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.', localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 987e0a31..df5f49a9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -485,16 +485,16 @@ export const zhCN = { streamingInsertTitle: '流式输入', streamingInsertTitleLinux: '流式输入(实验)', streamingInsertDesc: - '开启后润色 SSE 边到达边逐字落到光标,感知延迟显著降低。需要满足:OpenAI 兼容 LLM 提供商(Ark / DeepSeek 等)+ 非 Raw / 翻译模式 + 焦点不是密码框等 Secure Input 控件。任何条件不满足都自动回落到原一次性粘贴路径,行为与关闭等价。', - streamingInsertLabel: '启用流式输入', + '润色一边到达一边逐字落到光标,显著降低感知延迟。条件不满足时(非 OpenAI 兼容 LLM、Raw / 翻译模式、Secure Input 焦点)自动回落到一次性粘贴。', + streamingInsertLabel: '流式输入', streamingInsertHintMac: - '流式期间临时切换到 ABC 输入源(规避中文 / 日文 IME 拦截 Unicode 字符),session 结束自动切回。', + '临时切到 ABC 输入源,避免 CJK IME 拦截,会话结束后自动切回。', streamingInsertHintWindows: - 'SendInput Unicode 直接发字符事件,绕过 TSF / IME,不切输入法。', + 'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。', streamingInsertHintLinux: - '实验性:enigo + XTest 模拟键盘事件,X11 稳定;Wayland 看 compositor 是否允许 libei,失败自动回落到一次性插入。', - streamingInsertSaveClipboardLabel: '同步写入剪贴板', - streamingInsertSaveClipboardHint: '流式输入成功后把这次的最终文本写到系统剪贴板,方便 Cmd+V 再次粘贴。关闭后流式过程完全不动剪贴板。', + 'enigo + XTest 合成按键。X11 稳定;Wayland 取决于 compositor,失败自动回落。', + streamingInsertSaveClipboardLabel: '同步到剪贴板', + streamingInsertSaveClipboardHint: '插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。', localAsrTitle: '本地 ASR 模型(实验性)', localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。', localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 35e82844..ad0295a6 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -487,16 +487,16 @@ export const zhTW: typeof zhCN = { streamingInsertTitle: '流式輸入', streamingInsertTitleLinux: '流式輸入(實驗)', streamingInsertDesc: - '開啟後潤色 SSE 邊到達邊逐字落到游標,感知延遲顯著降低。需要滿足:OpenAI 相容 LLM 提供商(Ark / DeepSeek 等)+ 非 Raw / 翻譯模式 + 焦點不是密碼框等 Secure Input 控件。任何條件不滿足都自動回落到原一次性貼上路徑,行為與關閉等價。', - streamingInsertLabel: '啟用流式輸入', + '潤色一邊到達一邊逐字落到游標,顯著降低感知延遲。條件不滿足時(非 OpenAI 相容 LLM、Raw / 翻譯模式、Secure Input 焦點)自動回落到一次性貼上。', + streamingInsertLabel: '流式輸入', streamingInsertHintMac: - '流式期間臨時切換到 ABC 輸入源(規避中文 / 日文 IME 攔截 Unicode 字元),session 結束自動切回。', + '臨時切到 ABC 輸入源,避免 CJK IME 攔截,會話結束後自動切回。', streamingInsertHintWindows: - 'SendInput Unicode 直接發字元事件,繞過 TSF / IME,不切輸入法。', + 'SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。', streamingInsertHintLinux: - '實驗性:enigo + XTest 模擬鍵盤事件,X11 穩定;Wayland 看 compositor 是否允許 libei,失敗自動回落到一次性插入。', - streamingInsertSaveClipboardLabel: '同步寫入剪貼簿', - streamingInsertSaveClipboardHint: '流式輸入成功後把這次的最終文字寫到系統剪貼簿,方便 Cmd+V 再次貼上。關閉後流式過程完全不動剪貼簿。', + 'enigo + XTest 合成按鍵。X11 穩定;Wayland 取決於 compositor,失敗自動回落。', + streamingInsertSaveClipboardLabel: '同步到剪貼簿', + streamingInsertSaveClipboardHint: '插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。', localAsrTitle: '本地 ASR 模型(實驗性)', localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。', localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index d990a040..2a333dd6 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1,68 +1,42 @@ // Settings.tsx — ported verbatim from design_handoff_openless/pages.jsx::Settings. -// Internal sub-sections (Recording / Providers / Shortcuts / Permissions / Language / About) -// keep their inline-style literals 1:1 with the source JSX. +// Section 拆分见 settings/ 子目录;本文件保留 dispatcher + RecordingSection + ProvidersSection(含其内嵌助手), +// 其他 section 已挪出。原导出 Toggle / AboutUpdateControl / SettingsSectionId 通过 re-export 维持向后兼容。 import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { ShortcutRecorder } from '../components/ShortcutRecorder'; -import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { detectOS } from '../components/WindowChrome'; -import { LocalAsr } from './LocalAsr'; -import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; import { - defaultQaShortcut, getHotkeyBindingCodes, getHotkeyBindingLabel, getHotkeyCodeLabel, } from '../lib/hotkey'; import { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder'; import { - checkAccessibilityPermission, - checkMicrophonePermission, - getHotkeyStatus, - getWindowsImeStatus, isTauri, listMicrophoneDevices, openExternal, - openSystemSettings, listProviderModels, readCredential, - requestAccessibilityPermission, - requestMicrophonePermission, setActiveAsrProvider, setActiveLlmProvider, setCredential, setDictationHotkey, - setOpenAppHotkey, - setQaHotkey, - setSwitchStyleHotkey, - setTranslationHotkey, startMicrophoneLevelMonitor, stopMicrophoneLevelMonitor, validateProviderCredentials, } from '../lib/ipc'; import type { - HotkeyCapability, HotkeyBinding, HotkeyMode, - HotkeyStatus, HotkeyTrigger, MicrophoneDevice, PasteShortcut, - PermissionStatus, - WindowsImeStatus, } from '../lib/types'; import { emitSaved } from '../lib/savedEvent'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; -import i18n, { - FOLLOW_SYSTEM, - getLocalePreference, - outputPrefsForLocale, - setLocalePreference, - type SupportedLocale, -} from '../i18n'; import { Btn, Card, Collapsible, PageHeader, Pill } from './_atoms'; import { deleteLocalAsrModel, @@ -71,6 +45,14 @@ import { type LocalAsrModelStatus, type LocalAsrSettings, } from '../lib/localAsr'; +import { SettingRow, Toggle, inputStyle, type AsrPresetId } from './settings/shared'; +import { AdvancedSection } from './settings/AdvancedSection'; +import { ShortcutsSection } from './settings/ShortcutsSection'; +import { PermissionsSection } from './settings/PermissionsSection'; +import { LanguageSection } from './settings/LanguageSection'; + +export { Toggle } from './settings/shared'; +export { AboutUpdateControl } from './settings/AboutUpdateControl'; /// Settings → ASR 选了 local-qwen3 时触发跳到「模型设置」页 + 关 Settings modal。 /// FloatingShell 监听同名事件做 setCurrentTab('localAsr') + setSettingsOpen(false)。 @@ -209,25 +191,6 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set ); } -interface SettingRowProps { - label: string; - desc?: string; - children: ReactNode; - controlWidth?: number | string; -} - -function SettingRow({ label, desc, children, controlWidth }: SettingRowProps) { - return ( -
-
-
{label}
- {desc &&
{desc}
} -
-
{children}
-
- ); -} - function RecordingSection() { const { t } = useTranslation(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); @@ -1134,28 +1097,6 @@ function AutostartRow() { ); } -export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) { - return ( - - ); -} - function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) { const { t } = useTranslation(); return ( @@ -1271,7 +1212,8 @@ const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration'; // 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`; // 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id; // 3. 在 i18n 的 `settings.providers.presets.` 加文案。 -const ASR_PRESETS = [ +// `AsrPresetId` 定义在 settings/shared.ts,AdvancedSection / ProvidersSection 共用同一份。 +const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: string; model: string }> = [ { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, { id: 'bailian', nameKey: 'asrBailian', baseUrl: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/', model: 'fun-asr-realtime' }, { id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' }, @@ -1281,9 +1223,7 @@ const ASR_PRESETS = [ { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' }, // 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「模型设置」页下载与切换。 { id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' }, -] as const; - -type AsrPresetId = typeof ASR_PRESETS[number]['id']; +]; function ProvidersSection() { const { t } = useTranslation(); @@ -1611,259 +1551,6 @@ function ProvidersSection() { ); } -/// 「高级」标签页——本地推理 / 实验性开关都集中在这里。 -/// -/// 设计: -/// - 主 Providers 下拉只列云端选项;本地推理 (local-qwen3 / foundry-local-whisper) -/// 完全收纳到此栏,新手不会在主流程里误开 CPU 推理。 -/// - 启用本地推理时弹出**屏幕中央**的 modal(背景模糊)——一个框、一段短话、确认/取消。 -/// - 旧的「模型设置」独立页 (LocalAsr.tsx 作为 nav tab) 已下线,模型管理 UI -/// 通过 内嵌在此处统一管理。 -/// -/// 平台对称(每端只露本机有后端的引擎,另一端的引擎以 disabled 行 + "本平台暂不支持"提示露出): -/// - macOS: Qwen3-ASR 主行可启用;Foundry **不显示**(macOS 不展示 Windows 端模型内容)。 -/// - Windows: Foundry 主行可启用;Qwen3 行 disabled,desc 为 notSupportedHere。 -/// - Linux: 「该平台暂未支持本地 ASR 模型集成」整段兜底。 -function AdvancedSection() { - const { t } = useTranslation(); - const { prefs, updatePrefs } = useHotkeySettings(); - const os = detectOS(); - const isMac = os === 'mac'; - const isWin = os === 'win'; - const isLinux = os === 'linux'; - const platformSupported = isMac || isWin; - const switchSeqRef = useRef(0); - const [busy, setBusy] = useState(false); - // 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切; - // 点取消 → 回到 null。一次只允许一个 modal。 - const [pendingTarget, setPendingTarget] = useState(null); - - const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId; - const isOnLocalQwen3 = activeAsrProvider === 'local-qwen3'; - const isOnFoundry = activeAsrProvider === 'foundry-local-whisper'; - const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry; - - const requestEnable = (target: AsrPresetId) => { - setPendingTarget(target); - }; - - const performSwitch = async (target: AsrPresetId) => { - setBusy(true); - const seq = ++switchSeqRef.current; - try { - await setActiveAsrProvider(target); - if (seq !== switchSeqRef.current) return; - if (prefs) { - await updatePrefs({ ...prefs, activeAsrProvider: target }); - } - } finally { - if (seq === switchSeqRef.current) { - setBusy(false); - setPendingTarget(null); - } - } - }; - - const pendingNameKey = - pendingTarget === 'local-qwen3' ? 'asrLocalQwen3' - : pendingTarget === 'foundry-local-whisper' ? 'asrFoundryLocalWhisper' - : null; - - return ( - <> - {/* ─── 屏幕中央确认 modal(背景模糊) ───────────────────────────── - 点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */} - {pendingTarget && pendingNameKey && ( -
{ - if (e.target === e.currentTarget && !busy) setPendingTarget(null); - }}> - -
- ⚠️ {t('settings.advanced.confirmEnableLocalTitle')} -
-
- {t('settings.advanced.confirmEnableLocalBody', { - target: t(`settings.providers.presets.${pendingNameKey}`), - })} -
-
- setPendingTarget(null)}> - {t('common.cancel')} - - void performSwitch(pendingTarget)}> - {t('settings.advanced.confirm')} - -
-
-
- )} - - {/* ─── 流式输入(全平台 opt-in) ─────────────────────────────────── - 润色 SSE 一边到达一边逐字模拟键盘事件落到光标。开启后用户感知到的处理 - 时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径): - - macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底) - - Windows:SendInput Unicode,绕过 TSF / IME,不需要切输入法 - - Linux(实验):enigo XTest;Wayland compositor 拒绝 libei 时失败回落 - - 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级 - - 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落 - 每个平台用各自的 hint key,互相不显示对方平台的细节。 */} - -
- {t(isLinux - ? 'settings.advanced.streamingInsertTitleLinux' - : 'settings.advanced.streamingInsertTitle')} -
-
- {t('settings.advanced.streamingInsertDesc')} -
- - { - if (prefs) void updatePrefs({ ...prefs, streamingInsert: next }); - }} - /> - - - { - if (prefs) void updatePrefs({ ...prefs, streamingInsertSaveClipboard: next }); - }} - /> - -
- - - {/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 */} -
-
-
{t('settings.advanced.localAsrTitle')}
-
- {t('settings.advanced.localAsrDesc')} -
-
-
- ⚠️ {t('settings.advanced.localAsrWarningShort')} -
-
- - {!platformSupported ? ( -
- {t('settings.advanced.platformNotSupported')} -
- ) : ( - <> - {/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off - + 不可点 + desc=notSupportedHere,跟"本平台不可用"视觉一致。跨平台 - 异常(Windows profile 同步到 local-qwen3)时 active 状态靠下方独立 - "禁用本地 ASR" 行兜底,避免 Toggle ON + desc 说不支持的自相矛盾感 - (pr_agent #403 'Stale Windows state' 修法)。 */} - -
- { - if (next) requestEnable('local-qwen3'); - else void performSwitch('volcengine'); - } : undefined} - /> -
-
- - {/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */} - {isWin && ( - -
- { - if (next) requestEnable('foundry-local-whisper'); - else void performSwitch('volcengine'); - } : undefined} - /> -
-
- )} - - )} - - {/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出: - - Linux / 不支持平台:根本没有任何引擎行 - - 跨平台异常(macOS profile 同步到 foundry / Windows profile 同步到 qwen3): - 本机引擎 Toggle 是 off,关不动异常 active 的对方引擎 - 否则平台本机 Toggle 自身就能 off → 关停,重复 disable 行徒增视觉。 */} - {isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && isOnFoundry)) && ( - -
- void performSwitch('volcengine')}> - {t('settings.advanced.disable')} - -
-
- )} -
- - {/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local) - inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */} - {platformSupported && } - - ); -} type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; @@ -2164,15 +1851,6 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue ); } -const inputStyle: CSSProperties = { - flex: 1, height: 32, padding: '0 10px', - border: '0.5px solid var(--ol-line-strong)', - borderRadius: 8, fontSize: 12.5, - fontFamily: 'inherit', outline: 'none', - background: 'var(--ol-surface-2)', - width: '100%', maxWidth: 360, - transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', -}; const miniBtnStyle: CSSProperties = { height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', @@ -2264,371 +1942,10 @@ const iconBtnStyle: CSSProperties = { transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', }; -function ShortcutsSection() { - const { t } = useTranslation(); - const { prefs, hotkey, capability, updatePrefs: savePrefs } = useHotkeySettings(); - if (!prefs || !hotkey || !capability) { - return ( - -
{t('common.loading')}
-
- ); - } - const desc = capability.requiresAccessibilityPermission - ? t('settings.shortcuts.descAcc') - : t('settings.shortcuts.descNoAcc'); - const readonlyRows: Array<[string, string]> = [ - [t('settings.shortcuts.cancel'), 'Esc'], - [t('settings.shortcuts.confirm'), t('settings.shortcuts.confirmHint')], - ]; - return ( - -
{t('settings.shortcuts.title')}
-
{desc}
- -
- { - await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); - }} - /> -
- {hotkey.mode === 'hold' ? t('hotkey.modeHoldSuffix') : t('hotkey.modeToggleSuffix')} -
-
-
- - { - await setTranslationHotkey(binding); - await savePrefs({ ...prefs, translationHotkey: binding }); - }} - /> - - - {prefs.qaHotkey ? ( - { - await setQaHotkey(binding); - await savePrefs({ ...prefs, qaHotkey: binding }); - }} - /> - ) : ( - - )} - - - { - await setSwitchStyleHotkey(binding); - await savePrefs({ ...prefs, switchStyleHotkey: binding }); - }} - /> - - - { - await setOpenAppHotkey(binding); - await savePrefs({ ...prefs, openAppHotkey: binding }); - }} - /> - - {readonlyRows.map(([k, v]) => ( - - {v} - - ))} -
- ); -} -function PermissionsSection() { - const { t } = useTranslation(); - const [accessibility, setAccessibility] = useState('loading'); - const [microphone, setMicrophone] = useState('loading'); - const [hotkey, setHotkey] = useState(null); - const [windowsIme, setWindowsIme] = useState(null); - const { capability } = useHotkeySettings(); - const refreshPermissions = async () => { - const [a, m] = await Promise.all([ - checkAccessibilityPermission(), - checkMicrophonePermission(), - ]); - setAccessibility(a); - setMicrophone(m); - }; - - const refreshHotkey = async () => { - setHotkey(await getHotkeyStatus()); - }; - - const refreshWindowsIme = async () => { - setWindowsIme(await getWindowsImeStatus()); - }; - - useEffect(() => { - refreshPermissions(); - refreshHotkey(); - refreshWindowsIme(); - const hotkeyId = window.setInterval(refreshHotkey, 1000); - // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 - const permissionId = window.setInterval(refreshPermissions, 10000); - const onFocus = () => { - refreshPermissions(); - refreshHotkey(); - refreshWindowsIme(); - }; - window.addEventListener('focus', onFocus); - return () => { - window.clearInterval(hotkeyId); - window.clearInterval(permissionId); - window.removeEventListener('focus', onFocus); - }; - }, []); - - const reRequestAccessibility = async () => { - await requestAccessibilityPermission(); - refreshPermissions(); - }; - - const reRequestMicrophone = async () => { - if (microphone === 'denied' || microphone === 'restricted') { - await openSystemSettings('microphone'); - refreshPermissions(); - return; - } - const status = await requestMicrophonePermission(); - setMicrophone(status); - if (status === 'denied' || status === 'restricted') { - await openSystemSettings('microphone'); - } - refreshPermissions(); - }; - - const desc = capability?.requiresAccessibilityPermission - ? t('settings.permissions.descAcc') - : t('settings.permissions.descNoAcc'); - - return ( - -
{t('settings.permissions.title')}
-
- {desc} -
- -
- - {microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && ( - - {microphone === 'denied' || microphone === 'restricted' ? t('settings.permissions.openSystem') : t('settings.permissions.grant')} - - )} -
-
- {capability?.requiresAccessibilityPermission && ( - -
- - {accessibility !== 'granted' && accessibility !== 'notApplicable' && ( - - {t('settings.permissions.grant')} - - )} -
-
- )} - -
- {hotkey?.message && ( - - {hotkey.message} - - )} - -
-
- {windowsIme?.state !== 'notWindows' && ( - -
- {windowsIme && ( - - {t(`settings.permissions.windowsIme.${windowsIme.state}`)} - - )} - -
-
- )} - -
- {t('settings.permissions.networkOk')} -
-
-
- ); -} - -function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) { - const { t } = useTranslation(); - if (status === 'loading') { - return {t('settings.permissions.checking')}; - } - if (status === 'granted') { - return {t('settings.permissions.granted')}; - } - if (status === 'notApplicable') { - return {t('settings.permissions.notApplicable')}; - } - if (status === 'denied' || status === 'restricted') { - return {t('settings.permissions.denied')}; - } - return {t('settings.permissions.indeterminate')}; -} - -function LanguageSection() { - const { t } = useTranslation(); - const { updatePrefs } = useHotkeySettings(); - const [pref, setPref] = useState(getLocalePreference()); - - const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => { - setPref(next); - const resolved = await setLocalePreference(next); - const localePrefs = outputPrefsForLocale(resolved); - await updatePrefs(current => { - if ( - current.chineseScriptPreference === localePrefs.chineseScriptPreference && - current.outputLanguagePreference === localePrefs.outputLanguagePreference - ) { - return current; - } - return { ...current, ...localePrefs }; - }); - }; - - return ( - -
{t('settings.language.title')}
-
{t('settings.language.desc')}
- - - -
- {t('settings.language.restartHint')} -
-
- ); -} - -// AboutSection 已移除:内容并入 SettingsModal 的 AboutMini,避免设置内外两个"关于"重复入口。 - -export function AboutUpdateControl({ tagline }: { tagline: string }) { - const { t } = useTranslation(); - const u = useAutoUpdate(); - return ( - <> -
- {tagline} 路 {APP_VERSION_LABEL} - - {u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')} - -
- {(u.status === 'none' || u.status === 'error') && ( -
- {u.status === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')} -
- )} - {isDialogStatus(u.status) && ( - - )} - - ); -} - -function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) { - const { t } = useTranslation(); - if (!status) { - return {t('settings.permissions.checking')}; - } - if (status.state === 'installed') { - return {t('settings.permissions.hotkeyInstalled')}; - } - if (status.state === 'starting') { - return {t('settings.permissions.hotkeyStarting')}; - } - return {t('settings.permissions.hotkeyFailed')}; -} - -function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) { - const { t } = useTranslation(); - if (!status) { - return {t('settings.permissions.checking')}; - } - if (status.state === 'installed') { - return {t('settings.permissions.windowsImeInstalled')}; - } - return {t('settings.permissions.windowsImeUnavailable')}; -} - -function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { - if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); - if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); - return i18n.t('hotkey.adapter.rdev'); -} /// 本地 Qwen3-ASR 在 Settings → 服务商区里**不**让用户填空——展示当前激活模型 /// 是否已下载、列出所有已下载模型 + 删除按钮,并提示性能/质量预期,引导跳到 diff --git a/openless-all/app/src/pages/settings/AboutUpdateControl.tsx b/openless-all/app/src/pages/settings/AboutUpdateControl.tsx new file mode 100644 index 00000000..16ed5719 --- /dev/null +++ b/openless-all/app/src/pages/settings/AboutUpdateControl.tsx @@ -0,0 +1,38 @@ +// 关于面板里嵌入的"检查更新"控件。 +// 注:原 Settings 内的 About tab 已并入 SettingsModal 的 AboutMini;这里只是版本号 + 检查按钮。 + +import { useTranslation } from 'react-i18next'; +import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate'; +import { APP_VERSION_LABEL } from '../../lib/appVersion'; +import { Btn } from '../_atoms'; + +export function AboutUpdateControl({ tagline }: { tagline: string }) { + const { t } = useTranslation(); + const u = useAutoUpdate(); + return ( + <> +
+ {tagline} 路 {APP_VERSION_LABEL} + + {u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')} + +
+ {(u.status === 'none' || u.status === 'error') && ( +
+ {u.status === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')} +
+ )} + {isDialogStatus(u.status) && ( + + )} + + ); +} diff --git a/openless-all/app/src/pages/settings/AdvancedSection.tsx b/openless-all/app/src/pages/settings/AdvancedSection.tsx new file mode 100644 index 00000000..ce999819 --- /dev/null +++ b/openless-all/app/src/pages/settings/AdvancedSection.tsx @@ -0,0 +1,252 @@ +// 高级设置:流式输入开关 / 同步剪贴板 / 本地 ASR 模型启用与禁用。 +// 拆出自 Settings.tsx,逻辑零改动;i18n key 全部保持 `settings.advanced.*`。 + +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LocalAsr } from '../LocalAsr'; +import { detectOS } from '../../components/WindowChrome'; +import { setActiveAsrProvider } from '../../lib/ipc'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Btn, Card } from '../_atoms'; +import { SettingRow, Toggle, type AsrPresetId } from './shared'; + +export function AdvancedSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const os = detectOS(); + const isMac = os === 'mac'; + const isWin = os === 'win'; + const isLinux = os === 'linux'; + const platformSupported = isMac || isWin; + const switchSeqRef = useRef(0); + const [busy, setBusy] = useState(false); + // 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切; + // 点取消 → 回到 null。一次只允许一个 modal。 + const [pendingTarget, setPendingTarget] = useState(null); + + const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId; + const isOnLocalQwen3 = activeAsrProvider === 'local-qwen3'; + const isOnFoundry = activeAsrProvider === 'foundry-local-whisper'; + const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry; + + const requestEnable = (target: AsrPresetId) => { + setPendingTarget(target); + }; + + const performSwitch = async (target: AsrPresetId) => { + setBusy(true); + const seq = ++switchSeqRef.current; + try { + await setActiveAsrProvider(target); + if (seq !== switchSeqRef.current) return; + if (prefs) { + await updatePrefs({ ...prefs, activeAsrProvider: target }); + } + } finally { + if (seq === switchSeqRef.current) { + setBusy(false); + setPendingTarget(null); + } + } + }; + + const pendingNameKey = + pendingTarget === 'local-qwen3' ? 'asrLocalQwen3' + : pendingTarget === 'foundry-local-whisper' ? 'asrFoundryLocalWhisper' + : null; + + return ( + <> + {/* ─── 屏幕中央确认 modal(背景模糊) ───────────────────────────── + 点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */} + {pendingTarget && pendingNameKey && ( +
{ + if (e.target === e.currentTarget && !busy) setPendingTarget(null); + }}> + +
+ ⚠️ {t('settings.advanced.confirmEnableLocalTitle')} +
+
+ {t('settings.advanced.confirmEnableLocalBody', { + target: t(`settings.providers.presets.${pendingNameKey}`), + })} +
+
+ setPendingTarget(null)}> + {t('common.cancel')} + + void performSwitch(pendingTarget)}> + {t('settings.advanced.confirm')} + +
+
+
+ )} + + {/* ─── 流式输入(全平台 opt-in) ─────────────────────────────────── + 润色 SSE 一边到达一边逐字模拟键盘事件落到光标。开启后用户感知到的处理 + 时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径): + - macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底) + - Windows:SendInput Unicode,绕过 TSF / IME,不需要切输入法 + - Linux(实验):enigo XTest;Wayland compositor 拒绝 libei 时失败回落 + - 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级 + - 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落 + 每个平台用各自的 hint key,互相不显示对方平台的细节。 */} + +
+ {t(isLinux + ? 'settings.advanced.streamingInsertTitleLinux' + : 'settings.advanced.streamingInsertTitle')} +
+
+ {t('settings.advanced.streamingInsertDesc')} +
+ + { + if (prefs) void updatePrefs({ ...prefs, streamingInsert: next }); + }} + /> + + + { + if (prefs) void updatePrefs({ ...prefs, streamingInsertSaveClipboard: next }); + }} + /> + +
+ + + {/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 */} +
+
+
{t('settings.advanced.localAsrTitle')}
+
+ {t('settings.advanced.localAsrDesc')} +
+
+
+ ⚠️ {t('settings.advanced.localAsrWarningShort')} +
+
+ + {!platformSupported ? ( +
+ {t('settings.advanced.platformNotSupported')} +
+ ) : ( + <> + {/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off + + 不可点 + desc=notSupportedHere,跟"本平台不可用"视觉一致。跨平台 + 异常(Windows profile 同步到 local-qwen3)时 active 状态靠下方独立 + "禁用本地 ASR" 行兜底,避免 Toggle ON + desc 说不支持的自相矛盾感 + (pr_agent #403 'Stale Windows state' 修法)。 */} + +
+ { + if (next) requestEnable('local-qwen3'); + else void performSwitch('volcengine'); + } : undefined} + /> +
+
+ + {/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */} + {isWin && ( + +
+ { + if (next) requestEnable('foundry-local-whisper'); + else void performSwitch('volcengine'); + } : undefined} + /> +
+
+ )} + + )} + + {/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出: + - Linux / 不支持平台:根本没有任何引擎行 + - 跨平台异常(macOS profile 同步到 foundry / Windows profile 同步到 qwen3): + 本机引擎 Toggle 是 off,关不动异常 active 的对方引擎 + 否则平台本机 Toggle 自身就能 off → 关停,重复 disable 行徒增视觉。 */} + {isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && isOnFoundry)) && ( + +
+ void performSwitch('volcengine')}> + {t('settings.advanced.disable')} + +
+
+ )} +
+ + {/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local) + inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */} + {platformSupported && } + + ); +} diff --git a/openless-all/app/src/pages/settings/LanguageSection.tsx b/openless-all/app/src/pages/settings/LanguageSection.tsx new file mode 100644 index 00000000..9c909dd1 --- /dev/null +++ b/openless-all/app/src/pages/settings/LanguageSection.tsx @@ -0,0 +1,60 @@ +// 语言切换面板:跟随系统 / 简中 / 繁中 / 英文 / 日文 (Beta) / 韩文 (Beta)。 +// 切换语言同时把对应的 outputPrefs(中文偏好、输出语言)合并进 prefs。 + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { + FOLLOW_SYSTEM, + getLocalePreference, + outputPrefsForLocale, + setLocalePreference, + type SupportedLocale, +} from '../../i18n'; +import { Card } from '../_atoms'; +import { SettingRow, inputStyle } from './shared'; + +export function LanguageSection() { + const { t } = useTranslation(); + const { updatePrefs } = useHotkeySettings(); + const [pref, setPref] = useState(getLocalePreference()); + + const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => { + setPref(next); + const resolved = await setLocalePreference(next); + const localePrefs = outputPrefsForLocale(resolved); + await updatePrefs(current => { + if ( + current.chineseScriptPreference === localePrefs.chineseScriptPreference && + current.outputLanguagePreference === localePrefs.outputLanguagePreference + ) { + return current; + } + return { ...current, ...localePrefs }; + }); + }; + + return ( + +
{t('settings.language.title')}
+
{t('settings.language.desc')}
+ + + +
+ {t('settings.language.restartHint')} +
+
+ ); +} diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx new file mode 100644 index 00000000..014f57d1 --- /dev/null +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -0,0 +1,206 @@ +// 权限/连通性面板:麦克风 / 辅助功能 / 全局热键 / Windows IME / 网络。 +// 内含三个状态 Pill + 适配器名称翻译辅助函数。 + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '../../components/Icon'; +import { + checkAccessibilityPermission, + checkMicrophonePermission, + getHotkeyStatus, + getWindowsImeStatus, + openSystemSettings, + requestAccessibilityPermission, + requestMicrophonePermission, +} from '../../lib/ipc'; +import type { + HotkeyCapability, + HotkeyStatus, + PermissionStatus, + WindowsImeStatus, +} from '../../lib/types'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import i18n from '../../i18n'; +import { Btn, Card, Pill } from '../_atoms'; +import { SettingRow } from './shared'; + +export function PermissionsSection() { + const { t } = useTranslation(); + const [accessibility, setAccessibility] = useState('loading'); + const [microphone, setMicrophone] = useState('loading'); + const [hotkey, setHotkey] = useState(null); + const [windowsIme, setWindowsIme] = useState(null); + const { capability } = useHotkeySettings(); + + const refreshPermissions = async () => { + const [a, m] = await Promise.all([ + checkAccessibilityPermission(), + checkMicrophonePermission(), + ]); + setAccessibility(a); + setMicrophone(m); + }; + + const refreshHotkey = async () => { + setHotkey(await getHotkeyStatus()); + }; + + const refreshWindowsIme = async () => { + setWindowsIme(await getWindowsImeStatus()); + }; + + useEffect(() => { + refreshPermissions(); + refreshHotkey(); + refreshWindowsIme(); + const hotkeyId = window.setInterval(refreshHotkey, 1000); + // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 + const permissionId = window.setInterval(refreshPermissions, 10000); + const onFocus = () => { + refreshPermissions(); + refreshHotkey(); + refreshWindowsIme(); + }; + window.addEventListener('focus', onFocus); + return () => { + window.clearInterval(hotkeyId); + window.clearInterval(permissionId); + window.removeEventListener('focus', onFocus); + }; + }, []); + + const reRequestAccessibility = async () => { + await requestAccessibilityPermission(); + refreshPermissions(); + }; + + const reRequestMicrophone = async () => { + if (microphone === 'denied' || microphone === 'restricted') { + await openSystemSettings('microphone'); + refreshPermissions(); + return; + } + const status = await requestMicrophonePermission(); + setMicrophone(status); + if (status === 'denied' || status === 'restricted') { + await openSystemSettings('microphone'); + } + refreshPermissions(); + }; + + const desc = capability?.requiresAccessibilityPermission + ? t('settings.permissions.descAcc') + : t('settings.permissions.descNoAcc'); + + return ( + +
{t('settings.permissions.title')}
+
+ {desc} +
+ +
+ + {microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && ( + + {microphone === 'denied' || microphone === 'restricted' ? t('settings.permissions.openSystem') : t('settings.permissions.grant')} + + )} +
+
+ {capability?.requiresAccessibilityPermission && ( + +
+ + {accessibility !== 'granted' && accessibility !== 'notApplicable' && ( + + {t('settings.permissions.grant')} + + )} +
+
+ )} + +
+ {hotkey?.message && ( + + {hotkey.message} + + )} + +
+
+ {windowsIme?.state !== 'notWindows' && ( + +
+ {windowsIme && ( + + {t(`settings.permissions.windowsIme.${windowsIme.state}`)} + + )} + +
+
+ )} + +
+ {t('settings.permissions.networkOk')} +
+
+
+ ); +} + +function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) { + const { t } = useTranslation(); + if (status === 'loading') { + return {t('settings.permissions.checking')}; + } + if (status === 'granted') { + return {t('settings.permissions.granted')}; + } + if (status === 'notApplicable') { + return {t('settings.permissions.notApplicable')}; + } + if (status === 'denied' || status === 'restricted') { + return {t('settings.permissions.denied')}; + } + return {t('settings.permissions.indeterminate')}; +} + +function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) { + const { t } = useTranslation(); + if (!status) { + return {t('settings.permissions.checking')}; + } + if (status.state === 'installed') { + return {t('settings.permissions.hotkeyInstalled')}; + } + if (status.state === 'starting') { + return {t('settings.permissions.hotkeyStarting')}; + } + return {t('settings.permissions.hotkeyFailed')}; +} + +function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) { + const { t } = useTranslation(); + if (!status) { + return {t('settings.permissions.checking')}; + } + if (status.state === 'installed') { + return {t('settings.permissions.windowsImeInstalled')}; + } + return {t('settings.permissions.windowsImeUnavailable')}; +} + +function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { + if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); + if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); + return i18n.t('hotkey.adapter.rdev'); +} diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx new file mode 100644 index 00000000..a6fb7eb6 --- /dev/null +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -0,0 +1,122 @@ +// 快捷键设置:开始/停止、翻译、问答、切风格、唤起 App、以及只读取消/确认提示。 + +import { useTranslation } from 'react-i18next'; +import { ShortcutRecorder } from '../../components/ShortcutRecorder'; +import { defaultQaShortcut } from '../../lib/hotkey'; +import { + setDictationHotkey, + setOpenAppHotkey, + setQaHotkey, + setSwitchStyleHotkey, + setTranslationHotkey, +} from '../../lib/ipc'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Card } from '../_atoms'; +import { SettingRow } from './shared'; + +export function ShortcutsSection() { + const { t } = useTranslation(); + const { prefs, hotkey, capability, updatePrefs: savePrefs } = useHotkeySettings(); + + if (!prefs || !hotkey || !capability) { + return ( + +
{t('common.loading')}
+
+ ); + } + + const desc = capability.requiresAccessibilityPermission + ? t('settings.shortcuts.descAcc') + : t('settings.shortcuts.descNoAcc'); + const readonlyRows: Array<[string, string]> = [ + [t('settings.shortcuts.cancel'), 'Esc'], + [t('settings.shortcuts.confirm'), t('settings.shortcuts.confirmHint')], + ]; + return ( + +
{t('settings.shortcuts.title')}
+
{desc}
+ +
+ { + await setDictationHotkey(binding); + await savePrefs({ ...prefs, dictationHotkey: binding }); + }} + /> +
+ {hotkey.mode === 'hold' ? t('hotkey.modeHoldSuffix') : t('hotkey.modeToggleSuffix')} +
+
+
+ + { + await setTranslationHotkey(binding); + await savePrefs({ ...prefs, translationHotkey: binding }); + }} + /> + + + {prefs.qaHotkey ? ( + { + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + /> + ) : ( + + )} + + + { + await setSwitchStyleHotkey(binding); + await savePrefs({ ...prefs, switchStyleHotkey: binding }); + }} + /> + + + { + await setOpenAppHotkey(binding); + await savePrefs({ ...prefs, openAppHotkey: binding }); + }} + /> + + {readonlyRows.map(([k, v]) => ( + + {v} + + ))} +
+ ); +} diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx new file mode 100644 index 00000000..ca82afcd --- /dev/null +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -0,0 +1,67 @@ +// 共享在 Settings 各 section 间的原子(SettingRow / Toggle / inputStyle)。 +// AsrPresetId 也放在这里,让 AdvancedSection 与 Settings.tsx 都从一处来源拿。 + +import type { CSSProperties, ReactNode } from 'react'; + +interface SettingRowProps { + label: string; + desc?: string; + children: ReactNode; + controlWidth?: number | string; +} + +export function SettingRow({ label, desc, children, controlWidth }: SettingRowProps) { + return ( +
+
+
{label}
+ {desc &&
{desc}
} +
+
{children}
+
+ ); +} + +export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) { + return ( + + ); +} + +export const inputStyle: CSSProperties = { + flex: 1, height: 32, padding: '0 10px', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 8, fontSize: 12.5, + fontFamily: 'inherit', outline: 'none', + background: 'var(--ol-surface-2)', + width: '100%', maxWidth: 360, + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', +}; + +// ASR provider id 集合,跟 Settings.tsx::ASR_PRESETS 一一对应。 +// 拆成独立类型让 AdvancedSection / ProvidersSection 都能用同一份不互相依赖。 +export type AsrPresetId = + | 'volcengine' + | 'bailian' + | 'siliconflow' + | 'zhipu' + | 'groq' + | 'whisper' + | 'foundry-local-whisper' + | 'local-qwen3'; From 90b35057947540dc33f6cb365576d6302b02ecb6 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 12 May 2026 12:50:16 +0800 Subject: [PATCH 03/55] chore(release): bump version to 1.3.1-1 (Beta) First beta after Stable 1.3.0. Carries the Settings modularization refactor (PR #416). Tagged as v1.3.1-1-beta-tauri to trigger the release-tauri.yml Beta-channel build (prerelease=true, manifest filenames carry -beta suffix so the Stable in-app updater endpoint cannot pick this up). --- openless-all/app/package.json | 2 +- openless-all/app/src-tauri/Cargo.lock | 2 +- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 7fc3358d..c449cb7c 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "1.3.0", + "version": "1.3.1-1", "type": "module", "scripts": { "dev": "vite", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index fa627d41..0fea1c17 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3751,7 +3751,7 @@ dependencies = [ [[package]] name = "openless" -version = "1.3.0" +version = "1.3.1-1" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index ddde8db4..735a2109 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "1.3.0" +version = "1.3.1-1" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] edition = "2021" diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index c30c769e..b4202bf5 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "1.3.0", + "version": "1.3.1-1", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", From 5bf6350fd2bb20c3336c481746b2cda5a7687c2c Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 12 May 2026 13:34:08 +0800 Subject: [PATCH 04/55] =?UTF-8?q?fix(ui):=20Windows=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=20chrome=20/=20=E8=83=B6=E5=9B=8A=E7=BC=A9=E5=B0=8F=20/=20?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E4=B8=8B=E6=8B=89=20(#417=20#418)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WindowChrome: 移除 Windows 端自定义标题栏和 resize handles, 交还 Tauri decorations:true 原生 Win11 标题栏,修复 X 按钮重叠 与圆角处的黑边/白线 - Capsule (#417): Windows pill 196×52→180×44,textWidth 104→88, 视觉密度与 macOS 对齐 - SelectLite (#418): 重写为受控下拉组件(portal popover, 键盘导航 Arrow/Enter/Esc, 视口翻转, a11y combobox/listbox),替换 11 处 native ,与 Settings → Language 分区共享同一份 localStorage 偏好。 -function LanguagePicker() { - const { t } = useTranslation(); - const { updatePrefs } = useHotkeySettings(); - const [pref, setPref] = useState(getLocalePreference()); - - const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => { - setPref(next); - const resolved = await setLocalePreference(next); - const localePrefs = outputPrefsForLocale(resolved); - await updatePrefs(current => { - if ( - current.chineseScriptPreference === localePrefs.chineseScriptPreference && - current.outputLanguagePreference === localePrefs.outputLanguagePreference - ) { - return current; - } - return { ...current, ...localePrefs }; - }); - }; - - return ( - - ); -} diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index ca8e6af7..f7638212 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -1,6 +1,4 @@ -import { useState, type CSSProperties, type ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; -import { getCurrentWindow } from '@tauri-apps/api/window'; +import { type CSSProperties, type ReactNode } from 'react'; export type OS = 'mac' | 'win' | 'linux'; @@ -18,21 +16,7 @@ export function detectOS(): OS { const MAC_TITLEBAR_HEIGHT = 28; const MAC_SYSTEM_CONTROLS_RESERVED_WIDTH = 76; -export const WIN_TITLEBAR_HEIGHT = 36; -export const WIN_WINDOW_RADIUS = 10; -export const WIN_CONSOLE_RADIUS = 10; -const WIN_RESIZE_EDGE = 6; -const WIN_RESIZE_CORNER = 14; - -type ResizeDirection = - | 'East' - | 'North' - | 'NorthEast' - | 'NorthWest' - | 'South' - | 'SouthEast' - | 'SouthWest' - | 'West'; +const WIN_CONSOLE_RADIUS = 10; interface WindowChromeProps { os?: OS; @@ -43,48 +27,46 @@ interface WindowChromeProps { export function WindowChrome({ os = 'mac', - title = 'OpenLess', children, height = 800, }: WindowChromeProps) { - const shellRadius = os === 'mac' ? 0 : os === 'win' ? WIN_WINDOW_RADIUS : 14; + // Windows 下交还原生外壳(decorations:true):外层不画圆角 / 边框 / 阴影 / 标题栏, + // 避免与原生窗口的角和关闭按钮重叠。内层卡片保留 10px 圆角,跟整体设计对齐。 + const shellRadius = os === 'mac' ? 0 : os === 'win' ? 0 : 14; const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14; + const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : 0; + + const background = os === 'win' + ? 'linear-gradient(180deg, rgba(245,245,247,1) 0%, rgba(232,232,236,1) 100%)' + : ` + radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 60%), + radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%), + linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%) + `; return (
- {os === 'win' && } - {os === 'win' && } {os === 'mac' && (
); } - -interface WinTitleBarProps { - title: string; -} - -function WinTitleBar({ title }: WinTitleBarProps) { - const { t } = useTranslation(); - return ( -
-
- - {title} -
-
- - - - - - - - - -
-
- ); -} - -function WindowsResizeHandles() { - const handles: Array<{ - direction: ResizeDirection; - cursor: CSSProperties['cursor']; - style: CSSProperties; - }> = [ - { direction: 'North', cursor: 'ns-resize', style: { top: 0, left: WIN_RESIZE_CORNER, right: WIN_RESIZE_CORNER, height: WIN_RESIZE_EDGE } }, - { direction: 'South', cursor: 'ns-resize', style: { bottom: 0, left: WIN_RESIZE_CORNER, right: WIN_RESIZE_CORNER, height: WIN_RESIZE_EDGE } }, - { direction: 'West', cursor: 'ew-resize', style: { top: WIN_RESIZE_CORNER, bottom: WIN_RESIZE_CORNER, left: 0, width: WIN_RESIZE_EDGE } }, - { direction: 'East', cursor: 'ew-resize', style: { top: WIN_RESIZE_CORNER, bottom: WIN_RESIZE_CORNER, right: 0, width: WIN_RESIZE_EDGE } }, - { direction: 'NorthWest', cursor: 'nwse-resize', style: { top: 0, left: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } }, - { direction: 'NorthEast', cursor: 'nesw-resize', style: { top: 0, right: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } }, - { direction: 'SouthWest', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } }, - { direction: 'SouthEast', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } }, - ]; - - return ( -
- {handles.map(handle => ( -
{ - if (event.button !== 0) return; - event.preventDefault(); - event.stopPropagation(); - void startResizeDragging(handle.direction); - }} - style={{ - position: 'absolute', - pointerEvents: 'auto', - cursor: handle.cursor, - ...handle.style, - }} - /> - ))} -
- ); -} - -interface WinTitleButtonProps { - title: string; - action: 'minimize' | 'toggleMaximize' | 'close'; - tone?: 'default' | 'danger'; - children: ReactNode; -} - -function WinTitleButton({ title, action, tone = 'default', children }: WinTitleButtonProps) { - const [hovered, setHovered] = useState(false); - const [pressed, setPressed] = useState(false); - const danger = tone === 'danger'; - const background = pressed - ? danger ? '#c42b1c' : 'rgba(0, 0, 0, 0.12)' - : hovered ? danger ? '#e81123' : 'rgba(0, 0, 0, 0.08)' - : 'transparent'; - const color = danger && (hovered || pressed) ? '#fff' : 'var(--ol-ink-3)'; - - return ( - - ); -} - -async function startResizeDragging(direction: ResizeDirection) { - try { - await getCurrentWindow().startResizeDragging(direction); - } catch (error) { - console.warn(`[window] Windows resize ${direction} failed`, error); - } -} - -async function runWindowsWindowAction(action: 'minimize' | 'toggleMaximize' | 'close') { - try { - const currentWindow = getCurrentWindow(); - if (action === 'minimize') { - await currentWindow.minimize(); - } else if (action === 'toggleMaximize') { - await currentWindow.toggleMaximize(); - } else { - await currentWindow.close(); - } - } catch (error) { - console.warn(`[window] Windows title button ${action} failed`, error); - } -} - -const winBtnStyle: CSSProperties = { - width: 46, - height: '100%', - border: 0, - background: 'transparent', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: 'var(--ol-ink-3)', - cursor: 'default', - transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', -}; diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index c8116bfb..952b1b27 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -1,24 +1,279 @@ -// SelectLite — dropdown-styled display used in the Settings modal sub-sections. +// SelectLite — 受控的下拉组件,替代 native { + onChange={next => { foundrySelectionDirty.current = true; - setSelectedFoundryAlias(e.target.value as FoundryLocalAsrModelAlias); + setSelectedFoundryAlias(next as FoundryLocalAsrModelAlias); }} disabled={foundryBusy !== null} - style={{ - fontSize: 13, - padding: '6px 10px', - borderRadius: 8, - border: '0.5px solid rgba(0,0,0,0.12)', - background: 'var(--ol-surface)', - color: 'var(--ol-ink)', - minWidth: 260, - }}> - {FOUNDRY_LOCAL_ASR_MODELS.map(model => { + options={FOUNDRY_LOCAL_ASR_MODELS.map(model => { const catalog = foundryCatalog.find(item => item.alias === model.alias); const sizeMb = formatFoundrySizeMb(catalog?.fileSizeMb); - return ( - - ); + const sizeSuffix = sizeMb ? ` · ${t('localAsr.foundryApproxSizeMb', { mb: sizeMb })}` : ''; + return { value: model.alias, label: `${t(model.labelKey)}${sizeSuffix}` }; })} - + ariaLabel={t('localAsr.foundrySelectedModel')} + style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 260 }} + />
@@ -805,21 +785,16 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { {t('localAsr.mirrorDesc')}
- + onChange={next => void handleMirrorChange(next)} + options={[ + { value: 'huggingface', label: t('localAsr.mirrorHuggingface') }, + { value: 'hf-mirror', label: t('localAsr.mirrorHfMirror') }, + ]} + ariaLabel={t('localAsr.mirrorLabel')} + style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }} + /> @@ -859,24 +834,19 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { {t('localAsr.keepLoadedDesc')} - + void handleKeepLoadedChange(Number(next))} + options={[ + { value: '0', label: t('localAsr.keepImmediate') }, + { value: '60', label: t('localAsr.keep1min') }, + { value: '300', label: t('localAsr.keep5min') }, + { value: '1800', label: t('localAsr.keep30min') }, + { value: '86400', label: t('localAsr.keepForever') }, + ]} + ariaLabel={t('localAsr.keepLoadedLabel')} + style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }} + /> diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 2a333dd6..751aa47f 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -37,6 +37,7 @@ import type { } from '../lib/types'; import { emitSaved } from '../lib/savedEvent'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; +import { SelectLite } from '../components/ui/SelectLite'; import { Btn, Card, Collapsible, PageHeader, Pill } from './_atoms'; import { deleteLocalAsrModel, @@ -454,15 +455,17 @@ function RecordingSection() { label={t('settings.recording.pasteShortcutLabel')} desc={t('settings.recording.pasteShortcutDesc')} > - + /> )} {capability.adapter === 'windowsLowLevel' && ( @@ -1386,15 +1389,16 @@ function ProvidersSection() { {/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行, 视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */} - + /> {codexOAuthSelected ? (
@@ -1455,30 +1459,33 @@ function ProvidersSection() { : null; return (
- + onChange={next => onAsrProviderChange(next as AsrPresetId)} + options={[ + ...visibleAsrPresets.map(p => ({ + value: p.id, + label: t(`settings.providers.presets.${p.nameKey}`), + })), + ...(platformLocalAsr && platformLocalNameKey + ? [{ + value: platformLocalAsr, + label: t(`settings.providers.presets.${platformLocalNameKey}`), + disabled: true, + }] + : []), + ...(anomalousLocal && anomalousNameKey + ? [{ + value: anomalousLocal, + label: t(`settings.providers.presets.${anomalousNameKey}`), + disabled: true, + }] + : []), + ]} + ariaLabel={t('settings.providers.providerLabel')} + style={{ ...inputStyle, maxWidth: 200 }} + /> {platformLocalAsr && platformLocalNameKey && (
{t('settings.providers.localAsrTakeoverHint', { @@ -1623,15 +1630,15 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | {models.length > 0 && ( - + /> )}
{message && ( diff --git a/openless-all/app/src/pages/Translation.tsx b/openless-all/app/src/pages/Translation.tsx index 920beca8..f54c563c 100644 --- a/openless-all/app/src/pages/Translation.tsx +++ b/openless-all/app/src/pages/Translation.tsx @@ -4,10 +4,11 @@ // - 选一个翻译目标语言(单选;选"不启用"则 Shift 不触发翻译) // - 看完整使用说明(怎么触发、按钮位置、胶囊显示) -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { SavedToast } from '../components/SavedToast'; +import { SelectLite } from '../components/ui/SelectLite'; import { SUPPORTED_LANGUAGES } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { formatComboLabel } from '../lib/hotkey'; @@ -120,6 +121,11 @@ export function Translation() { const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey); const enabled = prefs.translationTargetLanguage.trim() !== ''; + const targetOptions = useMemo(() => ([ + { value: '', label: t('translation.target.disabled') }, + ...SUPPORTED_LANGUAGES.map(lang => ({ value: lang, label: lang })), + ]), [t]); + return ( <> @@ -216,28 +222,14 @@ export function Translation() { {enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')}
- + onChange={onTargetChange} + options={targetOptions} + placeholder={t('translation.target.disabled')} + ariaLabel={t('translation.target.title')} + style={{ width: '100%', maxWidth: 360, fontSize: 13, background: '#fff' }} + /> {/* 3. 使用方法 */} diff --git a/openless-all/app/src/pages/settings/LanguageSection.tsx b/openless-all/app/src/pages/settings/LanguageSection.tsx index 9c909dd1..89e91bf5 100644 --- a/openless-all/app/src/pages/settings/LanguageSection.tsx +++ b/openless-all/app/src/pages/settings/LanguageSection.tsx @@ -1,7 +1,7 @@ // 语言切换面板:跟随系统 / 简中 / 繁中 / 英文 / 日文 (Beta) / 韩文 (Beta)。 // 切换语言同时把对应的 outputPrefs(中文偏好、输出语言)合并进 prefs。 -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { @@ -11,14 +11,24 @@ import { setLocalePreference, type SupportedLocale, } from '../../i18n'; +import { SelectLite } from '../../components/ui/SelectLite'; import { Card } from '../_atoms'; -import { SettingRow, inputStyle } from './shared'; +import { SettingRow } from './shared'; export function LanguageSection() { const { t } = useTranslation(); const { updatePrefs } = useHotkeySettings(); const [pref, setPref] = useState(getLocalePreference()); + const options = useMemo(() => ([ + { value: FOLLOW_SYSTEM, label: t('settings.language.followSystem') }, + { value: 'zh-CN', label: t('settings.language.zh') }, + { value: 'zh-TW', label: t('settings.language.zhTW') }, + { value: 'en', label: t('settings.language.en') }, + { value: 'ja', label: t('settings.language.ja') }, + { value: 'ko', label: t('settings.language.ko') }, + ]), [t]); + const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => { setPref(next); const resolved = await setLocalePreference(next); @@ -39,18 +49,13 @@ export function LanguageSection() {
{t('settings.language.title')}
{t('settings.language.desc')}
- + onChange={next => apply(next as SupportedLocale | typeof FOLLOW_SYSTEM)} + options={options} + ariaLabel={t('settings.language.label')} + style={{ maxWidth: 220, minWidth: 200 }} + />
{t('settings.language.restartHint')} diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 1d39f5b4..f8a26b78 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -27,6 +27,11 @@ button { padding: 0; } +@keyframes ol-select-pop { + from { opacity: 0; transform: translateY(-4px) scale(.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + input, textarea { font-family: inherit; user-select: text; From 8a6ac8b308ed0d0753570e1468d0736b8ea2c6d5 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 12 May 2026 13:48:44 +0800 Subject: [PATCH 05/55] =?UTF-8?q?fix(ci):=20=E8=A1=A5=E9=BD=90=20package-l?= =?UTF-8?q?ock.json=20=E7=9A=84=20version=20=E5=AD=97=E6=AE=B5=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 90b35057 bump 到 1.3.1-1 时漏改了 package-lock.json 的 root 与 nested version 字段(都还是 1.3.0),CI "Verify version sync across 5 files" 卡死所有平台,本 PR 的 build / check 步骤根本 没机会执行。 手动同步到 1.3.1-1,与 package.json / tauri.conf.json / Cargo.toml / Cargo.lock 对齐。本地复跑 CI 第一步的 node -p 校验 全部一致。 --- openless-all/app/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 80f87601..0e9186c9 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.3.0", + "version": "1.3.1-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.3.0", + "version": "1.3.1-1", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", From 70917cb0273d380162cfa7311f731ce1485a9b0b Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 12 May 2026 14:02:48 +0800 Subject: [PATCH 06/55] =?UTF-8?q?fix(a11y):=20SelectLite=20/=20SwitchLite?= =?UTF-8?q?=20=E5=8A=A0=E9=94=AE=E7=9B=98=E7=84=A6=E7=82=B9=E6=8C=87?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-agent #419 review 指出两个 button 都用 outline:none 关掉了浏览器默认 焦点 ring 却没替代——键盘 Tab 时用户看不到当前焦点在哪。和 _atoms.tsx Collapsible 的 pr-agent #407 是同一类问题。 修法:新增 .ol-focus-ring class,:focus-visible 时上 2px 半透明蓝 box-shadow(不是 outline,因为 inline style 的 outline:none 会盖掉 class 里的 outline,但盖不掉 box-shadow)。:focus-visible 只在键盘 focus 时生效,鼠标点击保持干净。 --- openless-all/app/src/components/ui/SelectLite.tsx | 1 + openless-all/app/src/components/ui/SwitchLite.tsx | 1 + openless-all/app/src/styles/global.css | 7 +++++++ 3 files changed, 9 insertions(+) diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 952b1b27..2bdfa124 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -183,6 +183,7 @@ export function SelectLite({