diff --git a/package.json b/package.json index 65b205d..751e834 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "thing", "private": true, - "version": "26.9.2", + "version": "26.9.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9e7f2da..f579d92 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6184,9 +6184,10 @@ dependencies = [ [[package]] name = "thing" -version = "26.9.2" +version = "26.9.3" dependencies = [ "aes", + "async-trait", "axum 0.7.9", "base64 0.22.1", "bytes", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8c2ae66..c42708c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thing" -version = "26.9.2" +version = "26.9.3" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -24,6 +24,9 @@ tauri-plugin-dialog = "2" tauri-plugin-snap-layout = "1" tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2" +# 翻译引擎抽象:TranslateEngine 需要 Box 动态分派(多源在运行期决定), +# 原生 async fn in trait 在 dyn 场景下不可用 +async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" @@ -94,6 +97,18 @@ windows = { version = "0.52", features = [ "Win32_System_Variant", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", + # 翻译模块:Windows.Media.Ocr 本地识别(WinRT) + # Foundation_Collections:OcrResult.Lines / OcrEngine.AvailableRecognizerLanguages 返回 IVectorView + # Security_Cryptography:用 CryptographicBuffer 从内存字节构造 IBuffer(不落盘) + "Foundation", + "Foundation_Collections", + "Globalization", + "Graphics_Imaging", + "Media_Ocr", + "Security_Cryptography", + # 划词取词的智能路径:UIA 直读焦点元素选区(IUIAutomation / ITextProvider) + "Win32_UI_Accessibility", + "Storage_Streams", # HDR 鬮ォ・エ繝サ・ス繝サ縺、ツ€鬮ョ雜」・ス・ャ髯キ闌ィ・ス・キ郢晢スサ繝サ・シ髯橸ス「繝サ・ス髫カツ€繝サ・「鬮」蛹・スス・ウ郢晢スサ繝サ・セ DXGI 鬯ョ・エ陞ウ謖会スァ驛「譎「・ス・サ髮趣スシ繝サ・カ郢晢スサ繝サ・イ鬮ッ貅キ遘√・・ス繝サ・ゥ鬯ゥ蛹・スス・ィ郢晢スサ繝サ・コ鬯ッ・ョ繝サ・」郢晢スサ繝サ・エ驛「譎「・ス・サ驛「譎「・ス・サdvanced color 鬮ォ・エ鬲・シ夲スス・ス繝サ・カ鬯ョ・エ陞ウ謖会スァ驛「譎「・ス・サ G2084/PQ 鬯ゥ蛹・スス・ィ郢晢スサ繝サ・コ鬯ッ・ョ繝サ・」郢晢スサ繝サ・エ驛「譎「・ス・サ驛「譎「・ス・サ "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", diff --git a/src-tauri/capabilities/translate-popup.json b/src-tauri/capabilities/translate-popup.json new file mode 100644 index 0000000..5928dc9 --- /dev/null +++ b/src-tauri/capabilities/translate-popup.json @@ -0,0 +1,19 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "translate-popup", + "description": "Capability for the non-activating translate popup window", + "windows": ["translate-popup"], + "permissions": [ + "core:default", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-close", + "core:window:allow-set-theme", + "core:window:allow-set-effects", + "core:window:allow-set-background-color", + "core:window:allow-start-dragging", + "core:event:allow-listen", + "core:event:allow-emit", + "snap-layout:default" + ] +} diff --git a/src-tauri/src/clipboard/manager.rs b/src-tauri/src/clipboard/manager.rs index b208728..6553c1e 100644 --- a/src-tauri/src/clipboard/manager.rs +++ b/src-tauri/src/clipboard/manager.rs @@ -12,6 +12,7 @@ use specta::Type; use super::monitor::start_monitor; use super::reader::{write_dib, write_files, write_text}; use super::storage::Storage; +use super::suppress::SuppressState; use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber; /// 剪贴板设置(持久化到 clipboard/settings.json) @@ -56,8 +57,9 @@ impl Default for ClipboardSettings { pub struct ClipboardManager { storage: Arc, settings: Arc>, - /// 本应用 copy_back 写入后的剪贴板序列号,用于跳过自身写入产生的记录 - suppress: Arc>>, + /// 写入抑制状态。由本模块持有(谁启动监听谁负责),但**对其他模块开放**: + /// 划词取词会连续改三次剪贴板,同样需要屏蔽,见 `super::suppress`。 + suppress: Arc, monitor_stop: Arc, monitor_handle: Mutex>>, settings_path: PathBuf, @@ -75,7 +77,7 @@ impl ClipboardManager { }; let settings_path = clip_dir.join("settings.json"); let settings = Arc::new(Mutex::new(load_settings(&settings_path))); - let suppress = Arc::new(Mutex::new(None)); + let suppress = Arc::new(SuppressState::new()); let monitor_stop = Arc::new(AtomicBool::new(true)); Self { storage, @@ -167,7 +169,7 @@ impl ClipboardManager { // 绑定到写入完成后的剪贴板序列号:仅跳过本次写入产生的记录, // 用户后续复制(序列号不同)不会被误吞。 let seq = unsafe { GetClipboardSequenceNumber() }; - *self.suppress.lock().unwrap_or_else(|e| e.into_inner()) = Some(seq); + self.suppress.mark_seq(seq); Ok(()) } else { Err("写回剪贴板失败".into()) @@ -177,6 +179,20 @@ impl ClipboardManager { pub fn storage(&self) -> &Arc { &self.storage } + + /// 写入抑制状态(供划词取词等会改剪贴板的其他模块共享) + pub fn suppress(&self) -> Arc { + self.suppress.clone() + } + + /// 把**当前**剪贴板序列号登记为「应跳过」。 + /// + /// 给其他模块写完剪贴板后调用:它们自己拿不到「写入后」的序列号, + /// 但知道「刚刚写完」这件事。把 win32 调用留在这个模块里, + /// 别处就不必重复引入 DataExchange。 + pub fn suppress_current_sequence(&self) { + self.suppress.mark_seq(unsafe { GetClipboardSequenceNumber() }); + } } impl Drop for ClipboardManager { diff --git a/src-tauri/src/clipboard/mod.rs b/src-tauri/src/clipboard/mod.rs index 2c09589..b339492 100644 --- a/src-tauri/src/clipboard/mod.rs +++ b/src-tauri/src/clipboard/mod.rs @@ -6,6 +6,7 @@ pub mod monitor; pub mod popup; pub mod reader; pub mod storage; +pub mod suppress; pub use commands::{ clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history, diff --git a/src-tauri/src/clipboard/monitor.rs b/src-tauri/src/clipboard/monitor.rs index 948b855..7fe6204 100644 --- a/src-tauri/src/clipboard/monitor.rs +++ b/src-tauri/src/clipboard/monitor.rs @@ -12,15 +12,17 @@ use tauri::{AppHandle, Emitter}; use super::reader::{read_clipboard, ClipData}; use super::storage::{NewItem, Storage}; +use super::suppress::SuppressState; use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber; /// 启动监听线程,返回 JoinHandle。 -/// `suppress` 记录本应用 copy_back 写入后的剪贴板序列号,用于跳过自身写入产生的记录。 +/// `suppress` 记录本应用写入剪贴板产生的序列号、以及取词等流程的屏蔽窗口, +/// 用于跳过自身写入产生的记录。 pub fn start_monitor( storage: Arc, app: AppHandle, settings: Arc>, - suppress: Arc>>, + suppress: Arc, stop: Arc, ) -> thread::JoinHandle<()> { thread::spawn(move || loop { @@ -41,13 +43,10 @@ pub fn start_monitor( if seq == last { continue; } - // 序列号变化:仅当变化来自本应用 copy_back(序列号精确匹配)时跳过, - // 避免旧布尔标志在用户后续复制时被误吞。 - if let Ok(mut s) = suppress.lock() { - if *s == Some(seq) { - *s = None; - continue; - } + // 序列号变化,但可能来自本应用:精确记账(copy_back)或屏蔽窗口内(取词流程)。 + // 判定放在读剪贴板之前,避免为一次注定要丢弃的变化做无谓的读取与解码。 + if suppress.should_skip(seq) { + continue; } let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = { let s = settings.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src-tauri/src/clipboard/suppress.rs b/src-tauri/src/clipboard/suppress.rs new file mode 100644 index 0000000..3df08d3 --- /dev/null +++ b/src-tauri/src/clipboard/suppress.rs @@ -0,0 +1,142 @@ +//! 剪贴板写入抑制。 +//! +//! 存在的理由:剪贴板监听线程会把**任何**序列号变化录进历史。而应用自身也会写剪贴板 +//! (copy_back 写回、划词取词时模拟 Ctrl+C 与随后的还原),这些都不该出现在用户的 +//! 历史里。抑制状态因此必须能被**多个模块**访问,而不是某个模块的私有字段。 +//! +//! 两种机制并存,因为要解决的问题不同: +//! +//! - **精确抑制(seq)**:只跳过「本应用刚写入的那一次变化」。写入者是我们自己时, +//! 写入后的序列号可以立刻读到,于是能精确记账一次;用户随后的复制是另一个序列号, +//! 不会被误吞。`copy_back` 用这条。 +//! +//! - **时间窗抑制(burst)**:屏蔽一个区间内的**所有**变化。划词取词要连续动三次剪贴板 +//! (Ctrl+C 覆盖 → 我们读走 → 还原原文),而监听线程是 250ms 轮询:按序列号逐个记账 +//! 存在竞态——监听恰好落在我们两次操作之间时,选区文本就被录进历史了。 +//! 因此取词期间必须整体屏蔽,读完并还原之后再解除。 +//! +//! burst 用**计数**而非布尔:取词流程内部可能再触发一次写入,用布尔会在内层先结束时 +//! 提前解除屏蔽。配对由 [`SuppressState::burst`] 返回的 RAII 守卫保证,提前 return 也安全。 + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +#[derive(Default)] +pub struct SuppressState { + /// 待跳过的序列号(消费一次即清空) + seq: Mutex>, + /// 屏蔽窗口嵌套计数 + burst: AtomicUsize, +} + +/// 屏蔽窗口守卫:析构时自动解除,保证与 `begin` 严格配对。 +pub struct BurstGuard<'a>(&'a SuppressState); + +impl Drop for BurstGuard<'_> { + fn drop(&mut self) { + self.0.end_burst(); + } +} + +impl SuppressState { + pub fn new() -> Self { + Self::default() + } + + /// 记账:跳过 `seq` 这一次变化(消费一次)。 + /// 只记一个序列号即可——本应用的写入是串行的,不会同时积压多次。 + pub fn mark_seq(&self, seq: u32) { + if let Ok(mut guard) = self.seq.lock() { + *guard = Some(seq); + } + } + + /// 开始屏蔽窗口。返回的守卫析构时自动结束,**不要**手动配对 end。 + pub fn burst(&self) -> BurstGuard<'_> { + self.burst.fetch_add(1, Ordering::SeqCst); + BurstGuard(self) + } + + fn end_burst(&self) { + // saturating:异常路径下的多余 end 不应让计数下溢,否则会永久屏蔽 + let _ = self + .burst + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| { + Some(v.saturating_sub(1)) + }); + } + + /// 当前是否处于屏蔽窗口内(供调用方在取词前做提示,不参与判定) + pub fn in_burst(&self) -> bool { + self.burst.load(Ordering::SeqCst) > 0 + } + + /// 监听线程询问:这次变化是否应当跳过。 + /// 命中序列号时**消费**该记账(下次同序列号不再跳过),避免误吞用户后续的复制。 + pub fn should_skip(&self, seq: u32) -> bool { + if self.in_burst() { + return true; + } + let mut guard = match self.seq.lock() { + Ok(g) => g, + Err(e) => e.into_inner(), + }; + if *guard == Some(seq) { + *guard = None; + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seq_suppression_is_consumed_once() { + let s = SuppressState::new(); + s.mark_seq(7); + assert!(s.should_skip(7), "记账的那次应被跳过"); + assert!(!s.should_skip(7), "同序列号不应被反复跳过"); + assert!(!s.should_skip(8), "其他序列号不受影响"); + } + + #[test] + fn burst_suppresses_everything_until_dropped() { + let s = SuppressState::new(); + { + let _guard = s.burst(); + assert!(s.should_skip(1)); + assert!(s.should_skip(2)); + assert!(s.in_burst()); + } + assert!(!s.in_burst()); + assert!(!s.should_skip(3)); + } + + #[test] + fn burst_is_reentrant() { + let s = SuppressState::new(); + let outer = s.burst(); + { + let _inner = s.burst(); + } + // 内层结束不应提前解除外层 + assert!(s.in_burst()); + assert!(s.should_skip(9)); + drop(outer); + assert!(!s.in_burst()); + } + + #[test] + fn extra_end_does_not_underflow() { + let s = SuppressState::new(); + s.end_burst(); + s.end_burst(); + // 下溢会让计数变成极大值从而永久屏蔽,这里确保不会 + assert!(!s.in_burst()); + assert!(!s.should_skip(4)); + } +} diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 02df6ee..d2d6a2e 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -14,6 +14,8 @@ pub mod windows { pub const SCREENSHOT_PIN: &str = "screenshot-pin"; #[allow(dead_code)] pub const SCREENSHOT_SCROLL: &str = "screenshot-scroll"; + /// 取词翻译悬浮窗(由 translate 模块预创建,非激活显示) + pub const TRANSLATE_POPUP: &str = "translate-popup"; } /// Tauri 事件名(与前端 constants::EVENTS 对应) @@ -61,6 +63,14 @@ pub mod events { pub const SCROLL_CANCELLED: &str = "screenshot-scroll-cancelled"; // 内核安装进度 pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress"; + // 翻译:取词悬浮窗显示(负载见 translate::popup::PopupPayload)/ 隐藏(无负载) + pub const TRANSLATE_POPUP_SHOW: &str = "translate-popup-show"; + pub const TRANSLATE_POPUP_HIDE: &str = "translate-popup-hide"; + // 翻译:流式输出(负载见 translate::engines::StreamEvent;失败经 start 的 Promise reject, + // 已发出 requestId 之后的失败额外走 error 事件兜底) + pub const TRANSLATE_STREAM_CHUNK: &str = "translate-stream-chunk"; + pub const TRANSLATE_STREAM_DONE: &str = "translate-stream-done"; + pub const TRANSLATE_STREAM_ERROR: &str = "translate-stream-error"; // 音乐模块:Python 便携运行时安装进度 pub const MUSIC_RUNTIME_INSTALL_PROGRESS: &str = "music-runtime-install-progress"; // 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5f5d557..ff49757 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,9 +12,11 @@ mod osd_window; mod process_manager; mod quickpanel; mod screenshot; +mod secrets; mod setup; mod shortcut; mod snap_fix; +mod translate; mod tray_menu; mod updater; mod win32_util; @@ -101,6 +103,18 @@ use quickpanel::{ quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window, }; use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready}; +use translate::{ + translate_abort, translate_apply_shortcuts, translate_copy_text, translate_engine_delete, + translate_engine_models, translate_engine_save, translate_engine_test_config, + translate_engines_list, + translate_get_settings, translate_history_clear, translate_history_delete, + translate_history_list, translate_history_set_favorited, translate_ocr_languages, + translate_paste_back, translate_popup_edit_mode, translate_popup_hide, + translate_popup_prefs_set, translate_popup_ready, translate_popup_resize, + translate_popup_set_pinned, translate_preview_popup, translate_run, translate_save_settings, + translate_screenshot_region, translate_secret_clear, translate_secret_set, + translate_stream_start, +}; use updater::{ app_version, update_check, update_install, update_thinghk_apply, update_thinghk_cancel, update_thinghk_confirm, ThinghkUpdateState, @@ -113,11 +127,16 @@ fn quit_app(app: tauri::AppHandle) { } /// 导出 tauri-specta 生成的 TypeScript 类型与命令绑定(仅 debug 构建,开发时自动刷新)。 -/// 覆盖 proxy / quickpanel / clipboard / download_engine / screenshot 五个模块; +/// 覆盖 proxy / quickpanel / clipboard / download_engine / screenshot / translate 等模块; /// 豁免清单(返回 serde_json::Value 或 tauri::ipc::Response/Request,specta 无法生成): /// proxy_version / proxy_get_proxies / proxy_get_connections / proxy_patch_configs、 /// downloader_status / downloader_get_extension_info、 -/// screenshot_get_fullscreen_bmp(返回 ipc::Response)/ screenshot_compose_copy(接收 ipc::Request)。 +/// screenshot_get_fullscreen_bmp(返回 ipc::Response)/ screenshot_compose_copy(接收 ipc::Request)、 +/// music_ping / music_get_sources / music_search(返回 serde_json::Value)。 +/// +/// 注意:export 结果写入 `../src/lib/bindings.ts`,因此**新增模块的命令后需至少以 debug +/// 构建运行一次**,前端才能用 `commands.xxx` 拿到类型。在此之前前端若需调用, +/// 只能退回原生 `invoke`(返回类型需自行声明)。 #[cfg(debug_assertions)] fn export_bindings() { use specta_typescript::Typescript; @@ -178,6 +197,17 @@ fn export_bindings() { screenshot_set_scroll_hole, screenshot_copy_image, screenshot_save_png, screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache, + // translate(20) + translate_get_settings, translate_save_settings, translate_apply_shortcuts, + translate_engines_list, translate_engine_save, translate_engine_delete, + translate_secret_set, translate_secret_clear, translate_engine_test_config, + translate_engine_models, translate_run, translate_copy_text, + translate_preview_popup, translate_popup_ready, translate_popup_hide, + translate_popup_edit_mode, translate_popup_set_pinned, translate_popup_prefs_set, + translate_popup_resize, translate_screenshot_region, translate_ocr_languages, + translate_history_list, translate_history_delete, translate_history_clear, + translate_history_set_favorited, + translate_stream_start, translate_abort, translate_paste_back, ]) .export(Typescript::default(), "../src/lib/bindings.ts") .expect("failed to export bindings"); @@ -415,7 +445,35 @@ pub fn run() { screenshot_unregister_pin_shortcut, screenshot_disable_transitions, screenshot_compose_copy, - screenshot_compose_png + screenshot_compose_png, + translate_get_settings, + translate_save_settings, + translate_apply_shortcuts, + translate_engines_list, + translate_engine_save, + translate_engine_delete, + translate_secret_set, + translate_secret_clear, + translate_engine_test_config, + translate_engine_models, + translate_run, + translate_copy_text, + translate_preview_popup, + translate_popup_ready, + translate_popup_hide, + translate_popup_edit_mode, + translate_popup_set_pinned, + translate_popup_prefs_set, + translate_popup_resize, + translate_screenshot_region, + translate_ocr_languages, + translate_history_list, + translate_history_delete, + translate_history_clear, + translate_history_set_favorited, + translate_stream_start, + translate_abort, + translate_paste_back ]) .setup(setup::init) .on_window_event(|window, event| { diff --git a/src-tauri/src/music/secrets.rs b/src-tauri/src/music/secrets.rs index cd491a1..cbf4640 100644 --- a/src-tauri/src/music/secrets.rs +++ b/src-tauri/src/music/secrets.rs @@ -1,22 +1,19 @@ -//! 敏感串的统一存放处(Windows 系统凭据管理器,DPAPI 保护)。 +//! 音乐模块的凭据键约定与便捷读取。 //! -//! 存在的理由:本项目里出现过**两种安全姿态**——WebDAV 账号密码走凭据管理器, -//! 而飞牛登录 token 与 QQ 音乐 Cookie 明文躺在 `settings.json` / localStorage。 -//! 同样是可冒充身份的凭据,不该区别对待。 +//! 底层读写原语已抽到 crate 级 [`crate::secrets`](同一套 `Thing` 服务名与迁移 +//! 策略,翻译模块等其他使用者共享)。本文件只保留**音乐自己的键名**与 +//! 「前端可达范围」这道闸门,读写一律委托给公共模块,避免出现第二套实现。 //! //! 约定: //! - 一律使用 `Thing` 作为凭据服务名,`key` 作为用户名(Entry 的 account)。 +//! 服务名是历史值,改动会导致已有凭据读不到。 //! - 明文只允许存在于内存与系统凭据库,禁止回写 `settings.json` / localStorage。 //! - 迁移采用「先写凭据库成功、再清明文」的顺序;**写失败时保留明文**, //! 宁可牺牲一致性也不能把用户已登录的会话弄丢。 //! - 非 Windows 平台没有凭据管理器:读取返回 None、写入报错, //! 调用方据此退化为「明文存 settings」(功能优先)。 -use crate::logger; - -/// 凭据服务名(与历史实现一致,改动会导致已有 WebDAV 凭据读不到)。 -#[cfg(windows)] -const SERVICE: &str = "Thing"; +pub use crate::secrets::{secret_delete, secret_read, secret_write, try_store}; /// WebDAV 凭据的 key(**历史值,不可更改**)。 pub const KEY_WEBDAV: &str = "webdav-credentials"; @@ -43,79 +40,12 @@ pub fn frontend_key_allowed(key: &str) -> bool { FRONTEND_KEYS.contains(&key) } -/// 读取明文(未配置 → `Ok(None)`)。 -#[cfg(windows)] -pub fn secret_read(key: &str) -> Result, String> { - let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; - match entry.get_password() { - Ok(v) => Ok(Some(v)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(format!("读取凭据失败: {e}")), - } -} - -/// 写入明文(覆盖式)。 -#[cfg(windows)] -pub fn secret_write(key: &str, value: &str) -> Result<(), String> { - let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; - entry.set_password(value).map_err(|e| format!("保存凭据失败: {e}")) -} - -/// 删除凭据(不存在视为成功)。 -#[cfg(windows)] -pub fn secret_delete(key: &str) -> Result<(), String> { - let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; - match entry.delete_credential() { - Ok(()) => Ok(()), - Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(format!("删除凭据失败: {e}")), - } -} - -#[cfg(not(windows))] -pub fn secret_read(_key: &str) -> Result, String> { - Ok(None) -} - -#[cfg(not(windows))] -pub fn secret_write(_key: &str, _value: &str) -> Result<(), String> { - Err("当前平台不支持系统凭据管理器".to_string()) -} - -#[cfg(not(windows))] -pub fn secret_delete(_key: &str) -> Result<(), String> { - Ok(()) -} - -/// 把明文**尽力**迁入凭据库(失败只记日志,不抛错)。 -/// -/// 返回「明文是否可以安全清除」:只有写入成功才为 true。 -/// 调用方用这个返回值决定要不要清空内存/配置里的明文。 -pub fn try_store(key: &str, value: &str) -> bool { - if value.is_empty() { - return true; - } - match secret_write(key, value) { - Ok(()) => true, - Err(e) => { - logger::log_error( - "music", - &format!("凭据 {key} 写入系统凭据管理器失败(保留明文作为降级): {e}"), - ); - false - } - } -} - /// 读取飞牛连接 token;未配置或读取失败 → 空串(等价于未登录)。 pub fn read_feiniu_token(connection_id: &str) -> String { if connection_id.is_empty() { return String::new(); } - secret_read(&feiniu_token_key(connection_id)) - .ok() - .flatten() - .unwrap_or_default() + crate::secrets::read_or_empty(&feiniu_token_key(connection_id)) } /// 该连接是否已有可用 token(供前端展示「已登录」)。 diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs new file mode 100644 index 0000000..62f5f27 --- /dev/null +++ b/src-tauri/src/secrets.rs @@ -0,0 +1,110 @@ +//! 敏感串的统一存放处(Windows 系统凭据管理器,DPAPI 保护)。 +//! +//! 从 `music::secrets` 抽出为 crate 级公共模块。抽出的理由:本项目早期出现过 +//! **两种安全姿态**——WebDAV 账号密码走凭据管理器,而飞牛登录 token 与 QQ 音乐 +//! Cookie 明文躺在 `settings.json` / localStorage。同样是可冒充身份的凭据,不该 +//! 区别对待。此后翻译模块又要接入多家 AI 的 API Key,若每个模块自带一套实现, +//! 姿态只会再次分叉,因此把「原语」收敛到这里,各模块只保留自己的键名约定。 +//! +//! 约定: +//! - 一律使用 `Thing` 作为凭据服务名,`key` 作为用户名(Entry 的 account)。 +//! **服务名不可更改**:已有凭据(`webdav-credentials`、`music-feiniu-token-*`、 +//! `music-qq-cookie`)都以它存盘,改动会导致这些凭据读不到。 +//! - 明文只允许存在于内存与系统凭据库,禁止回写 `settings.json` / localStorage。 +//! - 迁移采用「先写凭据库成功、再清明文」的顺序;**写失败时保留明文**, +//! 宁可牺牲一致性也不能把用户已登录的会话弄丢。 +//! - 非 Windows 平台没有凭据管理器:读取返回 None、写入报错, +//! 调用方据此退化为「明文存设置」(功能优先)。 + +/// 凭据服务名(**历史值,不可更改**)。 +pub const SERVICE: &str = "Thing"; + +/// 读取明文(未配置 → `Ok(None)`)。 +#[cfg(windows)] +pub fn secret_read(key: &str) -> Result, String> { + let entry = + keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; + match entry.get_password() { + Ok(v) => Ok(Some(v)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("读取凭据失败: {e}")), + } +} + +/// 写入明文(覆盖式)。 +#[cfg(windows)] +pub fn secret_write(key: &str, value: &str) -> Result<(), String> { + let entry = + keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; + entry + .set_password(value) + .map_err(|e| format!("保存凭据失败: {e}")) +} + +/// 删除凭据(不存在视为成功)。 +#[cfg(windows)] +pub fn secret_delete(key: &str) -> Result<(), String> { + let entry = + keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("删除凭据失败: {e}")), + } +} + +#[cfg(not(windows))] +pub fn secret_read(_key: &str) -> Result, String> { + Ok(None) +} + +#[cfg(not(windows))] +pub fn secret_write(_key: &str, _value: &str) -> Result<(), String> { + Err("当前平台不支持系统凭据管理器".to_string()) +} + +#[cfg(not(windows))] +pub fn secret_delete(_key: &str) -> Result<(), String> { + Ok(()) +} + +/// 把明文**尽力**迁入凭据库(失败只记日志,不抛错)。 +/// +/// 返回「明文是否可以安全清除」:只有写入成功才为 true。 +/// 调用方用这个返回值决定要不要清空内存/配置里的明文。 +pub fn try_store(key: &str, value: &str) -> bool { + if value.is_empty() { + return true; + } + match secret_write(key, value) { + Ok(()) => true, + Err(e) => { + crate::logger::log_error( + "secrets", + &format!("凭据 {key} 写入系统凭据管理器失败(保留明文作为降级): {e}"), + ); + false + } + } +} + +/// 读取明文,失败或未配置一律退化为空串(调用方无需区分「没配」与「读不到」)。 +pub fn read_or_empty(key: &str) -> String { + secret_read(key).ok().flatten().unwrap_or_default() +} + +/// 掩码展示:保留前 3 位与后 4 位,中间以圆点替代。 +/// 长度不足时全部打码,绝不泄露完整明文。 +pub fn mask(value: &str) -> String { + let v = value.trim(); + let n = v.chars().count(); + if n == 0 { + return String::new(); + } + if n <= 8 { + return "•".repeat(n); + } + let head: String = v.chars().take(3).collect(); + let tail: String = v.chars().skip(n - 4).collect(); + format!("{head}••••{tail}") +} diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs index 019ca37..dde32e8 100644 --- a/src-tauri/src/setup.rs +++ b/src-tauri/src/setup.rs @@ -7,6 +7,7 @@ //! - 下载:DownloadEngine + 扩展 HTTP API 服务 //! - 剪贴板:ClipboardManager + 快捷键 + 预创建弹窗 //! - 快速面板:快捷键 + 预创建弹窗 + 文件索引 +//! - 翻译:TranslateManager(设置与密钥按需读取,启动时不发网络请求) //! - 托盘:自定义菜单窗口 //! - 进程:监控线程 @@ -20,6 +21,7 @@ use crate::monitor_kernel::{MonitorKernel, check_and_relaunch_if_needed}; use crate::music::MusicManager; use crate::network_monitor::NetworkMonitor; use crate::process_manager::{ProcessManager, start_monitoring_thread}; +use crate::translate::TranslateManager; /// 应用启动初始化入口(setup 闭包调用)。 /// 初始化顺序即依赖顺序:日志 → 数据目录 → 各管理器 → 托盘 → 进程监控 → 自动启动。 @@ -61,6 +63,15 @@ pub fn init(app: &mut App) -> Result<(), Box> { music.set_app(app.handle().clone()); app.manage(music); + // ===== 翻译模块:TranslateManager ===== + // 仅注册状态:设置与密钥都在命令调用时按需读取,启动阶段不发任何网络请求 + // (模型可用性校验因此改为懒校验:首次翻译失败时解释原因 + 设置页手动拉取模型列表)。 + let translate = TranslateManager::new(app_data_dir.clone()); + app.manage(translate); + // 按设置注册「翻译取词 / 翻译剪贴板」两个全局快捷键并预创建悬浮窗。 + // 失败只记日志(快捷键被占用不该阻断启动)。 + crate::translate::init_on_launch(app.handle()); + // 网速采样不依赖提权,应用启动即开始 let network_monitor = Arc::new(NetworkMonitor::new()); app.manage(network_monitor.clone()); diff --git a/src-tauri/src/translate/capture/clipboard_capture.rs b/src-tauri/src/translate/capture/clipboard_capture.rs new file mode 100644 index 0000000..96c08e1 --- /dev/null +++ b/src-tauri/src/translate/capture/clipboard_capture.rs @@ -0,0 +1,500 @@ +//! 取词:智能路径(UIA 直读)优先,兼容路径(模拟 Ctrl+C)兜底。 +//! +//! 整个流程在**调用方的阻塞线程**上执行(最长约 1s)。**严禁在主线程调用**: +//! 全局快捷键回调与 UI 线程都不该被这段等待卡住。命令层用 `spawn_blocking` 包装, +//! 快捷键回调自行 `std::thread::spawn`。 + +use std::time::{Duration, Instant}; + +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, Manager}; + +use crate::clipboard::reader::{read_clipboard, write_dib, write_files, write_text, ClipData}; +use crate::clipboard::ClipboardManager; +use crate::translate::engines::{ErrorKind, TranslateError}; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber; +use windows_sys::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, +}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + GetAsyncKeyState, SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, VK_CONTROL, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetWindowThreadProcessId, +}; + +/// 模拟按键后等待剪贴板更新的上限。 +/// +/// 300ms 偏短:浏览器、Electron 应用、带插件的编辑器在复制前还要走一遍自己的 +/// 命令分发,慢一点的直接超时。放宽到 600ms 后失败率明显下降,而用户感知的 +/// 「按下到弹出」延迟仍在可接受范围(取词是主动行为,不是输入反馈)。 +const PASTE_WAIT: Duration = Duration::from_millis(600); +/// 轮询间隔 +const POLL_INTERVAL: Duration = Duration::from_millis(10); +/// 等用户自然松开修饰键的时间(见 [`neutralise_modifiers`]) +const MOD_RELEASE_WAIT: Duration = Duration::from_millis(400); + +const VK_C: u16 = 0x43; +const VK_V: u16 = 0x56; +const VK_INSERT: u16 = 0x2D; +const VK_MENU: u16 = 0x12; // Alt +const VK_SHIFT: u16 = 0x10; +const VK_LWIN: u16 = 0x5B; +const VK_RWIN: u16 = 0x5C; + +/// 会「污染」Ctrl+C 的修饰键:按下时目标应用看到的是 Alt+Ctrl+C 之类, +/// 没有任何应用把它当复制。Alt 与 Shift 会被强制松开(见 [`neutralise_modifiers`])。 +const BLOCKING_MODIFIERS: [u16; 2] = [VK_MENU, VK_SHIFT]; +/// 参与「等自然松开」但不强制合成的键:合成 Win 抬起会触发开始菜单,代价太大。 +const WAIT_ONLY_MODIFIERS: [u16; 2] = [VK_LWIN, VK_RWIN]; + +/// 取词参数(来自设置) +#[derive(Debug, Clone)] +pub struct CaptureRequest { + /// 文本字符数上限,超出直接拒绝而不是发一个巨大的请求 + pub max_chars: usize, + /// 取词后是否还原剪贴板 + pub restore_clipboard: bool, + /// 跳过取词的进程名黑名单(终端类) + pub blacklist: Vec, + /// 取词方式:"smart"(UIA 直读优先,失败退回模拟按键)| "compat"(只用模拟按键) + pub mode: String, +} + +/// 取词结果 +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CaptureOutcome { + pub text: String, + /// 取词来源:"clipboard"(模拟 Ctrl+C) + pub source: String, + /// 取词时的前台窗口句柄(P3 回填替换选区时要用,事后无法补齐) + pub source_hwnd: i64, + /// 前台窗口所属进程名(用于提示与黑名单判定) + pub source_process: String, + /// 剪贴板是否被成功还原。false 有两种情况:备份时剪贴板是**不支持的格式** + /// (如仅含 HTML/RTF,无法原样写回),或还原本身失败——此时选区文本会留在剪贴板上。 + pub restored_clipboard: bool, +} + +/// 执行一次取词。**阻塞**,见模块头注释。 +/// +/// 并发互斥:连按快捷键(以为没反应再按一次是常见操作)会让两套 +/// 「备份 → Ctrl+C → 轮询 → 还原」并发执行,互相覆盖剪贴板状态。 +/// 在途时后来者直接放弃,静默返回。 +pub fn capture_selection( + app: &AppHandle, + req: &CaptureRequest, +) -> Result { + use std::sync::atomic::{AtomicBool, Ordering}; + + static IN_FLIGHT: AtomicBool = AtomicBool::new(false); + if IN_FLIGHT.swap(true, Ordering::SeqCst) { + return Err(TranslateError::new( + ErrorKind::Empty, + "上一次取词仍在进行中", + )); + } + let result = capture_selection_inner(app, req); + IN_FLIGHT.store(false, Ordering::SeqCst); + result +} + +fn capture_selection_inner( + app: &AppHandle, + req: &CaptureRequest, +) -> Result { + let (hwnd, process) = foreground_window_process(); + let process_name = process.clone().unwrap_or_default(); + + if !process_name.is_empty() + && req + .blacklist + .iter() + .any(|b| b.trim().eq_ignore_ascii_case(&process_name)) + { + return Err(TranslateError::unsupported(format!( + "「{process_name}」中 Ctrl+C 是中断信号,无法用于取词。\ + 请选中文字后复制,再用「翻译剪贴板」快捷键。" + ))); + } + + // ===== 智能路径:UIA 直读(不动键盘、不碰剪贴板)===== + // 命中即返回:既避免了修饰键残留 / 提权窗口 / 剪贴板占用这三类兼容路径问题, + // 也快得多(一次跨进程 COM 调用 vs 一次 600ms 的剪贴板等待)。 + if req.mode.trim() != "compat" { + if let Some(text) = uia_selection() { + crate::logger::log_info( + "translate", + &format!( + "取词:UIA 直读命中({} 字符),前台进程 {:?}", + text.chars().count(), + process_name + ), + ); + return finish_text(req, text, hwnd, &process_name, true); + } + } + + // 屏蔽窗口覆盖「Ctrl+C 覆盖剪贴板 → 读走 → 还原」的全过程。 + // 守卫析构时自动解除,提前 return 也不会漏。 + let suppress = app.try_state::().map(|m| m.suppress()); + let _guard = suppress.as_ref().map(|s| s.burst()); + + let backup = read_clipboard(); + let before = clipboard_seq(); + + // 关键:先让修饰键回到「都没按」的状态,再发 Ctrl+C。 + // 全局快捷键是在**按下**的瞬间触发的,此刻 Alt 必然还被物理按住; + // 直接补发 Ctrl+C,目标应用收到的是 Alt+Ctrl+C —— 复制不会发生, + // 于是必然走到下面的「取词超时」。这是兼容路径取不到词最主要的原因。 + let forced = neutralise_modifiers(); + + send_ctrl_c(); + let mut updated = wait_clipboard_change(before, PASTE_WAIT); + // 重试一次:部分应用首次按键被自身的输入法/菜单状态吃掉,第二次才真正复制。 + // 复制是幂等的,多按一次没有副作用,比直接判定失败划算。 + if !updated { + send_ctrl_c(); + updated = wait_clipboard_change(before, PASTE_WAIT / 2); + } + // Ctrl+Insert 兜底:个别应用对合成的 Ctrl+C 不响应,但认经典的复制和弦 + // (控制台/部分老程序对 Ctrl+Insert 的处理路径也与 Ctrl+C 不同)。 + // 无选区时该组合键无副作用,与「复制是幂等的」同理。 + if !updated { + crate::logger::log_info( + "translate", + &format!("取词:Ctrl+C 未更新剪贴板(前台进程 {process_name:?}),改试 Ctrl+Insert"), + ); + send_ctrl_insert(); + updated = wait_clipboard_change(before, PASTE_WAIT / 2); + } + let clip = if updated { read_clipboard() } else { None }; + // 键盘状态尽早复原:越早把 Alt 按回去,越不容易让目标应用进入菜单栏模式 + restore_modifiers(&forced); + + if !updated { + return Err(TranslateError::new( + ErrorKind::Empty, + "取词失败:模拟 Ctrl+C 后剪贴板没有更新。\ + 常见原因是目标窗口以管理员权限运行(系统会丢弃来自普通权限程序的模拟按键),\ + 或该应用不支持复制选区。可改用「翻译剪贴板」:先复制,再按对应快捷键。" + .to_string(), + )); + } + + // 剪贴板已被目标应用覆盖为选区内容。**先还原再做一切判定**: + // 还原必须覆盖所有后续路径(非文本 / 空文本 / 超限 / 成功), + // 否则「取词失败」的代价是用户剪贴板被悄悄换掉,与 restore_clipboard 设置矛盾。 + let restored = if req.restore_clipboard { + restore_clipboard(backup) + } else { + false + }; + // 还原本身也改了剪贴板。虽然还在屏蔽窗口内,但窗口解除后监听可能才轮到这一次变化, + // 于是额外精确记账一次,把边界情况的漏网也堵上。 + if restored { + if let Some(s) = suppress.as_ref() { + s.mark_seq(clipboard_seq()); + } + } + + let text = match clip { + Some(ClipData::Text(t)) => t, + Some(_) => { + return Err(TranslateError::new( + ErrorKind::Empty, + "取到的内容不是文本(选区可能是图片或文件)", + )) + } + None => { + return Err(TranslateError::new( + ErrorKind::Empty, + "未获取到选中文本:目标窗口可能不允许复制,或当前没有选中任何文字", + )) + } + }; + + finish_text( + req, + text, + hwnd, + &process_name, + // UIA 路径:剪贴板从头到尾没被碰过 + restored, + ) +} + +/// 取到文本后的公共收尾:裁剪 → 空判定 → 字数上限 → 组装结果。 +/// +/// 两条取词路径(UIA 直读 / 模拟 Ctrl+C)都必须过这套校验,否则「字数上限」 +/// 只对其中一条生效——那正是配置里写「上限」却仍被绕过的原因。 +fn finish_text( + req: &CaptureRequest, + text: String, + hwnd: i64, + process_name: &str, + clip_intact: bool, +) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err(TranslateError::new( + ErrorKind::Empty, + "未获取到选中文本(取到的内容为空)", + )); + } + let char_count = trimmed.chars().count(); + if req.max_chars > 0 && char_count > req.max_chars { + return Err(TranslateError::unsupported(format!( + "选中内容 {char_count} 字符,超过取词上限({})。\ + 请在「翻译 → 设置 → 划词翻译」中调高上限,或改用主面板翻译。", + req.max_chars + ))); + } + + Ok(CaptureOutcome { + text: trimmed.to_string(), + source: "selection".to_string(), + source_hwnd: hwnd, + source_process: process_name.to_string(), + restored_clipboard: clip_intact, + }) +} + +fn clipboard_seq() -> u32 { + unsafe { GetClipboardSequenceNumber() } +} + +/// UIA 直读选区(智能路径)。`windows` crate 只在 Windows 目标上参与构建, +/// 因此非 Windows 目标这里直接返回 None(等价于「读不到」→ 走兼容路径)。 +#[cfg(windows)] +fn uia_selection() -> Option { + super::uia_capture::read_selection() +} + +#[cfg(not(windows))] +fn uia_selection() -> Option { + None +} + +/// 等剪贴板序号变化(即目标应用完成了复制)。 +fn wait_clipboard_change(before: u32, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if clipboard_seq() != before { + return true; + } + std::thread::sleep(POLL_INTERVAL); + } + false +} + +fn key_down(vk: u16) -> bool { + (unsafe { GetAsyncKeyState(vk as i32) } as u16 & 0x8000) != 0 +} + +/// 发送单个按键事件(`up` 为真表示抬起)。 +fn send_key(vk: u16, up: bool) { + let mut input: INPUT = unsafe { std::mem::zeroed() }; + input.r#type = INPUT_KEYBOARD; + input.Anonymous.ki = KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: if up { KEYEVENTF_KEYUP } else { 0 }, + time: 0, + dwExtraInfo: 0, + }; + unsafe { + SendInput(1, &input, std::mem::size_of::() as i32); + } +} + +/// 让修饰键回到「都没按」的状态,返回被**强制**松开的键(调用方负责按回去)。 +/// +/// 两步走,顺序很重要: +/// 1. **先等用户自然松开**。全局快捷键在按键**按下**的瞬间触发,此刻 Alt 一定还按着; +/// 绝大多数情况用户几十毫秒内就松手了,等一下既解决了问题,又完全不用合成按键 +/// (合成 Alt 抬起有让目标应用进入菜单栏模式的风险)。 +/// 2. 超时仍未松开(长按、卡键)才合成抬起事件。只处理 Alt/Shift:合成 Win 抬起 +/// 会触发开始菜单,代价远大于收益——Win 参与的组合键本就罕见。 +fn neutralise_modifiers() -> Vec { + let deadline = Instant::now() + MOD_RELEASE_WAIT; + let all: Vec = BLOCKING_MODIFIERS + .iter() + .chain(WAIT_ONLY_MODIFIERS.iter()) + .copied() + .collect(); + while Instant::now() < deadline { + if !all.iter().copied().any(key_down) { + break; + } + std::thread::sleep(Duration::from_millis(8)); + } + + let mut forced = Vec::new(); + for vk in BLOCKING_MODIFIERS { + if key_down(vk) { + send_key(vk, true); + forced.push(vk); + } + } + if !forced.is_empty() { + // 给目标应用一点时间处理抬起事件,避免紧接着的 Ctrl+C 被合并成 Alt+Ctrl+C + std::thread::sleep(Duration::from_millis(10)); + } + forced +} + +/// 把 [`neutralise_modifiers`] 强制松开的键按回去,让用户自己松手时状态一致。 +/// +/// **只还原仍然物理按住的键**:用户可能在等待期间就松手了,此时再合成一个 keydown +/// 会把 Alt 留在「按下」状态——他下一次敲任意键都会变成 Alt+某键,比不还原糟糕得多。 +fn restore_modifiers(keys: &[u16]) { + for vk in keys { + if key_down(*vk) { + send_key(*vk, false); + } + } +} + +/// 把备份内容原样写回。仅支持文本 / 图片 / 文件三种格式——其余格式(HTML/RTF 等) +/// 在备份阶段就读不出来,因此无法还原,返回 false 而不是假装成功。 +fn restore_clipboard(backup: Option) -> bool { + match backup { + Some(ClipData::Text(t)) => write_text(&t), + Some(ClipData::Image { dib, .. }) => write_dib(&dib), + Some(ClipData::Files(files)) => write_files(&files), + None => false, + } +} + +/// 模拟 Ctrl+V(译文回填替换选区用)。 +/// +/// 与 [`send_ctrl_c`] 相同的修饰键处理:若用户仍按着 Ctrl,只补发 V 键, +/// 避免把用户的修饰键一并释放。 +pub fn send_ctrl_v() { + let ctrl_held = (unsafe { GetAsyncKeyState(VK_CONTROL as i32) } as u16 & 0x8000) != 0; + + let mut inputs: Vec = Vec::with_capacity(4); + let mut push = |vk: u16, up: bool| { + let mut input: INPUT = unsafe { std::mem::zeroed() }; + input.r#type = INPUT_KEYBOARD; + input.Anonymous.ki = KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: if up { KEYEVENTF_KEYUP } else { 0 }, + time: 0, + dwExtraInfo: 0, + }; + inputs.push(input); + }; + + // 取词悬浮窗是非激活的,前台窗口仍是原应用:Ctrl+V 会落在原选区上 + let own_ctrl = !ctrl_held; + if own_ctrl { + push(VK_CONTROL, false); + } + push(VK_V, false); + push(VK_V, true); + if own_ctrl { + push(VK_CONTROL, true); + } + + unsafe { + SendInput( + inputs.len() as u32, + inputs.as_ptr(), + std::mem::size_of::() as i32, + ); + } +} + +/// 取前台窗口句柄与所属进程名。 +fn foreground_window_process() -> (i64, Option) { + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd == 0 { + return (0, None); + } + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, &mut pid); + let hwnd_i64 = hwnd as i64; + if pid == 0 { + return (hwnd_i64, None); + } + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle == 0 { + // 目标进程权限更高时连句柄都拿不到——这本身就是一个有效信号, + // 但不必在这里下结论,让后续「取词超时」去提示 + return (hwnd_i64, None); + } + let mut buf = [0u16; 512]; + let mut size = buf.len() as u32; + let ok = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size); + CloseHandle(handle); + if ok == 0 { + return (hwnd_i64, None); + } + let path = String::from_utf16_lossy(&buf[..size as usize]); + let name = path + .rsplit(['\\', '/']) + .next() + .unwrap_or(path.as_str()) + .to_string(); + (hwnd_i64, if name.is_empty() { None } else { Some(name) }) + } +} + +/// 模拟「Ctrl + 某键」。 +/// +/// 关键细节:若用户此刻**正按着 Ctrl**(例如取词快捷键本身带 Ctrl), +/// 我们发出的 Ctrl 抬起会把用户的修饰键一并释放,造成「按一次快捷键后 +/// Ctrl 行为异常」。因此先探测 Ctrl 的物理状态,只补发缺失的那一段。 +fn send_ctrl_chord(vk: u16) { + // 高位为 1 表示当前处于按下状态 + let ctrl_held = (unsafe { GetAsyncKeyState(VK_CONTROL as i32) } as u16 & 0x8000) != 0; + + let mut inputs: Vec = Vec::with_capacity(4); + let mut push = |vk: u16, up: bool| { + let mut input: INPUT = unsafe { std::mem::zeroed() }; + input.r#type = INPUT_KEYBOARD; + input.Anonymous.ki = KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: if up { KEYEVENTF_KEYUP } else { 0 }, + time: 0, + dwExtraInfo: 0, + }; + inputs.push(input); + }; + + let own_ctrl = !ctrl_held; + if own_ctrl { + push(VK_CONTROL, false); + } + push(vk, false); + push(vk, true); + if own_ctrl { + push(VK_CONTROL, true); + } + + unsafe { + SendInput( + inputs.len() as u32, + inputs.as_ptr(), + std::mem::size_of::() as i32, + ); + } +} + +/// 模拟 Ctrl+C。 +fn send_ctrl_c() { + send_ctrl_chord(VK_C); +} + +/// 模拟 Ctrl+Insert:部分应用对合成的 Ctrl+C 不响应,但认这条经典复制和弦。 +fn send_ctrl_insert() { + send_ctrl_chord(VK_INSERT); +} diff --git a/src-tauri/src/translate/capture/mod.rs b/src-tauri/src/translate/capture/mod.rs new file mode 100644 index 0000000..cfd557f --- /dev/null +++ b/src-tauri/src/translate/capture/mod.rs @@ -0,0 +1,28 @@ +//! 取词:把「用户选中的文本」弄到手。 +//! +//! 两条路径,由 `selection.mode` 决定(默认 `"smart"`): +//! - **UIA 直读**([`uia_capture`]):向目标进程的自动化提供者要当前选区。 +//! 不模拟按键、不碰剪贴板,因此不受修饰键残留、UIPI、剪贴板占用影响。读不到就 +//! 返回 None,自动退回下一条路径。 +//! - **兼容路径**([`clipboard_capture`]):备份剪贴板 → 模拟 Ctrl+C → 读走 → 还原。 +//! 覆盖最广(几乎所有支持复制的宿主都行),代价是短暂占用剪贴板。 +//! +//! 调用方不该关心用了哪条路径,只看 [`clipboard_capture::CaptureOutcome::source`] 即可。 +//! +//! 四条必须显式处理的现实约束(都是踩过才知道的): +//! 0. **修饰键残留**:全局快捷键在按键**按下**瞬间触发,此时 Alt 仍被物理按住, +//! 补发的 Ctrl+C 在目标应用看来是 `Alt+Ctrl+C` —— 没有任何应用把它当复制。 +//! 这是「按了快捷键却取不到词」最主要的原因,见 +//! [`clipboard_capture`] 里的 `neutralise_modifiers`。 +//! 1. **剪贴板必须被屏蔽**:整个取词过程会动三次剪贴板,而监听线程是 250ms 轮询, +//! 按序列号逐个记账存在竞态。见 [`crate::clipboard::suppress`]。 +//! 2. **终端类应用不能取词**:Ctrl+C 在那里是中断信号。走进程黑名单,给出可行的替代做法。 +//! 3. **提权窗口取不到词**:目标进程以管理员权限运行时,非提权进程的 `SendInput` +//! 会被 UIPI 直接丢弃(不报错、无反馈)。因此必须区分「超时」与「剪贴板变了但没有文本」, +//! 否则用户只会看到一句含糊的失败。 + +pub mod clipboard_capture; +#[cfg(windows)] +pub mod uia_capture; + +pub use clipboard_capture::{capture_selection, send_ctrl_v, CaptureRequest}; diff --git a/src-tauri/src/translate/capture/uia_capture.rs b/src-tauri/src/translate/capture/uia_capture.rs new file mode 100644 index 0000000..8500acf --- /dev/null +++ b/src-tauri/src/translate/capture/uia_capture.rs @@ -0,0 +1,174 @@ +//! UIA 直读取词:不模拟任何按键,直接从焦点元素读出选区文本。 +//! +//! 为什么需要它 —— 模拟 Ctrl+C 这条兼容路径有三个绕不过去的现实问题: +//! 1. **修饰键残留**。全局快捷键在**按键按下**的瞬间触发,此时 Alt 仍被物理按住, +//! 我们补发的 Ctrl+C 在目标应用看来是 `Alt+Ctrl+C` —— 没有任何应用把它当「复制」, +//! 于是必然走到「取词超时」。这正是「试了好几个程序都取不到」的主因。 +//! 2. **提权窗口**。UIPI 会静默丢弃来自低完整性级别进程的模拟按键。 +//! 3. **剪贴板占用**。取词期间用户的剪贴板被临时换掉,任何并发的复制都会丢。 +//! +//! UIA 三条全避开:它只是「问」目标进程的自动化提供者要当前选区,不改键盘状态、 +//! 不碰剪贴板。浏览器(Chromium / Firefox)、Office、多数 Qt / Win32 编辑控件都提供 +//! TextPattern;读不到就返回 `None`,由调用方退回兼容路径。 +//! +//! 两条工程约束: +//! - **必须在独立线程上执行并带超时**。UIA 是跨进程 COM 调用,目标进程无响应时 +//! `GetFocusedElement` 会一直挂着(既不返回也不报错)。没有超时就会漏线程、 +//! 并且让「取词」这个动作永久卡住。 +//! - **只能尽力而为**。这里不返回 `Result`:UIA 读不到是正常的(很多程序没有 +//! TextPattern),调用方只需按「有 / 没有」分支,不需要错误文案。 + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc as std_mpsc; +use std::time::Duration; + +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_SERVER, COINIT_MULTITHREADED, +}; +use windows::Win32::UI::Accessibility::{ + CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationTextPattern, + IUIAutomationTextRangeArray, UIA_TextPatternId, +}; + +/// 单次 UIA 读取的超时上限。 +/// +/// 700ms 的依据:本地跨进程 COM 往返正常在 10ms 量级;给到 700ms 足以覆盖目标进程 +/// 偶发忙碌,又不会让用户感到「按了没反应」。再长就该交给兼容路径去兜底了。 +const UIA_TIMEOUT: Duration = Duration::from_millis(700); + +/// 沿焦点元素向上找 TextPattern 的最大层数。 +/// +/// 浏览器里焦点常落在一个深层节点(甚至是 body),而 TextPattern 挂在更上层的 +/// 文档节点上;但要限制层数——一路上溯到桌面根节点既慢又可能读到整页文本。 +const MAX_ANCESTORS: usize = 4; + +/// 连续超时次数上限。达到后本次进程内暂停尝试 UIA。 +/// +/// 超时意味着目标进程(或 UIA 服务)无响应。每次取词都起一个注定挂住的线程 +/// 会持续泄漏,因此给它一个熔断。熔断带 60s 衰减(见 [`read_selection`]): +/// 只针对当下无响应的目标,不该让一次抖动永久禁用 UIA。 +static TIMEOUTS: AtomicU32 = AtomicU32::new(0); +const TIMEOUT_LIMIT: u32 = 3; +/// 最近一次 UIA 超时的系统时间(毫秒),配合 TIMEOUTS 做衰减复位 +static LAST_TIMEOUT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// 熔断衰减窗口:距上次超时超过该时长即清零计数,重新给 UIA 机会 +const TIMEOUT_DECAY_MS: u64 = 60_000; + +fn system_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// 尝试用 UIA 直读当前选区文本。读不到返回 `None`。 +pub fn read_selection() -> Option { + // 熔断 + 衰减:达到上限后跳过 UIA,但 60s 内未再超时就清零重新启用。 + // 没有衰减的话,一次抖动(目标进程短暂忙碌)就会让 UIA 在整个进程生命周期里失效, + // 之后所有取词都退到模拟按键路径——症状正是「换个程序也取不到」。 + if TIMEOUTS.load(Ordering::SeqCst) >= TIMEOUT_LIMIT { + let last = LAST_TIMEOUT_MS.load(Ordering::SeqCst); + let now = system_millis(); + if last != 0 && now.saturating_sub(last) < TIMEOUT_DECAY_MS { + return None; + } + TIMEOUTS.store(0, Ordering::SeqCst); + } + + let (tx, rx) = std_mpsc::channel(); + std::thread::spawn(move || { + let result = read_selection_blocking(); + let _ = tx.send(result); + }); + + match rx.recv_timeout(UIA_TIMEOUT) { + Ok(Some(text)) => { + // 命中即清零熔断计数:超时针对的是「当下的目标」,成功说明 UIA 服务正常 + TIMEOUTS.store(0, Ordering::SeqCst); + Some(text) + } + // 读不到(没有 TextPattern / 无选区):正常情况,交给兼容路径 + Ok(None) => None, + Err(_) => { + LAST_TIMEOUT_MS.store(system_millis(), Ordering::SeqCst); + let n = TIMEOUTS.fetch_add(1, Ordering::SeqCst) + 1; + crate::logger::log_warn( + "translate", + &format!("UIA 取词超时(第 {n} 次),本次改用模拟 Ctrl+C 兜底"), + ); + None + } + } +} + +/// 真正的读取逻辑。**阻塞**,且必须在独立线程上调用(见模块头注释)。 +fn read_selection_blocking() -> Option { + unsafe { + let hr = CoInitializeEx(None, COINIT_MULTITHREADED); + // 只在本次调用确实初始化了 COM 时才配平 Uninitialize + let uninit = hr.is_ok(); + let result = read_selection_inner(); + if uninit { + CoUninitialize(); + } + result + } +} + +fn read_selection_inner() -> Option { + unsafe { + let automation: IUIAutomation = CoCreateInstance(&CUIAutomation, None, CLSCTX_SERVER).ok()?; + let walker = automation.ControlViewWalker().ok()?; + let mut element: Option = automation.GetFocusedElement().ok(); + + let mut depth = 0; + while let Some(el) = element { + if let Some(text) = selection_text_of(&el) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + if depth >= MAX_ANCESTORS { + break; + } + depth += 1; + element = walker.GetParentElement(&el).ok(); + } + None + } +} + +/// 取某个元素上「当前选区」的文本。元素不支持 TextPattern 或没有选区 → None。 +fn selection_text_of(element: &IUIAutomationElement) -> Option { + unsafe { + let pattern = element + .GetCurrentPatternAs::(UIA_TextPatternId) + .ok()?; + let ranges: IUIAutomationTextRangeArray = pattern.GetSelection().ok()?; + join_ranges(&ranges) + } +} + +/// 把多个选区区间拼成一段文本(`-1` 表示不限长度,取区间全部内容)。 +fn join_ranges(ranges: &IUIAutomationTextRangeArray) -> Option { + unsafe { + let count = ranges.Length().ok()?; + if count <= 0 { + return None; + } + let mut parts: Vec = Vec::new(); + for i in 0..count { + let range = ranges.GetElement(i).ok()?; + let text = range.GetText(-1).ok()?.to_string(); + if !text.trim().is_empty() { + parts.push(text); + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } + } +} diff --git a/src-tauri/src/translate/commands.rs b/src-tauri/src/translate/commands.rs new file mode 100644 index 0000000..9ac1ff6 --- /dev/null +++ b/src-tauri/src/translate/commands.rs @@ -0,0 +1,1069 @@ +//! 翻译模块 Tauri 命令层。 +//! +//! 这一层刻意保持"薄":只做参数整形、校验与错误归类,业务编排都在 +//! [`super::TranslateManager`]、各引擎实现与 [`super::popup`] 里。 +//! +//! 两条安全约定: +//! - **不存在读取 API Key 明文的命令**。列表接口只回传「是否已配置」与掩码字符串, +//! 写入走 `translate_secret_set`,删除走 `translate_secret_clear`。 +//! - **写剪贴板走 Rust**(`translate_copy_text`),不让前端用 `navigator.clipboard`: +//! 取词悬浮窗是 NOACTIVATE 窗口,浏览器对「无用户激活」的剪贴板写入会拒绝, +//! 走 Rust 既可靠,也顺带把这次写入登记进抑制表、不污染剪贴板历史。 + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, Manager, State}; + +use super::engines::{EngineRequest, ErrorKind, TranslateError, TranslateMode, TranslateResult}; +use super::history::HistoryItem; +use super::settings::TranslateEngineConfig; +use super::{engine_api_key, engine_secret_key, popup, TranslateManager, TranslateSettings}; + +/// 引擎实例的前端视图:配置 + 派生状态(密钥是否已配、是否可用)。 +/// +/// 拆成「config + 派生」而不是直接回传配置,是为了让前端保存时能原样回传 +/// `view.config`,不必自己去拼装结构,也不会误把派生字段写回配置。 +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EngineView { + pub config: TranslateEngineConfig, + /// 是否已在系统凭据管理器中配置密钥 + pub has_api_key: bool, + /// 密钥掩码(未配置时为空串) + pub api_key_masked: String, + /// 是否已具备发起翻译的完整配置 + pub ready: bool, + /// 不可用原因(ready 为 true 时为空) + pub issue: Option, +} + +/// 引擎连通性自检结果。 +/// +/// 与其它命令不同,这里**失败也返回 Ok**:测试失败是「数据」而不是「命令异常」, +/// 前端要显示具体原因,不该走 try/catch 分支。 +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EngineTestResult { + pub ok: bool, + pub latency_ms: u64, + pub message: String, + /// 失败时的错误分类(成功时为 None) + pub error_kind: Option, + /// 上游原始响应片段(已截断) + pub detail: Option, +} + +/// 单次翻译请求参数。 +#[derive(Debug, Clone, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TranslateRunParams { + pub text: String, + /// 源语言代码,"auto" 或省略表示自动检测 + #[serde(default)] + pub from: Option, + /// 目标语言代码(如 zh-Hans) + #[serde(default)] + pub to: Option, + /// 目标语言自然语言全称(如 简体中文),提示词用 + #[serde(default)] + pub to_label: Option, + /// 源语言自然语言全称,仅显式指定源语言时有意义 + #[serde(default)] + pub from_label: Option, + /// 指定引擎实例 id;省略或 "auto" 表示按优先级自动(含降级) + #[serde(default)] + pub engine_id: Option, + /// 模式:"translate"(默认)| "polish" | "explain" | "summarize" + #[serde(default)] + pub mode: Option, + /// 记入历史时的来源:"manual"(主面板)| "selection" | "clipboard" | "screenshot" + #[serde(default)] + pub via: Option, + /// 是否写入历史。默认 true;**多引擎对比与预览必须传 false**。 + #[serde(default)] + pub record: Option, +} + +// ===== 设置 ===== + +/// 读取翻译设置 +#[tauri::command] +#[specta::specta] +pub fn translate_get_settings(state: State<'_, TranslateManager>) -> Result { + Ok(state.load_settings()) +} + +/// 保存翻译设置。 +/// +/// 保存后立即重新应用全局快捷键:快捷键改动若不能即时生效,用户会以为「设置没保存」, +/// 而重新注册本身是幂等的(内部先注销旧的)。快捷键注册失败**不当作保存失败**—— +/// 设置已经落盘了,把失败原因作为返回的错误信息告知即可。 +#[tauri::command] +#[specta::specta] +pub fn translate_save_settings( + app: AppHandle, + state: State<'_, TranslateManager>, + settings: TranslateSettings, +) -> Result<(), String> { + // 容量上限现在由设置页暴露,调小之后必须立刻生效:否则用户设成 500、 + // 回到历史页看到的仍是 2000 条,只会以为设置没保存。只在**调小**时淘汰—— + // 调大或与历史无关的设置改动不需要跑这次索引扫描。 + let previous_max = state.load_settings().history.max_items; + state.save_settings(&settings)?; + if settings.history.max_items < previous_max { + if let Some(history) = state.history() { + history.prune_to_max(settings.history.max_items as i64); + } + } + match popup::apply_shortcuts(&app) { + Ok(()) => Ok(()), + Err(e) => { + crate::logger::log_warn("translate", &format!("快捷键应用失败: {e}")); + Err(format!("设置已保存,但快捷键注册失败:{e}")) + } + } +} + +/// 按当前设置重新注册全局快捷键(设置页改动后或启动时调用) +#[tauri::command] +#[specta::specta] +pub fn translate_apply_shortcuts(app: AppHandle) -> Result<(), String> { + popup::apply_shortcuts(&app) +} + +/// 记住划词悬浮窗里选的语言(源 / 目标)。 +/// +/// 弹窗是独立窗口且**收不到键盘事件**(非激活窗口不持有焦点),所有交互只能用鼠标, +/// 因此语言切换做成了循环按钮而不是下拉;切换结果必须落盘,否则用户每次划词都要重选一遍 +/// ——对「MyMemory 必须显式源语言」这类源来说,等于每次都得手动指定。 +/// +/// 刻意不重新注册快捷键:这里只改弹窗外观相关的字段,走一遍快捷键注册是白费功夫, +/// 还可能在注册失败时把一个无关的错误抛给前端。 +#[tauri::command] +#[specta::specta] +pub fn translate_popup_prefs_set( + state: State<'_, TranslateManager>, + source_lang: String, + target_lang: String, +) -> Result<(), String> { + let mut settings = state.load_settings(); + settings.popup.source_lang = source_lang.trim().to_string(); + settings.popup.target_lang = target_lang.trim().to_string(); + state.save_settings(&settings) +} + +// ===== 引擎管理 ===== + +/// 列出引擎实例(含密钥状态与可用性) +#[tauri::command] +#[specta::specta] +pub async fn translate_engines_list( + state: State<'_, TranslateManager>, +) -> Result, String> { + let settings = state.load_settings(); + Ok(settings + .engines + .iter() + .map(|cfg| to_view(cfg)) + .collect()) +} + +/// 新增或更新一个引擎实例(按 id upsert)。 +/// +/// 校验 id:它会进入凭据键(`translate-engine-`),含特殊字符会让键名难以排查, +/// 因此限定为字母/数字/下划线/短横线。 +#[tauri::command] +#[specta::specta] +pub fn translate_engine_save( + state: State<'_, TranslateManager>, + config: TranslateEngineConfig, +) -> Result<(), String> { + // 先把 id 取成自有值:下面要整体移动 config,不能再持有对 config.id 的借用 + let id = config.id.trim().to_string(); + if id.is_empty() { + return Err("引擎标识不能为空".to_string()); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err("引擎标识只能包含字母、数字、下划线和短横线".to_string()); + } + if config.name.trim().is_empty() { + return Err("引擎名称不能为空".to_string()); + } + + let mut settings = state.load_settings(); + let mut next = config; + next.id = id; + match settings.engines.iter_mut().find(|e| e.id == next.id) { + Some(slot) => *slot = next, + None => settings.engines.push(next), + } + settings.engines.sort_by_key(|e| e.priority); + state.save_settings(&settings) +} + +/// 删除引擎实例(连同它在系统凭据管理器中的密钥)。 +#[tauri::command] +#[specta::specta] +pub fn translate_engine_delete(state: State<'_, TranslateManager>, id: String) -> Result<(), String> { + let mut settings = state.load_settings(); + let before = settings.engines.len(); + settings.engines.retain(|e| e.id != id); + if settings.engines.len() == before { + return Err(format!("找不到引擎实例「{id}」")); + } + // 默认引擎指向被删除的实例时改为「自动」,比静默改写为别的实例更符合预期 + if settings.default_engine_id == id { + settings.default_engine_id = "auto".to_string(); + } + state.save_settings(&settings)?; + // 凭据删除失败只记日志:引擎已从配置里移除,残留一条孤儿凭据不影响使用 + if let Err(e) = crate::secrets::secret_delete(&engine_secret_key(&id)) { + crate::logger::log_error("translate", &format!("删除引擎「{id}」的密钥失败: {e}")); + } + Ok(()) +} + +/// 写入引擎的 API Key(明文只在此处进入系统凭据管理器,永不回写配置文件)。 +#[tauri::command] +#[specta::specta] +pub fn translate_secret_set( + state: State<'_, TranslateManager>, + id: String, + api_key: String, +) -> Result<(), String> { + if state.load_settings().engine(&id).is_none() { + return Err(format!("找不到引擎实例「{id}」")); + } + let key = engine_secret_key(&id); + let value = api_key.trim(); + if value.is_empty() { + return crate::secrets::secret_delete(&key); + } + crate::secrets::secret_write(&key, value) +} + +/// 清除引擎的 API Key +#[tauri::command] +#[specta::specta] +pub fn translate_secret_clear(id: String) -> Result<(), String> { + crate::secrets::secret_delete(&engine_secret_key(&id)) +} + +/// 连通性自检:用前端传入的配置测试(**不落盘**)。 +/// +/// 与「按 id 测已保存配置」的区别:编辑草稿里的 Base URL / 模型改动还没保存时, +/// 按 id 测的是旧配置——用户改完地址立刻点「测试」会得到「未配置服务地址」, +/// 让人以为自己的输入没生效。这里直接收草稿配置,让「改完立刻测」成立; +/// API Key 仍按 `config.id` 从系统凭据管理器读取(引擎需先保存过一次才能配 Key)。 +#[tauri::command] +#[specta::specta] +pub async fn translate_engine_test_config( + state: State<'_, TranslateManager>, + config: TranslateEngineConfig, +) -> Result { + if config.id.trim().is_empty() || config.name.trim().is_empty() { + return Ok(failure( + TranslateError::config("引擎标识或名称不能为空,请先保存一次引擎"), + 0, + )); + } + let engine = match state.build(&config).await { + Ok(e) => e, + Err(e) => return Ok(failure(e, 0)), + }; + + let started = std::time::Instant::now(); + match engine.test().await { + Ok(message) => Ok(EngineTestResult { + ok: true, + latency_ms: started.elapsed().as_millis() as u64, + message, + error_kind: None, + detail: None, + }), + Err(e) => Ok(failure(e, started.elapsed().as_millis() as u64)), + } +} + +/// 拉取上游模型列表(应对「上游改名」导致预设失效的自救入口)。 +/// 免密钥源没有模型概念,返回空列表。 +#[tauri::command] +#[specta::specta] +pub async fn translate_engine_models( + state: State<'_, TranslateManager>, + id: String, +) -> Result, String> { + let settings = state.load_settings(); + let cfg = settings + .engine(&id) + .cloned() + .ok_or_else(|| format!("找不到引擎实例「{id}」"))?; + let engine = state.build(&cfg).await.map_err(|e| e.message)?; + engine.list_models().await.map_err(|e| e.message) +} + +// ===== 翻译 ===== + +/// 执行一次翻译。 +/// +/// 错误以结构化的 [`TranslateError`] 返回,前端按 `kind` 分支给出不同提示 +/// (改 Key / 查网络 / 稍后重试是三件不同的事)。 +#[tauri::command] +#[specta::specta] +pub async fn translate_run( + state: State<'_, TranslateManager>, + params: TranslateRunParams, +) -> Result { + let req = EngineRequest { + text: params.text, + from: normalize(params.from, "auto"), + to: normalize(params.to, String::new()), + to_label: normalize(params.to_label, String::new()), + from_label: normalize(params.from_label, String::new()), + mode: TranslateMode::parse(params.mode.as_deref()), + image_png: None, + via: normalize( + params.via, + "manual", + ) + .replace(|c: char| !c.is_ascii_alphanumeric(), ""), + }; + let record = params.record.unwrap_or(true); + state.run(req, params.engine_id.as_deref(), record).await +} + +/// 把文本写入系统剪贴板(供主面板与取词悬浮窗复制译文)。 +/// 写入会登记进剪贴板抑制表,因此不会在剪贴板历史里留下一条「自己复制自己」的记录。 +#[tauri::command] +#[specta::specta] +pub fn translate_copy_text(app: AppHandle, text: String) -> Result<(), String> { + if text.is_empty() { + return Err("没有可复制的内容".to_string()); + } + if !crate::clipboard::reader::write_text(&text) { + return Err("写入剪贴板失败".to_string()); + } + if let Some(manager) = app.try_state::() { + manager.suppress_current_sequence(); + } + Ok(()) +} + +// ===== 划词取词与悬浮窗 ===== + +/// 预览取词悬浮窗:用固定样例文本走一遍完整链路(窗口定位、非激活显示、翻译)。 +/// +/// 为什么不提供「测试取词」按钮:从应用内触发取词必然失败——前台窗口是本应用自己, +/// Ctrl+C 会落到一个没有选区的窗口上。要验证取词只能去别的应用里按快捷键。 +#[tauri::command] +#[specta::specta] +pub fn translate_preview_popup(app: AppHandle, state: State<'_, TranslateManager>) { + let settings = state.load_settings(); + popup::show(&app, popup::preview_payload(&settings)); +} + +/// 悬浮窗挂载完成(仅兜底创建路径真正显示) +#[tauri::command] +#[specta::specta] +pub fn translate_popup_ready(app: AppHandle) { + popup::ready(&app); +} + +/// 译文回填:把译文写入剪贴板并模拟 Ctrl+V 粘贴回原窗口,替换原选区。 +/// +/// 只对划词来源有意义——剪贴板/截图来源没有「原处」可回填。 +/// 能成立的前提是**悬浮窗从不抢焦点**(非激活窗口),因此原窗口的键盘焦点与选区 +/// 在翻译期间一直保持原样。回填前校验原窗口仍然存活,避免粘贴进毫不相干的窗口。 +#[tauri::command] +#[specta::specta] +pub fn translate_paste_back(app: AppHandle, text: String, hwnd: i64) -> Result<(), String> { + if text.trim().is_empty() { + return Err("没有可回填的译文".to_string()); + } + #[cfg(windows)] + { + use windows_sys::Win32::UI::WindowsAndMessaging::IsWindow; + if hwnd == 0 || unsafe { IsWindow(hwnd as _) } == 0 { + return Err("原窗口已关闭或不可用,无法回填".to_string()); + } + } + #[cfg(not(windows))] + let _ = hwnd; + + if !crate::clipboard::reader::write_text(&text) { + return Err("写入剪贴板失败".to_string()); + } + if let Some(manager) = app.try_state::() { + manager.suppress_current_sequence(); + } + super::popup::hide(&app); + // 延迟粘贴:等悬浮窗隐藏完成、焦点回到原窗口 + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + super::capture::send_ctrl_v(); + }); + Ok(()) +} + +/// 隐藏悬浮窗 +#[tauri::command] +#[specta::specta] +pub fn translate_popup_hide(app: AppHandle) { + popup::hide(&app); +} + +/// 弹窗编辑模式开关(原文编辑框 / 译文选中复制需要键盘焦点)。 +/// +/// 弹窗平时是 NOACTIVATE 的(不抢焦点、收不到键盘事件);用户点击原文编辑区时 +/// 前端请求 `enable=true`,临时移除 NOACTIVATE 并把弹窗推到前台。隐藏与下次显示 +/// 时由后端自动恢复,不依赖前端记得关。 +#[tauri::command] +#[specta::specta] +pub fn translate_popup_edit_mode(app: AppHandle, enable: bool) -> Result<(), String> { + popup::set_edit_mode(&app, enable) +} + +/// 钉住弹窗:钉住后点击外部与长时间停留都不再自动收起,只能手动关闭。 +/// 弹窗收起时后端自动复位钉住状态。 +#[tauri::command] +#[specta::specta] +pub fn translate_popup_set_pinned(pinned: bool) { + popup::set_pinned(pinned); +} + +/// 按内容自适应悬浮窗尺寸(前端测高后调用)。 +/// +/// 返回前端根节点应使用的 max-height(逻辑像素,来自工作区而非窗口自身—— +/// 用 100vh 会形成收缩反馈循环,见 `popup::resize` 的注释);0 表示不限制。 +#[tauri::command] +#[specta::specta] +pub fn translate_popup_resize(app: AppHandle, width: f64, height: f64) -> f64 { + popup::resize(&app, width, height) +} + +// ===== 截图翻译 ===== + +/// 截图翻译的结果摘要(展示交给悬浮窗,这里供调用方与历史记录使用) +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ScreenshotOutcome { + /// 本地 OCR 的识别文本(视觉直译模式为空) + pub ocr_text: String, + /// OCR 使用的语言(BCP-47,本地模式) + pub ocr_lang: String, + pub engine_name: String, + pub translated: bool, +} + +/// 翻译一个已框选的屏幕区域。 +/// +/// 由截图覆盖层的「翻译」按钮调用(选区确认后)。流程: +/// 本地模式 → 裁剪 → 本地 OCR → 识别文本交给悬浮窗(弹窗自己走翻译引擎,语言表在前端); +/// 视觉模式 → 裁剪 → 图像直接交给支持图像输入的模型,识别与翻译一步完成。 +/// +/// 识别/翻译失败**也弹窗**:用户视线就在选区那里,失败原因出现在视线里 +/// 比回到主界面看 toast 有用得多。OCR 在阻塞线程池执行(WinRT 的 `get()` 会阻塞)。 +#[tauri::command] +#[specta::specta] +pub async fn translate_screenshot_region( + app: AppHandle, + state: State<'_, TranslateManager>, + x: i32, + y: i32, + w: i32, + h: i32, + to_label: Option, +) -> Result { + if w < 2 || h < 2 { + return Err(TranslateError::empty()); + } + let settings = state.load_settings(); + let sc = settings.screenshot.clone(); + // 结果贴在选区右下角;工作区放不下时由悬浮窗自动翻侧/钳制 + let (anchor_x, anchor_y) = (x + w, y + h); + + // ===== 视觉直译:跳过本地 OCR,识别 + 翻译一步完成 ===== + if sc.ocr_mode == "vision" { + // 配置类失败也弹窗:覆盖层已隐藏,静默早退会让用户点了按钮毫无反应 + let cfg = match pick_vision_engine(&settings) { + Ok(c) => c, + Err(e) => { + popup::show_screenshot_error( + &app, + &settings, + e.message.clone(), + anchor_x, + anchor_y, + ); + return Err(e); + } + }; + let engine = match state.build(&cfg).await { + Ok(e) => e, + Err(e) => { + popup::show_screenshot_error( + &app, + &settings, + e.message.clone(), + anchor_x, + anchor_y, + ); + return Err(e); + } + }; + let png = match tauri::async_runtime::spawn_blocking(move || { + crop_region_png_blocking(x, y, w, h) + }) + .await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + popup::show_screenshot_error( + &app, + &settings, + e.message.clone(), + anchor_x, + anchor_y, + ); + return Err(e); + } + Err(e) => { + let err = + TranslateError::new(ErrorKind::Unknown, format!("裁剪任务失败: {e}")); + popup::show_screenshot_error( + &app, + &settings, + err.message.clone(), + anchor_x, + anchor_y, + ); + return Err(err); + } + }; + let image_png = { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(&png) + }; + let req = EngineRequest { + text: String::new(), + from: "auto".to_string(), + to: settings.default_target.clone(), + to_label: normalize(to_label, settings.default_target.clone()), + from_label: String::new(), + mode: TranslateMode::Translate, + image_png: Some(image_png), + via: "screenshot".to_string(), + }; + let result = engine.translate(&req).await.map_err(|e| { + popup::show_screenshot_error(&app, &settings, e.message.clone(), anchor_x, anchor_y); + e + })?; + // 视觉直译绕过了 state.run(),历史必须在这里补记(原文以占位说明来源) + if settings.history.enabled { + if let Some(h) = state.history() { + let _ = h.record( + "auto", + &settings.default_target, + &cfg.id, + &result.engine_name, + "(屏幕截图)", + &result.text, + "screenshot", + result.latency_ms as i64, + ); + h.prune_to_max(settings.history.max_items as i64); + } + } + let engine_name = cfg.name.clone(); + popup::show_screenshot_result( + &app, + &settings, + String::new(), + Some(format!("图片已上传至 {engine_name}")), + Some(result), + true, + anchor_x, + anchor_y, + ); + return Ok(ScreenshotOutcome { + ocr_text: String::new(), + ocr_lang: String::new(), + engine_name, + translated: true, + }); + } + + // ===== 本地 OCR:识别文本交给悬浮窗,翻译由弹窗(持有语言表)完成 ===== + let ocr_lang = sc.ocr_lang.clone(); + let ocr = tauri::async_runtime::spawn_blocking(move || { + crop_region_png_blocking(x, y, w, h).and_then(|png| { + let lang = if ocr_lang.trim().is_empty() || ocr_lang.trim() == "auto" { + None + } else { + Some(ocr_lang.as_str()) + }; + super::ocr::ocr_png(&png, lang) + }) + }) + .await + .map_err(|e| TranslateError::new(ErrorKind::Unknown, format!("OCR 任务失败: {e}")))? + .map_err(|e| { + popup::show_screenshot_error(&app, &settings, e.message.clone(), anchor_x, anchor_y); + e + })?; + + let ocr_text = ocr + .lines + .iter() + .map(|l| l.text.as_str()) + .collect::>() + .join("\n"); + + if ocr_text.trim().is_empty() { + popup::show_screenshot_error( + &app, + &settings, + "未识别到文字:截图里可能没有文本,或当前识别语言对这种字体效果不佳".to_string(), + anchor_x, + anchor_y, + ); + return Ok(ScreenshotOutcome { + ocr_text: String::new(), + ocr_lang: ocr.lang, + engine_name: "本地 OCR".to_string(), + translated: false, + }); + } + + let note = format!("本地识别 · {}", ocr.lang); + popup::show_screenshot_result( + &app, + &settings, + ocr_text.clone(), + Some(note), + None, + sc.auto_translate, + anchor_x, + anchor_y, + ); + + Ok(ScreenshotOutcome { + ocr_text, + ocr_lang: ocr.lang, + engine_name: "本地 OCR".to_string(), + translated: sc.auto_translate, + }) +} + +/// 列出系统可用的 OCR 语言(设置页据此提示语言包缺失)。 +/// +/// **必须带超时**:`OcrEngine.AvailableRecognizerLanguages` 在部分机器上会卡住不返回 +/// (WinRT 激活失败时既不成功也不报错)。直接在阻塞线程池上等它,设置页的按钮就会 +/// 永远转圈——用户只能重启应用。这里改为在**独立线程**上执行并用 `recv_timeout` 兜底: +/// 超时就当失败,让前端显示可重试的错误,而不是把一个线程永远挂在 WinRT 里。 +#[tauri::command] +#[specta::specta] +pub async fn translate_ocr_languages() -> Result, String> { + const OCR_LANG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8); + + let outcome = tauri::async_runtime::spawn_blocking(move || { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(super::ocr::available_languages()); + }); + match rx.recv_timeout(OCR_LANG_TIMEOUT) { + Ok(result) => result.map_err(|e| e.to_string()), + Err(_) => Err( + "读取系统 OCR 语言超时:Windows OCR 无响应。\ + 识别语言可填 auto(跟随系统语言),或稍后重试。" + .to_string(), + ), + } + }) + .await + .map_err(|e| format!("读取 OCR 语言失败: {e}"))?; + outcome +} + +/// 裁剪选区并解码为 PNG 字节(阻塞,须在阻塞线程池调用) +fn crop_region_png_blocking(x: i32, y: i32, w: i32, h: i32) -> Result, TranslateError> { + use base64::Engine as _; + let cropped = crate::screenshot::capture::crop_stored(x, y, w, h) + .map_err(|e| TranslateError::new(ErrorKind::Unknown, format!("裁剪选区失败: {e}")))?; + base64::engine::general_purpose::STANDARD + .decode(cropped.png_base64.as_bytes()) + .map_err(|e| TranslateError::parse(format!("解码截图失败: {e}"))) +} + +/// 视觉直译的引擎选择:优先默认引擎(若它声明支持图像输入),否则按优先级取第一个支持的。 +fn pick_vision_engine( + settings: &TranslateSettings, +) -> Result { + if let Some(cfg) = settings.engine(&settings.default_engine_id) { + if cfg.supports_vision && cfg.kind == "ai" { + return Ok(cfg.clone()); + } + } + settings + .engines + .iter() + .filter(|c| c.supports_vision && c.kind == "ai" && c.enabled) + .min_by_key(|c| c.priority) + .cloned() + .ok_or_else(|| { + TranslateError::config( + "视觉直译需要一个支持图像输入的引擎:请在引擎设置中勾选「支持图像输入」", + ) + }) +} + +// ===== 流式输出 ===== + +/// 流式请求自增序号(配合毫秒时间戳生成唯一 requestId) +static STREAM_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// 在途流式请求的停止标志 +static ACTIVE_STREAMS: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + +fn active_streams() -> &'static std::sync::Mutex< + std::collections::HashMap>, +> { + ACTIVE_STREAMS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +/// 启动一次流式翻译,返回 requestId;增量与结果经事件下发。 +/// +/// 与 `translate_run` 的差异(刻意为之): +/// - **降级只在「还没吐出任何内容」时发生**。降级发生在请求被拒绝的瞬间才有意义; +/// 一旦已经输出了一半再换引擎重译,用户只会看到内容跳变。所以这里用 +/// `produced` 标记首个 chunk 是否已下发:没产出且还有候选 → 静默换下一个源; +/// 已产出或已是最后一个候选 → 把错误交给前端。 +/// 这一条正是「MyMemory 需要显式源语言」这类配置类失败能在划词场景自动落到 +/// DeepSeek 上的原因。 +/// - **不透传 thinking**:翻译场景要低延迟,流式下更是如此。 +/// +/// **requestId 由前端在发请求前生成并经 `request_id` 参数传入**:本命令要等翻译 +/// 结束才返回,chunk/done 事件全部先于 Promise 到达;若前端等返回值才知道 +/// requestId,所有事件都会因对不上号被过滤(表现为「历史里有译文、界面空白」)。 +/// 该参数为空时(旧调用方)回退到本地生成,行为兼容。 +/// +/// 停止:`translate_abort(requestId)` 置位停止标志 → 命令层放弃转发并 drop 引擎 future +/// → HTTP 流随之取消(而不是等下一块数据到来才发现被放弃)。 +#[tauri::command] +#[specta::specta] +pub async fn translate_stream_start( + app: AppHandle, + state: State<'_, TranslateManager>, + params: TranslateRunParams, + request_id: Option, +) -> Result { + use std::sync::atomic::{AtomicBool, Ordering}; + use tauri::Emitter; + + let request_id = request_id + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && s.chars().count() <= 64) + .unwrap_or_else(|| { + format!( + "t{}-{}", + chrono::Utc::now().timestamp_millis(), + STREAM_SEQ.fetch_add(1, Ordering::Relaxed) + ) + }); + let abort = std::sync::Arc::new(AtomicBool::new(false)); + active_streams() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(request_id.clone(), abort.clone()); + + let settings = state.load_settings(); + let req = EngineRequest { + text: params.text, + from: normalize(params.from, "auto"), + to: normalize(params.to, String::new()), + to_label: normalize(params.to_label, String::new()), + from_label: normalize(params.from_label, String::new()), + mode: TranslateMode::parse(params.mode.as_deref()), + image_png: None, + via: normalize(params.via, "manual").replace(|c: char| !c.is_ascii_alphanumeric(), ""), + }; + let record = params.record.unwrap_or(true); + let history = state.history(); + let history_enabled = settings.history.enabled; + let history_max = settings.history.max_items as i64; + + let candidates = state.resolve_candidates(params.engine_id.as_deref())?; + let last_index = candidates.len().saturating_sub(1); + let mut last_error: Option = None; + + for (index, cfg) in candidates.into_iter().enumerate() { + let engine = match state.build(&cfg).await { + Ok(e) => e, + Err(e) => { + // 装配失败(未接入的引擎类型等)直接换下一个候选 + last_error = Some(e); + continue; + } + }; + + let (tx, mut rx) = tokio::sync::mpsc::channel::(64); + // 本次尝试是否已向前端吐出过内容(决定是否还能安全降级) + let produced = std::sync::Arc::new(AtomicBool::new(false)); + + // 转发任务:事件 → 前端;Done 时记历史(需要 Arc 克隆,State 本身无法跨任务) + let forward_app = app.clone(); + let forward_history = history.clone(); + let (req_text, from_lang, to_lang, via) = ( + req.text.clone(), + req.from.clone(), + req.to.clone(), + req.via.clone(), + ); + let forward_produced = produced.clone(); + let forwarder = tauri::async_runtime::spawn(async move { + while let Some(ev) = rx.recv().await { + use super::engines::StreamEvent; + let event = match &ev { + StreamEvent::Chunk { .. } => { + crate::constants::events::TRANSLATE_STREAM_CHUNK + } + StreamEvent::Done { .. } => crate::constants::events::TRANSLATE_STREAM_DONE, + StreamEvent::Error { .. } => crate::constants::events::TRANSLATE_STREAM_ERROR, + }; + let _ = forward_app.emit(event, &ev); + match ev { + StreamEvent::Chunk { .. } => { + forward_produced.store(true, Ordering::SeqCst); + } + StreamEvent::Done { result, .. } => { + forward_produced.store(true, Ordering::SeqCst); + if let (true, Some(h)) = (history_enabled && record, forward_history.as_ref()) + { + let _ = h.record( + &from_lang, + &to_lang, + &result.engine_id, + &result.engine_name, + &req_text, + &result.text, + &via, + result.latency_ms as i64, + ); + h.prune_to_max(history_max); + } + break; + } + StreamEvent::Error { .. } => break, + } + } + }); + + // 引擎执行与「停止」信号竞争:select 败者被 drop,HTTP 流随之取消 + let engine_fut = engine.translate_stream(&req, request_id.clone(), tx); + let outcome = tokio::select! { + res = engine_fut => res, + _ = wait_for_abort(abort.clone()) => { + forwarder.abort(); + active_streams().lock().unwrap_or_else(|e| e.into_inner()).remove(&request_id); + return Ok(request_id); + } + }; + + // 等转发任务收尾:Done 事件可能还排在通道里,提前返回会让前端的 + // Promise 先于结果事件到达(表现为「已返回但没有译文」) + let _ = forwarder.await; + + match outcome { + Ok(()) => { + active_streams() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&request_id); + return Ok(request_id); + } + Err(e) => { + crate::logger::log_warn( + "translate", + &format!( + "流式翻译:引擎「{}」失败:{}(kind={:?})", + cfg.name, e.message, e.kind + ), + ); + let can_fallback = !produced.load(Ordering::SeqCst) + && index < last_index + && e.retryable(); + if !can_fallback { + active_streams() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&request_id); + // 兜底通道:Promise 也会 reject,这里额外发事件是为了让「已发出 + // requestId 之后才失败」的路径也有可靠出口 + let _ = app.emit( + crate::constants::events::TRANSLATE_STREAM_ERROR, + super::engines::StreamEvent::Error { + request_id: request_id.clone(), + error: e.clone(), + }, + ); + return Err(e); + } + last_error = Some(e); + } + } + } + + active_streams() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&request_id); + let err = last_error.unwrap_or_else(|| { + TranslateError::config("没有已启用的翻译引擎,请先在翻译设置中启用至少一个") + }); + Err(err) +} + +async fn wait_for_abort(flag: std::sync::Arc) { + use std::sync::atomic::Ordering; + loop { + if flag.load(Ordering::SeqCst) { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} + +/// 停止一次流式请求(requestId 不存在时静默成功) +#[tauri::command] +#[specta::specta] +pub fn translate_abort(request_id: String) { + use std::sync::atomic::Ordering; + if let Some(flag) = active_streams() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&request_id) + { + flag.store(true, Ordering::SeqCst); + } +} + +// ===== 历史 ===== + +/// 分页列出历史(按时间倒序) +#[tauri::command] +#[specta::specta] +pub fn translate_history_list( + state: State<'_, TranslateManager>, + offset: Option, + limit: Option, + query: Option, + favorited_only: Option, +) -> Result, String> { + let history = state.history().ok_or_else(|| "历史记录不可用".to_string())?; + history.list( + offset.unwrap_or(0).max(0), + limit.unwrap_or(50).clamp(1, 200), + query.as_deref(), + favorited_only.unwrap_or(false), + ) +} + +#[tauri::command] +#[specta::specta] +pub fn translate_history_delete(state: State<'_, TranslateManager>, id: i64) -> Result<(), String> { + let history = state.history().ok_or_else(|| "历史记录不可用".to_string())?; + history.delete(id) +} + +/// 清空全部历史(含收藏)。破坏性操作,前端需二次确认后调用。 +#[tauri::command] +#[specta::specta] +pub fn translate_history_clear(state: State<'_, TranslateManager>) -> Result<(), String> { + let history = state.history().ok_or_else(|| "历史记录不可用".to_string())?; + history.clear() +} + +#[tauri::command] +#[specta::specta] +pub fn translate_history_set_favorited( + state: State<'_, TranslateManager>, + id: i64, + favorited: bool, +) -> Result<(), String> { + let history = state.history().ok_or_else(|| "历史记录不可用".to_string())?; + history.set_favorited(id, favorited) +} + +// ===== 内部工具 ===== + +fn normalize(value: Option, fallback: impl Into) -> String { + value + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| fallback.into()) +} + +fn failure(e: TranslateError, latency_ms: u64) -> EngineTestResult { + EngineTestResult { + ok: false, + latency_ms, + message: e.message.clone(), + error_kind: Some(e.kind), + detail: e.detail.clone(), + } +} + +/// 构造引擎视图并给出「是否可用」的判断。 +/// +/// 判断放在后端而不是前端:可用性取决于密钥是否存在、服务地址是否填了 +/// (只有后端知道)。与其让前端根据 `hasApiKey` + `baseUrl` + `model` + `useProxy` +/// 自己拼规则,不如只回答一个问题。 +fn to_view( + cfg: &TranslateEngineConfig, +) -> EngineView { + let raw_key = engine_api_key(&cfg.id); + let has_api_key = !raw_key.trim().is_empty(); + + let issue = match cfg.kind.as_str() { + "ai" => { + if cfg.base_url.trim().is_empty() { + Some("未配置 Base URL".to_string()) + } else if cfg.model.trim().is_empty() { + Some("未选择模型".to_string()) + } else if !has_api_key { + Some("未配置 API Key".to_string()) + } else { + None + } + } + "free" => match cfg.preset.as_str() { + // LibreTranslate 是用户自部署的服务,地址是唯一硬性要求; + // API Key 可选(部署启用 LT_API_KEYS 校验时才需要) + "libretranslate" => { + if cfg.base_url.trim().is_empty() { + Some("未配置服务地址(自建 LibreTranslate 的 Base URL)".to_string()) + } else { + None + } + } + "mymemory" => None, + other => Some(format!("未接入的免密钥源「{other}」")), + }, + "cloud" => match cfg.preset.as_str() { + "deepl" => None, + other => Some(format!("未接入的云厂商源「{other}」")), + }, + other => Some(format!("未知的引擎类型「{other}」")), + }; + + EngineView { + config: cfg.clone(), + has_api_key, + api_key_masked: crate::secrets::mask(&raw_key), + ready: issue.is_none() && cfg.enabled, + issue, + } +} diff --git a/src-tauri/src/translate/engines/ai.rs b/src-tauri/src/translate/engines/ai.rs new file mode 100644 index 0000000..7f88c80 --- /dev/null +++ b/src-tauri/src/translate/engines/ai.rs @@ -0,0 +1,593 @@ +//! OpenAI 兼容引擎。 +//! +//! 一套代码覆盖 DeepSeek / OpenAI / 通义(DashScope 兼容模式)/ Kimi / 智谱 / +//! 本地 Ollama / LM Studio / one-api 等中转服务——它们都提供 +//! `POST {baseUrl}/chat/completions` 且请求响应结构一致,因此差异只在配置项里。 +//! +//! 翻译场景刻意**不开思考模式**:不写 `thinking` / `reasoning_effort`,换低延迟与低费用。 +//! 若用户确实需要,可通过 `extra_body`(JSON 文本)自行透传。 + +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::json; + +use super::{ + apply_common_params, build_user_content, render_template, EngineRequest, ErrorKind, TokenUsage, + TranslateEngine, TranslateError, TranslateMode, TranslateResult, +}; +use crate::translate::settings::{PromptTemplates, TranslateEngineConfig}; + +/// 自检与连通性测试使用的探测文本 +const PROBE_TEXT: &str = "Hello, world."; + +pub struct AiEngine { + cfg: TranslateEngineConfig, + templates: PromptTemplates, + client: reqwest::Client, +} + +impl AiEngine { + pub fn new( + cfg: TranslateEngineConfig, + templates: PromptTemplates, + client: reqwest::Client, + ) -> Self { + Self { + cfg, + templates, + client, + } + } + + /// API 根地址。容错处理:用户常把完整端点(`.../chat/completions`)直接粘进来, + /// 若不在末尾剥掉,就会拼出 `.../chat/completions/chat/completions`, + /// 而这类错误在上游表现为 404,排查成本远高于此处一行判断。 + fn api_root(&self) -> Result { + let raw = self.cfg.base_url.trim(); + if raw.is_empty() { + return Err(TranslateError::config(format!( + "引擎「{}」尚未配置 Base URL", + self.cfg.name + ))); + } + if !(raw.starts_with("http://") || raw.starts_with("https://")) { + return Err(TranslateError::config(format!( + "Base URL 需以 http:// 或 https:// 开头,当前为「{raw}」" + ))); + } + let mut root = raw.trim_end_matches('/').to_string(); + for suffix in ["/chat/completions", "/completions", "/models"] { + if let Some(stripped) = root.strip_suffix(suffix) { + root = stripped.trim_end_matches('/').to_string(); + break; + } + } + Ok(root) + } + + fn chat_endpoint(&self) -> Result { + Ok(format!("{}/chat/completions", self.api_root()?)) + } + + fn models_endpoint(&self) -> Result { + Ok(format!("{}/models", self.api_root()?)) + } + + /// 密钥只从系统凭据管理器读,不进配置文件、不经前端。 + fn api_key(&self) -> String { + crate::translate::engine_api_key(&self.cfg.id) + } + + fn require_key(&self) -> Result { + let key = self.api_key(); + if key.trim().is_empty() { + return Err(TranslateError::auth(format!( + "引擎「{}」尚未配置 API Key,请在翻译设置中填写", + self.cfg.name + ))); + } + Ok(key) + } + + fn require_model(&self) -> Result { + let model = self.cfg.model.trim(); + if model.is_empty() { + return Err(TranslateError::config(format!( + "引擎「{}」尚未选择模型,可在设置中拉取模型列表后选择", + self.cfg.name + ))); + } + Ok(model.to_string()) + } + + /// 有效 system prompt:实例级自定义提示词优先,否则用模式对应的全局模板。 + fn system_prompt(&self, req: &EngineRequest) -> String { + if !self.cfg.system_prompt.trim().is_empty() { + return render_template(&self.cfg.system_prompt, req); + } + render_template(self.templates.for_mode(req.mode), req) + } + + /// 流式收尾。空内容不直接判死:**自动改用同步接口重试一次**。 + /// + /// 上游偶发「流正常结束但 content 为空」(安全拦截、思维链吃满 max_tokens、 + /// 中转服务抖动都会导致)。用户视角这与请求失败无异,但同步接口往往能正常 + /// 返回——与其抛错让人手动重试,不如在这里自愈一次。重试失败才把错误交给上层。 + async fn finish_stream_with_fallback( + &self, + tx: &tokio::sync::mpsc::Sender, + request_id: String, + content: String, + req: &EngineRequest, + started: Instant, + finish_reason: Option, + ) -> Result<(), TranslateError> { + if content.trim().is_empty() { + if tx.is_closed() { + // 调用方已放弃(停止 / 新请求顶替),不再花一次 API 调用 + return Ok(()); + } + crate::logger::log_warn( + "translate", + &format!( + "「{}」流式返回为空(finish_reason={:?}),自动改用同步接口重试", + self.cfg.name, finish_reason + ), + ); + // 同步路径的错误更具体(认证/额度/响应解析都能区分),直接透传 + let result = self.translate(req).await?; + let _ = tx.send(super::StreamEvent::Done { request_id, result }).await; + return Ok(()); + } + finish_stream( + tx, + request_id, + content, + req, + &self.cfg, + started.elapsed().as_millis() as u64, + ) + .await + } +} + +#[async_trait::async_trait] +impl TranslateEngine for AiEngine { + fn config(&self) -> &TranslateEngineConfig { + &self.cfg + } + + async fn translate(&self, req: &EngineRequest) -> Result { + if req.text.trim().is_empty() { + return Err(TranslateError::empty()); + } + let key = self.require_key()?; + let model = self.require_model()?; + let endpoint = self.chat_endpoint()?; + + let mut body = serde_json::Map::new(); + body.insert("model".to_string(), json!(model)); + body.insert("stream".to_string(), json!(false)); + body.insert( + "messages".to_string(), + json!([ + { "role": "system", "content": self.system_prompt(req) }, + { "role": "user", "content": build_user_content(req, self.cfg.supports_vision) }, + ]), + ); + apply_common_params(&mut body, &self.cfg); + + let started = Instant::now(); + let resp = self + .client + .post(&endpoint) + .bearer_auth(&key) + .timeout(Duration::from_millis(self.cfg.timeout_ms.max(1000))) + .json(&serde_json::Value::Object(body)) + .send() + .await + .map_err(|e| classify_reqwest(e, &self.cfg.name))?; + + let status = resp.status(); + let raw = resp + .text() + .await + .map_err(|e| TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name)))?; + + if !status.is_success() { + return Err(classify_http(status.as_u16(), &raw, &self.cfg.name, &model)); + } + + let parsed: ChatResponse = serde_json::from_str(&raw).map_err(|e| { + TranslateError::parse(format!("「{}」响应不是预期的 JSON: {e}", self.cfg.name)) + .with_detail(&raw) + })?; + + if let Some(err) = parsed.error { + let msg = err + .message + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| "上游返回了错误对象".to_string()); + return Err( + TranslateError::new(ErrorKind::Unknown, format!("「{}」返回错误:{msg}", self.cfg.name)) + .with_detail(&raw), + ); + } + + let text = parsed + .choices + .into_iter() + .next() + .and_then(|c| c.message.content) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(TranslateError::empty)?; + + let latency_ms = started.elapsed().as_millis() as u64; + let usage = parsed.usage.map(|u| { + let total = if u.total_tokens > 0 { + u.total_tokens + } else { + u.prompt_tokens + u.completion_tokens + }; + TokenUsage { + prompt_tokens: u.prompt_tokens, + completion_tokens: u.completion_tokens, + total_tokens: total, + } + }); + + Ok(TranslateResult { + text, + // AI 引擎不做语言检测:显式指定了源语言时回显,auto 时留空由前端展示「自动」 + detected: if req.from.trim().is_empty() || req.from == "auto" { + None + } else { + Some(req.from.clone()) + }, + engine_id: self.cfg.id.clone(), + engine_name: self.cfg.name.clone(), + latency_ms, + usage, + }) + } + + /// 流式翻译(SSE)。增量经通道下发,`data: [DONE]` 或流结束时发 Done。 + /// + /// 错误处理约定:**本方法不发送 `StreamEvent::Error`**——任何失败都通过 `Err` 返回, + /// 由命令层统一转成错误事件,避免前端收到两条错误。通道关闭(调用方已放弃, + /// 例如用户点了停止)时安静返回 `Ok(())`,不当作失败。 + async fn translate_stream( + &self, + req: &EngineRequest, + request_id: String, + tx: tokio::sync::mpsc::Sender, + ) -> Result<(), TranslateError> { + if req.text.trim().is_empty() && req.image_png.is_none() { + return Err(TranslateError::empty()); + } + let key = self.require_key()?; + let model = self.require_model()?; + let endpoint = self.chat_endpoint()?; + + let mut body = serde_json::Map::new(); + body.insert("model".to_string(), json!(model)); + body.insert("stream".to_string(), json!(true)); + body.insert( + "messages".to_string(), + json!([ + { "role": "system", "content": self.system_prompt(req) }, + { "role": "user", "content": build_user_content(req, self.cfg.supports_vision) }, + ]), + ); + apply_common_params(&mut body, &self.cfg); + + let started = Instant::now(); + let resp = self + .client + .post(&endpoint) + .bearer_auth(&key) + .timeout(Duration::from_millis(self.cfg.timeout_ms.max(1000))) + .json(&serde_json::Value::Object(body)) + .send() + .await + .map_err(|e| classify_reqwest(e, &self.cfg.name))?; + + let status = resp.status(); + if !status.is_success() { + let raw = resp.text().await.unwrap_or_default(); + return Err(classify_http(status.as_u16(), &raw, &self.cfg.name, &model)); + } + + // SSE 逐行解析:字节块可能把一行劈成两半,必须先攒缓冲再按 \n 切 + use futures_util::StreamExt; + let mut stream = resp.bytes_stream(); + let mut buffer: Vec = Vec::new(); + let mut content = String::new(); + let mut finish_reason: Option = None; + + while let Some(item) = stream.next().await { + let bytes = + item.map_err(|e| TranslateError::network(format!("读取流失败: {e}")))?; + buffer.extend_from_slice(&bytes); + while let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_bytes: Vec = buffer.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line_bytes[..line_bytes.len() - 1]); + let Some(payload) = line.trim().strip_prefix("data:") else { + continue; + }; + let payload = payload.trim(); + if payload.is_empty() { + continue; + } + if payload == "[DONE]" { + return self + .finish_stream_with_fallback( + &tx, + request_id, + content, + req, + started, + finish_reason, + ) + .await; + } + let Ok(value) = serde_json::from_str::(payload) else { + continue; + }; + // 上游在流中携带错误对象时终止 + if let Some(message) = value + .pointer("/error/message") + .and_then(|m| m.as_str()) + .filter(|m| !m.trim().is_empty()) + { + return Err(TranslateError::new( + ErrorKind::Unknown, + format!("「{}」流中返回错误:{message}", self.cfg.name), + )); + } + if let Some(fr) = value + .pointer("/choices/0/finish_reason") + .and_then(|v| v.as_str()) + { + finish_reason = Some(fr.to_string()); + } + let Some(delta) = value + .pointer("/choices/0/delta/content") + .and_then(|c| c.as_str()) + .filter(|d| !d.is_empty()) + else { + continue; + }; + content.push_str(delta); + if tx + .send(super::StreamEvent::Chunk { + request_id: request_id.clone(), + delta: delta.to_string(), + }) + .await + .is_err() + { + // 通道已关 = 调用方放弃(停止 / 新请求顶替),安静退出 + return Ok(()); + } + } + } + + // 流结束但没收到 [DONE]:部分上游异常断流。已有内容仍视为成功, + // 否则用户会看着已译出一半的结果被告知失败。 + self.finish_stream_with_fallback( + &tx, + request_id, + content, + req, + started, + finish_reason, + ) + .await + } + + async fn list_models(&self) -> Result, TranslateError> { + let key = self.require_key()?; + let endpoint = self.models_endpoint()?; + let resp = self + .client + .get(&endpoint) + .bearer_auth(&key) + .timeout(Duration::from_millis(self.cfg.timeout_ms.clamp(3_000, 15_000))) + .send() + .await + .map_err(|e| classify_reqwest(e, &self.cfg.name))?; + + let status = resp.status(); + let raw = resp + .text() + .await + .map_err(|e| TranslateError::network(format!("读取「{}」模型列表失败: {e}", self.cfg.name)))?; + + if !status.is_success() { + return Err(classify_http( + status.as_u16(), + &raw, + &self.cfg.name, + self.cfg.model.as_str(), + )); + } + + let parsed: ModelsResponse = serde_json::from_str(&raw).map_err(|e| { + TranslateError::parse(format!("「{}」的模型列表无法解析: {e}", self.cfg.name)) + .with_detail(&raw) + })?; + + let mut ids: Vec = parsed + .data + .into_iter() + .map(|m| m.id) + .filter(|id| !id.trim().is_empty()) + .collect(); + ids.sort(); + ids.dedup(); + Ok(ids) + } + + async fn test(&self) -> Result { + let req = EngineRequest { + text: PROBE_TEXT.to_string(), + from: "en".to_string(), + to: "zh-Hans".to_string(), + to_label: "简体中文".to_string(), + from_label: "英语".to_string(), + mode: TranslateMode::Translate, + image_png: None, + via: "preview".to_string(), + }; + let started = Instant::now(); + let result = self.translate(&req).await?; + let total = started.elapsed().as_millis() as u64; + Ok(format!( + "连通正常 · 模型 {} · {}ms · 回显「{}」", + self.cfg.model, total, result.text + )) + } +} + +/// 收尾:把累积内容封装成结果下发。空内容按「上游没产出」处理。 +async fn finish_stream( + tx: &tokio::sync::mpsc::Sender, + request_id: String, + content: String, + req: &EngineRequest, + cfg: &TranslateEngineConfig, + latency_ms: u64, +) -> Result<(), TranslateError> { + let text = content.trim().to_string(); + if text.is_empty() { + return Err(TranslateError::new( + ErrorKind::Empty, + format!("「{}」未返回任何译文(流提前结束)", cfg.name), + )); + } + let _ = tx + .send(super::StreamEvent::Done { + request_id, + result: TranslateResult { + text, + detected: if req.from.trim().is_empty() || req.from == "auto" { + None + } else { + Some(req.from.clone()) + }, + engine_id: cfg.id.clone(), + engine_name: cfg.name.clone(), + latency_ms, + // 流式路径不索取 usage:stream_options 是各家扩展,兼容性不一 + usage: None, + }, + }) + .await; + Ok(()) +} + +/// 网络层错误分类。 +fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError { + if e.is_timeout() { + return TranslateError::timeout(format!( + "请求「{engine}」超时,可在引擎设置中调大超时时间,或检查网络" + )); + } + if e.is_connect() { + return TranslateError::network(format!( + "无法连接「{engine}」:{e}(若该服务在境外,请检查网络或开启代理)" + )); + } + TranslateError::network(format!("请求「{engine}」失败:{e}")) +} + +/// HTTP 状态码分类。把「Key 不对」「模型名不对」「被限流」分开, +/// 是因为这三者在前端的处置动作完全不同。 +fn classify_http(status: u16, body: &str, engine: &str, model: &str) -> TranslateError { + let snippet = body.trim(); + let lower = snippet.to_lowercase(); + let err = match status { + 401 | 403 => TranslateError::auth(format!( + "「{engine}」认证失败(HTTP {status}),请检查 API Key 是否正确、是否有该模型的权限" + )), + 402 => TranslateError::auth(format!("「{engine}」余额不足或未开通计费(HTTP 402)")), + 429 => TranslateError::rate_limit(format!( + "「{engine}」请求过于频繁(HTTP 429),请稍后重试或降低频率" + )), + 400 | 404 | 422 => { + if lower.contains("model") { + TranslateError::config(format!( + "「{engine}」不识别模型「{model}」(HTTP {status}):上游模型名可能已变更,\ + 请在设置中拉取模型列表后重新选择" + )) + } else { + TranslateError::new( + ErrorKind::Config, + format!("「{engine}」拒绝了该请求(HTTP {status})"), + ) + } + } + 408 | 504 => TranslateError::timeout(format!("「{engine}」上游超时(HTTP {status})")), + s if (500..600).contains(&s) => { + TranslateError::network(format!("「{engine}」服务端错误(HTTP {status}),可稍后重试")) + } + s => TranslateError::new(ErrorKind::Unknown, format!("「{engine}」返回 HTTP {s}")), + }; + err.with_detail(snippet) +} + +// ===== 响应结构(宽松解析:缺字段不报错,由业务层判断内容是否可用) ===== + +#[derive(Debug, Deserialize)] +struct ChatResponse { + #[serde(default)] + choices: Vec, + #[serde(default)] + usage: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Deserialize)] +struct ChatChoice { + #[serde(default)] + message: ChatMessage, +} + +#[derive(Debug, Default, Deserialize)] +struct ChatMessage { + /// 部分实现会返回 null(例如只产出思维链时),故用 Option 而非 String + #[serde(default)] + content: Option, +} + +#[derive(Debug, Deserialize)] +struct ChatUsage { + #[serde(default)] + prompt_tokens: u32, + #[serde(default)] + completion_tokens: u32, + #[serde(default)] + total_tokens: u32, +} + +#[derive(Debug, Deserialize)] +struct ApiErrorBody { + #[serde(default)] + message: Option, +} + +#[derive(Debug, Deserialize)] +struct ModelsResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + #[serde(default)] + id: String, +} diff --git a/src-tauri/src/translate/engines/deepl.rs b/src-tauri/src/translate/engines/deepl.rs new file mode 100644 index 0000000..d136c0c --- /dev/null +++ b/src-tauri/src/translate/engines/deepl.rs @@ -0,0 +1,255 @@ +//! DeepL 云厂商翻译源。 +//! +//! 选它作云厂商第一家 purely 因为接入成本:`Authorization: DeepL-Auth-Key ` +//! 一个头就完成鉴权,没有 MD5/TC3 那类签名流程。其它云厂商(百度/腾讯/阿里/有道) +//! 签名各不相同,按需再加。 +//! +//! 三个必须如实告知用户的限制: +//! - **免费 Key 与付费 Key 的端点不同**(`api-free.deepl.com` / `api.deepl.com`)。 +//! 用错端点会返回 403,错误信息里必须把这个可能性讲出来,否则用户只会反复重输 Key。 +//! - **目标语言只有简体中文**(`ZH`)。DeepL 暂无繁体中文目标,本实现会把 +//! `zh-Hant` 也映射到 `ZH`,译出的会是简体——不是 bug,是上游能力边界。 +//! - 无流式接口,走 trait 默认实现(同步完成后整体下发)。 +//! +//! HTTP 形态:`POST {base}/translate`,body `{"text":["..."],"target_lang":"ZH"}`, +//! `source_lang` 省略时由上游自动检测。 + +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +use super::{ + is_auto, provider_code, ErrorKind, TranslateEngine, TranslateError, TranslateResult, +}; +use crate::translate::settings::TranslateEngineConfig; + +const PROBE_TEXT: &str = "Hello, world."; + +pub struct DeepLEngine { + cfg: TranslateEngineConfig, + client: reqwest::Client, +} + +impl DeepLEngine { + pub fn new(cfg: TranslateEngineConfig, client: reqwest::Client) -> Self { + Self { cfg, client } + } + + /// API 根地址(容错:剥掉误粘的 `/translate`,与 AI 引擎同一思路) + fn api_root(&self) -> Result { + let raw = self.cfg.base_url.trim(); + if raw.is_empty() { + return Err(TranslateError::config(format!( + "引擎「{}」尚未配置 Base URL(免费 Key 用 https://api-free.deepl.com/v2,\ + 付费 Key 用 https://api.deepl.com/v2)", + self.cfg.name + ))); + } + if !(raw.starts_with("http://") || raw.starts_with("https://")) { + return Err(TranslateError::config(format!( + "Base URL 需以 http:// 或 https:// 开头,当前为「{raw}」" + ))); + } + let mut root = raw.trim_end_matches('/').to_string(); + if let Some(stripped) = root.strip_suffix("/translate") { + root = stripped.trim_end_matches('/').to_string(); + } + Ok(root) + } + + fn require_key(&self) -> Result { + let key = crate::translate::engine_api_key(&self.cfg.id); + if key.trim().is_empty() { + return Err(TranslateError::auth(format!( + "引擎「{}」尚未配置 DeepL Auth Key", + self.cfg.name + ))); + } + Ok(key) + } + + /// 目标语言码。DeepL 要求变体形式:英语必须是 EN-GB/EN-US,葡语必须是 PT-PT/PT-BR。 + fn target_code(internal: &str) -> Result { + let code = provider_code(internal); + let lower = code.to_lowercase(); + let out = match lower.as_str() { + "zh" | "zh-cn" => "ZH".to_string(), + "zh-tw" | "zh-hant" => "ZH".to_string(), + "en" => "EN-US".to_string(), + "en-gb" => "EN-GB".to_string(), + "en-us" => "EN-US".to_string(), + "pt" => "PT-BR".to_string(), + "pt-pt" => "PT-PT".to_string(), + "pt-br" => "PT-BR".to_string(), + other => other.split('-').next().unwrap_or(other).to_uppercase(), + }; + if out.trim().is_empty() { + return Err(TranslateError::config("未指定目标语言")); + } + Ok(out) + } + + /// 源语言码。省略(auto)时不上送,由上游检测。 + fn source_code(internal: &str) -> Option { + if is_auto(internal) { + return None; + } + let code = provider_code(internal); + Some(code.split('-').next().unwrap_or(&code).to_uppercase()) + } +} + +#[async_trait::async_trait] +impl TranslateEngine for DeepLEngine { + fn config(&self) -> &TranslateEngineConfig { + &self.cfg + } + + async fn translate( + &self, + req: &super::EngineRequest, + ) -> Result { + let text = req.text.trim(); + if text.is_empty() { + return Err(TranslateError::empty()); + } + let key = self.require_key()?; + let target = Self::target_code(&req.to)?; + let root = self.api_root()?; + + let mut body = serde_json::json!({ "text": [text], "target_lang": target }); + if let Some(source) = Self::source_code(&req.from) { + body["source_lang"] = serde_json::json!(source); + } + + let started = Instant::now(); + let resp = self + .client + .post(format!("{root}/translate")) + .header("Authorization", format!("DeepL-Auth-Key {key}")) + .timeout(Duration::from_millis(self.cfg.timeout_ms.max(2_000))) + .json(&body) + .send() + .await + .map_err(|e| classify_reqwest(e, self.cfg.name.as_str()))?; + + let status = resp.status(); + let raw = resp.text().await.map_err(|e| { + TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name)) + })?; + if !status.is_success() { + return Err(classify_http(status.as_u16(), &raw, self.cfg.name.as_str())); + } + + let parsed: DeepLResponse = serde_json::from_str(&raw).map_err(|e| { + TranslateError::parse(format!("「{}」响应不是预期的 JSON: {e}", self.cfg.name)) + .with_detail(&raw) + })?; + + // DeepL 的翻译结果按 text 数组分段返回;单段请求时取第一段即可 + let text_out = parsed + .translations + .first() + .and_then(|t| t.text.clone()) + .map(|t| t.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + TranslateError::new( + ErrorKind::Empty, + format!("「{}」未返回译文", self.cfg.name), + ) + .with_detail(raw.trim()) + })?; + + let detected = parsed + .translations + .first() + .and_then(|t| t.detected_source_language.clone()) + .filter(|s| !s.trim().is_empty()) + // 上游返回大写(如 "EN"),统一转小写与内部语言码对齐 + .map(|s| s.to_lowercase()); + + Ok(TranslateResult { + text: text_out, + detected, + engine_id: self.cfg.id.clone(), + engine_name: self.cfg.name.clone(), + latency_ms: started.elapsed().as_millis() as u64, + usage: None, + }) + } + + async fn list_models(&self) -> Result, TranslateError> { + Ok(Vec::new()) + } + + async fn test(&self) -> Result { + let req = super::EngineRequest { + text: PROBE_TEXT.to_string(), + from: "en".to_string(), + to: "zh-Hans".to_string(), + to_label: "简体中文".to_string(), + from_label: "英语".to_string(), + mode: super::TranslateMode::Translate, + image_png: None, + via: "preview".to_string(), + }; + let started = Instant::now(); + let result = self.translate(&req).await?; + Ok(format!( + "连通正常 · {}ms · 回显「{}」", + started.elapsed().as_millis(), + result.text + )) + } +} + +fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError { + if e.is_timeout() { + return TranslateError::timeout(format!("请求「{engine}」超时,可调大超时时间")); + } + if e.is_connect() { + return TranslateError::network(format!("无法连接「{engine}」:{e},请检查网络")); + } + TranslateError::network(format!("请求「{engine}」失败:{e}")) +} + +fn classify_http(status: u16, body: &str, engine: &str) -> TranslateError { + let err = match status { + 403 => TranslateError::auth(format!( + "「{engine}」认证失败(HTTP 403):Key 无效,或免费 Key 用了付费端点(反之亦然)。\ + 免费 Key 应使用 https://api-free.deepl.com/v2" + )), + 456 => TranslateError::rate_limit(format!( + "「{engine}」本月翻译额度已用尽(HTTP 456)" + )), + 429 => TranslateError::rate_limit(format!( + "「{engine}」请求过于频繁(HTTP 429),请稍后重试" + )), + 400 => TranslateError::config(format!( + "「{engine}」拒绝了该请求(HTTP 400):目标语言或文本不合法" + )), + 414 => TranslateError::unsupported(format!( + "文本过长,「{engine}」拒绝了该请求(HTTP 414)" + )), + s if (500..600).contains(&s) => { + TranslateError::network(format!("「{engine}」服务端错误(HTTP {status}),可稍后重试")) + } + s => TranslateError::new(ErrorKind::Unknown, format!("「{engine}」返回 HTTP {s}")), + }; + err.with_detail(body.trim()) +} + +#[derive(Debug, Deserialize)] +struct DeepLResponse { + #[serde(default)] + translations: Vec, +} + +#[derive(Debug, Deserialize)] +struct DeepLTranslation { + #[serde(default)] + text: Option, + #[serde(rename = "detected_source_language", default)] + detected_source_language: Option, +} diff --git a/src-tauri/src/translate/engines/libretranslate.rs b/src-tauri/src/translate/engines/libretranslate.rs new file mode 100644 index 0000000..4bebfad --- /dev/null +++ b/src-tauri/src/translate/engines/libretranslate.rs @@ -0,0 +1,244 @@ +//! LibreTranslate 自托管翻译源。 +//! +//! 定位:用户自部署的开源翻译服务(https://github.com/LibreTranslate/LibreTranslate), +//! 数据发往用户自己的服务器,不受境外免费接口的 IP 风控(429)限制。 +//! +//! 接入形态: +//! - HTTP:`POST {base}/translate`,body `{"q":..., "source":..., "target":..., "format":"text", "api_key":...}`, +//! 返回 `{"translatedText": "...", "detectedLanguage": {"language": ..., "confidence": ...}}`。 +//! - **语言码不能走 [`super::provider_code`]**:LibreTranslate 用 `zh-Hans` / `zh-Hant` / `en` / `ja` +//! 这类代码,与内部语言码一致,原样透传即可;`provider_code` 会把 `zh-Hans` 换成 `zh-CN`, +//! 那是 Google / MyMemory 那套写法。 +//! - 目标语言集取决于部署时的语言包(常见部署仅含 en / ja / zh-Hans 等少数语言); +//! 不支持的语言对上游返回 400 + 错误说明,这里把错误说明透传进 detail,而不是猜一个原因。 +//! - **API Key 可选**:部署启用 `LT_API_KEYS` 后必需;未启用时传不传都行, +//! 本实现仅在已配置密钥时才上送 `api_key` 字段。 + +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +use super::{is_auto, ErrorKind, TranslateEngine, TranslateError, TranslateResult}; +use crate::translate::settings::TranslateEngineConfig; + +const PROBE_TEXT: &str = "Hello, world."; + +pub struct LibreTranslateEngine { + cfg: TranslateEngineConfig, + client: reqwest::Client, +} + +impl LibreTranslateEngine { + pub fn new(cfg: TranslateEngineConfig, client: reqwest::Client) -> Self { + Self { cfg, client } + } + + /// API 根地址(容错:剥掉误粘的 `/translate`,与 DeepL / AI 引擎同一思路) + fn api_root(&self) -> Result { + let raw = self.cfg.base_url.trim(); + if raw.is_empty() { + return Err(TranslateError::config(format!( + "引擎「{}」尚未配置服务地址:请填写自建 LibreTranslate 的地址(如 https://translate.example.com)", + self.cfg.name + ))); + } + if !(raw.starts_with("http://") || raw.starts_with("https://")) { + return Err(TranslateError::config(format!( + "服务地址需以 http:// 或 https:// 开头,当前为「{raw}」" + ))); + } + let mut root = raw.trim_end_matches('/').to_string(); + if let Some(stripped) = root.strip_suffix("/translate") { + root = stripped.trim_end_matches('/').to_string(); + } + Ok(root) + } + + /// API Key(可选)。部署未启用密钥校验时留空即可。 + fn api_key(&self) -> Option { + let key = crate::translate::engine_api_key(&self.cfg.id); + let key = key.trim(); + if key.is_empty() { + None + } else { + Some(key.to_string()) + } + } + + /// 源语言码:auto 原样上送(LibreTranslate 支持自动检测);显式语言原样透传。 + fn source_code(&self, from: &str) -> String { + if is_auto(from) { + "auto".to_string() + } else { + from.to_string() + } + } + + /// 目标语言码:原样透传。不能走 provider_code(见文件头注释)。 + fn target_code(&self, to: &str) -> Result { + let code = to.trim(); + if code.is_empty() { + return Err(TranslateError::config("未指定目标语言")); + } + Ok(code.to_string()) + } +} + +#[async_trait::async_trait] +impl TranslateEngine for LibreTranslateEngine { + fn config(&self) -> &TranslateEngineConfig { + &self.cfg + } + + async fn translate( + &self, + req: &super::EngineRequest, + ) -> Result { + let text = req.text.trim(); + if text.is_empty() { + return Err(TranslateError::empty()); + } + let target = self.target_code(&req.to)?; + let source = self.source_code(&req.from); + let root = self.api_root()?; + + let mut body = serde_json::json!({ + "q": text, + "source": source, + "target": target, + "format": "text", + }); + if let Some(key) = self.api_key() { + body["api_key"] = serde_json::json!(key); + } + + let started = Instant::now(); + let resp = self + .client + .post(format!("{root}/translate")) + .timeout(Duration::from_millis(self.cfg.timeout_ms.max(2_000))) + .json(&body) + .send() + .await + .map_err(|e| classify_reqwest(e, self.cfg.name.as_str()))?; + + let status = resp.status(); + let raw = resp.text().await.map_err(|e| { + TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name)) + })?; + if !status.is_success() { + return Err(classify_http(status.as_u16(), &raw, self.cfg.name.as_str())); + } + + let parsed: LibreTranslateResponse = serde_json::from_str(&raw).map_err(|e| { + TranslateError::parse(format!("「{}」响应不是预期的 JSON: {e}", self.cfg.name)) + .with_detail(&raw) + })?; + + let text_out = parsed + .translated_text + .map(|t| t.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + TranslateError::new( + ErrorKind::Empty, + format!("「{}」未返回译文(该语言对可能不受支持)", self.cfg.name), + ) + .with_detail(raw.trim()) + })?; + + // 仅 source=auto 时上游会回检测结果;显式源语言时该字段缺失 + let detected = parsed + .detected_language + .and_then(|d| d.language) + .filter(|s| !s.trim().is_empty()); + + Ok(TranslateResult { + text: text_out, + detected, + engine_id: self.cfg.id.clone(), + engine_name: self.cfg.name.clone(), + latency_ms: started.elapsed().as_millis() as u64, + usage: None, + }) + } + + /// 免密钥/自托管端点没有「模型」概念 + async fn list_models(&self) -> Result, TranslateError> { + Ok(Vec::new()) + } + + async fn test(&self) -> Result { + let req = super::EngineRequest { + text: PROBE_TEXT.to_string(), + from: "en".to_string(), + to: "zh-Hans".to_string(), + to_label: "简体中文".to_string(), + from_label: "英语".to_string(), + mode: super::TranslateMode::Translate, + image_png: None, + via: "preview".to_string(), + }; + let started = Instant::now(); + let result = self.translate(&req).await?; + Ok(format!( + "连通正常 · {}ms · 回显「{}」", + started.elapsed().as_millis(), + result.text + )) + } +} + +fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError { + if e.is_timeout() { + return TranslateError::timeout(format!("请求「{engine}」超时,可调大超时时间")); + } + if e.is_connect() { + return TranslateError::network(format!("无法连接「{engine}」:{e},请检查服务地址与网络")); + } + TranslateError::network(format!("请求「{engine}」失败:{e}")) +} + +fn classify_http(status: u16, body: &str, engine: &str) -> TranslateError { + // LibreTranslate 的错误体是 JSON `{"error": "..."}`,取这条说明作 detail 比整段响应可读 + let detail = serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| body.to_string()); + let err = match status { + 400 => TranslateError::config(format!( + "「{engine}」拒绝了该请求(HTTP 400):目标语言可能不在该部署支持范围内" + )), + 401 => TranslateError::auth(format!( + "「{engine}」要求 API Key(HTTP 401):该部署启用了密钥校验,请配置 API Key" + )), + 403 => TranslateError::auth(format!( + "「{engine}」API Key 无效(HTTP 403):请检查 Key 与该部署的密钥设置" + )), + 429 => TranslateError::rate_limit(format!( + "「{engine}」请求过于频繁(HTTP 429),请稍后重试" + )), + 404 => TranslateError::config(format!( + "「{engine}」地址无效(HTTP 404):请确认 Base URL 是 LibreTranslate 服务根地址而非某个页面" + )), + s if (500..600).contains(&s) => { + TranslateError::network(format!("「{engine}」服务端错误(HTTP {status}),可稍后重试")) + } + s => TranslateError::new(ErrorKind::Unknown, format!("「{engine}」返回 HTTP {s}")), + }; + err.with_detail(detail) +} + +#[derive(Debug, Deserialize)] +struct LibreTranslateResponse { + #[serde(rename = "translatedText", default)] + translated_text: Option, + #[serde(rename = "detectedLanguage", default)] + detected_language: Option, +} + +#[derive(Debug, Deserialize)] +struct DetectedLanguage { + #[serde(default)] + language: Option, +} diff --git a/src-tauri/src/translate/engines/mod.rs b/src-tauri/src/translate/engines/mod.rs new file mode 100644 index 0000000..418042b --- /dev/null +++ b/src-tauri/src/translate/engines/mod.rs @@ -0,0 +1,477 @@ +//! 翻译引擎抽象层。 +//! +//! 设计要点: +//! - 所有引擎实现同一个 [`TranslateEngine`] trait,命令层只面对 `Box`, +//! 因此「多源」与「指定源」都不需要在上层写分支。 +//! - 错误被细分为 [`ErrorKind`]:网络类失败可以重试到下一个源,认证/额度类失败重试 +//! 没有意义。前端也据此给出不同提示(「检查 Key」与「检查网络/代理」是两件事)。 +//! - 目标语言一律传**自然语言全称**给模型(`to_label`),语言码只用于记录与查询—— +//! 模型对「繁体中文」的遵循度明显高于 `zh-Hant`。 + +pub mod ai; +pub mod deepl; +pub mod libretranslate; +pub mod mymemory; + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use specta::Type; + +use super::settings::{PromptTemplates, TranslateEngineConfig}; + +/// 翻译模式(P0 只用 Translate;其余三档为 P3 的同入口扩展预留)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranslateMode { + Translate, + Polish, + Explain, + Summarize, +} + +impl TranslateMode { + /// 宽松解析:未知值一律按「翻译」处理,不因前端多传一个枚举值而报错。 + pub fn parse(raw: Option<&str>) -> Self { + match raw.unwrap_or("").trim() { + "polish" => Self::Polish, + "explain" => Self::Explain, + "summarize" => Self::Summarize, + _ => Self::Translate, + } + } +} + +/// 引擎请求(内部结构,不参与类型绑定导出)。 +#[derive(Debug, Clone)] +pub struct EngineRequest { + /// 待处理文本 + pub text: String, + /// 源语言代码,"auto" 表示自动检测 + pub from: String, + /// 目标语言代码(如 zh-Hans) + pub to: String, + /// 目标语言自然语言全称(如 简体中文),提示词用 + pub to_label: String, + /// 源语言自然语言全称,auto 时为空 + pub from_label: String, + pub mode: TranslateMode, + /// 图像输入(base64 PNG,不含 data: 前缀)。仅截图翻译的「视觉直译」模式使用。 + /// 免密钥源与未声明 supports_vision 的 AI 引擎会拒绝该请求。 + pub image_png: Option, + /// 记入历史时的来源标签:"manual" | "selection" | "clipboard" | "screenshot" | "preview" + pub via: String, +} + +/// Token 用量(AI 引擎返回;其余引擎为 None) +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TokenUsage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} + +/// 翻译结果 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TranslateResult { + /// 译文 + pub text: String, + /// 检测到的源语言(免费源会上报;AI 引擎为显式指定值或 None) + pub detected: Option, + /// 实际使用的引擎实例 id + pub engine_id: String, + /// 实际使用的引擎展示名(自动降级时前端要能看出「是谁答的」) + pub engine_name: String, + /// 耗时(毫秒) + pub latency_ms: u64, + pub usage: Option, +} + +/// 错误分类。区分它们的意义在于**前端能给出可操作的提示**: +/// `Auth` 要用户去改 Key,`Network` 要用户查网络/代理,`RateLimit` 只需等待。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ErrorKind { + /// 配置缺失或不合法(未填 Base URL、模型名无效等) + Config, + /// 认证失败 / 无权限 / 额度耗尽 + Auth, + /// 被限流 + RateLimit, + /// 网络不可达(含代理问题) + Network, + /// 超时 + Timeout, + /// 响应无法解析(上游改了格式) + Parse, + /// 不支持的引擎类型或语言对 + Unsupported, + /// 输入为空(取词失败或用户未输入) + Empty, + /// 被风控/验证码拦截(免费源常见) + Captcha, + Unknown, +} + +/// 结构化错误:作为 Tauri 命令的 error 类型返回,前端按 `kind` 分支处理。 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TranslateError { + pub kind: ErrorKind, + pub message: String, + /// 上游原始响应片段(已截断),用于排查;不含密钥 + pub detail: Option, +} + +/// 上游响应片段入库前的截断长度(避免把整页 HTML 塞进错误对象) +const DETAIL_LIMIT: usize = 400; + +impl TranslateError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + detail: None, + } + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + let raw = detail.into(); + let trimmed = raw.trim(); + if !trimmed.is_empty() { + let clipped: String = trimmed.chars().take(DETAIL_LIMIT).collect(); + self.detail = Some(clipped); + } + self + } + + pub fn config(msg: impl Into) -> Self { + Self::new(ErrorKind::Config, msg) + } + pub fn auth(msg: impl Into) -> Self { + Self::new(ErrorKind::Auth, msg) + } + pub fn rate_limit(msg: impl Into) -> Self { + Self::new(ErrorKind::RateLimit, msg) + } + pub fn network(msg: impl Into) -> Self { + Self::new(ErrorKind::Network, msg) + } + pub fn timeout(msg: impl Into) -> Self { + Self::new(ErrorKind::Timeout, msg) + } + pub fn parse(msg: impl Into) -> Self { + Self::new(ErrorKind::Parse, msg) + } + pub fn unsupported(msg: impl Into) -> Self { + Self::new(ErrorKind::Unsupported, msg) + } + pub fn empty() -> Self { + Self::new(ErrorKind::Empty, "没有可翻译的内容") + } + + /// 该错误是否值得「换一个源再试」。 + /// + /// 只有 `Empty` 不可重试:输入本身为空,换任何源结果都一样。 + /// `Unsupported`(本源超长上限 / 不支持该语言对 / 类型未接入)**必须可降级**—— + /// 「这个源处理不了这个请求」的含义就是「换下一个」,否则配了 + /// MyMemory + DeepSeek 的用户翻译一篇超长文本会整体失败(MyMemory 优先时)。 + pub fn retryable(&self) -> bool { + self.kind != ErrorKind::Empty + } +} + +impl std::fmt::Display for TranslateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for TranslateError {} + +/// 把模板中的占位符替换为实际语言名。 +pub fn render_template(template: &str, req: &EngineRequest) -> String { + let target = if req.to_label.trim().is_empty() { + req.to.as_str() + } else { + req.to_label.as_str() + }; + let source = if req.from_label.trim().is_empty() { + "原文的语言(自动判断)" + } else { + req.from_label.as_str() + }; + template + .replace("{target}", target) + .replace("{source}", source) +} + +/// 翻译模式下统一附加的「混排」指令。 +/// +/// 截图 OCR 经常抽出中英日混排的文本;显式源语言提示(如「源语言:日语」)反而 +/// 会诱导模型把与目标语言字形相同的部分(日文汉字)当作「已经是译文」原样保留 +/// ——这正是「日转中却总有一些日语没被翻译」的主因。 +/// +/// 固定追加在 user 内容末尾而不是改进提示词模板:模板是用户已保存的存量设置, +/// 改默认值对老用户不生效;这条属于引擎层的硬约束,不该被模板覆盖。 +const MIXED_LANG_NOTE: &str = "注意:原文可能是多语言混排(如中英日混排)。\ + 请把全部内容统一译为目标语言——包括与目标语言字形相同的文字(如日文汉字)也要译,\ + 不要原样保留;仅专有名词可保留原文。"; + +/// 构造送给模型的用户内容。 +/// +/// 纯文本时:仅在**显式指定了源语言**时补一行提示——模型对「源语言是日语」的显式声明 +/// 比让它自行判断更稳;不指定时什么都不加,避免制造无谓的 token 与 +/// 「别输出提示」的博弈。 +/// +/// 带图像时:改为 OpenAI 视觉消息的 content-parts 结构(文本段 + image_url 段), +/// 与 `https://platform.openai.com/docs/guides/vision` 的请求体一致; +/// 兼容该格式的服务(DeepSeek 的实验视觉模型、通义、GLM-4V 等)可直接受理。 +pub fn build_user_content(req: &EngineRequest, supports_vision: bool) -> serde_json::Value { + let source_hint = if req.mode == TranslateMode::Translate + && !req.from.trim().is_empty() + && req.from != "auto" + && !req.from_label.trim().is_empty() + { + Some(format!("源语言:{}", req.from_label)) + } else { + None + }; + + match &req.image_png { + Some(data_url) if supports_vision => { + let mut parts = vec![]; + if let Some(hint) = source_hint { + parts.push(json!({ "type": "text", "text": hint })); + } + parts.push(json!({ + "type": "text", + "text": "识别图中的文字并按系统提示翻译。保持原有排版与分段。" + })); + if req.mode == TranslateMode::Translate { + parts.push(json!({ "type": "text", "text": MIXED_LANG_NOTE })); + } + parts.push(json!({ + "type": "image_url", + "image_url": { "url": format!("data:image/png;base64,{data_url}") } + })); + json!(parts) + } + Some(_) => { + // 带图但引擎不支持:这不该发生(命令层已拦截),兜底只发文本说明, + // 而不是让请求带着一个模型无法理解的字段出去 + let note = "(请求包含图像,但当前引擎不支持图像输入,仅能处理文本。)"; + json!(format!("{}{}", note, req.text)) + } + None => { + let text = req.text.clone(); + let base = match source_hint { + Some(hint) => format!("{hint}\n\n{text}"), + None => text, + }; + // 翻译模式统一追加混排指令;其他模式(润色/解释/总结)语义不同,不追加 + if req.mode == TranslateMode::Translate { + json!(format!("{base}\n\n{MIXED_LANG_NOTE}")) + } else { + json!(base) + } + } + } +} + +/// 参数对齐后的公共请求体骨架(`model` / `messages` / `stream` 由各引擎补充)。 +pub fn apply_common_params( + body: &mut serde_json::Map, + cfg: &TranslateEngineConfig, +) { + body.insert("temperature".to_string(), serde_json::json!(cfg.temperature)); + body.insert("max_tokens".to_string(), serde_json::json!(cfg.max_tokens)); + if let Some(extra) = cfg.extra_body.as_deref().map(str::trim) { + if !extra.is_empty() { + if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::(extra) { + for (k, v) in map { + body.insert(k, v); + } + } + // 解析失败按「无额外参数」处理:不能让一次笔误导致整个翻译不可用, + // 具体错误由命令层的 translate_engine_test 反馈给用户。 + } + } +} + +/// 流式翻译的单条事件。 +/// +/// 引擎 → 命令层用 `mpsc` 通道传递(引擎不持有 AppHandle,保持传输层与 UI 层分离), +/// 命令层再转发为 Tauri 事件给前端。 +/// +/// **序列化形态是前端契约的一部分**:`tag = "type"` 让事件扁平化为 +/// `{"type":"chunk","requestId":"...","delta":"..."}`,而不是 serde 默认的外层标签 +/// `{"Chunk":{...}}`。前端按 `requestId` 过滤事件,多一层嵌套会让 `requestId` 取到 +/// `undefined`、所有事件被丢弃——表现为「历史里有结果,界面上一片空白」。 +/// +/// **注意 serde 的一个坑**:`rename_all` 用在枚举上只重命名**变体名**(Chunk → chunk), +/// 不作用于变体内的字段——`request_id` 会原样序列化成 `request_id`,前端拿 +/// `payload.requestId` 永远是 undefined。因此每个变体的 `request_id` 字段都显式 +/// `#[serde(rename = "requestId")]`,别合并成 `rename_all_fields`(依赖 serde 版本)。 +/// 改动这里必须同步改 `src/lib/translate/api.ts` 的 `StreamEventPayload`。 +/// +/// `Error` 变体是**兜底通道**:主通道仍是命令的 `Err`(Promise reject)。保留它 +/// 是为了覆盖「命令已返回 requestId、之后才失败」的情形,否则前端会永远停在 +/// 「翻译中」而没有出口。 +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum StreamEvent { + /// 增量片段 + Chunk { + #[serde(rename = "requestId")] + request_id: String, + delta: String, + }, + /// 完成(携带完整结果,含实际引擎名) + Done { + #[serde(rename = "requestId")] + request_id: String, + result: TranslateResult, + }, + /// 失败(兜底通道,见类型注释) + Error { + #[serde(rename = "requestId")] + request_id: String, + error: TranslateError, + }, +} + +/// 引擎统一接口。 +/// +/// 用 `async_trait` 而非原生 `async fn in trait`:本 trait 需要 `Box` 动态分派 +/// (多源切换是运行期决定的),原生 async fn 在 dyn 场景下不可用。 +#[async_trait::async_trait] +pub trait TranslateEngine: Send + Sync { + /// 引擎配置。id / name 等一律由此读取,避免在每个实现里重复存字段。 + fn config(&self) -> &TranslateEngineConfig; + + /// 展示名(自动降级时用于记录「是谁失败了」) + fn name(&self) -> &str { + &self.config().name + } + + /// 执行一次翻译 + async fn translate(&self, req: &EngineRequest) -> Result; + + /// 流式翻译:增量经 `tx` 下发,结束时发一条 [`StreamEvent::Done`]。 + /// + /// 默认实现是「同步翻译后整体下发」——**不支持流式的引擎不需要实现它**, + /// 前端因此可以统一走流式入口,无需自己判断引擎能力。 + /// 通道关闭(`send` 失败)意味着调用方已放弃本次请求,实现方应尽快返回而不是报错。 + async fn translate_stream( + &self, + req: &EngineRequest, + request_id: String, + tx: tokio::sync::mpsc::Sender, + ) -> Result<(), TranslateError> { + let result = self.translate(req).await?; + let _ = tx.send(StreamEvent::Done { request_id, result }).await; + Ok(()) + } + + /// 拉取可用模型列表(用于「上游改名」这类失效场景的自救入口) + async fn list_models(&self) -> Result, TranslateError>; + + /// 连通性自检:返回一句人类可读的成功描述(含实际模型回显) + async fn test(&self) -> Result; +} + +/// 内部语言码 → 第三方源使用的语言码。 +/// +/// 只有中文需要换算:内部统一用 BCP-47 的 `zh-Hans` / `zh-Hant`,而 MyMemory +/// 沿用 `zh-CN` / `zh-TW` 这一代写法。其余语言码两边一致,原样透传—— +/// 与其维护一张可能过期的全量映射表,不如只处理确有差异的项。 +/// 注意 LibreTranslate 用的是 `zh-Hans` / `zh-Hant`,与内部一致,**不走本函数**。 +pub fn provider_code(code: &str) -> String { + match code { + "zh-Hans" => "zh-CN".to_string(), + "zh-Hant" => "zh-TW".to_string(), + other => other.to_string(), + } +} + +/// 免费源的语言支持是「尽力而为」的:第三方接口对不支持的语言对通常返回 +/// 空译文而不是明确报错,因此统一在这里识别,给出可操作的提示。 +pub fn is_auto(code: &str) -> bool { + let c = code.trim(); + c.is_empty() || c == "auto" +} + +#[cfg(test)] +mod tests { + use super::StreamEvent; + + /// 前端契约测试:事件必须扁平化为 `{"type":"chunk","requestId":...}`。 + /// 守护枚举字段命名的 serde 坑(rename_all 在枚举上不改字段名), + /// 一旦回退,前端所有流式事件都会因 requestId 取到 undefined 被丢弃。 + #[test] + fn stream_event_serializes_flat_with_camel_case_request_id() { + let json = serde_json::to_value(StreamEvent::Chunk { + request_id: "r1".into(), + delta: "x".into(), + }) + .unwrap(); + assert_eq!(json["type"], "chunk", "实际形态: {json}"); + assert_eq!(json["requestId"], "r1", "实际形态: {json}"); + assert!(json.get("request_id").is_none(), "实际形态: {json}"); + + let json = serde_json::to_value(StreamEvent::Done { + request_id: "r1".into(), + result: super::TranslateResult { + text: "t".into(), + detected: None, + engine_id: "e".into(), + engine_name: "n".into(), + latency_ms: 1, + usage: None, + }, + }) + .unwrap(); + assert_eq!(json["type"], "done", "实际形态: {json}"); + assert_eq!(json["requestId"], "r1", "实际形态: {json}"); + assert_eq!(json["result"]["engineName"], "n", "实际形态: {json}"); + } +} + +/// 依据配置构造引擎实例。 +/// +/// P0/P1 已实现:`ai`(OpenAI 兼容家族)与 `free` 下的 libretranslate / mymemory; +/// `cloud`(需签名的云厂商)在 P3。未实现的类型显式返回 `Unsupported` 而不是静默忽略, +/// 避免用户配了却「以为生效」。 +pub fn build_engine( + cfg: &TranslateEngineConfig, + templates: &PromptTemplates, + client: reqwest::Client, +) -> Result, TranslateError> { + match cfg.kind.as_str() { + "ai" => Ok(Box::new(ai::AiEngine::new( + cfg.clone(), + templates.clone(), + client, + ))), + "free" => match cfg.preset.as_str() { + "libretranslate" => Ok(Box::new(libretranslate::LibreTranslateEngine::new( + cfg.clone(), + client, + ))), + "mymemory" => Ok(Box::new(mymemory::MyMemoryEngine::new(cfg.clone(), client))), + other => Err(TranslateError::unsupported(format!( + "免密钥源「{other}」尚未接入(可选:libretranslate / mymemory)" + ))), + }, + "cloud" => match cfg.preset.as_str() { + "deepl" => Ok(Box::new(deepl::DeepLEngine::new(cfg.clone(), client))), + other => Err(TranslateError::unsupported(format!( + "云厂商源「{other}」尚未接入(可选:deepl)" + ))), + }, + other => Err(TranslateError::config(format!( + "未知的引擎类型「{other}」(仅支持 ai / free / cloud)" + ))), + } +} diff --git a/src-tauri/src/translate/engines/mymemory.rs b/src-tauri/src/translate/engines/mymemory.rs new file mode 100644 index 0000000..0870471 --- /dev/null +++ b/src-tauri/src/translate/engines/mymemory.rs @@ -0,0 +1,216 @@ +//! MyMemory 免密钥翻译源。 +//! +//! 定位:国内可直接访问的免密兜底。质量不如大模型,但胜在零配置、无代理依赖。 +//! +//! 两个必须知道的限制: +//! - **不支持自动检测源语言**。`langpair` 要求显式源语言,因此源语言为 `auto` 时 +//! 直接返回可操作的配置错误(而不是发一个注定失败的请求),让「自动」模式降级到下一个源。 +//! - 免费额度按**字节**计(约 500 字节/次,匿名另有每日上限)。超限时上游返回 +//! `responseStatus: 403` 并在 `responseDetails` 里说明,这里原样透传给用户看。 + +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +use super::{ + is_auto, provider_code, ErrorKind, TranslateEngine, TranslateError, TranslateResult, +}; +use crate::translate::settings::TranslateEngineConfig; + +const ENDPOINT: &str = "https://api.mymemory.translated.net/get"; + +/// 单次查询的字节上限(上游按字节限制,且 langpair 也占额度的一部分) +const MAX_QUERY_BYTES: usize = 500; + +const PROBE_TEXT: &str = "Hello, world."; + +pub struct MyMemoryEngine { + cfg: TranslateEngineConfig, + client: reqwest::Client, +} + +impl MyMemoryEngine { + pub fn new(cfg: TranslateEngineConfig, client: reqwest::Client) -> Self { + Self { cfg, client } + } +} + +#[async_trait::async_trait] +impl TranslateEngine for MyMemoryEngine { + fn config(&self) -> &TranslateEngineConfig { + &self.cfg + } + + async fn translate( + &self, + req: &super::EngineRequest, + ) -> Result { + let text = req.text.trim(); + if text.is_empty() { + return Err(TranslateError::empty()); + } + if is_auto(&req.from) { + // 不猜:源语言未知时上游无法工作,明确返回配置类错误(可降级)。 + // 文案要同时覆盖两个入口:主面板的源语言下拉、划词弹窗顶部的「自」循环按钮 + // ——弹窗的源语言是独立记忆项,与主面板的选择无关,不点明用户会以为 + // 「主界面选过了为什么还报错」。 + return Err(TranslateError::config(format!( + "「{}」需要显式指定源语言:主面板请在源语言下拉中选具体语言;\ + 划词弹窗请点击顶部的「自」按钮切换源语言(选择会被记住)", + self.cfg.name + ))); + } + if text.len() > MAX_QUERY_BYTES { + return Err(TranslateError::unsupported(format!( + "文本 {} 字节,超过「{}」的单次上限({MAX_QUERY_BYTES} 字节)", + text.len(), + self.cfg.name + ))); + } + let target = provider_code(&req.to); + if target.trim().is_empty() { + return Err(TranslateError::config("未指定目标语言")); + } + let pair = format!("{}|{}", provider_code(&req.from), target); + + let started = Instant::now(); + let resp = self + .client + .get(ENDPOINT) + .query(&[("q", text), ("langpair", pair.as_str())]) + .timeout(Duration::from_millis(self.cfg.timeout_ms.max(2_000))) + .send() + .await + .map_err(|e| classify_reqwest(e, self.cfg.name.as_str()))?; + + let status = resp.status(); + let raw = resp.text().await.map_err(|e| { + TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name)) + })?; + if !status.is_success() { + return Err(TranslateError::network(format!( + "「{}」返回 HTTP {status}", + self.cfg.name + )) + .with_detail(raw.trim())); + } + + let parsed: MyMemoryResponse = serde_json::from_str(&raw).map_err(|e| { + TranslateError::parse(format!("「{}」响应不是预期的 JSON: {e}", self.cfg.name)) + .with_detail(&raw) + })?; + + // responseStatus 可能是数字 200 也可能是字符串 "403",统一取整再判断 + let code = parsed + .response_status + .as_ref() + .and_then(status_as_u64); + if let Some(c) = code { + if c != 200 { + let detail = parsed + .response_details + .clone() + .unwrap_or_else(|| "上游未提供说明".to_string()); + let kind = if c == 403 { ErrorKind::RateLimit } else { ErrorKind::Unknown }; + return Err(TranslateError::new( + kind, + format!("「{}」拒绝了请求(status={c})", self.cfg.name), + ) + .with_detail(detail)); + } + } + + let text_out = parsed + .response_data + .and_then(|d| d.translated_text) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let out = match text_out { + Some(t) => t, + None => { + // 免费额度用尽时也会走到这里,把上游说明带上,用户才知所以然 + let detail = parsed + .response_details + .filter(|d| !d.trim().is_empty()) + .unwrap_or_else(|| raw.clone()); + return Err(TranslateError::new( + ErrorKind::Empty, + format!("「{}」未返回译文(额度用尽或语言对不支持)", self.cfg.name), + ) + .with_detail(detail)); + } + }; + + Ok(TranslateResult { + text: out, + // 源语言是用户显式指定的,没有可上报的检测结果 + detected: None, + engine_id: self.cfg.id.clone(), + engine_name: self.cfg.name.clone(), + latency_ms: started.elapsed().as_millis() as u64, + usage: None, + }) + } + + async fn list_models(&self) -> Result, TranslateError> { + Ok(Vec::new()) + } + + async fn test(&self) -> Result { + let req = super::EngineRequest { + text: PROBE_TEXT.to_string(), + from: "en".to_string(), + to: "zh-Hans".to_string(), + to_label: "简体中文".to_string(), + from_label: "英语".to_string(), + mode: super::TranslateMode::Translate, + image_png: None, + via: "preview".to_string(), + }; + let started = Instant::now(); + let result = self.translate(&req).await?; + Ok(format!( + "连通正常 · {}ms · 回显「{}」", + started.elapsed().as_millis(), + result.text + )) + } +} + +/// 从数字或字符串形式的 status 字段取整 +fn status_as_u64(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Number(n) => n.as_u64(), + serde_json::Value::String(s) => s.trim().parse::().ok(), + _ => None, + } +} + +fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError { + if e.is_timeout() { + return TranslateError::timeout(format!( + "请求「{engine}」超时,可调大超时时间或检查网络" + )); + } + if e.is_connect() { + return TranslateError::network(format!("无法连接「{engine}」:{e},请检查网络")); + } + TranslateError::network(format!("请求「{engine}」失败:{e}")) +} + +#[derive(Debug, Deserialize)] +struct MyMemoryResponse { + #[serde(rename = "responseData", default)] + response_data: Option, + #[serde(rename = "responseStatus", default)] + response_status: Option, + #[serde(rename = "responseDetails", default)] + response_details: Option, +} + +#[derive(Debug, Deserialize)] +struct ResponseData { + #[serde(rename = "translatedText", default)] + translated_text: Option, +} diff --git a/src-tauri/src/translate/history.rs b/src-tauri/src/translate/history.rs new file mode 100644 index 0000000..f0fba62 --- /dev/null +++ b/src-tauri/src/translate/history.rs @@ -0,0 +1,532 @@ +//! 翻译历史(SQLite)。 +//! +//! 存储约定:`{app_data_dir}/translate/history.db`。去重键是 +//! `(source_text, to_lang, engine_id)` —— 同一段文字、同一个目标语言、同一个引擎 +//! 只保留一条,重复翻译只更新时间与译文。**不含 `via`**:划词翻过的句子再用主面板翻, +//! 是同一件事,拆成两条只会让历史变得难搜。 +//! +//! 收藏条目不参与容量淘汰(`prune_to_max`)——用户明确说「留着」的东西, +//! 不该因为新记录挤进来而消失。 +//! +//! 搜索走 FTS5 三元组索引(`history_fts`),详见 `FTS_MIN_CHARS` 与 `fts_phrase` 的说明。 + +use std::path::Path; +use std::sync::Mutex; + +use rusqlite::{params, Connection}; +use serde::Serialize; +use specta::Type; + +/// 库结构版本。 +/// +/// 与本值不等的库在打开时**整库重建**(见 `History::new`)。现阶段模块仍在开发、 +/// 未实装,历史属于可丢弃数据,因此不做增量迁移——维护一堆迁移分支、还要处理 +/// 「迁移到一半失败」留下的半新半旧库,成本远高于丢掉几条测试记录。 +/// **实装之后再改结构就必须换成真正的迁移。** +const SCHEMA_VERSION: i64 = 2; + +/// 走 FTS 索引所需的最小字符数。 +/// +/// trigram 分词器把文本切成连续 3 字符的 n-gram,索引里不存在长度小于 3 的片段, +/// 因此 1~2 个字符的 MATCH **不会报错,只会静默返回空结果**。「条件明明对却搜不到」 +/// 比「慢一点」糟糕得多(中文里两字词又恰恰最常见),所以短词回退到 LIKE 全表扫描: +/// 此时无论走哪条路都谈不上选择性,扫描是可接受的代价。 +const FTS_MIN_CHARS: usize = 3; + +/// 一条历史记录 +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HistoryItem { + pub id: i64, + /// 毫秒时间戳 + pub ts: i64, + pub from_lang: String, + pub to_lang: String, + pub engine_id: String, + pub engine_name: String, + pub source_text: String, + pub result_text: String, + /// 来源:"manual" | "selection" | "clipboard" | "screenshot" | "preview" + pub via: String, + pub favorited: bool, + pub latency_ms: i64, +} + +pub struct History { + conn: Mutex, +} + +/// 全部建表语句。结构版本变化时整库重建,所以这里不需要考虑兼容旧结构。 +/// +/// `history_fts` 是**外部内容表**(`content='history'`):索引只存倒排表, +/// 正文仍只留在 `history` 里一份,不做双份存储。代价是它不会自己感知主表变化, +/// 必须靠下面三个触发器手动同步——漏了任何一个,索引就会和主表静默错位。 +const SCHEMA_SQL: &str = " + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + from_lang TEXT NOT NULL, + to_lang TEXT NOT NULL, + engine_id TEXT NOT NULL, + engine_name TEXT NOT NULL DEFAULT '', + source_text TEXT NOT NULL, + result_text TEXT NOT NULL, + via TEXT NOT NULL DEFAULT 'manual', + favorited INTEGER NOT NULL DEFAULT 0, + latency_ms INTEGER NOT NULL DEFAULT 0, + UNIQUE(source_text, to_lang, engine_id) + ); + CREATE INDEX IF NOT EXISTS idx_history_ts ON history(ts DESC); + CREATE INDEX IF NOT EXISTS idx_history_fav ON history(favorited, ts DESC); + + CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5( + source_text, result_text, + content='history', content_rowid='id', + tokenize='trigram' + ); + + CREATE TRIGGER IF NOT EXISTS history_fts_ai AFTER INSERT ON history BEGIN + INSERT INTO history_fts(rowid, source_text, result_text) + VALUES (new.id, new.source_text, new.result_text); + END; + CREATE TRIGGER IF NOT EXISTS history_fts_ad AFTER DELETE ON history BEGIN + INSERT INTO history_fts(history_fts, rowid, source_text, result_text) + VALUES ('delete', old.id, old.source_text, old.result_text); + END; + CREATE TRIGGER IF NOT EXISTS history_fts_au AFTER UPDATE ON history BEGIN + INSERT INTO history_fts(history_fts, rowid, source_text, result_text) + VALUES ('delete', old.id, old.source_text, old.result_text); + INSERT INTO history_fts(rowid, source_text, result_text) + VALUES (new.id, new.source_text, new.result_text); + END; +"; + +impl History { + pub fn new(dir: &Path) -> Result { + std::fs::create_dir_all(dir).map_err(|e| format!("创建翻译目录失败: {e}"))?; + let conn = + Connection::open(dir.join("history.db")).map_err(|e| format!("打开历史库失败: {e}"))?; + conn.execute_batch("PRAGMA journal_mode = WAL;") + .map_err(|e| format!("初始化历史库失败: {e}"))?; + + let version: i64 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(|e| format!("读取历史库版本失败: {e}"))?; + if version != SCHEMA_VERSION { + // 先删 FTS 表再删主表:触发器挂在主表上,会跟着一起消失 + conn.execute_batch( + "DROP TABLE IF EXISTS history_fts; + DROP TABLE IF EXISTS history;", + ) + .map_err(|e| format!("重建历史库失败: {e}"))?; + } + + conn.execute_batch(SCHEMA_SQL) + .map_err(|e| format!("初始化历史表失败: {e}"))?; + conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};")) + .map_err(|e| format!("写入历史库版本失败: {e}"))?; + + Ok(Self { + conn: Mutex::new(conn), + }) + } + + fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { + self.conn.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// 记录一次翻译(去重:命中则更新时间与译文)。 + pub fn record( + &self, + from_lang: &str, + to_lang: &str, + engine_id: &str, + engine_name: &str, + source_text: &str, + result_text: &str, + via: &str, + latency_ms: i64, + ) -> Result<(), String> { + if source_text.trim().is_empty() || result_text.trim().is_empty() { + return Ok(()); + } + let now = chrono::Utc::now().timestamp_millis(); + self.conn() + .execute( + "INSERT INTO history (ts, from_lang, to_lang, engine_id, engine_name, + source_text, result_text, via, favorited, latency_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9) + ON CONFLICT(source_text, to_lang, engine_id) + DO UPDATE SET ts = excluded.ts, result_text = excluded.result_text, + via = excluded.via, latency_ms = excluded.latency_ms", + params![ + now, + from_lang, + to_lang, + engine_id, + engine_name, + source_text, + result_text, + via, + latency_ms + ], + ) + .map_err(|e| format!("写入历史失败: {e}"))?; + Ok(()) + } + + /// 列表。`query` 非空时按原文/译文模糊匹配。 + /// + /// 两种 SQL 形态互斥,但**参数个数与顺序固定为 (limit, offset, ?3)**, + /// 这样绑定点不需要跟着分支走: + /// - 关键词 ≥ `FTS_MIN_CHARS` 走 FTS5 索引(`history_fts MATCH`); + /// - 更短(或没有关键词)走 LIKE。 + /// + /// 两条路径都是子串语义、都忽略 ASCII 大小写,行为一致。 + pub fn list( + &self, + offset: i64, + limit: i64, + query: Option<&str>, + favorited_only: bool, + ) -> Result, String> { + let term = query.map(str::trim).filter(|s| !s.is_empty()); + let use_fts = term.is_some_and(|t| t.chars().count() >= FTS_MIN_CHARS); + + let mut sql = String::new(); + if use_fts { + // 列必须带 h. 前缀:source_text / result_text 在两张表里同名,不加限定会歧义。 + sql.push_str( + "SELECT h.id, h.ts, h.from_lang, h.to_lang, h.engine_id, h.engine_name, + h.source_text, h.result_text, h.via, h.favorited, h.latency_ms + FROM history_fts f JOIN history h ON h.id = f.rowid + WHERE history_fts MATCH ?3", + ); + if favorited_only { + sql.push_str(" AND h.favorited = 1"); + } + sql.push_str(" ORDER BY h.ts DESC LIMIT ?1 OFFSET ?2"); + } else { + sql.push_str( + "SELECT id, ts, from_lang, to_lang, engine_id, engine_name, source_text, + result_text, via, favorited, latency_ms FROM history WHERE ", + ); + if favorited_only { + sql.push_str("favorited = 1 AND "); + } + // 无关键词时 pattern 为 NULL,`?3 IS NULL` 让条件恒真。 + // 这样参数个数固定,避免「有/无 ?3」两种形态下绑定索引不一致。 + // ESCAPE '\':SQLite 的 LIKE 默认没有转义符,不写这个子句 + // escape_like 对 %/_ 的转义就是无效代码。 + sql.push_str( + "(?3 IS NULL OR source_text LIKE ?3 ESCAPE '\\' OR result_text LIKE ?3 ESCAPE '\\') + ORDER BY ts DESC LIMIT ?1 OFFSET ?2", + ); + } + + let param3 = term.map(|t| { + if use_fts { + fts_phrase(t) + } else { + format!("%{}%", escape_like(t)) + } + }); + + let conn = self.conn(); + let mut stmt = conn.prepare(&sql).map_err(|e| format!("查询历史失败: {e}"))?; + let rows = stmt + .query_map(params![limit, offset, param3], row_to_item) + .map_err(|e| format!("查询历史失败: {e}"))?; + let mut items = Vec::new(); + for row in rows { + items.push(row.map_err(|e| format!("读取历史失败: {e}"))?); + } + Ok(items) + } + + pub fn delete(&self, id: i64) -> Result<(), String> { + self.conn() + .execute("DELETE FROM history WHERE id = ?1", params![id]) + .map_err(|e| format!("删除历史失败: {e}"))?; + Ok(()) + } + + pub fn clear(&self) -> Result<(), String> { + self.conn() + .execute("DELETE FROM history", []) + .map_err(|e| format!("清空历史失败: {e}"))?; + Ok(()) + } + + pub fn set_favorited(&self, id: i64, favorited: bool) -> Result<(), String> { + self.conn() + .execute( + "UPDATE history SET favorited = ?1 WHERE id = ?2", + params![favorited as i64, id], + ) + .map_err(|e| format!("更新收藏失败: {e}"))?; + Ok(()) + } + + /// 容量淘汰:超出上限时优先删最旧的非收藏记录。 + pub fn prune_to_max(&self, max_items: i64) { + if max_items <= 0 { + return; + } + let Ok(conn) = self.conn.lock() else { + return; + }; + let _ = conn.execute( + "DELETE FROM history WHERE id IN ( + SELECT id FROM history WHERE favorited = 0 + ORDER BY ts DESC LIMIT -1 OFFSET ?1 + )", + params![max_items], + ); + } +} + +fn escape_like(input: &str) -> String { + input.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") +} + +/// 把用户输入包成一个 FTS5 短语查询。 +/// +/// **必须包引号**:FTS5 的 MATCH 参数有自己的一套查询语法,`-`、`*`、`(`、`^` +/// 以及 `AND`/`OR`/`NOT` 都会被当作操作符——用户搜 `a-b` 会被解释成「含 a 但不含 b」, +/// 搜 `(` 之类则直接抛语法错误。整串加双引号后退化成「按字面顺序出现的短语」, +/// 与原本 LIKE 子串语义对齐;引号内的 `"` 用双写转义。 +fn fts_phrase(term: &str) -> String { + format!("\"{}\"", term.replace('"', "\"\"")) +} + +fn row_to_item(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(HistoryItem { + id: row.get(0)?, + ts: row.get(1)?, + from_lang: row.get(2)?, + to_lang: row.get(3)?, + engine_id: row.get(4)?, + engine_name: row.get(5)?, + source_text: row.get(6)?, + result_text: row.get(7)?, + via: row.get(8)?, + favorited: row.get::<_, i64>(9)? != 0, + latency_ms: row.get(10)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// 每个用例一个独立目录。`History` 持有连接,测试结束前无法删目录(Windows 会拒绝), + /// 因此这里只保证不互相踩,不留清理——落在系统临时目录里是可以接受的代价。 + fn open(tag: &str) -> (History, PathBuf) { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "thing-history-test-{}-{tag}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + let history = History::new(&dir).expect("建库失败"); + (history, dir) + } + + /// 一组覆盖两种分词行为的样例: + /// 中日英混排、含 FTS 元字符(`-` `*` `"`)、含 LIKE 元字符(`%`)、含 FTS 操作符关键字。 + const SOURCE_JA: &str = "日本語の翻訳を確認します"; + const RESULT_JA: &str = "确认日本语翻译"; + const SOURCE_EN: &str = "return the formatted date string"; + const RESULT_EN: &str = "返回格式化后的日期字符串"; + const SOURCE_JA2: &str = "すべての項目に入力をしてください"; + const RESULT_JA2: &str = "请在所有项目中输入。"; + const SOURCE_META: &str = "tail with \"quote\" and *star and a-b"; + const SOURCE_PCT: &str = "100% done"; + const SOURCE_KW: &str = "a AND b"; + + fn seed(h: &History) { + let rows = [ + (SOURCE_JA, RESULT_JA, "manual"), + (SOURCE_EN, RESULT_EN, "clipboard"), + (SOURCE_JA2, RESULT_JA2, "selection"), + (SOURCE_META, "x", "manual"), + (SOURCE_PCT, "y", "manual"), + (SOURCE_KW, "z", "manual"), + ]; + for (i, (src, res, via)) in rows.iter().enumerate() { + h.record("auto", "zh-Hans", "mock", "Mock", src, res, via, i as i64) + .expect("写入失败"); + } + } + + /// 用原生 LIKE 算出的基准集合,作为 FTS 路径的正确性参照。 + fn like_baseline(dir: &Path, term: &str, favorited_only: bool) -> Vec { + let conn = Connection::open(dir.join("history.db")).unwrap(); + let pattern = format!("%{}%", escape_like(term)); + let sql = if favorited_only { + "SELECT id FROM history WHERE favorited = 1 + AND (source_text LIKE ?1 ESCAPE '\\' OR result_text LIKE ?1 ESCAPE '\\')" + } else { + "SELECT id FROM history WHERE + source_text LIKE ?1 ESCAPE '\\' OR result_text LIKE ?1 ESCAPE '\\'" + }; + let mut stmt = conn.prepare(sql).unwrap(); + let rows = stmt.query_map(params![pattern], |r| r.get::<_, i64>(0)).unwrap(); + rows.map(|r| r.unwrap()).collect() + } + + /// 按集合比较,忽略顺序:`record()` 用 wall clock 打时间戳,同一用例内的记录 + /// ts 可能相同,`ORDER BY ts DESC` 的先后不稳定,比顺序会随机失败。 + fn sorted_ids(h: &History, term: &str, favorited_only: bool) -> Vec { + let mut ids: Vec = h + .list(0, 100, Some(term), favorited_only) + .expect("查询失败") + .iter() + .map(|i| i.id) + .collect(); + ids.sort_unstable(); + ids + } + + fn sorted(v: Vec) -> Vec { + let mut v = v; + v.sort_unstable(); + v + } + + /// 两种 SQL 形态必须给出**完全一致**的结果。 + /// + /// 这条测试的核心是钉住长度分流:trigram 索引里没有短于 3 字符的片段, + /// 对 1~2 字的关键词 `MATCH` 不报错、只返回空集。少了回退分支,中文两字词 + /// (最常见的一类查询)就会「条件明明对却搜不到」。 + #[test] + fn fts_and_like_paths_agree_across_query_lengths() { + let (h, dir) = open("paths-agree"); + seed(&h); + let ids: Vec = h.list(0, 100, None, false).unwrap().iter().map(|i| i.id).collect(); + assert_eq!(ids.len(), 6, "样例数据应为 6 条(去重键各不相同)"); + h.set_favorited(ids[1], true).unwrap(); + + let terms = [ + "确", // 1 字 → LIKE 回退 + "确认", // 2 字 → LIKE 回退(中文里最常见,最容易踩坑的长度) + "fo", // 2 字符 ASCII → LIKE 回退 + "确认日", // 3 字 → FTS + "日本语翻译", // 5 字 → FTS + "form", // 4 字符 → FTS,且是 formatted 的子串 + "the formatted", + "zzz", // 无命中 + "100%", // LIKE 元字符:两条路径都必须按字面处理 + "a-b", // FTS 里 `-` 是操作符,必须被短语引号中和 + "*star", + "with \"quote\"", + "AND", // FTS 关键字,必须按字面匹配而不是当运算符 + "(((", + ]; + for term in terms { + for favorited_only in [false, true] { + assert_eq!( + sorted_ids(&h, term, favorited_only), + sorted(like_baseline(&dir, term, favorited_only)), + "关键词 {term:?}(favoritedOnly={favorited_only})两条路径结果不一致" + ); + } + } + } + + /// 外部内容表不会自动感知主表变化,全靠触发器。漏一个就会让索引与正文静默错位 + /// ——表现是「刚翻过的句子搜不到」或「已删掉的记录还能搜出来」,都很难归因。 + #[test] + fn triggers_keep_fts_in_sync_with_upsert_and_delete() { + let (h, _dir) = open("trigger-sync"); + h.record("ja", "zh-Hans", "mock", "Mock", SOURCE_JA, RESULT_JA, "manual", 1) + .unwrap(); + assert_eq!(sorted_ids(&h, "日本语翻译", false).len(), 1, "新记录应可搜到"); + + // 去重键命中 → 走 ON CONFLICT DO UPDATE,应触发 update 触发器 + h.record("ja", "zh-Hans", "mock", "Mock", SOURCE_JA, "换了译文的说法", "manual", 2) + .unwrap(); + assert_eq!(h.list(0, 100, None, false).unwrap().len(), 1, "应仍是同一条记录"); + assert_eq!(sorted_ids(&h, "换了译文的说法", false).len(), 1, "更新后新译文应可搜到"); + assert!(sorted_ids(&h, "日本语翻译", false).is_empty(), "更新后旧译文不应再命中"); + + let id = h.list(0, 100, None, false).unwrap()[0].id; + h.delete(id).unwrap(); + assert!(sorted_ids(&h, "换了译文的说法", false).is_empty(), "删除后不应再命中"); + + h.record("ja", "zh-Hans", "mock", "Mock", SOURCE_JA, RESULT_JA, "manual", 3) + .unwrap(); + h.clear().unwrap(); + assert!(h.list(0, 100, None, false).unwrap().is_empty()); + assert!(sorted_ids(&h, "换了译文的说法", false).is_empty(), "清空后索引应为空"); + } + + /// FTS 索引必须与主表行数一致。用 FTS5 自带的完整性检查暴露静默错位。 + #[test] + fn fts_index_passes_integrity_check() { + let (h, _dir) = open("integrity"); + seed(&h); + let conn = h.conn(); + conn.execute("INSERT INTO history_fts(history_fts) VALUES('integrity-check')", []) + .expect("FTS 索引与主表不一致"); + } + + /// 收藏条目不参与容量淘汰(既有约定,顺手一起守住)。 + #[test] + fn prune_drops_oldest_unfavorited_only() { + let (h, _dir) = open("prune"); + for i in 0..6 { + // 原文必须各不相同:去重键含 source_text,同一段文字只会留一条 + let src = format!("{SOURCE_JA}{i}"); + h.record("auto", "zh-Hans", "mock", "Mock", &src, RESULT_JA, "manual", i) + .unwrap(); + } + let ids: Vec = h.list(0, 100, None, false).unwrap().iter().map(|i| i.id).collect(); + assert_eq!(ids.len(), 6); + let favorite = ids[2]; + h.set_favorited(favorite, true).unwrap(); + + h.prune_to_max(3); + + let left: Vec = h.list(0, 100, None, false).unwrap().iter().map(|i| i.id).collect(); + assert!(left.contains(&favorite), "收藏条目被淘汰了"); + assert_eq!(left.len(), 4, "上限 3 之外只该多留那条收藏,实际剩 {left:?}"); + } + + /// 结构版本变化时整库重建。这条是「现在允许丢数据」这个前提的守卫: + /// 一旦有人把 SCHEMA_VERSION 忘了改,旧库会带着不兼容的结构跑下去。 + #[test] + fn stale_schema_is_dropped_and_rebuilt() { + let (h, dir) = open("schema-reset"); + seed(&h); + assert_eq!(h.list(0, 100, None, false).unwrap().len(), 6); + drop(h); + + { + let conn = Connection::open(dir.join("history.db")).unwrap(); + conn.execute("INSERT INTO history(ts, from_lang, to_lang, engine_id, engine_name, source_text, result_text, via, favorited, latency_ms) + VALUES (1,'auto','zh-Hans','mock','Mock','stale row','stale row','manual',0,0)", []).unwrap(); + conn.execute_batch("PRAGMA user_version = 1;").unwrap(); + } + + let rebuilt = History::new(&dir).expect("重建失败"); + assert!( + rebuilt.list(0, 100, None, false).unwrap().is_empty(), + "版本不匹配时旧数据应被清掉" + ); + assert!(sorted_ids(&rebuilt, "stale row", false).is_empty(), "重建后不应残留旧索引"); + } + + /// 短语化必须中和 FTS5 的查询语法,否则用户搜 `a-b` 会被解释成「含 a 但不含 b」, + /// 搜 `(` 之类则直接抛语法错误。 + #[test] + fn fts_phrase_quotes_and_escapes() { + assert_eq!(fts_phrase("a-b"), "\"a-b\""); + assert_eq!(fts_phrase("say \"hi\""), "\"say \"\"hi\"\"\""); + assert_eq!(fts_phrase("("), "\"(\""); + } +} diff --git a/src-tauri/src/translate/mod.rs b/src-tauri/src/translate/mod.rs new file mode 100644 index 0000000..1ec00b1 --- /dev/null +++ b/src-tauri/src/translate/mod.rs @@ -0,0 +1,452 @@ +//! 翻译模块:多源翻译 + AI 翻译 + 划词翻译 + 截图翻译。 +//! +//! 架构(详见仓库根目录 `TRANSLATE_MODULE_PLAN.md`): +//! +//! ```text +//! 前端(主面板 / 划词悬浮窗 / 截图选区) +//! ↓ Tauri IPC +//! commands.rs 命令层(薄:参数整形 + 交给 manager) +//! ↓ +//! TranslateManager(本文件):设置读写 / 引擎装配 / 代理判定 / 自动降级编排 +//! ↓ +//! engines/ 引擎实现(ai = OpenAI 兼容;free = libretranslate / mymemory) +//! capture/ 取词(P1:Ctrl+C 兼容路径) +//! popup.rs 取词结果的非激活悬浮窗 +//! ↓ reqwest +//! DeepSeek / OpenAI 兼容端点 / LibreTranslate / MyMemory +//! ``` +//! +//! 三条贯穿全模块的约束: +//! 1. **网络请求只在 Rust 侧发生**。生产构建的 CSP 只允许 `ipc:` 连接,前端直连外部 +//! API 会被静默拦截;且密钥若进入 WebView,等于把凭据暴露给了页面上下文。 +//! 2. **引擎实例按请求即时构造**,不缓存。引擎是有状态的(密钥、模型、超时), +//! 而设置页改完就该立刻生效——缓存实例反而要额外处理失效,得不偿失。 +//! 3. **代理只在这里判定一次**。reqwest 的代理必须在建 Client 时指定、不能按请求覆盖, +//! 因此「直连还是走代理」的决策收敛到 [`TranslateManager::select_client`], +//! 各引擎一律拿现成的客户端。 + +mod capture; +mod commands; +mod engines; +mod history; +mod ocr; +mod popup; +mod settings; + +pub use commands::{ + translate_abort, translate_apply_shortcuts, translate_copy_text, translate_engine_delete, + translate_engine_models, translate_engine_save, translate_engine_test_config, + translate_engines_list, + translate_get_settings, translate_history_clear, translate_history_delete, + translate_history_list, translate_history_set_favorited, translate_ocr_languages, + translate_paste_back, translate_popup_edit_mode, translate_popup_hide, + translate_popup_prefs_set, translate_popup_ready, translate_popup_resize, + translate_popup_set_pinned, translate_preview_popup, translate_run, translate_save_settings, + translate_screenshot_region, translate_secret_clear, translate_secret_set, + translate_stream_start, +}; +pub use engines::{TranslateEngine, TranslateError, TranslateResult}; +// 只再导出本模块内部(mod.rs / commands.rs / popup.rs)实际用到的类型。其余类型仍保留在 +// `engines::` / `settings::` 下,等真正用到时再提升到此处——提前摆出一堆无人消费的再导出, +// 只会让「谁在用」更难判断。 +pub use settings::TranslateSettings; + +use engines::EngineRequest; +use settings::TranslateEngineConfig; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// 设置内存缓存有效期。设置页存在「读一次、改一处、再读」的高频往返, +/// 不缓存会反复读盘;缓存过长又会让外部改动不可见,500ms 与音乐模块保持一致。 +const SETTINGS_CACHE_TTL: Duration = Duration::from_millis(500); + +/// 代理可用性的缓存有效期。控制端探测是一次本机 HTTP 请求(通常 1ms 内拒绝/返回), +/// 但设置页刷一次引擎列表就要探测一次,加个短缓存避免无谓往返。 +const PROXY_CACHE_TTL: Duration = Duration::from_millis(5000); + +/// 引擎 API Key 的凭据键前缀(键名格式:`translate-engine-`)。 +const ENGINE_KEY_PREFIX: &str = "translate-engine-"; + +/// 某引擎实例的凭据键。 +pub fn engine_secret_key(engine_id: &str) -> String { + format!("{ENGINE_KEY_PREFIX}{engine_id}") +} + +/// 启动时按设置注册全局快捷键并预创建取词悬浮窗。 +/// 失败只记日志、不阻断启动——快捷键被别的程序占用不该让应用起不来。 +pub fn init_on_launch(app: &tauri::AppHandle) { + if let Err(e) = popup::apply_shortcuts(app) { + crate::logger::log_warn("translate", &format!("启动时注册取词快捷键失败: {e}")); + } +} + +/// 读取某引擎实例的 API Key(未配置或读取失败 → 空串)。 +pub fn engine_api_key(engine_id: &str) -> String { + if engine_id.trim().is_empty() { + return String::new(); + } + crate::secrets::read_or_empty(&engine_secret_key(engine_id)) +} + +struct SettingsCache { + read_at: Instant, + settings: TranslateSettings, +} + +/// 翻译模块管理器(Tauri State)。 +pub struct TranslateManager { + /// 应用数据目录(proxy 模块的配置也在这一层,代理判定需要) + data_dir: PathBuf, + /// 模块自身目录:{app_data_dir}/translate + root: PathBuf, + /// 直连客户端 + client: reqwest::Client, + cache: Mutex>, + /// 代理地址解析缓存:`(解析时刻, 结果)`;结果为 None 表示已开启但不可用 + proxy_cache: Mutex)>>, + /// 带代理的客户端缓存:`(代理地址, 客户端)`。与地址一一对应,地址变了就重建。 + proxied_client: Mutex>, + /// 翻译历史。打开失败时为 None:历史是锦上添花,不值得为它让整个模块不可用。 + history: Option>, +} + +impl TranslateManager { + pub fn new(app_data_dir: PathBuf) -> Self { + let root = app_data_dir.join("translate"); + std::fs::create_dir_all(&root).ok(); + let client = reqwest::Client::builder() + // 兜底超时:单个请求还会按引擎配置设置更精确的超时 + .timeout(Duration::from_secs(120)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let history = match history::History::new(&root) { + Ok(h) => Some(std::sync::Arc::new(h)), + Err(e) => { + crate::logger::log_warn("translate", &format!("翻译历史不可用: {e}")); + None + } + }; + Self { + data_dir: app_data_dir, + root, + client, + cache: Mutex::new(None), + proxy_cache: Mutex::new(None), + proxied_client: Mutex::new(None), + history, + } + } + + fn settings_path(&self) -> PathBuf { + self.root.join("settings.json") + } + + // ===== 设置 ===== + + /// 读取设置(带短时缓存;文件缺失/损坏回退默认值;自愈结果落盘)。 + pub fn load_settings(&self) -> TranslateSettings { + if let Ok(cache) = self.cache.lock() { + if let Some(entry) = cache.as_ref() { + if entry.read_at.elapsed() < SETTINGS_CACHE_TTL { + return entry.settings.clone(); + } + } + } + // 文件缺失或损坏一律回退默认值:翻译是可随时重建的配置, + // 不值得为「读不懂的旧文件」让整个模块不可用。 + let mut settings = std::fs::read_to_string(self.settings_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_default(); + let healed = settings.heal(); + if let Ok(mut cache) = self.cache.lock() { + *cache = Some(SettingsCache { + read_at: Instant::now(), + settings: settings.clone(), + }); + } + if healed { + if let Err(e) = self.save_settings(&settings) { + crate::logger::log_error("translate", &format!("设置自愈落盘失败: {e}")); + } + } + settings + } + + /// 保存设置并刷新缓存。 + pub fn save_settings(&self, settings: &TranslateSettings) -> Result<(), String> { + let json = serde_json::to_string_pretty(settings) + .map_err(|e| format!("序列化翻译设置失败: {e}"))?; + std::fs::write(self.settings_path(), json).map_err(|e| format!("写入翻译设置失败: {e}"))?; + if let Ok(mut cache) = self.cache.lock() { + *cache = Some(SettingsCache { + read_at: Instant::now(), + settings: settings.clone(), + }); + } + Ok(()) + } + + // ===== 代理 ===== + + /// 读代理模块(mihomo)配置:`{app_data_dir}/proxy/settings.json` 的 mixedPort。 + /// 返回 `(mixedPort, externalController)`;缺失或非法返回 None。 + /// + /// 这里刻意**只读文件不依赖 MihomoManager 状态**:翻译模块不该因为代理模块被禁用 + /// 就拿不到端口;端口是否可用由下面的控制端探测回答。 + fn read_mixed_port(&self) -> Option<(u16, String)> { + let path = self.data_dir.join("proxy").join("settings.json"); + let raw = std::fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let port = value.get("mixedPort").and_then(|v| v.as_u64())?; + if port == 0 || port > 65535 { + return None; + } + let controller = value + .get("externalController") + .and_then(|v| v.as_str()) + .unwrap_or("127.0.0.1:9090") + .to_string(); + Some((port as u16, controller)) + } + + /// 探测 mihomo 控制端是否在线。 + /// + /// 判定标准是「能建立连接」而不是「返回 200」:控制端设了 secret 时会返回 401, + /// 但那恰恰说明内核在运行、代理端口可用。只有连接失败才算离线。 + async fn probe_controller(&self, controller: &str) -> bool { + let base = if controller.starts_with("http://") || controller.starts_with("https://") { + controller.to_string() + } else { + format!("http://{controller}") + }; + let url = format!("{}/version", base.trim_end_matches('/')); + self.client + .get(&url) + .timeout(Duration::from_millis(800)) + .send() + .await + .is_ok() + } + + /// 解析当前应使用的代理地址(带 TTL 缓存)。未开启开关时返回 None。 + async fn resolve_proxy_url(&self) -> Option { + if !self.load_settings().use_proxy { + return None; + } + if let Ok(cache) = self.proxy_cache.lock() { + if let Some((at, url)) = cache.as_ref() { + if at.elapsed() < PROXY_CACHE_TTL { + return url.clone(); + } + } + } + + let resolved = match self.read_mixed_port() { + Some((port, controller)) => { + let url = format!("http://127.0.0.1:{port}"); + if self.probe_controller(&controller).await { + Some(url) + } else { + None + } + } + None => None, + }; + + if let Ok(mut cache) = self.proxy_cache.lock() { + let previous = cache.as_ref().and_then(|(_, u)| u.clone()); + let changed = previous != resolved; + *cache = Some((Instant::now(), resolved.clone())); + // 地址变了(或可用性变了)就丢弃旧客户端,否则会继续用已失效的代理 + if changed { + if let Ok(mut client) = self.proxied_client.lock() { + *client = None; + } + } + } + resolved + } + + /// 选择本次请求使用的 HTTP 客户端:开了代理且可用 → 走代理,否则直连。 + /// 代理不可用时**静默回退直连**:让请求自己去失败,比在这里造一个错误更接近真相 + /// (也许代理确实不通但目标站恰好可达)。 + async fn select_client(&self) -> reqwest::Client { + let Some(url) = self.resolve_proxy_url().await else { + return self.client.clone(); + }; + if let Ok(cache) = self.proxied_client.lock() { + if let Some((cached_url, client)) = cache.as_ref() { + if *cached_url == url { + return client.clone(); + } + } + } + let built = reqwest::Proxy::all(&url).ok().and_then(|proxy| { + reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .proxy(proxy) + .build() + .ok() + }); + match built { + Some(client) => { + if let Ok(mut cache) = self.proxied_client.lock() { + *cache = Some((url, client.clone())); + } + client + } + None => { + crate::logger::log_warn("translate", "构造带代理的 HTTP 客户端失败,本次回退直连"); + self.client.clone() + } + } + } + + // ===== 引擎 ===== + + /// 按配置装配引擎实例(异步:需要先决定走不走代理)。 + pub async fn build( + &self, + cfg: &TranslateEngineConfig, + ) -> Result, TranslateError> { + let templates = self.load_settings().prompt_templates; + let client = self.select_client().await; + engines::build_engine(cfg, &templates, client) + } + + /// 解析本次请求实际要尝试的引擎序列。 + /// + /// - 显式指定了 `engineId`:只用它(用户要的就是「这个源」)——请求失败不做降级, + /// 否则「指定了 A 却拿到 B 的译文」比报错更令人困惑。 + /// - `auto` / 未指定:开启降级时按优先级依次尝试;关闭降级时只用默认引擎。 + pub fn resolve_candidates( + &self, + engine_id: Option<&str>, + ) -> Result, TranslateError> { + let settings = self.load_settings(); + let explicit = engine_id + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "auto"); + + if let Some(id) = explicit { + return settings + .engine(id) + .cloned() + .map(|cfg| vec![cfg]) + .ok_or_else(|| TranslateError::config(format!("找不到引擎实例「{id}」"))); + } + + if settings.auto_fallback { + let list: Vec = + settings.auto_candidates().into_iter().cloned().collect(); + if list.is_empty() { + return Err(TranslateError::config( + "没有已启用的翻译引擎,请先在翻译设置中启用至少一个", + )); + } + return Ok(list); + } + + // 未开启降级:只尝试默认引擎;默认引擎失效时回落到优先级最高的已启用实例 + let preferred = settings + .engine(&settings.default_engine_id) + .cloned() + .or_else(|| settings.auto_candidates().first().map(|c| (*c).clone())); + preferred.map(|cfg| vec![cfg]).ok_or_else(|| { + TranslateError::config("没有已启用的翻译引擎,请先在翻译设置中启用至少一个") + }) + } + + /// 执行一次翻译(含自动降级)。 + /// + /// `record`:是否写入历史。正常翻译为 true;**多引擎对比必须传 false**—— + /// 对比一次产生 N 条结果,全部入库只会污染记录。历史是否记录由此参数 + /// 显式决定,而不是靠调用方绕开本方法(绕开会让「哪些入口记历史」无从查起)。 + pub async fn run( + &self, + req: EngineRequest, + engine_id: Option<&str>, + record: bool, + ) -> Result { + let candidates = self.resolve_candidates(engine_id)?; + let mut last_error: Option = None; + + for cfg in candidates { + let engine = match self.build(&cfg).await { + Ok(e) => e, + Err(e) => { + // 装配失败(如未接入的引擎类型)继续尝试下一个候选, + // 让「自动」模式真正具备容错意义 + last_error = Some(e); + continue; + } + }; + match engine.translate(&req).await { + Ok(result) => { + if record { + self.record_history(&req, &result, &result.engine_id); + } + return Ok(result); + } + Err(e) => { + crate::logger::log_warn( + "translate", + &format!( + "引擎「{}」翻译失败:{}(kind={:?})", + engine.name(), + e.message, + e.kind + ), + ); + // 输入为空、语言对不支持这类错误换源也没用,直接返回首个错误 + if !e.retryable() { + return Err(e); + } + last_error = Some(e); + } + } + } + + Err(last_error.unwrap_or_else(|| { + TranslateError::config("没有可用的翻译引擎,请先在翻译设置中启用至少一个") + })) + } + + /// 历史库(打开失败时为 None)。`Arc` 便于流式转发任务独立持有。 + pub(crate) fn history(&self) -> Option> { + self.history.clone() + } + + /// 记录一次成功翻译(失败不记:历史是「翻过的东西」,不是「试过的东西」)。 + /// 历史开关关闭或库不可用时静默跳过。 + fn record_history(&self, req: &EngineRequest, result: &TranslateResult, engine_id: &str) { + let settings = self.load_settings(); + if !settings.history.enabled { + return; + } + let Some(history) = self.history.as_ref() else { + return; + }; + // 截图(视觉直译)没有原文文本,用占位说明来源 + let source = if req.text.trim().is_empty() { + "(屏幕截图)" + } else { + req.text.as_str() + }; + if let Err(e) = history.record( + &req.from, + &req.to, + engine_id, + &result.engine_name, + source, + &result.text, + &req.via, + result.latency_ms as i64, + ) { + crate::logger::log_warn("translate", &format!("记录历史失败: {e}")); + } + history.prune_to_max(settings.history.max_items as i64); + } +} diff --git a/src-tauri/src/translate/ocr/mod.rs b/src-tauri/src/translate/ocr/mod.rs new file mode 100644 index 0000000..6b04726 --- /dev/null +++ b/src-tauri/src/translate/ocr/mod.rs @@ -0,0 +1,15 @@ +//! OCR:把图变字。 +//! +//! 目前只有 Windows 本地识别(`Windows.Media.Ocr`)。抽象成独立模块的原因是 +//! **截图翻译有两条并行的取字路径**:本地 OCR(离线、零成本、图片不外发)与 +//! 视觉大模型直译(识别 + 翻译一步完成,对表格/菜单这类复杂排版明显更好)。 +//! 两条路径对上层暴露相同的入口签名,`screenshot.ocrMode` 决定走哪条。 +//! +//! 关于本地 OCR 的三个已知限制(都会在错误信息里引导,而不是含糊报错): +//! - 依赖系统 OCR 语言包:英文永远可用,中/日/韩等需要系统安装对应语言包; +//! - 印刷体效果好,竖排、艺术字、低对比度截图效果差; +//! - `Windows.Media.Ocr` 要求输入为 BGRA8 格式,其它格式需先转换。 + +pub mod windows_ocr; + +pub use windows_ocr::{available_languages, ocr_png}; diff --git a/src-tauri/src/translate/ocr/windows_ocr.rs b/src-tauri/src/translate/ocr/windows_ocr.rs new file mode 100644 index 0000000..afd4975 --- /dev/null +++ b/src-tauri/src/translate/ocr/windows_ocr.rs @@ -0,0 +1,201 @@ +//! Windows.Media.Ocr 本地识别。 +//! +//! 实现要点(三个坑都已规避): +//! 1. **不落盘**。走「`image` 解码 PNG → RGBA 转 BGRA → `CryptographicBuffer` 构造 +//! `IBuffer` → `SoftwareBitmap::CreateCopyFromBuffer`」的纯内存路径。 +//! 0.52 版 windows crate 的 `RandomAccessStreamReference` 没有 `CreateFromByteArray`, +//! 走 BitmapDecoder 的流路径既要临时流又要二次解码,因此放弃。 +//! 2. **像素格式**。`RecognizeAsync` 要求 BGRA8,而 PNG 解出来常是 Rgba8, +//! 转换在这里显式完成(同时交换 R/B 通道),不做这一步识别会失败。 +//! 3. **`IAsyncOperation::get()` 会阻塞当前线程**。调用方必须在阻塞线程池执行, +//! 并初始化 MTA——在 STA 上 `get()` 有死锁风险。 +//! +//! 若干方法(`Lines` / `AvailableRecognizerLanguages`)在 windows crate 里按 feature +//! 裁剪(返回 `IVectorView` 需要 `Foundation_Collections`),缺 feature 时表现为 +//! 「方法不存在」而不是链接错误,排查时先看 Cargo.toml。 + +use serde::Serialize; +use specta::Type; +use windows::Globalization::Language; +use windows::Graphics::Imaging::{BitmapPixelFormat, SoftwareBitmap}; +use windows::Media::Ocr::OcrEngine; +use windows::Security::Cryptography::CryptographicBuffer; +use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + +use super::super::engines::{ErrorKind, TranslateError}; + +/// 一行识别结果 +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct OcrLine { + pub text: String, + /// 行边界框(相对输入图像的物理像素)。多个词的矩形取并集。 + /// 一并返回是为了给「译文叠加在原文位置上」留出能力——这是截图翻译体验质变的前提。 + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// 一次识别的完整结果 +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct OcrResult { + pub lines: Vec, + /// 实际使用的识别语言(BCP-47) + pub lang: String, +} + +/// 对 PNG 图像执行本地 OCR。 +/// +/// `lang`:`None` 或 "auto" 跟随用户配置的语言,否则用指定的 BCP-47 标签。 +pub fn ocr_png(png: &[u8], lang: Option<&str>) -> Result { + // MTA:`get()` 阻塞等待需要正确初始化的公寓;已初始化(S_FALSE)与 + // 变更模式(RPC_E_CHANGED_MODE)都忽略——后者说明调用方已是 STA。 + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + + let engine = match lang.map(str::trim).filter(|s| !s.is_empty() && *s != "auto") { + Some(tag) => create_engine_for(tag)?, + None => match OcrEngine::TryCreateFromUserProfileLanguages() { + Ok(e) => e, + Err(_) => { + return Err(missing_language_pack( + "未检测到可用的 OCR 引擎", + "请在系统「设置 → 时间和语言 → 语言和区域」中添加语言并勾选「文本识别」", + )) + } + }, + }; + + let used_tag = engine + .RecognizerLanguage() + .ok() + .and_then(|l| l.LanguageTag().ok()) + .map(|t| t.to_string_lossy()) + .unwrap_or_default(); + + let bitmap = bitmap_from_png(png)?; + let recognized = match engine.RecognizeAsync(&bitmap) { + Ok(op) => match op.get() { + Ok(r) => r, + Err(e) => return Err(TranslateError::new(ErrorKind::Unknown, format!("识别失败: {e}"))), + }, + Err(e) => return Err(TranslateError::new(ErrorKind::Unknown, format!("识别失败: {e}"))), + }; + + let mut lines = Vec::new(); + if let Ok(view) = recognized.Lines() { + let count = view.Size().unwrap_or(0); + for i in 0..count { + let Ok(line) = view.GetAt(i) else { continue }; + let Ok(text) = line.Text() else { continue }; + let text = text.to_string_lossy(); + if text.trim().is_empty() { + continue; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, 0.0_f64, 0.0_f64); + if let Ok(words) = line.Words() { + let wcount = words.Size().unwrap_or(0); + for j in 0..wcount { + let Ok(word) = words.GetAt(j) else { continue }; + let Ok(rect) = word.BoundingRect() else { continue }; + x0 = x0.min(rect.X as f64); + y0 = y0.min(rect.Y as f64); + x1 = x1.max((rect.X + rect.Width) as f64); + y1 = y1.max((rect.Y + rect.Height) as f64); + } + } + let (x, y, width, height) = if x1 > x0 && y1 > y0 { + (x0, y0, x1 - x0, y1 - y0) + } else { + (0.0, 0.0, 0.0, 0.0) + }; + lines.push(OcrLine { + text: text.trim().to_string(), + x, + y, + width, + height, + }); + } + } + + Ok(OcrResult { + lines, + lang: used_tag, + }) +} + +/// 列出系统当前可用的 OCR 语言(BCP-47 标签)。 +/// 设置页用它展示「哪些语言能识别、哪些需要先装语言包」,而不是让用户撞一次错才知道。 +pub fn available_languages() -> Result, TranslateError> { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + let view = OcrEngine::AvailableRecognizerLanguages() + .map_err(|e| TranslateError::parse(format!("读取系统 OCR 语言失败: {e}")))?; + let count = view.Size().unwrap_or(0); + let mut tags = Vec::with_capacity(count as usize); + for i in 0..count { + if let Ok(lang) = view.GetAt(i) { + if let Ok(tag) = lang.LanguageTag() { + let tag = tag.to_string_lossy(); + if !tag.is_empty() { + tags.push(tag); + } + } + } + } + tags.sort(); + tags.dedup(); + Ok(tags) +} + +/// PNG → BGRA8 `SoftwareBitmap`(纯内存,不落盘)。 +fn bitmap_from_png(png: &[u8]) -> Result { + let img = image::load_from_memory(png) + .map_err(|e| TranslateError::parse(format!("解码截图失败: {e}")))?; + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + // OCR 引擎要求 BGRA8:RGBA 与 BGRA 只差 R/B 两个通道,就地交换 + let mut bgra = rgba.into_raw(); + for px in bgra.chunks_exact_mut(4) { + px.swap(0, 2); + } + let buffer = CryptographicBuffer::CreateFromByteArray(&bgra) + .map_err(|e| TranslateError::parse(format!("构造图像缓冲失败: {e}")))?; + SoftwareBitmap::CreateCopyFromBuffer( + &buffer, + BitmapPixelFormat::Bgra8, + width as i32, + height as i32, + ) + .map_err(|e| TranslateError::parse(format!("构造位图失败: {e}"))) +} + +fn create_engine_for(tag: &str) -> Result { + let language = match Language::CreateLanguage(&windows::core::HSTRING::from(tag)) { + Ok(l) => l, + Err(e) => { + return Err(TranslateError::config(format!( + "无法解析 OCR 语言「{tag}」: {e}" + ))) + } + }; + match OcrEngine::TryCreateFromLanguage(&language) { + Ok(engine) => Ok(engine), + // Try* 系列「失败」的返回形态(空对象 / Err)在 windows-rs 里不完全一致, + // 统一在这里兜住,给用户「去哪儿装语言包」的确切指引 + Err(_) => Err(missing_language_pack( + &format!("系统未安装「{tag}」的 OCR 语言包"), + "请在系统「设置 → 时间和语言 → 语言和区域」中添加该语言并勾选「文本识别」,\ + 或把识别语言改回「自动」", + )), + } +} + +fn missing_language_pack(what: &str, how: &str) -> TranslateError { + TranslateError::config(format!("{what}。{how}")) +} diff --git a/src-tauri/src/translate/popup.rs b/src-tauri/src/translate/popup.rs new file mode 100644 index 0000000..3223bd3 --- /dev/null +++ b/src-tauri/src/translate/popup.rs @@ -0,0 +1,770 @@ +//! 取词结果的悬浮窗(非激活)。 +//! +//! 沿用剪贴板预览窗已验证的模式(屏幕外预创建、按光标定位、工作区钳制、内容自适应), +//! 但有一处**刻意的差异**:本窗口在创建时就打上 `WS_EX_NOACTIVATE`([`apply_no_activate`]), +//! 因此显示时永远不会抢走焦点。这对划词翻译是硬要求——抢焦点会取消用户的选区、打断阅读, +//! 弹出一次就把原文弄没了,功能等于不可用。 +//! +//! 代价是**收不到键盘事件**:非激活窗口不持有键盘焦点,Escape 之类的快捷键不可靠。 +//! 所以本窗口的交互全部走鼠标(复制、换源、关闭都是按钮),并且靠看护线程在 +//! 「点击窗口外部」时自动收起——不能像剪贴板弹窗那样依赖 `Focused(false)`。 +//! +//! [`apply_no_activate`]: crate::win32_util::apply_no_activate + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use tauri::window::{Effect, EffectsBuilder}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; + +use super::capture::{self, CaptureRequest}; +use super::settings::TranslateSettings; +use super::TranslateManager; +use crate::clipboard::reader::{read_clipboard, ClipData}; +use crate::win32_util::{ + get_cursor_pos, get_dpi_for_point, get_work_area_at_point, +}; + +/// 窗口 label(与 `capabilities/translate-popup.json`、前端 `WINDOWS.translatePopup` 三处对齐, +/// 因此统一取常量而不是写字面量) +pub const POPUP_LABEL: &str = crate::constants::windows::TRANSLATE_POPUP; + +/// 弹窗逻辑尺寸。双栏布局(左原文右译文)宽度翻倍;高度只是初值, +/// 前端测完内容会调 `translate_popup_resize` 贴合(内容区上限 26rem,见前端模板)。 +const POPUP_W: f64 = 840.0; +const POPUP_BASE_H: f64 = 480.0; + +/// 尺寸上下限(逻辑像素):防止前端异常值把窗口撑到屏幕外或压成一条线。 +/// MAX_H 高于前端根节点的 max-height(920px)——前端会在内容超过时自行出滚动条, +/// 这里只是兜住异常值;实际窗口高度以 syncSize 上报为准,不会被钳到。 +const MIN_W: f64 = 300.0; +const MAX_W: f64 = 900.0; +const MIN_H: f64 = 72.0; +const MAX_H: f64 = 960.0; + +/// 光标与弹窗之间的间距 +const GAP: f64 = 12.0; + +/// 看护线程的最大看护时长。超过则自动收起,避免用户离开后弹窗长期滞留在屏幕上。 +const WATCH_MAX: Duration = Duration::from_secs(120); +/// 看护轮询间隔 +const WATCH_TICK: Duration = Duration::from_millis(40); + +/// 兜底创建路径:窗口是新建的,等前端 `onMounted` 调 ready 再显示 +static PENDING_SHOW: AtomicBool = AtomicBool::new(false); +/// 兜底路径下待投递的负载(前端 ready 时取走) +static PENDING_PAYLOAD: Mutex> = Mutex::new(None); +/// 弹窗当前是否可见(同步单一事实来源,hide 时立刻置 false) +static POPUP_VISIBLE: AtomicBool = AtomicBool::new(false); +/// 钉住状态:钉住后点击外部与超时都不再自动收起(只能手动关闭)。 +/// hide 时复位——收起即视为本次会话结束,下次打开回到默认自动收起。 +static PINNED: AtomicBool = AtomicBool::new(false); +/// 本次显示的光标锚点与落位偏好。resize 时沿用它重算位置, +/// 否则内容变高会从固定左上角往下长、越出工作区。 +#[derive(Clone, Copy)] +struct Anchor { + x: i32, + y: i32, + /// 优先落在光标右侧(否则左侧) + prefer_right: bool, + /// 优先落在光标下方(否则上方) + prefer_bottom: bool, +} + +static ANCHOR: Mutex> = Mutex::new(None); +/// 看护线程防重入 +static WATCHING: AtomicBool = AtomicBool::new(false); + +/// 面板候选条目(翻译面板打开时收集)。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PanelCandidate { + /// 候选文本 + pub text: String, + /// 来源标签:"划词" | "剪贴板"(展示用) + pub origin: String, +} + +/// 投递给悬浮窗前端的负载。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PopupPayload { + /// 待翻译文本(取词失败时为空串) + pub text: String, + /// 取词来源:"selection"(模拟 Ctrl+C)| "clipboard"(直接读剪贴板)| "screenshot"(截图)| "panel"(翻译面板) + pub source: String, + /// 取词失败原因(成功时为 None) + pub error: Option, + /// 来源窗口进程名(展示用) + pub process: String, + /// 头部补充说明(如「本地识别」「图片已上传至 xx」),比 source 更能回答用户关心的问题 + pub source_note: Option, + /// 来源窗口句柄(P3 回填替换选区用) + pub hwnd: i64, + /// 剪贴板是否已还原 + pub restored_clipboard: bool, + /// 目标语言(语言码;展示名由前端查语言表得出,避免两处维护同一张表) + pub to_lang: String, + /// 源语言("auto" 表示交给引擎判断)。 + /// + /// 弹窗必须能自己指定源语言:MyMemory 这类源**不支持自动检测**, + /// 而划词场景天然不知道原文是什么语言。没有它,这类源在划词里等于不可用。 + pub source_lang: String, + /// 默认引擎实例 id("auto" 表示按优先级) + pub engine_id: String, + /// 是否默认展开原文 + pub show_original: bool, + pub font_size: u32, + /// 正文字号之外的整窗不透明度百分比(100 = 不透明) + pub opacity: u32, + /// 优先落位:"bottom-right" | "bottom-left" | "top-right" | "top-left" + pub position_preference: String, + /// 显示后是否立即翻译。截图翻译关闭「自动翻译」时为 false:只展示识别文本, + /// 由用户决定是否发翻译请求。 + pub auto_start: bool, + /// 是否写入历史。预览悬浮窗为 false(固定样例文本,入库只是噪音)。 + pub record: bool, + /// 预置结果(视觉直译路径用):识别与翻译在 Rust 侧一步完成,弹窗直接展示, + /// 不再发第二次请求。为 None 时弹窗自行用 `text` 走翻译引擎。 + pub preset_result: Option, + /// 面板候选(仅 source == "panel"):划词结果与剪贴板首条。 + /// 为空时面板直接聚焦原文输入框,等用户手动输入。 + #[serde(default)] + pub candidates: Vec, +} + +// ===== 窗口生命周期 ===== + +/// 应用启动时预创建(隐藏)。首次按快捷键时窗口已就绪,直接显示,避免首次创建的时序问题。 +pub fn ensure_window(app: &AppHandle) { + if app.get_webview_window(POPUP_LABEL).is_some() { + return; + } + create_window(app); +} + +fn create_window(app: &AppHandle) { + let win = match WebviewWindowBuilder::new( + app, + POPUP_LABEL, + WebviewUrl::App("index.html#translate-popup".into()), + ) + .title("翻译") + .inner_size(POPUP_W, POPUP_BASE_H) + .position(-10000.0, -10000.0) // 屏幕外,避免隐藏状态下闪一下 + .decorations(false) + .transparent(true) + .shadow(true) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .visible(false) + .focused(false) // 不抢占焦点 + .effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build()) + .build() + { + Ok(w) => w, + Err(e) => { + crate::logger::log_error("translate", &format!("创建取词悬浮窗失败: {e}")); + return; + } + }; + + // 非激活:显示时不夺取前台。圆角:NOACTIVATE 悬浮窗系统不自动圆角,需显式指定, + // 否则与剪贴板弹窗外观不一致。 + #[cfg(windows)] + if let Ok(hwnd) = win.hwnd() { + crate::win32_util::apply_no_activate(hwnd.0 as isize); + crate::win32_util::apply_rounded_corners(hwnd.0 as isize); + } + + crate::logger::log_info("translate", "取词悬浮窗已预创建(隐藏状态)"); +} + +/// 显示悬浮窗并把负载投递给前端(锚点取当前光标位置)。 +pub fn show(app: &AppHandle, payload: PopupPayload) { + let Some((mx, my)) = get_cursor_pos() else { + return; + }; + // 先取锚点再移交 payload:show_with_anchor 会拿走所有权,参数求值顺序里 + // 「先 move 后借用」是编译错误 + let anchor = Anchor::from(mx, my, &payload.position_preference); + show_with_anchor(app, payload, anchor); +} + +/// 指定锚点显示(截图翻译用):结果要贴在**选区**旁,而截图流程里光标 +/// 已经离开了原位置,跟着光标走会飘到别处。 +pub fn show_at(app: &AppHandle, payload: PopupPayload, anchor_x: i32, anchor_y: i32) { + let anchor = Anchor::from(anchor_x, anchor_y, &payload.position_preference); + show_with_anchor(app, payload, anchor); +} + +fn show_with_anchor(app: &AppHandle, payload: PopupPayload, anchor: Anchor) { + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + // 用**当前实际尺寸**参与定位:上一次可能是长译文撑高的窗口, + // 恒按 200px 基础高度定位会让底部越界、等前端 resize 才跳回。 + // outer_size 含四周不可见边框(各约 8px),偏差方向是保守的。 + let scale = anchor.scale(); + let (w, h) = win + .outer_size() + .map(|s| { + ( + (s.width as f64 / scale).max(POPUP_W), + s.height as f64 / scale, + ) + }) + .unwrap_or((POPUP_W, POPUP_BASE_H)); + mark_shown(anchor); + let (x, y) = position_for(anchor, w, h); + let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y })); + reveal(&win); + let _ = app.emit(crate::constants::events::TRANSLATE_POPUP_SHOW, payload); + return; + } + + // 兜底:窗口被销毁过,重新创建并等前端 ready + PENDING_SHOW.store(true, Ordering::SeqCst); + if let Ok(mut slot) = PENDING_PAYLOAD.lock() { + *slot = Some(payload); + } + mark_shown(anchor); + create_window(app); +} + +/// 前端挂载完成后调用(仅兜底创建路径真正显示)。 +pub fn ready(app: &AppHandle) { + if !PENDING_SHOW.swap(false, Ordering::SeqCst) { + return; + } + let payload = PENDING_PAYLOAD.lock().ok().and_then(|mut slot| slot.take()); + let Some(win) = app.get_webview_window(POPUP_LABEL) else { + return; + }; + // 兜底路径窗口建在屏幕外,显示前必须按锚点定位,否则弹窗出现在屏幕外不可见 + if let Some(anchor) = ANCHOR.lock().ok().and_then(|a| *a) { + let (x, y) = position_for(anchor, POPUP_W, POPUP_BASE_H); + let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y })); + } + reveal(&win); + if let Some(p) = payload { + let _ = app.emit(crate::constants::events::TRANSLATE_POPUP_SHOW, p); + } +} + +/// 隐藏(保留复用,不销毁)。 +pub fn hide(app: &AppHandle) { + POPUP_VISIBLE.store(false, Ordering::SeqCst); + // 钉住状态随之复位:收起即视为本次会话结束 + PINNED.store(false, Ordering::SeqCst); + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + // 若本次显示期间进入过编辑模式(NOACTIVATE 被移除),在这里恢复, + // 保证下一次显示仍然不抢焦点 + #[cfg(windows)] + if let Ok(hwnd) = win.hwnd() { + crate::win32_util::set_no_activate(hwnd.0 as isize, true); + } + let _ = win.hide(); + // 以原生 SW_SHOWNOACTIVATE 显示的窗口,Tauri 内部可见性状态可能不同步, + // 补一次原生 SW_HIDE,确保任何路径下都被可靠隐藏。 + #[cfg(windows)] + if let Ok(hwnd) = win.hwnd() { + crate::win32_util::hide_window(hwnd.0 as isize); + } + } + let _ = app.emit(crate::constants::events::TRANSLATE_POPUP_HIDE, ()); +} + +/// 弹窗编辑模式开关:移除/恢复 WS_EX_NOACTIVATE,开启时把弹窗推到前台。 +/// +/// 弹窗默认是非激活窗口(不抢焦点),代价是收不到键盘事件——原文编辑框因此 +/// 无法输入。用户点击原文编辑区或译文卡片时前端调用 `enable=true`:移除 +/// NOACTIVATE 并把弹窗推到前台,编辑框即可获得键盘焦点、译文可 Ctrl+C。 +/// 弹窗隐藏([`hide`])与下次显示([`reveal`])都会恢复 NOACTIVATE。 +pub fn set_edit_mode(app: &AppHandle, enable: bool) -> Result<(), String> { + let Some(win) = app.get_webview_window(POPUP_LABEL) else { + return Err("弹窗不可用".to_string()); + }; + #[cfg(windows)] + { + let Ok(hwnd) = win.hwnd() else { + return Err("无法获取弹窗句柄".to_string()); + }; + crate::win32_util::set_no_activate(hwnd.0 as isize, !enable); + if enable { + // 用户刚点击了弹窗(本进程持有最新输入),force_foreground 可绕过前台锁定 + crate::win32_util::force_foreground(hwnd.0 as isize); + } + } + #[cfg(not(windows))] + let _ = enable; + Ok(()) +} + +/// 按内容尺寸自适应(前端测高后调用),返回前端根节点应使用的 max-height。 +/// +/// 返回值 = 光标所在显示器工作区允许的最大内容高度(逻辑像素,0 表示不限制)。 +/// **刻意来自工作区而不是窗口自身高度**:前端若用 100vh 当上限,窗口缩 → vh 缩 → +/// 测得高度缩 → 再缩窗口,形成收缩反馈循环(实测窗口一路缩到 MIN_H)。 +/// 工作区是稳定值,前端把它设为根节点 max-height 后整个测量回路收敛。 +/// +/// 其余行为:**只在越界时钳制,绝不重排回打开时的锚点**。历史实现每次 resize 都按 +/// `ANCHOR`(打开时的光标位置)重算并 `set_position`——窗口尺寸因内容变化而 +/// 调整时,会把用户拖动后的位置覆盖掉(实测:钉住面板拖到别处,粘贴内容 +/// 触发 resize 又跳回原位)。现在以窗口**当前位置**为基准:内容变高向 +/// 下/向右生长越出工作区时,才把位置钳回来。 +pub fn resize(app: &AppHandle, width: f64, height: f64) -> f64 { + let Some(win) = app.get_webview_window(POPUP_LABEL) else { + return 0.0; + }; + if !POPUP_VISIBLE.load(Ordering::SeqCst) { + return 0.0; + } + let w = width.clamp(MIN_W, MAX_W); + let h = height.clamp(MIN_H, MAX_H); + let Ok(pos) = win.outer_position() else { + return 0.0; + }; + let scale = scale_for(pos.x, pos.y); + let mut w_px = (w * scale).round() as i32; + let mut h_px = (h * scale).round() as i32; + let mut max_inner_logical = 0.0f64; + let work_area = get_work_area_at_point(pos.x, pos.y); + // 小屏适配:窗口尺寸不得超过所在显示器的工作区(留边距) + if let Some((left, top, right, bottom)) = work_area { + let max_h_px = (bottom - top - 16).max(200); + let max_w_px = (right - left - 16).max(200); + h_px = h_px.min(max_h_px); + w_px = w_px.min(max_w_px); + max_inner_logical = max_h_px as f64 / scale; + } + let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize { + width: w_px.max(1) as u32, + height: h_px.max(1) as u32, + })); + // 越界才钳制:不越界时保持用户拖动后的位置不动 + if let Some((left, top, right, bottom)) = work_area { + let x = pos.x.clamp(left, (right - w_px).max(left)); + let y = pos.y.clamp(top, (bottom - h_px).max(top)); + if x != pos.x || y != pos.y { + let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y })); + } + } + max_inner_logical +} + +fn reveal(win: &tauri::WebviewWindow) { + // 每次显示都重置为「不激活」:上一次会话可能以编辑模式结束(NOACTIVATE 被移除) + #[cfg(windows)] + if let Ok(hwnd) = win.hwnd() { + crate::win32_util::set_no_activate(hwnd.0 as isize, true); + } + // 不激活显示:保持用户当前窗口的前台状态与选区 + #[cfg(windows)] + if let Ok(hwnd) = win.hwnd() { + crate::win32_util::show_no_activate(hwnd.0 as isize); + } + #[cfg(not(windows))] + let _ = win.show(); + spawn_click_outside_watch(win.app_handle().clone()); +} + +impl Anchor { + fn from(x: i32, y: i32, preference: &str) -> Self { + match preference { + "bottom-left" => Self { + x, + y, + prefer_right: false, + prefer_bottom: true, + }, + "top-right" => Self { + x, + y, + prefer_right: true, + prefer_bottom: false, + }, + "top-left" => Self { + x, + y, + prefer_right: false, + prefer_bottom: false, + }, + // "bottom-right" 与未知值:落回默认(右下),与设置页文案一致 + _ => Self { + x, + y, + prefer_right: true, + prefer_bottom: true, + }, + } + } + + fn scale(&self) -> f64 { + scale_for(self.x, self.y) + } +} + +fn mark_shown(anchor: Anchor) { + POPUP_VISIBLE.store(true, Ordering::SeqCst); + if let Ok(mut slot) = ANCHOR.lock() { + *slot = Some(anchor); + } +} + +fn scale_for(x: i32, y: i32) -> f64 { + get_dpi_for_point(x, y).unwrap_or(96) as f64 / 96.0 +} + +/// 按锚点与落位偏好算出弹窗左上角(物理像素)。 +/// +/// 两级处理,意义不同:先按偏好落位,放不下时**翻到另一侧**(处理常见情况, +/// 例如光标贴着屏幕右缘);最后再钳制到工作区(兜住极端情况,例如窗口比屏幕还宽)。 +fn position_for(anchor: Anchor, w: f64, h: f64) -> (i32, i32) { + let (left, top, right, bottom) = + get_work_area_at_point(anchor.x, anchor.y).unwrap_or((0, 0, 1920, 1040)); + let scale = anchor.scale(); + let w_px = w * scale; + let h_px = h * scale; + + let mut x = if anchor.prefer_right { + anchor.x as f64 + GAP + } else { + anchor.x as f64 - GAP - w_px + }; + if anchor.prefer_right && x + w_px > right as f64 { + x = anchor.x as f64 - GAP - w_px; + } else if !anchor.prefer_right && x < left as f64 { + x = anchor.x as f64 + GAP; + } + + let mut y = if anchor.prefer_bottom { + anchor.y as f64 + GAP + } else { + anchor.y as f64 - GAP - h_px + }; + if anchor.prefer_bottom && y + h_px > bottom as f64 { + y = anchor.y as f64 - GAP - h_px; + } else if !anchor.prefer_bottom && y < top as f64 { + y = anchor.y as f64 + GAP; + } + + let min_x = left as f64; + let max_x = (right as f64 - w_px).max(min_x); + let min_y = top as f64; + let max_y = (bottom as f64 - h_px).max(min_y); + ( + x.clamp(min_x, max_x).round() as i32, + y.clamp(min_y, max_y).round() as i32, + ) +} + +/// 设置钉住状态(前端钉住按钮调用)。 +pub fn set_pinned(pinned: bool) { + PINNED.store(pinned, Ordering::SeqCst); +} + +/// 点击弹窗外部即收起。 +/// +/// 为什么不用 `Focused(false)`:本窗口是 NOACTIVATE 的,永远不会获得焦点, +/// 也就永远收不到失焦事件。只能主动轮询「左键按下沿是否发生在窗口外」。 +/// **钉住时整条自动收起路径失效**(点击外部与超时都不收),只能手动关闭。 +fn spawn_click_outside_watch(app: AppHandle) { + if WATCHING.swap(true, Ordering::SeqCst) { + return; + } + std::thread::spawn(move || { + let start = Instant::now(); + let mut was_down = false; + loop { + std::thread::sleep(WATCH_TICK); + if !POPUP_VISIBLE.load(Ordering::SeqCst) + || app.get_webview_window(POPUP_LABEL).is_none() + { + break; + } + let pinned = PINNED.load(Ordering::SeqCst); + if start.elapsed() > WATCH_MAX && !pinned { + hide(&app); + break; + } + let down = crate::win32_util::is_left_button_down(); + // 按下沿判定:上一轮未按下、本轮按下,且落点在窗口外 + if down && !was_down && !pinned && !cursor_in_popup(&app) { + hide(&app); + break; + } + was_down = down; + } + WATCHING.store(false, Ordering::SeqCst); + }); +} + +fn cursor_in_popup(app: &AppHandle) -> bool { + let Some(win) = app.get_webview_window(POPUP_LABEL) else { + return false; + }; + let (Ok(pos), Ok(size)) = (win.outer_position(), win.outer_size()) else { + return false; + }; + let Some((cx, cy)) = get_cursor_pos() else { + return false; + }; + cx >= pos.x && cx <= pos.x + size.width as i32 && cy >= pos.y && cy <= pos.y + size.height as i32 +} + +// ===== 快捷键与翻译面板入口 ===== + +/// 按当前设置注册/注销「翻译面板」全局快捷键(默认 Ctrl+2)。 +/// +/// 面板打开时会尝试读取当前划词(若「启用划词翻译」)与剪贴板首条文本作为候选, +/// 覆盖了旧版「翻译取词 Alt+T」「翻译剪贴板 Alt+Shift+T」两个入口的全部场景; +/// 那两个快捷键因此移除,这里顺带注销其历史注册(升级后首次应用设置时清理)。 +/// +/// 设置项为空串即注销(`register_shortcut` 的既定语义)。 +pub fn apply_shortcuts(app: &AppHandle) -> Result<(), String> { + let Some(manager) = app.try_state::() else { + return Ok(()); + }; + let panel_key = manager.load_settings().selection.panel_shortcut.clone(); + + // 旧版入口已不存在,显式清理历史注册,释放被 Alt+T / Alt+Shift+T 占用的组合键 + crate::shortcut::unregister_shortcut(app, "翻译取词"); + crate::shortcut::unregister_shortcut(app, "翻译剪贴板"); + + let mut errors: Vec = Vec::new(); + let r = crate::shortcut::register_shortcut(app, "翻译面板", &panel_key, |handle| { + let handle = handle.clone(); + // 收集候选含最长约 1s 的阻塞等待,绝不能跑在快捷键回调线程上 + std::thread::spawn(move || on_panel_shortcut(&handle)); + }); + if let Err(e) = r { + errors.push(e); + } + + // 快捷键生效就顺手预创建窗口,避免首次触发时现建 WebView + if !panel_key.trim().is_empty() { + ensure_window(app); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(";")) + } +} + +/// 面板候选:划词(若启用且非 clipboard 模式)+ 剪贴板首条文本(去重)。 +/// +/// 顺序执行而非并行:兼容路径取词会临时占用剪贴板,并行读会拿到中间状态。 +/// 取词失败静默跳过——面板里还有剪贴板候选与手动输入,失败提示反而碍事 +/// (与旧取词路径「失败也弹窗」不同,这里失败不阻断打开面板)。 +fn collect_candidates(app: &AppHandle) -> Vec { + let Some(manager) = app.try_state::() else { + return Vec::new(); + }; + let settings = manager.load_settings(); + let sel = settings.selection.clone(); + let mut out: Vec = Vec::new(); + + if sel.enabled && sel.mode.trim() != "clipboard" { + let req = CaptureRequest { + max_chars: sel.max_chars as usize, + restore_clipboard: sel.restore_clipboard, + blacklist: sel.blacklist.clone(), + mode: sel.mode.clone(), + }; + if let Ok(outcome) = capture::capture_selection(app, &req) { + if !outcome.text.trim().is_empty() { + out.push(PanelCandidate { + text: outcome.text, + origin: "划词".to_string(), + }); + } + } + } + + // 只读不写:不模拟按键,终端里的 Ctrl+C 不受影响 + if let Some(ClipData::Text(t)) = read_clipboard() { + let t = t.trim().to_string(); + if !t.is_empty() && !out.iter().any(|c| c.text == t) { + out.push(PanelCandidate { + text: t, + origin: "剪贴板".to_string(), + }); + } + } + out +} + +fn on_panel_shortcut(app: &AppHandle) { + // 已显示:保持原状态(内容不动),只把它带到前台恢复键盘输入 + if POPUP_VISIBLE.load(Ordering::SeqCst) { + focus_panel(app); + return; + } + + let Some(manager) = app.try_state::() else { + return; + }; + let settings = manager.load_settings(); + // 候选必须在面板抢焦点**之前**收集:取词(UIA / 模拟按键)都作用在 + // 当时的前台目标应用上,面板一旦激活,取到的就只剩面板自己了。 + // 代价是按下快捷键到面板出现要等一次取词(UIA 命中时毫秒级,最差约 1s)。 + let candidates = collect_candidates(app); + // 收集期间用户可能已通过其他方式打开了面板(如再次连按):不再覆盖 + if POPUP_VISIBLE.load(Ordering::SeqCst) { + focus_panel(app); + return; + } + let mut payload = + build_payload(&settings, String::new(), "panel", None, String::new(), 0, false); + // 面板不自动翻译:原文空着,等用户选候选或手动输入 + payload.auto_start = false; + payload.candidates = candidates; + show(app, payload); + // 面板需要键盘输入(候选导航、原文编辑),显示后立即取得焦点 + focus_panel(app); +} + +/// 把弹窗带到前台并恢复键盘输入(移除 NOACTIVATE + 强制前台)。 +fn focus_panel(app: &AppHandle) { + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + #[cfg(windows)] + if let Ok(hwnd) = win.hwnd() { + crate::win32_util::set_no_activate(hwnd.0 as isize, false); + crate::win32_util::force_foreground(hwnd.0 as isize); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn build_payload( + settings: &TranslateSettings, + text: String, + source: &str, + error: Option, + process: String, + hwnd: i64, + restored_clipboard: bool, +) -> PopupPayload { + build_payload_with( + settings, + text, + source, + error, + process, + hwnd, + restored_clipboard, + None, + None, + ) +} + +/// 组装弹窗负载。`source_note` / `preset_result` 只有截图链路会用到。 +#[allow(clippy::too_many_arguments)] +fn build_payload_with( + settings: &TranslateSettings, + text: String, + source: &str, + error: Option, + process: String, + hwnd: i64, + restored_clipboard: bool, + source_note: Option, + preset_result: Option, +) -> PopupPayload { + PopupPayload { + text, + source: source.to_string(), + error, + process, + source_note, + hwnd, + restored_clipboard, + to_lang: settings + .popup + .effective_target_lang(&settings.default_target) + .to_string(), + source_lang: settings.popup.effective_source_lang().to_string(), + engine_id: settings.default_engine_id.clone(), + show_original: settings.popup.show_original, + font_size: settings.popup.font_size, + opacity: settings.popup.opacity, + position_preference: settings.popup.position_preference.clone(), + auto_start: true, + // 预览(source == "preview")用固定样例文本走链路,入库只是噪音 + record: source != "preview", + preset_result, + candidates: Vec::new(), + } +} + +/// 预览用负载:用一段固定文本走完整链路(窗口定位、非激活显示、翻译、复制)。 +/// +/// 存在的理由:**从应用内无法真正测试取词**——测试时前台窗口是本应用自己, +/// Ctrl+C 只会落到一个没有选区的窗口上。所以就"验证悬浮窗这条链路"而言, +/// 预览比伪造一次取词更有意义,也让用户不必离开设置页去试。 +pub fn preview_payload(settings: &TranslateSettings) -> PopupPayload { + build_payload( + settings, + "The quick brown fox jumps over the lazy dog.".to_string(), + "preview", + None, + String::new(), + 0, + false, + ) +} + +/// 截图翻译的结果弹窗入口(锚点为选区右下角,结果贴在选区旁)。 +/// +/// `preset_result` 为 Some 时表示识别与翻译已在 Rust 侧一步完成(视觉直译), +/// 弹窗只负责展示;为 None 时弹窗用 `text`(OCR 文本)自行走翻译引擎。 +#[allow(clippy::too_many_arguments)] +pub fn show_screenshot_result( + app: &AppHandle, + settings: &TranslateSettings, + text: String, + source_note: Option, + preset_result: Option, + auto_start: bool, + anchor_x: i32, + anchor_y: i32, +) { + let mut payload = build_payload( + settings, + text, + "screenshot", + None, + String::new(), + 0, + false, + ); + payload.source_note = source_note; + payload.preset_result = preset_result; + payload.auto_start = auto_start; + show_at(app, payload, anchor_x, anchor_y); +} + +/// 截图链路的失败弹窗:识别失败也弹窗,用户视线就在选区那里。 +pub fn show_screenshot_error( + app: &AppHandle, + settings: &TranslateSettings, + message: String, + anchor_x: i32, + anchor_y: i32, +) { + let mut payload = build_payload( + settings, + String::new(), + "screenshot", + Some(message), + String::new(), + 0, + false, + ); + payload.auto_start = false; + show_at(app, payload, anchor_x, anchor_y); +} diff --git a/src-tauri/src/translate/settings.rs b/src-tauri/src/translate/settings.rs new file mode 100644 index 0000000..44ff9a9 --- /dev/null +++ b/src-tauri/src/translate/settings.rs @@ -0,0 +1,400 @@ +//! 翻译模块设置的数据模型与默认值。 +//! +//! 持久化位置:`{app_data_dir}/translate/settings.json`(与音乐模块同一范式)。 +//! 容器级 `#[serde(default)]`:新增字段对旧配置文件是**向后兼容**的——缺字段取 +//! 默认值而不是让整份设置反序列化失败,避免用户因为一次升级丢掉全部配置。 +//! +//! 安全姿态:**API Key 不在这里**。本结构只允许出现 `hasApiKey` 这类派生展示字段 +//! (且由命令层回填,不落盘),明文一律进系统凭据管理器,见 [`super::engine_secret_key`]。 + +use serde::{Deserialize, Serialize}; +use specta::Type; + +use super::engines::TranslateMode; + +/// 提示词模板。`{target}` 为占位符,翻译时替换为目标语言的自然语言全称 +/// (用全称而非语言码,模型遵循度明显更高)。 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct PromptTemplates { + /// 纯翻译(默认模式) + pub translate: String, + /// 润色(P3) + pub polish: String, + /// 解释(P3) + pub explain: String, + /// 总结(P3) + pub summarize: String, +} + +impl Default for PromptTemplates { + fn default() -> Self { + Self { + translate: "你是专业翻译引擎。将用户内容翻译为{target}。\n\ + 要求:只输出译文,不解释、不加引号、不保留原文;保持术语、代码片段、\ + 专有名词、数字与原有格式(Markdown / 换行 / 列表)不变;\ + 若原文已经是{target},则原样返回。" + .to_string(), + polish: + "你是专业文字编辑。将用户内容改写为更地道、通顺的{target},保持原意与信息量不变。\ + 只输出改写结果,不解释。" + .to_string(), + explain: + "你是专业讲解者。用{target}解释用户给出的内容:先说要点,再说明背景与可能的歧义。\ + 保持简洁,不要复述原文。" + .to_string(), + summarize: + "你是专业摘要助手。用{target}总结用户给出的内容,保留关键事实、数字与结论,\ + 去掉冗余表述。只输出摘要。" + .to_string(), + } + } +} + +impl PromptTemplates { + /// 取指定模式的模板(未填写时退回默认模板)。 + pub fn for_mode(&self, mode: TranslateMode) -> &str { + let candidate = match mode { + TranslateMode::Translate => &self.translate, + TranslateMode::Polish => &self.polish, + TranslateMode::Explain => &self.explain, + TranslateMode::Summarize => &self.summarize, + }; + if candidate.trim().is_empty() { + self.translate.as_str() + } else { + candidate.as_str() + } + } +} + +/// 单个翻译引擎实例的配置。 +/// +/// 「实例」而非「类型」:同一类型可以配置多份(例如官方 API 与本地 Ollama 并存), +/// 每份有自己的 id / 优先级 / 模型与参数。 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct TranslateEngineConfig { + /// 实例唯一标识(同时是凭据键的一部分,创建后不建议修改) + pub id: String, + /// 展示名称 + pub name: String, + /// 引擎类型:"ai"(OpenAI 兼容)| "free"(免密钥网络源)| "cloud"(需签名的云厂商,P3) + pub kind: String, + /// 预设标识:"deepseek" | "openai" | "ollama" | "custom" | "libretranslate" | "mymemory" + pub preset: String, + /// 是否参与「自动」模式的候选 + pub enabled: bool, + /// 优先级(数值越小越先尝试;自动模式下失败按序降级到下一个) + pub priority: i32, + /// API 根地址,如 https://api.deepseek.com(末尾可有可无 /,不可含 /chat/completions) + pub base_url: String, + /// 模型标识,如 deepseek-flash + pub model: String, + /// 采样温度(翻译建议 0.2~0.3) + pub temperature: f64, + /// 单次请求最大输出 token + pub max_tokens: u32, + /// 请求超时(毫秒) + pub timeout_ms: u64, + /// 自定义 system prompt;**非空时覆盖模式模板**(留空表示用全局模板) + pub system_prompt: String, + /// 额外请求体字段的 JSON 文本(用于 thinking / enable_thinking 这类非标准参数); + /// 用文本而非结构化字段:一是前端直接给文本域,二是避免把任意 JSON 塞进类型绑定。 + pub extra_body: Option, + /// 是否支持图像输入(截图翻译的「视觉直译」模式只发给勾选了此项的引擎)。 + /// 默认 false:多模态模型与文本模型的计费和端点约束不同,宁可让用户显式勾选。 + #[serde(default)] + pub supports_vision: bool, +} + +impl Default for TranslateEngineConfig { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + kind: "ai".to_string(), + preset: "custom".to_string(), + enabled: true, + priority: 100, + base_url: String::new(), + model: String::new(), + temperature: 0.3, + max_tokens: 4096, + timeout_ms: 30_000, + system_prompt: String::new(), + extra_body: None, + supports_vision: false, + } + } +} + +impl TranslateEngineConfig { + /// 内置 DeepSeek 预设:默认引擎。模型标识取现行名 `deepseek-flash` + /// (`deepseek-chat` / `deepseek-reasoner` 已于 2026-07-24 停用;`deepseek-v4-flash` + /// 等旧名虽仍被受理,但请求已被路由到 V4.1-Flash,不适合再写进默认配置)。 + pub fn deepseek_default() -> Self { + Self { + id: "deepseek".to_string(), + name: "DeepSeek Flash".to_string(), + kind: "ai".to_string(), + preset: "deepseek".to_string(), + enabled: true, + priority: 10, + base_url: "https://api.deepseek.com".to_string(), + model: "deepseek-flash".to_string(), + temperature: 0.3, + max_tokens: 4096, + timeout_ms: 30_000, + // 翻译场景用非思考模式:不写 thinking / reasoning_effort,换低延迟低费用 + system_prompt: String::new(), + extra_body: None, + supports_vision: false, + } + } +} + +/// 划词翻译设置(P1 生效;结构在 P0 即定型,避免 P2 改数据结构)。 +/// +/// P2 重构说明:原「取词快捷键 Alt+T」「翻译剪贴板 Alt+Shift+T」已移除, +/// 统一为「翻译面板」快捷键(`panel_shortcut`)——面板打开时自动尝试读取 +/// 当前划词与剪贴板首条作为候选,覆盖了原来两个入口的全部场景。 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct SelectionSettings { + /// 总开关:打开翻译面板时是否尝试读取当前划词作为候选 + pub enabled: bool, + /// 打开翻译面板的全局快捷键(默认 Ctrl+2) + #[serde(default = "default_panel_shortcut")] + pub panel_shortcut: String, + /// 取词方式: + /// - "smart"(默认):先用 UIA 直读选区,读不到再退回模拟 Ctrl+C。不碰剪贴板, + /// 因此不受「目标窗口提权」「Alt 仍被按住导致 Ctrl+C 变成 Alt+Ctrl+C」这两类问题影响。 + /// - "compat":只用模拟 Ctrl+C(覆盖最广,但会短暂占用剪贴板)。 + /// - "clipboard":不取词,面板只提供剪贴板首条作为候选。 + pub mode: String, + /// 取词后是否还原剪贴板(关掉则保留选中文本) + pub restore_clipboard: bool, + /// 单次取词的字符数上限。超出直接拒绝并提示:划词误选整篇文档时, + /// 发一个几万字的请求既慢又费钱,不如让用户明确知道发生了什么。 + pub max_chars: u32, + /// 取词跳过的进程名黑名单(终端类 Ctrl+C 是中断信号,必须排除) + pub blacklist: Vec, +} + +fn default_panel_shortcut() -> String { + "Ctrl+2".to_string() +} + +impl Default for SelectionSettings { + fn default() -> Self { + Self { + // P0 未实现取词,默认关闭以免给出「已开启但无反应」的错误预期 + enabled: false, + panel_shortcut: default_panel_shortcut(), + // 默认「智能」:UIA 直读优先,读不到再退回模拟 Ctrl+C + mode: "smart".to_string(), + restore_clipboard: true, + max_chars: 2000, + blacklist: vec![ + "WindowsTerminal.exe".to_string(), + "conhost.exe".to_string(), + "mintty.exe".to_string(), + "OpenConsole.exe".to_string(), + ], + } + } +} + +/// 划词结果悬浮窗外观(P1 生效)。 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct PopupSettings { + /// 正文字号(逻辑像素) + pub font_size: u32, + /// 不透明度百分比(100 = 不透明) + pub opacity: u32, + /// 优先落位:"bottom-right" | "bottom-left" | "top-right" | "top-left" + pub position_preference: String, + /// 是否默认展开原文 + pub show_original: bool, + /// 划词弹窗的源语言("auto" 表示交给引擎判断)。 + /// + /// 存在的理由:MyMemory 这类源**不支持自动检测**,而划词场景天然不知道源语言。 + /// 与其让弹窗永远传 auto、把这类源判成「不可用」,不如让用户在这里定一次并记住。 + #[serde(default = "default_source_lang")] + pub source_lang: String, + /// 划词弹窗的目标语言。空串表示跟随全局「默认目标语言」。 + #[serde(default)] + pub target_lang: String, +} + +fn default_source_lang() -> String { + "auto".to_string() +} + +impl Default for PopupSettings { + fn default() -> Self { + Self { + font_size: 13, + opacity: 100, + position_preference: "bottom-right".to_string(), + show_original: true, + source_lang: default_source_lang(), + target_lang: String::new(), + } + } +} + +impl PopupSettings { + /// 生效的源语言:空串(旧配置)按 auto 处理。 + pub fn effective_source_lang(&self) -> &str { + let v = self.source_lang.trim(); + if v.is_empty() { + "auto" + } else { + v + } + } + + /// 生效的目标语言:未单独设置时跟随全局默认。 + pub fn effective_target_lang<'a>(&'a self, fallback: &'a str) -> &'a str { + let v = self.target_lang.trim(); + if v.is_empty() { + fallback + } else { + v + } + } +} + +/// 截图翻译设置。 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct ScreenshotSettings { + /// 识别方式:"windows"(Windows.Media.Ocr,本地离线,默认)| "vision"(视觉模型直译) + pub ocr_mode: String, + /// OCR 语言:"auto" 或 BCP-47 标签(如 en-US / zh-Hans-CN) + pub ocr_lang: String, + /// 框选完成后是否自动翻译(关闭则只识别,译文由用户在悬浮窗手动触发) + pub auto_translate: bool, +} + +impl Default for ScreenshotSettings { + fn default() -> Self { + Self { + ocr_mode: "windows".to_string(), + ocr_lang: "auto".to_string(), + auto_translate: true, + } + } +} + +/// 翻译历史设置(P2 生效)。 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct HistorySettings { + pub enabled: bool, + /// 保留条数上限(超出按时间淘汰) + pub max_items: u32, +} + +impl Default for HistorySettings { + fn default() -> Self { + Self { + enabled: true, + max_items: 2000, + } + } +} + +/// 翻译模块设置根结构。 +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct TranslateSettings { + /// 结构版本号(用于后续迁移判断) + pub version: u32, + /// 默认目标语言(内部代码,如 zh-Hans) + pub default_target: String, + /// 默认引擎实例 id("auto" 表示按优先级自动降级) + pub default_engine_id: String, + /// 自动模式下的失败降级开关 + pub auto_fallback: bool, + /// 翻译请求是否走代理模块(mihomo mixed 端口)。 + /// **默认关闭**:自建 LibreTranslate 在境内、MyMemory 与 DeepSeek 均可直连, + /// 不需要代理;仅当翻译服务部署在境外或直连被拦截时由用户显式打开。 + pub use_proxy: bool, + /// 引擎实例列表 + pub engines: Vec, + pub selection: SelectionSettings, + pub popup: PopupSettings, + pub screenshot: ScreenshotSettings, + pub history: HistorySettings, + pub prompt_templates: PromptTemplates, +} + +impl Default for TranslateSettings { + fn default() -> Self { + Self { + version: 1, + default_target: "zh-Hans".to_string(), + default_engine_id: "deepseek".to_string(), + auto_fallback: true, + use_proxy: false, + engines: vec![TranslateEngineConfig::deepseek_default()], + selection: SelectionSettings::default(), + popup: PopupSettings::default(), + screenshot: ScreenshotSettings::default(), + history: HistorySettings::default(), + prompt_templates: PromptTemplates::default(), + } + } +} + +impl TranslateSettings { + /// 按 id 找引擎实例。 + pub fn engine(&self, id: &str) -> Option<&TranslateEngineConfig> { + self.engines.iter().find(|e| e.id == id) + } + + /// 参与「自动」模式的引擎,按优先级升序。 + pub fn auto_candidates(&self) -> Vec<&TranslateEngineConfig> { + let mut list: Vec<&TranslateEngineConfig> = self.engines.iter().filter(|e| e.enabled).collect(); + list.sort_by_key(|e| e.priority); + list + } + + /// 自愈:列表为空时补回 DeepSeek 默认实例;默认引擎 id 失效时回落到第一个可用实例。 + /// 返回是否发生了变更(由调用方决定落盘)。 + pub fn heal(&mut self) -> bool { + let mut changed = false; + if self.engines.is_empty() { + self.engines.push(TranslateEngineConfig::deepseek_default()); + changed = true; + } + if self.default_target.trim().is_empty() { + self.default_target = "zh-Hans".to_string(); + changed = true; + } + if self.default_engine_id.trim().is_empty() { + self.default_engine_id = "auto".to_string(); + changed = true; + } + if self.default_engine_id != "auto" && self.engine(&self.default_engine_id).is_none() { + // 指向的实例已被删除:不要静默改写成某个实例,改为「自动」更符合用户预期 + self.default_engine_id = "auto".to_string(); + changed = true; + } + // v1 → v2:取词方式默认值从 "compat" 改为 "smart"(UIA 直读已实现)。 + // v1 里这个字段没有任何 UI 入口,用户不可能主动选过它,因此直接迁移是安全的; + // 迁移后用户再选 "compat" 会被原样保留(版本号已推进,不会再被改写)。 + if self.version < 2 { + if self.selection.mode.trim() == "compat" { + self.selection.mode = "smart".to_string(); + } + self.version = 2; + changed = true; + } + changed + } +} diff --git a/src-tauri/src/win32_util.rs b/src-tauri/src/win32_util.rs index 5d40851..35ebe23 100644 --- a/src-tauri/src/win32_util.rs +++ b/src-tauri/src/win32_util.rs @@ -137,6 +137,30 @@ pub fn apply_no_activate(hwnd: isize) { } } +/// 按需添加/移除 WS_EX_NOACTIVATE(不动 WS_EX_TOOLWINDOW)。 +/// +/// 取词悬浮窗默认不激活;但弹窗里的原文编辑框需要键盘焦点——NOACTIVATE 窗口 +/// 永远拿不到焦点,根本无法输入。用户点击编辑区时移除该样式并强制激活 +/// (见 [`force_foreground`]),弹窗隐藏时恢复,保证下一次划词仍不抢焦点。 +#[cfg(windows)] +pub fn set_no_activate(hwnd: isize, no_activate: bool) { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE, + }; + unsafe { + let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + let new_ex = if no_activate { + ex | (WS_EX_NOACTIVATE as isize) + } else { + ex & !(WS_EX_NOACTIVATE as isize) + }; + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, new_ex); + } +} + +#[cfg(not(windows))] +pub fn set_no_activate(_hwnd: isize, _no_activate: bool) {} + /// 鼠标左键当前是否按下(GetAsyncKeyState,全局异步状态,无需窗口焦点)。 /// 供看护线程轮询检测"点击外部"(配合按下沿判定)。 #[cfg(windows)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0d5726f..91f4a25 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "thing", - "version": "26.9.2", + "version": "26.9.3", "identifier": "thing.lfeng.me", "build": { "beforeDevCommand": "bun run dev", @@ -24,7 +24,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src ipc: http://ipc.localhost; media-src 'self' data: blob:" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost http://127.0.0.1:*; font-src 'self' data:; connect-src ipc: http://ipc.localhost; media-src 'self' data: blob: http://127.0.0.1:*" } }, "bundle": { diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 4d48c62..21f3d6c 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -355,6 +355,158 @@ export const commands = { screenshotLoadCache: (path: string) => __TAURI_INVOKE("screenshot_load_cache", { path }), /** 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件) */ screenshotDeleteCache: (path: string) => __TAURI_INVOKE("screenshot_delete_cache", { path }), + /** 读取翻译设置 */ + translateGetSettings: () => __TAURI_INVOKE("translate_get_settings"), + /** + * 保存翻译设置。 + * + * 保存后立即重新应用全局快捷键:快捷键改动若不能即时生效,用户会以为「设置没保存」, + * 而重新注册本身是幂等的(内部先注销旧的)。快捷键注册失败**不当作保存失败**—— + * 设置已经落盘了,把失败原因作为返回的错误信息告知即可。 + */ + translateSaveSettings: (settings: TranslateSettings) => __TAURI_INVOKE("translate_save_settings", { settings }), + /** 按当前设置重新注册全局快捷键(设置页改动后或启动时调用) */ + translateApplyShortcuts: () => __TAURI_INVOKE("translate_apply_shortcuts"), + /** 列出引擎实例(含密钥状态与可用性) */ + translateEnginesList: () => __TAURI_INVOKE("translate_engines_list"), + /** + * 新增或更新一个引擎实例(按 id upsert)。 + * + * 校验 id:它会进入凭据键(`translate-engine-`),含特殊字符会让键名难以排查, + * 因此限定为字母/数字/下划线/短横线。 + */ + translateEngineSave: (config: TranslateEngineConfig) => __TAURI_INVOKE("translate_engine_save", { config }), + /** 删除引擎实例(连同它在系统凭据管理器中的密钥)。 */ + translateEngineDelete: (id: string) => __TAURI_INVOKE("translate_engine_delete", { id }), + /** 写入引擎的 API Key(明文只在此处进入系统凭据管理器,永不回写配置文件)。 */ + translateSecretSet: (id: string, apiKey: string) => __TAURI_INVOKE("translate_secret_set", { id, apiKey }), + /** 清除引擎的 API Key */ + translateSecretClear: (id: string) => __TAURI_INVOKE("translate_secret_clear", { id }), + /** + * 连通性自检:用前端传入的配置测试(**不落盘**)。 + * + * 与「按 id 测已保存配置」的区别:编辑草稿里的 Base URL / 模型改动还没保存时, + * 按 id 测的是旧配置——用户改完地址立刻点「测试」会得到「未配置服务地址」, + * 让人以为自己的输入没生效。这里直接收草稿配置,让「改完立刻测」成立; + * API Key 仍按 `config.id` 从系统凭据管理器读取(引擎需先保存过一次才能配 Key)。 + */ + translateEngineTestConfig: (config: TranslateEngineConfig) => __TAURI_INVOKE("translate_engine_test_config", { config }), + /** + * 拉取上游模型列表(应对「上游改名」导致预设失效的自救入口)。 + * 免密钥源没有模型概念,返回空列表。 + */ + translateEngineModels: (id: string) => __TAURI_INVOKE("translate_engine_models", { id }), + /** + * 执行一次翻译。 + * + * 错误以结构化的 [`TranslateError`] 返回,前端按 `kind` 分支给出不同提示 + * (改 Key / 查网络 / 稍后重试是三件不同的事)。 + */ + translateRun: (params: TranslateRunParams) => __TAURI_INVOKE("translate_run", { params }), + /** + * 把文本写入系统剪贴板(供主面板与取词悬浮窗复制译文)。 + * 写入会登记进剪贴板抑制表,因此不会在剪贴板历史里留下一条「自己复制自己」的记录。 + */ + translateCopyText: (text: string) => __TAURI_INVOKE("translate_copy_text", { text }), + /** + * 预览取词悬浮窗:用固定样例文本走一遍完整链路(窗口定位、非激活显示、翻译)。 + * + * 为什么不提供「测试取词」按钮:从应用内触发取词必然失败——前台窗口是本应用自己, + * Ctrl+C 会落到一个没有选区的窗口上。要验证取词只能去别的应用里按快捷键。 + */ + translatePreviewPopup: () => __TAURI_INVOKE("translate_preview_popup"), + /** 悬浮窗挂载完成(仅兜底创建路径真正显示) */ + translatePopupReady: () => __TAURI_INVOKE("translate_popup_ready"), + /** 隐藏悬浮窗 */ + translatePopupHide: () => __TAURI_INVOKE("translate_popup_hide"), + /** + * 弹窗编辑模式开关(原文编辑框 / 译文选中复制需要键盘焦点)。 + * + * 弹窗平时是 NOACTIVATE 的(不抢焦点、收不到键盘事件);用户点击原文编辑区时 + * 前端请求 `enable=true`,临时移除 NOACTIVATE 并把弹窗推到前台。隐藏与下次显示 + * 时由后端自动恢复,不依赖前端记得关。 + */ + translatePopupEditMode: (enable: boolean) => __TAURI_INVOKE("translate_popup_edit_mode", { enable }), + /** + * 钉住弹窗:钉住后点击外部与长时间停留都不再自动收起,只能手动关闭。 + * 弹窗收起时后端自动复位钉住状态。 + */ + translatePopupSetPinned: (pinned: boolean) => __TAURI_INVOKE("translate_popup_set_pinned", { pinned }), + /** + * 记住划词悬浮窗里选的语言(源 / 目标)。 + * + * 弹窗是独立窗口且**收不到键盘事件**(非激活窗口不持有焦点),所有交互只能用鼠标, + * 因此语言切换做成了循环按钮而不是下拉;切换结果必须落盘,否则用户每次划词都要重选一遍 + * ——对「MyMemory 必须显式源语言」这类源来说,等于每次都得手动指定。 + * + * 刻意不重新注册快捷键:这里只改弹窗外观相关的字段,走一遍快捷键注册是白费功夫, + * 还可能在注册失败时把一个无关的错误抛给前端。 + */ + translatePopupPrefsSet: (sourceLang: string, targetLang: string) => __TAURI_INVOKE("translate_popup_prefs_set", { sourceLang, targetLang }), + /** + * 按内容自适应悬浮窗尺寸(前端测高后调用)。 + * + * 返回前端根节点应使用的 max-height(逻辑像素,来自工作区而非窗口自身—— + * 用 100vh 会形成收缩反馈循环,见 `popup::resize` 的注释);0 表示不限制。 + */ + translatePopupResize: (width: number | null, height: number | null) => __TAURI_INVOKE("translate_popup_resize", { width, height }), + /** + * 翻译一个已框选的屏幕区域。 + * + * 由截图覆盖层的「翻译」按钮调用(选区确认后)。流程: + * 本地模式 → 裁剪 → 本地 OCR → 识别文本交给悬浮窗(弹窗自己走翻译引擎,语言表在前端); + * 视觉模式 → 裁剪 → 图像直接交给支持图像输入的模型,识别与翻译一步完成。 + * + * 识别/翻译失败**也弹窗**:用户视线就在选区那里,失败原因出现在视线里 + * 比回到主界面看 toast 有用得多。OCR 在阻塞线程池执行(WinRT 的 `get()` 会阻塞)。 + */ + translateScreenshotRegion: (x: number, y: number, w: number, h: number, toLabel: string | null) => __TAURI_INVOKE("translate_screenshot_region", { x, y, w, h, toLabel }), + /** + * 列出系统可用的 OCR 语言(设置页据此提示语言包缺失)。 + * + * **必须带超时**:`OcrEngine.AvailableRecognizerLanguages` 在部分机器上会卡住不返回 + * (WinRT 激活失败时既不成功也不报错)。直接在阻塞线程池上等它,设置页的按钮就会 + * 永远转圈——用户只能重启应用。这里改为在**独立线程**上执行并用 `recv_timeout` 兜底: + * 超时就当失败,让前端显示可重试的错误,而不是把一个线程永远挂在 WinRT 里。 + */ + translateOcrLanguages: () => __TAURI_INVOKE("translate_ocr_languages"), + /** 分页列出历史(按时间倒序) */ + translateHistoryList: (offset: number | null, limit: number | null, query: string | null, favoritedOnly: boolean | null) => __TAURI_INVOKE("translate_history_list", { offset, limit, query, favoritedOnly }), + translateHistoryDelete: (id: number) => __TAURI_INVOKE("translate_history_delete", { id }), + /** 清空全部历史(含收藏)。破坏性操作,前端需二次确认后调用。 */ + translateHistoryClear: () => __TAURI_INVOKE("translate_history_clear"), + translateHistorySetFavorited: (id: number, favorited: boolean) => __TAURI_INVOKE("translate_history_set_favorited", { id, favorited }), + /** + * 启动一次流式翻译,返回 requestId;增量与结果经事件下发。 + * + * 与 `translate_run` 的差异(刻意为之): + * - **降级只在「还没吐出任何内容」时发生**。降级发生在请求被拒绝的瞬间才有意义; + * 一旦已经输出了一半再换引擎重译,用户只会看到内容跳变。所以这里用 + * `produced` 标记首个 chunk 是否已下发:没产出且还有候选 → 静默换下一个源; + * 已产出或已是最后一个候选 → 把错误交给前端。 + * 这一条正是「MyMemory 需要显式源语言」这类配置类失败能在划词场景自动落到 + * DeepSeek 上的原因。 + * - **不透传 thinking**:翻译场景要低延迟,流式下更是如此。 + * + * **requestId 由前端在发请求前生成并经 `request_id` 参数传入**:本命令要等翻译 + * 结束才返回,chunk/done 事件全部先于 Promise 到达;若前端等返回值才知道 + * requestId,所有事件都会因对不上号被过滤(表现为「历史里有译文、界面空白」)。 + * 该参数为空时(旧调用方)回退到本地生成,行为兼容。 + * + * 停止:`translate_abort(requestId)` 置位停止标志 → 命令层放弃转发并 drop 引擎 future + * → HTTP 流随之取消(而不是等下一块数据到来才发现被放弃)。 + */ + translateStreamStart: (params: TranslateRunParams, requestId: string | null) => __TAURI_INVOKE("translate_stream_start", { params, requestId }), + /** 停止一次流式请求(requestId 不存在时静默成功) */ + translateAbort: (requestId: string) => __TAURI_INVOKE("translate_abort", { requestId }), + /** + * 译文回填:把译文写入剪贴板并模拟 Ctrl+V 粘贴回原窗口,替换原选区。 + * + * 只对划词来源有意义——剪贴板/截图来源没有「原处」可回填。 + * 能成立的前提是**悬浮窗从不抢焦点**(非激活窗口),因此原窗口的键盘焦点与选区 + * 在翻译期间一直保持原样。回填前校验原窗口仍然存活,避免粘贴进毫不相干的窗口。 + */ + translatePasteBack: (text: string, hwnd: number) => __TAURI_INVOKE("translate_paste_back", { text, hwnd }), }; /* Types */ @@ -548,6 +700,64 @@ export type DuplicateKind = /** 磁盘文件已存在 */ "fileExists"; +/** + * 引擎连通性自检结果。 + * + * 与其它命令不同,这里**失败也返回 Ok**:测试失败是「数据」而不是「命令异常」, + * 前端要显示具体原因,不该走 try/catch 分支。 + */ +export type EngineTestResult = { + ok: boolean, + latencyMs: number, + message: string, + /** 失败时的错误分类(成功时为 None) */ + errorKind: ErrorKind | null, + /** 上游原始响应片段(已截断) */ + detail: string | null, +}; + +/** + * 引擎实例的前端视图:配置 + 派生状态(密钥是否已配、是否可用)。 + * + * 拆成「config + 派生」而不是直接回传配置,是为了让前端保存时能原样回传 + * `view.config`,不必自己去拼装结构,也不会误把派生字段写回配置。 + */ +export type EngineView = { + config: TranslateEngineConfig, + /** 是否已在系统凭据管理器中配置密钥 */ + hasApiKey: boolean, + /** 密钥掩码(未配置时为空串) */ + apiKeyMasked: string, + /** 是否已具备发起翻译的完整配置 */ + ready: boolean, + /** 不可用原因(ready 为 true 时为空) */ + issue: string | null, +}; + +/** + * 错误分类。区分它们的意义在于**前端能给出可操作的提示**: + * `Auth` 要用户去改 Key,`Network` 要用户查网络/代理,`RateLimit` 只需等待。 + */ +export type ErrorKind = +/** 配置缺失或不合法(未填 Base URL、模型名无效等) */ +"config" | +/** 认证失败 / 无权限 / 额度耗尽 */ +"auth" | +/** 被限流 */ +"rateLimit" | +/** 网络不可达(含代理问题) */ +"network" | +/** 超时 */ +"timeout" | +/** 响应无法解析(上游改了格式) */ +"parse" | +/** 不支持的引擎类型或语言对 */ +"unsupported" | +/** 输入为空(取词失败或用户未输入) */ +"empty" | +/** 被风控/验证码拦截(免费源常见) */ +"captcha" | "unknown"; + /** 已存在的任务信息(用于前端展示) */ export type ExistingTaskInfo = { id: string, @@ -601,12 +811,36 @@ export type FileRecord = { isDir: boolean, }; +/** 一条历史记录 */ +export type HistoryItem = { + id: number, + /** 毫秒时间戳 */ + ts: number, + fromLang: string, + toLang: string, + engineId: string, + engineName: string, + sourceText: string, + resultText: string, + /** 来源:"manual" | "selection" | "clipboard" | "screenshot" | "preview" */ + via: string, + favorited: boolean, + latencyMs: number, +}; + /** 历史查询结果(含总数,用于分页) */ export type HistoryPage = { items: ClipboardItem[], total: number, }; +/** 翻译历史设置(P2 生效)。 */ +export type HistorySettings = { + enabled?: boolean, + /** 保留条数上限(超出按时间淘汰) */ + maxItems?: number, +}; + /** 索引状态(返回给前端) */ export type IndexStats = { total: number, @@ -707,6 +941,27 @@ export type MusicSettings = { feiniuAutoUpload?: boolean, }; +/** 划词结果悬浮窗外观(P1 生效)。 */ +export type PopupSettings = { + /** 正文字号(逻辑像素) */ + fontSize?: number, + /** 不透明度百分比(100 = 不透明) */ + opacity?: number, + /** 优先落位:"bottom-right" | "bottom-left" | "top-right" | "top-left" */ + positionPreference?: string, + /** 是否默认展开原文 */ + showOriginal?: boolean, + /** + * 划词弹窗的源语言("auto" 表示交给引擎判断)。 + * + * 存在的理由:MyMemory 这类源**不支持自动检测**,而划词场景天然不知道源语言。 + * 与其让弹窗永远传 auto、把这类源判成「不可用」,不如让用户在这里定一次并记住。 + */ + sourceLang?: string, + /** 划词弹窗的目标语言。空串表示跟随全局「默认目标语言」。 */ + targetLang?: string, +}; + /** 进程信息(返回给前端) */ export type ProcessInfo = { id: string, @@ -728,6 +983,21 @@ export type ProfileMeta = { size?: number | null, }; +/** + * 提示词模板。`{target}` 为占位符,翻译时替换为目标语言的自然语言全称 + * (用全称而非语言码,模型遵循度明显更高)。 + */ +export type PromptTemplates = { + /** 纯翻译(默认模式) */ + translate?: string, + /** 润色(P3) */ + polish?: string, + /** 解释(P3) */ + explain?: string, + /** 总结(P3) */ + summarize?: string, +}; + export type ProxySettings = { mixedPort?: number, externalController?: string, @@ -798,6 +1068,26 @@ export type ScreenRect = { height: number, }; +/** 截图翻译的结果摘要(展示交给悬浮窗,这里供调用方与历史记录使用) */ +export type ScreenshotOutcome = { + /** 本地 OCR 的识别文本(视觉直译模式为空) */ + ocrText: string, + /** OCR 使用的语言(BCP-47,本地模式) */ + ocrLang: string, + engineName: string, + translated: boolean, +}; + +/** 截图翻译设置。 */ +export type ScreenshotSettings = { + /** 识别方式:"windows"(Windows.Media.Ocr,本地离线,默认)| "vision"(视觉模型直译) */ + ocrMode?: string, + /** OCR 语言:"auto" 或 BCP-47 标签(如 en-US / zh-Hans-CN) */ + ocrLang?: string, + /** 框选完成后是否自动翻译(关闭则只识别,译文由用户在悬浮窗手动触发) */ + autoTranslate?: boolean, +}; + /** 滚动截图区域(屏幕物理像素坐标,通常为覆盖层框选区平移到屏幕) */ export type ScrollRegion = { x: number, @@ -818,6 +1108,37 @@ export type Segment = { completed: number, }; +/** + * 划词翻译设置(P1 生效;结构在 P0 即定型,避免 P2 改数据结构)。 + * + * P2 重构说明:原「取词快捷键 Alt+T」「翻译剪贴板 Alt+Shift+T」已移除, + * 统一为「翻译面板」快捷键(`panel_shortcut`)——面板打开时自动尝试读取 + * 当前划词与剪贴板首条作为候选,覆盖了原来两个入口的全部场景。 + */ +export type SelectionSettings = { + /** 总开关:打开翻译面板时是否尝试读取当前划词作为候选 */ + enabled?: boolean, + /** 打开翻译面板的全局快捷键(默认 Ctrl+2) */ + panelShortcut?: string, + /** + * 取词方式: + * - "smart"(默认):先用 UIA 直读选区,读不到再退回模拟 Ctrl+C。不碰剪贴板, + * 因此不受「目标窗口提权」「Alt 仍被按住导致 Ctrl+C 变成 Alt+Ctrl+C」这两类问题影响。 + * - "compat":只用模拟 Ctrl+C(覆盖最广,但会短暂占用剪贴板)。 + * - "clipboard":不取词,面板只提供剪贴板首条作为候选。 + */ + mode?: string, + /** 取词后是否还原剪贴板(关掉则保留选中文本) */ + restoreClipboard?: boolean, + /** + * 单次取词的字符数上限。超出直接拒绝并提示:划词误选整篇文档时, + * 发一个几万字的请求既慢又费钱,不如让用户明确知道发生了什么。 + */ + maxChars?: number, + /** 取词跳过的进程名黑名单(终端类 Ctrl+C 是中断信号,必须排除) */ + blacklist?: string[], +}; + /** 快捷位置条目 */ export type SpecialLocation = { id: string, @@ -852,6 +1173,13 @@ export type TaskStatus = /** 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载) */ "cancelled"; +/** Token 用量(AI 引擎返回;其余引擎为 None) */ +export type TokenUsage = { + promptTokens: number, + completionTokens: number, + totalTokens: number, +}; + /** 种子信息(inspect 解析结果,供命令返回给前端做文件勾选) */ export type TorrentInfo = { /** 种子名称 */ @@ -878,6 +1206,118 @@ export type TrafficSnapshot = { activeConnections: number, }; +/** + * 单个翻译引擎实例的配置。 + * + * 「实例」而非「类型」:同一类型可以配置多份(例如官方 API 与本地 Ollama 并存), + * 每份有自己的 id / 优先级 / 模型与参数。 + */ +export type TranslateEngineConfig = { + /** 实例唯一标识(同时是凭据键的一部分,创建后不建议修改) */ + id?: string, + /** 展示名称 */ + name?: string, + /** 引擎类型:"ai"(OpenAI 兼容)| "free"(免密钥网络源)| "cloud"(需签名的云厂商,P3) */ + kind?: string, + /** 预设标识:"deepseek" | "openai" | "ollama" | "custom" | "libretranslate" | "mymemory" */ + preset?: string, + /** 是否参与「自动」模式的候选 */ + enabled?: boolean, + /** 优先级(数值越小越先尝试;自动模式下失败按序降级到下一个) */ + priority?: number, + /** API 根地址,如 https://api.deepseek.com(末尾可有可无 /,不可含 /chat/completions) */ + baseUrl?: string, + /** 模型标识,如 deepseek-flash */ + model?: string, + /** 采样温度(翻译建议 0.2~0.3) */ + temperature?: number | null, + /** 单次请求最大输出 token */ + maxTokens?: number, + /** 请求超时(毫秒) */ + timeoutMs?: number, + /** 自定义 system prompt;**非空时覆盖模式模板**(留空表示用全局模板) */ + systemPrompt?: string, + /** + * 额外请求体字段的 JSON 文本(用于 thinking / enable_thinking 这类非标准参数); + * 用文本而非结构化字段:一是前端直接给文本域,二是避免把任意 JSON 塞进类型绑定。 + */ + extraBody?: string | null, + /** + * 是否支持图像输入(截图翻译的「视觉直译」模式只发给勾选了此项的引擎)。 + * 默认 false:多模态模型与文本模型的计费和端点约束不同,宁可让用户显式勾选。 + */ + supportsVision?: boolean, +}; + +/** 结构化错误:作为 Tauri 命令的 error 类型返回,前端按 `kind` 分支处理。 */ +export type TranslateError = { + kind: ErrorKind, + message: string, + /** 上游原始响应片段(已截断),用于排查;不含密钥 */ + detail: string | null, +}; + +/** 翻译结果 */ +export type TranslateResult = { + /** 译文 */ + text: string, + /** 检测到的源语言(免费源会上报;AI 引擎为显式指定值或 None) */ + detected: string | null, + /** 实际使用的引擎实例 id */ + engineId: string, + /** 实际使用的引擎展示名(自动降级时前端要能看出「是谁答的」) */ + engineName: string, + /** 耗时(毫秒) */ + latencyMs: number, + usage: TokenUsage | null, +}; + +/** 单次翻译请求参数。 */ +export type TranslateRunParams = { + text: string, + /** 源语言代码,"auto" 或省略表示自动检测 */ + from?: string | null, + /** 目标语言代码(如 zh-Hans) */ + to?: string | null, + /** 目标语言自然语言全称(如 简体中文),提示词用 */ + toLabel?: string | null, + /** 源语言自然语言全称,仅显式指定源语言时有意义 */ + fromLabel?: string | null, + /** 指定引擎实例 id;省略或 "auto" 表示按优先级自动(含降级) */ + engineId?: string | null, + /** 模式:"translate"(默认)| "polish" | "explain" | "summarize" */ + mode?: string | null, + /** 记入历史时的来源:"manual"(主面板)| "selection" | "clipboard" | "screenshot" */ + via?: string | null, + /** 是否写入历史。默认 true;**多引擎对比与预览必须传 false**。 */ + record?: boolean | null, +}; + +/** 翻译模块设置根结构。 */ +export type TranslateSettings = { + /** 结构版本号(用于后续迁移判断) */ + version?: number, + /** 默认目标语言(内部代码,如 zh-Hans) */ + defaultTarget?: string, + /** 默认引擎实例 id("auto" 表示按优先级自动降级) */ + defaultEngineId?: string, + /** 自动模式下的失败降级开关 */ + autoFallback?: boolean, + /** + * 翻译请求是否走代理模块(mihomo mixed 端口)。 + * **默认关闭**:自建 LibreTranslate 在境内、MyMemory 与 DeepSeek 均可直连, + * 不需要代理;仅当翻译服务部署在境外或直连被拦截时由用户显式打开。 + */ + useProxy?: boolean, + /** 引擎实例列表 */ + engines?: TranslateEngineConfig[], + selection?: SelectionSettings, + popup?: PopupSettings, + screenshot?: ScreenshotSettings, + history?: HistorySettings, + promptTemplates?: PromptTemplates, +}; + /** release 中的一个资产 */ export type UpdateAsset = { name: string, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6ea7cb4..d06748f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -16,6 +16,8 @@ export const WINDOWS = { screenshotScroll: 'screenshot-scroll', /** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-` */ downloadWindow: 'download-window', + /** 取词翻译悬浮窗(非激活显示,由 Rust 预创建) */ + translatePopup: 'translate-popup', } as const /** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */ @@ -64,6 +66,13 @@ export const EVENTS = { screenshotEditorLoad: 'screenshot-editor-load', // 内核安装进度 kernelInstallProgress: 'kernel-install-progress', + // 翻译:取词悬浮窗显示(负载见 Rust translate::popup::PopupPayload)/ 隐藏 + translatePopupShow: 'translate-popup-show', + translatePopupHide: 'translate-popup-hide', + // 翻译:流式输出(失败经 start 的 Promise reject,不走事件) + translateStreamChunk: 'translate-stream-chunk', + translateStreamDone: 'translate-stream-done', + translateStreamError: 'translate-stream-error', // 后端自动切换节点完成(后台执行,刷新节点列表并提示) proxyAutoSwitch: 'proxy-auto-switch', // 应用更新进度 diff --git a/src/lib/translate/api.ts b/src/lib/translate/api.ts new file mode 100644 index 0000000..88e6762 --- /dev/null +++ b/src/lib/translate/api.ts @@ -0,0 +1,169 @@ +/** + * 翻译模块的后端调用封装。 + * + * 单独抽一层的原因:**取词悬浮窗是独立窗口,不加载 Pinia**(`main.ts` 的 + * `standaloneWindowApps` 分支只 createApp,不装 store)。要让主面板与悬浮窗共用同一套 + * 调用与错误规整逻辑,就不能把它写在 store 里。 + * + * 调用方式仍用原生 `invoke` 而非 `@/lib/bindings` 的 `commands.*`:specta 绑定只在 + * debug 构建启动时生成,新增命令在首次 `tauri dev` 之前不存在于 bindings.ts。 + * 全部收敛在这一个文件与 translateStore 里,将来替换只需改这两处。 + */ + +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { EVENTS } from '@/lib/constants' +import type { + EngineView, + TranslateError, + TranslateErrorKind, + TranslateResult, + TranslateRunParams +} from '@/types/translate' + +/** + * 把任意异常规整为 TranslateError。 + * + * 后端有两种错误形态:翻译命令返回结构化对象 `{ kind, message, detail }`, + * 其余命令返回字符串 —— 这里一并兼容,调用方只需处理一种形状。 + */ +export function normalizeTranslateError(e: unknown): TranslateError { + if (e && typeof e === 'object') { + const o = e as Record + if (typeof o.kind === 'string' && typeof o.message === 'string') { + return { + kind: o.kind as TranslateErrorKind, + message: o.message, + detail: typeof o.detail === 'string' ? o.detail : null + } + } + try { + return { kind: 'unknown', message: JSON.stringify(o), detail: null } + } catch { + /* 循环引用等异常,落到下面的兜底 */ + } + } + return { kind: 'unknown', message: String(e ?? '未知错误'), detail: null } +} + +/** 执行一次翻译(含后端的自动降级) */ +export function runTranslate(params: TranslateRunParams): Promise { + return invoke('translate_run', { params }) +} + +/** 引擎实例列表(含密钥状态、可用性、代理状态) */ +export function listEngines(): Promise { + return invoke('translate_engines_list') +} + +/** + * 写文本到系统剪贴板。 + * + * 走 Rust 而不是 `navigator.clipboard`:悬浮窗是非激活窗口,浏览器会以 + * 「无用户激活」为由拒绝写入;走 Rust 还能顺带把这次写入登记进剪贴板抑制表, + * 不在剪贴板历史里留下一条自己复制给自己的记录。 + */ +export function copyToClipboard(text: string): Promise { + return invoke('translate_copy_text', { text }) +} + +// ===== 流式输出 ===== + +/** + * 流式事件的负载形状。 + * + * 与 Rust `translate::engines::StreamEvent` 一一对应:该枚举用 `#[serde(tag = "type")]` + * 序列化,因此 `requestId` 与 `delta` / `result` 是**平级字段**,而不是嵌在 + * `{"Chunk": {...}}` 里。曾经因为两边形状不一致,所有事件都被 `requestId` 过滤掉, + * 表现为「历史里有译文、界面上一片空白」——改任一处都要同时看另一处。 + */ +interface StreamEventPayload { + requestId: string + type?: 'chunk' | 'done' | 'error' + delta?: string + result?: TranslateResult + error?: TranslateError +} + +export interface StreamHandlers { + onChunk?: (delta: string) => void + onDone?: (result: TranslateResult) => void + onError?: (error: TranslateError) => void +} + +/** + * 一次流式翻译的会话。 + * + * 三条事件(chunk/done/error)都带 requestId,监听器按它过滤——弹窗是常驻窗口, + * 用户连续划词时前一次的残留事件不能串进新请求。 + * + * 「统一走流式入口」的含义:不支持流式的引擎(免密钥源、DeepL)在后端走 + * 同步兜底并发整条 Done,前端无需关心引擎能力差异。 + */ +export class StreamSession { + private unlisten: UnlistenFn[] = [] + private requestId: string | null = null + private finished = false + /** 客户端生成 requestId 用的自增序号(配合时间戳保证同毫秒内不重复) */ + private static seq = 0 + + constructor(private handlers: StreamHandlers) {} + + /** 注册事件监听。必须在首次 start 之前调用。 */ + async init(): Promise { + this.unlisten.push( + await listen(EVENTS.translateStreamChunk, e => { + if (e.payload.requestId !== this.requestId || this.finished) return + if (e.payload.delta) this.handlers.onChunk?.(e.payload.delta) + }) + ) + this.unlisten.push( + await listen(EVENTS.translateStreamDone, e => { + if (e.payload.requestId !== this.requestId || this.finished) return + this.finished = true + if (e.payload.result) this.handlers.onDone?.(e.payload.result) + }) + ) + // 兜底通道:命令已返回 requestId、之后才失败的情形(引擎降级失败等)。 + // 没有它,前端会永远停在「翻译中」——Promise 与事件谁先到没有保证。 + this.unlisten.push( + await listen(EVENTS.translateStreamError, e => { + if (e.payload.requestId !== this.requestId || this.finished) return + this.finished = true + if (e.payload.error) this.handlers.onError?.(e.payload.error) + }) + ) + } + + /** + * 启动一次流式翻译。启动即失败(如没有可用引擎)时走 onError。 + * + * requestId 由**客户端在发请求之前生成**并随参数带给后端:流式命令要等翻译 + * 全部结束才返回,chunk/done 事件全部先于 Promise 到达。若等 invoke 返回才 + * 拿后端生成的 requestId,期间所有事件都因对不上号被过滤——表现为 + * 「历史里有译文、界面上一片空白」。后端仅在客户端未提供时才自行生成。 + */ + async start(params: TranslateRunParams): Promise { + this.requestId = `c${Date.now()}-${++StreamSession.seq}` + this.finished = false + try { + await invoke('translate_stream_start', { params, requestId: this.requestId }) + } catch (e) { + this.handlers.onError?.(normalizeTranslateError(e)) + } + } + + /** 停止当前请求(后端会取消 HTTP 流)。 */ + stop(): void { + if (this.requestId && !this.finished) { + void invoke('translate_abort', { requestId: this.requestId }).catch(() => {}) + } + this.finished = true + } + + dispose(): void { + this.stop() + this.unlisten.forEach(fn => fn()) + this.unlisten = [] + } +} diff --git a/src/lib/translate/languages.ts b/src/lib/translate/languages.ts new file mode 100644 index 0000000..ac0c5bf --- /dev/null +++ b/src/lib/translate/languages.ts @@ -0,0 +1,89 @@ +/** + * 语言表。 + * + * 内部统一用 BCP-47 短码(`zh-Hans` / `en` / `ja`…)作为**唯一标识**,用于设置持久化、 + * 历史记录与前端状态;各翻译源自己的语言码方言(如 Google 的 `zh-CN`、Bing 的 `zh-Hans`、 + * 云厂商的 `zh`)由各自的适配器在内部映射,不向上暴露——否则上层会被迫处处判断 + * 「这个源认哪种写法」。 + * + * `promptName` 是送给 AI 模型的自然语言全称。刻意不用语言码:模型对「繁体中文」的 + * 遵循度明显高于 `zh-Hant`,而这是零成本的改动。 + */ + +export interface LanguageDef { + /** 内部统一代码 */ + code: string + /** 中文展示名 */ + label: string + /** 送给模型的自然语言全称 */ + promptName: string + /** 常用语言(下拉置顶) */ + common?: boolean +} + +/** 自动检测的伪语言项(仅出现在源语言下拉中) */ +export const AUTO_DETECT: LanguageDef = { + code: 'auto', + label: '自动检测', + promptName: '' +} + +export const LANGUAGES: LanguageDef[] = [ + { code: 'zh-Hans', label: '简体中文', promptName: '简体中文', common: true }, + { code: 'zh-Hant', label: '繁体中文', promptName: '繁体中文', common: true }, + { code: 'en', label: '英语', promptName: '英语', common: true }, + { code: 'ja', label: '日语', promptName: '日语', common: true }, + { code: 'ko', label: '韩语', promptName: '韩语', common: true }, + { code: 'fr', label: '法语', promptName: '法语' }, + { code: 'de', label: '德语', promptName: '德语' }, + { code: 'es', label: '西班牙语', promptName: '西班牙语' }, + { code: 'pt', label: '葡萄牙语', promptName: '巴西葡萄牙语' }, + { code: 'ru', label: '俄语', promptName: '俄语' }, + { code: 'it', label: '意大利语', promptName: '意大利语' }, + { code: 'nl', label: '荷兰语', promptName: '荷兰语' }, + { code: 'pl', label: '波兰语', promptName: '波兰语' }, + { code: 'tr', label: '土耳其语', promptName: '土耳其语' }, + { code: 'ar', label: '阿拉伯语', promptName: '阿拉伯语' }, + { code: 'hi', label: '印地语', promptName: '印地语' }, + { code: 'th', label: '泰语', promptName: '泰语' }, + { code: 'vi', label: '越南语', promptName: '越南语' }, + { code: 'id', label: '印尼语', promptName: '印尼语' }, + { code: 'ms', label: '马来语', promptName: '马来语' } +] + +const BY_CODE = new Map(LANGUAGES.map(l => [l.code, l])) + +export function findLanguage(code: string | null | undefined): LanguageDef | undefined { + if (!code) return undefined + return BY_CODE.get(code) +} + +/** 展示用名称(未知代码原样返回,避免出现空白) */ +export function labelOf(code: string | null | undefined): string { + if (!code || code === 'auto') return AUTO_DETECT.label + return BY_CODE.get(code)?.label ?? code +} + +/** + * 送模型的自然语言语言名。 + * 未知代码时退回语言码本身——总比留空让模型自行猜测要好。 + */ +export function promptNameOf(code: string | null | undefined): string { + if (!code || code === 'auto') return '' + return BY_CODE.get(code)?.promptName ?? code +} + +/** 常用语言在前、其余按表内顺序 */ +export function orderedLanguages(includeAuto: boolean): LanguageDef[] { + const common = LANGUAGES.filter(l => l.common) + const rest = LANGUAGES.filter(l => !l.common) + return includeAuto ? [AUTO_DETECT, ...common, ...rest] : [...common, ...rest] +} + +/** + * 推断本次请求的目标语言标签。 + * 目标语言必须显式选择(没有「自动」一说),因此仅在代码未知时退回代码本身。 + */ +export function resolveTargetLabel(code: string): string { + return promptNameOf(code) || labelOf(code) +} diff --git a/src/main.ts b/src/main.ts index c22758b..3943298 100644 --- a/src/main.ts +++ b/src/main.ts @@ -39,6 +39,7 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr ['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')], ['#screenshot-scroll', '滚动截图', () => import('./modules/screenshot/ScrollControl.vue')], ['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')], + ['#translate-popup', '翻译悬浮窗', () => import('./modules/translate/TranslatePopup.vue')], ] const winHash = window.location.hash diff --git a/src/modules/icons.ts b/src/modules/icons.ts index e81bd91..5a1de7f 100644 --- a/src/modules/icons.ts +++ b/src/modules/icons.ts @@ -8,7 +8,8 @@ import { Download, Command, Wrench, - Music + Music, + Languages } from '@lucide/vue' /** @@ -27,7 +28,8 @@ export const moduleIconMap: Record = { downloader: Download, quickpanel: Command, devtools: Wrench, - music: Music + music: Music, + translate: Languages } /** 获取模块图标组件,未找到时回退到 Settings 图标 */ diff --git a/src/modules/index.ts b/src/modules/index.ts index b8e54ea..a939faf 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -11,6 +11,7 @@ import { moduleConfig as quickpanel } from './quickpanel' import { moduleConfig as devtools } from './devtools' import { moduleConfig as settings } from './settings' import { moduleConfig as music } from './music' +import { moduleConfig as translate } from './translate' const allModules: ModuleConfig[] = [ proxy, @@ -21,7 +22,8 @@ const allModules: ModuleConfig[] = [ quickpanel, devtools, settings, - music + music, + translate ] // 启动时注册所有模块 diff --git a/src/modules/screenshot/ScreenshotOverlay.vue b/src/modules/screenshot/ScreenshotOverlay.vue index 7f1fb51..7cd9889 100644 --- a/src/modules/screenshot/ScreenshotOverlay.vue +++ b/src/modules/screenshot/ScreenshotOverlay.vue @@ -7,8 +7,9 @@ import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants' // Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts) import { commands } from '@/lib/bindings' import { - Undo2, Redo2, Eraser, Copy, Save, ChevronsDown, + Undo2, Redo2, Eraser, Copy, Save, ChevronsDown, Languages, } from '@lucide/vue' +import { resolveTargetLabel } from '@/lib/translate/languages' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Slider } from '@/components/ui/slider' import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -1577,14 +1578,59 @@ function composeCanvas(): HTMLCanvasElement | null { } /** 纯导出(保存用,无副作用):canvas → toDataURL base64 */ -async function exportBase64(): Promise { - if (annotations.value.length === 0) return cropFromStored() +async function exportBase64(): Promise { if (annotations.value.length === 0) return cropFromStored() const canvas = composeCanvas() if (!canvas) return null const sp = selPhys.value return { b64: canvas.toDataURL('image/png').split(',')[1] ?? '', w: sp.w, h: sp.h } } +// ===== 截图翻译 ===== +const translating = ref(false) +let cachedToLabel = '' + +/** 目标语言的自然语言名(提示词用),从翻译设置读取并缓存 */ +async function resolveToLabel(): Promise { + if (cachedToLabel) return cachedToLabel + try { + const s = await invoke<{ defaultTarget: string }>('translate_get_settings') + cachedToLabel = resolveTargetLabel(s.defaultTarget) || 'zh-Hans' + } catch { + cachedToLabel = 'zh-Hans' + } + return cachedToLabel +} + +/** + * 翻译选区:把选区交给翻译模块(本地 OCR 或视觉直译 → 悬浮窗展示)。 + * + * 与「复制」同样的先隐藏后处理模式:OCR 与翻译耗时不可控,先隐藏覆盖层给用户即时反馈; + * 结果与失败提示都由非激活悬浮窗在选区旁展示,不在主界面 toast。 + */ +async function onTranslate() { + if (translating.value) return + const sp = selPhys.value + if (sp.w < 2 || sp.h < 2) return + translating.value = true + magVisible.value = false + await win.hide().catch(() => {}) + try { + await invoke('translate_screenshot_region', { + x: sp.x, + y: sp.y, + w: sp.w, + h: sp.h, + toLabel: await resolveToLabel(), + }) + // 裁剪已完成,释放全屏底图内存(与正常关闭路径共用清理逻辑,重复清空无害) + await invoke('screenshot_clear_fullscreen') + } catch (e) { + console.error('[screenshot] 翻译选区失败', e) + } finally { + translating.value = false + } +} + async function finish() { if (exporting.value) return exporting.value = true @@ -2297,6 +2343,20 @@ onUnmounted(() => {
+ +
+ + + + + 翻译选区 + +
+ +
+
diff --git a/src/modules/translate/TranslateModule.vue b/src/modules/translate/TranslateModule.vue new file mode 100644 index 0000000..d3613f2 --- /dev/null +++ b/src/modules/translate/TranslateModule.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/modules/translate/TranslatePopup.vue b/src/modules/translate/TranslatePopup.vue new file mode 100644 index 0000000..0b36db2 --- /dev/null +++ b/src/modules/translate/TranslatePopup.vue @@ -0,0 +1,1021 @@ + + +