├── identifier.sqlite ├── icon.png ├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── 64x64.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square310x310Logo.png │ ├── ios │ │ ├── AppIcon-512@2x.png │ │ ├── AppIcon-20x20@1x.png │ │ ├── AppIcon-20x20@2x.png │ │ ├── AppIcon-20x20@3x.png │ │ ├── AppIcon-29x29@1x.png │ │ ├── AppIcon-29x29@2x.png │ │ ├── AppIcon-29x29@3x.png │ │ ├── AppIcon-40x40@1x.png │ │ ├── AppIcon-40x40@2x.png │ │ ├── AppIcon-40x40@3x.png │ │ ├── AppIcon-60x60@2x.png │ │ ├── AppIcon-60x60@3x.png │ │ ├── AppIcon-76x76@1x.png │ │ ├── AppIcon-76x76@2x.png │ │ ├── AppIcon-20x20@2x-1.png │ │ ├── AppIcon-29x29@2x-1.png │ │ ├── AppIcon-40x40@2x-1.png │ │ └── AppIcon-83.5x83.5@2x.png │ └── android │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── values │ │ └── ic_launcher_background.xml │ │ └── mipmap-anydpi-v26 │ │ └── ic_launcher.xml ├── .gitignore ├── src │ ├── main.rs │ ├── platform │ │ ├── unsupported.rs │ │ ├── mod.rs │ │ ├── windows.rs │ │ └── macos.rs │ ├── app_state.rs │ ├── token_limits.rs │ ├── system_tray.rs │ ├── ocr.rs │ ├── ocr_tasks.rs │ ├── lib.rs │ ├── http_client.rs │ ├── translation.rs │ ├── database.rs │ ├── commands.rs │ └── shortcuts.rs ├── tauri.macos.conf.json ├── capabilities │ └── default.json ├── tauri.conf.json └── Cargo.toml ├── .vscode └── extensions.json ├── src ├── main.js ├── area-selector.js ├── assets │ └── vue.svg ├── components │ ├── TitleBar.vue │ ├── LanguageSelector.vue │ ├── BottomToolbar.vue │ ├── CustomSelect.vue │ ├── TranslationResult.vue │ ├── TextInput.vue │ ├── ModelSelectorModal.vue │ ├── HotkeyRecorder.vue │ └── HistoryModal.vue └── AreaSelector.vue ├── index.html ├── .gitignore ├── area-selector.html ├── package.json ├── vite.config.js ├── .github └── workflows │ └── release.yml ├── README.ko.md ├── README.ja.md ├── README.zh.md ├── README.vi.md ├── README.es.md └── README.md /identifier.sqlite: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/icon.png -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/64x64.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-512@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-20x20@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-20x20@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-20x20@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-29x29@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-29x29@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-29x29@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-40x40@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-40x40@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-40x40@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-60x60@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-60x60@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-76x76@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-76x76@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-20x20@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-29x29@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-40x40@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qyzhg/prism/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #fff 4 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | trans_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/src/platform/unsupported.rs: -------------------------------------------------------------------------------- 1 | use tauri::AppHandle; 2 | 3 | pub async fn start_area_selection(app_handle: AppHandle) -> Result<(), String> { 4 | let _ = app_handle; 5 | Err("当前平台暂不支持区域截图".to_string()) 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/src/app_state.rs: -------------------------------------------------------------------------------- 1 | use crate::{database::Database, translation::TranslationService}; 2 | use std::sync::Mutex; 3 | 4 | /// Shared application state registered with Tauri. 5 | pub struct AppState { 6 | pub db: Mutex, 7 | pub translation_service: Mutex, 8 | } 9 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | 4 | if (import.meta.env.PROD) { 5 | // Hide reload/inspect menu in release builds 6 | window.addEventListener("contextmenu", (event) => event.preventDefault()); 7 | } 8 | 9 | createApp(App).mount("#app"); 10 | -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/area-selector.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import AreaSelector from "./AreaSelector.vue"; 3 | 4 | if (import.meta.env.PROD) { 5 | // Area selector window should also hide the context menu in release 6 | window.addEventListener("contextmenu", (event) => event.preventDefault()); 7 | } 8 | 9 | createApp(AreaSelector).mount("#app"); 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tauri + Vue 3 App 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src-tauri/src/platform/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "macos")] 2 | mod macos; 3 | #[cfg(target_os = "macos")] 4 | pub use macos::*; 5 | 6 | #[cfg(target_os = "windows")] 7 | mod windows; 8 | #[cfg(target_os = "windows")] 9 | pub use windows::*; 10 | 11 | #[cfg(not(any(target_os = "macos", target_os = "windows")))] 12 | mod unsupported; 13 | #[cfg(not(any(target_os = "macos", target_os = "windows")))] 14 | pub use unsupported::*; 15 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /area-selector.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Area Selector 7 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src-tauri/tauri.macos.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "bundle": { 4 | "active": true, 5 | "targets": "all", 6 | "icon": [ 7 | "icons/32x32.png", 8 | "icons/128x128.png", 9 | "icons/128x128@2x.png", 10 | "icons/icon.icns", 11 | "icons/icon.ico" 12 | ], 13 | "macOS": { 14 | "entitlements": null, 15 | "exceptionDomain": "", 16 | "frameworks": [], 17 | "providerShortName": null, 18 | "signingIdentity": "-", 19 | "hardenedRuntime": false, 20 | "minimumSystemVersion": "10.13" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": [ 6 | "main", 7 | "area-selector" 8 | ], 9 | "permissions": [ 10 | "core:default", 11 | "opener:default", 12 | "core:window:allow-minimize", 13 | "core:window:allow-close", 14 | "core:window:allow-start-dragging", 15 | "core:window:allow-set-always-on-top", 16 | "core:window:allow-set-size", 17 | "core:window:allow-hide", 18 | "global-shortcut:default", 19 | "autostart:default", 20 | "updater:allow-check", 21 | "updater:allow-download-and-install" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prism", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@tauri-apps/api": "^2", 14 | "@tauri-apps/plugin-autostart": "^2.5.1", 15 | "@tauri-apps/plugin-dialog": "^2", 16 | "@tauri-apps/plugin-global-shortcut": "^2.3.1", 17 | "@tauri-apps/plugin-opener": "^2", 18 | "@tauri-apps/plugin-process": "^2", 19 | "@tauri-apps/plugin-updater": "^2", 20 | "vue": "^3.5.13" 21 | }, 22 | "devDependencies": { 23 | "@tauri-apps/cli": "^2", 24 | "@vitejs/plugin-vue": "^5.2.1", 25 | "vite": "^6.0.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | const host = process.env.TAURI_DEV_HOST; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig(async () => ({ 8 | plugins: [vue()], 9 | 10 | build: { 11 | rollupOptions: { 12 | input: { 13 | main: "index.html", 14 | "area-selector": "area-selector.html", 15 | }, 16 | }, 17 | }, 18 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 19 | // 20 | // 1. prevent Vite from obscuring rust errors 21 | clearScreen: false, 22 | // 2. tauri expects a fixed port, fail if that port is not available 23 | server: { 24 | port: 1420, 25 | strictPort: true, 26 | host: host || false, 27 | hmr: host 28 | ? { 29 | protocol: "ws", 30 | host, 31 | port: 1421, 32 | } 33 | : undefined, 34 | watch: { 35 | // 3. tell Vite to ignore watching `src-tauri` 36 | ignored: ["**/src-tauri/**"], 37 | }, 38 | }, 39 | })); 40 | -------------------------------------------------------------------------------- /src/components/TitleBar.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | 19 | 61 | -------------------------------------------------------------------------------- /src-tauri/src/token_limits.rs: -------------------------------------------------------------------------------- 1 | use crate::database::TokenLimitConfig; 2 | 3 | pub const MIN_AI_MAX_TOKENS: u32 = 1000; 4 | 5 | fn clamp_with_limits(estimated: u64, config: Option<&TokenLimitConfig>) -> u32 { 6 | let mut value = estimated.max(MIN_AI_MAX_TOKENS as u64); 7 | 8 | if let Some(cfg) = config { 9 | if cfg.enable_user_max_tokens { 10 | let user_max = (cfg.user_max_tokens as u64).max(MIN_AI_MAX_TOKENS as u64); 11 | value = value.min(user_max); 12 | } 13 | } 14 | 15 | value.min(u32::MAX as u64) as u32 16 | } 17 | 18 | pub fn calculate_text_response_tokens( 19 | text: &str, 20 | config: Option<&TokenLimitConfig>, 21 | ) -> u32 { 22 | if text.is_empty() { 23 | return clamp_with_limits(MIN_AI_MAX_TOKENS as u64, config); 24 | } 25 | 26 | let char_count = text.chars().count() as u64; 27 | let scaled = ((char_count as f64) * 1.2).ceil() as u64; 28 | let buffered = scaled.saturating_add(256); 29 | 30 | clamp_with_limits(buffered, config) 31 | } 32 | 33 | pub fn calculate_image_response_tokens( 34 | width: u32, 35 | height: u32, 36 | config: Option<&TokenLimitConfig>, 37 | ) -> u32 { 38 | if width == 0 || height == 0 { 39 | return clamp_with_limits(MIN_AI_MAX_TOKENS as u64, config); 40 | } 41 | 42 | let area = width as u64 * height as u64; 43 | // Assume each readable character roughly occupies a 350px^2 area and add buffer 44 | let approx_chars = ((area as f64) / 350.0).ceil() as u64; 45 | let buffered = approx_chars.saturating_add(512); 46 | 47 | clamp_with_limits(buffered, config) 48 | } 49 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "prism", 4 | "version": "0.9.20", 5 | "identifier": "com.qyzhg.prism", 6 | "build": { 7 | "beforeDevCommand": "pnpm dev", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "pnpm build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "macOSPrivateApi": true, 14 | "windows": [ 15 | { 16 | "title": "prism", 17 | "width": 450, 18 | "height": 630, 19 | "minWidth": 450, 20 | "minHeight": 630, 21 | "resizable": true, 22 | "center": true, 23 | "alwaysOnTop": false, 24 | "skipTaskbar": false, 25 | "decorations": false, 26 | "transparent": false 27 | } 28 | ], 29 | "security": { 30 | "csp": null 31 | } 32 | }, 33 | "plugins": { 34 | "updater": { 35 | "endpoints": [ 36 | "https://github.com/qyzhg/prism/releases/latest/download/latest.json" 37 | ], 38 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhGN0Y2RkFBRDU4NjcyN0IKUldSN2NvYlZxbTkvajh5TVBPK2lpN0Q0SWNyR2pUeE5WQi8vcHhENEtjQVJxSFI2TWduK09nT2cK" 39 | } 40 | }, 41 | "bundle": { 42 | "active": true, 43 | "targets": "all", 44 | "createUpdaterArtifacts": true, 45 | "windows": { 46 | "nsis": { 47 | "installMode": "perMachine" 48 | } 49 | }, 50 | "icon": [ 51 | "icons/32x32.png", 52 | "icons/128x128.png", 53 | "icons/128x128@2x.png", 54 | "icons/icon.icns", 55 | "icons/icon.ico" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prism" 3 | version = "0.9.20" 4 | description = "全平台AI翻译软件" 5 | authors = ["qyzhg"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "trans_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } 22 | tauri-plugin-opener = "2" 23 | tauri-plugin-shell = "2" 24 | tauri-plugin-dialog = "2" 25 | tauri-plugin-process = "2" 26 | tauri-plugin-updater = "2" 27 | tauri-plugin-autostart = "2" 28 | rusqlite = { version = "0.37", features = ["bundled"] } 29 | reqwest = { version = "0.12", features = ["json"] } 30 | tokio = { version = "1", features = ["full"] } 31 | serde = { version = "1", features = ["derive"] } 32 | serde_json = "1" 33 | image = "0.25" 34 | base64 = "0.22" 35 | chrono = { version = "0.4", features = ["serde"] } 36 | screenshots = "0.8" 37 | tauri-plugin-global-shortcut = "2.3" 38 | arboard = "3.6" 39 | dirs = "6.0" 40 | scopeguard = "1.2" 41 | 42 | [target.'cfg(windows)'.dependencies] 43 | windows = { version = "0.62", features = [ 44 | "Win32_UI_Accessibility", 45 | "Win32_System_Com", 46 | "Win32_System_Variant", 47 | "Win32_Foundation", 48 | "Win32_UI_WindowsAndMessaging", 49 | "Win32_UI_Input_KeyboardAndMouse", 50 | "Win32_System_Console", 51 | ] } 52 | -------------------------------------------------------------------------------- /src-tauri/src/system_tray.rs: -------------------------------------------------------------------------------- 1 | use tauri::{ 2 | menu::{Menu, MenuItem}, 3 | tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, 4 | AppHandle, Manager, 5 | }; 6 | 7 | pub fn show_main_window(app: &AppHandle) { 8 | if let Some(window) = app.get_webview_window("main") { 9 | let _ = window.unminimize(); 10 | let _ = window.show(); 11 | let _ = window.set_focus(); 12 | } 13 | } 14 | 15 | pub fn setup_system_tray(app: &AppHandle) -> tauri::Result<()> { 16 | let show_item = MenuItem::with_id(app, "show-main", "显示主界面", true, None::<&str>)?; 17 | let hide_item = MenuItem::with_id(app, "hide-main", "隐藏到托盘", true, None::<&str>)?; 18 | let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; 19 | 20 | let menu = Menu::with_items(app, &[&show_item, &hide_item, &quit_item])?; 21 | 22 | let mut tray_builder = TrayIconBuilder::new() 23 | .menu(&menu) 24 | .show_menu_on_left_click(false) 25 | .on_menu_event(|app, event| match event.id.as_ref() { 26 | "show-main" => show_main_window(app), 27 | "hide-main" => { 28 | if let Some(window) = app.get_webview_window("main") { 29 | let _ = window.hide(); 30 | } 31 | } 32 | "quit" => { 33 | app.exit(0); 34 | } 35 | _ => {} 36 | }) 37 | .on_tray_icon_event(|tray, event| { 38 | if let TrayIconEvent::Click { 39 | button: MouseButton::Left, 40 | button_state: MouseButtonState::Up, 41 | .. 42 | } = event 43 | { 44 | let app_handle = tray.app_handle(); 45 | show_main_window(app_handle); 46 | } 47 | }); 48 | 49 | if let Some(icon) = app.default_window_icon() { 50 | tray_builder = tray_builder.icon(icon.clone()); 51 | } 52 | 53 | tray_builder.build(app)?; 54 | 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /src-tauri/src/platform/windows.rs: -------------------------------------------------------------------------------- 1 | use crate::system_tray::show_main_window; 2 | use screenshots::Screen; 3 | use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, Position, Size, WebviewUrl}; 4 | 5 | pub async fn start_area_selection(app_handle: AppHandle) -> Result<(), String> { 6 | // Hide main window before showing area selector 7 | let mut main_window_hidden = false; 8 | if let Some(main_window) = app_handle.get_webview_window("main") { 9 | main_window 10 | .hide() 11 | .map_err(|e| format!("隐藏主窗口失败: {}", e))?; 12 | main_window_hidden = true; 13 | } 14 | 15 | let setup_result = (|| -> Result<(), String> { 16 | let screens = Screen::all().map_err(|e| format!("获取屏幕列表失败: {}", e))?; 17 | 18 | if screens.is_empty() { 19 | return Err("未找到任何屏幕".to_string()); 20 | } 21 | 22 | let mut min_x = 0; 23 | let mut min_y = 0; 24 | let mut max_x = 0; 25 | let mut max_y = 0; 26 | 27 | for screen in &screens { 28 | let info = &screen.display_info; 29 | min_x = min_x.min(info.x); 30 | min_y = min_y.min(info.y); 31 | max_x = max_x.max(info.x + info.width as i32); 32 | max_y = max_y.max(info.y + info.height as i32); 33 | } 34 | 35 | let width = (max_x - min_x) as u32; 36 | let height = (max_y - min_y) as u32; 37 | 38 | let window = tauri::WebviewWindowBuilder::new( 39 | &app_handle, 40 | "area-selector", 41 | WebviewUrl::App("area-selector.html".into()), 42 | ) 43 | .title("区域选择") 44 | .decorations(false) 45 | .always_on_top(true) 46 | .skip_taskbar(true) 47 | .resizable(false) 48 | .transparent(true) 49 | .visible(false) 50 | .build() 51 | .map_err(|e| format!("创建区域选择窗口失败: {}", e))?; 52 | 53 | window 54 | .set_position(Position::Physical(PhysicalPosition { x: min_x, y: min_y })) 55 | .map_err(|e| format!("设置区域选择窗口位置失败: {}", e))?; 56 | 57 | window 58 | .set_size(Size::Physical(PhysicalSize { width, height })) 59 | .map_err(|e| format!("设置区域选择窗口大小失败: {}", e))?; 60 | 61 | window 62 | .show() 63 | .map_err(|e| format!("显示区域选择窗口失败: {}", e))?; 64 | window 65 | .set_focus() 66 | .map_err(|e| format!("设置区域选择窗口焦点失败: {}", e))?; 67 | 68 | Ok(()) 69 | })(); 70 | 71 | if setup_result.is_err() && main_window_hidden { 72 | show_main_window(&app_handle); 73 | } 74 | 75 | setup_result 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | build: 11 | permissions: 12 | contents: write 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - os: macos-latest 19 | name: macOS 20 | - os: windows-latest 21 | name: Windows 22 | - os: ubuntu-latest 23 | name: Linux 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Setup pnpm 30 | uses: pnpm/action-setup@v3 31 | with: 32 | version: 9 33 | 34 | - name: Setup Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 20 38 | cache: "pnpm" 39 | 40 | - name: Install system dependencies (Linux) 41 | if: matrix.os == 'ubuntu-latest' 42 | run: | 43 | sudo apt-get update 44 | sudo apt-get install -y \ 45 | libgtk-3-dev \ 46 | libwebkit2gtk-4.1-dev \ 47 | libayatana-appindicator3-dev \ 48 | librsvg2-dev \ 49 | patchelf 50 | 51 | - name: Install Rust toolchain 52 | uses: dtolnay/rust-toolchain@stable 53 | 54 | - name: Cache Rust dependencies 55 | uses: Swatinem/rust-cache@v2 56 | with: 57 | workspaces: | 58 | src-tauri 59 | 60 | - name: Install frontend dependencies 61 | run: pnpm install --frozen-lockfile 62 | 63 | - name: Build and upload release 64 | uses: tauri-apps/tauri-action@v0 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 68 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 69 | TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} 70 | TAURI_PUBLIC_KEY: ${{ secrets.TAURI_PUBLIC_KEY }} 71 | with: 72 | tauriScript: pnpm tauri 73 | releaseId: ${{ github.event_name == 'release' && github.event.release.id || '' }} 74 | tagName: ${{ github.event_name == 'release' && github.event.release.tag_name || 'v__VERSION__' }} 75 | releaseName: ${{ github.event_name == 'release' && github.event.release.name || 'prism v__VERSION__' }} 76 | releaseBody: ${{ github.event_name == 'release' && github.event.release.body || '自动构建的 Prism 桌面版本。' }} 77 | releaseDraft: false 78 | prerelease: ${{ github.event_name == 'release' && github.event.release.prerelease || false }} 79 | uploadUpdaterJson: true 80 | -------------------------------------------------------------------------------- /src-tauri/src/ocr.rs: -------------------------------------------------------------------------------- 1 | use crate::http_client::http_client; 2 | use base64::{engine::general_purpose, Engine as _}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Clone, Serialize, Deserialize)] 6 | pub struct OcrRequest { 7 | pub image_data: Vec, 8 | pub image_format: String, 9 | pub max_tokens: u32, 10 | } 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub struct OcrResult { 14 | pub text: String, 15 | pub confidence: f32, 16 | } 17 | 18 | pub struct OcrService { 19 | api_key: String, 20 | base_url: String, 21 | model_id: String, 22 | } 23 | 24 | impl OcrService { 25 | pub fn new(api_key: String, base_url: String, model_id: String) -> Self { 26 | Self { 27 | api_key, 28 | base_url, 29 | model_id, 30 | } 31 | } 32 | 33 | pub async fn extract_text(&self, request: OcrRequest) -> Result { 34 | // 将图片转换为base64 35 | let base64_image = general_purpose::STANDARD.encode(&request.image_data); 36 | let data_url = format!( 37 | "data:image/{};base64,{}", 38 | request.image_format, base64_image 39 | ); 40 | 41 | let client = http_client(); 42 | println!( 43 | "正在发送OCR请求...{}, 模型:{}", 44 | self.base_url, self.model_id 45 | ); 46 | let body = serde_json::json!({ 47 | "model": self.model_id, 48 | "messages": [ 49 | { 50 | "role": "user", 51 | "content": [ 52 | { 53 | "type": "text", 54 | "text": "Please extract all the text content from the image, only return the recognized text, without adding any explanation or formatting. If there is no text in the image, please return an empty string." 55 | }, 56 | { 57 | "type": "image_url", 58 | "image_url": { 59 | "url": data_url 60 | } 61 | } 62 | ] 63 | } 64 | ], 65 | "max_tokens": request.max_tokens, 66 | "temperature": 0.1 67 | }); 68 | 69 | let endpoint = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); 70 | 71 | let response = client 72 | .post(&endpoint) 73 | .header("Authorization", format!("Bearer {}", self.api_key)) 74 | .header("Content-Type", "application/json") 75 | .json(&body) 76 | .send() 77 | .await 78 | .map_err(|e| format!("发送OCR请求失败: {}", e))?; 79 | 80 | if !response.status().is_success() { 81 | let error_text = response.text().await.unwrap_or_default(); 82 | return Err(format!("OCR API错误: {}", error_text)); 83 | } 84 | 85 | let response_json: serde_json::Value = response 86 | .json() 87 | .await 88 | .map_err(|e| format!("解析OCR响应失败: {}", e))?; 89 | 90 | let extracted_text = response_json 91 | .get("choices") 92 | .and_then(|choices| choices.get(0)) 93 | .and_then(|choice| choice.get("message")) 94 | .and_then(|message| message.get("content")) 95 | .and_then(|content| content.as_str()) 96 | .unwrap_or("") 97 | .trim() 98 | .to_string(); 99 | println!("orc解析文本: {}", extracted_text); 100 | Ok(OcrResult { 101 | text: extracted_text, 102 | confidence: 0.95, 103 | }) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/components/LanguageSelector.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 106 | 107 | 165 | -------------------------------------------------------------------------------- /src-tauri/src/ocr_tasks.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app_state::AppState, 3 | ocr::{OcrRequest, OcrService}, 4 | token_limits::calculate_image_response_tokens, 5 | }; 6 | use image::{imageops::FilterType, GenericImageView, ImageFormat}; 7 | use std::io::Cursor; 8 | use tauri::State; 9 | 10 | const MIN_OCR_DIMENSION: u32 = 28; 11 | 12 | fn ensure_minimum_ocr_size(image_data: Vec) -> Result<(Vec, u32, u32), String> { 13 | let image = 14 | image::load_from_memory(&image_data).map_err(|e| format!("解析OCR图片失败: {}", e))?; 15 | let (width, height) = image.dimensions(); 16 | 17 | if width == 0 || height == 0 { 18 | return Err("截图区域太小,无法进行OCR".to_string()); 19 | } 20 | 21 | if width >= MIN_OCR_DIMENSION && height >= MIN_OCR_DIMENSION { 22 | return Ok((image_data, width, height)); 23 | } 24 | 25 | let scale = f32::max( 26 | MIN_OCR_DIMENSION as f32 / width as f32, 27 | MIN_OCR_DIMENSION as f32 / height as f32, 28 | ); 29 | 30 | let target_width = (width as f32 * scale).ceil() as u32; 31 | let target_height = (height as f32 * scale).ceil() as u32; 32 | let resized = image.resize_exact(target_width, target_height, FilterType::CatmullRom); 33 | 34 | let mut buffer = Vec::new(); 35 | resized 36 | .write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png) 37 | .map_err(|e| format!("编码OCR图片失败: {}", e))?; 38 | 39 | println!( 40 | "OCR截图尺寸过小 ({}x{}), 已缩放至 {}x{}", 41 | width, height, target_width, target_height 42 | ); 43 | 44 | Ok((buffer, target_width, target_height)) 45 | } 46 | 47 | pub async fn run_ocr_on_image_data( 48 | image_data: Vec, 49 | state: State<'_, AppState>, 50 | ) -> Result { 51 | let (processed_image_data, width, height) = ensure_minimum_ocr_size(image_data)?; 52 | 53 | let (api_key, base_url, model_id, token_config) = { 54 | let db = state 55 | .db 56 | .lock() 57 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 58 | 59 | match db.get_app_config() { 60 | Ok(Some(config)) => { 61 | let token_config = config.token_limits.clone(); 62 | if config.ocr.reuse_translation { 63 | let translation_config = config.translation; 64 | let model_id = if !config.ocr.model_id.is_empty() { 65 | config.ocr.model_id 66 | } else { 67 | translation_config.model_id 68 | }; 69 | ( 70 | translation_config.api_key, 71 | translation_config.base_url, 72 | model_id, 73 | token_config, 74 | ) 75 | } else { 76 | let ocr_config = config.ocr; 77 | ( 78 | ocr_config.api_key, 79 | ocr_config.base_url, 80 | ocr_config.model_id, 81 | token_config, 82 | ) 83 | } 84 | } 85 | Ok(None) => { 86 | return Err("OCR配置不存在,请先在设置中配置OCR".to_string()); 87 | } 88 | Err(e) => { 89 | return Err(format!("获取OCR配置失败: {}", e)); 90 | } 91 | } 92 | }; 93 | 94 | if api_key.is_empty() { 95 | return Err("OCR API密钥未配置,请在设置中配置API密钥".to_string()); 96 | } 97 | 98 | let ocr_service = OcrService::new(api_key, base_url, model_id); 99 | let max_tokens = 100 | calculate_image_response_tokens(width, height, Some(&token_config)); 101 | let ocr_request = OcrRequest { 102 | image_data: processed_image_data, 103 | image_format: "png".to_string(), 104 | max_tokens, 105 | }; 106 | 107 | let ocr_result = ocr_service 108 | .extract_text(ocr_request) 109 | .await 110 | .map_err(|e| format!("OCR识别失败: {}", e))?; 111 | 112 | Ok(ocr_result.text) 113 | } 114 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod app_state; 2 | mod commands; 3 | mod database; 4 | mod http_client; 5 | mod ocr; 6 | mod ocr_tasks; 7 | mod platform; 8 | mod shortcuts; 9 | mod system_tray; 10 | mod translation; 11 | mod token_limits; 12 | 13 | use app_state::AppState; 14 | #[cfg(not(target_os = "macos"))] 15 | use commands::submit_area_for_ocr; 16 | use commands::{ 17 | capture_and_ocr, capture_area_and_ocr, capture_screen, capture_screen_area, clear_history, 18 | fetch_available_models, get_api_key, get_app_config, get_setting, get_translation_history, 19 | reload_shortcuts, save_api_key, save_app_config, save_setting, save_translation, 20 | search_history, set_ocr_result, start_area_selection, translate_text, 21 | }; 22 | use database::Database; 23 | use http_client::configure_http_client; 24 | #[cfg(target_os = "macos")] 25 | use platform::submit_area_for_ocr; 26 | use shortcuts::register_shortcuts; 27 | use std::sync::Mutex; 28 | use system_tray::setup_system_tray; 29 | use tauri::Manager; 30 | use translation::{get_supported_languages, TranslationService}; 31 | 32 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 33 | pub fn run() { 34 | tauri::Builder::default() 35 | .plugin(tauri_plugin_opener::init()) 36 | .plugin(tauri_plugin_shell::init()) 37 | .plugin(tauri_plugin_global_shortcut::Builder::new().build()) 38 | .plugin(tauri_plugin_dialog::init()) 39 | .plugin(tauri_plugin_process::init()) 40 | .plugin(tauri_plugin_updater::Builder::new().build()) 41 | .plugin(tauri_plugin_autostart::Builder::new().build()) 42 | .setup(|app| { 43 | let db = Database::new(app.handle()).expect("数据库初始化失败"); 44 | let translation_service = TranslationService::OpenAI; 45 | 46 | match db.get_app_config() { 47 | Ok(Some(config)) => { 48 | if let Err(err) = configure_http_client(Some(&config.proxy)) { 49 | eprintln!("初始化代理配置失败: {}", err); 50 | } 51 | } 52 | Ok(None) => { 53 | if let Err(err) = configure_http_client(None) { 54 | eprintln!("使用默认HTTP客户端失败: {}", err); 55 | } 56 | } 57 | Err(err) => { 58 | eprintln!("加载初始配置失败: {}", err); 59 | if let Err(init_err) = configure_http_client(None) { 60 | eprintln!("使用默认HTTP客户端失败: {}", init_err); 61 | } 62 | } 63 | } 64 | 65 | app.manage(AppState { 66 | db: Mutex::new(db), 67 | translation_service: Mutex::new(translation_service), 68 | }); 69 | 70 | register_shortcuts(app.handle()); 71 | setup_system_tray(app.handle())?; 72 | 73 | Ok(()) 74 | }) 75 | .on_window_event(|window, event| { 76 | if window.label() == "main" { 77 | if let tauri::WindowEvent::CloseRequested { api, .. } = event { 78 | api.prevent_close(); 79 | let _ = window.hide(); 80 | } 81 | } 82 | }) 83 | .invoke_handler(tauri::generate_handler![ 84 | translate_text, 85 | save_translation, 86 | get_translation_history, 87 | search_history, 88 | clear_history, 89 | save_setting, 90 | get_setting, 91 | save_api_key, 92 | get_api_key, 93 | save_app_config, 94 | get_app_config, 95 | reload_shortcuts, 96 | capture_screen, 97 | capture_screen_area, 98 | capture_and_ocr, 99 | capture_area_and_ocr, 100 | submit_area_for_ocr, 101 | start_area_selection, 102 | set_ocr_result, 103 | get_supported_languages, 104 | fetch_available_models 105 | ]) 106 | .run(tauri::generate_context!()) 107 | .expect("应用启动失败"); 108 | } 109 | -------------------------------------------------------------------------------- /src/components/BottomToolbar.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 68 | 69 | 156 | -------------------------------------------------------------------------------- /src-tauri/src/platform/macos.rs: -------------------------------------------------------------------------------- 1 | use crate::{app_state::AppState, ocr_tasks::run_ocr_on_image_data, system_tray::show_main_window}; 2 | use tauri::Emitter; 3 | use tauri::{AppHandle, Manager, State}; 4 | use tokio::{fs, process::Command}; 5 | 6 | #[tauri::command] 7 | pub async fn submit_area_for_ocr( 8 | app_handle: AppHandle, 9 | _x: i32, 10 | _y: i32, 11 | _width: u32, 12 | _height: u32, 13 | _state: State<'_, AppState>, 14 | ) -> Result<(), String> { 15 | start_area_selection(app_handle).await 16 | } 17 | 18 | pub async fn start_area_selection(app_handle: AppHandle) -> Result<(), String> { 19 | use std::time::{SystemTime, UNIX_EPOCH}; 20 | 21 | if let Some(main_window) = app_handle.get_webview_window("main") { 22 | main_window 23 | .emit("ocr-pending", true) 24 | .map_err(|e| e.to_string())?; 25 | main_window 26 | .hide() 27 | .map_err(|e| format!("隐藏主窗口失败: {}", e))?; 28 | } 29 | 30 | tauri::async_runtime::spawn(async move { 31 | let mut temp_path = std::env::temp_dir(); 32 | let timestamp = SystemTime::now() 33 | .duration_since(UNIX_EPOCH) 34 | .map(|d| d.as_millis()) 35 | .unwrap_or(0); 36 | temp_path.push(format!("prism_capture_{}.png", timestamp)); 37 | 38 | let output = Command::new("screencapture") 39 | .arg("-i") 40 | .arg("-x") 41 | .arg(&temp_path) 42 | .output() 43 | .await; 44 | 45 | let capture_result = match output { 46 | Ok(output) => { 47 | let has_file = match fs::metadata(&temp_path).await { 48 | Ok(metadata) => metadata.len() > 0, 49 | Err(_) => false, 50 | }; 51 | 52 | if has_file { 53 | fs::read(&temp_path) 54 | .await 55 | .map_err(|e| format!("读取截图失败: {}", e)) 56 | } else { 57 | let stderr_message = String::from_utf8_lossy(&output.stderr).trim().to_string(); 58 | let stderr_lower = stderr_message.to_lowercase(); 59 | let permission_denied = stderr_lower 60 | .contains("could not create image from rect") 61 | || stderr_lower.contains("not authorized") 62 | || stderr_lower.contains("permission"); 63 | 64 | if permission_denied { 65 | Err("无法截图:请在“系统设置 -> 隐私与安全 -> 屏幕录制”中允许 Prism(或终端)访问屏幕,然后重新尝试。".to_string()) 66 | } else if matches!(output.status.code(), Some(1) | Some(2)) { 67 | Err("用户取消截图".to_string()) 68 | } else if !stderr_message.is_empty() { 69 | Err(format!("截图失败: {}", stderr_message)) 70 | } else { 71 | Err(match output.status.code() { 72 | Some(code) => format!("截图命令失败 (code {})", code), 73 | None => "截图命令被中断".to_string(), 74 | }) 75 | } 76 | } 77 | } 78 | Err(e) => Err(format!("调用截图工具失败: {}", e)), 79 | }; 80 | 81 | let _ = fs::remove_file(&temp_path).await; 82 | 83 | let ocr_result = match capture_result { 84 | Ok(image_data) => { 85 | show_main_window(&app_handle); 86 | let state = app_handle.state::(); 87 | run_ocr_on_image_data(image_data, state).await 88 | } 89 | Err(err) => { 90 | show_main_window(&app_handle); 91 | Err(err) 92 | } 93 | }; 94 | 95 | if let Some(main_window) = app_handle.get_webview_window("main") { 96 | match ocr_result { 97 | Ok(text) => { 98 | let _ = main_window.emit("ocr-result", text); 99 | } 100 | Err(err) => { 101 | let _ = main_window.emit("ocr-result", format!("Error: {}", err)); 102 | } 103 | } 104 | } 105 | }); 106 | 107 | Ok(()) 108 | } 109 | -------------------------------------------------------------------------------- /README.ko.md: -------------------------------------------------------------------------------- 1 | # Prism - AI 번역 소프트웨어 2 | 3 |
4 | 5 | **[English](./README.md) | [中文](./README.zh.md) | [Español](./README.es.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [Tiếng Việt](./README.vi.md)** 6 | 7 | 🚀 고급 언어 모델과 OCR 기술로 구동되는 강력한 크로스플랫폼 AI 번역 애플리케이션. 8 | 9 | [다운로드](#다운로드) • [기능](#기능) • [빠른-시작](#빠른-시작) • [문서](#문서) 10 | 11 |
12 | 13 | --- 14 | 아이콘 15 | 16 | ## 기능 17 | 18 | - **🌍 크로스플랫폼 지원** - Windows, macOS, Linux 모두 동일한 기능 제공 19 | - **🤖 고급 AI 번역** - Tencent Hunyuan-MT-7B 기반의 문맥 인지 번역 20 | - **📸 내장 OCR** - Qwen3-VL-8B-Instruct로 스크린샷에서 텍스트 추출 및 번역 21 | - **⚡ 초고속 번역** - 실시간, 저지연 번역 22 | - **🎯 직관적 UI** - Vue 3 기반의 부드러운 인터페이스 23 | - **🔗 글로벌 핫키** - 커스텀 가능한 단축키(개발 중) 24 | - **💾 로컬 기록** - SQLite에 번역 내역 저장 25 | - **🎨 현대적 아키텍처** - Tauri + Rust로 높은 성능과 보안 26 | 27 | --- 28 | 29 | ## 기술 스택 30 | 31 | ### 프론트엔드 32 | 33 | - **Vue 3** (3.5.13) - 현대적 프로그레시브 프레임워크 34 | - **Vite** (6.0.3) - 차세대 빌드 도구 35 | - **Tauri UI 컴포넌트** - 네이티브급 데스크톱 경험 36 | 37 | ### 백엔드 38 | 39 | - **Rust** (2021 edition) - 고성능 시스템 언어 40 | - **Tauri** (2.9.3) - 경량 데스크톱 프레임워크 41 | - **Tokio** (1.48.0) - 비동기 런타임 42 | 43 | ### AI 및 처리 44 | 45 | - **번역 모델** - Tencent Hunyuan-MT-7B 46 | - **OCR 모델** - Qwen3-VL-8B-Instruct 47 | - **API 제공자** - SiliconFlow 48 | - **OpenAI 호환 커스텀 모델 완전 지원** 49 | 50 | ### 저장소 및 라이브러리 51 | 52 | - **SQLite** (rusqlite 0.37.0) - 로컬 데이터베이스 53 | - **Reqwest** (0.12.24) - HTTP 클라이언트 54 | - **이미지 처리** (0.25.9) - 스크린샷 및 이미지 처리 55 | - **글로벌 핫키** (2.3.1) - 키보드 단축키 플러그인 56 | 57 | --- 58 | 59 | ## 빠른 시작 60 | 61 | ### 필요 환경 62 | 63 | - Rust 1.91.0 이상 64 | - Node.js 18+ 및 pnpm 65 | - Git 66 | 67 | ### 설치 68 | 69 | **1. 리포지토리 클론** 70 | ```bash 71 | git clone https://github.com/qyzhg/prism.git 72 | cd prism 73 | ``` 74 | 75 | **2. 의존성 설치** 76 | #### 프론트엔드 의존성 77 | ```bash 78 | pnpm install 79 | ``` 80 | 81 | #### Rust 의존성은 Cargo가 관리 82 | 83 | **3. API Key 준비** 84 | - OpenAI 호환 Base URL과 API Key를 입력하면 바로 사용 가능. 85 | - 초대 링크로 SiliconFlow 가입 시 무료 크레딧 제공: [https://cloud.siliconflow.cn/i/QhM7Qyuq](https://cloud.siliconflow.cn/i/QhM7Qyuq) 86 | 87 | **4. 개발 모드 실행** 88 | ```bash 89 | pnpm tauri dev 90 | ``` 91 | 92 | **5. 프로덕션 빌드** 93 | ```bash 94 | pnpm tauri build 95 | ``` 96 | 97 | --- 98 | 99 | ## 다운로드 100 | 101 | | 플랫폼 | 링크 | 102 | |-------|------| 103 | | 🪟 Windows | [최신 버전](https://github.com/qyzhg/prism/releases) | 104 | | 🍎 macOS | [최신 버전](https://github.com/qyzhg/prism/releases) | 105 | | 🐧 Linux | 곧 제공 | 106 | 107 | ### macOS 설치 메모 108 | 109 | Prism는 ad-hoc 서명으로 배포되므로 첫 실행 시 Gatekeeper 경고가 뜰 수 있습니다. 110 | 111 | 1. `Prism.app`을 `/Applications`로 이동합니다. 112 | 2. **Terminal**에서 다음을 실행: 113 | ```bash 114 | xattr -cr /Applications/prism.app 115 | sudo spctl --add --label Prism /Applications/prism.app 116 | ``` 117 | 3. Finder에서 우클릭(또는 Control+클릭) 후 **Open**을 선택해 한 번 허용하면 이후엔 정상 실행됩니다. 118 | 119 | --- 120 | 121 | ## 문서 122 | 123 | ### 설정 124 | 125 | 설정 패널에서 다음을 관리할 수 있습니다: 126 | 127 | - 기본 언어 쌍 선택 128 | - API Key 관리 129 | - 단축키 커스터마이즈(개발 중) 130 | 131 | ### 핫키 132 | 133 | 개발 중 - 곧 제공 134 | 135 | ### AI 모델 136 | 137 | - **번역 모델** - `tencent/Hunyuan-MT-7B` 엔터프라이즈 다국어 번역 138 | - **OCR 모델** - `Qwen/Qwen3-VL-8B-Instruct` 고급 비전-언어 모델 139 | 140 | --- 141 | 142 | ## 로드맵 143 | 144 | - [x] 핵심 번역 기능 145 | - [x] 스크린샷 OCR 통합 146 | - [x] 커스텀 단축키 설정 147 | - [ ] 번역 메모리 및 용어집 148 | - [ ] 파일 일괄 번역 149 | - [ ] 플러그인 생태계 150 | - [ ] 모바일 동반 앱 151 | 152 | --- 153 | 154 | ## 자주 묻는 질문(FAQ) 155 | 156 | **Q: 무료로 사용할 수 있나요?** 157 | 네. SiliconFlow 초대 링크로 가입하면 장기간 쓸 수 있는 무료 크레딧을 받을 수 있습니다. 158 | 159 | **Q: 어떤 언어를 지원하나요?** 160 | Tencent Hunyuan-MT-7B는 중국어, 영어, 일본어, 한국어 등 주요 언어의 상호 번역을 지원합니다. 원하는 모델을 선택해도 됩니다. 161 | 162 | **Q: 데이터가 저장되나요?** 163 | 번역 기록은 로컬 SQLite에 저장되며 서버로 전송되지 않습니다. 개인정보는 보호됩니다. 164 | 165 | **Q: 오프라인 사용이 가능한가요?** 166 | 온라인 모델은 연결이 필요합니다. 로컬 모델을 사용하면 오프라인도 가능합니다. 167 | 168 | **Q: 핫키는 언제쯤 사용 가능한가요?** 169 | 현재 개발 중이며 곧 제공될 예정입니다. 170 | 171 | --- 172 | 173 | ## 기여 174 | 175 | Issue와 Pull Request를 환영합니다. 기여를 기다립니다! 176 | 177 | --- 178 | 179 | ## 라이선스 180 | 181 | MIT 라이선스. 자세한 내용은 [LICENSE](LICENSE) 참고. 182 | 183 | --- 184 | 185 | ## 감사의 말 186 | 187 | - [Tauri](https://tauri.app/)로 제작 188 | - 개발 중 번역 서비스는 [SiliconFlow](https://siliconflow.cn/) 제공 189 | - UI 프레임워크는 [Vue 3](https://vuejs.org/) 190 | 191 | --- 192 | 193 | ## 도움말 194 | 195 | - 🐛 버그 신고: [GitHub Issues](https://github.com/qyzhg/prism/issues) 196 | 197 | --- 198 | 199 |
200 | 201 | ❤️ Prism 팀@pity 개발 202 | 203 | **[⬆ 맨 위로](#prism---ai-번역-소프트웨어)** 204 | 205 |
206 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | # Prism - AI翻訳ソフトウェア 2 | 3 |
4 | 5 | **[English](./README.md) | [中文](./README.zh.md) | [Español](./README.es.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [Tiếng Việt](./README.vi.md)** 6 | 7 | 🚀 先進的な言語モデルとOCRを搭載したクロスプラットフォームAI翻訳アプリ。 8 | 9 | [ダウンロード](#ダウンロード) • [機能](#機能) • [クイックスタート](#クイックスタート) • [ドキュメント](#ドキュメント) 10 | 11 |
12 | 13 | --- 14 | アイコン 15 | 16 | ## 機能 17 | 18 | - **🌍 クロスプラットフォーム対応** - Windows / macOS / Linux で同等の機能を提供 19 | - **🤖 高度なAI翻訳** - Tencent Hunyuan-MT-7B を採用し、文脈を考慮した正確な翻訳 20 | - **📸 内蔵OCR** - Qwen3-VL-8B-Instruct でスクリーンショットから直接テキスト抽出・翻訳 21 | - **⚡ 高速翻訳** - リアルタイムかつ低遅延 22 | - **🎯 使いやすいUI** - Vue 3 ベースの直感的でスムーズなUI 23 | - **🔗 グローバルホットキー** - カスタマイズ可能なショートカット(開発中) 24 | - **💾 ローカル履歴** - SQLite に翻訳履歴を保存 25 | - **🎨 モダンなアーキテクチャ** - Tauri + Rust による高性能かつ安全な構成 26 | 27 | --- 28 | 29 | ## 技術スタック 30 | 31 | ### フロントエンド 32 | 33 | - **Vue 3** (3.5.13) - 近代的なプログレッシブフレームワーク 34 | - **Vite** (6.0.3) - 次世代ビルドツール 35 | - **Tauri UI コンポーネント** - ネイティブに近いデスクトップ体験 36 | 37 | ### バックエンド 38 | 39 | - **Rust** (2021 edition) - 高性能システム言語 40 | - **Tauri** (2.9.3) - 軽量デスクトップフレームワーク 41 | - **Tokio** (1.48.0) - 非同期ランタイム 42 | 43 | ### AI と処理 44 | 45 | - **翻訳モデル** - Tencent Hunyuan-MT-7B 46 | - **OCRモデル** - Qwen3-VL-8B-Instruct 47 | - **APIプロバイダ** - SiliconFlow 48 | - **OpenAI互換のカスタムモデルを幅広くサポート** 49 | 50 | ### ストレージとライブラリ 51 | 52 | - **SQLite** (rusqlite 0.37.0) - ローカルデータベース 53 | - **Reqwest** (0.12.24) - HTTPクライアント 54 | - **画像処理** (0.25.9) - スクリーンショットと画像処理 55 | - **グローバルホットキー** (2.3.1) - キーボードショートカットプラグイン 56 | 57 | --- 58 | 59 | ## クイックスタート 60 | 61 | ### 必要環境 62 | 63 | - Rust 1.91.0 以上 64 | - Node.js 18+ と pnpm 65 | - Git 66 | 67 | ### セットアップ 68 | 69 | **1. リポジトリをクローン** 70 | ```bash 71 | git clone https://github.com/qyzhg/prism.git 72 | cd prism 73 | ``` 74 | 75 | **2. 依存関係をインストール** 76 | #### フロントエンド依存 77 | ```bash 78 | pnpm install 79 | ``` 80 | 81 | #### Rust 依存は Cargo が管理 82 | 83 | **3. API キーを取得** 84 | - OpenAI 互換の Base URL と API Key を用意すれば利用開始できます。 85 | - 招待リンクから SiliconFlow に登録すると無料クレジットが得られます: [https://cloud.siliconflow.cn/i/QhM7Qyuq](https://cloud.siliconflow.cn/i/QhM7Qyuq) 86 | 87 | **4. 開発モードで起動** 88 | ```bash 89 | pnpm tauri dev 90 | ``` 91 | 92 | **5. 本番ビルド** 93 | ```bash 94 | pnpm tauri build 95 | ``` 96 | 97 | --- 98 | 99 | ## ダウンロード 100 | 101 | | プラットフォーム | リンク | 102 | |----------------|-------| 103 | | 🪟 Windows | [最新版](https://github.com/qyzhg/prism/releases) | 104 | | 🍎 macOS | [最新版](https://github.com/qyzhg/prism/releases) | 105 | | 🐧 Linux | 近日対応 | 106 | 107 | ### macOS インストールメモ 108 | 109 | Prism は ad-hoc 署名のため、初回起動時に Gatekeeper の警告が表示されます。 110 | 111 | 1. `Prism.app` を `/Applications` に移動。 112 | 2. **Terminal** で以下を実行: 113 | ```bash 114 | xattr -cr /Applications/prism.app 115 | sudo spctl --add --label Prism /Applications/prism.app 116 | ``` 117 | 3. Finder で右クリック(または Control+クリック)し **開く** を選択して一度承認すれば、次回以降は通常起動できます。 118 | 119 | --- 120 | 121 | ## ドキュメント 122 | 123 | ### 設定 124 | 125 | 設定パネルで翻訳の好みを管理できます: 126 | 127 | - デフォルトの言語ペア選択 128 | - API キー管理 129 | - ホットキーのカスタマイズ(開発中) 130 | 131 | ### ホットキー 132 | 133 | 開発中 - 近日公開 134 | 135 | ### AI モデル 136 | 137 | - **翻訳モデル** - `tencent/Hunyuan-MT-7B` エンタープライズ向け多言語翻訳 138 | - **OCRモデル** - `Qwen/Qwen3-VL-8B-Instruct` 高度なビジョン・ランゲージ 139 | 140 | --- 141 | 142 | ## ロードマップ 143 | 144 | - [x] 基本の翻訳機能 145 | - [x] スクリーンショットOCR統合 146 | - [x] カスタムホットキー設定 147 | - [ ] 翻訳メモリと用語管理 148 | - [ ] ファイルのバッチ翻訳 149 | - [ ] プラグインエコシステム 150 | - [ ] モバイル連携アプリ 151 | 152 | --- 153 | 154 | ## FAQ 155 | 156 | **Q: 無料で使えますか?** 157 | はい。招待リンク経由で SiliconFlow に登録すると、長期間使える無料クレジットがもらえます。 158 | 159 | **Q: どの言語に対応していますか?** 160 | Tencent Hunyuan-MT-7B は主要言語(中国語・英語・日本語・韓国語など)の相互翻訳に対応しています。好みのモデルを使うことも可能です。 161 | 162 | **Q: データは保存されますか?** 163 | 翻訳履歴はローカルの SQLite に保存され、サーバーへは送信されません。プライバシーは保護されます。 164 | 165 | **Q: オフラインで使えますか?** 166 | オンラインモデルは接続が必須です。ローカルモデルを利用する場合はオフラインも可能です。 167 | 168 | **Q: ホットキーはいつ使えますか?** 169 | 現在開発中で、近日リリース予定です。 170 | 171 | --- 172 | 173 | ## コントリビュート 174 | 175 | Issue と Pull Request を歓迎します。ぜひ貢献してください! 176 | 177 | --- 178 | 179 | ## ライセンス 180 | 181 | MIT ライセンスです。詳細は [LICENSE](LICENSE) を参照してください。 182 | 183 | --- 184 | 185 | ## 謝辞 186 | 187 | - [Tauri](https://tauri.app/) によって構築 188 | - 開発中の翻訳サービスは [SiliconFlow](https://siliconflow.cn/) 提供 189 | - UI フレームワークは [Vue 3](https://vuejs.org/) 190 | 191 | --- 192 | 193 | ## サポート 194 | 195 | - 🐛 バグ報告: [GitHub Issues](https://github.com/qyzhg/prism/issues) 196 | 197 | --- 198 | 199 |
200 | 201 | ❤️ Prism チーム@pity による開発 202 | 203 | **[⬆ トップに戻る](#prism---ai翻訳ソフトウェア)** 204 | 205 |
206 | -------------------------------------------------------------------------------- /src/AreaSelector.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 123 | 124 | 158 | -------------------------------------------------------------------------------- /src/components/CustomSelect.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 100 | 101 | 204 | 205 | -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # Prism - AI翻译软件 2 | 3 |
4 | 5 | **[中文](./README.zh.md) | [English](./README.md) | [Español](./README.es.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [Tiếng Việt](./README.vi.md)** 6 | 7 | 🚀 一款强大的跨平台AI翻译应用,采用先进的语言模型和OCR技术。 8 | 9 | [下载](#下载) • [功能特性](#功能特性) • [快速开始](#快速开始) • [文档](#文档) 10 | 11 |
12 | 13 | --- 14 | 图标 15 | 16 | ## 功能特性 17 | 18 | - **🌍 跨平台支持** - 完美支持 Windows、macOS 和 Linux,功能完全一致 19 | - **🤖 先进的AI翻译** - 完全自由的ai配置接口 20 | - **📸 内置OCR识别** - 完全自由的ai配置接口 21 | - **⚡ 极速翻译** - 实时翻译,延迟最小化 22 | - **🎯 易用的界面** - 基于Vue 3的直观用户界面,交互流畅 23 | - **🔗 全局快捷键** - 自定义快捷键快速访问(开发中) 24 | - **💾 本地翻译历史** - 基于SQLite的翻译记录存储 25 | - **🎨 现代化架构** - 基于Tauri + Rust构建,性能优异且安全可靠 26 | 27 | --- 28 | 29 | ## 技术栈 30 | 31 | ### 前端技术 32 | 33 | - **Vue 3** (3.5.13) - 现代化的渐进式JavaScript框架 34 | - **Vite** (6.0.3) - 下一代构建工具 35 | - **Tauri UI组件** - 原生级桌面应用体验 36 | 37 | ### 后端技术 38 | 39 | - **Rust** (2021 edition) - 高性能系统编程语言 40 | - **Tauri** (2.9.3) - 轻量级桌面应用框架 41 | - **Tokio** (1.48.0) - 异步运行时,支持并发操作 42 | 43 | ### AI与处理 44 | 45 | - **推荐翻译模型** - Qwen3-Next-80B-A3B-Instruct 46 | - **OCR模型** - Qwen3-VL-30B-A3B-Instruct 47 | - **API提供商** - 硅基流动 (SiliconFlow) 48 | - **完全支持openai协议的所有AI自定义** 49 | 50 | ### 存储与工具库 51 | 52 | - **SQLite** (rusqlite 0.37.0) - 本地数据库存储 53 | - **Reqwest** (0.12.24) - HTTP客户端 54 | - **图像处理** (0.25.9) - 截图和图像处理 55 | - **全局快捷键** (2.3.1) - 键盘快捷键插件 56 | 57 | --- 58 | 59 | ## 快速开始 60 | 61 | ### 环境要求 62 | 63 | - Rust 1.91.0 或更高版本 64 | - Node.js 18+ 和 pnpm 65 | - Git 66 | 67 | ### 安装步骤 68 | 69 | **1. 克隆仓库** 70 | ``` bash 71 | git clone https://github.com/qyzhg/prism.git cd prism 72 | ``` 73 | 74 | **2. 安装依赖** 75 | #### 安装前端依赖 76 | ```bash 77 | pnpm install 78 | ``` 79 | 80 | #### Rust依赖由Cargo管理 81 | **3. 获取API密钥** 82 | - 使用自己的兼容openai的baseurl和API密钥,即可开始使用。 83 | - 使用邀请链接注册硅基流动账户:[https://cloud.siliconflow.cn/i/QhM7Qyuq](https://cloud.siliconflow.cn/i/QhM7Qyuq)注册后会获得赠金额度(有效期很长) 84 | 85 | **4. 开发模式运行** 86 | ```bash 87 | pnpm tauri dev 88 | ``` 89 | **5. 生产环境构建** 90 | ```bash 91 | pnpm tauri build 92 | ``` 93 | 94 | ## 自动更新 & 发布 95 | 96 | 借助内置的 `tauri-plugin-updater` 插件和 `.github/workflows/release.yml`,项目可以通过 GitHub Releases 分发更新,并在客户端内自动检测/安装。 97 | 98 | ### 1. 生成签名密钥 99 | 100 | Tauri Updater 需要对安装包进行签名: 101 | 102 | ```bash 103 | pnpm tauri signer generate 104 | ``` 105 | 106 | 命令会输出 **Private Key**、**Public Key** 以及可选的密码。请妥善保存: 107 | 108 | - `TAURI_PRIVATE_KEY`:复制整段私钥(含 BEGIN/END)到 GitHub 仓库的 Secrets。 109 | - `TAURI_PUBLIC_KEY`:复制公钥,分别配置在本地环境变量(构建或调试时导出)以及 GitHub Secrets 中。 110 | - `TAURI_KEY_PASSWORD`:如果生成密钥时设置了密码,也需要作为 Secret 写入。 111 | 112 | 本地开发可在 shell 中导出公钥,例如: 113 | 114 | ```bash 115 | export TAURI_PUBLIC_KEY="your-public-key" 116 | ``` 117 | 118 | ### 2. 配置 GitHub Actions 119 | 120 | 在仓库的 **Settings → Secrets and variables → Actions** 中新增: 121 | 122 | | 名称 | 说明 | 123 | | --- | --- | 124 | | `TAURI_PRIVATE_KEY` | 第一步生成的私钥内容 | 125 | | `TAURI_PUBLIC_KEY` | 与 `tauri.conf.json` 一致的公钥 | 126 | | `TAURI_KEY_PASSWORD` | 如果设置了密码则必填,未设置可留空 | 127 | 128 | `release` 工作流会在 macOS / Windows / Linux 三个平台构建应用、上传产物,并生成 `latest.json` 供客户端更新使用。 129 | 130 | ### 3. 触发发布 131 | 132 | 1. 更新 `package.json` 以及 `src-tauri/tauri.conf.json` 中的版本号。 133 | 2. 提交并 push。 134 | 3. 在 GitHub 上创建 **Release**(或手动触发 `Release` workflow)。 135 | 136 | 工作流执行完成后,Release 页面会包含各平台安装包以及 `latest.json`。 137 | 138 | ### 4. 客户端更新体验 139 | 140 | - 应用启动后会自动检查一次更新,并通过系统对话框提示是否下载。 141 | - “设置 → 软件更新” 中可以查看当前版本、手动检查以及执行更新,过程会展示下载进度。 142 | - 所有更新包都来自 GitHub Releases,并通过签名验证,确保来源可靠。 143 | 144 | --- 145 | 146 | ## 下载 147 | 148 | | 平台 | 下载链接 | 149 | |------|---------| 150 | | 🪟 Windows | [最新版本](https://github.com/qyzhg/prism/releases) | 151 | | 🍎 macOS | [最新版本](https://github.com/qyzhg/prism/releases) | 152 | | 🐧 Linux | 敬请期待 | 153 | 154 | ### macOS 安装提示 155 | 156 | 我们只能使用免费(Ad-hoc)的签名方式,苹果不会信任没有付费证书的应用,所以第一次运行时仍然会出现“Prism.app 已损坏/无法打开”的 Gatekeeper 警告,这是所有开源免费应用的常态。解决方法: 157 | 158 | 1. 将 `Prism.app` 拖到 `/Applications`。 159 | 2. 打开 **终端** 执行: 160 | ```bash 161 | xattr -cr /Applications/prism.app 162 | sudo spctl --add --label Prism /Applications/prism.app 163 | ``` 164 | 3. 回到 Finder,右键(或按住 Control 点击)`Prism.app` 选择“打开”,确认一次即可。之后就能像普通应用一样直接打开。 165 | 166 | --- 167 | 168 | ## 文档 169 | 170 | ### 配置说明 171 | 172 | 在设置面板中配置您的翻译偏好: 173 | 174 | - 默认语言对选择 175 | - API密钥管理 176 | - 快捷键自定义(开发中) 177 | 178 | ### 快捷键 179 | 180 | 当前开发中 - 敬请期待 181 | 182 | ### AI模型 183 | 184 | - **翻译模型** - `Qwen/Qwen3-Next-80B-A3B-Instruct` 企业级多语言翻译 185 | - **OCR模型** - `Qwen/Qwen3-VL-30B-A3B-Instruct` 高级视觉语言理解 186 | 187 | --- 188 | 189 | ## 开发路线图 190 | 191 | - [x] 核心翻译功能 192 | - [x] 截图OCR集成 193 | - [x] 自定义快捷键配置 194 | - [ ] 翻译记忆和术语表管理 195 | - [ ] 批量文件翻译 196 | - [ ] 插件生态系统 197 | - [ ] 移动端配套应用 198 | - [x] 代理 199 | - [ ] 自带魔法的国际AI支持(有成本,可能不会免费) 200 | 201 | --- 202 | 203 | ## 常见问题 (FAQ) 204 | 205 | **Q: 我可以免费使用吗?** 206 | 207 | A: 可以!使用我们的邀请链接注册硅基流动账户,即可获得免费赠金额度,足以长期使用。 208 | 209 | **Q: 支持哪些语言?** 210 | 211 | A: 腾讯混元-MT-7B 模型支持多种主流语言的互译,包括中文、英文、日文、韩文等。或者使用自己喜欢的模型进行翻译。 212 | 213 | **Q: 我的数据会被保存吗?** 214 | 215 | A: 翻译历史会保存在本地SQLite数据库中,不会上传到服务器。您的隐私得到完全保护。 216 | 217 | **Q: 可以离线使用吗?** 218 | 219 | A: 使用在线模型不可以,翻译和OCR功能需要连接到模型API。如果使用本地模型,可以离线使用。 220 | 221 | **Q: 快捷键功能何时上线?** 222 | 223 | A: 快捷键功能目前正在开发中,即将推出。 224 | 225 | --- 226 | 227 | ## 贡献指南 228 | 229 | 欢迎提交问题和拉取请求!我们很高兴能得到您的贡献。 230 | 231 | --- 232 | 233 | ## 许可证 234 | 235 | 本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件 236 | 237 | --- 238 | 239 | ## 致谢 240 | 241 | - 基于 [Tauri](https://tauri.app/) 构建 242 | - 开发中使用的翻译功能由 [硅基流动](https://siliconflow.cn/) 提供 243 | - UI框架采用 [Vue 3](https://vuejs.org/) 244 | 245 | --- 246 | 247 | ## 获取帮助 248 | 249 | - 🐛 问题反馈:[GitHub Issues](https://github.com/qyzhg/prism/issues) 250 | 251 | --- 252 | 253 |
254 | 255 | ❤️ 由 Prism 团队@pity开发 256 | 257 | **[⬆ 返回顶部](#prism---ai翻译软件)** 258 | 259 |
260 | -------------------------------------------------------------------------------- /src-tauri/src/http_client.rs: -------------------------------------------------------------------------------- 1 | use crate::database::{ProxyConfig, ProxyMode}; 2 | use reqwest::{Client, ClientBuilder, Proxy}; 3 | use std::sync::{OnceLock, RwLock}; 4 | use std::env; 5 | 6 | const PROXY_ENV_KEYS: [&str; 6] = [ 7 | "HTTP_PROXY", 8 | "http_proxy", 9 | "HTTPS_PROXY", 10 | "https_proxy", 11 | "ALL_PROXY", 12 | "all_proxy", 13 | ]; 14 | 15 | static ORIGINAL_PROXY_ENV: OnceLock)>> = OnceLock::new(); 16 | 17 | static HTTP_CLIENT: OnceLock> = OnceLock::new(); 18 | 19 | fn client_lock() -> &'static RwLock { 20 | HTTP_CLIENT.get_or_init(|| { 21 | let client = build_client(None).expect("Failed to create initial HTTP client"); 22 | RwLock::new(client) 23 | }) 24 | } 25 | 26 | fn build_client(proxy: Option<&ProxyConfig>) -> Result { 27 | let mut builder = Client::builder() 28 | .timeout(std::time::Duration::from_secs(30)) 29 | .connect_timeout(std::time::Duration::from_secs(10)); 30 | 31 | if let Some(config) = proxy { 32 | if config.enabled { 33 | builder = apply_proxy(builder, config)?; 34 | } 35 | } 36 | 37 | builder.build() 38 | } 39 | 40 | fn apply_proxy( 41 | builder: ClientBuilder, 42 | config: &ProxyConfig, 43 | ) -> Result { 44 | match config.mode { 45 | ProxyMode::System => configure_system_proxy(builder), 46 | ProxyMode::Https | ProxyMode::Http | ProxyMode::Socks5 => { 47 | let server = config.server.trim(); 48 | if server.is_empty() { 49 | return Ok(builder); 50 | } 51 | let proxy = match config.mode { 52 | ProxyMode::Https => Proxy::https(server)?, 53 | ProxyMode::Http => Proxy::http(server)?, 54 | ProxyMode::Socks5 => Proxy::all(server)?, 55 | ProxyMode::System => unreachable!(), 56 | }; 57 | apply_single_proxy(builder, proxy) 58 | } 59 | } 60 | } 61 | 62 | fn apply_single_proxy( 63 | builder: ClientBuilder, 64 | proxy: Proxy, 65 | ) -> Result { 66 | Ok(builder.proxy(proxy)) 67 | } 68 | 69 | fn configure_system_proxy(builder: ClientBuilder) -> Result { 70 | let mut builder = builder; 71 | 72 | if let Some(url) = env_proxy_value(&["ALL_PROXY", "all_proxy"]) { 73 | builder = builder.proxy(Proxy::all(url)?); 74 | } 75 | 76 | if let Some(url) = env_proxy_value(&["HTTPS_PROXY", "https_proxy"]) { 77 | builder = builder.proxy(Proxy::https(url)?); 78 | } 79 | 80 | if let Some(url) = env_proxy_value(&["HTTP_PROXY", "http_proxy"]) { 81 | builder = builder.proxy(Proxy::http(url)?); 82 | } 83 | 84 | Ok(builder) 85 | } 86 | 87 | fn env_proxy_value(keys: &[&str]) -> Option { 88 | for key in keys { 89 | if let Ok(value) = std::env::var(key) { 90 | let trimmed = value.trim().to_string(); 91 | if !trimmed.is_empty() { 92 | return Some(trimmed); 93 | } 94 | } 95 | } 96 | None 97 | } 98 | 99 | fn capture_original_proxy_env() -> &'static Vec<(String, Option)> { 100 | ORIGINAL_PROXY_ENV.get_or_init(|| { 101 | PROXY_ENV_KEYS 102 | .iter() 103 | .map(|key| (key.to_string(), env::var(key).ok())) 104 | .collect() 105 | }) 106 | } 107 | 108 | fn clear_runtime_proxy_env() { 109 | for key in PROXY_ENV_KEYS { 110 | env::remove_var(key); 111 | } 112 | } 113 | 114 | fn restore_proxy_environment() { 115 | let original = capture_original_proxy_env(); 116 | for (key, value) in original { 117 | match value { 118 | Some(val) => env::set_var(key, val), 119 | None => env::remove_var(key), 120 | } 121 | } 122 | } 123 | 124 | fn set_proxy_env_var(key: &str, value: &str) { 125 | if value.is_empty() { 126 | env::remove_var(key); 127 | } else { 128 | env::set_var(key, value); 129 | } 130 | } 131 | 132 | fn apply_proxy_environment(proxy: Option<&ProxyConfig>) { 133 | capture_original_proxy_env(); 134 | 135 | let Some(config) = proxy else { 136 | clear_runtime_proxy_env(); 137 | return; 138 | }; 139 | 140 | if !config.enabled { 141 | clear_runtime_proxy_env(); 142 | return; 143 | } 144 | 145 | if matches!(config.mode, ProxyMode::System) { 146 | restore_proxy_environment(); 147 | return; 148 | } 149 | 150 | let server = config.server.trim(); 151 | if server.is_empty() { 152 | clear_runtime_proxy_env(); 153 | return; 154 | } 155 | 156 | clear_runtime_proxy_env(); 157 | set_proxy_env_var("HTTP_PROXY", server); 158 | set_proxy_env_var("http_proxy", server); 159 | set_proxy_env_var("HTTPS_PROXY", server); 160 | set_proxy_env_var("https_proxy", server); 161 | 162 | if matches!(config.mode, ProxyMode::Socks5) { 163 | set_proxy_env_var("ALL_PROXY", server); 164 | set_proxy_env_var("all_proxy", server); 165 | } else { 166 | env::remove_var("ALL_PROXY"); 167 | env::remove_var("all_proxy"); 168 | } 169 | } 170 | 171 | pub fn http_client() -> Client { 172 | client_lock() 173 | .read() 174 | .expect("Failed to read HTTP client") 175 | .clone() 176 | } 177 | 178 | pub fn configure_http_client(proxy: Option<&ProxyConfig>) -> Result<(), String> { 179 | let client = build_client(proxy).map_err(|e| e.to_string())?; 180 | apply_proxy_environment(proxy); 181 | if let Ok(mut guard) = client_lock().write() { 182 | *guard = client; 183 | Ok(()) 184 | } else { 185 | Err("无法更新HTTP客户端".to_string()) 186 | } 187 | } 188 | 189 | pub fn validate_http_client(proxy: Option<&ProxyConfig>) -> Result<(), String> { 190 | build_client(proxy).map(|_| ()).map_err(|e| e.to_string()) 191 | } 192 | -------------------------------------------------------------------------------- /README.vi.md: -------------------------------------------------------------------------------- 1 | # Prism - Phần mềm dịch thuật AI 2 | 3 |
4 | 5 | **[English](./README.md) | [中文](./README.zh.md) | [Español](./README.es.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [Tiếng Việt](./README.vi.md)** 6 | 7 | 🚀 Ứng dụng dịch đa nền tảng mạnh mẽ, sử dụng mô hình ngôn ngữ tiên tiến và công nghệ OCR. 8 | 9 | [Tải xuống](#tải-xuống) • [Tính-năng](#tính-năng) • [Bắt-đầu-nhanh](#bắt-đầu-nhanh) • [Tài-liệu](#tài-liệu) 10 | 11 |
12 | 13 | --- 14 | Biểu tượng 15 | 16 | ## Tính năng 17 | 18 | - **🌍 Đa nền tảng** - Hỗ trợ đầy đủ Windows, macOS và Linux với chức năng thống nhất 19 | - **🤖 Dịch AI nâng cao** - Dựa trên Tencent Hunyuan-MT-7B cho dịch thuật chính xác theo ngữ cảnh 20 | - **📸 OCR tích hợp** - Trích xuất và dịch văn bản trực tiếp từ ảnh chụp màn hình với Qwen3-VL-8B-Instruct 21 | - **⚡ Tốc độ cao** - Dịch thời gian thực với độ trễ tối thiểu 22 | - **🎯 Giao diện thân thiện** - UI trực quan trên Vue 3 với trải nghiệm mượt mà 23 | - **🔗 Phím tắt toàn cục** - Tùy chỉnh phím tắt (đang phát triển) 24 | - **💾 Lịch sử cục bộ** - Lưu lịch sử dịch trong SQLite 25 | - **🎨 Kiến trúc hiện đại** - Xây dựng bằng Tauri + Rust cho hiệu năng và bảo mật cao 26 | 27 | --- 28 | 29 | ## Công nghệ 30 | 31 | ### Frontend 32 | 33 | - **Vue 3** (3.5.13) - Framework JavaScript hiện đại 34 | - **Vite** (6.0.3) - Công cụ build thế hệ mới 35 | - **Thành phần UI Tauri** - Trải nghiệm ứng dụng desktop gần gũi native 36 | 37 | ### Backend 38 | 39 | - **Rust** (2021 edition) - Ngôn ngữ hệ thống hiệu năng cao 40 | - **Tauri** (2.9.3) - Framework desktop nhẹ 41 | - **Tokio** (1.48.0) - Runtime bất đồng bộ 42 | 43 | ### AI & xử lý 44 | 45 | - **Mô hình dịch** - Tencent Hunyuan-MT-7B 46 | - **Mô hình OCR** - Qwen3-VL-8B-Instruct 47 | - **Nhà cung cấp API** - SiliconFlow 48 | - **Hỗ trợ đầy đủ API tương thích OpenAI cho mô hình tùy chỉnh** 49 | 50 | ### Lưu trữ & thư viện 51 | 52 | - **SQLite** (rusqlite 0.37.0) - CSDL cục bộ 53 | - **Reqwest** (0.12.24) - HTTP client 54 | - **Xử lý ảnh** (0.25.9) - Chụp và xử lý ảnh 55 | - **Phím tắt toàn cục** (2.3.1) - Plugin phím tắt 56 | 57 | --- 58 | 59 | ## Bắt đầu nhanh 60 | 61 | ### Yêu cầu 62 | 63 | - Rust 1.91.0 trở lên 64 | - Node.js 18+ và pnpm 65 | - Git 66 | 67 | ### Cài đặt 68 | 69 | **1. Nhân bản kho mã** 70 | ```bash 71 | git clone https://github.com/qyzhg/prism.git 72 | cd prism 73 | ``` 74 | 75 | **2. Cài phụ thuộc** 76 | #### Phụ thuộc frontend 77 | ```bash 78 | pnpm install 79 | ``` 80 | 81 | #### Phụ thuộc Rust được quản lý bởi Cargo 82 | 83 | **3. Lấy API Key** 84 | - Dùng Base URL tương thích OpenAI và API Key của bạn để bắt đầu. 85 | - Đăng ký SiliconFlow qua link mời để nhận tín dụng miễn phí: [https://cloud.siliconflow.cn/i/QhM7Qyuq](https://cloud.siliconflow.cn/i/QhM7Qyuq) 86 | 87 | **4. Chạy chế độ phát triển** 88 | ```bash 89 | pnpm tauri dev 90 | ``` 91 | 92 | **5. Build bản sản xuất** 93 | ```bash 94 | pnpm tauri build 95 | ``` 96 | 97 | --- 98 | 99 | ## Tải xuống 100 | 101 | | Nền tảng | Liên kết | 102 | |---------|----------| 103 | | 🪟 Windows | [Bản mới nhất](https://github.com/qyzhg/prism/releases) | 104 | | 🍎 macOS | [Bản mới nhất](https://github.com/qyzhg/prism/releases) | 105 | | 🐧 Linux | Sắp ra mắt | 106 | 107 | ### Ghi chú cài đặt macOS 108 | 109 | Prism dùng chữ ký ad-hoc (không có chứng chỉ Developer ID trả phí), nên Gatekeeper sẽ cảnh báo ở lần mở đầu tiên. 110 | 111 | 1. Di chuyển `Prism.app` vào `/Applications`. 112 | 2. Mở **Terminal** và chạy: 113 | ```bash 114 | xattr -cr /Applications/prism.app 115 | sudo spctl --add --label Prism /Applications/prism.app 116 | ``` 117 | 3. Nhấp chuột phải vào app, chọn **Open** và xác nhận một lần. Những lần sau mở bình thường. 118 | 119 | --- 120 | 121 | ## Tài liệu 122 | 123 | ### Cấu hình 124 | 125 | Quản lý tùy chỉnh trong bảng cài đặt: 126 | 127 | - Chọn cặp ngôn ngữ mặc định 128 | - Quản lý API Key 129 | - Tùy chỉnh phím tắt (đang phát triển) 130 | 131 | ### Phím tắt 132 | 133 | Đang phát triển - Sẽ sớm có 134 | 135 | ### Mô hình AI 136 | 137 | - **Mô hình dịch** - `tencent/Hunyuan-MT-7B` dịch đa ngôn ngữ cấp doanh nghiệp 138 | - **Mô hình OCR** - `Qwen/Qwen3-VL-8B-Instruct` thị giác-ngôn ngữ nâng cao 139 | 140 | --- 141 | 142 | ## Lộ trình 143 | 144 | - [x] Chức năng dịch cốt lõi 145 | - [x] Tích hợp OCR cho ảnh chụp màn hình 146 | - [x] Cấu hình phím tắt tùy chỉnh 147 | - [ ] Bộ nhớ dịch và quản lý thuật ngữ 148 | - [ ] Dịch hàng loạt tệp 149 | - [ ] Hệ sinh thái plugin 150 | - [ ] Ứng dụng di động đi kèm 151 | 152 | --- 153 | 154 | ## Câu hỏi thường gặp (FAQ) 155 | 156 | **Hỏi: Có dùng miễn phí được không?** 157 | Có. Đăng ký SiliconFlow qua link mời để nhận tín dụng miễn phí đủ dùng lâu dài. 158 | 159 | **Hỏi: Hỗ trợ những ngôn ngữ nào?** 160 | Tencent Hunyuan-MT-7B hỗ trợ nhiều ngôn ngữ chính (Trung, Anh, Nhật, Hàn...). Bạn cũng có thể dùng mô hình ưa thích. 161 | 162 | **Hỏi: Dữ liệu có được lưu lại không?** 163 | Lịch sử dịch lưu cục bộ trong SQLite và không tải lên máy chủ. Quyền riêng tư được bảo vệ. 164 | 165 | **Hỏi: Có dùng offline được không?** 166 | Mô hình online cần kết nối. Nếu dùng mô hình cục bộ, có thể làm việc offline. 167 | 168 | **Hỏi: Khi nào có phím tắt?** 169 | Đang phát triển và sẽ phát hành sớm. 170 | 171 | --- 172 | 173 | ## Đóng góp 174 | 175 | Chào đón Issue và Pull Request. Rất mong đóng góp của bạn! 176 | 177 | --- 178 | 179 | ## Giấy phép 180 | 181 | Phần mềm theo giấy phép MIT - xem [LICENSE](LICENSE) để biết chi tiết. 182 | 183 | --- 184 | 185 | ## Lời cảm ơn 186 | 187 | - Xây dựng với [Tauri](https://tauri.app/) 188 | - Dịch vụ dịch trong quá trình phát triển do [SiliconFlow](https://siliconflow.cn/) cung cấp 189 | - UI dùng [Vue 3](https://vuejs.org/) 190 | 191 | --- 192 | 193 | ## Trợ giúp 194 | 195 | - 🐛 Báo lỗi: [GitHub Issues](https://github.com/qyzhg/prism/issues) 196 | 197 | --- 198 | 199 |
200 | 201 | ❤️ Phát triển bởi đội Prism@pity 202 | 203 | **[⬆ Lên đầu trang](#prism---phần-mềm-dịch-thuật-ai)** 204 | 205 |
206 | -------------------------------------------------------------------------------- /src/components/TranslationResult.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 103 | 104 | 234 | -------------------------------------------------------------------------------- /src/components/TextInput.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 81 | 82 | 254 | -------------------------------------------------------------------------------- /src/components/ModelSelectorModal.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 101 | 102 | 267 | -------------------------------------------------------------------------------- /README.es.md: -------------------------------------------------------------------------------- 1 | # Prism - Software de traducción con IA 2 | 3 |
4 | 5 | **[English](./README.md) | [中文](./README.zh.md) | [Español](./README.es.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [Tiếng Việt](./README.vi.md)** 6 | 7 | 🚀 Una potente aplicación de traducción multiplataforma impulsada por modelos de lenguaje avanzados y tecnología OCR. 8 | 9 | [Descarga](#descarga) • [Funciones](#funciones) • [Inicio rápido](#inicio-rápido) • [Documentación](#documentación) 10 | 11 |
12 | 13 | --- 14 | Icono 15 | 16 | ## Funciones 17 | 18 | - **🌍 Compatibilidad multiplataforma** - Soporte completo para Windows, macOS y Linux con las mismas funciones 19 | - **🤖 Traducción IA avanzada** - Basado en el modelo Tencent Hunyuan-MT-7B para traducciones precisas con contexto 20 | - **📸 OCR integrado** - Extrae y traduce texto directamente desde capturas con Qwen3-VL-8B-Instruct 21 | - **⚡ Traducción ultrarrápida** - Traducción en tiempo real con latencia mínima 22 | - **🎯 Interfaz fácil de usar** - UI intuitiva en Vue 3 con interacciones fluidas 23 | - **🔗 Atajos globales** - Atajos personalizables para acceso rápido (en desarrollo) 24 | - **💾 Historial local** - Almacena traducciones en SQLite de forma local 25 | - **🎨 Arquitectura moderna** - Construido con Tauri + Rust para máximo rendimiento y seguridad 26 | 27 | --- 28 | 29 | ## Stack tecnológico 30 | 31 | ### Frontend 32 | 33 | - **Vue 3** (3.5.13) - Framework JavaScript progresivo y moderno 34 | - **Vite** (6.0.3) - Herramienta de build de nueva generación 35 | - **Componentes UI de Tauri** - Experiencia nativa en escritorio 36 | 37 | ### Backend 38 | 39 | - **Rust** (edición 2021) - Lenguaje de sistemas de alto rendimiento 40 | - **Tauri** (2.9.3) - Framework ligero para apps de escritorio 41 | - **Tokio** (1.48.0) - Runtime asíncrono para concurrencia 42 | 43 | ### IA y procesamiento 44 | 45 | - **Modelo de traducción** - Tencent Hunyuan-MT-7B 46 | - **Modelo de OCR** - Qwen3-VL-8B-Instruct 47 | - **Proveedor API** - SiliconFlow 48 | - **Compatibilidad completa con APIs tipo OpenAI para modelos personalizados** 49 | 50 | ### Almacenamiento y librerías 51 | 52 | - **SQLite** (rusqlite 0.37.0) - Base de datos local 53 | - **Reqwest** (0.12.24) - Cliente HTTP 54 | - **Procesamiento de imágenes** (0.25.9) - Capturas y manipulación de imágenes 55 | - **Atajos globales** (2.3.1) - Plugin de atajos de teclado 56 | 57 | --- 58 | 59 | ## Inicio rápido 60 | 61 | ### Requisitos previos 62 | 63 | - Rust 1.91.0 o superior 64 | - Node.js 18+ y pnpm 65 | - Git 66 | 67 | ### Instalación 68 | 69 | **1. Clonar el repositorio** 70 | ```bash 71 | git clone https://github.com/qyzhg/prism.git 72 | cd prism 73 | ``` 74 | 75 | **2. Instalar dependencias** 76 | #### Dependencias frontend 77 | ```bash 78 | pnpm install 79 | ``` 80 | 81 | #### Dependencias Rust gestionadas por Cargo 82 | 83 | **3. Obtener API Key** 84 | - Usa tu propia URL base compatible con OpenAI y tu API key para empezar. 85 | - Regístrate en SiliconFlow con nuestro enlace de invitación: [https://cloud.siliconflow.cn/i/QhM7Qyuq](https://cloud.siliconflow.cn/i/QhM7Qyuq) y consigue créditos gratuitos (larga validez). 86 | 87 | **4. Ejecutar en modo desarrollo** 88 | ```bash 89 | pnpm tauri dev 90 | ``` 91 | 92 | **5. Compilar para producción** 93 | ```bash 94 | pnpm tauri build 95 | ``` 96 | 97 | --- 98 | 99 | ## Descarga 100 | 101 | | Plataforma | Enlace | 102 | |-----------|--------| 103 | | 🪟 Windows | [Última versión](https://github.com/qyzhg/prism/releases) | 104 | | 🍎 macOS | [Última versión](https://github.com/qyzhg/prism/releases) | 105 | | 🐧 Linux | Próximamente | 106 | 107 | ### Notas de instalación en macOS 108 | 109 | Prism está firmado ad-hoc (los certificados Developer ID son de pago), por lo que Gatekeeper mostrará una advertencia la primera vez. 110 | 111 | 1. Mueve `Prism.app` a `/Applications`. 112 | 2. Abre **Terminal** y ejecuta: 113 | ```bash 114 | xattr -cr /Applications/prism.app 115 | sudo spctl --add --label Prism /Applications/prism.app 116 | ``` 117 | 3. Haz clic derecho en la app, elige **Open** y confirma una vez. Las próximas ejecuciones serán normales. 118 | 119 | --- 120 | 121 | ## Documentación 122 | 123 | ### Configuración 124 | 125 | Gestiona tus preferencias en el panel de ajustes: 126 | 127 | - Selección de idioma origen/destino por defecto 128 | - Gestión de API keys 129 | - Personalización de atajos (en desarrollo) 130 | 131 | ### Atajos 132 | 133 | En desarrollo - Próximamente 134 | 135 | ### Modelos de IA 136 | 137 | - **Modelo de traducción** - `tencent/Hunyuan-MT-7B` traducción multilingüe empresarial 138 | - **Modelo de OCR** - `Qwen/Qwen3-VL-8B-Instruct` visión-lenguaje avanzada 139 | 140 | --- 141 | 142 | ## Hoja de ruta 143 | 144 | - [x] Funcionalidad básica de traducción 145 | - [x] Integración de OCR por captura 146 | - [x] Configuración de atajos personalizados 147 | - [ ] Memoria de traducción y gestión de glosarios 148 | - [ ] Traducción por lotes de archivos 149 | - [ ] Ecosistema de plugins 150 | - [ ] App complementaria móvil 151 | 152 | --- 153 | 154 | ## Preguntas frecuentes (FAQ) 155 | 156 | **P: ¿Puedo usarlo gratis?** 157 | R: Sí. Regístrate en SiliconFlow con nuestro enlace para obtener créditos gratuitos suficientes para uso prolongado. 158 | 159 | **P: ¿Qué idiomas se soportan?** 160 | R: El modelo Tencent Hunyuan-MT-7B cubre varios idiomas principales (chino, inglés, japonés, coreano y más). También puedes usar el modelo que prefieras. 161 | 162 | **P: ¿Se guarda mi información?** 163 | R: El historial de traducción se guarda localmente en SQLite y nunca se sube a servidores. Tu privacidad está protegida. 164 | 165 | **P: ¿Puedo usarlo sin conexión?** 166 | R: Los modelos en línea requieren conexión. Con modelos locales, el uso sin conexión es posible. 167 | 168 | **P: ¿Cuándo estarán listos los atajos?** 169 | R: La función de atajos está en desarrollo y llegará pronto. 170 | 171 | --- 172 | 173 | ## Contribuir 174 | 175 | Se aceptan Issues y Pull Requests. ¡Tus contribuciones son bienvenidas! 176 | 177 | --- 178 | 179 | ## Licencia 180 | 181 | Proyecto bajo licencia MIT - consulta [LICENSE](LICENSE) para más detalles. 182 | 183 | --- 184 | 185 | ## Agradecimientos 186 | 187 | - Construido con [Tauri](https://tauri.app/) 188 | - Servicios de traducción durante el desarrollo proporcionados por [SiliconFlow](https://siliconflow.cn/) 189 | - UI basada en [Vue 3](https://vuejs.org/) 190 | 191 | --- 192 | 193 | ## Ayuda 194 | 195 | - 🐛 Reporte de bugs: [GitHub Issues](https://github.com/qyzhg/prism/issues) 196 | 197 | --- 198 | 199 |
200 | 201 | ❤️ Desarrollado por el equipo Prism@pity 202 | 203 | **[⬆ Volver arriba](#prism---software-de-traducción-con-ia)** 204 | 205 |
206 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prism - AI Translation Software 2 | 3 |
4 | 5 | **[English](./README.md) | [中文](./README.zh.md) | [Español](./README.es.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [Tiếng Việt](./README.vi.md)** 6 | 7 | 🚀 A powerful cross-platform AI translation application powered by advanced language models and OCR technology. 8 | 9 | [Download](#download) • [Features](#features) • [Quick Start](#quick-start) • [Documentation](#documentation) 10 | 11 |
12 | 13 | --- 14 | 图标 15 | 16 | ## Features 17 | 18 | - **🌍 Cross-Platform Support** - Full support for Windows, macOS, and Linux with consistent functionality 19 | - **🤖 Advanced AI Translation** - Powered by Tencent Hunyuan-MT-7B model for accurate context-aware translation 20 | - **📸 Built-in OCR Recognition** - Extract and translate text directly from screenshots using Qwen3-VL-8B-Instruct 21 | - **⚡ Lightning-Fast Translation** - Real-time translation with minimal latency 22 | - **🎯 User-Friendly Interface** - Intuitive Vue 3-based UI with smooth interactions 23 | - **🔗 Global Hotkeys** - Customizable shortcuts for quick access (in development) 24 | - **💾 Local Translation History** - SQLite-based translation record storage 25 | - **🎨 Modern Architecture** - Built with Tauri + Rust for superior performance and security 26 | 27 | --- 28 | 29 | ## Tech Stack 30 | 31 | ### Frontend Technologies 32 | 33 | - **Vue 3** (3.5.13) - Modern progressive JavaScript framework 34 | - **Vite** (6.0.3) - Next-generation build tool 35 | - **Tauri UI Components** - Native desktop application experience 36 | 37 | ### Backend Technologies 38 | 39 | - **Rust** (2021 edition) - High-performance systems programming language 40 | - **Tauri** (2.9.3) - Lightweight desktop application framework 41 | - **Tokio** (1.48.0) - Async runtime for concurrent operations 42 | 43 | ### AI & Processing 44 | 45 | - **Translation Model** - Tencent Hunyuan-MT-7B 46 | - **OCR Model** - Qwen3-VL-8B-Instruct 47 | - **API Provider** - SiliconFlow 48 | - **Full support for all OpenAI-compatible AI custom models** 49 | 50 | ### Storage & Libraries 51 | 52 | - **SQLite** (rusqlite 0.37.0) - Local database storage 53 | - **Reqwest** (0.12.24) - HTTP client 54 | - **Image Processing** (0.25.9) - Screenshot and image processing 55 | - **Global Hotkeys** (2.3.1) - Keyboard shortcut plugin 56 | 57 | --- 58 | 59 | ## Quick Start 60 | 61 | ### Prerequisites 62 | 63 | - Rust 1.91.0 or higher 64 | - Node.js 18+ and pnpm 65 | - Git 66 | 67 | ### Installation 68 | 69 | **1. Clone the repository** 70 | ```bash 71 | git clone https://github.com/qyzhg/prism.git 72 | cd prism 73 | ``` 74 | 75 | **2. Install dependencies** 76 | #### Install frontend dependencies 77 | ```bash 78 | pnpm install 79 | ``` 80 | 81 | #### Rust dependencies are managed by Cargo 82 | 83 | **3. Get API Key** 84 | - Use your own OpenAI-compatible base URL and API key to start using 85 | - Register for a SiliconFlow account with our invite link: [https://cloud.siliconflow.cn/i/QhM7Qyuq](https://cloud.siliconflow.cn/i/QhM7Qyuq) to get free credits (long validity period) 86 | 87 | **4. Run in development mode** 88 | ```bash 89 | pnpm tauri dev 90 | ``` 91 | 92 | **5. Build for production** 93 | ```bash 94 | pnpm tauri build 95 | ``` 96 | 97 | --- 98 | 99 | ## Download 100 | 101 | | Platform | Download Link | 102 | |----------|---------------| 103 | | 🪟 Windows | [Latest Release](https://github.com/qyzhg/prism/releases) | 104 | | 🍎 macOS | [Latest Release](https://github.com/qyzhg/prism/releases) | 105 | | 🐧 Linux | Coming Soon | 106 | 107 | ### macOS installation notes 108 | 109 | Prism is ad-hoc signed (Developer ID certificates cost ~$99/year), so macOS Gatekeeper will still warn the first time you try to run the download. 110 | To get past “Prism.app is damaged / can’t be opened” without a paid certificate: 111 | 112 | 1. Move `Prism.app` into `/Applications`. 113 | 2. Open **Terminal** and run: 114 | ```bash 115 | xattr -cr /Applications/prism.app 116 | sudo spctl --add --label Prism /Applications/prism.app 117 | ``` 118 | 3. Right-click the app, choose **Open**, and confirm once. Future launches can be done normally. 119 | 120 | --- 121 | 122 | ## Documentation 123 | 124 | ### Configuration 125 | 126 | Configure your translation preferences in the settings panel: 127 | 128 | - Default language pair selection 129 | - API key management 130 | - Hotkey customization (in development) 131 | 132 | ### Hotkeys 133 | 134 | Currently in development - Coming soon 135 | 136 | ### AI Models 137 | 138 | - **Translation Model** - `tencent/Hunyuan-MT-7B` Enterprise-grade multilingual translation 139 | - **OCR Model** - `Qwen/Qwen3-VL-8B-Instruct` Advanced vision-language understanding 140 | 141 | --- 142 | 143 | ## Roadmap 144 | 145 | - [x] Core translation functionality 146 | - [x] Screenshot OCR integration 147 | - [x] Custom hotkey configuration 148 | - [ ] Translation memory and terminology management 149 | - [ ] Batch file translation 150 | - [ ] Plugin ecosystem 151 | - [ ] Mobile companion app 152 | 153 | --- 154 | 155 | ## Frequently Asked Questions (FAQ) 156 | 157 | **Q: Can I use it for free?** 158 | 159 | A: Yes! Register for a SiliconFlow account using our invite link to get free credits that are sufficient for long-term use. 160 | 161 | **Q: What languages are supported?** 162 | 163 | A: The Tencent Hunyuan-MT-7B model supports mutual translation between multiple mainstream languages, including Chinese, English, Japanese, Korean, and more. You can also use your preferred models for translation. 164 | 165 | **Q: Is my data saved?** 166 | 167 | A: Translation history is saved locally in a SQLite database and never uploaded to servers. Your privacy is fully protected. 168 | 169 | **Q: Can I use it offline?** 170 | 171 | A: Online models cannot be used offline. Translation and OCR features require connection to the model API. If using local models, offline usage is possible. 172 | 173 | **Q: When will the hotkey feature be available?** 174 | 175 | A: The hotkey feature is currently under development and will be released soon. 176 | 177 | --- 178 | 179 | ## Contributing 180 | 181 | Issues and pull requests are welcome! We'd love to have your contribution. 182 | 183 | --- 184 | 185 | ## License 186 | 187 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 188 | 189 | --- 190 | 191 | ## Acknowledgments 192 | 193 | - Built with [Tauri](https://tauri.app/) 194 | - Translation services during development provided by [SiliconFlow](https://siliconflow.cn/) 195 | - UI framework powered by [Vue 3](https://vuejs.org/) 196 | 197 | --- 198 | 199 | ## Get Help 200 | 201 | - 🐛 Bug Reports: [GitHub Issues](https://github.com/qyzhg/prism/issues) 202 | 203 | --- 204 | 205 |
206 | 207 | ❤️ Developed by Prism Team@pity 208 | 209 | **[⬆ Back to Top](#prism---ai-translation-software)** 210 | 211 |
212 | -------------------------------------------------------------------------------- /src/components/HotkeyRecorder.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 236 | 237 | 302 | -------------------------------------------------------------------------------- /src-tauri/src/translation.rs: -------------------------------------------------------------------------------- 1 | use crate::http_client::http_client; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Clone, Serialize, Deserialize)] 5 | pub struct TranslationRequest { 6 | pub text: String, 7 | pub from_lang: String, 8 | pub to_lang: String, 9 | pub max_tokens: u32, 10 | } 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub struct TranslationResult { 14 | pub translated_text: String, 15 | pub from_lang: String, 16 | pub to_lang: String, 17 | pub service: String, 18 | } 19 | 20 | #[derive(Debug, Clone, Serialize, Deserialize)] 21 | pub struct TranslationResponse { 22 | pub translated_text: String, 23 | pub source_lang: String, 24 | pub target_lang: String, 25 | } 26 | 27 | #[derive(Debug, Clone, Serialize, Deserialize)] 28 | pub enum TranslationService { 29 | OpenAI, 30 | Google, 31 | Baidu, 32 | } 33 | 34 | impl TranslationService { 35 | pub async fn translate( 36 | &self, 37 | request: TranslationRequest, 38 | api_key: &str, 39 | base_url: &str, 40 | model_id: &str, 41 | ) -> Result { 42 | match self { 43 | TranslationService::OpenAI => { 44 | let translator = Translator::new( 45 | api_key.to_string(), 46 | base_url.to_string(), 47 | model_id.to_string(), 48 | TranslationService::OpenAI, 49 | ); 50 | let response = translator.translate(request).await?; 51 | Ok(TranslationResult { 52 | translated_text: response.translated_text, 53 | from_lang: response.source_lang, 54 | to_lang: response.target_lang, 55 | service: "OpenAI".to_string(), 56 | }) 57 | } 58 | TranslationService::Google => { 59 | let translator = Translator::new( 60 | api_key.to_string(), 61 | base_url.to_string(), 62 | model_id.to_string(), 63 | TranslationService::Google, 64 | ); 65 | let response = translator.translate(request).await?; 66 | Ok(TranslationResult { 67 | translated_text: response.translated_text, 68 | from_lang: response.source_lang, 69 | to_lang: response.target_lang, 70 | service: "Google".to_string(), 71 | }) 72 | } 73 | TranslationService::Baidu => { 74 | let translator = Translator::new( 75 | api_key.to_string(), 76 | base_url.to_string(), 77 | model_id.to_string(), 78 | TranslationService::Baidu, 79 | ); 80 | let response = translator.translate(request).await?; 81 | Ok(TranslationResult { 82 | translated_text: response.translated_text, 83 | from_lang: response.source_lang, 84 | to_lang: response.target_lang, 85 | service: "Baidu".to_string(), 86 | }) 87 | } 88 | } 89 | } 90 | } 91 | 92 | pub struct Translator { 93 | api_key: String, 94 | base_url: String, 95 | model_id: String, 96 | service: TranslationService, 97 | } 98 | 99 | impl Translator { 100 | pub fn new( 101 | api_key: String, 102 | base_url: String, 103 | model_id: String, 104 | service: TranslationService, 105 | ) -> Self { 106 | Self { 107 | api_key, 108 | base_url, 109 | model_id, 110 | service, 111 | } 112 | } 113 | 114 | pub async fn translate( 115 | &self, 116 | request: TranslationRequest, 117 | ) -> Result { 118 | match self.service { 119 | TranslationService::OpenAI => self.translate_openai(&request).await, 120 | TranslationService::Google => self.translate_google(&request).await, 121 | TranslationService::Baidu => self.translate_baidu(&request).await, 122 | } 123 | } 124 | 125 | async fn translate_openai( 126 | &self, 127 | request: &TranslationRequest, 128 | ) -> Result { 129 | let client = http_client(); 130 | 131 | println!( 132 | "开始请求大模型翻译从 {} 到 {}.", 133 | request.from_lang, request.to_lang 134 | ); 135 | let normalized_text = Self::normalize_naming_convention(&request.text); 136 | let text_to_translate = &normalized_text; 137 | let prompt = format!( 138 | "Translate the following text from {} to {}. Only return the translated text, no explanations:\n\n{}", 139 | request.from_lang, request.to_lang, text_to_translate 140 | ); 141 | 142 | let body = serde_json::json!({ 143 | "model": self.model_id, 144 | "messages": [ 145 | { 146 | "role": "system", 147 | "content": "You are a professional translator. Translate the given text accurately while preserving the original meaning and tone.\n\nTranslation rules:\n1. Translate Chinese content into English\n2. Translate all non-Chinese content into Chinese\n3. Only return the translated result, without any explanations or additional commentary\n4. Preserve code formatting, variable names (snake_case, camelCase), and special characters\n5. Maintain the original tone and technical terminology accuracy" 148 | }, 149 | { 150 | "role": "user", 151 | "content": prompt 152 | } 153 | ], 154 | "max_tokens": request.max_tokens, 155 | "temperature": 0.3 156 | }); 157 | let endpoint = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); 158 | 159 | let response = client 160 | .post(&endpoint) 161 | .header("Authorization", format!("Bearer {}", self.api_key)) 162 | .header("Content-Type", "application/json") 163 | .json(&body) 164 | .send() 165 | .await 166 | .map_err(|e| format!("请求AI失败: {}", e))?; 167 | 168 | if !response.status().is_success() { 169 | let error_text = response.text().await.unwrap_or_default(); 170 | return Err(format!("AI状态错误: {}", error_text)); 171 | } 172 | 173 | let response_json: serde_json::Value = response 174 | .json() 175 | .await 176 | .map_err(|e| format!("无法解析响应: {}", e))?; 177 | 178 | let translated_text = response_json 179 | .get("choices") 180 | .and_then(|choices| choices.get(0)) 181 | .and_then(|choice| choice.get("message")) 182 | .and_then(|message| message.get("content")) 183 | .and_then(|content| content.as_str()) 184 | .unwrap_or("") 185 | .trim() 186 | .to_string(); 187 | 188 | if translated_text.is_empty() { 189 | return Err("无法获取到翻译内容".to_string()); 190 | } 191 | println!("翻译成功!结果为:{translated_text}"); 192 | Ok(TranslationResponse { 193 | translated_text, 194 | source_lang: request.from_lang.clone(), 195 | target_lang: request.to_lang.clone(), 196 | }) 197 | } 198 | 199 | async fn translate_google( 200 | &self, 201 | _request: &TranslationRequest, 202 | ) -> Result { 203 | // todo: 预留口子 204 | Err("预留的,没实现呢".to_string()) 205 | } 206 | 207 | async fn translate_baidu( 208 | &self, 209 | _request: &TranslationRequest, 210 | ) -> Result { 211 | // todo: 预留口子 212 | Err("预留的,没实现呢".to_string()) 213 | } 214 | 215 | fn normalize_naming_convention(text: &str) -> String { 216 | // 将蛇形命名法转换为标准格式(用空格替换下划线) 217 | // 将驼峰命名法转换为标准格式(在大写字母前插入空格) 218 | let mut result = String::new(); 219 | let mut prev_is_lower = false; 220 | 221 | for ch in text.chars() { 222 | if ch == '_' || ch == '-' { 223 | result.push(' '); 224 | prev_is_lower = false; 225 | } else if ch.is_uppercase() && prev_is_lower { 226 | result.push(' '); 227 | result.push(ch); 228 | prev_is_lower = false; 229 | } else { 230 | result.push(ch); 231 | prev_is_lower = ch.is_lowercase(); 232 | } 233 | } 234 | println!("{}", result); 235 | result 236 | } 237 | } 238 | 239 | #[tauri::command] 240 | pub fn get_supported_languages() -> Vec<(String, String)> { 241 | vec![ 242 | ("auto".to_string(), "自动检测".to_string()), 243 | ("zh-CN".to_string(), "中文".to_string()), 244 | ("en".to_string(), "英语".to_string()), 245 | ("ja".to_string(), "日语".to_string()), 246 | ("ko".to_string(), "韩语".to_string()), 247 | ("fr".to_string(), "法语".to_string()), 248 | ("de".to_string(), "德语".to_string()), 249 | ("es".to_string(), "西班牙语".to_string()), 250 | ("ru".to_string(), "俄语".to_string()), 251 | ("ar".to_string(), "阿拉伯语".to_string()), 252 | ("pt".to_string(), "葡萄牙语".to_string()), 253 | ("it".to_string(), "意大利语".to_string()), 254 | ] 255 | } 256 | -------------------------------------------------------------------------------- /src/components/HistoryModal.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 218 | 219 | 480 | -------------------------------------------------------------------------------- /src-tauri/src/database.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use rusqlite::{params, Connection, Error as RusqliteError, ErrorCode, Result}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::fs; 5 | use std::path::PathBuf; 6 | use std::sync::{Arc, Mutex}; 7 | use tauri::{AppHandle, Manager}; 8 | 9 | #[derive(Debug, Serialize, Deserialize, Clone)] 10 | pub struct TranslationRecord { 11 | pub id: Option, 12 | pub original_text: String, 13 | pub translated_text: String, 14 | pub service: String, 15 | pub from_language: Option, 16 | pub to_language: Option, 17 | pub created_at: Option, 18 | } 19 | 20 | fn default_service() -> String { 21 | "openai".to_string() 22 | } 23 | 24 | #[derive(Debug, Serialize, Deserialize, Clone)] 25 | pub struct TranslationConfig { 26 | #[serde(default = "default_service")] 27 | pub service: String, 28 | pub base_url: String, 29 | pub api_key: String, 30 | pub model_id: String, 31 | } 32 | 33 | #[derive(Debug, Serialize, Deserialize, Clone)] 34 | pub struct OcrConfig { 35 | pub base_url: String, 36 | pub api_key: String, 37 | pub model_id: String, 38 | pub reuse_translation: bool, 39 | } 40 | 41 | #[derive(Debug, Serialize, Deserialize, Clone)] 42 | pub struct HotkeyConfig { 43 | pub popup_window: String, 44 | pub slide_translation: String, 45 | pub screenshot_translation: String, 46 | } 47 | 48 | impl HotkeyConfig { 49 | pub fn platform_default() -> Self { 50 | let (popup, slide, screenshot) = if cfg!(target_os = "macos") { 51 | ("Option+A", "Option+D", "Option+S") 52 | } else { 53 | ("Alt+A", "Alt+D", "Alt+S") 54 | }; 55 | 56 | HotkeyConfig { 57 | popup_window: popup.to_string(), 58 | slide_translation: slide.to_string(), 59 | screenshot_translation: screenshot.to_string(), 60 | } 61 | } 62 | } 63 | 64 | #[derive(Debug, Serialize, Deserialize, Clone)] 65 | #[serde(rename_all = "snake_case")] 66 | pub enum ProxyMode { 67 | System, 68 | Https, 69 | Http, 70 | Socks5, 71 | } 72 | 73 | impl Default for ProxyMode { 74 | fn default() -> Self { 75 | ProxyMode::System 76 | } 77 | } 78 | 79 | #[derive(Debug, Serialize, Deserialize, Clone)] 80 | pub struct ProxyConfig { 81 | pub enabled: bool, 82 | pub mode: ProxyMode, 83 | pub server: String, 84 | } 85 | 86 | impl Default for ProxyConfig { 87 | fn default() -> Self { 88 | ProxyConfig { 89 | enabled: false, 90 | mode: ProxyMode::System, 91 | server: String::new(), 92 | } 93 | } 94 | } 95 | 96 | fn default_user_max_tokens() -> u32 { 97 | 4096 98 | } 99 | 100 | #[derive(Debug, Serialize, Deserialize, Clone)] 101 | pub struct TokenLimitConfig { 102 | #[serde(default)] 103 | pub enable_user_max_tokens: bool, 104 | #[serde(default = "default_user_max_tokens")] 105 | pub user_max_tokens: u32, 106 | } 107 | 108 | impl Default for TokenLimitConfig { 109 | fn default() -> Self { 110 | TokenLimitConfig { 111 | enable_user_max_tokens: false, 112 | user_max_tokens: default_user_max_tokens(), 113 | } 114 | } 115 | } 116 | 117 | #[derive(Debug, Serialize, Deserialize, Clone)] 118 | pub struct AutostartConfig { 119 | #[serde(default)] 120 | pub enabled: bool, 121 | } 122 | 123 | impl Default for AutostartConfig { 124 | fn default() -> Self { 125 | AutostartConfig { enabled: false } 126 | } 127 | } 128 | 129 | #[derive(Debug, Serialize, Deserialize, Clone)] 130 | pub struct AppConfig { 131 | pub translation: TranslationConfig, 132 | pub ocr: OcrConfig, 133 | pub hotkeys: HotkeyConfig, 134 | #[serde(default)] 135 | pub proxy: ProxyConfig, 136 | #[serde(default)] 137 | pub token_limits: TokenLimitConfig, 138 | #[serde(default)] 139 | pub autostart: AutostartConfig, 140 | } 141 | 142 | #[derive(Clone)] 143 | pub struct Database { 144 | conn: Arc>, 145 | } 146 | 147 | impl Database { 148 | pub fn new(app_handle: &AppHandle) -> Result { 149 | let app_dir = resolve_app_data_dir(app_handle).map_err(io_to_rusqlite_error)?; 150 | 151 | // 确保应用数据目录存在 152 | fs::create_dir_all(&app_dir).map_err(io_to_rusqlite_error)?; 153 | 154 | println!("配置目录: {}", app_dir.display()); 155 | let db_path = app_dir.join("trans.db"); 156 | let conn = Connection::open(db_path)?; 157 | let conn = Arc::new(Mutex::new(conn)); 158 | 159 | let db = Database { conn }; 160 | db.init_tables()?; 161 | Ok(db) 162 | } 163 | 164 | fn init_tables(&self) -> Result<()> { 165 | let conn = self.conn.lock().unwrap(); 166 | 167 | // 创建翻译历史表 168 | conn.execute( 169 | "CREATE TABLE IF NOT EXISTS translation_history ( 170 | id INTEGER PRIMARY KEY AUTOINCREMENT, 171 | original_text TEXT NOT NULL, 172 | translated_text TEXT NOT NULL, 173 | service TEXT NOT NULL, 174 | from_language TEXT, 175 | to_language TEXT, 176 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 177 | )", 178 | [], 179 | )?; 180 | 181 | // 创建用户设置表 182 | conn.execute( 183 | "CREATE TABLE IF NOT EXISTS user_settings ( 184 | id INTEGER PRIMARY KEY AUTOINCREMENT, 185 | key TEXT UNIQUE NOT NULL, 186 | value TEXT NOT NULL, 187 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 188 | )", 189 | [], 190 | )?; 191 | 192 | // 创建 API 密钥表 193 | conn.execute( 194 | "CREATE TABLE IF NOT EXISTS api_keys ( 195 | id INTEGER PRIMARY KEY AUTOINCREMENT, 196 | service TEXT UNIQUE NOT NULL, 197 | api_key TEXT NOT NULL, 198 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 199 | )", 200 | [], 201 | )?; 202 | 203 | // 创建索引 204 | conn.execute( 205 | "CREATE INDEX IF NOT EXISTS idx_translation_history_created_at 206 | ON translation_history(created_at DESC)", 207 | [], 208 | )?; 209 | 210 | Ok(()) 211 | } 212 | 213 | // 保存翻译记录 214 | pub fn save_translation(&self, record: &TranslationRecord) -> Result { 215 | let conn = self.conn.lock().unwrap(); 216 | let created_at = record 217 | .created_at 218 | .clone() 219 | .unwrap_or_else(|| Utc::now().to_rfc3339()); 220 | 221 | conn.execute( 222 | "INSERT INTO translation_history 223 | (original_text, translated_text, service, from_language, to_language, created_at) 224 | VALUES (?1, ?2, ?3, ?4, ?5, ?6)", 225 | params![ 226 | record.original_text, 227 | record.translated_text, 228 | record.service, 229 | record.from_language, 230 | record.to_language, 231 | created_at 232 | ], 233 | )?; 234 | Ok(conn.last_insert_rowid()) 235 | } 236 | 237 | // 获取翻译历史 238 | pub fn get_translation_history( 239 | &self, 240 | limit: Option, 241 | offset: Option, 242 | ) -> Result> { 243 | let conn = self.conn.lock().unwrap(); 244 | let mut stmt = conn.prepare( 245 | "SELECT id, original_text, translated_text, service, from_language, to_language, 246 | created_at 247 | FROM translation_history 248 | ORDER BY datetime(created_at) DESC 249 | LIMIT ?1 OFFSET ?2", 250 | )?; 251 | 252 | let rows = stmt.query_map(params![limit.unwrap_or(50), offset.unwrap_or(0)], |row| { 253 | Ok(TranslationRecord { 254 | id: Some(row.get(0)?), 255 | original_text: row.get(1)?, 256 | translated_text: row.get(2)?, 257 | service: row.get(3)?, 258 | from_language: row.get(4)?, 259 | to_language: row.get(5)?, 260 | created_at: Some(row.get(6)?), 261 | }) 262 | })?; 263 | 264 | let mut records = Vec::new(); 265 | for row in rows { 266 | records.push(row?); 267 | } 268 | Ok(records) 269 | } 270 | 271 | // 搜索翻译历史 272 | pub fn search_history( 273 | &self, 274 | keyword: &str, 275 | limit: Option, 276 | ) -> Result> { 277 | let conn = self.conn.lock().unwrap(); 278 | let mut stmt = conn.prepare( 279 | "SELECT id, original_text, translated_text, service, from_language, to_language, 280 | created_at 281 | FROM translation_history 282 | WHERE original_text LIKE ?1 OR translated_text LIKE ?1 283 | ORDER BY datetime(created_at) DESC 284 | LIMIT ?2", 285 | )?; 286 | 287 | let search_pattern = format!("%{}%", keyword); 288 | let rows = stmt.query_map(params![search_pattern, limit.unwrap_or(50)], |row| { 289 | Ok(TranslationRecord { 290 | id: Some(row.get(0)?), 291 | original_text: row.get(1)?, 292 | translated_text: row.get(2)?, 293 | service: row.get(3)?, 294 | from_language: row.get(4)?, 295 | to_language: row.get(5)?, 296 | created_at: Some(row.get(6)?), 297 | }) 298 | })?; 299 | 300 | let mut records = Vec::new(); 301 | for row in rows { 302 | records.push(row?); 303 | } 304 | Ok(records) 305 | } 306 | 307 | // 保存用户设置 308 | pub fn save_setting(&self, key: &str, value: &str) -> Result<()> { 309 | let conn = self.conn.lock().unwrap(); 310 | conn.execute( 311 | "INSERT OR REPLACE INTO user_settings (key, value) VALUES (?1, ?2)", 312 | params![key, value], 313 | )?; 314 | Ok(()) 315 | } 316 | 317 | // 获取用户设置 318 | pub fn get_setting(&self, key: &str) -> Result> { 319 | let conn = self.conn.lock().unwrap(); 320 | let mut stmt = conn.prepare("SELECT value FROM user_settings WHERE key = ?1")?; 321 | 322 | let mut rows = stmt.query_map(params![key], |row| row.get::<_, String>(0))?; 323 | 324 | if let Some(row) = rows.next() { 325 | Ok(Some(row?)) 326 | } else { 327 | Ok(None) 328 | } 329 | } 330 | 331 | // 保存 API 密钥 332 | pub fn save_api_key(&self, service: &str, api_key: &str) -> Result<()> { 333 | let conn = self.conn.lock().unwrap(); 334 | conn.execute( 335 | "INSERT OR REPLACE INTO api_keys (service, api_key) VALUES (?1, ?2)", 336 | params![service, api_key], 337 | )?; 338 | Ok(()) 339 | } 340 | 341 | // 获取 API 密钥 342 | pub fn get_api_key(&self, service: &str) -> Result> { 343 | let conn = self.conn.lock().unwrap(); 344 | let mut stmt = conn.prepare("SELECT api_key FROM api_keys WHERE service = ?1")?; 345 | 346 | let mut rows = stmt.query_map(params![service], |row| row.get::<_, String>(0))?; 347 | 348 | if let Some(row) = rows.next() { 349 | Ok(Some(row?)) 350 | } else { 351 | Ok(None) 352 | } 353 | } 354 | 355 | // 删除翻译记录 356 | #[allow(dead_code)] 357 | pub fn delete_translation(&self, id: i64) -> Result<()> { 358 | let conn = self.conn.lock().unwrap(); 359 | conn.execute("DELETE FROM translation_history WHERE id = ?1", params![id])?; 360 | Ok(()) 361 | } 362 | 363 | // 清空翻译历史 364 | pub fn clear_history(&self) -> Result<()> { 365 | let conn = self.conn.lock().unwrap(); 366 | conn.execute("DELETE FROM translation_history", [])?; 367 | Ok(()) 368 | } 369 | 370 | // 保存应用配置 371 | pub fn save_app_config(&self, config: &AppConfig) -> Result<()> { 372 | println!("正在保存应用配置到数据库..."); 373 | let config_json = serde_json::to_string(config) 374 | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; 375 | 376 | match self.save_setting("app_config", &config_json) { 377 | Ok(_) => { 378 | println!("配置已保存到数据库"); 379 | Ok(()) 380 | } 381 | Err(e) => { 382 | eprintln!("保存配置到数据库失败: {}", e); 383 | Err(e) 384 | } 385 | } 386 | } 387 | 388 | // 获取应用配置 389 | pub fn get_app_config(&self) -> Result> { 390 | println!("尝试从数据库加载配置..."); 391 | if let Some(config_json) = self.get_setting("app_config")? { 392 | match serde_json::from_str::(&config_json) { 393 | Ok(config) => { 394 | println!("从数据库加载配置成功"); 395 | Ok(Some(config)) 396 | } 397 | Err(e) => { 398 | eprintln!("解析数据库中的配置失败: {}", e); 399 | // 解析失败时返回错误,以便前端知道发生了什么 400 | Err(rusqlite::Error::FromSqlConversionFailure( 401 | 0, 402 | rusqlite::types::Type::Text, 403 | Box::new(e), 404 | )) 405 | } 406 | } 407 | } else { 408 | println!("未找到配置,使用默认配置"); 409 | Ok(self.get_default_config()) 410 | } 411 | } 412 | 413 | fn get_default_config(&self) -> Option { 414 | Some(AppConfig { 415 | translation: TranslationConfig { 416 | service: "openai".to_string(), 417 | base_url: "https://api.openai.com/v1".to_string(), 418 | api_key: "".to_string(), 419 | model_id: "gpt-5-nano".to_string(), 420 | }, 421 | ocr: OcrConfig { 422 | base_url: "https://api.openai.com/v1".to_string(), 423 | api_key: "".to_string(), 424 | model_id: "gpt-4-vision-preview".to_string(), 425 | reuse_translation: true, 426 | }, 427 | hotkeys: HotkeyConfig::platform_default(), 428 | proxy: ProxyConfig::default(), 429 | token_limits: TokenLimitConfig::default(), 430 | autostart: AutostartConfig::default(), 431 | }) 432 | } 433 | } 434 | 435 | fn io_to_rusqlite_error(err: std::io::Error) -> RusqliteError { 436 | RusqliteError::SqliteFailure( 437 | rusqlite::ffi::Error { 438 | code: ErrorCode::Unknown, 439 | extended_code: err.raw_os_error().unwrap_or(0), 440 | }, 441 | Some(err.to_string()), 442 | ) 443 | } 444 | 445 | fn resolve_app_data_dir(app_handle: &AppHandle) -> std::io::Result { 446 | if let Ok(dir) = app_handle.path().app_data_dir() { 447 | return Ok(dir); 448 | } 449 | 450 | if let Ok(dir) = app_handle.path().app_config_dir() { 451 | return Ok(dir); 452 | } 453 | 454 | let identifier = app_handle.config().identifier.clone(); 455 | 456 | if cfg!(target_os = "macos") { 457 | if let Some(mut dir) = dirs::home_dir() { 458 | dir.push("Library"); 459 | dir.push("Application Support"); 460 | dir.push(&identifier); 461 | return Ok(dir); 462 | } 463 | } 464 | 465 | if let Some(mut dir) = dirs::data_dir() { 466 | dir.push(&identifier); 467 | return Ok(dir); 468 | } 469 | 470 | if let Some(mut dir) = dirs::home_dir() { 471 | dir.push(".config"); 472 | dir.push(&identifier); 473 | return Ok(dir); 474 | } 475 | 476 | let mut dir = std::env::current_dir()?; 477 | dir.push(&identifier); 478 | Ok(dir) 479 | } 480 | -------------------------------------------------------------------------------- /src-tauri/src/commands.rs: -------------------------------------------------------------------------------- 1 | use crate::http_client::{configure_http_client, http_client, validate_http_client}; 2 | #[cfg(not(target_os = "macos"))] 3 | use crate::system_tray::show_main_window; 4 | use crate::{ 5 | app_state::AppState, 6 | database::{AppConfig, TokenLimitConfig, TranslationRecord}, 7 | ocr_tasks::run_ocr_on_image_data, 8 | platform, 9 | shortcuts::register_shortcuts, 10 | token_limits::calculate_text_response_tokens, 11 | translation::{TranslationRequest, TranslationResult}, 12 | }; 13 | use serde::Serialize; 14 | use serde_json::Value; 15 | use tauri::Emitter; 16 | use tauri::{AppHandle, Manager, State}; 17 | 18 | type ScreenshotImage = screenshots::image::ImageBuffer, Vec>; 19 | 20 | #[derive(Debug, Serialize)] 21 | pub struct ModelInfo { 22 | pub id: String, 23 | pub label: String, 24 | } 25 | 26 | fn screenshot_to_dynamic_image(image: &ScreenshotImage) -> Result { 27 | let (width, height) = (image.width(), image.height()); 28 | let data = image.clone().into_vec(); 29 | let rgba = image::RgbaImage::from_raw(width, height, data) 30 | .ok_or_else(|| "无法转换截图到图像缓冲区".to_string())?; 31 | Ok(image::DynamicImage::ImageRgba8(rgba)) 32 | } 33 | 34 | fn encode_image_to_png(image: &ScreenshotImage) -> Result, String> { 35 | use image::ImageFormat; 36 | use std::io::Cursor; 37 | 38 | let dynamic_image = screenshot_to_dynamic_image(image)?; 39 | let mut cursor = Cursor::new(Vec::new()); 40 | dynamic_image 41 | .write_to(&mut cursor, ImageFormat::Png) 42 | .map_err(|e| format!("图片编码失败: {}", e))?; 43 | Ok(cursor.into_inner()) 44 | } 45 | 46 | #[tauri::command] 47 | pub async fn translate_text( 48 | text: String, 49 | from_language: Option, 50 | to_language: String, 51 | _service: String, 52 | state: State<'_, AppState>, 53 | ) -> Result { 54 | let from_lang_value = from_language.unwrap_or_default(); 55 | let to_lang_value = to_language; 56 | 57 | let translation_service = { 58 | let service = state 59 | .translation_service 60 | .lock() 61 | .map_err(|e| format!("获取翻译服务失败: {}", e))?; 62 | service.clone() 63 | }; 64 | 65 | let (api_key, base_url, model_id, token_config) = { 66 | let db = state 67 | .db 68 | .lock() 69 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 70 | 71 | match db.get_app_config() { 72 | Ok(Some(config)) => { 73 | let token_config = config.token_limits.clone(); 74 | let translation_config = config.translation; 75 | ( 76 | translation_config.api_key, 77 | translation_config.base_url, 78 | translation_config.model_id, 79 | token_config, 80 | ) 81 | } 82 | Ok(None) => ( 83 | "".to_string(), 84 | "https://api.openai.com/v1".to_string(), 85 | "gpt-5-nano".to_string(), 86 | TokenLimitConfig::default(), 87 | ), 88 | Err(e) => { 89 | return Err(format!("获取应用配置失败: {}", e)); 90 | } 91 | } 92 | }; 93 | 94 | let max_tokens = calculate_text_response_tokens(&text, Some(&token_config)); 95 | let request = TranslationRequest { 96 | text, 97 | from_lang: from_lang_value, 98 | to_lang: to_lang_value, 99 | max_tokens, 100 | }; 101 | 102 | translation_service 103 | .translate(request, &api_key, &base_url, &model_id) 104 | .await 105 | } 106 | 107 | #[tauri::command] 108 | pub async fn save_translation( 109 | original_text: String, 110 | translated_text: String, 111 | from_language: String, 112 | to_language: String, 113 | service: String, 114 | state: State<'_, AppState>, 115 | ) -> Result<(), String> { 116 | let db = state 117 | .db 118 | .lock() 119 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 120 | 121 | let record = TranslationRecord { 122 | id: None, 123 | original_text: original_text.clone(), 124 | translated_text: translated_text.clone(), 125 | service: service.clone(), 126 | from_language: Some(from_language), 127 | to_language: Some(to_language), 128 | created_at: Some(chrono::Utc::now().to_rfc3339()), 129 | }; 130 | 131 | db.save_translation(&record) 132 | .map(|_| ()) 133 | .map_err(|e| format!("保存翻译记录失败: {}", e))?; 134 | 135 | Ok(()) 136 | } 137 | 138 | #[tauri::command] 139 | pub async fn get_translation_history( 140 | limit: Option, 141 | offset: Option, 142 | state: State<'_, AppState>, 143 | ) -> Result, String> { 144 | let db = state 145 | .db 146 | .lock() 147 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 148 | 149 | db.get_translation_history(limit, offset) 150 | .map_err(|e| format!("获取翻译历史失败: {}", e)) 151 | } 152 | 153 | #[tauri::command] 154 | pub async fn search_history( 155 | keyword: String, 156 | limit: Option, 157 | state: State<'_, AppState>, 158 | ) -> Result, String> { 159 | let db = state 160 | .db 161 | .lock() 162 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 163 | 164 | db.search_history(&keyword, limit) 165 | .map_err(|e| format!("搜索历史记录失败: {}", e)) 166 | } 167 | 168 | #[tauri::command] 169 | pub async fn clear_history(state: State<'_, AppState>) -> Result<(), String> { 170 | let db = state 171 | .db 172 | .lock() 173 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 174 | 175 | db.clear_history() 176 | .map_err(|e| format!("清空历史记录失败: {}", e)) 177 | } 178 | 179 | #[tauri::command] 180 | pub async fn save_setting( 181 | key: String, 182 | value: String, 183 | state: State<'_, AppState>, 184 | ) -> Result<(), String> { 185 | let db = state 186 | .db 187 | .lock() 188 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 189 | 190 | db.save_setting(&key, &value) 191 | .map_err(|e| format!("保存设置失败: {}", e)) 192 | } 193 | 194 | #[tauri::command] 195 | pub async fn get_setting( 196 | key: String, 197 | state: State<'_, AppState>, 198 | ) -> Result, String> { 199 | let db = state 200 | .db 201 | .lock() 202 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 203 | 204 | db.get_setting(&key) 205 | .map_err(|e| format!("获取设置失败: {}", e)) 206 | } 207 | 208 | #[tauri::command] 209 | pub async fn save_api_key( 210 | service: String, 211 | api_key: String, 212 | state: State<'_, AppState>, 213 | ) -> Result<(), String> { 214 | let db = state 215 | .db 216 | .lock() 217 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 218 | 219 | db.save_api_key(&service, &api_key) 220 | .map_err(|e| format!("保存 API 密钥失败: {}", e)) 221 | } 222 | 223 | #[tauri::command] 224 | pub async fn get_api_key( 225 | service: String, 226 | state: State<'_, AppState>, 227 | ) -> Result, String> { 228 | let db = state 229 | .db 230 | .lock() 231 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 232 | 233 | db.get_api_key(&service) 234 | .map_err(|e| format!("获取 API 密钥失败: {}", e)) 235 | } 236 | 237 | #[tauri::command] 238 | pub async fn save_app_config(config: AppConfig, state: State<'_, AppState>) -> Result<(), String> { 239 | validate_http_client(Some(&config.proxy)).map_err(|e| format!("验证代理配置失败: {}", e))?; 240 | 241 | { 242 | let db = state 243 | .db 244 | .lock() 245 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 246 | 247 | db.save_app_config(&config) 248 | .map_err(|e| format!("保存应用配置失败: {}", e))?; 249 | } 250 | 251 | configure_http_client(Some(&config.proxy)).map_err(|e| format!("应用代理配置失败: {}", e)) 252 | } 253 | 254 | #[tauri::command] 255 | pub async fn get_app_config(state: State<'_, AppState>) -> Result { 256 | let db = state 257 | .db 258 | .lock() 259 | .map_err(|e| format!("获取数据库连接失败: {}", e))?; 260 | 261 | db.get_app_config() 262 | .map_err(|e| format!("获取应用配置失败: {}", e)) 263 | .and_then(|config_opt| config_opt.ok_or_else(|| "应用配置不存在".to_string())) 264 | } 265 | 266 | #[tauri::command] 267 | pub async fn reload_shortcuts(app_handle: AppHandle) -> Result<(), String> { 268 | register_shortcuts(&app_handle); 269 | Ok(()) 270 | } 271 | 272 | fn parse_model_value(value: &Value) -> Option { 273 | match value { 274 | Value::String(text) => Some(ModelInfo { 275 | id: text.clone(), 276 | label: text.clone(), 277 | }), 278 | Value::Object(map) => { 279 | let identifier = map 280 | .get("id") 281 | .and_then(|v| v.as_str()) 282 | .or_else(|| map.get("model").and_then(|v| v.as_str())) 283 | .or_else(|| map.get("name").and_then(|v| v.as_str())) 284 | .or_else(|| map.get("slug").and_then(|v| v.as_str()))?; 285 | 286 | let owner = map 287 | .get("owned_by") 288 | .and_then(|v| v.as_str()) 289 | .or_else(|| map.get("organization").and_then(|v| v.as_str())) 290 | .or_else(|| map.get("provider").and_then(|v| v.as_str())); 291 | 292 | let label = owner 293 | .map(|o| format!("{} ({})", identifier, o)) 294 | .unwrap_or_else(|| identifier.to_string()); 295 | 296 | Some(ModelInfo { 297 | id: identifier.to_string(), 298 | label, 299 | }) 300 | } 301 | _ => None, 302 | } 303 | } 304 | 305 | fn extract_model_list(payload: &Value) -> Vec { 306 | if let Some(array) = payload.as_array() { 307 | return array.iter().filter_map(parse_model_value).collect(); 308 | } 309 | 310 | if let Some(obj) = payload.as_object() { 311 | if let Some(array) = obj.get("data").and_then(|v| v.as_array()) { 312 | return array.iter().filter_map(parse_model_value).collect(); 313 | } 314 | 315 | if let Some(array) = obj.get("models").and_then(|v| v.as_array()) { 316 | return array.iter().filter_map(parse_model_value).collect(); 317 | } 318 | 319 | if let Some(nested_obj) = obj.get("data").and_then(|v| v.as_object()) { 320 | return nested_obj.values().filter_map(parse_model_value).collect(); 321 | } 322 | 323 | let mut collected = Vec::new(); 324 | for value in obj.values() { 325 | if let Some(array) = value.as_array() { 326 | for item in array { 327 | if let Some(model) = parse_model_value(item) { 328 | collected.push(model); 329 | } 330 | } 331 | } else if let Some(model) = parse_model_value(value) { 332 | collected.push(model); 333 | } 334 | } 335 | return collected; 336 | } 337 | 338 | parse_model_value(payload).into_iter().collect() 339 | } 340 | 341 | #[tauri::command] 342 | pub async fn fetch_available_models( 343 | base_url: String, 344 | api_key: String, 345 | ) -> Result, String> { 346 | let trimmed_base = base_url.trim(); 347 | let trimmed_key = api_key.trim(); 348 | 349 | if trimmed_base.is_empty() { 350 | return Err("Base URL不能为空".to_string()); 351 | } 352 | 353 | if trimmed_key.is_empty() { 354 | return Err("API Key不能为空".to_string()); 355 | } 356 | 357 | let normalized_base = trimmed_base.trim_end_matches('/'); 358 | let endpoint = format!("{}/models", normalized_base); 359 | 360 | let response = http_client() 361 | .get(&endpoint) 362 | .header("Authorization", format!("Bearer {}", trimmed_key)) 363 | .header("Content-Type", "application/json") 364 | .send() 365 | .await 366 | .map_err(|e| format!("请求模型列表失败: {}", e))?; 367 | 368 | if !response.status().is_success() { 369 | let status = response.status(); 370 | let error_body = response.text().await.unwrap_or_default(); 371 | let error_msg = if error_body.is_empty() { 372 | format!("模型接口返回错误: {}", status) 373 | } else { 374 | format!("模型接口返回错误: {} - {}", status, error_body) 375 | }; 376 | return Err(error_msg); 377 | } 378 | 379 | let payload: Value = response 380 | .json() 381 | .await 382 | .map_err(|e| format!("解析模型列表失败: {}", e))?; 383 | 384 | let models = extract_model_list(&payload); 385 | 386 | if models.is_empty() { 387 | return Err("未从接口获取到模型列表".to_string()); 388 | } 389 | 390 | Ok(models) 391 | } 392 | 393 | #[tauri::command] 394 | pub async fn capture_screen() -> Result { 395 | use base64::Engine; 396 | use screenshots::Screen; 397 | 398 | let screens = Screen::all().map_err(|e| format!("获取屏幕列表失败: {}", e))?; 399 | 400 | if screens.is_empty() { 401 | return Err("未找到任何屏幕".to_string()); 402 | } 403 | 404 | let screen = &screens[0]; 405 | let image = screen.capture().map_err(|e| format!("截图失败: {}", e))?; 406 | 407 | let buffer = encode_image_to_png(&image)?; 408 | 409 | let base64_string = base64::engine::general_purpose::STANDARD.encode(&buffer); 410 | 411 | Ok(base64_string) 412 | } 413 | 414 | #[tauri::command] 415 | pub async fn capture_screen_area( 416 | x: u32, 417 | y: u32, 418 | width: u32, 419 | height: u32, 420 | ) -> Result { 421 | use base64::Engine; 422 | use screenshots::Screen; 423 | 424 | let screens = Screen::all().map_err(|e| format!("获取屏幕列表失败: {}", e))?; 425 | 426 | if screens.is_empty() { 427 | return Err("未找到任何屏幕".to_string()); 428 | } 429 | 430 | let screen = &screens[0]; 431 | let image = screen.capture().map_err(|e| format!("截图失败: {}", e))?; 432 | 433 | let mut dynamic_image = screenshot_to_dynamic_image(&image)?; 434 | 435 | let cropped_image = dynamic_image.crop(x, y, width, height); 436 | 437 | let mut cropped_buffer = Vec::new(); 438 | cropped_image 439 | .write_to( 440 | &mut std::io::Cursor::new(&mut cropped_buffer), 441 | image::ImageFormat::Png, 442 | ) 443 | .map_err(|e| format!("编码裁剪图片失败: {}", e))?; 444 | 445 | let base64_string = base64::engine::general_purpose::STANDARD.encode(&cropped_buffer); 446 | 447 | Ok(base64_string) 448 | } 449 | 450 | #[tauri::command] 451 | pub async fn start_area_selection(app_handle: AppHandle) -> Result<(), String> { 452 | platform::start_area_selection(app_handle).await 453 | } 454 | 455 | #[tauri::command] 456 | pub async fn set_ocr_result(app_handle: AppHandle, text: String) -> Result<(), String> { 457 | if let Some(main_window) = app_handle.get_webview_window("main") { 458 | main_window 459 | .emit("ocr-result", text) 460 | .map_err(|e| format!("发送OCR结果失败: {}", e))?; 461 | } 462 | Ok(()) 463 | } 464 | 465 | #[cfg(not(target_os = "macos"))] 466 | #[tauri::command] 467 | pub async fn submit_area_for_ocr( 468 | app_handle: AppHandle, 469 | x: i32, 470 | y: i32, 471 | width: u32, 472 | height: u32, 473 | state: State<'_, AppState>, 474 | ) -> Result<(), String> { 475 | if let Some(main_window) = app_handle.get_webview_window("main") { 476 | main_window 477 | .emit("ocr-pending", true) 478 | .map_err(|e| e.to_string())?; 479 | } 480 | show_main_window(&app_handle); 481 | 482 | let app_handle_clone = app_handle.clone(); 483 | state.inner(); 484 | 485 | tauri::async_runtime::spawn(async move { 486 | let state = app_handle_clone.state::(); 487 | 488 | match capture_area_and_ocr(x, y, width, height, state).await { 489 | Ok(text) => { 490 | println!("OCR成功,文本长度: {}, 内容: '{}'", text.len(), text); 491 | if let Some(main_window) = app_handle_clone.get_webview_window("main") { 492 | println!("发送ocr-result事件到主窗口"); 493 | match main_window.emit("ocr-result", &text) { 494 | Ok(_) => println!("ocr-result事件发送成功"), 495 | Err(e) => println!("ocr-result事件发送失败: {}", e), 496 | } 497 | } else { 498 | println!("无法找到主窗口"); 499 | } 500 | } 501 | Err(e) => { 502 | println!("Background OCR failed: {}", e); 503 | if let Some(main_window) = app_handle_clone.get_webview_window("main") { 504 | let error_msg = format!("Error: {}", e); 505 | let _ = main_window.emit("ocr-result", error_msg); 506 | } 507 | } 508 | } 509 | }); 510 | 511 | Ok(()) 512 | } 513 | 514 | #[tauri::command] 515 | pub async fn capture_area_and_ocr( 516 | x: i32, 517 | y: i32, 518 | width: u32, 519 | height: u32, 520 | state: State<'_, AppState>, 521 | ) -> Result { 522 | use screenshots::Screen; 523 | 524 | let screens = Screen::all().map_err(|e| format!("获取屏幕列表失败: {}", e))?; 525 | 526 | if screens.is_empty() { 527 | return Err("未找到任何屏幕".to_string()); 528 | } 529 | 530 | let center_x = x + (width as i32 / 2); 531 | let center_y = y + (height as i32 / 2); 532 | 533 | let screen = screens 534 | .iter() 535 | .find(|s| { 536 | let info = &s.display_info; 537 | center_x >= info.x 538 | && center_x < (info.x + info.width as i32) 539 | && center_y >= info.y 540 | && center_y < (info.y + info.height as i32) 541 | }) 542 | .unwrap_or(&screens[0]); 543 | 544 | let image = screen.capture().map_err(|e| format!("截图失败: {}", e))?; 545 | 546 | let mut dynamic_image = screenshot_to_dynamic_image(&image)?; 547 | 548 | let relative_x = (x - screen.display_info.x) as u32; 549 | let relative_y = (y - screen.display_info.y) as u32; 550 | 551 | let cropped_image = dynamic_image.crop(relative_x, relative_y, width, height); 552 | 553 | let mut cropped_buffer = Vec::new(); 554 | cropped_image 555 | .write_to( 556 | &mut std::io::Cursor::new(&mut cropped_buffer), 557 | image::ImageFormat::Png, 558 | ) 559 | .map_err(|e| format!("编码裁剪图片失败: {}", e))?; 560 | 561 | run_ocr_on_image_data(cropped_buffer, state).await 562 | } 563 | 564 | #[tauri::command] 565 | pub async fn capture_and_ocr(state: State<'_, AppState>) -> Result { 566 | use screenshots::Screen; 567 | 568 | let screens = Screen::all().map_err(|e| format!("获取屏幕列表失败: {}", e))?; 569 | 570 | if screens.is_empty() { 571 | return Err("未找到任何屏幕".to_string()); 572 | } 573 | 574 | let screen = &screens[0]; 575 | let image = screen.capture().map_err(|e| format!("截图失败: {}", e))?; 576 | 577 | let buffer = encode_image_to_png(&image)?; 578 | 579 | run_ocr_on_image_data(buffer, state).await 580 | } 581 | -------------------------------------------------------------------------------- /src-tauri/src/shortcuts.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app_state::AppState, commands::start_area_selection, database, system_tray::show_main_window, 3 | }; 4 | use std::str::FromStr; 5 | use tauri::{AppHandle, Emitter, Manager}; 6 | use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; 7 | 8 | const PREFILL_EVENT: &str = "prefill-text"; 9 | 10 | #[cfg(any(target_os = "macos", target_os = "windows"))] 11 | const COPY_SHORTCUT_ATTEMPTS: usize = 3; 12 | 13 | pub fn register_shortcuts(app: &AppHandle) { 14 | eprintln!("register_shortcuts()"); 15 | let state = app.state::(); 16 | let db = state.db.lock().unwrap(); 17 | 18 | let _ = app.global_shortcut().unregister_all(); 19 | 20 | let hotkeys = if let Ok(Some(config)) = db.get_app_config() { 21 | config.hotkeys 22 | } else { 23 | database::HotkeyConfig::platform_default() 24 | }; 25 | 26 | if !hotkeys.popup_window.is_empty() { 27 | if let Ok(shortcut) = Shortcut::from_str(&hotkeys.popup_window) { 28 | let app_handle = app.clone(); 29 | // 直接使用 on_shortcut,不需要先 register 30 | let result = 31 | app.global_shortcut() 32 | .on_shortcut(shortcut, move |_app, _shortcut, event| { 33 | eprintln!("🎯 SHORTCUT TRIGGERED: {:?}", shortcut); 34 | eprintln!("Event state: {:?}", event.state); 35 | if event.state == ShortcutState::Pressed { 36 | if app_handle.get_webview_window("main").is_some() { 37 | eprintln!("✓ Window found, forcing focus..."); 38 | show_main_window(&app_handle); 39 | } else { 40 | eprintln!("✗ Window 'main' NOT FOUND!"); 41 | } 42 | } 43 | }); 44 | match result { 45 | Ok(_) => println!("Registered popup window shortcut: {}", hotkeys.popup_window), 46 | Err(e) => { 47 | eprintln!( 48 | "Failed to register popup window shortcut '{}': {}", 49 | hotkeys.popup_window, e 50 | ); 51 | } 52 | } 53 | } 54 | } 55 | 56 | if !hotkeys.screenshot_translation.is_empty() { 57 | if let Ok(shortcut) = Shortcut::from_str(&hotkeys.screenshot_translation) { 58 | let app_handle = app.clone(); 59 | let result = 60 | app.global_shortcut() 61 | .on_shortcut(shortcut, move |_app, _shortcut, event| { 62 | if event.state == ShortcutState::Pressed { 63 | let handle = app_handle.clone(); 64 | tauri::async_runtime::spawn(async move { 65 | handle_area_ocr_shortcut(handle).await; 66 | }); 67 | } 68 | }); 69 | match result { 70 | Ok(_) => println!( 71 | "Registered screenshot translation shortcut: {}", 72 | hotkeys.screenshot_translation 73 | ), 74 | Err(e) => { 75 | eprintln!( 76 | "Failed to register screenshot translation shortcut '{}': {}", 77 | hotkeys.screenshot_translation, e 78 | ); 79 | } 80 | } 81 | } 82 | } 83 | 84 | if !hotkeys.slide_translation.is_empty() { 85 | if let Ok(shortcut) = Shortcut::from_str(&hotkeys.slide_translation) { 86 | let app_handle = app.clone(); 87 | let result = 88 | app.global_shortcut() 89 | .on_shortcut(shortcut, move |_app, _shortcut, event| { 90 | if event.state == ShortcutState::Pressed { 91 | let handle = app_handle.clone(); 92 | tauri::async_runtime::spawn(async move { 93 | handle_slide_translation_shortcut(handle).await; 94 | }); 95 | } 96 | }); 97 | match result { 98 | Ok(_) => println!( 99 | "Registered slide translation shortcut: {}", 100 | hotkeys.slide_translation 101 | ), 102 | Err(e) => { 103 | eprintln!( 104 | "Failed to register slide translation shortcut '{}': {}", 105 | hotkeys.slide_translation, e 106 | ); 107 | } 108 | } 109 | } 110 | } 111 | } 112 | async fn handle_area_ocr_shortcut(app_handle: AppHandle) { 113 | let handle_for_recovery = app_handle.clone(); 114 | if let Err(err) = start_area_selection(app_handle).await { 115 | eprintln!("启动区域截图失败: {}", err); 116 | show_main_window(&handle_for_recovery); 117 | } 118 | } 119 | 120 | async fn handle_slide_translation_shortcut(app_handle: AppHandle) { 121 | let selected_text = capture_selected_text(); 122 | show_main_window(&app_handle); 123 | 124 | if let Some(window) = app_handle.get_webview_window("main") { 125 | let payload = selected_text.unwrap_or_default(); 126 | let _ = window.emit(PREFILL_EVENT, payload); 127 | } 128 | } 129 | 130 | fn truncate_for_display(s: &str, max_chars: usize) -> String { 131 | let char_count = s.chars().count(); 132 | if char_count <= max_chars { 133 | s.to_string() 134 | } else { 135 | let truncated: String = s.chars().take(max_chars).collect(); 136 | format!("{}... ({} chars)", truncated, char_count) 137 | } 138 | } 139 | 140 | fn capture_selected_text() -> Option { 141 | eprintln!("📝 [Capture] Starting text capture..."); 142 | 143 | #[cfg(target_os = "windows")] 144 | { 145 | if let Some(text) = capture_via_windows_ui_automation() { 146 | eprintln!( 147 | "✅ [Capture] Success via UI Automation: {}", 148 | truncate_for_display(&text, 50) 149 | ); 150 | return Some(text); 151 | } 152 | eprintln!("âš ️ [Capture] UI Automation failed, trying clipboard..."); 153 | } 154 | 155 | #[cfg(target_os = "macos")] 156 | { 157 | match capture_via_macos_accessibility() { 158 | Some(text) if !text.trim().is_empty() => { 159 | eprintln!( 160 | "✅ [Capture] Success via accessibility: {}", 161 | truncate_for_display(&text, 50) 162 | ); 163 | return Some(text); 164 | } 165 | Some(_) => { 166 | eprintln!("❌ [Capture] Accessibility returned empty text, continuing..."); 167 | } 168 | None => { 169 | eprintln!( 170 | "âš ️ [Capture] Accessibility API capture failed, trying clipboard..." 171 | ); 172 | } 173 | } 174 | } 175 | 176 | #[cfg(target_os = "linux")] 177 | let mut should_try_direct_clipboard = false; 178 | 179 | if let Some(text) = capture_via_primary_selection() { 180 | if !looks_like_file_path(&text) { 181 | eprintln!( 182 | "✅ [Capture] Success: {}", 183 | truncate_for_display(&text, 50) 184 | ); 185 | return Some(text); 186 | } else { 187 | eprintln!( 188 | "âš ️ [Capture] Got file path from primary selection: {:?}", 189 | text 190 | ); 191 | #[cfg(target_os = "linux")] 192 | { 193 | should_try_direct_clipboard = true; 194 | } 195 | #[cfg(not(target_os = "linux"))] 196 | { 197 | eprintln!( 198 | "❌ [Capture] Skipping clipboard fallback on this platform (file path detected)" 199 | ); 200 | } 201 | } 202 | } else { 203 | #[cfg(target_os = "linux")] 204 | { 205 | should_try_direct_clipboard = true; 206 | } 207 | #[cfg(not(target_os = "linux"))] 208 | { 209 | eprintln!( 210 | "❌ [Capture] Primary selection capture failed and clipboard fallback is disabled" 211 | ); 212 | } 213 | } 214 | 215 | #[cfg(target_os = "linux")] 216 | { 217 | if should_try_direct_clipboard { 218 | eprintln!( 219 | "⏭️ [Capture] Primary selection unavailable, trying direct clipboard..." 220 | ); 221 | 222 | if let Some(text) = read_clipboard_directly() { 223 | eprintln!( 224 | "✅ [Capture] Success via direct clipboard: {}", 225 | truncate_for_display(&text, 50) 226 | ); 227 | return Some(text); 228 | } 229 | } 230 | } 231 | 232 | None 233 | } 234 | 235 | fn looks_like_file_path(text: &str) -> bool { 236 | let trimmed = text.trim(); 237 | 238 | let is_path = trimmed.starts_with('/') 239 | || trimmed.starts_with("C:\\") 240 | || trimmed.starts_with("file://") 241 | || trimmed.ends_with(".resolved") 242 | || trimmed.ends_with(".md") 243 | || trimmed.ends_with(".rs") 244 | || trimmed.ends_with(".js") 245 | || trimmed.ends_with(".ts") 246 | || trimmed.ends_with(".json") 247 | || trimmed.ends_with(".txt"); 248 | 249 | let has_path_structure = 250 | (trimmed.contains('/') || trimmed.contains('\\')) && (trimmed.len() > 20); 251 | 252 | is_path || has_path_structure 253 | } 254 | 255 | #[cfg(target_os = "windows")] 256 | fn capture_via_windows_ui_automation() -> Option { 257 | use windows::{ 258 | core::{Interface, BSTR}, 259 | Win32::{ 260 | System::Com::{ 261 | CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, 262 | }, 263 | UI::Accessibility::{ 264 | CUIAutomation, IUIAutomation, IUIAutomationLegacyIAccessiblePattern, 265 | IUIAutomationTextPattern, IUIAutomationValuePattern, 266 | UIA_LegacyIAccessiblePatternId, UIA_TextPattern2Id, UIA_TextPatternId, 267 | UIA_ValuePatternId, 268 | }, 269 | }, 270 | }; 271 | 272 | eprintln!("🔍 [UI Automation] Starting capture..."); 273 | 274 | unsafe { 275 | // Initialize COM 276 | let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); 277 | 278 | let automation: IUIAutomation = 279 | match CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER) { 280 | Ok(a) => a, 281 | Err(e) => { 282 | eprintln!( 283 | "❌ [UI Automation] Failed to create CUIAutomation: {}", 284 | e 285 | ); 286 | return None; 287 | } 288 | }; 289 | 290 | let element = match automation.GetFocusedElement() { 291 | Ok(e) => e, 292 | Err(e) => { 293 | eprintln!( 294 | "❌ [UI Automation] Failed to get focused element: {}", 295 | e 296 | ); 297 | return None; 298 | } 299 | }; 300 | 301 | // Try TextPattern 302 | if let Ok(pattern) = element.GetCurrentPattern(UIA_TextPatternId) { 303 | let pattern: IUIAutomationTextPattern = match pattern.cast() { 304 | Ok(p) => p, 305 | Err(_) => return None, 306 | }; 307 | 308 | if let Ok(ranges) = pattern.GetSelection() { 309 | if let Ok(length) = ranges.Length() { 310 | if length > 0 { 311 | if let Ok(range) = ranges.GetElement(0) { 312 | if let Ok(text) = range.GetText(-1) { 313 | let text_str = text.to_string(); 314 | if !text_str.is_empty() { 315 | return Some(text_str); 316 | } 317 | } 318 | } 319 | } 320 | } 321 | } 322 | } else if let Ok(pattern) = element.GetCurrentPattern(UIA_TextPattern2Id) { 323 | // Fallback to TextPattern2 (though it inherits from TextPattern, sometimes casting helps?) 324 | let pattern: IUIAutomationTextPattern = match pattern.cast() { 325 | Ok(p) => p, 326 | Err(_) => return None, 327 | }; 328 | if let Ok(ranges) = pattern.GetSelection() { 329 | if let Ok(length) = ranges.Length() { 330 | if length > 0 { 331 | if let Ok(range) = ranges.GetElement(0) { 332 | if let Ok(text) = range.GetText(-1) { 333 | let text_str = text.to_string(); 334 | if !text_str.is_empty() { 335 | return Some(text_str); 336 | } 337 | } 338 | } 339 | } 340 | } 341 | } 342 | } else if let Ok(pattern) = element.GetCurrentPattern(UIA_ValuePatternId) { 343 | // Fallback to ValuePattern (e.g. for simple text boxes) 344 | let pattern: IUIAutomationValuePattern = match pattern.cast() { 345 | Ok(p) => p, 346 | Err(_) => return None, 347 | }; 348 | if let Ok(value) = pattern.CurrentValue() { 349 | let value_str = value.to_string(); 350 | if !value_str.is_empty() { 351 | return Some(value_str); 352 | } 353 | } 354 | } else if let Ok(pattern) = element.GetCurrentPattern(UIA_LegacyIAccessiblePatternId) { 355 | // Fallback to LegacyIAccessiblePattern (older apps) 356 | let pattern: IUIAutomationLegacyIAccessiblePattern = match pattern.cast() { 357 | Ok(p) => p, 358 | Err(_) => return None, 359 | }; 360 | 361 | // Try CurrentValue first 362 | if let Ok(value) = pattern.CurrentValue() { 363 | let value_str = value.to_string(); 364 | if !value_str.is_empty() { 365 | return Some(value_str); 366 | } 367 | } 368 | 369 | // If no value, maybe it's a selection? LegacyIAccessible doesn't have a direct "Selection" text method like TextPattern. 370 | // But sometimes CurrentName holds the text for static text controls. 371 | // However, we want *selected* text. LegacyIAccessible doesn't easily give *selected* text unless the whole control is the selection. 372 | // We'll stick to CurrentValue for now as it maps to "Value" of the control. 373 | } else { 374 | // Log debug info 375 | let name = element.CurrentName().unwrap_or(BSTR::new()); 376 | let class_name = element.CurrentClassName().unwrap_or(BSTR::new()); 377 | let control_type = element 378 | .CurrentControlType() 379 | .unwrap_or(windows::Win32::UI::Accessibility::UIA_CONTROLTYPE_ID(0)); 380 | 381 | eprintln!( 382 | "âš ️ [UI Automation] Focused element does not support TextPattern. Name: '{}', Class: '{}', ControlType: {}", 383 | name, class_name, control_type.0 384 | ); 385 | } 386 | } 387 | 388 | None 389 | } 390 | 391 | #[cfg(any(target_os = "macos", target_os = "windows"))] 392 | fn capture_via_primary_selection() -> Option { 393 | use arboard::Clipboard; 394 | use std::time::{Duration, Instant}; 395 | 396 | eprintln!("🔍 [Primary Selection] Starting capture..."); 397 | 398 | let mut clipboard = Clipboard::new().ok()?; 399 | let original_clipboard = clipboard.get_text().ok(); 400 | 401 | eprintln!( 402 | "📋 [Primary Selection] Original clipboard: {:?}", 403 | original_clipboard 404 | .as_ref() 405 | .map(|s| truncate_for_display(s, 50)) 406 | ); 407 | 408 | let mut captured_text = None; 409 | 410 | // Total attempts to trigger the shortcut 411 | for attempt in 1..=COPY_SHORTCUT_ATTEMPTS { 412 | eprintln!( 413 | "⌨️ [Primary Selection] Triggering copy shortcut (attempt {}/{})...", 414 | attempt, COPY_SHORTCUT_ATTEMPTS 415 | ); 416 | trigger_copy_shortcut(); 417 | 418 | // Polling loop: check clipboard every 50ms for up to 400ms (increasing with attempts) 419 | let poll_duration = Duration::from_millis(200 + (attempt as u64 * 100)); 420 | let start_time = Instant::now(); 421 | let poll_interval = Duration::from_millis(50); 422 | 423 | loop { 424 | std::thread::sleep(poll_interval); 425 | 426 | let new_clipboard = clipboard.get_text().ok(); 427 | 428 | match (&original_clipboard, &new_clipboard) { 429 | (Some(orig), Some(new)) if orig != new => { 430 | eprintln!( 431 | "✅ [Primary Selection] Captured new text on attempt {} (took {:?})", 432 | attempt, 433 | start_time.elapsed() 434 | ); 435 | captured_text = Some(new.clone()); 436 | break; 437 | } 438 | (None, Some(new)) if !new.trim().is_empty() => { 439 | eprintln!( 440 | "✅ [Primary Selection] Captured new text on attempt {} (took {:?})", 441 | attempt, 442 | start_time.elapsed() 443 | ); 444 | captured_text = Some(new.clone()); 445 | break; 446 | } 447 | _ => {} 448 | } 449 | 450 | if start_time.elapsed() > poll_duration { 451 | eprintln!( 452 | "⏳ [Primary Selection] Timeout waiting for clipboard change on attempt {}", 453 | attempt 454 | ); 455 | break; 456 | } 457 | } 458 | 459 | if captured_text.is_some() { 460 | break; 461 | } 462 | } 463 | 464 | if let Some(original) = original_clipboard { 465 | let _ = clipboard.set_text(original); 466 | eprintln!("🔄 [Primary Selection] Restored original clipboard"); 467 | } else { 468 | let _ = clipboard.clear(); 469 | eprintln!("🔄 [Primary Selection] Cleared clipboard (was empty)"); 470 | } 471 | 472 | captured_text.filter(|text| !text.trim().is_empty()) 473 | } 474 | 475 | #[cfg(target_os = "linux")] 476 | fn capture_via_primary_selection() -> Option { 477 | use arboard::{Clipboard, GetExtLinux, LinuxClipboardKind}; 478 | 479 | eprintln!("🔍 [Primary Selection] Reading from Linux primary selection..."); 480 | let mut clipboard = Clipboard::new().ok()?; 481 | 482 | match clipboard 483 | .get() 484 | .clipboard(LinuxClipboardKind::Primary) 485 | .text() 486 | { 487 | Ok(text) => { 488 | if text.trim().is_empty() { 489 | eprintln!("❌ [Primary Selection] Primary selection was empty"); 490 | None 491 | } else { 492 | eprintln!( 493 | "✅ [Primary Selection] Captured text from primary selection: {}", 494 | truncate_for_display(&text, 50) 495 | ); 496 | Some(text) 497 | } 498 | } 499 | Err(err) => { 500 | eprintln!( 501 | "❌ [Primary Selection] Failed to read primary selection via arboard: {}", 502 | err 503 | ); 504 | None 505 | } 506 | } 507 | } 508 | 509 | #[cfg(all( 510 | not(target_os = "macos"), 511 | not(target_os = "windows"), 512 | not(target_os = "linux") 513 | ))] 514 | fn capture_via_primary_selection() -> Option { 515 | None 516 | } 517 | 518 | #[cfg(target_os = "macos")] 519 | fn capture_via_macos_accessibility() -> Option { 520 | use std::process::Command; 521 | 522 | let script = r#" 523 | set selectedText to "" 524 | try 525 | tell application "System Events" 526 | set frontApp to first application process whose frontmost is true 527 | set focusedElement to value of attribute "AXFocusedUIElement" of frontApp 528 | try 529 | set selectedText to value of attribute "AXSelectedText" of focusedElement 530 | on error 531 | try 532 | set selectedText to value of attribute "AXValue" of focusedElement 533 | on error 534 | set selectedText to "" 535 | end try 536 | end try 537 | end tell 538 | end try 539 | selectedText 540 | "#; 541 | 542 | let output = Command::new("osascript") 543 | .arg("-e") 544 | .arg(script) 545 | .output() 546 | .ok()?; 547 | 548 | if !output.status.success() { 549 | eprintln!( 550 | "❌ [Capture] Accessibility script failed with status: {}", 551 | output.status 552 | ); 553 | return None; 554 | } 555 | 556 | let text = String::from_utf8_lossy(&output.stdout) 557 | .trim_matches('\u{0}') 558 | .trim() 559 | .to_string(); 560 | 561 | if text.is_empty() || text == "missing value" { 562 | None 563 | } else { 564 | Some(text) 565 | } 566 | } 567 | 568 | #[cfg(target_os = "linux")] 569 | fn read_clipboard_directly() -> Option { 570 | use arboard::Clipboard; 571 | 572 | let mut clipboard = Clipboard::new().ok()?; 573 | let text = clipboard.get_text().ok()?; 574 | if text.trim().is_empty() { 575 | None 576 | } else { 577 | Some(text) 578 | } 579 | } 580 | 581 | #[cfg(target_os = "macos")] 582 | fn trigger_copy_shortcut() { 583 | use std::process::Command; 584 | 585 | let script = r#"tell application "System Events" to keystroke "c" using {command down}"#; 586 | let _ = Command::new("osascript").arg("-e").arg(script).output(); 587 | } 588 | 589 | #[cfg(target_os = "windows")] 590 | fn trigger_copy_shortcut() { 591 | use scopeguard::guard; 592 | use std::{thread, time::Duration}; 593 | use windows::Win32::System::Console::SetConsoleCtrlHandler; 594 | use windows::Win32::UI::Input::KeyboardAndMouse::{ 595 | SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, VK_C, VK_CONTROL, 596 | }; 597 | 598 | unsafe { 599 | // Set up a scope guard to temporarily ignore Ctrl+C signals 600 | let _guard = guard((), |_| { 601 | // This will be called when the guard goes out of scope 602 | // Restore default Ctrl+C handler 603 | let _ = SetConsoleCtrlHandler(None, false); 604 | }); 605 | 606 | // Temporarily disable Ctrl+C handling to prevent the app from exiting 607 | let _ = SetConsoleCtrlHandler(None, true); 608 | 609 | let inputs = [ 610 | // Press Ctrl 611 | INPUT { 612 | r#type: INPUT_KEYBOARD, 613 | Anonymous: INPUT_0 { 614 | ki: KEYBDINPUT { 615 | wVk: VK_CONTROL, 616 | ..Default::default() 617 | }, 618 | }, 619 | }, 620 | // Press C 621 | INPUT { 622 | r#type: INPUT_KEYBOARD, 623 | Anonymous: INPUT_0 { 624 | ki: KEYBDINPUT { 625 | wVk: VK_C, 626 | ..Default::default() 627 | }, 628 | }, 629 | }, 630 | // Release C 631 | INPUT { 632 | r#type: INPUT_KEYBOARD, 633 | Anonymous: INPUT_0 { 634 | ki: KEYBDINPUT { 635 | wVk: VK_C, 636 | dwFlags: KEYEVENTF_KEYUP, 637 | ..Default::default() 638 | }, 639 | }, 640 | }, 641 | // Release Ctrl 642 | INPUT { 643 | r#type: INPUT_KEYBOARD, 644 | Anonymous: INPUT_0 { 645 | ki: KEYBDINPUT { 646 | wVk: VK_CONTROL, 647 | dwFlags: KEYEVENTF_KEYUP, 648 | ..Default::default() 649 | }, 650 | }, 651 | }, 652 | ]; 653 | 654 | SendInput(&inputs, std::mem::size_of::() as i32); 655 | thread::sleep(Duration::from_millis(50)); 656 | 657 | // The guard will automatically restore the Ctrl+C handler when it goes out of scope 658 | } 659 | } 660 | 661 | #[cfg(not(any(target_os = "macos", target_os = "windows")))] 662 | fn trigger_copy_shortcut() {} 663 | --------------------------------------------------------------------------------