feat: add native Windows app and OCR, remove legacy Tauri frontend

This commit is contained in:
ZASENJC committed 2026-10-15 20:29:44 +08:00
1 parent 46c2f3664b
commit d16722fdf4
100 files changed
+4711 -19918

No files matched your search

-8
View File
@@ -1,14 +1,6 @@
.DS_Store
.claude/scheduled_tasks.lock
node_modules
/build
/dist
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/target
Generated
+67 -2
View File
@@ -3160,6 +3160,7 @@ dependencies = [
"objc2-vision",
"serde",
"tempfile",
"windows",
]
[[package]]
@@ -3455,6 +3456,51 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -3468,8 +3514,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
]
[[package]]
@@ -3481,6 +3536,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
+10
View File
@@ -114,3 +114,13 @@ objc2-app-kit = { version = "0.3", features = [
"objc2-core-foundation",
] }
objc2-quartz-core = { version = "0.3", features = ["CALayer"] }
windows = { version = "0.58", features = [
"Foundation",
"Foundation_Collections",
"Globalization",
"Graphics_Imaging",
"Media_Ocr",
"Security_Cryptography",
"Storage_Streams",
"Win32_System_WinRT",
] }
File renamed without changes.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 974 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 903 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

File renamed without changes.
File renamed without changes.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

-3
View File
@@ -23,6 +23,3 @@ tempfile.workspace = true
candle-core = { version = "0.11.0", features = ["metal", "accelerate"] }
candle-transformers = { version = "0.11.0", features = ["metal", "accelerate"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
candle-core = { version = "0.11.0" }
candle-transformers = { version = "0.11.0" }
+33 -1
View File
@@ -6,25 +6,36 @@
//! 3. 优先启用 Apple Silicon `Device::new_metal(0)` GPU 加速,并在内存中常驻缓存已加载的模型权重,二次翻译 0 加载延迟;
//! 4. 每生成一个 Token 即刻回调 UI 并检查 `CancelToken`,支持随时毫秒级打断。
#[cfg(target_os = "macos")]
use std::collections::HashMap;
#[cfg(target_os = "macos")]
use std::fs::File;
#[cfg(target_os = "macos")]
use std::io::BufReader;
#[cfg(target_os = "macos")]
use std::path::Path;
#[cfg(target_os = "macos")]
use std::sync::{Mutex, OnceLock};
#[cfg(target_os = "macos")]
use candle_core::quantized::gguf_file::{self, Value as GgufValue};
#[cfg(target_os = "macos")]
use candle_core::{Device, Tensor};
#[cfg(target_os = "macos")]
use candle_transformers::models::{quantized_llama, quantized_qwen2, quantized_qwen3};
use ttree_core::{CancelToken, ChatMessage};
#[cfg(target_os = "macos")]
const MAX_NEW_TOKENS: usize = 512;
#[cfg(target_os = "macos")]
enum QuantizedArch {
Qwen2(quantized_qwen2::ModelWeights),
Qwen3(quantized_qwen3::ModelWeights),
Llama(quantized_llama::ModelWeights),
}
#[cfg(target_os = "macos")]
impl QuantizedArch {
fn clear_kv_cache(&mut self) {
match self {
@@ -44,6 +55,7 @@ impl QuantizedArch {
}
/// 从 GGUF 内嵌的 `tokenizer.ggml.tokens` 词表构建的轻量分词/解码器。
#[cfg(target_os = "macos")]
pub struct GgufEmbeddedTokenizer {
id_to_token: Vec<String>,
token_to_id: HashMap<String, u32>,
@@ -51,6 +63,7 @@ pub struct GgufEmbeddedTokenizer {
bos_token: Option<u32>,
}
#[cfg(target_os = "macos")]
impl GgufEmbeddedTokenizer {
pub fn from_gguf_metadata(md: &HashMap<String, GgufValue>) -> Result<Self, String> {
let raw_tokens = md
@@ -184,6 +197,7 @@ impl GgufEmbeddedTokenizer {
}
}
#[cfg(target_os = "macos")]
struct CachedSession {
model_path: String,
device: Device,
@@ -191,16 +205,18 @@ struct CachedSession {
tokenizer: GgufEmbeddedTokenizer,
}
#[cfg(target_os = "macos")]
static GGUF_SESSION: OnceLock<Mutex<Option<CachedSession>>> = OnceLock::new();
#[cfg(target_os = "macos")]
fn select_device() -> Device {
#[cfg(target_os = "macos")]
if let Ok(metal) = Device::new_metal(0) {
return metal;
}
Device::Cpu
}
#[cfg(target_os = "macos")]
fn load_session(model_path: &str) -> Result<CachedSession, String> {
let path = Path::new(model_path);
if !path.is_file() {
@@ -261,6 +277,7 @@ pub fn format_chatml_prompt(messages: &[ChatMessage]) -> String {
prompt
}
#[cfg(target_os = "macos")]
pub async fn stream_gguf_inference<F>(
gguf_model_path: &str,
_gguf_tokenizer_path: &str,
@@ -355,7 +372,22 @@ where
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub async fn stream_gguf_inference<F>(
_gguf_model_path: &str,
_gguf_tokenizer_path: &str,
_messages: &[ChatMessage],
_cancel: &CancelToken,
_on_delta: &mut F,
) -> Result<(), String>
where
F: FnMut(&str) + Send,
{
Err("Candle Metal GGUF 直载仅限 macOS;Windows 请选择「内置 HY-MT1.5-1.8B」或「LocalServer / Ollama」后端。".to_string())
}
/// 执行一步前向推理并采用 Argmax 贪心采样(翻译任务使用 temperature=0 最准确稳定)。
#[cfg(target_os = "macos")]
fn run_step(
arch: &mut QuantizedArch,
device: &Device,
+81 -6
View File
@@ -273,6 +273,10 @@ pub fn resolve_existing_llama_server() -> Option<PathBuf> {
if is_executable_file(&managed) {
return Some(managed);
}
let managed_nested = runtime_dir().join("build").join("bin").join(bin_name);
if is_executable_file(&managed_nested) {
return Some(managed_nested);
}
// 2. 应用包内置同级目录(若打包时附带)
if let Ok(exe) = std::env::current_exe() {
@@ -417,6 +421,15 @@ fn detect_cpu_brand() -> String {
}
}
}
#[cfg(target_os = "windows")]
{
if let Ok(id) = std::env::var("PROCESSOR_IDENTIFIER") {
let trimmed = id.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
format!("{} CPU", std::env::consts::ARCH)
}
@@ -441,6 +454,31 @@ fn detect_total_ram_gb() -> f64 {
}
}
}
#[cfg(target_os = "windows")]
{
#[repr(C)]
struct MemoryStatusEx {
length: u32,
memory_load: u32,
total_phys: u64,
avail_phys: u64,
total_page_file: u64,
avail_page_file: u64,
total_virtual: u64,
avail_virtual: u64,
avail_extended_virtual: u64,
}
extern "system" {
fn GlobalMemoryStatusEx(lpBuffer: *mut MemoryStatusEx) -> i32;
}
unsafe {
let mut status: MemoryStatusEx = std::mem::zeroed();
status.length = std::mem::size_of::<MemoryStatusEx>() as u32;
if GlobalMemoryStatusEx(&mut status) != 0 && status.total_phys > 0 {
return (status.total_phys as f64) / (1024.0 * 1024.0 * 1024.0);
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(meminfo) = fs::read_to_string("/proc/meminfo") {
@@ -477,6 +515,36 @@ fn detect_free_disk_gb(path: &Path) -> f64 {
}
}
}
#[cfg(target_os = "windows")]
{
use std::os::windows::ffi::OsStrExt;
extern "system" {
fn GetDiskFreeSpaceExW(
lpDirectoryName: *const u16,
lpFreeBytesAvailableToCaller: *mut u64,
lpTotalNumberOfBytes: *mut u64,
lpTotalNumberOfFreeBytes: *mut u64,
) -> i32;
}
let wide: Vec<u16> = check_dir
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut avail: u64 = 0;
unsafe {
if GetDiskFreeSpaceExW(
wide.as_ptr(),
&mut avail,
std::ptr::null_mut(),
std::ptr::null_mut(),
) != 0
&& avail > 0
{
return (avail as f64) / (1024.0 * 1024.0 * 1024.0);
}
}
}
50.0
}
@@ -882,10 +950,16 @@ pub async fn ensure_server_running(
let root = local_llm_root_dir();
let _ = fs::create_dir_all(&root);
let log_path = root.join("llama-server.log");
let log_file = File::create(&log_path).unwrap_or_else(|_| File::open("/dev/null").unwrap());
let log_err = log_file
.try_clone()
.unwrap_or_else(|_| File::open("/dev/null").unwrap());
let (stdout_cfg, stderr_cfg) = match File::create(&log_path) {
Ok(f) => {
let err_stdio = f
.try_clone()
.map(Stdio::from)
.unwrap_or_else(|_| Stdio::null());
(Stdio::from(f), err_stdio)
}
Err(_) => (Stdio::null(), Stdio::null()),
};
let mut cmd = Command::new(&server_bin);
cmd.arg("-m")
@@ -908,9 +982,10 @@ pub async fn ensure_server_running(
.arg("1.05")
.arg("--no-webui")
.stdin(Stdio::null())
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(log_err));
.stdout(stdout_cfg)
.stderr(stderr_cfg);
#[cfg(unix)]
if let Some(bin_dir) = server_bin.parent() {
#[cfg(target_os = "macos")]
cmd.env("DYLD_LIBRARY_PATH", bin_dir);
+13 -5
View File
@@ -1,4 +1,4 @@
//! `ttree` —— 100% 纯 Rust 原生 macOS AI 翻译与 OCR 桌面端入口。
//! `ttree` —— 100% 纯 Rust 原生跨平台 (macOS AppKit / Windows Win32+DWM) AI 翻译与 OCR 桌面端入口。
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
@@ -7,16 +7,24 @@ mod state;
#[cfg(target_os = "macos")]
mod ui_helpers;
#[cfg(not(target_os = "macos"))]
fn main() {
eprintln!("TTREE 纯 Rust 原生 AppKit 客户端仅支持 macOS 运行。");
}
#[cfg(target_os = "windows")]
mod windows_app;
#[cfg(target_os = "macos")]
fn main() {
macos_app::run();
}
#[cfg(target_os = "windows")]
fn main() {
windows_app::run();
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
fn main() {
eprintln!("TTREE 纯 Rust 原生客户端目前仅支持 macOS 与 Windows 运行。");
}
#[cfg(target_os = "macos")]
pub(crate) fn dispatch_splitter_drag(splitter_id: u8, delta_down: f64) {
macos_app::on_splitter_drag(splitter_id, delta_down);
File diff suppressed because it is too large. Load diff
+3
View File
@@ -18,3 +18,6 @@ objc2-core-ml.workspace = true
objc2-foundation.workspace = true
objc2-image-io.workspace = true
objc2-vision.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
windows.workspace = true
+38 -3
View File
@@ -6,6 +6,8 @@
//! 3. `image.rs` 自动采样截图边缘背景色填充 Padding(替代旧版硬编码纯白底,彻底修复深色模式单行小图识别失败);
//! 4. `vision.rs` 移除主路径上的 `PreferBackgroundProcessing(true)` 后台降级标志,确保菜单栏配件应用极速响应。
pub mod postprocess;
pub mod preprocess;
pub mod screenshot;
#[cfg(target_os = "macos")]
@@ -13,6 +15,11 @@ pub mod image;
#[cfg(target_os = "macos")]
pub mod vision;
#[cfg(target_os = "windows")]
pub mod windows_capture;
#[cfg(target_os = "windows")]
pub mod windows_ocr;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -46,11 +53,14 @@ pub enum OcrOutcome {
EmptyText,
/// 上一次截图框选仍在进行中。
Busy,
/// macOS 屏幕录制权限未授予或已失效。
/// 屏幕录制权限未授予,或系统缺少 OCR 语言包。
PermissionDenied(String),
}
/// 完整执行一次「macOS 原生交互式框选 (`screencapture -i -x -r`) + Apple Vision OCR」。
/// 完整执行一次「原生交互式自由框选 + 原生离线 OCR 识别」。
/// - macOS:调用 `/usr/sbin/screencapture -i -x -r` + Apple Vision OCR
/// - Windows:调用 Per-Monitor DPI v2 原生全屏框选 + 双通道增强多语言 `Windows.Media.Ocr`
#[cfg(target_os = "macos")]
pub fn capture_and_recognize() -> Result<OcrOutcome, String> {
let Some(_guard) = BusyGuard::try_acquire() else {
return Ok(OcrOutcome::Busy);
@@ -65,12 +75,37 @@ pub fn capture_and_recognize() -> Result<OcrOutcome, String> {
}
}
#[cfg(target_os = "windows")]
pub fn capture_and_recognize() -> Result<OcrOutcome, String> {
let Some(_guard) = BusyGuard::try_acquire() else {
return Ok(OcrOutcome::Busy);
};
match windows_capture::capture_interactive_region()? {
windows_capture::WindowsCaptureResult::Captured(raw_img) => {
windows_ocr::recognize_raw_bgra(raw_img)
}
windows_capture::WindowsCaptureResult::Cancelled => Ok(OcrOutcome::Cancelled),
}
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn capture_and_recognize() -> Result<OcrOutcome, String> {
Err("当前平台暂不支持截图 OCR".to_string())
}
#[cfg(target_os = "macos")]
pub fn recognize_file(path: &str) -> Result<OcrOutcome, String> {
vision::recognize_file(path)
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
pub fn recognize_file(path: &str) -> Result<OcrOutcome, String> {
windows_ocr::recognize_file(path)
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn recognize_file(_path: &str) -> Result<OcrOutcome, String> {
Err("当前平台暂不支持 OCR".to_string())
}
+173
View File
@@ -0,0 +1,173 @@
//! 跨平台 OCR 文本后处理与多语言引擎结果评分器。
//!
//! 解决 `Windows.Media.Ocr` 的两大典型缺陷:
//! 1. **中文字符间被强行插入空格**:`Windows.Media.Ocr` 的 `OcrLine::Text()` 会将每个中文词/字用空格拼起来
//! (如 `"截 图 翻 译 测 试"`),严重干扰下游机器翻译与大模型理解。本模块通过 `join_ocr_words` 与
//! `clean_cjk_ocr_spaces` 智能消去 CJK 字符及全角标点之间的伪空格,同时完整保留英文单词间的合法空格;
//! 2. **错选纯英文引擎识别中文产生乱码符号**:提供 `score_ocr_text` 质量评分函数,供多语言引擎回退择优。
/// 判断字符是否属于 CJK 汉字、假名、谚文或全角标点符号。
#[inline]
pub fn is_cjk_or_fullwidth(c: char) -> bool {
matches!(
c as u32,
0x2E80..=0x2FFF // CJK 部首补充、康熙部首
| 0x3000..=0x303F // CJK 符号和标点
| 0x3040..=0x30FF // 平假名、片假名
| 0x3100..=0x312F // 注音符号
| 0x3130..=0x318F // 谚文兼容字母
| 0x3200..=0x32FF // 带圈 CJK
| 0x3400..=0x4DBF // CJK 统一表意文字扩展 A
| 0x4E00..=0x9FFF // CJK 统一表意文字基本集
| 0xAC00..=0xD7AF // 韩文音节
| 0xF900..=0xFAFF // CJK 兼容表意文字
| 0xFE30..=0xFE4F // CJK 兼容形式
| 0xFF00..=0xFFEF // 全角 ASCII、全角标点
| 0x20000..=0x2FA1F // CJK 扩展 B~F
)
}
/// 将一行内的 OCR 分词序列智能拼接:
/// - 相邻两个 Token 若任意一侧紧邻全角标点,或两侧均为 CJK 字符,则直接无缝拼接;
/// - 英文/数字单词之间保留单个空格。
pub fn join_ocr_words<S: AsRef<str>>(words: &[S]) -> String {
let mut out = String::new();
for word in words {
let piece = word.as_ref().trim();
if piece.is_empty() {
continue;
}
if let (Some(prev_char), Some(next_char)) = (out.chars().last(), piece.chars().next()) {
if should_insert_space_between(prev_char, next_char) {
out.push(' ');
}
}
out.push_str(piece);
}
clean_cjk_ocr_spaces(&out)
}
#[inline]
fn should_insert_space_between(left: char, right: char) -> bool {
if is_cjk_or_fullwidth(left) && is_cjk_or_fullwidth(right) {
return false;
}
// 全角标点与英文/数字之间也不加多余空格
if is_fullwidth_punctuation(left) || is_fullwidth_punctuation(right) {
return false;
}
// 两侧均为 ASCII 字母/数字,或一侧为 ASCII 单词标点时保留空格
left.is_ascii_graphic() && right.is_ascii_graphic()
}
#[inline]
fn is_fullwidth_punctuation(c: char) -> bool {
matches!(
c,
',' | '。'
| '、'
| ';'
| ':'
| '?'
| '!'
| '“'
| '”'
| '‘'
| '’'
| '('
| ')'
| '【'
| '】'
| '《'
| '》'
| '—'
| '…'
| '·'
)
}
/// 清理单行字符串中 CJK 字符之间的冗余空格(兼容直接返回 `OcrLine::Text()` 的路径)。
pub fn clean_cjk_ocr_spaces(line: &str) -> String {
let chars: Vec<char> = line.trim().chars().collect();
if chars.is_empty() {
return String::new();
}
let mut out = String::with_capacity(line.len());
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch == ' ' || ch == '\t' {
// 跳过连续空白,检查左右有效字符
let prev = out.chars().last();
let mut j = i + 1;
while j < chars.len() && (chars[j] == ' ' || chars[j] == '\t') {
j += 1;
}
let next = chars.get(j).copied();
if let (Some(l), Some(r)) = (prev, next) {
if should_insert_space_between(l, r) {
out.push(' ');
}
}
i = j;
} else {
out.push(ch);
i += 1;
}
}
out
}
/// 评估 OCR 识别结果的有效文本质量分(用于多语言引擎或多轮预处理候选择优)。
pub fn score_ocr_text(text: &str) -> i64 {
let trimmed = text.trim();
if trimmed.is_empty() {
return 0;
}
let mut score: i64 = 0;
for ch in trimmed.chars() {
if is_cjk_or_fullwidth(ch) {
score += 5;
} else if ch.is_ascii_alphanumeric() {
score += 3;
} else if ch.is_alphabetic() {
score += 3;
} else if matches!(ch, '.' | ',' | '-' | '_' | '/' | ':' | ';' | '?' | '!' | '\'' | '"') {
score += 1;
} else if ch.is_whitespace() {
// 不加分也不扣分
} else {
// 孤立生僻控制符或乱码噪点轻微扣分
score -= 1;
}
}
score
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_spaces_between_cjk_words_while_preserving_english_spaces() {
let words = vec!["这", "是", "Windows", "原", "生", "OCR", "Test", "engine", "!"];
assert_eq!(join_ocr_words(&words), "这是Windows原生OCR Test engine!");
}
#[test]
fn cleans_inline_cjk_spaces() {
assert_eq!(
clean_cjk_ocr_spaces("请 安 装 Windows OCR 语 言 包 : 中文(简体)"),
"请安装Windows OCR语言包:中文(简体)"
);
}
#[test]
fn scores_meaningful_text_higher_than_ocr_noise() {
let good = score_ocr_text("你好世界 Hello World");
let noise = score_ocr_text("|| ~ ^ `");
assert!(good > 20);
assert!(good > noise);
}
}
+395
View File
@@ -0,0 +1,395 @@
//! 跨平台纯 Rust OCR 图像预处理器(专为解决 Windows.Media.Ocr 识别率痛点设计)。
//!
//! 解决的核心问题:
//! 1. **小字/单行截图识别失败**:当框选高度 `< 56px` 或 `< 112px` 时,自动执行 3× / 2× 双线性插值放大(Bilinear Upscale),
//! 使字高落在 `Windows.Media.Ocr` 最佳识别区间(36~64px);
//! 2. **紧贴边缘吞首尾字**:采样原图外圈像素计算自适应背景色,四周无缝扩展 `32px` 边距并保证最小画布 `192×128`;
//! 3. **深色模式/低对比度灰字漏识**:提供 Pass-2 高对比度归一化(自动检测深色背景并反相为白底黑字 + 5%~95% 分位对比度拉伸 + S 曲线锐化)。
const BORDER_PADDING: u32 = 32;
const MIN_CANVAS_WIDTH: u32 = 192;
const MIN_CANVAS_HEIGHT: u32 = 128;
const MAX_OCR_PIXELS: u64 = 16_000_000;
/// 内存中自上而下(Top-Down)排列的 32-bit BGRA 位图。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawBgraImage {
pub width: u32,
pub height: u32,
pub bgra: Vec<u8>,
}
impl RawBgraImage {
pub fn new(width: u32, height: u32, bgra: Vec<u8>) -> Result<Self, String> {
if width == 0 || height == 0 {
return Err("截图尺寸为空,请重新框选。".to_string());
}
let expected = (width as usize)
.checked_mul(height as usize)
.and_then(|px| px.checked_mul(4))
.ok_or_else(|| "截图尺寸溢出".to_string())?;
if bgra.len() != expected {
return Err(format!(
"位图字节数不匹配:期望 {expected},实际 {}",
bgra.len()
));
}
Ok(Self {
width,
height,
bgra,
})
}
/// 裁剪子区域 `[x..x+w, y..y+h]`。
pub fn crop(&self, x: u32, y: u32, w: u32, h: u32) -> Result<Self, String> {
if w == 0 || h == 0 {
return Err("选区尺寸为空".to_string());
}
let x2 = x.saturating_add(w).min(self.width);
let y2 = y.saturating_add(h).min(self.height);
if x >= x2 || y >= y2 {
return Err("选区超出屏幕范围".to_string());
}
let actual_w = x2 - x;
let actual_h = y2 - y;
let mut out = vec![0u8; (actual_w as usize) * (actual_h as usize) * 4];
let src_stride = (self.width as usize) * 4;
let dst_stride = (actual_w as usize) * 4;
for row in 0..actual_h as usize {
let src_start = (y as usize + row) * src_stride + (x as usize) * 4;
let dst_start = row * dst_stride;
out[dst_start..dst_start + dst_stride]
.copy_from_slice(&self.bgra[src_start..src_start + dst_stride]);
}
Ok(Self {
width: actual_w,
height: actual_h,
bgra: out,
})
}
}
/// 计算适合 OCR 的放大倍率(1× / 2× / 3×)。
pub fn recommended_upscale_factor(width: u32, height: u32) -> u32 {
let base_pixels = (width as u64) * (height as u64);
if (height < 56 || width < 160) && base_pixels * 9 <= MAX_OCR_PIXELS {
3
} else if (height < 112 || width < 320) && base_pixels * 4 <= MAX_OCR_PIXELS {
2
} else {
1
}
}
/// 消除 Windows ClearType LCD 亚像素渲染产生的红/蓝彩边伪影:
/// ClearType 在 100%/125% 缩放下对字体竖直笔画两侧使用红/蓝亚像素抗锯齿(导致 `|R - B|` 显著偏大且 R/G/B 单调递减/递增),
/// 这会让 `Windows.Media.Ocr` 二值化时笔画粘连或断裂。将其还原为以绿色通道为主导的平滑灰度边缘 `(R + 2G + B) / 4`。
pub fn neutralize_cleartype_fringes(src: &RawBgraImage) -> RawBgraImage {
let mut out = src.bgra.clone();
for px in out.chunks_exact_mut(4) {
let b = px[0] as i32;
let g = px[1] as i32;
let r = px[2] as i32;
let rb_diff = (r - b).abs();
let is_monotonic = (r >= g && g >= b) || (b >= g && g >= r);
if is_monotonic && rb_diff >= 24 {
let luma = ((r + 2 * g + b) >> 2) as u8;
px[0] = luma;
px[1] = luma;
px[2] = luma;
}
}
RawBgraImage {
width: src.width,
height: src.height,
bgra: out,
}
}
/// Pass-1 预处理:消除 ClearType 彩边 + 小图自适应双线性放大 + 外圈背景色无缝填充边距。
pub fn prepare_primary_for_ocr(img: &RawBgraImage) -> Result<RawBgraImage, String> {
let defringed = neutralize_cleartype_fringes(img);
let factor = recommended_upscale_factor(defringed.width, defringed.height);
let scaled = if factor > 1 {
upscale_bilinear(&defringed, factor)
} else {
defringed
};
let (bg_b, bg_g, bg_r) = sample_border_bgr(&scaled);
pad_with_bg(&scaled, bg_b, bg_g, bg_r)
}
/// Pass-2 高对比度增强预处理:针对深色模式(白字黑底)或低对比度灰字,
/// 自动反相为白底深色字并做分位数对比度拉伸 + S 曲线增强。
pub fn prepare_high_contrast_for_ocr(img: &RawBgraImage) -> Result<RawBgraImage, String> {
let defringed = neutralize_cleartype_fringes(img);
let factor = recommended_upscale_factor(defringed.width, defringed.height).max(2);
let factor = if (defringed.width as u64)
* (defringed.height as u64)
* (factor as u64)
* (factor as u64)
<= MAX_OCR_PIXELS
{
factor
} else {
1
};
let scaled = if factor > 1 {
upscale_bilinear(&defringed, factor)
} else {
defringed
};
let (bg_b, bg_g, bg_r) = sample_border_bgr(&scaled);
let bg_luma = rgb_to_luma(bg_r, bg_g, bg_b);
let invert = bg_luma < 128;
let pixel_count = (scaled.width as usize) * (scaled.height as usize);
let mut lumas = Vec::with_capacity(pixel_count);
let mut hist = [0usize; 256];
for chunk in scaled.bgra.chunks_exact(4) {
let mut y = rgb_to_luma(chunk[2], chunk[1], chunk[0]);
if invert {
y = 255 - y;
}
lumas.push(y);
hist[y as usize] += 1;
}
let low = percentile_from_hist(&hist, pixel_count, 5);
let high = percentile_from_hist(&hist, pixel_count, 95).max(low.saturating_add(20));
let span = (high - low).max(1) as f32;
let mut enhanced = vec![255u8; scaled.bgra.len()];
for (i, &y) in lumas.iter().enumerate() {
let norm = ((y as f32 - low as f32) / span).clamp(0.0, 1.0);
// S 曲线拉开笔画与底色对比度:暗部更黑,亮部纯白
let curved = if norm > 0.72 {
1.0
} else if norm < 0.28 {
0.0
} else {
((norm - 0.28) / 0.44).clamp(0.0, 1.0)
};
let out_v = (curved * 255.0).round() as u8;
let idx = i * 4;
enhanced[idx] = out_v;
enhanced[idx + 1] = out_v;
enhanced[idx + 2] = out_v;
enhanced[idx + 3] = 255;
}
let normalized_img = RawBgraImage {
width: scaled.width,
height: scaled.height,
bgra: enhanced,
};
pad_with_bg(&normalized_img, 255, 255, 255)
}
#[inline]
fn rgb_to_luma(r: u8, g: u8, b: u8) -> u8 {
((77u32 * r as u32 + 150u32 * g as u32 + 29u32 * b as u32) >> 8) as u8
}
fn percentile_from_hist(hist: &[usize; 256], total: usize, pct: usize) -> u8 {
if total == 0 {
return 0;
}
let target = (total * pct) / 100;
let mut acc = 0usize;
for (val, &count) in hist.iter().enumerate() {
acc += count;
if acc >= target {
return val as u8;
}
}
255
}
/// 双线性插值放大 BGRA 图像。
pub fn upscale_bilinear(src: &RawBgraImage, factor: u32) -> RawBgraImage {
if factor <= 1 {
return src.clone();
}
let dst_w = src.width * factor;
let dst_h = src.height * factor;
let mut dst = vec![0u8; (dst_w as usize) * (dst_h as usize) * 4];
let src_w = src.width as usize;
let src_h = src.height as usize;
let scale = 1.0 / (factor as f32);
for dy in 0..dst_h as usize {
let sy_f = ((dy as f32 + 0.5) * scale - 0.5).max(0.0);
let y0 = (sy_f.floor() as usize).min(src_h - 1);
let y1 = (y0 + 1).min(src_h - 1);
let wy = sy_f - (y0 as f32);
for dx in 0..dst_w as usize {
let sx_f = ((dx as f32 + 0.5) * scale - 0.5).max(0.0);
let x0 = (sx_f.floor() as usize).min(src_w - 1);
let x1 = (x0 + 1).min(src_w - 1);
let wx = sx_f - (x0 as f32);
let i00 = (y0 * src_w + x0) * 4;
let i10 = (y0 * src_w + x1) * 4;
let i01 = (y1 * src_w + x0) * 4;
let i11 = (y1 * src_w + x1) * 4;
let dst_idx = (dy * (dst_w as usize) + dx) * 4;
for c in 0..3 {
let v00 = src.bgra[i00 + c] as f32;
let v10 = src.bgra[i10 + c] as f32;
let v01 = src.bgra[i01 + c] as f32;
let v11 = src.bgra[i11 + c] as f32;
let top = v00 + (v10 - v00) * wx;
let bot = v01 + (v11 - v01) * wx;
let val = top + (bot - top) * wy;
dst[dst_idx + c] = val.round().clamp(0.0, 255.0) as u8;
}
dst[dst_idx + 3] = 255;
}
}
RawBgraImage {
width: dst_w,
height: dst_h,
bgra: dst,
}
}
/// 采样图像四周边缘像素的平均 `(B, G, R)`。
pub fn sample_border_bgr(img: &RawBgraImage) -> (u8, u8, u8) {
let w = img.width as usize;
let h = img.height as usize;
if w == 0 || h == 0 {
return (255, 255, 255);
}
let mut b_sum: u64 = 0;
let mut g_sum: u64 = 0;
let mut r_sum: u64 = 0;
let mut count: u64 = 0;
let mut sample = |x: usize, y: usize| {
let idx = (y * w + x) * 4;
b_sum += img.bgra[idx] as u64;
g_sum += img.bgra[idx + 1] as u64;
r_sum += img.bgra[idx + 2] as u64;
count += 1;
};
for x in 0..w {
sample(x, 0);
if h > 1 {
sample(x, h - 1);
}
}
if h > 2 {
for y in 1..(h - 1) {
sample(0, y);
if w > 1 {
sample(w - 1, y);
}
}
}
if count == 0 {
return (255, 255, 255);
}
(
(b_sum / count) as u8,
(g_sum / count) as u8,
(r_sum / count) as u8,
)
}
fn pad_with_bg(
img: &RawBgraImage,
bg_b: u8,
bg_g: u8,
bg_r: u8,
) -> Result<RawBgraImage, String> {
let canvas_w = img
.width
.checked_add(BORDER_PADDING * 2)
.map(|w| w.max(MIN_CANVAS_WIDTH))
.ok_or_else(|| "画布宽度溢出".to_string())?;
let canvas_h = img
.height
.checked_add(BORDER_PADDING * 2)
.map(|h| h.max(MIN_CANVAS_HEIGHT))
.ok_or_else(|| "画布高度溢出".to_string())?;
if (canvas_w as u64) * (canvas_h as u64) > MAX_OCR_PIXELS {
return Err("截图区域过大,请缩小框选范围后重试。".to_string());
}
let total_px = (canvas_w as usize) * (canvas_h as usize);
let mut out = vec![0u8; total_px * 4];
for chunk in out.chunks_exact_mut(4) {
chunk[0] = bg_b;
chunk[1] = bg_g;
chunk[2] = bg_r;
chunk[3] = 255;
}
let offset_x = ((canvas_w - img.width) / 2) as usize;
let offset_y = ((canvas_h - img.height) / 2) as usize;
let src_stride = (img.width as usize) * 4;
let dst_stride = (canvas_w as usize) * 4;
for row in 0..img.height as usize {
let src_start = row * src_stride;
let dst_start = (offset_y + row) * dst_stride + offset_x * 4;
out[dst_start..dst_start + src_stride]
.copy_from_slice(&img.bgra[src_start..src_start + src_stride]);
}
Ok(RawBgraImage {
width: canvas_w,
height: canvas_h,
bgra: out,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn small_single_line_image_is_upscaled_and_padded() {
let raw = RawBgraImage::new(80, 24, vec![20u8; 80 * 24 * 4]).unwrap();
assert_eq!(recommended_upscale_factor(80, 24), 3);
let prepared = prepare_primary_for_ocr(&raw).unwrap();
assert_eq!(prepared.width, 80 * 3 + BORDER_PADDING * 2);
assert!(prepared.height >= MIN_CANVAS_HEIGHT);
}
#[test]
fn dark_mode_image_inverts_to_white_background_in_high_contrast_pass() {
let mut bgra = vec![24u8; 60 * 30 * 4];
// 在深灰背景 (24,24,24) 中间画几颗浅灰文字像素 (210,210,210)
for y in 10..20 {
for x in 20..40 {
let idx = (y * 60 + x) * 4;
bgra[idx] = 210;
bgra[idx + 1] = 210;
bgra[idx + 2] = 210;
bgra[idx + 3] = 255;
}
}
let raw = RawBgraImage::new(60, 30, bgra).unwrap();
let hc = prepare_high_contrast_for_ocr(&raw).unwrap();
// 外圈填充应为纯白背景 (255, 255, 255)
assert_eq!(&hc.bgra[0..3], &[255, 255, 255]);
// 中心文字像素应被反相并拉伸为深黑色 (0, 0, 0)
let center_idx = ((hc.height as usize / 2) * hc.width as usize + (hc.width as usize / 2)) * 4;
assert_eq!(&hc.bgra[center_idx..center_idx + 3], &[0, 0, 0]);
}
}
+661
View File
@@ -0,0 +1,661 @@
//! Windows 纯 Rust 原生全屏自由框选截图(零 Webview 依赖、Per-Monitor DPI v2 1:1 物理像素对齐)。
//!
//! 解决旧版 Windows 截图的三大痛点:
//! 1. **彻底消除 DPI 缩放偏移**:进入截图前将当前线程切换至 `DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2`,
//! 无论系统缩放为 100% / 125% / 150% / 200% 或多显示器异构缩放,GDI 截屏与鼠标框选坐标严格 1:1 对齐;
//! 2. **支持多显示器虚拟桌面**:使用 `SM_XVIRTUALSCREEN..SM_CXVIRTUALSCREEN` 覆盖全部显示器;
//! 3. **毫秒级顺滑框选**:预生成 45% 暗化背景缓冲,拖拽时直接内存合成高亮选区 + 2px 天蓝色边框,通过单次
//! `SetDIBitsToDevice` 零闪烁输出,无需启动笨重的 Webview 窗口或落盘临时文件。
#![cfg(target_os = "windows")]
#![allow(non_snake_case)]
use std::cell::RefCell;
use std::ffi::c_void;
use std::ptr;
use crate::preprocess::RawBgraImage;
pub enum WindowsCaptureResult {
Captured(RawBgraImage),
Cancelled,
}
type HWND = *mut c_void;
type HDC = *mut c_void;
type HBITMAP = *mut c_void;
type HGDIOBJ = *mut c_void;
type HICON = *mut c_void;
type HCURSOR = *mut c_void;
type HBRUSH = *mut c_void;
type HINSTANCE = *mut c_void;
type WPARAM = usize;
type LPARAM = isize;
type LRESULT = isize;
type BOOL = i32;
const SM_CXSCREEN: i32 = 0;
const SM_CYSCREEN: i32 = 1;
const SM_XVIRTUALSCREEN: i32 = 76;
const SM_YVIRTUALSCREEN: i32 = 77;
const SM_CXVIRTUALSCREEN: i32 = 78;
const SM_CYVIRTUALSCREEN: i32 = 79;
const SRCCOPY: u32 = 0x00CC0020;
const CAPTUREBLT: u32 = 0x40000000;
const BI_RGB: u32 = 0;
const DIB_RGB_COLORS: u32 = 0;
const WS_POPUP: u32 = 0x80000000;
const WS_VISIBLE: u32 = 0x10000000;
const WS_EX_TOPMOST: u32 = 0x00000008;
const WS_EX_TOOLWINDOW: u32 = 0x00000080;
const WM_DESTROY: u32 = 0x0002;
const WM_PAINT: u32 = 0x000F;
const WM_ERASEBKGND: u32 = 0x0014;
const WM_SETCURSOR: u32 = 0x0020;
const WM_KEYDOWN: u32 = 0x0100;
const WM_MOUSEMOVE: u32 = 0x0200;
const WM_LBUTTONDOWN: u32 = 0x0201;
const WM_LBUTTONUP: u32 = 0x0202;
const WM_RBUTTONDOWN: u32 = 0x0204;
const VK_ESCAPE: usize = 0x1B;
const IDC_CROSS: *const u16 = 32515 as *const u16;
const SW_SHOW: i32 = 5;
const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2: isize = -4;
#[repr(C)]
#[derive(Copy, Clone)]
struct POINT {
x: i32,
y: i32,
}
#[repr(C)]
#[derive(Copy, Clone)]
struct MSG {
hwnd: HWND,
message: u32,
wParam: WPARAM,
lParam: LPARAM,
time: u32,
pt: POINT,
}
#[repr(C)]
struct PAINTSTRUCT {
hdc: HDC,
fErase: BOOL,
rcPaint: [i32; 4],
fRestore: BOOL,
fIncUpdate: BOOL,
rgbReserved: [u8; 32],
}
#[repr(C)]
struct WNDCLASSEXW {
cbSize: u32,
style: u32,
lpfnWndProc: unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT,
cbClsExtra: i32,
cbWndExtra: i32,
hInstance: HINSTANCE,
hIcon: HICON,
hCursor: HCURSOR,
hbrBackground: HBRUSH,
lpszMenuName: *const u16,
lpszClassName: *const u16,
hIconSm: HICON,
}
#[repr(C)]
struct BITMAPINFOHEADER {
biSize: u32,
biWidth: i32,
biHeight: i32,
biPlanes: u16,
biBitCount: u16,
biCompression: u32,
biSizeImage: u32,
biXPelsPerMeter: i32,
biYPelsPerMeter: i32,
biClrUsed: u32,
biClrImportant: u32,
}
#[repr(C)]
struct BITMAPINFO {
bmiHeader: BITMAPINFOHEADER,
bmiColors: [u32; 1],
}
extern "system" {
fn SetThreadDpiAwarenessContext(dpiContext: isize) -> isize;
fn GetSystemMetrics(nIndex: i32) -> i32;
fn GetDC(hWnd: HWND) -> HDC;
fn ReleaseDC(hWnd: HWND, hDC: HDC) -> i32;
fn CreateCompatibleDC(hdc: HDC) -> HDC;
fn DeleteDC(hdc: HDC) -> BOOL;
fn CreateCompatibleBitmap(hdc: HDC, cx: i32, cy: i32) -> HBITMAP;
fn SelectObject(hdc: HDC, h: HGDIOBJ) -> HGDIOBJ;
fn DeleteObject(ho: HGDIOBJ) -> BOOL;
fn BitBlt(
hdc: HDC,
x: i32,
y: i32,
cx: i32,
cy: i32,
hdcSrc: HDC,
x1: i32,
y1: i32,
rop: u32,
) -> BOOL;
fn GetDIBits(
hdc: HDC,
hbm: HBITMAP,
start: u32,
cLines: u32,
lpvBits: *mut c_void,
lpbmi: *mut BITMAPINFO,
usage: u32,
) -> i32;
fn SetDIBitsToDevice(
hdc: HDC,
xDest: i32,
yDest: i32,
w: u32,
h: u32,
xSrc: i32,
ySrc: i32,
StartScan: u32,
cLines: u32,
lpvBits: *const c_void,
lpbmi: *const BITMAPINFO,
ColorUse: u32,
) -> i32;
fn GetModuleHandleW(lpModuleName: *const u16) -> HINSTANCE;
fn RegisterClassExW(lpWndClass: *const WNDCLASSEXW) -> u16;
fn CreateWindowExW(
dwExStyle: u32,
lpClassName: *const u16,
lpWindowName: *const u16,
dwStyle: u32,
X: i32,
Y: i32,
nWidth: i32,
nHeight: i32,
hWndParent: HWND,
hMenu: *mut c_void,
hInstance: HINSTANCE,
lpParam: *mut c_void,
) -> HWND;
fn DestroyWindow(hWnd: HWND) -> BOOL;
fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
fn SetForegroundWindow(hWnd: HWND) -> BOOL;
fn SetFocus(hWnd: HWND) -> HWND;
fn SetCapture(hWnd: HWND) -> HWND;
fn ReleaseCapture() -> BOOL;
fn InvalidateRect(hWnd: HWND, lpRect: *const i32, bErase: BOOL) -> BOOL;
fn BeginPaint(hWnd: HWND, lpPaint: *mut PAINTSTRUCT) -> HDC;
fn EndPaint(hWnd: HWND, lpPaint: *const PAINTSTRUCT) -> BOOL;
fn LoadCursorW(hInstance: HINSTANCE, lpCursorName: *const u16) -> HCURSOR;
fn SetCursor(hCursor: HCURSOR) -> HCURSOR;
fn DefWindowProcW(hWnd: HWND, Msg: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT;
fn GetMessageW(lpMsg: *mut MSG, hWnd: HWND, wMsgFilterMin: u32, wMsgFilterMax: u32) -> BOOL;
fn TranslateMessage(lpMsg: *const MSG) -> BOOL;
fn DispatchMessageW(lpMsg: *const MSG) -> LRESULT;
fn PostQuitMessage(nExitCode: i32);
}
struct OverlayState {
width: u32,
height: u32,
orig_bgra: Vec<u8>,
dimmed_bgra: Vec<u8>,
frame_bgra: Vec<u8>,
dragging: bool,
start_x: i32,
start_y: i32,
curr_x: i32,
curr_y: i32,
selected_rect: Option<(u32, u32, u32, u32)>,
cancelled: bool,
}
thread_local! {
static OVERLAY_STATE: RefCell<Option<OverlayState>> = const { RefCell::new(None) };
}
fn wide_null(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
#[inline]
fn loword_i16(lparam: LPARAM) -> i32 {
(lparam as u32 & 0xFFFF) as i16 as i32
}
#[inline]
fn hiword_i16(lparam: LPARAM) -> i32 {
((lparam as u32 >> 16) & 0xFFFF) as i16 as i32
}
fn normalized_rect(
x1: i32,
y1: i32,
x2: i32,
y2: i32,
max_w: u32,
max_h: u32,
) -> (u32, u32, u32, u32) {
let left = x1.min(x2).clamp(0, max_w as i32) as u32;
let right = x1.max(x2).clamp(0, max_w as i32) as u32;
let top = y1.min(y2).clamp(0, max_h as i32) as u32;
let bottom = y1.max(y2).clamp(0, max_h as i32) as u32;
(left, top, right.saturating_sub(left), bottom.saturating_sub(top))
}
/// 执行一次原生全屏框选截图,返回内存中的 `RawBgraImage`。
pub fn capture_interactive_region() -> Result<WindowsCaptureResult, String> {
unsafe {
// 切换当前线程到 Per-Monitor DPI v2,确保截屏与鼠标坐标均为 1:1 物理像素
let prev_dpi = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
let result = run_capture_session();
if prev_dpi != 0 {
SetThreadDpiAwarenessContext(prev_dpi);
}
result
}
}
unsafe fn run_capture_session() -> Result<WindowsCaptureResult, String> {
let mut vx = GetSystemMetrics(SM_XVIRTUALSCREEN);
let mut vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
let mut vw = GetSystemMetrics(SM_CXVIRTUALSCREEN);
let mut vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
if vw <= 0 || vh <= 0 {
vx = 0;
vy = 0;
vw = GetSystemMetrics(SM_CXSCREEN);
vh = GetSystemMetrics(SM_CYSCREEN);
}
if vw <= 0 || vh <= 0 {
return Err("获取 Windows 屏幕分辨率失败".to_string());
}
let orig_bgra = capture_virtual_screen_bgra(vx, vy, vw, vh)?;
let mut dimmed_bgra = orig_bgra.clone();
for px in dimmed_bgra.chunks_exact_mut(4) {
px[0] = ((px[0] as u16 * 115) >> 8) as u8;
px[1] = ((px[1] as u16 * 115) >> 8) as u8;
px[2] = ((px[2] as u16 * 115) >> 8) as u8;
px[3] = 255;
}
let frame_bgra = dimmed_bgra.clone();
OVERLAY_STATE.with(|cell| {
*cell.borrow_mut() = Some(OverlayState {
width: vw as u32,
height: vh as u32,
orig_bgra,
dimmed_bgra,
frame_bgra,
dragging: false,
start_x: 0,
start_y: 0,
curr_x: 0,
curr_y: 0,
selected_rect: None,
cancelled: false,
});
});
let hinstance = GetModuleHandleW(ptr::null());
let class_name = wide_null("TTreeScreenCaptureOverlayClass");
let cursor = LoadCursorW(ptr::null_mut(), IDC_CROSS);
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: 0,
lpfnWndProc: overlay_wnd_proc,
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: hinstance,
hIcon: ptr::null_mut(),
hCursor: cursor,
hbrBackground: ptr::null_mut(),
lpszMenuName: ptr::null(),
lpszClassName: class_name.as_ptr(),
hIconSm: ptr::null_mut(),
};
let _ = RegisterClassExW(&wc);
let win_title = wide_null("TTREE Screenshot Selection");
let hwnd = CreateWindowExW(
WS_EX_TOPMOST | WS_EX_TOOLWINDOW,
class_name.as_ptr(),
win_title.as_ptr(),
WS_POPUP | WS_VISIBLE,
vx,
vy,
vw,
vh,
ptr::null_mut(),
ptr::null_mut(),
hinstance,
ptr::null_mut(),
);
if hwnd.is_null() {
OVERLAY_STATE.with(|cell| *cell.borrow_mut() = None);
return Err("创建全屏截图框选窗口失败".to_string());
}
ShowWindow(hwnd, SW_SHOW);
SetForegroundWindow(hwnd);
SetFocus(hwnd);
let mut msg: MSG = std::mem::zeroed();
while GetMessageW(&mut msg, ptr::null_mut(), 0, 0) > 0 {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
let state = OVERLAY_STATE
.with(|cell| cell.borrow_mut().take())
.ok_or_else(|| "截图框选状态丢失".to_string())?;
if state.cancelled {
return Ok(WindowsCaptureResult::Cancelled);
}
let Some((rx, ry, rw, rh)) = state.selected_rect else {
return Ok(WindowsCaptureResult::Cancelled);
};
if rw < 4 || rh < 4 {
return Ok(WindowsCaptureResult::Cancelled);
}
let full = RawBgraImage::new(state.width, state.height, state.orig_bgra)?;
let cropped = full.crop(rx, ry, rw, rh)?;
Ok(WindowsCaptureResult::Captured(cropped))
}
unsafe fn capture_virtual_screen_bgra(
vx: i32,
vy: i32,
vw: i32,
vh: i32,
) -> Result<Vec<u8>, String> {
let hdc_screen = GetDC(ptr::null_mut());
if hdc_screen.is_null() {
return Err("获取屏幕 DC 失败".to_string());
}
let hdc_mem = CreateCompatibleDC(hdc_screen);
if hdc_mem.is_null() {
ReleaseDC(ptr::null_mut(), hdc_screen);
return Err("创建内存 DC 失败".to_string());
}
let hbmp = CreateCompatibleBitmap(hdc_screen, vw, vh);
if hbmp.is_null() {
DeleteDC(hdc_mem);
ReleaseDC(ptr::null_mut(), hdc_screen);
return Err("创建屏幕位图失败".to_string());
}
let old_obj = SelectObject(hdc_mem, hbmp);
let ok = BitBlt(
hdc_mem,
0,
0,
vw,
vh,
hdc_screen,
vx,
vy,
SRCCOPY | CAPTUREBLT,
);
let mut bmi: BITMAPINFO = std::mem::zeroed();
bmi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
bmi.bmiHeader.biWidth = vw;
// 负值 biHeight 表示 Top-Down 位图,免去手动上下翻转
bmi.bmiHeader.biHeight = -vh;
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32;
bmi.bmiHeader.biCompression = BI_RGB;
let mut bgra = vec![0u8; (vw as usize) * (vh as usize) * 4];
let lines = if ok != 0 {
GetDIBits(
hdc_screen,
hbmp,
0,
vh as u32,
bgra.as_mut_ptr() as *mut c_void,
&mut bmi,
DIB_RGB_COLORS,
)
} else {
0
};
SelectObject(hdc_mem, old_obj);
DeleteObject(hbmp);
DeleteDC(hdc_mem);
ReleaseDC(ptr::null_mut(), hdc_screen);
if ok == 0 || lines == 0 {
return Err("抓取屏幕像素失败".to_string());
}
Ok(bgra)
}
unsafe extern "system" fn overlay_wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_ERASEBKGND => 1,
WM_SETCURSOR => {
SetCursor(LoadCursorW(ptr::null_mut(), IDC_CROSS));
1
}
WM_KEYDOWN => {
if wparam == VK_ESCAPE {
OVERLAY_STATE.with(|cell| {
if let Some(st) = cell.borrow_mut().as_mut() {
st.cancelled = true;
}
});
ReleaseCapture();
DestroyWindow(hwnd);
return 0;
}
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_RBUTTONDOWN => {
OVERLAY_STATE.with(|cell| {
if let Some(st) = cell.borrow_mut().as_mut() {
st.cancelled = true;
}
});
ReleaseCapture();
DestroyWindow(hwnd);
0
}
WM_LBUTTONDOWN => {
let x = loword_i16(lparam);
let y = hiword_i16(lparam);
OVERLAY_STATE.with(|cell| {
if let Some(st) = cell.borrow_mut().as_mut() {
st.dragging = true;
st.start_x = x;
st.start_y = y;
st.curr_x = x;
st.curr_y = y;
}
});
SetCapture(hwnd);
InvalidateRect(hwnd, ptr::null(), 0);
0
}
WM_MOUSEMOVE => {
let x = loword_i16(lparam);
let y = hiword_i16(lparam);
let mut should_repaint = false;
OVERLAY_STATE.with(|cell| {
if let Some(st) = cell.borrow_mut().as_mut() {
if st.dragging {
st.curr_x = x;
st.curr_y = y;
should_repaint = true;
}
}
});
if should_repaint {
InvalidateRect(hwnd, ptr::null(), 0);
}
0
}
WM_LBUTTONUP => {
let x = loword_i16(lparam);
let y = hiword_i16(lparam);
ReleaseCapture();
let mut should_close = false;
OVERLAY_STATE.with(|cell| {
if let Some(st) = cell.borrow_mut().as_mut() {
if st.dragging {
st.dragging = false;
st.curr_x = x;
st.curr_y = y;
let rect = normalized_rect(
st.start_x, st.start_y, st.curr_x, st.curr_y, st.width, st.height,
);
if rect.2 >= 6 && rect.3 >= 6 {
st.selected_rect = Some(rect);
should_close = true;
}
}
}
});
if should_close {
DestroyWindow(hwnd);
} else {
InvalidateRect(hwnd, ptr::null(), 0);
}
0
}
WM_PAINT => {
let mut ps: PAINTSTRUCT = std::mem::zeroed();
let hdc = BeginPaint(hwnd, &mut ps);
OVERLAY_STATE.with(|cell| {
if let Some(st) = cell.borrow_mut().as_mut() {
render_overlay_frame(hdc, st);
}
});
EndPaint(hwnd, &ps);
0
}
WM_DESTROY => {
PostQuitMessage(0);
0
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
unsafe fn render_overlay_frame(hdc: HDC, st: &mut OverlayState) {
st.frame_bgra.copy_from_slice(&st.dimmed_bgra);
if st.dragging {
let (rx, ry, rw, rh) =
normalized_rect(st.start_x, st.start_y, st.curr_x, st.curr_y, st.width, st.height);
if rw > 0 && rh > 0 {
let stride = (st.width as usize) * 4;
let row_bytes = (rw as usize) * 4;
for row in ry..(ry + rh) {
let offset = (row as usize) * stride + (rx as usize) * 4;
st.frame_bgra[offset..offset + row_bytes]
.copy_from_slice(&st.orig_bgra[offset..offset + row_bytes]);
}
// 绘制 2px 天蓝色 (#38BDF8 -> B=0xF8, G=0xBD, R=0x38) 选区外框
draw_rect_border_bgra(
&mut st.frame_bgra,
st.width,
st.height,
rx,
ry,
rw,
rh,
[0xF8, 0xBD, 0x38, 0xFF],
);
}
}
let mut bmi: BITMAPINFO = std::mem::zeroed();
bmi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
bmi.bmiHeader.biWidth = st.width as i32;
bmi.bmiHeader.biHeight = -(st.height as i32);
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32;
bmi.bmiHeader.biCompression = BI_RGB;
SetDIBitsToDevice(
hdc,
0,
0,
st.width,
st.height,
0,
0,
0,
st.height,
st.frame_bgra.as_ptr() as *const c_void,
&bmi,
DIB_RGB_COLORS,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_rect_border_bgra(
buf: &mut [u8],
img_w: u32,
img_h: u32,
rx: u32,
ry: u32,
rw: u32,
rh: u32,
color: [u8; 4],
) {
let x_end = (rx + rw).min(img_w);
let y_end = (ry + rh).min(img_h);
let mut set_px = |x: u32, y: u32| {
if x < img_w && y < img_h {
let idx = ((y as usize) * (img_w as usize) + (x as usize)) * 4;
buf[idx..idx + 4].copy_from_slice(&color);
}
};
for t in 0..2u32 {
let top_y = ry.saturating_add(t);
let bot_y = y_end.saturating_sub(1 + t);
for x in rx..x_end {
set_px(x, top_y);
set_px(x, bot_y);
}
let left_x = rx.saturating_add(t);
let right_x = x_end.saturating_sub(1 + t);
for y in ry..y_end {
set_px(left_x, y);
set_px(right_x, y);
}
}
}
+339
View File
@@ -0,0 +1,339 @@
//! Windows 增强型多语言、双通道自适应 `Windows.Media.Ocr` 引擎。
//!
//! 针对旧版 Windows OCR 识别效果差的 5 项根本修复:
//! 1. **内存直构 `SoftwareBitmap`**:交互式截图直接由 `CryptographicBuffer::CreateFromByteArray` +
//! `SoftwareBitmap::CreateCopyWithAlphaFromBuffer(Bgra8)` 构建,彻底绕过易出错的临时文件与 `BitmapDecoder`;
//! 2. **双语优先的多引擎调度**:Windows 的 `zh-Hans`(简体中文)OCR 模型天然具备「中英双语混合识别」能力,
//! 而纯 `en-US` 引擎遇到汉字会输出乱码符号。因此优先探测并启用 `zh-Hans-CN` / `zh-Hans` / `zh-Hant`,
//! 当首选引擎得分过低时自动在已安装的其余语言引擎间回退择优;
//! 3. **双通道图像预处理重试(Pass-1 自然放大延展 + Pass-2 深色反相高对比度拉伸)**:
//! 解决小字号单行截图与深色模式(白字黑底/灰字暗底)漏识问题;
//! 4. **分词级 CJK 伪空格消除**:遍历 `OcrLine::Words()`,通过 `postprocess::join_ocr_words` 消除汉字间多余空格;
//! 5. **独立 MTA 线程执行 WinRT 异步等待**:在 `RO_INIT_MULTITHREADED` 线程内调用 `.get()`,杜绝 Win32 UI 线程 STA 死锁。
#![cfg(target_os = "windows")]
use windows::core::HSTRING;
use windows::Globalization::Language;
use windows::Graphics::Imaging::{
BitmapAlphaMode, BitmapDecoder, BitmapPixelFormat, SoftwareBitmap,
};
use windows::Media::Ocr::{OcrEngine, OcrResult};
use windows::Security::Cryptography::CryptographicBuffer;
use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream};
use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED};
use crate::postprocess::{clean_cjk_ocr_spaces, join_ocr_words, score_ocr_text};
use crate::preprocess::{
prepare_high_contrast_for_ocr, prepare_primary_for_ocr, RawBgraImage,
};
use crate::OcrOutcome;
const MISSING_LANG_PACK_HINT: &str = "未检测到可用的 Windows OCR 识别引擎。\n\
请在「系统设置 → 时间和语言 → 语言和区域」中为「中文(简体)」或「English」安装「光学字符识别 (OCR)」功能,\n\
或以管理员运行 PowerShell 执行:\n\
Add-WindowsCapability -Online -Name \"Language.OCR~~~zh-CN~0.0.1.0\"";
/// 对内存中的原始选区位图执行双通道增强 + 多语言择优 OCR。
pub fn recognize_raw_bgra(raw: RawBgraImage) -> Result<OcrOutcome, String> {
std::thread::spawn(move || {
unsafe {
let _ = RoInitialize(RO_INIT_MULTITHREADED);
}
recognize_raw_bgra_on_mta(&raw)
})
.join()
.map_err(|_| "Windows OCR 工作线程异常退出".to_string())?
}
/// 对磁盘上的 PNG / BMP / JPEG 图片文件执行 OCR。
pub fn recognize_file(path: &str) -> Result<OcrOutcome, String> {
let bytes = std::fs::read(path).map_err(|e| format!("读取截图文件失败: {e}"))?;
std::thread::spawn(move || {
unsafe {
let _ = RoInitialize(RO_INIT_MULTITHREADED);
}
recognize_encoded_bytes_on_mta(&bytes)
})
.join()
.map_err(|_| "Windows OCR 工作线程异常退出".to_string())?
}
fn recognize_raw_bgra_on_mta(raw: &RawBgraImage) -> Result<OcrOutcome, String> {
let engines = build_candidate_engines();
if engines.is_empty() {
return Ok(OcrOutcome::PermissionDenied(
MISSING_LANG_PACK_HINT.to_string(),
));
}
// Pass 1:双线性放大 + 外圈自适应背景色填充边距
let pass1_img = prepare_primary_for_ocr(raw)?;
let pass1_bmp = software_bitmap_from_bgra(&pass1_img)?;
let mut best_text = String::new();
let mut best_score: i64 = 0;
if let Ok(text) = run_engine_and_format(&engines[0], &pass1_bmp) {
let sc = score_ocr_text(&text);
if sc > best_score {
best_score = sc;
best_text = text;
}
}
// 若首选引擎在 Pass 1 已获得充足有效文本(例如 >= 6 分),直接返回以保证极速响应
if best_score >= 6 {
return Ok(OcrOutcome::Text(best_text));
}
// Pass 2:深色模式自动反相 + 分位数对比度拉伸 + S 曲线锐化
if let Ok(pass2_img) = prepare_high_contrast_for_ocr(raw) {
if let Ok(pass2_bmp) = software_bitmap_from_bgra(&pass2_img) {
if let Ok(text) = run_engine_and_format(&engines[0], &pass2_bmp) {
let sc = score_ocr_text(&text);
if sc > best_score {
best_score = sc;
best_text = text;
}
}
// 若仍为空或得分极低,尝试备用语言引擎(例如用户框选了日文/韩文或纯英文)
if best_score < 6 {
for fallback_engine in engines.iter().skip(1).take(2) {
for bmp in [&pass1_bmp, &pass2_bmp] {
if let Ok(text) = run_engine_and_format(fallback_engine, bmp) {
let sc = score_ocr_text(&text);
if sc > best_score {
best_score = sc;
best_text = text;
}
}
}
if best_score >= 6 {
break;
}
}
}
}
}
let trimmed = best_text.trim();
if trimmed.is_empty() {
Ok(OcrOutcome::EmptyText)
} else {
Ok(OcrOutcome::Text(trimmed.to_string()))
}
}
fn recognize_encoded_bytes_on_mta(bytes: &[u8]) -> Result<OcrOutcome, String> {
let engines = build_candidate_engines();
if engines.is_empty() {
return Ok(OcrOutcome::PermissionDenied(
MISSING_LANG_PACK_HINT.to_string(),
));
}
let stream =
InMemoryRandomAccessStream::new().map_err(|e| format!("创建内存流失败: {e}"))?;
let output = stream
.GetOutputStreamAt(0)
.map_err(|e| format!("获取输出流失败: {e}"))?;
let writer =
DataWriter::CreateDataWriter(&output).map_err(|e| format!("创建 DataWriter 失败: {e}"))?;
writer
.WriteBytes(bytes)
.map_err(|e| format!("写入图片字节失败: {e}"))?;
writer
.StoreAsync()
.map_err(|e| format!("存储流数据失败: {e}"))?
.get()
.map_err(|e| format!("等待流存储完成失败: {e}"))?;
stream
.Seek(0)
.map_err(|e| format!("重置流偏移失败: {e}"))?;
let decoder = BitmapDecoder::CreateAsync(&stream)
.map_err(|e| format!("创建图片解码器失败: {e}"))?
.get()
.map_err(|e| format!("解码图片失败: {e}"))?;
let decoded_bmp = decoder
.GetSoftwareBitmapAsync()
.map_err(|e| format!("读取位图失败: {e}"))?
.get()
.map_err(|e| format!("等待位图就绪失败: {e}"))?;
let bgra_bmp = SoftwareBitmap::ConvertWithAlpha(
&decoded_bmp,
BitmapPixelFormat::Bgra8,
BitmapAlphaMode::Ignore,
)
.unwrap_or(decoded_bmp);
let mut best_text = String::new();
let mut best_score: i64 = 0;
for engine in engines.iter().take(3) {
if let Ok(text) = run_engine_and_format(engine, &bgra_bmp) {
let sc = score_ocr_text(&text);
if sc > best_score {
best_score = sc;
best_text = text;
}
if best_score >= 6 {
break;
}
}
}
let trimmed = best_text.trim();
if trimmed.is_empty() {
Ok(OcrOutcome::EmptyText)
} else {
Ok(OcrOutcome::Text(trimmed.to_string()))
}
}
fn software_bitmap_from_bgra(img: &RawBgraImage) -> Result<SoftwareBitmap, String> {
let ibuffer = CryptographicBuffer::CreateFromByteArray(&img.bgra)
.map_err(|e| format!("创建 WinRT 像素缓冲区失败: {e}"))?;
SoftwareBitmap::CreateCopyWithAlphaFromBuffer(
&ibuffer,
BitmapPixelFormat::Bgra8,
img.width as i32,
img.height as i32,
BitmapAlphaMode::Ignore,
)
.map_err(|e| format!("构建 SoftwareBitmap 失败: {e}"))
}
fn try_push_tag(engines: &mut Vec<OcrEngine>, seen_tags: &mut Vec<String>, tag_str: &str) {
let lower = tag_str.to_ascii_lowercase();
if seen_tags.contains(&lower) {
return;
}
if let Ok(lang) = Language::CreateLanguage(&HSTRING::from(tag_str)) {
if let Ok(eng) = OcrEngine::TryCreateFromLanguage(&lang) {
if let Ok(eng_lang) = eng.RecognizerLanguage() {
if let Ok(resolved) = eng_lang.LanguageTag() {
let resolved_lower = resolved.to_string().to_ascii_lowercase();
if !seen_tags.contains(&resolved_lower) {
seen_tags.push(resolved_lower);
engines.push(eng);
return;
}
}
}
seen_tags.push(lower);
engines.push(eng);
}
}
}
/// 按优先级构建已安装的 OCR 语言引擎列表:
/// 1. 简体中文 (`zh-Hans-CN` / `zh-Hans` / `zh-CN`) —— 支持中英双语混排,优先级最高
/// 2. 繁体中文 (`zh-Hant-TW` / `zh-Hant-HK` / `zh-Hant`)
/// 3. 用户系统首选语言 (`TryCreateFromUserProfileLanguages`)
/// 4. 英文 / 日文 / 韩文 (`en-US` / `ja` / `ko`)
fn build_candidate_engines() -> Vec<OcrEngine> {
let mut engines = Vec::new();
let mut seen_tags = Vec::<String>::new();
// 1. 显式尝试中英双语能力最强的中文识别包
for preferred in [
"zh-Hans-CN",
"zh-Hans",
"zh-CN",
"zh-Hant-TW",
"zh-Hant-HK",
"zh-Hant",
] {
try_push_tag(&mut engines, &mut seen_tags, preferred);
}
// 2. 枚举系统已安装的识别语言,优先把任何 zh-* 排在前面
if let Ok(installed) = OcrEngine::AvailableRecognizerLanguages() {
let count = installed.Size().unwrap_or(0);
for i in 0..count {
if let Ok(lang) = installed.GetAt(i) {
if let Ok(tag) = lang.LanguageTag() {
let s = tag.to_string();
if s.to_ascii_lowercase().starts_with("zh") {
try_push_tag(&mut engines, &mut seen_tags, &s);
}
}
}
}
}
// 3. 用户系统 Profile 默认语言
if let Ok(user_eng) = OcrEngine::TryCreateFromUserProfileLanguages() {
let already_in = user_eng
.RecognizerLanguage()
.and_then(|l| l.LanguageTag())
.map(|t| seen_tags.contains(&t.to_string().to_ascii_lowercase()))
.unwrap_or(false);
if !already_in {
engines.push(user_eng);
}
}
// 4. 其余常用语言包兜底
for extra in ["en-US", "en-GB", "ja", "ko"] {
try_push_tag(&mut engines, &mut seen_tags, extra);
}
engines
}
fn run_engine_and_format(engine: &OcrEngine, bitmap: &SoftwareBitmap) -> Result<String, String> {
let result: OcrResult = engine
.RecognizeAsync(bitmap)
.map_err(|e| format!("启动 OCR 识别失败: {e}"))?
.get()
.map_err(|e| format!("等待 OCR 识别完成失败: {e}"))?;
let lines = result
.Lines()
.map_err(|e| format!("读取 OCR 文本行失败: {e}"))?;
let line_count = lines.Size().unwrap_or(0);
if line_count == 0 {
return Ok(String::new());
}
let mut out_lines = Vec::with_capacity(line_count as usize);
for i in 0..line_count {
let Ok(line) = lines.GetAt(i) else {
continue;
};
let mut word_pieces = Vec::new();
if let Ok(words) = line.Words() {
let word_count = words.Size().unwrap_or(0);
for w_idx in 0..word_count {
if let Ok(w) = words.GetAt(w_idx) {
if let Ok(t) = w.Text() {
let s = t.to_string();
if !s.trim().is_empty() {
word_pieces.push(s);
}
}
}
}
}
let formatted_line = if !word_pieces.is_empty() {
join_ocr_words(&word_pieces)
} else if let Ok(raw_line) = line.Text() {
clean_cjk_ocr_spaces(&raw_line.to_string())
} else {
String::new()
};
if !formatted_line.is_empty() {
out_lines.push(formatted_line);
}
}
Ok(out_lines.join("\n"))
}
+246 -2
View File
@@ -218,6 +218,240 @@ mod imp {
}
}
#[cfg(target_os = "windows")]
#[allow(non_snake_case)]
mod win_imp {
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use super::normalize_selected_text;
const COPY_TIMEOUT: Duration = Duration::from_millis(280);
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(12);
type HANDLE = *mut core::ffi::c_void;
type BOOL = i32;
const NULL_HANDLE: HANDLE = std::ptr::null_mut();
const INPUT_KEYBOARD: u32 = 1;
const KEYEVENTF_KEYUP: u32 = 2;
const VK_SHIFT: u16 = 0x10;
const VK_CONTROL: u16 = 0x11;
const VK_MENU: u16 = 0x12;
const VK_C: u16 = 0x43;
const VK_LWIN: u16 = 0x5B;
const VK_RWIN: u16 = 0x5C;
const VK_LSHIFT: u16 = 0xA0;
const VK_RSHIFT: u16 = 0xA1;
const VK_LMENU: u16 = 0xA4;
const VK_RMENU: u16 = 0xA5;
const CF_UNICODETEXT: u32 = 13;
const GMEM_MOVEABLE: u32 = 0x0002;
#[repr(C)]
#[derive(Copy, Clone)]
struct KEYBDINPUT {
wVk: u16,
wScan: u16,
dwFlags: u32,
time: u32,
dwExtraInfo: usize,
}
#[repr(C)]
#[derive(Copy, Clone)]
union INPUT_0 {
ki: KEYBDINPUT,
_padding: [u8; 24],
}
#[repr(C)]
#[derive(Copy, Clone)]
struct INPUT {
r#type: u32,
Anonymous: INPUT_0,
}
extern "system" {
fn OpenClipboard(hWndNewOwner: HANDLE) -> BOOL;
fn CloseClipboard() -> BOOL;
fn EmptyClipboard() -> BOOL;
fn GetClipboardData(uFormat: u32) -> HANDLE;
fn SetClipboardData(uFormat: u32, hMem: HANDLE) -> HANDLE;
fn EnumClipboardFormats(format: u32) -> u32;
fn GetClipboardSequenceNumber() -> u32;
fn GlobalAlloc(uFlags: u32, dwBytes: usize) -> HANDLE;
fn GlobalLock(hMem: HANDLE) -> *mut core::ffi::c_void;
fn GlobalUnlock(hMem: HANDLE) -> BOOL;
fn GlobalSize(hMem: HANDLE) -> usize;
fn GlobalFree(hMem: HANDLE) -> HANDLE;
fn SendInput(cInputs: u32, pInputs: *const INPUT, cbSize: i32) -> u32;
}
struct ClipboardSnapshot {
sequence: u32,
unicode_bytes: Option<Vec<u8>>,
}
pub fn capture_selected_text() -> Result<Option<String>> {
unsafe {
let snapshot = snapshot_clipboard();
post_copy_keypress()?;
let Some(_new_seq) = wait_for_clipboard_change(snapshot.sequence) else {
return Ok(None);
};
let copied_text = read_clipboard_text().and_then(normalize_selected_text);
// 若剪贴板仅发生本次 Ctrl+C 一次更新,无感还原用户原剪贴板文本
if OpenClipboard(NULL_HANDLE) != 0 {
let current_seq = GetClipboardSequenceNumber();
if current_seq == snapshot.sequence.wrapping_add(1) {
EmptyClipboard();
if let Some(ref data) = snapshot.unicode_bytes {
let hmem = GlobalAlloc(GMEM_MOVEABLE, data.len());
if !hmem.is_null() {
let ptr = GlobalLock(hmem);
if !ptr.is_null() {
std::ptr::copy_nonoverlapping(
data.as_ptr(),
ptr as *mut u8,
data.len(),
);
GlobalUnlock(hmem);
SetClipboardData(CF_UNICODETEXT, hmem);
} else {
GlobalFree(hmem);
}
}
}
}
CloseClipboard();
}
Ok(copied_text)
}
}
unsafe fn snapshot_clipboard() -> ClipboardSnapshot {
let sequence = GetClipboardSequenceNumber();
let mut unicode_bytes = None;
if OpenClipboard(NULL_HANDLE) != 0 {
let mut format = 0u32;
loop {
format = EnumClipboardFormats(format);
if format == 0 {
break;
}
if format == CF_UNICODETEXT {
let handle = GetClipboardData(CF_UNICODETEXT);
if !handle.is_null() {
let size = GlobalSize(handle);
let ptr = GlobalLock(handle);
if !ptr.is_null() && size > 0 {
unicode_bytes =
Some(std::slice::from_raw_parts(ptr as *const u8, size).to_vec());
GlobalUnlock(handle);
}
}
break;
}
}
CloseClipboard();
}
ClipboardSnapshot {
sequence,
unicode_bytes,
}
}
unsafe fn read_clipboard_text() -> Option<String> {
if OpenClipboard(NULL_HANDLE) == 0 {
return None;
}
let mut text = None;
let handle = GetClipboardData(CF_UNICODETEXT);
if !handle.is_null() {
let ptr = GlobalLock(handle);
if !ptr.is_null() {
let mut len = 0usize;
let base = ptr as *const u16;
let max_u16 = GlobalSize(handle) / 2;
while len < max_u16 && *base.add(len) != 0 {
len += 1;
}
let slice = std::slice::from_raw_parts(base, len);
text = String::from_utf16(slice).ok();
GlobalUnlock(handle);
}
}
CloseClipboard();
text
}
fn key_input(vk: u16, flags: u32) -> INPUT {
INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: vk,
wScan: 0,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
}
}
fn post_copy_keypress() -> Result<()> {
unsafe {
// 先释放触发全局热键时用户可能仍按住的 Alt / Shift / Win 修饰键,防止合成出 Ctrl+Alt+C
let inputs = [
key_input(VK_MENU, KEYEVENTF_KEYUP),
key_input(VK_LMENU, KEYEVENTF_KEYUP),
key_input(VK_RMENU, KEYEVENTF_KEYUP),
key_input(VK_SHIFT, KEYEVENTF_KEYUP),
key_input(VK_LSHIFT, KEYEVENTF_KEYUP),
key_input(VK_RSHIFT, KEYEVENTF_KEYUP),
key_input(VK_LWIN, KEYEVENTF_KEYUP),
key_input(VK_RWIN, KEYEVENTF_KEYUP),
key_input(VK_CONTROL, 0),
key_input(VK_C, 0),
key_input(VK_C, KEYEVENTF_KEYUP),
key_input(VK_CONTROL, KEYEVENTF_KEYUP),
];
let sent = SendInput(
inputs.len() as u32,
inputs.as_ptr(),
std::mem::size_of::<INPUT>() as i32,
);
if sent != inputs.len() as u32 {
return Err(anyhow!("SendInput 发送 Ctrl+C 不完整"));
}
}
thread::sleep(Duration::from_millis(35));
Ok(())
}
fn wait_for_clipboard_change(before: u32) -> Option<u32> {
let deadline = Instant::now() + COPY_TIMEOUT;
while Instant::now() < deadline {
let current = unsafe { GetClipboardSequenceNumber() };
if current != before {
return Some(current);
}
thread::sleep(COPY_POLL_INTERVAL);
}
None
}
}
#[cfg(target_os = "macos")]
pub fn capture_selected_text() -> Result<Option<String>> {
imp::capture_selected_text()
@@ -228,12 +462,22 @@ pub fn is_accessibility_trusted() -> bool {
imp::is_accessibility_trusted()
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
pub fn capture_selected_text() -> Result<Option<String>> {
win_imp::capture_selected_text()
}
#[cfg(target_os = "windows")]
pub fn is_accessibility_trusted() -> bool {
true
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn capture_selected_text() -> Result<Option<String>> {
Ok(None)
}
#[cfg(not(target_os = "macos"))]
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn is_accessibility_trusted() -> bool {
false
}
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TTREE</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-2845
View File
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
{
"name": "ttree",
"version": "0.1.5",
"description": "轻量 macOS AI 翻译器",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"ci:install": "bash scripts/ci-install.sh",
"test:release": "bash scripts/tests/run-release-tests.sh",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vitest": "^4.1.9"
}
}
+2 -2
View File
@@ -30,8 +30,8 @@ mkdir -p "$MACOS_BIN_DIR" "$RESOURCES_DIR"
cp "$TARGET_DIR/release/ttree" "$MACOS_BIN_DIR/ttree"
chmod +x "$MACOS_BIN_DIR/ttree"
if [[ -f "$ROOT_DIR/src-tauri/icons/icon.icns" ]]; then
cp "$ROOT_DIR/src-tauri/icons/icon.icns" "$RESOURCES_DIR/icon.icns"
if [[ -f "$ROOT_DIR/assets/icons/icon.icns" ]]; then
cp "$ROOT_DIR/assets/icons/icon.icns" "$RESOURCES_DIR/icon.icns"
fi
cat > "$CONTENTS_DIR/Info.plist" <<EOF
-7
View File
@@ -1,7 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
-5601
View File
File diff suppressed because it is too large. Load diff
-74
View File
@@ -1,74 +0,0 @@
[package]
name = "ttree"
version = "0.1.5"
description = "轻量 macOS AI 翻译器"
authors = ["you"]
edition = "2021"
[lib]
name = "ttree_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
tauri-plugin-store = "2"
tauri-plugin-autostart = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
window-vibrancy = "0.7"
reqwest = { version = "0.12", features = ["json", "stream", "cookies"] }
futures-util = "0.3"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
urlencoding = "2"
url = "2"
tempfile = "3"
# macOS 原生 OCR(Apple Vision)
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
# macOS Keychain —— 存放 API key,避免明文落盘
security-framework = "3"
objc2 = "0.6"
objc2-core-foundation = "0.3"
objc2-core-image = { version = "0.3", features = ["CIContext"] }
objc2-core-ml = { version = "0.3", features = ["MLCPUComputeDevice", "MLComputeDeviceProtocol"] }
objc2-image-io = { version = "0.3", features = ["CGImageSource"] }
objc2-app-kit = { version = "0.3", features = [
"NSPasteboard",
"NSPasteboardItem",
] }
objc2-core-graphics = { version = "0.3", features = [
"CGBitmapContext",
"CGColorSpace",
"CGContext",
"CGEvent",
"CGEventTypes",
"CGRemoteOperation",
"CGImage",
] }
objc2-foundation = { version = "0.3", features = [
"NSString",
"NSArray",
"NSData",
"NSDictionary",
"NSError",
"NSObject",
"NSValue",
] }
objc2-vision = { version = "0.3", features = [
"objc2-core-graphics",
"objc2-core-ml",
"VNRequest",
"VNRecognizeTextRequest",
"VNObservation",
"VNRequestHandler",
"VNTypes",
] }
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
</dict>
</plist>
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-26
View File
@@ -1,26 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "主窗口能力",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-center",
"core:window:allow-start-dragging",
"core:event:default",
"core:app:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"store:allow-load",
"store:allow-get",
"store:allow-set",
"store:allow-save",
"updater:allow-check",
"updater:allow-download-and-install",
"process:allow-restart"
]
}
-216
View File
@@ -1,216 +0,0 @@
//! 暴露给前端的 Tauri command。
use tauri::{AppHandle, Runtime};
use crate::config::{self, AppearanceConfig, ChatAiConfig, OpenAiConfig, ShortcutConfig};
use crate::history;
use crate::ocr;
use crate::screenshot;
use crate::shortcut;
use crate::translate::{self, ChatMessage, Engine, TranslateRequest};
use crate::window;
/// 读取 OpenAI 配置。
#[tauri::command]
pub fn get_openai_config<R: Runtime>(app: AppHandle<R>) -> Result<OpenAiConfig, String> {
config::load_openai(&app)
}
/// 保存 OpenAI 配置。
#[tauri::command]
pub fn set_openai_config<R: Runtime>(
app: AppHandle<R>,
config: OpenAiConfig,
) -> Result<(), String> {
crate::config::save_openai(&app, &config)
}
/// 读取 AI 对话配置。
#[tauri::command]
pub fn get_chat_ai_config<R: Runtime>(app: AppHandle<R>) -> Result<ChatAiConfig, String> {
config::load_chat_ai(&app)
}
/// 保存 AI 对话配置。
#[tauri::command]
pub fn set_chat_ai_config<R: Runtime>(
app: AppHandle<R>,
config: ChatAiConfig,
) -> Result<(), String> {
crate::config::save_chat_ai(&app, &config)
}
/// 读取快捷键配置。
#[tauri::command]
pub fn get_shortcut_config<R: Runtime>(app: AppHandle<R>) -> ShortcutConfig {
config::load_shortcuts(&app)
}
/// 保存快捷键配置并立即重注册。
#[tauri::command]
pub fn set_shortcut_config<R: Runtime>(
app: AppHandle<R>,
config: ShortcutConfig,
) -> Result<(), String> {
shortcut::apply_shortcuts(&app, &config).map_err(|e| e.to_string())?;
crate::config::save_shortcuts(&app, &config)
}
/// 录制快捷键时临时取消注册全局快捷键,使按键能传到前端;结束时恢复。
#[tauri::command]
pub fn set_shortcut_recording<R: Runtime>(app: AppHandle<R>, active: bool) -> Result<(), String> {
shortcut::set_recording_active(&app, active)
}
/// 显示并聚焦主窗口(前端在 OCR 完成后调用)。
#[tauri::command]
pub fn show_main<R: Runtime>(app: AppHandle<R>) {
window::show_main(&app);
}
/// 普通 AI 对话命令:不使用翻译提示词。
#[tauri::command]
pub async fn chat<R: Runtime>(app: AppHandle<R>, messages: Vec<ChatMessage>) -> Result<(), String> {
if messages.is_empty() {
return Ok(());
}
translate::validate_messages(&messages)?;
let cfg = config::load_chat_ai_for_request(&app)?;
translate::openai::chat_stream(&app, &cfg, &messages).await
}
/// 读取固定窗口状态。
#[tauri::command]
pub fn get_pinned() -> bool {
window::is_pinned()
}
/// 读取外观配置。
#[tauri::command]
pub fn get_appearance_config<R: Runtime>(app: AppHandle<R>) -> AppearanceConfig {
config::load_appearance(&app)
}
/// 保存外观配置。
#[tauri::command]
pub fn set_appearance_config<R: Runtime>(
app: AppHandle<R>,
config: AppearanceConfig,
) -> Result<(), String> {
crate::config::save_appearance(&app, &config)
}
/// 设置固定窗口状态。固定时置顶且失焦不隐藏。
#[tauri::command]
pub fn set_pinned<R: Runtime>(app: AppHandle<R>, pinned: bool) -> Result<bool, String> {
let Some(w) = window::main_window(&app) else {
return Err("主窗口不存在".to_string());
};
window::set_pinned(&w, pinned).map_err(|e| format!("设置固定窗口失败: {e}"))
}
/// 翻译命令。
///
/// - Auto:免费服务自动分流和故障切换,一次性返回完整译文
/// - OpenAI:流式,通过 `translate-chunk` 事件推送,命令本身返回空串
#[tauri::command]
pub async fn translate<R: Runtime>(
app: AppHandle<R>,
req: TranslateRequest,
) -> Result<String, String> {
let text = req.text.trim();
if text.is_empty() {
return Ok(String::new());
}
translate::validate_translation_text(text)?;
translate::validate_language_pair(&req.source, &req.target)?;
match req.engine {
Engine::Auto => translate::auto::translate(text, &req.source, &req.target).await,
Engine::Google => translate::google::translate(text, &req.source, &req.target).await,
Engine::Bing => translate::bing::translate(text, &req.source, &req.target).await,
Engine::Openai => {
let cfg = config::load_openai_for_request(&app)?;
translate::openai::translate_stream(
&app,
&cfg,
req.request_id,
text,
&req.source,
&req.target,
)
.await?;
Ok(String::new())
}
}
}
/// 截图 OCR 命令:Apple 自带自由框选 → 系统 OCR → 返回识别文本。
///
/// 用户取消截图时返回空串。在阻塞线程执行(screencapture 与 Vision 均为阻塞调用)。
#[tauri::command]
pub async fn screenshot_ocr<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
static OCR_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
let Ok(_guard) = OCR_LOCK.try_lock() else {
// 忽略重复快捷键,避免多个系统框选进程和 Vision 任务抢占资源。
return Ok(String::new());
};
let main = window::main_window(&app);
let was_visible = main.as_ref().is_some_and(|w| w.is_visible().unwrap_or(false));
if let Some(main) = main.as_ref() {
window::hide_window(main);
}
// 等待窗口退出屏幕合成,固定窗口也不会遮挡用户要截取的文字。
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
let result = tauri::async_runtime::spawn_blocking(|| {
let Some(path) = screenshot::capture_interactive()? else {
return Ok(String::new());
};
let result = ocr::recognize_file(&path.to_string_lossy());
// TempPath 离开作用域时自动清理临时截图。
result
})
.await
.map_err(|e| format!("OCR 任务失败: {e}"))?;
if was_visible && result.as_ref().map_or(true, |text| text.is_empty()) {
window::show_main(&app);
}
result
}
/// 读取全部 AI 对话历史(JSONL 流式解析,损坏行容错)。
#[tauri::command]
pub fn load_chat_history<R: Runtime>(app: AppHandle<R>) -> Result<Vec<ChatMessage>, String> {
history::read_all(&app)
}
/// 以对话为单位读取历史(按 session_start 标记切分)。
#[tauri::command]
pub fn load_conversations<R: Runtime>(
app: AppHandle<R>,
) -> Result<Vec<history::Conversation>, String> {
history::read_conversations(&app)
}
/// 写入对话分隔标记,开启一段新对话(不影响已有历史)。
#[tauri::command]
pub fn start_new_conversation<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
history::start_new_conversation(&app)
}
/// 追加一轮对话到历史文件(追加写、永不重写)。
#[tauri::command]
pub fn append_chat_history<R: Runtime>(
app: AppHandle<R>,
conversation_id: Option<usize>,
round: Vec<ChatMessage>,
) -> Result<(), String> {
translate::validate_messages(&round)?;
history::append_round(&app, conversation_id, &round)
}
/// 清空全部对话历史(删除历史文件)。
#[tauri::command]
pub fn clear_chat_history<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
history::clear(&app)
}
File diff suppressed because it is too large. Load diff
-458
View File
@@ -1,458 +0,0 @@
//! AI 对话历史持久化 —— 行分隔 JSON (JSONL) 追加文件。
//!
//! 设计目标:**最省存储 + 全量 + 以对话为单位**。
//! - 追加写:每轮结束把消息各写一行,`OpenOptions::append`,**永不重写**旧数据,
//! O(1) 写入、最小 I/O。
//! - 行分隔:无 JSON 数组括号/缩进,每行一条 JSON,天然最小体积。
//! - 流式读:`BufReader::lines()` 逐行解析,单行损坏不影响其余(容错)。
//! - 独立文件 `chat-history.jsonl`,与 `settings.json`(含 API 配置)物理隔离。
//! - 对话边界:用一条 `{"type":"session_start"}` 标记新对话开始。
//! 旧数据(无标记)自动归为最旧的一段,向后兼容。
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager, Runtime};
use crate::translate::ChatMessage;
/// 历史文件名(与 settings.json 同目录,但独立文件)。
const HISTORY_FILE: &str = "chat-history.jsonl";
/// 序列化所有历史读写:多个 Tauri 命令(append/read/clear)可能并发执行,
/// 若各自 open + write_all 会交错写入、破坏 JSONL。临界区很短,单把锁足够。
static HISTORY_LOCK: Mutex<()> = Mutex::new(());
/// 单段对话:包含一组消息,以及用于列表展示的摘要(首句用户提问)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
/// 稳定会话 id;恢复旧对话后返回顺序会变化,但 id 保持不变。
pub id: usize,
pub messages: Vec<ChatMessage>,
/// 摘要:该段对话第一条用户提问(截断),用作列表标题。
pub summary: String,
}
/// 文件行:可能是对话分隔标记或普通消息。
/// 用未标记的 `#[serde(untagged)]` 让旧格式 `{"role","content"}` 继续可解析。
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum HistoryLine {
/// 恢复已有对话,后续消息继续归入指定稳定 id。
SessionResume {
#[serde(rename = "type")]
kind: String,
id: usize,
},
/// 新对话开始标记(`{"type":"session_start"}`)。
SessionStart {
#[serde(rename = "type")]
kind: String,
},
/// 普通消息(role/content)。
Message(ChatMessage),
}
const SESSION_START_MARKER: &str = r#"{"type":"session_start"}"#;
const SUMMARY_MAX_CHARS: usize = 40;
/// 解析历史文件绝对路径。目录不存在时尝试创建。
fn history_path<R: Runtime>(app: &AppHandle<R>) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| format!("解析配置目录失败: {e}"))?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败: {e}"))?;
}
Ok(dir.join(HISTORY_FILE))
}
/// 写入对话分隔标记,表示之后的消息属于一段新对话。
/// 仅在文件已有内容时才需要写标记(首段对话无需前导标记)。
pub fn start_new_conversation<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
let _guard = HISTORY_LOCK
.lock()
.map_err(|e| format!("历史锁中毒: {e}"))?;
let path = history_path(app)?;
// 空文件无需分隔标记——它本身已是新对话起点
let needs_marker = match fs::metadata(&path) {
Ok(meta) => meta.len() > 0,
Err(_) => false, // 不存在 → 视为空,首段无需标记
};
if !needs_marker {
return Ok(());
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("打开历史文件失败: {e}"))?;
let mut buf = String::from(SESSION_START_MARKER);
buf.push('\n');
file.write_all(buf.as_bytes())
.map_err(|e| format!("写入对话分隔标记失败: {e}"))
}
/// 追加一轮对话(多条消息)。
///
/// 每条消息独占一行,单次 write 调用写完整批,保证原子性尽量高、I/O 最小。
/// 空切片直接返回 Ok。
pub fn append_round<R: Runtime>(
app: &AppHandle<R>,
conversation_id: Option<usize>,
msgs: &[ChatMessage],
) -> Result<(), String> {
if msgs.is_empty() {
return Ok(());
}
let _guard = HISTORY_LOCK
.lock()
.map_err(|e| format!("历史锁中毒: {e}"))?;
let path = history_path(app)?;
// 预先序列化所有行,合并成一次写入
let mut buf = String::with_capacity(128 * msgs.len());
if let Some(id) = conversation_id {
let conversations = read_conversations_from_path(&path)?;
if !conversations
.iter()
.any(|conversation| conversation.id == id)
{
return Err(format!("对话不存在: {id}"));
}
let marker = serde_json::json!({ "type": "session_resume", "id": id });
buf.push_str(
&serde_json::to_string(&marker).map_err(|e| format!("序列化对话恢复标记失败: {e}"))?,
);
buf.push('\n');
}
for m in msgs {
let line = serde_json::to_string(m).map_err(|e| format!("序列化消息失败: {e}"))?;
buf.push_str(&line);
buf.push('\n');
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("打开历史文件失败: {e}"))?;
file.write_all(buf.as_bytes())
.map_err(|e| format!("写入历史文件失败: {e}"))
}
/// 读取全部历史消息(扁平流,向后兼容旧前端调用)。
pub fn read_all<R: Runtime>(app: &AppHandle<R>) -> Result<Vec<ChatMessage>, String> {
Ok(read_conversations(app)?
.into_iter()
.flat_map(|c| c.messages)
.collect())
}
/// 以对话为单位读取历史:按 `session_start` 标记切分,旧数据(无标记)
/// 归为第一段(id=0)。返回顺序为最久未活动 → 最近活动。
pub fn read_conversations<R: Runtime>(app: &AppHandle<R>) -> Result<Vec<Conversation>, String> {
let _guard = HISTORY_LOCK
.lock()
.map_err(|e| format!("历史锁中毒: {e}"))?;
let path = history_path(app)?;
read_conversations_from_path(&path)
}
fn read_conversations_from_path(path: &Path) -> Result<Vec<Conversation>, String> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("打开历史文件失败: {e}")),
};
let reader = BufReader::new(file);
Ok(parse_conversation_lines(
reader.lines().map_while(Result::ok),
))
}
fn parse_conversation_lines<I>(lines: I) -> Vec<Conversation>
where
I: IntoIterator<Item = String>,
{
let mut conversations: Vec<Conversation> = Vec::new();
let mut current_id: Option<usize> = None;
let mut next_id: usize = 0;
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// 单行损坏不影响其余历史
let parsed = match serde_json::from_str::<HistoryLine>(trimmed) {
Ok(p) => p,
Err(_) => continue,
};
match parsed {
HistoryLine::SessionResume { kind, id } if kind == "session_resume" => {
current_id = Some(id);
next_id = next_id.max(id.saturating_add(1));
if let Some(index) = conversations.iter().position(|c| c.id == id) {
let conversation = conversations.remove(index);
conversations.push(conversation);
}
}
HistoryLine::SessionStart { kind } if kind == "session_start" => {
current_id = Some(next_id);
next_id = next_id.saturating_add(1);
}
HistoryLine::Message(msg) => {
let id = current_id.unwrap_or_else(|| {
let id = next_id;
next_id = next_id.saturating_add(1);
current_id = Some(id);
id
});
if !conversations
.iter()
.any(|conversation| conversation.id == id)
{
conversations.push(Conversation {
id,
messages: Vec::new(),
summary: String::new(),
});
}
let conv = conversations
.iter_mut()
.find(|conversation| conversation.id == id)
.expect("conversation was inserted above");
if conv.summary.is_empty() && msg.role == "user" {
conv.summary = truncate_summary(&msg.content);
}
conv.messages.push(msg);
}
// 非法 type 值:忽略,不影响其余
_ => continue,
}
}
conversations
}
/// 截取摘要:首行 + 限定长度,避免超长提问撑爆列表标题。
fn truncate_summary(content: &str) -> String {
let first_line = content.lines().next().unwrap_or("").trim();
if first_line.chars().count() <= SUMMARY_MAX_CHARS {
return first_line.to_string();
}
let truncated: String = first_line.chars().take(SUMMARY_MAX_CHARS).collect();
format!("{truncated}…")
}
/// 清空全部历史:直接删除文件(下次 append 会重建)。
pub fn clear<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
let _guard = HISTORY_LOCK
.lock()
.map_err(|e| format!("历史锁中毒: {e}"))?;
let path = history_path(app)?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("删除历史文件失败: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(role: &str, content: &str) -> ChatMessage {
ChatMessage {
role: role.to_string(),
content: content.to_string(),
}
}
#[test]
fn parse_recovers_around_corrupt_lines() {
// 构造一段含损坏行的 JSONL:第 1、3 行合法,第 2 行损坏
let data = concat!(
r#"{"role":"user","content":"你好"}"#,
"\n",
r#"this is not json"#,
"\n",
r#"{"role":"assistant","content":"你好!有什么可以帮你?"}"#,
"\n",
);
let mut out = Vec::new();
for line in data.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if let Ok(m) = serde_json::from_str::<ChatMessage>(t) {
out.push(m);
}
}
assert_eq!(out.len(), 2);
assert_eq!(out[0].role, "user");
assert_eq!(out[1].content, "你好!有什么可以帮你?");
}
#[test]
fn append_then_read_roundtrip() {
// 临时文件:序列化 → 反序列化,验证行格式正确
let round = vec![msg("user", "翻译"), msg("assistant", "translate")];
let mut buf = String::new();
for m in &round {
buf.push_str(&serde_json::to_string(m).unwrap());
buf.push('\n');
}
let parsed: Vec<ChatMessage> = buf
.lines()
.filter_map(|l| serde_json::from_str(l.trim()).ok())
.collect();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].content, "翻译");
assert_eq!(parsed[1].role, "assistant");
}
#[test]
fn empty_input_is_noop() {
// 追加空切片应直接成功(由调用侧保证,这里验证逻辑)
let round: Vec<ChatMessage> = vec![];
assert!(round.is_empty());
}
#[test]
fn each_message_is_one_line_compact() {
// 验证最省存储:无缩进、无多余空白,每条恰好一行
let s = serde_json::to_string(&msg("user", "hi")).unwrap();
assert!(!s.contains('\n'));
assert!(!s.contains(" ")); // 无两连空格(缩进)
assert_eq!(s, r#"{"role":"user","content":"hi"}"#);
}
/// 解析 JSONL 为对话列表的核心逻辑(不依赖 AppHandle,便于单测)。
fn parse_conversations(data: &str) -> Vec<Conversation> {
parse_conversation_lines(data.lines().map(str::to_string))
}
#[test]
fn legacy_data_without_markers_becomes_single_conversation() {
// 旧格式(无 session_start 标记):全部归为一段对话(id=0)
let data = concat!(
r#"{"role":"user","content":"你好"}"#,
"\n",
r#"{"role":"assistant","content":"你好!"}"#,
"\n",
r#"{"role":"user","content":"再见"}"#,
"\n",
);
let convs = parse_conversations(data);
assert_eq!(convs.len(), 1);
assert_eq!(convs[0].id, 0);
assert_eq!(convs[0].messages.len(), 3);
assert_eq!(convs[0].summary, "你好");
}
#[test]
fn session_start_marker_splits_conversations() {
// 两个 session_start → 三段对话,摘要各取首句用户提问
let data = concat!(
r#"{"role":"user","content":"第一个问题"}"#,
"\n",
r#"{"role":"assistant","content":"回答一"}"#,
"\n",
r#"{"type":"session_start"}"#,
"\n",
r#"{"role":"user","content":"第二个问题"}"#,
"\n",
r#"{"role":"assistant","content":"回答二"}"#,
"\n",
r#"{"type":"session_start"}"#,
"\n",
r#"{"role":"user","content":"第三个问题"}"#,
"\n",
);
let convs = parse_conversations(data);
assert_eq!(convs.len(), 3);
assert_eq!(convs[0].messages.len(), 2);
assert_eq!(convs[1].messages.len(), 2);
assert_eq!(convs[2].messages.len(), 1);
assert_eq!(convs[0].summary, "第一个问题");
assert_eq!(convs[1].summary, "第二个问题");
assert_eq!(convs[2].summary, "第三个问题");
// id 严格递增
assert_eq!(convs[0].id, 0);
assert_eq!(convs[1].id, 1);
assert_eq!(convs[2].id, 2);
}
#[test]
fn long_summary_is_truncated() {
// 超过 SUMMARY_MAX_CHARS(40) 的提问应被截断并加省略号
let long = "一".repeat(60);
let summary = truncate_summary(&long);
assert!(summary.ends_with('…'));
// 截断后 = 40 字符 + 1 省略号
assert_eq!(summary.chars().count(), SUMMARY_MAX_CHARS + 1);
}
#[test]
fn short_summary_is_kept_intact() {
let short = "简短提问".to_string();
assert_eq!(truncate_summary(&short), "简短提问");
}
#[test]
fn summary_takes_first_line_only() {
let multi = "第一行提问\n第二行不该出现".to_string();
assert_eq!(truncate_summary(&multi), "第一行提问");
}
#[test]
fn summary_skips_assistant_until_first_user() {
// 首条是 assistant(异常但需容错),摘要应取第一条 user
let data = concat!(
r#"{"role":"assistant","content":"嗨"}"#,
"\n",
r#"{"role":"user","content":"真正的问题"}"#,
"\n",
);
let convs = parse_conversations(data);
assert_eq!(convs.len(), 1);
assert_eq!(convs[0].summary, "真正的问题");
}
#[test]
fn resumed_conversation_receives_appended_round_and_becomes_most_recent() {
let data = concat!(
r#"{"role":"user","content":"A1"}"#,
"\n",
r#"{"role":"assistant","content":"A2"}"#,
"\n",
r#"{"type":"session_start"}"#,
"\n",
r#"{"role":"user","content":"B1"}"#,
"\n",
r#"{"role":"assistant","content":"B2"}"#,
"\n",
r#"{"type":"session_resume","id":0}"#,
"\n",
r#"{"role":"user","content":"A3"}"#,
"\n",
r#"{"role":"assistant","content":"A4"}"#,
"\n",
);
let convs = parse_conversations(data);
assert_eq!(convs.len(), 2);
assert_eq!(convs[0].id, 1);
assert_eq!(convs[0].messages.last().unwrap().content, "B2");
assert_eq!(convs[1].id, 0);
assert_eq!(convs[1].messages.len(), 4);
assert_eq!(convs[1].messages.last().unwrap().content, "A4");
}
}
-154
View File
@@ -1,154 +0,0 @@
//! macOS Keychain 封装 —— 把敏感凭据(API key)存进系统钥匙串,
//! 避免明文落盘到 settings.json。
//!
//! 用「通用密码」(generic password) 项存储,service 固定为 bundle id,
//! account 区分不同 key。非 macOS 平台返回错误(本项目仅面向 macOS)。
#[cfg(target_os = "macos")]
use security_framework::passwords;
use serde::{Deserialize, Serialize};
/// Keychain 项的 service 名(沿用 bundle identifier)。
pub const SERVICE: &str = "com.samwstu.ttree";
/// OpenAI 翻译配置的 API key 账户名。
pub const ACCOUNT_OPENAI_KEY: &str = "openai.api_key";
/// AI 对话配置的 API key 账户名。
pub const ACCOUNT_CHAT_AI_KEY: &str = "chat_ai.api_key";
/// errSecItemNotFound:Keychain 中找不到该条目(首次使用属正常情况)。
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
const BOUND_SECRET_PREFIX: &str = "ttree-bound-secret-v1:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretRecord {
Legacy(String),
Bound { endpoint: String, value: String },
}
#[derive(Serialize, Deserialize)]
struct BoundSecretPayload {
endpoint: String,
value: String,
}
pub fn encode_bound_secret(endpoint: &str, value: &str) -> Result<String, String> {
if endpoint.is_empty() || value.is_empty() {
return Err("接口地址和密钥均不能为空".to_string());
}
let payload = serde_json::to_string(&BoundSecretPayload {
endpoint: endpoint.to_string(),
value: value.to_string(),
})
.map_err(|e| format!("序列化钥匙串记录失败: {e}"))?;
Ok(format!("{BOUND_SECRET_PREFIX}{payload}"))
}
pub fn decode_secret_record(raw: &str) -> Result<SecretRecord, String> {
let Some(payload) = raw.strip_prefix(BOUND_SECRET_PREFIX) else {
return Ok(SecretRecord::Legacy(raw.to_string()));
};
let record: BoundSecretPayload =
serde_json::from_str(payload).map_err(|e| format!("钥匙串记录损坏: {e}"))?;
if record.endpoint.is_empty() || record.value.is_empty() {
return Err("钥匙串记录损坏: 接口地址或密钥为空".to_string());
}
Ok(SecretRecord::Bound {
endpoint: record.endpoint,
value: record.value,
})
}
pub fn get_secret_record(account: &str) -> Result<Option<SecretRecord>, String> {
get_secret(account)?
.map(|raw| decode_secret_record(&raw))
.transpose()
}
pub fn set_bound_secret(account: &str, endpoint: &str, value: &str) -> Result<(), String> {
let encoded = encode_bound_secret(endpoint, value)?;
set_secret(account, &encoded)
}
/// 读取一个凭据。项不存在时返回 `Ok(None)`(首次使用),其余错误原样上抛。
#[cfg(target_os = "macos")]
pub fn get_secret(account: &str) -> Result<Option<String>, String> {
match passwords::get_generic_password(SERVICE, account) {
Ok(bytes) => Ok(Some(
String::from_utf8(bytes).map_err(|e| format!("密钥内容非 UTF-8: {e}"))?,
)),
Err(err) => {
// 项不存在属于正常情况,转成 None
let code = err.code();
if code == ERR_SEC_ITEM_NOT_FOUND {
Ok(None)
} else {
Err(format!("读取钥匙串失败 (code {code}): {err}"))
}
}
}
}
/// 写入凭据;若已存在则覆盖。
#[cfg(target_os = "macos")]
pub fn set_secret(account: &str, value: &str) -> Result<(), String> {
passwords::set_generic_password(SERVICE, account, value.as_bytes())
.map_err(|e| format!("写入钥匙串失败: {e}"))
}
/// 删除凭据;项不存在视为成功(幂等)。
#[cfg(target_os = "macos")]
pub fn delete_secret(account: &str) -> Result<(), String> {
match passwords::delete_generic_password(SERVICE, account) {
Ok(()) => Ok(()),
Err(err) if err.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(()),
Err(err) => Err(format!("删除钥匙串失败 (code {}): {err}", err.code())),
}
}
// ── 非 macOS 占位:本项目目前仅面向 macOS,这里仅作编译护栏 ──
#[cfg(not(target_os = "macos"))]
pub fn get_secret(_account: &str) -> Result<Option<String>, String> {
Err("此平台不支持 Keychain".to_string())
}
#[cfg(not(target_os = "macos"))]
pub fn set_secret(_account: &str, _value: &str) -> Result<(), String> {
Err("此平台不支持 Keychain".to_string())
}
#[cfg(not(target_os = "macos"))]
pub fn delete_secret(_account: &str) -> Result<(), String> {
Err("此平台不支持 Keychain".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_endpoint_bound_secrets() {
let encoded = encode_bound_secret("https://api.example.com/v1", "top-secret").unwrap();
assert_eq!(
decode_secret_record(&encoded).unwrap(),
SecretRecord::Bound {
endpoint: "https://api.example.com/v1".to_string(),
value: "top-secret".to_string(),
}
);
assert!(!encoded.contains("\"api_key\""));
}
#[test]
fn treats_existing_raw_keychain_values_as_legacy() {
assert_eq!(
decode_secret_record("sk-existing").unwrap(),
SecretRecord::Legacy("sk-existing".to_string())
);
}
#[test]
fn rejects_corrupt_bound_secret_records() {
assert!(decode_secret_record("ttree-bound-secret-v1:{broken").is_err());
}
}
-107
View File
@@ -1,107 +0,0 @@
//! TTREE —— 轻量 macOS AI 翻译器后端入口。
mod commands;
mod config;
mod history;
mod keychain;
mod ocr;
mod screenshot;
mod selection;
mod shortcut;
mod translate;
mod tray;
mod window;
use tauri::{Manager, WindowEvent};
#[cfg(target_os = "macos")]
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_positioner::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(shortcut::build_plugin())
.invoke_handler(tauri::generate_handler![
commands::translate,
commands::chat,
commands::screenshot_ocr,
commands::show_main,
commands::get_openai_config,
commands::set_openai_config,
commands::get_chat_ai_config,
commands::set_chat_ai_config,
commands::get_shortcut_config,
commands::set_shortcut_config,
commands::set_shortcut_recording,
commands::get_pinned,
commands::set_pinned,
commands::get_appearance_config,
commands::set_appearance_config,
commands::load_chat_history,
commands::load_conversations,
commands::start_new_conversation,
commands::append_chat_history,
commands::clear_chat_history
])
.setup(|app| {
// 菜单栏应用:隐藏 Dock 与应用切换器入口,只保留状态栏托盘图标。
#[cfg(target_os = "macos")]
app.handle()
.set_activation_policy(tauri::ActivationPolicy::Accessory)?;
// 应用毛玻璃磨砂底色 —— Material 3 扁平风格:保留 Popover 通透材质作为窗口底,
// 圆角与 CSS 的 --radius-window(14px) 对齐,控件一律走实色表面。
#[cfg(target_os = "macos")]
if let Some(win) = app.get_webview_window(window::MAIN_WINDOW) {
let _ = apply_vibrancy(
&win,
NSVisualEffectMaterial::Popover,
Some(NSVisualEffectState::Active),
Some(14.0),
);
}
// 恢复上次用户调整后的窗口尺寸;缺失则沿用配置默认值(最小宽度)。
if let Some(win) = app.get_webview_window(window::MAIN_WINDOW) {
if let Some((w, h)) = config::load_window_size(app.handle()) {
let _ = win.set_size(tauri::LogicalSize::new(w, h));
}
}
// 注册持久化快捷键配置
shortcut::register_saved(app.handle())?;
// 构建系统托盘
tray::setup_tray(app.handle())?;
Ok(())
})
.on_window_event(|win, event| {
// 失焦自动隐藏(仅主窗口)。固定窗口时不隐藏。
if win.label() == window::MAIN_WINDOW && !window::is_pinned() {
if let WindowEvent::Focused(false) = event {
let _ = win.hide();
}
}
// 用户调整窗口尺寸后持久化,下次呼出沿用。
// 仅在窗口可见时记录,避免启动期间窗口隐藏时的初始化尺寸覆盖已保存值。
if win.label() == window::MAIN_WINDOW && window::is_visible_safe(win) {
if let WindowEvent::Resized(physical) = event {
let scale = win.scale_factor().unwrap_or(1.0);
let w = physical.width as f64 / scale;
let h = physical.height as f64 / scale;
config::save_window_size(win.app_handle(), w, h);
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
ttree_lib::run()
}
-111
View File
@@ -1,111 +0,0 @@
//! 将系统 PNG 解码并绘制成 Vision 可以稳定读取的 8-bit sRGB 位图。
#![cfg(target_os = "macos")]
use objc2_core_foundation::{CFData, CFRetained, CGPoint, CGRect, CGSize};
use objc2_core_graphics::{
kCGColorSpaceSRGB, CGBitmapContextCreate, CGBitmapContextCreateImage,
CGColorSpace, CGContext, CGImage, CGImageAlphaInfo, CGImageByteOrderInfo,
};
use objc2_image_io::CGImageSource;
const MIN_DIMENSION: usize = 128;
const BORDER: usize = 16;
const MAX_PIXELS: usize = 32_000_000;
pub(super) fn prepare_png(bytes: &[u8]) -> Result<CFRetained<CGImage>, String> {
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 24 {
return Err("截图不是有效的 PNG 图片,请重新框选。".to_string());
}
// 在原生解码前限制 IHDR 尺寸,避免异常文件触发巨量分配。
if &bytes[12..16] != b"IHDR" {
return Err("截图 PNG 缺少图片尺寸信息,请重新框选。".to_string());
}
let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap()) as usize;
let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as usize;
canvas_size(width, height)?;
let data = CFData::from_bytes(bytes);
// SAFETY: 使用 ImageIO 默认选项,data 的内容在整个解码期间不变。
let source = unsafe { CGImageSource::with_data(&data, None) }
.ok_or_else(|| "系统无法读取截图 PNG,请重新框选。".to_string())?;
let image = unsafe { source.image_at_index(0, None) }
.ok_or_else(|| "截图 PNG 解码失败,请重新框选。".to_string())?;
let width = CGImage::width(Some(&image));
let height = CGImage::height(Some(&image));
let (canvas_width, canvas_height) = canvas_size(width, height)?;
// 显示器截图可能包含灰度、透明度、广色域或 16-bit 像素。统一为
// 8-bit sRGB、32-bit RGBX,并用白色背景消除透明通道的歧义。
let color_space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB }))
.ok_or_else(|| "创建 OCR 色彩空间失败".to_string())?;
let bitmap_info = CGImageAlphaInfo::NoneSkipLast.0 | CGImageByteOrderInfo::Order32Big.0;
// SAFETY: null 让 CoreGraphics 自行管理内存;尺寸已检查且每像素 4 字节。
let context = unsafe {
CGBitmapContextCreate(
std::ptr::null_mut(),
canvas_width,
canvas_height,
8,
canvas_width * 4,
Some(&color_space),
bitmap_info,
)
}
.ok_or_else(|| "创建 OCR 位图失败".to_string())?;
CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, 1.0);
CGContext::fill_rect(Some(&context), rect(0, 0, canvas_width, canvas_height));
// 保持原始像素大小;窄单行截图加边距,避免极小图像进入文字模型。
CGContext::draw_image(
Some(&context),
rect((canvas_width - width) / 2, (canvas_height - height) / 2, width, height),
Some(&image),
);
CGBitmapContextCreateImage(Some(&context))
.ok_or_else(|| "生成 OCR 位图失败".to_string())
}
fn canvas_size(width: usize, height: usize) -> Result<(usize, usize), String> {
if width == 0 || height == 0 {
return Err("截图尺寸为空,请重新框选。".to_string());
}
let padded = width
.checked_add(BORDER * 2)
.zip(height.checked_add(BORDER * 2))
.map(|(width, height)| (width.max(MIN_DIMENSION), height.max(MIN_DIMENSION)))
.filter(|(width, height)| {
width.checked_mul(*height).is_some_and(|pixels| pixels <= MAX_PIXELS)
});
padded.ok_or_else(|| "截图区域过大,请缩小框选范围后重试。".to_string())
}
fn rect(x: usize, y: usize, width: usize, height: usize) -> CGRect {
CGRect {
origin: CGPoint { x: x as _, y: y as _ },
size: CGSize { width: width as _, height: height as _ },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pads_single_line_captures_without_stretching_text() {
assert_eq!(canvas_size(400, 20).unwrap(), (432, 128));
assert_eq!(canvas_size(1, 1).unwrap(), (128, 128));
}
#[test]
fn refuses_zero_or_excessive_dimensions() {
assert!(canvas_size(0, 20).is_err());
assert!(canvas_size(20, 0).is_err());
assert!(canvas_size(usize::MAX, 20).is_err());
assert!(canvas_size(10_000, 10_000).is_err());
}
#[test]
fn rejects_truncated_png_without_entering_vision() {
assert!(prepare_png(b"\x89PNG\r\n\x1a\n").is_err());
}
}
-21
View File
@@ -1,21 +0,0 @@
//! OCR 模块入口。
#[cfg(target_os = "macos")]
mod image;
#[cfg(target_os = "macos")]
pub mod vision;
#[cfg(all(test, target_os = "macos"))]
mod tests;
/// 对图片文件执行 OCR,返回识别文本。
#[cfg(target_os = "macos")]
pub fn recognize_file(path: &str) -> Result<String, String> {
vision::recognize_file(path)
}
#[cfg(not(target_os = "macos"))]
pub fn recognize_file(_path: &str) -> Result<String, String> {
Err("当前平台暂不支持 OCR".to_string())
}
-75
View File
@@ -1,75 +0,0 @@
//! 真正执行 ImageIO + Vision 的 macOS 回归测试,不能只检查 request 属性。
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;
use objc2::rc::autoreleasepool;
use objc2_core_graphics::CGImage;
use tempfile::TempDir;
use super::{image, vision};
fn fixtures() -> &'static Path {
static FIXTURES: OnceLock<TempDir> = OnceLock::new();
FIXTURES
.get_or_init(|| {
let directory = TempDir::new().unwrap();
let script = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/ocr/tests/generate-fixtures.swift");
let output = Command::new("/usr/bin/swift")
.arg(script)
.arg(directory.path())
.output()
.expect("macOS OCR tests require the Xcode Command Line Tools");
assert!(
output.status.success(),
"生成 OCR 样例失败: {}",
String::from_utf8_lossy(&output.stderr)
);
directory
})
.path()
}
fn fixture(name: &str) -> Vec<u8> {
std::fs::read(fixtures().join(format!("{name}.png"))).unwrap()
}
#[test]
fn native_ocr_reads_real_png_formats_and_single_line_captures() {
for name in ["english", "single-line", "grayscale", "grayscale-16bit", "transparent"] {
let text = vision::recognize_bytes(&fixture(name))
.unwrap_or_else(|error| panic!("{name}: {error}"));
assert!(text.contains("Hello OCR"), "{name}: {text:?}");
}
let text = vision::recognize_bytes(&fixture("chinese")).unwrap();
let compact: String = text.chars().filter(|ch| !ch.is_whitespace()).collect();
assert!(compact.contains("你好世界"), "chinese: {text:?}");
assert!(text.contains("Hello OCR"), "mixed Chinese/English: {text:?}");
if objc2::available!(macos = 13.0) {
let text = vision::recognize_bytes(&fixture("japanese")).unwrap();
assert!(text.contains("テキスト"), "japanese: {text:?}");
}
}
#[test]
fn imageio_decodes_16bit_png_and_normalizes_pixels_before_vision() {
autoreleasepool(|_| {
let bytes = fixture("grayscale-16bit");
assert_eq!(bytes[24], 16, "fixture must actually have 16-bit PNG samples");
let image = image::prepare_png(&bytes).unwrap();
assert_eq!(CGImage::bits_per_component(Some(&image)), 8);
assert_eq!(CGImage::bits_per_pixel(Some(&image)), 32);
assert!(CGImage::height(Some(&image)) >= 128);
});
}
#[test]
fn compatibility_retry_really_recognizes_text_on_cpu() {
autoreleasepool(|_| {
let image = image::prepare_png(&fixture("english")).unwrap();
let text = vision::recognize_once(&image, true).unwrap();
assert!(text.contains("Hello OCR"), "CPU retry: {text:?}");
});
}
@@ -1,71 +0,0 @@
// CI-only comparison against Apple's minimal Vision request; synthetic text only.
import Foundation
import ImageIO
import Vision
import CoreML
import CoreImage
let file = URL(fileURLWithPath: CommandLine.arguments[1])
let data = try Data(contentsOf: file)
let source = CGImageSourceCreateWithData(data as CFData, nil)!
let image = CGImageSourceCreateImageAtIndex(source, 0, nil)!
print("Vision default revision: \(VNRecognizeTextRequest.defaultRevision)")
print("Vision supported revisions: \(VNRecognizeTextRequest.supportedRevisions)")
func assignCPU(_ request: VNRecognizeTextRequest) throws {
if #available(macOS 14.0, *) {
let stages = try request.supportedComputeStageDevices
for (stage, devices) in stages {
print("compute stage \(stage): \(devices)")
if let cpu = devices.first(where: {
if case .cpu(_) = $0 { return true }
return false
}) {
request.setComputeDevice(cpu, for: stage)
}
}
}
}
let profiles: [(String, (VNRecognizeTextRequest) throws -> Void)] = [
("apple-default", { _ in }),
("default-explicit-cpu", { try assignCPU($0) }),
("rev3-english-cpu", { request in
request.revision = 3
request.recognitionLanguages = ["en-US"]
try assignCPU(request)
}),
("rev3-chinese-english-cpu", { request in
request.revision = 3
request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"]
try assignCPU(request)
}),
("rev2-chinese-english-cpu", { request in
request.revision = 2
request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"]
try assignCPU(request)
}),
("fast-english-cpu", { request in
request.recognitionLevel = .fast
request.recognitionLanguages = ["en-US"]
try assignCPU(request)
}),
]
for (name, configure) in profiles {
for software in [false, true] {
do {
let request = VNRecognizeTextRequest()
try configure(request)
let options: [VNImageOption: Any] = software
? [.ciContext: CIContext(options: [.useSoftwareRenderer: true])]
: [:]
let handler = VNImageRequestHandler(cgImage: image, options: options)
try handler.perform([request])
let text = request.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n") ?? ""
print("\(name) software=\(software): OK \(text)")
} catch {
let error = error as NSError
print("\(name) software=\(software): ERROR domain=\(error.domain) code=\(error.code) details=\(error.userInfo)")
}
}
}
@@ -1,45 +0,0 @@
// Deterministic OCR fixtures generated with macOS system fonts, entirely offline.
import AppKit
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
func writeFixture(
_ name: String, text: String, width: Int = 440, height: Int = 80,
fontSize: CGFloat = 32, bits: Int = 8, grayscale: Bool = false,
transparent: Bool = false
) throws {
guard let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
bitsPerSample: bits, samplesPerPixel: grayscale ? 1 : 4,
hasAlpha: !grayscale, isPlanar: false,
colorSpaceName: grayscale ? .deviceWhite : .deviceRGB,
bytesPerRow: 0, bitsPerPixel: 0
), let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
fatalError("Could not create fixture bitmap: \(name)")
}
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
context.cgContext.setFillColor((transparent ? NSColor.clear : NSColor.white).cgColor)
context.cgContext.setBlendMode(.copy)
context.cgContext.fill(CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
context.cgContext.setBlendMode(.normal)
(text as NSString).draw(
at: NSPoint(x: 4, y: 2),
withAttributes: [.font: NSFont.systemFont(ofSize: fontSize),
.foregroundColor: NSColor.black]
)
context.flushGraphics()
NSGraphicsContext.restoreGraphicsState()
guard let png = bitmap.representation(using: .png, properties: [:]) else {
fatalError("Could not encode fixture PNG: \(name)")
}
try png.write(to: directory.appendingPathComponent(name + ".png"))
}
try writeFixture("english", text: "Hello OCR 123")
try writeFixture("single-line", text: "Hello OCR 123", width: 160, height: 22, fontSize: 14)
try writeFixture("grayscale", text: "Hello OCR 123", grayscale: true)
try writeFixture("grayscale-16bit", text: "Hello OCR 123", bits: 16, grayscale: true)
try writeFixture("transparent", text: "Hello OCR 123", transparent: true)
try writeFixture("chinese", text: "你好世界 Hello OCR")
try writeFixture("japanese", text: "日本語テキスト")
-263
View File
@@ -1,263 +0,0 @@
//! macOS 原生 OCR —— 通过 Apple Vision 框架的 VNRecognizeTextRequest 识别图片文字。
//!
//! 仅 macOS。PNG → ImageIO 解码 → 8-bit RGB CGImage → Vision 同步 perform →
//! 读取 VNRecognizedTextObservation 的 topCandidates(1) 拼接。
#![cfg(target_os = "macos")]
use objc2::rc::{autoreleasepool, Retained};
use objc2::runtime::AnyObject;
use objc2::{AnyThread, ClassType};
use objc2_core_graphics::CGImage;
use objc2_core_image::{kCIContextUseSoftwareRenderer, CIContext};
use objc2_core_ml::MLCPUComputeDevice;
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSObjectProtocol, NSString};
use objc2_vision::{
VNImageOptionCIContext, VNImageRequestHandler, VNRecognizeTextRequest,
VNRequestTextRecognitionLevel,
};
/// 对指定图片文件路径执行 OCR,返回识别文本(按行拼接)。
pub fn recognize_file(path: &str) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|e| format!("读取截图失败: {e}"))?;
recognize_bytes(&bytes)
}
/// 对内存中的图片字节执行 OCR。
pub fn recognize_bytes(bytes: &[u8]) -> Result<String, String> {
// Tokio 阻塞线程没有 AppKit 事件循环的自动释放池;每次识别单独释放
// Vision 创建的临时原生对象,防止多次截图后内存和计算资源累积。
autoreleasepool(|_| {
let image = super::image::prepare_png(bytes)?;
let first_error = match recognize_once(&image, false) {
Ok(text) if !text.trim().is_empty() => return Ok(text),
Ok(_) => "未识别到文字".to_string(),
Err(error) => error,
};
// 复用同一截图,但创建全新的 request/handler。CPU 回退避开临时
// GPU/Metal 故障,关闭语言自动检测和纠错以覆盖模型判断失败。
match recognize_once(&image, true) {
Ok(text) if !text.trim().is_empty() => Ok(text),
Ok(_) => Err("截图中未识别到文字,请框选清晰的文字区域后重试。".to_string()),
Err(error) => Err(format!("OCR 识别及兼容重试失败: {first_error}; {error}")),
}
})
}
pub(super) fn recognize_once(image: &CGImage, compatibility: bool) -> Result<String, String> {
// SAFETY: Vision API 须在持有有效图像数据期间调用;以下对象均由我们创建并在本函数内存活。
unsafe {
// 创建并配置多语言文字识别请求。
let request: Retained<VNRecognizeTextRequest> = VNRecognizeTextRequest::new();
configure_request(&request, compatibility)?;
let options = if compatibility || objc2::available!(macos = 27.0) {
let software = NSNumber::numberWithBool(true);
let values: [&AnyObject; 1] = [&software];
let context_options =
NSDictionary::from_slices(&[kCIContextUseSoftwareRenderer], &values);
let context = CIContext::contextWithOptions(Some(&context_options));
let values: [&AnyObject; 1] = [&context];
// CPU 模型选择不控制图像缩放器。显式软件 CIContext 避开
// 新系统后台/虚拟环境中不可用的硬件图像处理路径。
NSDictionary::from_slices(&[VNImageOptionCIContext], &values)
} else {
NSDictionary::new()
};
// 直接提供已规范化的 CGImage,避免 Vision 再走压缩数据解码路径。
let handler = VNImageRequestHandler::initWithCGImage_options(
VNImageRequestHandler::alloc(),
image,
&options,
);
let requests = NSArray::from_retained_slice(&[Retained::into_super(Retained::into_super(
request.clone(),
))]);
handler
.performRequests_error(&requests)
.map_err(|e| {
format!(
"OCR 执行失败: {e}(图像 {}×{},{} bit)",
CGImage::width(Some(image)),
CGImage::height(Some(image)),
CGImage::bits_per_component(Some(image)),
)
})?;
Ok(collect_text(&request))
}
}
fn configure_request(request: &VNRecognizeTextRequest, compatibility: bool) -> Result<(), String> {
request.setRecognitionLevel(VNRequestTextRecognitionLevel::Accurate);
request.setUsesLanguageCorrection(!compatibility);
// 自动检测需要 revision 3,不能只开启属性而依赖系统默认 revision。
let revision = if !compatibility && objc2::available!(macos = 13.0) {
3
} else {
2
};
// 兼容重试使用 revision 2,避开新版识别模型与图像读取器的同一失败路径。
// SAFETY: revision 2 在最低支持版本 macOS 11 可用;revision 3 经版本判断。
unsafe {
request.setRevision(revision);
request.setPreferBackgroundProcessing(true);
}
if objc2::available!(macos = 13.0) {
request.setAutomaticallyDetectsLanguage(!compatibility);
}
// 语言支持随 macOS 和 revision 变化;传入不支持的 ja-JP 等语言会使
// 整个请求失败。实例查询从 macOS 12 才存在,11 使用旧的类查询。
let supported = unsafe {
if objc2::available!(macos = 12.0) {
request.supportedRecognitionLanguagesAndReturnError()
} else {
#[allow(deprecated)]
VNRecognizeTextRequest::supportedRecognitionLanguagesForTextRecognitionLevel_revision_error(
VNRequestTextRecognitionLevel::Accurate,
revision,
)
}
}
.map_err(|error| format!("读取 OCR 支持语言失败: {error}"))?;
let supported_codes: Vec<String> = supported.iter().map(|code| code.to_string()).collect();
let languages: Vec<Retained<NSString>> = preferred_languages(&supported_codes)
.into_iter()
.map(NSString::from_str)
.collect();
if !languages.is_empty() {
request.setRecognitionLanguages(&NSArray::from_retained_slice(&languages));
}
// 新系统后台 OCR 不能依赖 ANE 自动选择。旧 usesCPUOnly 属性在新
// Vision 上不足以保证 CPU 执行,必须按模型支持的计算阶段显式选择。
if compatibility || objc2::available!(macos = 27.0) {
configure_cpu(request)?;
}
Ok(())
}
fn configure_cpu(request: &VNRecognizeTextRequest) -> Result<(), String> {
// SAFETY: 新计算设备 API 和 MLCPUComputeDevice 从 macOS 14 才可用。
unsafe {
if objc2::available!(macos = 14.0) {
let stages = request
.supportedComputeStageDevicesAndReturnError()
.map_err(|error| format!("读取 OCR 计算设备失败: {error}"))?;
let mut assigned = false;
for stage in stages.allKeys().iter() {
if let Some(devices) = stages.objectForKey(&stage) {
if let Some(cpu) = devices
.iter()
.find(|device| device.isKindOfClass(MLCPUComputeDevice::class()))
{
request.setComputeDevice_forComputeStage(Some(&cpu), &stage);
assigned = true;
}
}
}
if !assigned {
return Err("此 OCR 模型没有可用的 CPU 计算设备".to_string());
}
} else {
#[allow(deprecated)]
request.setUsesCPUOnly(true);
}
}
Ok(())
}
fn preferred_languages(supported: &[String]) -> Vec<&str> {
// Apple 的语言支持查询只说明各语言能单独识别。中文模型只能与
// 英文配对,不能混入日语、韩语或欧洲语言;自动检测负责选择脚本。
["zh-Hans", "zh-Hant", "en-US"]
.into_iter()
.filter(|code| supported.iter().any(|supported| supported.as_str() == *code))
.collect()
}
/// 从请求结果收集识别文本。
unsafe fn collect_text(request: &VNRecognizeTextRequest) -> String {
let Some(observations) = request.results() else {
return String::new();
};
let mut lines: Vec<String> = Vec::new();
for obs in observations.iter() {
let candidates = obs.topCandidates(1);
if let Some(top) = candidates.firstObject() {
lines.push(top.string().to_string());
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enables_automatic_language_detection_by_default_when_available() {
let request = VNRecognizeTextRequest::new();
configure_request(&request, false).unwrap();
if objc2::available!(macos = 13.0) {
assert!(request.automaticallyDetectsLanguage());
}
}
#[test]
fn only_configures_languages_supported_by_the_current_request() {
let request = VNRecognizeTextRequest::new();
configure_request(&request, false).unwrap();
let supported = unsafe {
if objc2::available!(macos = 12.0) {
request
.supportedRecognitionLanguagesAndReturnError()
.unwrap()
} else {
#[allow(deprecated)]
VNRecognizeTextRequest::supportedRecognitionLanguagesForTextRecognitionLevel_revision_error(
request.recognitionLevel(),
request.revision(),
)
.unwrap()
}
};
for configured in unsafe { request.recognitionLanguages() }.iter() {
assert!(supported.iter().any(|code| code == configured));
}
}
#[test]
fn compatibility_retry_disables_automatic_detection_and_correction() {
let request = VNRecognizeTextRequest::new();
configure_request(&request, true).unwrap();
assert!(!request.usesLanguageCorrection());
assert_eq!(unsafe { request.revision() }, 2);
if objc2::available!(macos = 13.0) {
assert!(!request.automaticallyDetectsLanguage());
}
}
#[test]
fn older_language_sets_exclude_unsupported_japanese() {
let languages = ["en-US".to_string(), "zh-Hans".to_string()];
assert_eq!(preferred_languages(&languages), ["zh-Hans", "en-US"]);
assert!(preferred_languages(&[]).is_empty());
}
#[test]
fn supported_languages_do_not_form_an_invalid_chinese_combination() {
let supported = ["zh-Hans", "zh-Hant", "en-US", "ja-JP", "ko-KR", "fr-FR"]
.map(String::from);
assert_eq!(preferred_languages(&supported), ["zh-Hans", "zh-Hant", "en-US"]);
}
#[test]
fn rejects_invalid_image_data_before_starting_vision() {
assert!(recognize_bytes(&[]).unwrap_err().contains("PNG"));
assert!(recognize_bytes(b"not an image").is_err());
}
}
-121
View File
@@ -1,121 +0,0 @@
//! 屏幕区域截图 —— 调用 macOS 系统 `screencapture -i` 让用户自由框选。
use std::path::Path;
use std::process::Command;
use tempfile::{NamedTempFile, TempPath};
fn screencapture_args() -> [&'static str; 3] {
["-i", "-x", "-r"]
}
/// 触发 Apple 自带的交互式区域截图,落到临时 PNG 文件。
///
/// 返回截图文件路径;用户按 Esc 取消选择时返回 `Ok(None)`。
pub fn capture_interactive() -> Result<Option<TempPath>, String> {
let target = new_capture_target()?;
let output = Command::new("/usr/sbin/screencapture")
.args(screencapture_args())
.arg(target.path())
.output()
.map_err(|e| format!("调用 screencapture 失败: {e}"))?;
let has_capture = validate_capture_output(target.path())?;
if !output.status.success() {
let detail = String::from_utf8_lossy(&output.stderr);
// 部分系统版本在 Esc 时返回非零状态,并可能删除输出文件。
if !has_capture
&& (detail.trim().is_empty() || detail.to_ascii_lowercase().contains("cancel"))
{
return Ok(None);
}
let detail: String = detail.trim().chars().take(300).collect();
return Err(format!(
"系统截图失败({}): {detail}。请检查系统设置 → 隐私与安全性 → 屏幕与系统音频录制中的 TTREE 权限,授权后重启应用。",
output.status
));
}
// NamedTempFile 预先创建了空文件;Esc 取消时它仍为空。
if !has_capture {
return Ok(None);
}
Ok(Some(target.into_temp_path()))
}
fn new_capture_target() -> Result<NamedTempFile, String> {
tempfile::Builder::new()
.prefix("ttree-shot-")
.suffix(".png")
.tempfile()
.map_err(|e| format!("创建截图临时文件失败: {e}"))
}
/// 返回 false 表示用户取消(文件为空);非普通文件一律拒绝。
fn validate_capture_output(path: &Path) -> Result<bool, String> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(format!("读取截图临时文件失败: {error}")),
};
if !metadata.file_type().is_file() {
return Err("截图输出不是普通文件".to_string());
}
Ok(metadata.len() > 0)
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn uses_native_interactive_screencapture_flags() {
assert_eq!(screencapture_args(), ["-i", "-x", "-r"]);
}
#[test]
fn capture_targets_are_random_private_regular_files() {
let first = new_capture_target().unwrap();
let second = new_capture_target().unwrap();
assert_ne!(first.path(), second.path());
assert!(first.path().is_file());
assert!(second.path().is_file());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = first.as_file().metadata().unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
}
#[test]
fn empty_capture_file_is_treated_as_cancellation() {
let target = new_capture_target().unwrap();
assert!(!validate_capture_output(target.path()).unwrap());
}
#[test]
fn deleted_capture_file_is_treated_as_cancellation() {
let target = new_capture_target().unwrap();
let path = target.path().to_path_buf();
drop(target);
assert!(!validate_capture_output(&path).unwrap());
}
#[test]
fn non_regular_capture_output_is_rejected() {
let target = new_capture_target().unwrap();
let path = target.path().to_path_buf();
drop(target);
fs::create_dir(&path).unwrap();
let result = validate_capture_output(&path);
fs::remove_dir(&path).unwrap();
assert!(result.is_err());
}
}
-212
View File
@@ -1,212 +0,0 @@
//! macOS 选中文本捕获实现。
use std::ptr;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use core_foundation::base::{Boolean, CFRelease, CFTypeRef, TCFType};
use core_foundation::string::{CFString, CFStringRef};
use objc2_app_kit::{NSPasteboard, NSPasteboardType, NSPasteboardTypeString};
use objc2_core_graphics::{CGEvent, CGEventFlags, CGEventTapLocation};
use objc2_foundation::{NSData, NSInteger, NSString};
use super::normalize_selected_text;
const AX_ERROR_SUCCESS: i32 = 0;
const COPY_KEY_CODE: u16 = 8;
const COPY_TIMEOUT: Duration = Duration::from_millis(450);
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(20);
#[repr(C)]
struct __AXUIElement {
_private: [u8; 0],
}
type AXUIElementRef = *const __AXUIElement;
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> Boolean;
fn AXUIElementCreateSystemWide() -> AXUIElementRef;
fn AXUIElementCopyAttributeValue(
element: AXUIElementRef,
attribute: CFStringRef,
value: *mut CFTypeRef,
) -> i32;
}
#[derive(Debug, Clone)]
struct PasteboardSnapshot {
entries: Vec<PasteboardEntry>,
}
#[derive(Debug, Clone)]
struct PasteboardEntry {
type_name: String,
data: Vec<u8>,
}
pub(crate) fn capture_selected_text() -> Result<Option<String>> {
if let Some(text) = capture_accessibility_selected_text()? {
return Ok(Some(text));
}
capture_clipboard_selected_text()
}
fn capture_accessibility_selected_text() -> Result<Option<String>> {
// SAFETY: We only call documented ApplicationServices Accessibility APIs. All returned
// CoreFoundation objects that follow the Create/Copy rule are released or wrapped exactly once.
unsafe {
if AXIsProcessTrusted() == 0 {
return Ok(None);
}
let system_element = AXUIElementCreateSystemWide();
if system_element.is_null() {
return Ok(None);
}
let focused_attr = CFString::new("AXFocusedUIElement");
let mut focused_value: CFTypeRef = ptr::null();
let focused_error = AXUIElementCopyAttributeValue(
system_element,
focused_attr.as_concrete_TypeRef(),
&mut focused_value,
);
CFRelease(system_element as CFTypeRef);
if focused_error != AX_ERROR_SUCCESS || focused_value.is_null() {
return Ok(None);
}
let selected_attr = CFString::new("AXSelectedText");
let mut selected_value: CFTypeRef = ptr::null();
let selected_error = AXUIElementCopyAttributeValue(
focused_value as AXUIElementRef,
selected_attr.as_concrete_TypeRef(),
&mut selected_value,
);
CFRelease(focused_value);
if selected_error != AX_ERROR_SUCCESS || selected_value.is_null() {
return Ok(None);
}
let selected = CFString::wrap_under_create_rule(selected_value as CFStringRef).to_string();
Ok(normalize_selected_text(selected))
}
}
fn capture_clipboard_selected_text() -> Result<Option<String>> {
let pasteboard = NSPasteboard::generalPasteboard();
let snapshot = snapshot_pasteboard(&pasteboard);
let before_copy = pasteboard.changeCount();
post_copy_keypress()?;
let Some(copy_change_count) = wait_for_pasteboard_change(&pasteboard, before_copy) else {
return Ok(None);
};
let copied_text = read_plain_text(&pasteboard).and_then(normalize_selected_text);
if should_restore_pasteboard(pasteboard.changeCount(), copy_change_count) {
restore_pasteboard(&pasteboard, &snapshot);
}
Ok(copied_text)
}
fn snapshot_pasteboard(pasteboard: &NSPasteboard) -> PasteboardSnapshot {
let entries = pasteboard
.types()
.map(|types| {
types
.to_vec()
.into_iter()
.filter_map(|type_name| {
pasteboard
.dataForType(&type_name)
.map(|data| PasteboardEntry {
type_name: type_name.to_string(),
data: data.to_vec(),
})
})
.collect()
})
.unwrap_or_default();
PasteboardSnapshot { entries }
}
fn restore_pasteboard(pasteboard: &NSPasteboard, snapshot: &PasteboardSnapshot) {
let _ = pasteboard.clearContents();
for entry in &snapshot.entries {
let type_name = NSString::from_str(&entry.type_name);
let data = NSData::with_bytes(&entry.data);
let _ = pasteboard.setData_forType(Some(&data), &type_name);
}
}
fn should_restore_pasteboard(
current_change_count: NSInteger,
copy_change_count: NSInteger,
) -> bool {
current_change_count == copy_change_count
}
fn wait_for_pasteboard_change(
pasteboard: &NSPasteboard,
before_copy: NSInteger,
) -> Option<NSInteger> {
let deadline = Instant::now() + COPY_TIMEOUT;
while Instant::now() < deadline {
let current = pasteboard.changeCount();
if current != before_copy {
return Some(current);
}
thread::sleep(COPY_POLL_INTERVAL);
}
None
}
fn read_plain_text(pasteboard: &NSPasteboard) -> Option<String> {
pasteboard
.stringForType(string_pasteboard_type())
.map(|text| text.to_string())
}
fn string_pasteboard_type() -> &'static NSPasteboardType {
// SAFETY: NSPasteboardTypeString is an AppKit constant with static lifetime.
unsafe { NSPasteboardTypeString }
}
fn post_copy_keypress() -> Result<()> {
let key_down = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, true)
.ok_or_else(|| anyhow!("创建复制按下事件失败"))?;
let key_up = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, false)
.ok_or_else(|| anyhow!("创建复制释放事件失败"))?;
CGEvent::set_flags(Some(&key_down), CGEventFlags::MaskCommand);
CGEvent::set_flags(Some(&key_up), CGEventFlags::MaskCommand);
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down));
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pasteboard_restore_requires_unchanged_copy_count() {
assert!(should_restore_pasteboard(42, 42));
assert!(!should_restore_pasteboard(43, 42));
}
}
-42
View File
@@ -1,42 +0,0 @@
//! 系统选中文本捕获。
//!
//! macOS 上优先通过 Accessibility 读取当前焦点控件的选中文本,失败后回退到
//! 临时 Cmd+C + 剪贴板读取。非 macOS 平台返回空结果。
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
pub(crate) use macos::capture_selected_text;
#[cfg(not(target_os = "macos"))]
pub(crate) fn capture_selected_text() -> anyhow::Result<Option<String>> {
Ok(None)
}
pub(crate) fn normalize_selected_text(text: impl AsRef<str>) -> Option<String> {
let trimmed = text.as_ref().trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_blank_text_to_none() {
assert_eq!(normalize_selected_text(" \n\t "), None);
}
#[test]
fn trims_non_empty_selected_text() {
assert_eq!(
normalize_selected_text(" hello "),
Some("hello".to_string())
);
}
}
-682
View File
@@ -1,682 +0,0 @@
//! 全局快捷键注册与分发。
//!
//! - 呼出快捷键:toggle 主窗口可见性
//! - 截图 OCR 快捷键:广播 `trigger-ocr`,前端调用截图/OCR/翻译流程
//! - AI 对话快捷键:呼出 AI 对话窗口
//! - 划词翻译快捷键:捕获选中文本并自动翻译
//! - 划词 AI 对话快捷键:捕获选中文本并发送给 AI
//!
//! 所有快捷键均可选;空字符串表示未启用。
use std::sync::atomic::{AtomicBool, Ordering};
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tauri_plugin_global_shortcut::{
Builder as ShortcutBuilder, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState,
};
use crate::config::{self, ShortcutConfig};
use crate::selection;
use crate::window;
static IS_RECORDING_SHORTCUT: AtomicBool = AtomicBool::new(false);
const MAX_SHORTCUT_SPEC_BYTES: usize = 128;
#[derive(Debug, Clone, Default)]
struct ParsedShortcuts {
toggle: Option<Shortcut>,
ocr: Option<Shortcut>,
ai_dialog: Option<Shortcut>,
selection_translate: Option<Shortcut>,
selection_ai_dialog: Option<Shortcut>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShortcutAction {
Toggle,
Ocr,
AiDialog,
SelectionTranslate,
SelectionAiDialog,
}
#[derive(Debug, Clone, Serialize)]
struct SelectedTextPayload {
text: String,
}
#[derive(Debug, Clone, Serialize)]
struct AiDialogPayload {
text: Option<String>,
}
impl ParsedShortcuts {
/// 分发优先级顺序:划词动作排在对应的纯呼出动作之前,
/// 这样当两者共用同一快捷键时,按更丰富的划词行为执行。
fn entries(&self) -> [(ShortcutAction, &Option<Shortcut>); 5] {
[
(
ShortcutAction::SelectionTranslate,
&self.selection_translate,
),
(ShortcutAction::SelectionAiDialog, &self.selection_ai_dialog),
(ShortcutAction::Toggle, &self.toggle),
(ShortcutAction::Ocr, &self.ocr),
(ShortcutAction::AiDialog, &self.ai_dialog),
]
}
/// 需要注册的快捷键集合,去重(共用同一键的只注册一次)。
fn shortcuts(&self) -> Vec<Shortcut> {
let mut out: Vec<Shortcut> = Vec::new();
for (_, shortcut) in self.entries() {
if let Some(shortcut) = shortcut {
if !out.contains(shortcut) {
out.push(*shortcut);
}
}
}
out
}
fn action_for(&self, shortcut: &Shortcut) -> Option<ShortcutAction> {
self.entries()
.into_iter()
.find(|(_, candidate)| candidate.as_ref() == Some(shortcut))
.map(|(action, _)| action)
}
/// 校验快捷键不冲突。允许两组共用同一键:
/// 呼出翻译↔划词翻译、呼出 AI 对话↔划词 AI 对话;其余两两不得相同。
fn reject_disallowed_duplicates(&self) -> Result<(), anyhow::Error> {
let fields: [(&str, &Option<Shortcut>); 5] = [
("呼出翻译快捷键", &self.toggle),
("截图 OCR 快捷键", &self.ocr),
("呼出 AI 对话快捷键", &self.ai_dialog),
("划词翻译快捷键", &self.selection_translate),
("划词 AI 对话快捷键", &self.selection_ai_dialog),
];
for (i, (left_label, left)) in fields.iter().enumerate() {
let Some(left) = left else { continue };
for (right_label, right) in fields.iter().skip(i + 1) {
let Some(right) = right else { continue };
if left == right && !is_allowed_shared_pair(left_label, right_label) {
return Err(anyhow::anyhow!("{left_label}和{right_label}不能相同"));
}
}
}
Ok(())
}
}
/// 这两组快捷键允许共用同一键(划词动作叠加在纯呼出动作上)。
fn is_allowed_shared_pair(a: &str, b: &str) -> bool {
const ALLOWED: [(&str, &str); 2] = [
("呼出翻译快捷键", "划词翻译快捷键"),
("呼出 AI 对话快捷键", "划词 AI 对话快捷键"),
];
ALLOWED
.iter()
.any(|&(x, y)| (a == x && b == y) || (a == y && b == x))
}
/// 进入/退出快捷键录制。
///
/// 录制期间取消注册所有全局快捷键,否则与已注册键相同的按键会被 OS 级全局热键
/// 拦截而无法作为普通 keydown 事件传到前端(表现为“按相同键没反应”)。
/// 退出录制时按已保存配置重新注册。
pub fn set_recording_active<R: tauri::Runtime>(
app: &AppHandle<R>,
is_active: bool,
) -> Result<(), String> {
let operation = if is_active {
app.global_shortcut()
.unregister_all()
.map_err(|e| format!("暂停全局快捷键失败: {e}"))
} else {
let saved = config::load_shortcuts(app);
apply_shortcuts(app, &saved).map_err(|e| format!("恢复全局快捷键失败: {e}"))
};
transition_recording_state(&IS_RECORDING_SHORTCUT, is_active, operation)
}
fn transition_recording_state<E>(
state: &AtomicBool,
is_active: bool,
operation: Result<(), E>,
) -> Result<(), E> {
operation?;
state.store(is_active, Ordering::SeqCst);
Ok(())
}
/// 构建 global-shortcut 插件,带统一处理器。
pub fn build_plugin<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
ShortcutBuilder::<R>::new()
.with_handler(move |app, shortcut, event| {
if event.state() != ShortcutState::Pressed {
return;
}
handle_shortcut(app, shortcut);
})
.build()
}
fn handle_shortcut<R: tauri::Runtime>(app: &AppHandle<R>, shortcut: &Shortcut) {
if IS_RECORDING_SHORTCUT.load(Ordering::SeqCst) {
return;
}
let cfg = config::load_shortcuts(app);
let Ok(shortcuts) = parse_shortcut_config(&cfg) else {
return;
};
match shortcuts.action_for(shortcut) {
Some(ShortcutAction::Toggle) => {
// 仅在切换为显示时复位到翻译页。
if window::toggle_window(app) {
let _ = app.emit("trigger-translate", ());
}
}
Some(ShortcutAction::Ocr) => {
let _ = app.emit("trigger-ocr", ());
}
Some(ShortcutAction::AiDialog) => handle_ai_dialog_shortcut(app),
Some(ShortcutAction::SelectionTranslate) => handle_selection_translate_shortcut(app),
Some(ShortcutAction::SelectionAiDialog) => handle_selection_ai_dialog_shortcut(app),
None => {}
}
}
/// 划词翻译:有选中文本则翻译,否则仅 toggle 窗口。
fn handle_selection_translate_shortcut<R: tauri::Runtime>(app: &AppHandle<R>) {
match selection::capture_selected_text() {
Ok(Some(text)) => {
window::show_main(app);
let _ = app.emit("trigger-selected-translate", SelectedTextPayload { text });
}
Ok(None) | Err(_) => {
window::toggle_window(app);
}
}
}
/// 呼出 AI 对话窗口(不捕获划词)。
fn handle_ai_dialog_shortcut<R: tauri::Runtime>(app: &AppHandle<R>) {
window::show_main(app);
let _ = app.emit("trigger-ai-dialog", AiDialogPayload { text: None });
}
/// 划词 AI 对话:有选中文本则连同原文发送给 AI,否则仅呼出对话窗口。
fn handle_selection_ai_dialog_shortcut<R: tauri::Runtime>(app: &AppHandle<R>) {
let text = selection::capture_selected_text().ok().flatten();
window::show_main(app);
let _ = app.emit("trigger-ai-dialog", AiDialogPayload { text });
}
/// 注册持久化配置中的快捷键。在 setup 中调用。
pub fn register_saved<R: tauri::Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
let saved = config::load_shortcuts(app);
if parse_shortcut_config(&saved).is_ok() && apply_shortcuts(app, &saved).is_ok() {
return Ok(());
}
let defaults = ShortcutConfig::default();
if apply_shortcuts(app, &defaults).is_ok() {
let _ = config::save_shortcuts(app, &defaults);
}
Ok(())
}
/// 验证并应用快捷键配置。保存设置时调用,立即生效。
pub fn apply_shortcuts<R: tauri::Runtime>(
app: &AppHandle<R>,
cfg: &ShortcutConfig,
) -> Result<(), anyhow::Error> {
let shortcuts = parse_shortcut_config(cfg)?;
let previous = parse_shortcut_config(&config::load_shortcuts(app)).ok();
let gs = app.global_shortcut();
gs.unregister_all()?;
for shortcut in shortcuts.shortcuts() {
if let Err(error) = gs.register(shortcut) {
let _ = gs.unregister_all();
restore_shortcuts(app, previous);
return Err(error.into());
}
}
Ok(())
}
fn restore_shortcuts<R: tauri::Runtime>(app: &AppHandle<R>, previous: Option<ParsedShortcuts>) {
let Some(previous) = previous else {
return;
};
let gs = app.global_shortcut();
for shortcut in previous.shortcuts() {
let _ = gs.register(shortcut);
}
}
fn parse_shortcut_config(cfg: &ShortcutConfig) -> Result<ParsedShortcuts, anyhow::Error> {
let toggle = parse_optional_shortcut("呼出翻译快捷键", &cfg.toggle)?;
let ocr = parse_optional_shortcut("截图 OCR 快捷键", &cfg.ocr)?;
let ai_dialog = parse_optional_shortcut("呼出 AI 对话快捷键", &cfg.ai_dialog)?;
let selection_translate = parse_optional_shortcut("划词翻译快捷键", &cfg.selection_translate)?;
let selection_ai_dialog =
parse_optional_shortcut("划词 AI 对话快捷键", &cfg.selection_ai_dialog)?;
let parsed = ParsedShortcuts {
toggle,
ocr,
ai_dialog,
selection_translate,
selection_ai_dialog,
};
parsed.reject_disallowed_duplicates()?;
Ok(parsed)
}
fn parse_required_shortcut(label: &str, spec: &str) -> Result<Shortcut, anyhow::Error> {
let shortcut = spec
.trim()
.parse::<Shortcut>()
.map_err(|e| anyhow::anyhow!("{label}无效: {e}"))?;
if !shortcut
.mods
.intersects(Modifiers::SUPER | Modifiers::CONTROL | Modifiers::ALT)
{
return Err(anyhow::anyhow!(
"{label}必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"
));
}
Ok(shortcut)
}
fn parse_optional_shortcut(label: &str, spec: &str) -> Result<Option<Shortcut>, anyhow::Error> {
if spec.len() > MAX_SHORTCUT_SPEC_BYTES {
return Err(anyhow::anyhow!(
"{label}超出 {MAX_SHORTCUT_SPEC_BYTES} 字节上限"
));
}
if spec.trim().is_empty() {
return Ok(None);
}
parse_required_shortcut(label, spec).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
fn shortcut_config(toggle: &str, ocr: &str, ai_dialog: &str) -> ShortcutConfig {
ShortcutConfig {
toggle: toggle.to_string(),
ocr: ocr.to_string(),
ai_dialog: ai_dialog.to_string(),
selection_translate: String::new(),
selection_ai_dialog: String::new(),
}
}
#[test]
fn accepts_default_shortcuts_with_unset_ai_dialog() {
let result = parse_shortcut_config(&shortcut_config(
"CmdOrCtrl+Shift+Space",
"CmdOrCtrl+Shift+S",
"",
));
assert!(result.is_ok());
assert!(result.unwrap().ai_dialog.is_none());
}
#[test]
fn accepts_configured_ai_dialog_shortcut() {
let result = parse_shortcut_config(&shortcut_config(
"CmdOrCtrl+Shift+Space",
"CmdOrCtrl+Shift+S",
"CmdOrCtrl+Shift+A",
));
assert!(result.is_ok());
assert!(result.unwrap().ai_dialog.is_some());
}
#[test]
fn accepts_all_empty_shortcuts() {
let result = parse_shortcut_config(&shortcut_config("", "", ""));
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(parsed.toggle.is_none());
assert!(parsed.ocr.is_none());
}
#[test]
fn accepts_selection_shortcuts() {
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: String::new(),
selection_translate: "CmdOrCtrl+Shift+T".to_string(),
selection_ai_dialog: "CmdOrCtrl+Shift+D".to_string(),
};
let result = parse_shortcut_config(&cfg);
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(parsed.selection_translate.is_some());
assert!(parsed.selection_ai_dialog.is_some());
}
#[test]
fn accepts_option_space_with_modifier() {
let result =
parse_shortcut_config(&shortcut_config("Option+Space", "CmdOrCtrl+Shift+S", ""));
assert!(result.is_ok());
}
#[test]
fn treats_whitespace_ai_dialog_as_unset() {
let result = parse_shortcut_config(&shortcut_config(
"CmdOrCtrl+Shift+Space",
"CmdOrCtrl+Shift+S",
" ",
));
assert!(result.is_ok());
assert!(result.unwrap().ai_dialog.is_none());
}
#[test]
fn rejects_toggle_without_modifier() {
let result = parse_shortcut_config(&shortcut_config("Space", "CmdOrCtrl+Shift+S", ""));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("呼出翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
}
#[test]
fn rejects_ocr_without_modifier() {
let result = parse_shortcut_config(&shortcut_config("CmdOrCtrl+Shift+Space", "S", ""));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("截图 OCR 快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
}
#[test]
fn rejects_ai_dialog_without_modifier() {
let result = parse_shortcut_config(&shortcut_config(
"CmdOrCtrl+Shift+Space",
"CmdOrCtrl+Shift+S",
"A",
));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("呼出 AI 对话快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
}
#[test]
fn rejects_selection_translate_without_modifier() {
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: String::new(),
selection_translate: "T".to_string(),
selection_ai_dialog: String::new(),
};
let result = parse_shortcut_config(&cfg);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("划词翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
}
#[test]
fn rejects_shift_only_shortcut() {
let result = parse_shortcut_config(&shortcut_config("Shift+S", "CmdOrCtrl+Shift+S", ""));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("呼出翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
}
#[test]
fn rejects_modifier_only_shortcut() {
let result = parse_shortcut_config(&shortcut_config("Shift", "CmdOrCtrl+Shift+S", ""));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("呼出翻译快捷键无效"));
}
#[test]
fn rejects_duplicate_toggle_and_ocr_shortcuts() {
let result = parse_shortcut_config(&shortcut_config(
"CmdOrCtrl+Shift+Space",
"CmdOrCtrl+Shift+Space",
"",
));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("呼出翻译快捷键和截图 OCR 快捷键不能相同"));
}
#[test]
fn rejects_duplicate_toggle_and_ai_dialog_shortcuts() {
let result = parse_shortcut_config(&shortcut_config(
"CmdOrCtrl+Shift+Space",
"CmdOrCtrl+Shift+S",
"CmdOrCtrl+Shift+Space",
));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("呼出翻译快捷键和呼出 AI 对话快捷键不能相同"));
}
#[test]
fn rejects_duplicate_ocr_and_ai_dialog_shortcuts() {
let result = parse_shortcut_config(&shortcut_config(
"CmdOrCtrl+Shift+Space",
"CmdOrCtrl+Shift+S",
"CmdOrCtrl+Shift+S",
));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("截图 OCR 快捷键和呼出 AI 对话快捷键不能相同"));
}
#[test]
fn rejects_duplicate_selection_shortcuts() {
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: String::new(),
selection_translate: "CmdOrCtrl+Shift+T".to_string(),
selection_ai_dialog: "CmdOrCtrl+Shift+T".to_string(),
};
let result = parse_shortcut_config(&cfg);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("划词翻译快捷键和划词 AI 对话快捷键不能相同"));
}
#[test]
fn allows_shared_toggle_and_selection_translate() {
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: String::new(),
selection_translate: "CmdOrCtrl+Shift+Space".to_string(),
selection_ai_dialog: String::new(),
};
let result = parse_shortcut_config(&cfg);
assert!(result.is_ok());
// 共用同一键时只注册一次。
assert_eq!(result.unwrap().shortcuts().len(), 2);
}
#[test]
fn allows_shared_ai_dialog_and_selection_ai_dialog() {
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
selection_translate: String::new(),
selection_ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
};
let result = parse_shortcut_config(&cfg);
assert!(result.is_ok());
assert_eq!(result.unwrap().shortcuts().len(), 3);
}
#[test]
fn shared_translate_key_dispatches_to_selection_action() {
// 呼出翻译与划词翻译共用同一键时,划词动作优先。
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: String::new(),
selection_translate: "CmdOrCtrl+Shift+Space".to_string(),
selection_ai_dialog: String::new(),
};
let shortcuts = parse_shortcut_config(&cfg).unwrap();
assert_eq!(
shortcuts.action_for(&"CmdOrCtrl+Shift+Space".parse().unwrap()),
Some(ShortcutAction::SelectionTranslate)
);
}
#[test]
fn shared_ai_key_dispatches_to_selection_action() {
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
selection_translate: String::new(),
selection_ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
};
let shortcuts = parse_shortcut_config(&cfg).unwrap();
assert_eq!(
shortcuts.action_for(&"CmdOrCtrl+Shift+A".parse().unwrap()),
Some(ShortcutAction::SelectionAiDialog)
);
}
#[test]
fn rejects_cross_group_shared_key() {
// 呼出翻译与划词 AI 对话不属于允许共用的组合。
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: String::new(),
selection_translate: String::new(),
selection_ai_dialog: "CmdOrCtrl+Shift+Space".to_string(),
};
let result = parse_shortcut_config(&cfg);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("不能相同"));
}
#[test]
fn resolves_known_shortcut_actions() {
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
selection_translate: "CmdOrCtrl+Shift+T".to_string(),
selection_ai_dialog: "CmdOrCtrl+Shift+D".to_string(),
};
let shortcuts = parse_shortcut_config(&cfg).unwrap();
assert_eq!(
shortcuts.action_for(&"CmdOrCtrl+Shift+Space".parse().unwrap()),
Some(ShortcutAction::Toggle)
);
assert_eq!(
shortcuts.action_for(&"CmdOrCtrl+Shift+S".parse().unwrap()),
Some(ShortcutAction::Ocr)
);
assert_eq!(
shortcuts.action_for(&"CmdOrCtrl+Shift+A".parse().unwrap()),
Some(ShortcutAction::AiDialog)
);
assert_eq!(
shortcuts.action_for(&"CmdOrCtrl+Shift+T".parse().unwrap()),
Some(ShortcutAction::SelectionTranslate)
);
assert_eq!(
shortcuts.action_for(&"CmdOrCtrl+Shift+D".parse().unwrap()),
Some(ShortcutAction::SelectionAiDialog)
);
assert_eq!(
shortcuts.action_for(&"CmdOrCtrl+Shift+X".parse().unwrap()),
None
);
}
#[test]
fn recording_state_changes_only_after_registration_operation_succeeds() {
let state = AtomicBool::new(false);
let failed: Result<(), &str> = transition_recording_state(&state, true, Err("failed"));
assert_eq!(failed, Err("failed"));
assert!(!state.load(Ordering::SeqCst));
transition_recording_state(&state, true, Ok::<(), &str>(())).unwrap();
assert!(state.load(Ordering::SeqCst));
}
#[test]
fn rejects_oversized_shortcut_specs_before_parsing() {
let error = parse_optional_shortcut("测试快捷键", &"x".repeat(129)).unwrap_err();
assert!(error.to_string().contains("128 字节"));
}
}
-290
View File
@@ -1,290 +0,0 @@
//! 免费翻译自动分流:健康服务轮询,失败切换,故障服务指数退避。
use std::future::Future;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use super::{bing, google};
const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(20);
const BASE_COOLDOWN: Duration = Duration::from_secs(30);
const MAX_COOLDOWN: Duration = Duration::from_secs(300);
// Bing 网页端限制较小;同时避免 Google GET 的 URL 随长文本膨胀。
const CHUNK_UTF16_LIMIT: usize = 1_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Provider {
Google,
Bing,
}
impl Provider {
fn index(self) -> usize {
match self {
Self::Google => 0,
Self::Bing => 1,
}
}
}
#[derive(Default)]
struct Health {
failures: u32,
retry_after: Option<Instant>,
}
#[derive(Default)]
struct Pool {
health: [Health; 2],
next: usize,
}
impl Pool {
fn candidates(&mut self, now: Instant) -> Vec<Provider> {
let providers = [Provider::Google, Provider::Bing];
let start = self.next;
self.next = (self.next + 1) % providers.len();
(0..providers.len())
.map(|offset| providers[(start + offset) % providers.len()])
.filter(|provider| self.available(*provider, now))
.collect()
}
fn available(&self, provider: Provider, now: Instant) -> bool {
self.health[provider.index()]
.retry_after
.is_none_or(|retry_after| now >= retry_after)
}
fn succeeded(&mut self, provider: Provider) {
self.health[provider.index()] = Health::default();
}
fn failed(&mut self, provider: Provider, now: Instant) {
let health = &mut self.health[provider.index()];
health.failures = health.failures.saturating_add(1);
let multiplier = 1 << health.failures.saturating_sub(1).min(4);
let cooldown = (BASE_COOLDOWN * multiplier).min(MAX_COOLDOWN);
health.retry_after = Some(now + cooldown);
}
}
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
static POOL: OnceLock<Mutex<Pool>> = OnceLock::new();
let pool = POOL.get_or_init(|| Mutex::new(Pool::default()));
let chunks = split_text(text);
let timeout = ATTEMPT_TIMEOUT
.saturating_mul(chunks.len() as u32)
.min(Duration::from_secs(120));
let chunks = &chunks;
translate_with(pool, timeout, |provider| async move {
let mut output = String::new();
for &chunk in chunks {
let content = chunk.trim();
if content.is_empty() {
output.push_str(chunk);
continue;
}
let translated = match provider {
Provider::Google => google::translate(content, source, target).await?,
Provider::Bing => bing::translate(content, source, target).await?,
};
// 保留分块边界上的空白和换行,避免多段译文粘在一起。
output.push_str(&chunk[..chunk.len() - chunk.trim_start().len()]);
output.push_str(translated.trim());
output.push_str(&chunk[chunk.trim_end().len()..]);
}
Ok(output)
})
.await
}
async fn translate_with<F, Fut>(
pool: &Mutex<Pool>,
timeout: Duration,
mut call: F,
) -> Result<String, String>
where
F: FnMut(Provider) -> Fut,
Fut: Future<Output = Result<String, String>>,
{
let candidates = pool
.lock()
.map_err(|_| "翻译服务状态不可用".to_string())?
.candidates(Instant::now());
for provider in candidates {
// 另一个并发请求可能已将此服务熔断;不在锁内等待网络。
if !pool
.lock()
.map_err(|_| "翻译服务状态不可用".to_string())?
.available(provider, Instant::now())
{
continue;
}
let result = tokio::time::timeout(timeout, call(provider))
.await
.unwrap_or_else(|_| Err("翻译服务请求超时".to_string()))
.and_then(|text| {
if text.trim().is_empty() {
Err("翻译服务未返回译文".to_string())
} else {
Ok(text)
}
});
let mut state = pool
.lock()
.map_err(|_| "翻译服务状态不可用".to_string())?;
match result {
Ok(text) => {
state.succeeded(provider);
return Ok(text);
}
Err(error) => {
state.failed(provider, Instant::now());
eprintln!("免费翻译服务 {provider:?} 失败,暂时停用: {error}");
}
}
}
Err("翻译服务暂时不可用,已自动尝试其他服务,请稍后重试。".to_string())
}
fn split_text(mut text: &str) -> Vec<&str> {
let mut chunks = Vec::new();
while !text.is_empty() {
let mut units = 0;
let mut end = text.len();
let mut boundary = None;
for (index, ch) in text.char_indices() {
units += ch.len_utf16();
if units > CHUNK_UTF16_LIMIT {
end = boundary.unwrap_or(index);
break;
}
if units >= CHUNK_UTF16_LIMIT / 2
&& (ch.is_whitespace() || matches!(ch, '.' | '!' | '?' | '。' | '!' | '?'))
{
boundary = Some(index + ch.len_utf8());
}
}
let (chunk, remaining) = text.split_at(end);
chunks.push(chunk);
text = remaining;
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn distributes_successful_requests_between_providers() {
let pool = Mutex::new(Pool::default());
let mut called = Vec::new();
for _ in 0..4 {
translate_with(&pool, ATTEMPT_TIMEOUT, |provider| {
called.push(provider);
async { Ok("译文".to_string()) }
})
.await
.unwrap();
}
assert_eq!(
called,
[Provider::Google, Provider::Bing, Provider::Google, Provider::Bing]
);
}
#[tokio::test]
async fn fails_over_and_skips_a_service_during_cooldown() {
let pool = Mutex::new(Pool::default());
let mut called = Vec::new();
for _ in 0..3 {
let result = translate_with(&pool, ATTEMPT_TIMEOUT, |provider| {
called.push(provider);
async move {
match provider {
Provider::Google => Err("429 Too Many Requests".to_string()),
Provider::Bing => Ok("备用译文".to_string()),
}
}
})
.await;
assert_eq!(result.unwrap(), "备用译文");
}
assert_eq!(
called,
[Provider::Google, Provider::Bing, Provider::Bing, Provider::Bing]
);
}
#[tokio::test]
async fn empty_results_trigger_failover() {
let pool = Mutex::new(Pool::default());
let result = translate_with(&pool, ATTEMPT_TIMEOUT, |provider| async move {
Ok(if provider == Provider::Google {
" "
} else {
"备用译文"
}
.to_string())
})
.await;
assert_eq!(result.unwrap(), "备用译文");
}
#[tokio::test]
async fn timeout_triggers_failover() {
let pool = Mutex::new(Pool::default());
let result = translate_with(&pool, Duration::from_millis(10), |provider| async move {
if provider == Provider::Google {
std::future::pending::<()>().await;
}
Ok("备用译文".to_string())
})
.await;
assert_eq!(result.unwrap(), "备用译文");
}
#[tokio::test]
async fn stops_requesting_when_all_providers_are_cooling_down() {
let pool = Mutex::new(Pool::default());
let mut count = 0;
for _ in 0..2 {
assert!(translate_with(&pool, ATTEMPT_TIMEOUT, |_| {
count += 1;
async { Err("offline".to_string()) }
})
.await
.is_err());
}
assert_eq!(count, 2);
}
#[test]
fn recovers_after_cooldown_and_resets_backoff_on_success() {
let mut pool = Pool::default();
let now = Instant::now();
pool.failed(Provider::Google, now);
assert!(!pool.available(Provider::Google, now));
assert!(pool.available(Provider::Google, now + BASE_COOLDOWN));
pool.failed(Provider::Google, now + BASE_COOLDOWN);
assert!(!pool.available(Provider::Google, now + BASE_COOLDOWN * 2));
pool.succeeded(Provider::Google);
assert!(pool.available(Provider::Google, now));
assert_eq!(pool.health[0].failures, 0);
}
#[test]
fn chunks_unicode_within_service_limits_without_losing_text() {
let text = format!("{}\n{}", "截图😀中文 ".repeat(500), "long text ".repeat(300));
let chunks = split_text(&text);
assert!(chunks.len() > 1);
assert!(chunks
.iter()
.all(|chunk| chunk.encode_utf16().count() <= CHUNK_UTF16_LIMIT));
assert_eq!(chunks.concat(), text);
assert_eq!(split_text("short text"), ["short text"]);
}
}
-211
View File
@@ -1,211 +0,0 @@
//! 必应翻译 —— 免费网页端接口(bing.com/ttranslatev3)。
//!
//! 注意:这是非官方端点。需要先抓取翻译页拿到一次性令牌(IG / IID / key / token),
//! 再带令牌 POST 翻译文本。可能随时失效或被限流。
use std::time::Duration;
use serde_json::Value;
use super::{
read_error_body, read_limited_response, shared_client, MAX_BING_PAGE_BYTES,
MAX_TRANSLATION_RESPONSE_BYTES,
};
const TRANSLATOR_URL: &str = "https://www.bing.com/translator";
const TRANSLATE_URL: &str = "https://www.bing.com/ttranslatev3";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
/// 翻译页里抓取的一次性令牌。
struct BingToken {
ig: String,
iid: String,
key: String,
token: String,
}
/// 把内部语言代码映射成必应使用的代码。
fn map_lang(code: &str) -> &str {
match code {
"auto" => "auto-detect",
"zh-CN" => "zh-Hans",
"zh-TW" => "zh-Hant",
other => other,
}
}
/// 调用必应免费端接口,返回完整译文。
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
let client = shared_client(REQUEST_TIMEOUT, true);
let token = fetch_token(&client).await?;
let url = format!(
"{TRANSLATE_URL}?isVertical=1&IG={ig}&IID={iid}",
ig = urlencoding::encode(&token.ig),
iid = urlencoding::encode(&token.iid),
);
let resp = client
.post(&url)
.header("User-Agent", USER_AGENT)
.header("Referer", TRANSLATOR_URL)
.form(&[
("fromLang", map_lang(source)),
("to", map_lang(target)),
("text", text),
("token", &token.token),
("key", &token.key),
])
.send()
.await
.map_err(|e| format!("必应翻译请求失败: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = read_error_body(resp).await;
return Err(format!("必应翻译返回状态 {status}: {body}"));
}
let body = read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "必应翻译响应").await?;
let body: Value =
serde_json::from_slice(&body).map_err(|e| format!("必应翻译响应解析失败: {e}"))?;
parse_response(&body)
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| "必应翻译未返回译文".to_string())
}
/// 抓取翻译页并解析出一次性令牌。
async fn fetch_token(client: &reqwest::Client) -> Result<BingToken, String> {
let response = client
.get(TRANSLATOR_URL)
.header("User-Agent", USER_AGENT)
.send()
.await
.map_err(|e| format!("必应翻译页请求失败: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = read_error_body(response).await;
return Err(format!("必应翻译页返回状态 {status}: {body}"));
}
let html = read_limited_response(response, MAX_BING_PAGE_BYTES, "必应翻译页").await?;
let html = std::str::from_utf8(&html).map_err(|e| format!("必应翻译页不是 UTF-8: {e}"))?;
parse_token(html).ok_or_else(|| "必应翻译令牌解析失败(端点可能已变更)".to_string())
}
/// 从翻译页 HTML 解析 IG / IID / key / token。
fn parse_token(html: &str) -> Option<BingToken> {
let ig = extract_between(html, "IG:\"", "\"")?;
// 页内其他控件也有 data-iid,优先寻找翻译器自己的 IID。
let iid = format!(
"translator.{}",
extract_between(html, "data-iid=\"translator.", "\"")?
);
// params_AbusePreventionHelper = [key,"token",time];
let assignment = html
.split_once("params_AbusePreventionHelper")?
.1
.trim_start();
let assignment = assignment.strip_prefix('=')?.trim_start();
let helper = assignment.strip_prefix('[')?.split_once(']')?.0;
let parts: Vec<Value> = serde_json::from_str(&format!("[{helper}]")).ok()?;
let key = parts.first()?.as_u64()?.to_string();
let token = parts.get(1)?.as_str()?.to_string();
if key.is_empty() || token.is_empty() {
return None;
}
Some(BingToken {
ig,
iid,
key,
token,
})
}
/// 取 `start` 与其后第一个 `end` 之间的子串。
fn extract_between(haystack: &str, start: &str, end: &str) -> Option<String> {
let begin = haystack.find(start)? + start.len();
let rest = &haystack[begin..];
let stop = rest.find(end)?;
Some(rest[..stop].to_string())
}
/// 解析必应响应:结构为 `[{"translations":[{"text":"译文"}], ...}]`。
fn parse_response(body: &Value) -> Option<String> {
body.get(0)?
.get("translations")?
.get(0)?
.get("text")?
.as_str()
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_internal_lang_codes() {
assert_eq!(map_lang("auto"), "auto-detect");
assert_eq!(map_lang("zh-CN"), "zh-Hans");
assert_eq!(map_lang("zh-TW"), "zh-Hant");
assert_eq!(map_lang("en"), "en");
}
#[test]
fn extracts_substring_between_markers() {
assert_eq!(
extract_between("a IG:\"ABC123\" b", "IG:\"", "\""),
Some("ABC123".to_string())
);
assert_eq!(extract_between("no marker", "IG:\"", "\""), None);
}
#[test]
fn parses_token_fields() {
let html = r#"<html>var IG:"FF00";<div data-iid="translator.5028"></div>
<script>params_AbusePreventionHelper = [167060,"abcdef",3600000];</script></html>"#;
let token = parse_token(html).expect("token");
assert_eq!(token.ig, "FF00");
assert_eq!(token.iid, "translator.5028");
assert_eq!(token.key, "167060");
assert_eq!(token.token, "abcdef");
}
#[test]
fn returns_none_when_token_missing() {
assert!(parse_token("<html>nothing here</html>").is_none());
}
#[test]
fn parses_compact_token_assignment_and_ignores_unrelated_iids() {
let html = r#"IG:"FF00"; <div data-iid="unrelated.1"></div>
<div data-iid="translator.5028"></div>
params_AbusePreventionHelper=[167060,"abc,def",3600000];"#;
let token = parse_token(html).unwrap();
assert_eq!(token.iid, "translator.5028");
assert_eq!(token.token, "abc,def");
}
#[test]
fn parses_translation_text() {
let body = json!([{
"detectedLanguage": { "language": "en", "score": 1.0 },
"translations": [{ "text": "你好", "to": "zh-Hans" }]
}]);
assert_eq!(parse_response(&body), Some("你好".to_string()));
}
#[test]
fn returns_none_on_malformed_response() {
assert_eq!(parse_response(&json!({ "error": "bad" })), None);
}
}
-85
View File
@@ -1,85 +0,0 @@
//! 谷歌翻译 —— 免费网页端接口(translate.googleapis.com)。
//!
//! 注意:这是非官方端点,可能被限流。设置项预留官方 Cloud API key 入口。
use std::time::Duration;
use serde_json::Value;
use super::{
read_error_body, read_limited_response, shared_client, MAX_TRANSLATION_RESPONSE_BYTES,
};
const ENDPOINT: &str = "https://translate.googleapis.com/translate_a/single";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
/// 调用谷歌免费端接口,返回完整译文。
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
let url = format!(
"{ENDPOINT}?client=gtx&sl={sl}&tl={tl}&dt=t&q={q}",
sl = urlencoding::encode(source),
tl = urlencoding::encode(target),
q = urlencoding::encode(text),
);
let resp = shared_client(REQUEST_TIMEOUT, false)
.get(&url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("谷歌翻译请求失败: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = read_error_body(resp).await;
return Err(format!("谷歌翻译返回状态 {status}: {body}"));
}
let body = read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "谷歌翻译响应").await?;
let body: Value =
serde_json::from_slice(&body).map_err(|e| format!("谷歌翻译响应解析失败: {e}"))?;
let text = parse_response(&body);
if text.trim().is_empty() {
return Err("谷歌翻译未返回有效译文".to_string());
}
Ok(text)
}
/// 解析谷歌响应:结构为 `[[[译文, 原文, ...], ...], ...]`。
/// 拼接第一段数组中所有片段的译文。
fn parse_response(body: &Value) -> String {
let mut out = String::new();
if let Some(segments) = body.get(0).and_then(|v| v.as_array()) {
for seg in segments {
if let Some(t) = seg.get(0).and_then(|v| v.as_str()) {
out.push_str(t);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_single_segment() {
let body = json!([[["你好", "hello", null, null, 10]], null, "en"]);
assert_eq!(parse_response(&body), "你好");
}
#[test]
fn concatenates_multiple_segments() {
let body = json!([[["你好", "hello"], ["世界", "world"]], null, "en"]);
assert_eq!(parse_response(&body), "你好世界");
}
#[test]
fn returns_empty_on_malformed() {
let body = json!({ "error": "bad" });
assert_eq!(parse_response(&body), "");
}
}
-325
View File
@@ -1,325 +0,0 @@
//! 翻译引擎:统一分发 + 各引擎实现。
pub mod auto;
pub mod bing;
pub mod google;
pub mod openai;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use futures_util::StreamExt;
use reqwest::{Client, Response};
use serde::{Deserialize, Serialize};
/// 复用连接池的 HTTP 客户端。
///
/// `cookie_store=true` 时返回一个独立实例(带 cookie jar),否则返回默认实例。
/// 相同参数多次调用复用同一客户端,避免反复握手建连。
pub(crate) fn shared_client(timeout: Duration, cookie_store: bool) -> Client {
static CLIENTS: OnceLock<Mutex<HashMap<(Duration, bool), Client>>> = OnceLock::new();
let clients = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
let mut clients = clients.lock().expect("HTTP 客户端缓存锁已损坏");
clients
.entry((timeout, cookie_store))
.or_insert_with(|| {
Client::builder()
.timeout(timeout)
.cookie_store(cookie_store)
// 禁止跟随重定向:OpenAI 兼容接口为单跳 POST,禁止 3xx 可避免
// 服务器把带 api_key 的请求重定向到内网/其它地址(防 SSRF 二跳)。
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("构建 HTTP 客户端失败")
})
.clone()
}
pub(crate) const MAX_TRANSLATION_TEXT_BYTES: usize = 64 * 1024;
pub(crate) const MAX_TRANSLATION_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
pub(crate) const MAX_BING_PAGE_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const MAX_ERROR_BODY_BYTES: usize = 16 * 1024;
pub(crate) const MAX_SSE_FRAME_BYTES: usize = 256 * 1024;
pub(crate) const MAX_STREAM_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
pub fn validate_translation_text(text: &str) -> Result<(), String> {
if text.len() > MAX_TRANSLATION_TEXT_BYTES {
return Err(format!(
"待翻译文本超出 {} 字节上限",
MAX_TRANSLATION_TEXT_BYTES
));
}
Ok(())
}
const SUPPORTED_SOURCE_LANGUAGES: &[&str] = &[
"auto", "zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru",
];
const SUPPORTED_TARGET_LANGUAGES: &[&str] =
&["zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru"];
pub fn validate_language_pair(source: &str, target: &str) -> Result<(), String> {
if !SUPPORTED_SOURCE_LANGUAGES.contains(&source) {
return Err(format!("不支持的源语言代码: {source}"));
}
if !SUPPORTED_TARGET_LANGUAGES.contains(&target) {
return Err(format!("不支持的目标语言代码: {target}"));
}
Ok(())
}
struct LimitedBody<'a> {
bytes: Vec<u8>,
limit: usize,
label: &'a str,
}
impl<'a> LimitedBody<'a> {
fn new(limit: usize, label: &'a str) -> Self {
Self {
bytes: Vec::new(),
limit,
label,
}
}
fn push(&mut self, chunk: &[u8]) -> Result<(), String> {
let new_len = self
.bytes
.len()
.checked_add(chunk.len())
.ok_or_else(|| format!("{}大小溢出", self.label))?;
if new_len > self.limit {
return Err(format!("{}超出 {} 字节上限", self.label, self.limit));
}
self.bytes.extend_from_slice(chunk);
Ok(())
}
fn finish(self) -> Vec<u8> {
self.bytes
}
}
pub(crate) async fn read_limited_response(
response: Response,
limit: usize,
label: &str,
) -> Result<Vec<u8>, String> {
if response
.content_length()
.is_some_and(|length| length > limit as u64)
{
return Err(format!("{label}超出 {limit} 字节上限"));
}
let mut body = LimitedBody::new(limit, label);
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("读取{label}失败: {e}"))?;
body.push(&chunk)?;
}
Ok(body.finish())
}
struct ErrorBody {
bytes: Vec<u8>,
limit: usize,
truncated: bool,
}
impl ErrorBody {
fn new(limit: usize) -> Self {
Self {
bytes: Vec::new(),
limit,
truncated: false,
}
}
/// 返回 true 表示已达到上限,调用方应停止读取响应体。
fn push(&mut self, chunk: &[u8]) -> bool {
let remaining = self.limit.saturating_sub(self.bytes.len());
if chunk.len() > remaining {
self.bytes.extend_from_slice(&chunk[..remaining]);
self.truncated = true;
true
} else {
self.bytes.extend_from_slice(chunk);
false
}
}
fn finish(self) -> String {
let mut body = String::from_utf8_lossy(&self.bytes).into_owned();
if self.truncated {
body.push_str("…[truncated]");
}
body
}
}
pub(crate) async fn read_error_body(response: Response) -> String {
let mut body = ErrorBody::new(MAX_ERROR_BODY_BYTES);
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(chunk) if body.push(&chunk) => break,
Ok(_) => {}
Err(error) => return format!("<读取错误响应失败: {error}>"),
}
}
body.finish()
}
/// 翻译引擎类型。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Engine {
Auto,
Openai,
Google,
Bing,
}
/// 翻译请求参数。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranslateRequest {
/// 前端生成的单调请求标识,用于隔离并发/过期流事件。
pub request_id: u64,
pub text: String,
pub engine: Engine,
/// 源语言代码,"auto" 表示自动检测。
pub source: String,
/// 目标语言代码。
pub target: String,
}
/// AI 对话消息。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
/// 允许转发的对话角色(白名单)。其余角色一律拒绝,避免任意 system 注入。
const ALLOWED_ROLES: &[&str] = &["user", "assistant", "system"];
/// 单条消息内容长度上限(字节),防御磁盘占用与上游 payload 滥用。
const MAX_CONTENT_BYTES: usize = 64 * 1024;
/// 单次对话请求的消息条数上限。
const MAX_MESSAGES: usize = 200;
/// 校验一组对话消息:角色白名单、单条长度、条数上限。
/// 在 Tauri 命令边界调用,确保 IPC 来的不可信载荷不会原样落盘或转发上游。
pub fn validate_messages(msgs: &[ChatMessage]) -> Result<(), String> {
if msgs.len() > MAX_MESSAGES {
return Err(format!("消息数超出上限 {MAX_MESSAGES} 条"));
}
for m in msgs {
if !ALLOWED_ROLES.contains(&m.role.as_str()) {
return Err(format!("非法消息角色: {}", m.role));
}
if m.content.len() > MAX_CONTENT_BYTES {
return Err(format!("单条消息超出 {} 字节上限", MAX_CONTENT_BYTES));
}
}
Ok(())
}
/// 流式 chunk 事件载荷(emit 到前端)。
#[derive(Debug, Clone, Serialize)]
pub struct TranslateChunk {
/// 翻译流携带请求标识;普通 AI 对话流不需要,值为 null。
pub request_id: Option<u64>,
/// 本次增量文本。
pub delta: String,
/// 是否结束。
pub done: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn delayed_response(delay: Duration) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0_u8; 1024];
let _ = socket.read(&mut request).await;
tokio::time::sleep(delay).await;
let _ = socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.await;
});
(address, task)
}
#[tokio::test]
async fn clients_with_different_timeouts_do_not_share_the_first_timeout() {
let (slow_address, slow_task) = delayed_response(Duration::from_millis(80)).await;
let short = shared_client(Duration::from_millis(10), false);
assert!(short
.get(format!("http://{slow_address}"))
.send()
.await
.is_err());
slow_task.await.unwrap();
let (fast_address, fast_task) = delayed_response(Duration::from_millis(50)).await;
let long = shared_client(Duration::from_secs(1), false);
assert!(long
.get(format!("http://{fast_address}"))
.send()
.await
.is_ok());
fast_task.await.unwrap();
}
#[test]
fn rejects_oversized_translation_input() {
assert!(validate_translation_text(&"x".repeat(MAX_TRANSLATION_TEXT_BYTES + 1)).is_err());
assert!(validate_translation_text(&"x".repeat(MAX_TRANSLATION_TEXT_BYTES)).is_ok());
}
#[test]
fn limited_body_rejects_chunks_over_the_cap() {
let mut body = LimitedBody::new(4, "test body");
assert!(body.push(b"1234").is_ok());
assert!(body.push(b"5").is_err());
}
#[test]
fn error_body_is_truncated_at_the_cap() {
let mut body = ErrorBody::new(4);
assert!(body.push(b"123456"));
assert_eq!(body.finish(), "1234…[truncated]");
}
#[test]
fn stream_chunks_serialize_the_originating_request_id() {
let value = serde_json::to_value(TranslateChunk {
request_id: Some(42),
delta: "hello".to_string(),
done: false,
})
.unwrap();
assert_eq!(value["request_id"], 42);
}
#[test]
fn rejects_unknown_or_oversized_language_codes() {
assert!(validate_language_pair("auto", "zh-CN").is_ok());
assert!(validate_language_pair("xx-custom", "zh-CN").is_err());
assert!(validate_language_pair(&"x".repeat(128), "en").is_err());
assert!(validate_language_pair("en", "auto").is_err());
}
}
-422
View File
@@ -1,422 +0,0 @@
//! OpenAI 兼容流式能力:翻译流 + 普通 AI 对话流。
use std::time::Duration;
use futures_util::StreamExt;
use serde_json::{json, Value};
use tauri::{AppHandle, Emitter, Runtime};
use crate::config::{ChatAiConfig, OpenAiConfig};
use crate::translate::{
read_error_body, shared_client, ChatMessage, TranslateChunk, MAX_SSE_FRAME_BYTES,
MAX_STREAM_OUTPUT_BYTES,
};
/// 流式翻译事件名。
pub const CHUNK_EVENT: &str = "translate-chunk";
/// 流式 AI 对话事件名。
pub const CHAT_CHUNK_EVENT: &str = "chat-chunk";
/// 单次请求的整体超时(含流式),避免网络挂起永久占用 loading 态。
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
fn render_translate_prompt(cfg: &OpenAiConfig, source: &str, target: &str) -> String {
let src = if source == "auto" {
"自动检测的源语言".to_string()
} else {
source.to_string()
};
cfg.translate_prompt
.replace("{source}", &src)
.replace("{target}", target)
}
fn build_translate_messages(
cfg: &OpenAiConfig,
text: &str,
source: &str,
target: &str,
) -> Vec<Value> {
let prompt = render_translate_prompt(cfg, source, target);
vec![
json!({ "role": "system", "content": prompt }),
json!({
"role": "user",
"content": format!(
"请严格执行以下翻译提示词:\n{prompt}\n\n待处理文本:\n{text}"
)
}),
]
}
/// 调用 OpenAI 兼容接口做流式翻译,每个 delta 通过事件推送前端。
pub async fn translate_stream<R: Runtime>(
app: &AppHandle<R>,
cfg: &OpenAiConfig,
request_id: u64,
text: &str,
source: &str,
target: &str,
) -> Result<(), String> {
let messages = build_translate_messages(cfg, text, source, target);
stream_messages(
app,
&cfg.base_url,
&cfg.api_key,
&cfg.model,
messages,
CHUNK_EVENT,
Some(request_id),
)
.await
}
/// 普通 AI 对话:使用独立 AI 对话配置。若配置了自定义提示词则作为 system 消息前置。
pub async fn chat_stream<R: Runtime>(
app: &AppHandle<R>,
cfg: &ChatAiConfig,
messages: &[ChatMessage],
) -> Result<(), String> {
let mut payload: Vec<Value> = Vec::with_capacity(messages.len() + 1);
if !cfg.chat_prompt.trim().is_empty() {
payload.push(json!({ "role": "system", "content": cfg.chat_prompt }));
}
payload.extend(
messages
.iter()
.map(|m| json!({ "role": m.role, "content": m.content })),
);
stream_messages(
app,
&cfg.base_url,
&cfg.api_key,
&cfg.model,
payload,
CHAT_CHUNK_EVENT,
None,
)
.await
}
async fn stream_messages<R: Runtime>(
app: &AppHandle<R>,
base_url: &str,
api_key: &str,
model: &str,
messages: Vec<Value>,
event_name: &str,
request_id: Option<u64>,
) -> Result<(), String> {
if api_key.is_empty() {
return Err("未配置 API Key".to_string());
}
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
let payload = json!({
"model": model,
"stream": true,
"messages": messages,
});
let resp = shared_client(REQUEST_TIMEOUT, false)
.post(&url)
.bearer_auth(api_key)
.json(&payload)
.send()
.await
.map_err(|e| format!("OpenAI 请求失败: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = read_error_body(resp).await;
return Err(format!("OpenAI 返回状态 {status}: {body}"));
}
let mut stream = resp.bytes_stream();
let mut decoder = SseDecoder::with_limits(MAX_SSE_FRAME_BYTES, MAX_STREAM_OUTPUT_BYTES);
while let Some(chunk) = stream.next().await {
let bytes = chunk.map_err(|e| format!("读取流失败: {e}"))?;
if emit_deltas(app, event_name, request_id, decoder.push(&bytes)?)? {
return Ok(());
}
}
if emit_deltas(app, event_name, request_id, decoder.finish()?)? {
Ok(())
} else {
Err("OpenAI 流结束但未收到 [DONE]".to_string())
}
}
/// 单行 SSE 解析结果。
#[derive(Debug, Clone, PartialEq, Eq)]
enum SseDelta {
Content(String),
Done,
}
/// 解析一行 SSE:`data: {...}` 或 `data: [DONE]`。
fn parse_sse_line(line: &str) -> Result<Option<SseDelta>, String> {
let Some(data) = line.strip_prefix("data:") else {
return Ok(None);
};
let data = data.trim();
if data.is_empty() {
return Ok(None);
}
if data == "[DONE]" {
return Ok(Some(SseDelta::Done));
}
let v: Value =
serde_json::from_str(data).map_err(|e| format!("OpenAI SSE JSON 解析失败: {e}"))?;
let content = v
.get("choices")
.and_then(|choices| choices.get(0))
.and_then(|choice| choice.get("delta"))
.and_then(|delta| delta.get("content"))
.and_then(Value::as_str);
Ok(content.map(|content| SseDelta::Content(content.to_string())))
}
struct SseDecoder {
buffer: Vec<u8>,
frame_limit: usize,
output_limit: usize,
output_bytes: usize,
saw_done: bool,
}
impl SseDecoder {
fn with_limits(frame_limit: usize, output_limit: usize) -> Self {
Self {
buffer: Vec::new(),
frame_limit,
output_limit,
output_bytes: 0,
saw_done: false,
}
}
fn push(&mut self, bytes: &[u8]) -> Result<Vec<SseDelta>, String> {
let mut deltas = Vec::new();
for byte in bytes {
if *byte == b'\n' {
if let Some(delta) = self.take_line()? {
deltas.push(delta);
}
continue;
}
if self.buffer.len() >= self.frame_limit {
return Err(format!("OpenAI SSE 单帧超出 {} 字节上限", self.frame_limit));
}
self.buffer.push(*byte);
}
Ok(deltas)
}
fn finish(mut self) -> Result<Vec<SseDelta>, String> {
let mut deltas = Vec::new();
if !self.buffer.is_empty() {
if let Some(delta) = self.take_line()? {
deltas.push(delta);
}
}
if !self.saw_done {
return Err("OpenAI 流结束但未收到 [DONE]".to_string());
}
Ok(deltas)
}
fn take_line(&mut self) -> Result<Option<SseDelta>, String> {
let line_bytes = std::mem::take(&mut self.buffer);
let line = std::str::from_utf8(&line_bytes)
.map_err(|e| format!("OpenAI SSE 包含非法 UTF-8: {e}"))?
.trim();
let Some(delta) = parse_sse_line(line)? else {
return Ok(None);
};
match &delta {
SseDelta::Content(content) => {
self.output_bytes = self
.output_bytes
.checked_add(content.len())
.ok_or_else(|| "OpenAI 流式输出大小溢出".to_string())?;
if self.output_bytes > self.output_limit {
return Err(format!(
"OpenAI 流式输出超出 {} 字节上限",
self.output_limit
));
}
}
SseDelta::Done => self.saw_done = true,
}
Ok(Some(delta))
}
}
fn emit_deltas<R: Runtime>(
app: &AppHandle<R>,
event_name: &str,
request_id: Option<u64>,
deltas: Vec<SseDelta>,
) -> Result<bool, String> {
for delta in deltas {
match delta {
SseDelta::Content(delta) => app
.emit(
event_name,
TranslateChunk {
request_id,
delta,
done: false,
},
)
.map_err(|e| format!("发送流式结果失败: {e}"))?,
SseDelta::Done => {
app.emit(
event_name,
TranslateChunk {
request_id,
delta: String::new(),
done: true,
},
)
.map_err(|e| format!("发送流式完成事件失败: {e}"))?;
return Ok(true);
}
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_content_delta() {
let line = r#"data: {"choices":[{"delta":{"content":"你好"}}]}"#;
match parse_sse_line(line).unwrap() {
Some(SseDelta::Content(t)) => assert_eq!(t, "你好"),
_ => panic!("expected content"),
}
}
#[test]
fn parses_done() {
assert!(matches!(
parse_sse_line("data: [DONE]").unwrap(),
Some(SseDelta::Done)
));
}
#[test]
fn ignores_non_data_lines() {
assert!(parse_sse_line(": keep-alive").unwrap().is_none());
assert!(parse_sse_line("").unwrap().is_none());
}
#[test]
fn ignores_delta_without_content() {
let line = r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#;
assert!(parse_sse_line(line).unwrap().is_none());
}
#[test]
fn renders_translate_prompt_placeholders() {
let cfg = OpenAiConfig {
translate_prompt: "from {source} to {target}".to_string(),
..OpenAiConfig::default()
};
assert_eq!(
render_translate_prompt(&cfg, "auto", "zh-CN"),
"from 自动检测的源语言 to zh-CN"
);
}
#[test]
fn includes_custom_translate_prompt_in_system_and_user_messages() {
let cfg = OpenAiConfig {
translate_prompt: "CUSTOM {source} => {target}".to_string(),
..OpenAiConfig::default()
};
let messages = build_translate_messages(&cfg, "hello", "en", "zh-CN");
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "CUSTOM en => zh-CN");
assert!(messages[1]["content"]
.as_str()
.unwrap()
.contains("CUSTOM en => zh-CN"));
assert!(messages[1]["content"].as_str().unwrap().contains("hello"));
}
#[test]
fn prepends_chat_prompt_as_system_message() {
let prompt = "你是一个友好的助手";
let history = [ChatMessage {
role: "user".to_string(),
content: "你好".to_string(),
}];
let mut payload: Vec<Value> = Vec::new();
if !prompt.trim().is_empty() {
payload.push(json!({ "role": "system", "content": prompt }));
}
payload.extend(
history
.iter()
.map(|m| json!({ "role": m.role, "content": m.content })),
);
assert_eq!(payload.len(), 2);
assert_eq!(payload[0]["role"], "system");
assert_eq!(payload[0]["content"], prompt);
assert_eq!(payload[1]["role"], "user");
}
#[test]
fn parses_final_sse_frame_without_a_trailing_newline() {
let mut decoder = SseDecoder::with_limits(1024, 1024);
let deltas = decoder
.push(b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: [DONE]")
.unwrap();
assert!(matches!(deltas.as_slice(), [SseDelta::Content(text)] if text == "hello"));
let final_deltas = decoder.finish().unwrap();
assert!(matches!(final_deltas.as_slice(), [SseDelta::Done]));
}
#[test]
fn rejects_eof_without_done_marker() {
let mut decoder = SseDecoder::with_limits(1024, 1024);
decoder
.push(b"data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n")
.unwrap();
assert!(decoder.finish().unwrap_err().contains("[DONE]"));
}
#[test]
fn rejects_oversized_sse_frames() {
let mut decoder = SseDecoder::with_limits(8, 1024);
assert!(decoder.push(b"data: 123456789").is_err());
}
#[test]
fn rejects_oversized_stream_output() {
let mut decoder = SseDecoder::with_limits(1024, 3);
assert!(decoder
.push(b"data: {\"choices\":[{\"delta\":{\"content\":\"four\"}}]}\n")
.is_err());
}
#[test]
fn rejects_malformed_data_frames_instead_of_silently_skipping_them() {
let mut decoder = SseDecoder::with_limits(1024, 1024);
assert!(decoder.push(b"data: {broken}\n").is_err());
}
}
-48
View File
@@ -1,48 +0,0 @@
//! 系统托盘:图标 + 菜单(翻译 / 设置 / 退出)。
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder,
AppHandle, Emitter, Runtime,
};
use crate::window;
/// 在 setup 中构建系统托盘。
pub fn setup_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
let open = MenuItem::with_id(app, "open", "翻译", true, None::<&str>)?;
let settings = MenuItem::with_id(app, "settings", "设置…", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "退出 TTREE", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &settings, &sep, &quit])?;
let icon = app
.default_window_icon()
.ok_or_else(|| tauri::Error::AssetNotFound("window icon".into()))?;
TrayIconBuilder::with_id("main-tray")
.icon(icon.clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
if let Some(w) = window::main_window(app) {
window::show_window(&w);
}
}
"settings" => {
if let Some(w) = window::main_window(app) {
window::show_window(&w);
}
let _ = app.emit("open-settings", ());
}
"quit" => {
app.exit(0);
}
_ => {}
})
.build(app)?;
Ok(())
}
-114
View File
@@ -1,114 +0,0 @@
//! 窗口管理:显示/隐藏/切换,以及失焦自动隐藏。
//!
//! 关键策略:窗口**隐藏而非销毁**,保证快捷键秒级呼出。
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Manager, WebviewWindow};
pub const MAIN_WINDOW: &str = "main";
static IS_PINNED: AtomicBool = AtomicBool::new(false);
/// 获取主窗口句柄。
pub fn main_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<WebviewWindow<R>> {
app.get_webview_window(MAIN_WINDOW)
}
/// 显示并聚焦窗口(居中)。
pub fn show_window<R: tauri::Runtime>(window: &WebviewWindow<R>) {
let _ = window.center();
let _ = window.set_always_on_top(is_pinned());
let _ = window.show();
let _ = window.set_focus();
}
/// 显示并聚焦主窗口。
pub fn show_main<R: tauri::Runtime>(app: &AppHandle<R>) {
if let Some(window) = main_window(app) {
show_window(&window);
}
}
/// 主窗口当前是否拥有焦点。
#[allow(dead_code)]
pub fn is_main_focused<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
main_window(app)
.and_then(|window| window.is_focused().ok())
.unwrap_or(false)
}
/// 窗口当前是否可见(查询失败时保守返回 false)。
pub fn is_visible_safe<R: tauri::Runtime>(window: &tauri::Window<R>) -> bool {
window.is_visible().unwrap_or(false)
}
/// 隐藏主窗口。
pub fn hide_window<R: tauri::Runtime>(window: &WebviewWindow<R>) {
let _ = window.hide();
}
/// 当前是否处于固定窗口模式。
pub fn is_pinned() -> bool {
IS_PINNED.load(Ordering::Relaxed)
}
/// 设置固定窗口模式。固定时窗口置顶且失焦不自动隐藏。
pub fn set_pinned<R: tauri::Runtime>(
window: &WebviewWindow<R>,
pinned: bool,
) -> tauri::Result<bool> {
let pinned = commit_pinned_state(&IS_PINNED, pinned, window.set_always_on_top(pinned))?;
if pinned {
let _ = window.show();
let _ = window.set_focus();
}
Ok(pinned)
}
fn commit_pinned_state<E>(
state: &AtomicBool,
pinned: bool,
operation: Result<(), E>,
) -> Result<bool, E> {
operation?;
state.store(pinned, Ordering::Relaxed);
Ok(pinned)
}
/// 切换主窗口可见性。返回 true 表示切换后窗口为显示状态。
pub fn toggle_window<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
let Some(window) = main_window(app) else {
return false;
};
match window.is_visible() {
Ok(true) => {
hide_window(&window);
false
}
_ => {
show_window(&window);
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pinned_state_changes_only_after_window_operation_succeeds() {
let state = AtomicBool::new(false);
let failed: Result<bool, &str> = commit_pinned_state(&state, true, Err("failed"));
assert_eq!(failed, Err("failed"));
assert!(!state.load(Ordering::Relaxed));
assert_eq!(
commit_pinned_state(&state, true, Ok::<(), &str>(())),
Ok(true)
);
assert!(state.load(Ordering::Relaxed));
}
}
-61
View File
@@ -1,61 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "TTREE",
"version": "0.1.5",
"identifier": "com.samwstu.ttree",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"macOSPrivateApi": true,
"windows": [
{
"label": "main",
"title": "TTREE",
"width": 480,
"height": 460,
"minWidth": 480,
"minHeight": 300,
"resizable": true,
"decorations": false,
"transparent": true,
"alwaysOnTop": false,
"visible": false,
"skipTaskbar": true,
"center": true,
"shadow": true,
"titleBarStyle": "Overlay"
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
}
},
"bundle": {
"active": true,
"targets": "all",
"category": "Productivity",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"minimumSystemVersion": "11.0"
},
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgOEZCRkNDMDRGMDBDQTQxNApSV1FVcEF6d0JNeS9qM0dGdUpCUUo1ZW5SdUhGeTBxMkhKYlhkMC9jRHhOeG5QcWFnQ1Z0dCt5UAo=",
"endpoints": [
"https://github.com/ZASENJC/ttree/releases/latest/download/latest.json"
]
}
}
}
-82
View File
@@ -1,82 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import TranslatePanel from "./lib/components/TranslatePanel.svelte";
import ChatPanel from "./lib/views/ChatPanel.svelte";
import Settings from "./lib/views/Settings.svelte";
import {
onOpenSettings,
onSelectedTranslate,
onTriggerAiDialog,
onTriggerOcr,
onTriggerTranslate,
} from "./lib/api/tauri";
import type { MainMode } from "./lib/components/EngineTabs.svelte";
import { chatStore } from "./lib/stores/chat.svelte";
import { translation } from "./lib/stores/translation.svelte";
import { appearance } from "./lib/stores/appearance.svelte";
import { updater } from "./lib/stores/update.svelte";
let mode = $state<MainMode>("translate");
let view = $state<"main" | "settings">("main");
onMount(() => {
// 启动即加载持久化的对话历史与外观配置
void chatStore.loadHistory();
void appearance.load();
// 启动时读取版本号,并在距上次检查满一周时静默检查更新
void updater.loadVersion();
void updater.autoCheckIfNeeded();
const unlisteners = [
onOpenSettings(() => (view = "settings")),
onTriggerTranslate(() => {
view = "main";
mode = "translate";
}),
onTriggerOcr(() => {
view = "main";
mode = "translate";
void translation.captureAndTranslate();
}),
onSelectedTranslate(({ text }) => {
view = "main";
mode = "translate";
void translation.translateText(text);
}),
onTriggerAiDialog(({ text }) => {
view = "main";
mode = "chat";
if (text) {
// 划词内容:针对选中内容提问,沿用当前对话上下文
void chatStore.sendText(text);
} else {
// 纯呼出:默认开一段新对话(历史提问仍保留在侧边悬浮浮层)
chatStore.reset();
}
}),
];
return () => {
unlisteners.forEach((unlisten) => unlisten.then((un) => un()));
};
});
onDestroy(() => updater.stopPeriodicCheck());
</script>
{#if view === "settings"}
<Settings onClose={() => (view = "main")} />
{:else if mode === "chat"}
<ChatPanel
{mode}
onModeChange={(nextMode) => (mode = nextMode)}
onSettings={() => (view = "settings")}
/>
{:else}
<TranslatePanel
{mode}
onModeChange={(nextMode) => (mode = nextMode)}
onSettings={() => (view = "settings")}
/>
{/if}
-37
View File
@@ -1,37 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@tauri-apps/api/core", () => ({ invoke }));
import { appendChatHistory } from "./tauri";
describe("appendChatHistory", () => {
beforeEach(() => invoke.mockReset());
it("passes the stable conversation id when resuming old history", async () => {
invoke.mockResolvedValue(undefined);
const round = [
{ role: "user" as const, content: "follow up" },
{ role: "assistant" as const, content: "answer" },
];
await appendChatHistory(round, 7);
expect(invoke).toHaveBeenCalledWith("append_chat_history", {
round,
conversationId: 7,
});
});
it("uses a null id for a newly started conversation", async () => {
invoke.mockResolvedValue(undefined);
await appendChatHistory([], null);
expect(invoke).toHaveBeenCalledWith("append_chat_history", {
round: [],
conversationId: null,
});
});
});
-223
View File
@@ -1,223 +0,0 @@
/** Tauri 后端调用与事件订阅封装。 */
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type Engine = "openai" | "auto" | "google" | "bing";
export interface TranslateRequest {
request_id: number;
text: string;
engine: Engine;
source: string;
target: string;
}
export interface TranslateChunk {
request_id: number | null;
delta: string;
done: boolean;
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
}
/** 单段对话:一组消息 + 摘要(首句用户提问,用作列表标题)。 */
export interface Conversation {
/** 稳定会话 id;列表排序变化时保持不变。 */
id: number;
messages: ChatMessage[];
summary: string;
}
/** 普通 AI 对话:不附加翻译提示词,结果通过 chat-chunk 事件返回。 */
export function chat(messages: ChatMessage[]): Promise<void> {
return invoke<void>("chat", { messages });
}
/** 读取全部对话历史(扁平消息流,向后兼容)。 */
export function loadChatHistory(): Promise<ChatMessage[]> {
return invoke<ChatMessage[]>("load_chat_history");
}
/** 以对话为单位读取历史(最久未活动 → 最近活动)。 */
export function loadConversations(): Promise<Conversation[]> {
return invoke<Conversation[]>("load_conversations");
}
/** 写入对话分隔标记,开启一段新对话(不删除已有历史)。 */
export function startNewConversation(): Promise<void> {
return invoke<void>("start_new_conversation");
}
/** 追加一轮对话;conversationId 非空时恢复并续写指定历史对话。 */
export function appendChatHistory(
round: ChatMessage[],
conversationId: number | null,
): Promise<void> {
return invoke<void>("append_chat_history", { round, conversationId });
}
/** 清空全部对话历史(删除历史文件)。 */
export function clearChatHistory(): Promise<void> {
return invoke<void>("clear_chat_history");
}
/** 订阅 AI 对话 chunk 事件。 */
export function onChatChunk(
handler: (chunk: TranslateChunk) => void,
): Promise<UnlistenFn> {
return listen<TranslateChunk>("chat-chunk", (e) => handler(e.payload));
}
/**
* 触发翻译。
* - auto:免费服务自动分流和故障切换,返回完整译文字符串
* - openai:返回空串,结果通过 onChunk 流式到达
*/
export function translate(req: TranslateRequest): Promise<string> {
return invoke<string>("translate", { req });
}
/** 订阅流式翻译 chunk 事件。 */
export function onChunk(
handler: (chunk: TranslateChunk) => void,
): Promise<UnlistenFn> {
return listen<TranslateChunk>("translate-chunk", (e) => handler(e.payload));
}
/** 触发交互式截图 + 系统 OCR,返回识别文本(用户取消时为空串)。 */
export function screenshotOcr(): Promise<string> {
return invoke<string>("screenshot_ocr");
}
/** 显示并聚焦主窗口。 */
export function showMain(): Promise<void> {
return invoke<void>("show_main");
}
/** 读取固定窗口状态。 */
export function getPinned(): Promise<boolean> {
return invoke<boolean>("get_pinned");
}
/** 设置固定窗口状态。 */
export function setPinned(pinned: boolean): Promise<boolean> {
return invoke<boolean>("set_pinned", { pinned });
}
export interface OpenAiConfig {
base_url: string;
api_key: string;
has_api_key: boolean;
clear_api_key: boolean;
model: string;
translate_prompt: string;
}
export interface ChatAiConfig {
base_url: string;
api_key: string;
has_api_key: boolean;
clear_api_key: boolean;
model: string;
chat_prompt: string;
}
export interface ShortcutConfig {
toggle: string;
ocr: string;
ai_dialog: string;
selection_translate: string;
selection_ai_dialog: string;
}
export interface AppearanceConfig {
/** 面板整体透明度,0–1。后端会钳制到合法范围。 */
panel_opacity: number;
}
export interface SelectedTranslatePayload {
text: string;
}
export interface AiDialogPayload {
text: string | null;
}
/** 读取 OpenAI 配置。 */
export function getOpenAiConfig(): Promise<OpenAiConfig> {
return invoke<OpenAiConfig>("get_openai_config");
}
/** 保存 OpenAI 配置。 */
export function setOpenAiConfig(config: OpenAiConfig): Promise<void> {
return invoke<void>("set_openai_config", { config });
}
/** 读取 AI 对话配置。 */
export function getChatAiConfig(): Promise<ChatAiConfig> {
return invoke<ChatAiConfig>("get_chat_ai_config");
}
/** 保存 AI 对话配置。 */
export function setChatAiConfig(config: ChatAiConfig): Promise<void> {
return invoke<void>("set_chat_ai_config", { config });
}
/** 读取快捷键配置。 */
export function getShortcutConfig(): Promise<ShortcutConfig> {
return invoke<ShortcutConfig>("get_shortcut_config");
}
/** 保存快捷键配置并让后端立即重注册。 */
export function setShortcutConfig(config: ShortcutConfig): Promise<void> {
return invoke<void>("set_shortcut_config", { config });
}
/** 录制快捷键时临时忽略全局快捷键触发。 */
export function setShortcutRecording(active: boolean): Promise<void> {
return invoke<void>("set_shortcut_recording", { active });
}
/** 读取外观配置(面板透明度等)。 */
export function getAppearanceConfig(): Promise<AppearanceConfig> {
return invoke<AppearanceConfig>("get_appearance_config");
}
/** 保存外观配置。 */
export function setAppearanceConfig(config: AppearanceConfig): Promise<void> {
return invoke<void>("set_appearance_config", { config });
}
/** 订阅托盘"设置"菜单事件。 */
export function onOpenSettings(handler: () => void): Promise<UnlistenFn> {
return listen("open-settings", () => handler());
}
/** 订阅截图 OCR 触发事件(快捷键按下时由后端发出)。 */
export function onTriggerOcr(handler: () => void): Promise<UnlistenFn> {
return listen("trigger-ocr", () => handler());
}
/** 订阅呼出翻译页事件(toggle 快捷键显示窗口时由后端发出)。 */
export function onTriggerTranslate(handler: () => void): Promise<UnlistenFn> {
return listen("trigger-translate", () => handler());
}
/** 订阅划词翻译事件。 */
export function onSelectedTranslate(
handler: (payload: SelectedTranslatePayload) => void,
): Promise<UnlistenFn> {
return listen<SelectedTranslatePayload>("trigger-selected-translate", (e) =>
handler(e.payload),
);
}
/** 订阅 AI 对话呼出事件。 */
export function onTriggerAiDialog(
handler: (payload: AiDialogPayload) => void,
): Promise<UnlistenFn> {
return listen<AiDialogPayload>("trigger-ai-dialog", (e) => handler(e.payload));
}
-92
View File
@@ -1,92 +0,0 @@
<script lang="ts">
export type MainMode = "translate" | "chat";
interface Props {
mode: MainMode;
onchange: (mode: MainMode) => void;
}
let { mode, onchange }: Props = $props();
const tabs: { id: MainMode; label: string }[] = [
{ id: "translate", label: "翻译" },
{ id: "chat", label: "AI" },
];
</script>
<div class="segmented" role="tablist" aria-label="模式切换" class:chat={mode === "chat"}>
<span class="active-pill" aria-hidden="true"></span>
{#each tabs as tab (tab.id)}
<button
role="tab"
aria-selected={mode === tab.id}
class="segment"
class:active={mode === tab.id}
onclick={() => onchange(tab.id)}
>
<span class="state-layer"></span>
<span class="label">{tab.label}</span>
</button>
{/each}
</div>
<style>
.segmented {
position: relative;
display: inline-grid;
grid-template-columns: repeat(2, 1fr);
min-height: 34px;
padding: 3px;
overflow: hidden;
border-radius: var(--radius);
}
.active-pill {
position: absolute;
inset: 3px auto 3px 3px;
width: calc(50% - 3px);
border-radius: var(--radius-sm);
background: var(--surface-raised);
box-shadow: var(--pill-shadow);
transition: transform var(--duration) var(--ease-emphasized);
}
.segmented.chat .active-pill {
transform: translateX(100%);
}
.segment {
position: relative;
z-index: 1;
min-width: 64px;
min-height: 30px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.01em;
padding: 0 14px;
cursor: pointer;
overflow: hidden;
transition:
color var(--duration-fast) var(--ease);
}
.state-layer {
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease);
}
.label {
position: relative;
z-index: 1;
}
.segment:hover .state-layer {
opacity: var(--state-hover);
}
.segment:active .state-layer {
opacity: var(--state-pressed);
}
.segment.active {
color: var(--text);
}
</style>
-71
View File
@@ -1,71 +0,0 @@
<script lang="ts">
import { LANGUAGES, TARGET_LANGUAGES } from "../stores/languages";
interface Props {
source: string;
target: string;
onSource: (code: string) => void;
onTarget: (code: string) => void;
}
let { source, target, onSource, onTarget }: Props = $props();
</script>
<div class="lang-row">
<select
class="lang"
aria-label="源语言"
value={source}
onchange={(e) => onSource(e.currentTarget.value)}
>
{#each LANGUAGES as lang (lang.code)}
<option value={lang.code}>{lang.name}</option>
{/each}
</select>
<span class="arrow" aria-hidden="true">→</span>
<select
class="lang"
aria-label="目标语言"
value={target}
onchange={(e) => onTarget(e.currentTarget.value)}
>
{#each TARGET_LANGUAGES as lang (lang.code)}
<option value={lang.code}>{lang.name}</option>
{/each}
</select>
</div>
<style>
.lang-row {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.lang {
appearance: none;
min-height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--text-sm);
font-weight: 600;
padding: 4px 6px;
cursor: pointer;
transition: background var(--duration-fast) var(--ease);
}
.lang:hover {
background: var(--accent-soft);
}
.lang:focus-visible {
outline: none;
background: var(--accent-soft);
}
.arrow {
color: var(--text-faint);
font-size: var(--text-sm);
font-weight: 700;
}
</style>
-183
View File
@@ -1,183 +0,0 @@
<script lang="ts">
interface Props {
webOutput: string;
aiOutput: string;
webLoading: boolean;
aiLoading: boolean;
webError: string | null;
aiError: string | null;
error: string | null;
}
let {
webOutput,
aiOutput,
webLoading,
aiLoading,
webError,
aiError,
error,
}: Props = $props();
let copied = $state<"web" | "ai" | null>(null);
async function copy(kind: "web" | "ai", text: string) {
if (!text) return;
await navigator.clipboard.writeText(text);
copied = kind;
setTimeout(() => (copied = null), 1400);
}
</script>
<div class="result-grid">
{#if error}
<p class="error global-error">{error}</p>
{/if}
<section class="column" aria-label="翻译结果">
<header class="column-head">
<span>翻译</span>
{#if webOutput}
<button class="copy" onclick={() => copy("web", webOutput)} aria-label="复制翻译结果">
{copied === "web" ? "已复制" : "复制"}
</button>
{/if}
</header>
{#if webError}
<p class="error">{webError}</p>
{:else if webOutput}
<p class="output">{webOutput}</p>
{:else if webLoading}
<p class="placeholder">翻译中…</p>
{:else}
<p class="placeholder">译文显示在这里</p>
{/if}
</section>
<section class="column ai-column" aria-label="AI 翻译结果">
<header class="column-head">
<span>AI</span>
{#if aiOutput}
<button class="copy" onclick={() => copy("ai", aiOutput)} aria-label="复制 AI 翻译结果">
{copied === "ai" ? "已复制" : "复制"}
</button>
{/if}
</header>
{#if aiError}
<p class="error">{aiError}</p>
{:else if aiOutput}
<p class="output">{aiOutput}{#if aiLoading}<span class="caret"></span>{/if}</p>
{:else if aiLoading}
<p class="placeholder">AI 翻译中…</p>
{:else}
<p class="placeholder">AI 译文显示在这里</p>
{/if}
</section>
</div>
<style>
.result-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 100%;
}
.global-error {
grid-column: 1 / -1;
}
.column {
position: relative;
min-width: 0;
min-height: 100%;
padding: var(--space-3) var(--space-4) var(--space-2);
overflow: hidden;
}
/* 两列之间只留一条细分隔线,不用卡片框 */
.column + .column {
box-shadow: inset 1px 0 0 var(--border);
}
.column-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
color: var(--text-faint);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.output {
position: relative;
z-index: 1;
margin: 0;
font-size: var(--text-lg);
line-height: 1.62;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
.placeholder {
position: relative;
z-index: 1;
margin: 0;
font-size: var(--text-base);
color: var(--text-faint);
}
.error {
position: relative;
z-index: 1;
margin: 0;
font-size: var(--text-sm);
color: var(--danger);
}
.caret {
display: inline-block;
width: 2px;
height: 1.1em;
margin-left: 1px;
vertical-align: text-bottom;
background: var(--accent);
border-radius: var(--radius-xs);
animation: blink 1s steps(2, start) infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
.copy {
position: relative;
z-index: 1;
border: none;
background: transparent;
color: var(--text-faint);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 3px 6px;
margin-right: -6px;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
color var(--duration-fast) var(--ease),
background var(--duration-fast) var(--ease);
}
.copy:hover {
color: var(--accent);
background: var(--accent-soft);
}
@media (max-width: 560px) {
.result-grid {
grid-template-columns: 1fr;
}
.column + .column {
box-shadow: inset 0 1px 0 var(--border);
}
}
</style>
-341
View File
@@ -1,341 +0,0 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { onMount } from "svelte";
import { translation } from "../stores/translation.svelte";
import { getPinned, setPinned } from "../api/tauri";
import EngineTabs, { type MainMode } from "./EngineTabs.svelte";
import LangSelector from "./LangSelector.svelte";
import ResultView from "./ResultView.svelte";
import { clampSourceHeight as clampSourcePaneHeight } from "./panelSizing";
const AUTO_TRANSLATE_DELAY_MS = 1000;
const t = translation;
let panel: HTMLElement | undefined = $state();
let textarea: HTMLTextAreaElement | undefined = $state();
let sourceHeight = $state(210);
let isDraggingSplit = $state(false);
let isPinned = $state(false);
let stopSplitResize: (() => void) | null = null;
interface Props {
mode: MainMode;
onModeChange: (mode: MainMode) => void;
onSettings: () => void;
}
let { mode, onModeChange, onSettings }: Props = $props();
// 呼出后自动聚焦输入框
$effect(() => {
textarea?.focus();
});
// 输入有文字并静止 1 秒后自动翻译;继续输入会重置计时器。
$effect(() => {
const text = t.state.input.trim();
if (!t.shouldAutoRun(text)) {
return;
}
const timer = window.setTimeout(() => {
if (t.shouldAutoRun(text) && t.state.input.trim() === text) {
void t.run();
}
}, AUTO_TRANSLATE_DELAY_MS);
return () => window.clearTimeout(timer);
});
// 初始化固定状态,并在窗口恢复/缩放时保持上下两块均可见。
onMount(() => {
let mounted = true;
void getPinned()
.then((pinned) => {
if (mounted) isPinned = pinned;
})
.catch(() => {});
const clampToPanel = () => {
sourceHeight = clampSourcePaneHeight(
sourceHeight,
panel?.clientHeight ?? window.innerHeight,
);
};
clampToPanel();
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(clampToPanel);
if (panel) observer?.observe(panel);
window.addEventListener("resize", clampToPanel);
return () => {
mounted = false;
observer?.disconnect();
window.removeEventListener("resize", clampToPanel);
stopSplitResize?.();
};
});
function onKeydown(e: KeyboardEvent) {
// Enter 翻译;Cmd/Shift+Enter 换行
if (e.key === "Enter" && !e.metaKey && !e.shiftKey) {
e.preventDefault();
t.run();
}
// Esc 隐藏窗口(用户显式关闭即使固定也允许)
if (e.key === "Escape") {
e.preventDefault();
getCurrentWindow().hide();
}
}
async function togglePinned() {
isPinned = await setPinned(!isPinned);
}
function startSplitResize(e: PointerEvent) {
e.preventDefault();
stopSplitResize?.();
const startY = e.clientY;
const startHeight = sourceHeight;
isDraggingSplit = true;
const onMove = (moveEvent: PointerEvent) => {
sourceHeight = clampSourcePaneHeight(
startHeight + moveEvent.clientY - startY,
panel?.clientHeight ?? window.innerHeight,
);
};
const onUp = () => {
isDraggingSplit = false;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
stopSplitResize = null;
};
stopSplitResize = onUp;
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
}
</script>
<main class="panel" bind:this={panel} class:dragging={isDraggingSplit}>
<!-- 顶部工具栏:可拖拽,只放引擎与设置 -->
<header class="toolbar" data-tauri-drag-region>
<EngineTabs
{mode}
onchange={onModeChange}
/>
<div class="toolbar-actions">
<button
class="md-icon-button pin"
class:active={isPinned}
onclick={togglePinned}
aria-pressed={isPinned}
aria-label={isPinned ? "取消固定窗口" : "固定窗口"}
title={isPinned ? "取消固定窗口" : "固定窗口"}
>
<span class="state-layer"></span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 4.5c0-.83.67-1.5 1.5-1.5S17 3.67 17 4.5v6.15l2.27 2.27c.47.47.14 1.28-.53 1.28H13v5.55c0 .41-.34.75-.75.75s-.75-.34-.75-.75V14.2H5.76c-.67 0-1-.81-.53-1.28L7.5 10.65V4.5C7.5 3.67 8.17 3 9 3s1.5.67 1.5 1.5v5.55c0 .2-.08.39-.22.53L8.36 12.5h7.28l-1.92-1.92a.75.75 0 0 1-.22-.53V4.5Z" />
</svg>
</button>
<button class="md-icon-button gear" onclick={onSettings} aria-label="设置" title="设置">
<span class="state-layer"></span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19.43 12.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.6-.22l-2.49 1a7.3 7.3 0 0 0-1.7-.98L14.5 2.42A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.42L9.12 5.07c-.61.24-1.18.56-1.7.98l-2.49-1a.5.5 0 0 0-.6.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.06.65-.06.98s.02.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46c.13.22.39.31.6.22l2.49-1c.52.4 1.09.73 1.7.98l.38 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.38-2.65c.61-.24 1.18-.56 1.7-.98l2.49 1c.22.09.48 0 .6-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.13-1.65ZM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z" />
</svg>
</button>
</div>
</header>
<!-- 原文区:拖动中间分割条调整高度 -->
<section
class="pane source-pane"
aria-label="原文"
style:height={`${sourceHeight}px`}
>
<textarea
bind:this={textarea}
value={t.state.input}
oninput={(event) => t.setInput(event.currentTarget.value)}
onkeydown={onKeydown}
class="input"
placeholder="输入文字,回车翻译"
spellcheck="false"
></textarea>
</section>
<!-- 语言选择:置于上下两条线中间,左对齐;上方隐形命中区可拖动调高度 -->
<div class="language-strip" class:active={isDraggingSplit}>
<button
class="resize-handle"
aria-label="调整原文和译文高度"
onpointerdown={startSplitResize}
></button>
<LangSelector
source={t.state.source}
target={t.state.target}
onSource={(c) => void t.setSource(c)}
onTarget={(c) => void t.setTarget(c)}
/>
</div>
<!-- 译文区:随原文高度变化自动获得剩余空间 -->
<section class="pane result-pane" aria-label="译文">
<ResultView
webOutput={t.state.webOutput}
aiOutput={t.state.aiOutput}
webLoading={t.state.webLoading}
aiLoading={t.state.aiLoading}
webError={t.state.webError}
aiError={t.state.aiError}
error={t.state.error}
/>
</section>
</main>
<style>
.panel {
position: relative;
display: flex;
flex-direction: column;
height: 100vh;
min-height: 0;
overflow: hidden;
border-radius: var(--radius-window);
background: var(--panel-bg);
/* 阴影交给原生窗口 (tauri shadow:true),避免双层圆角阴影叠加 */
}
.panel.dragging {
user-select: none;
cursor: ns-resize;
}
.toolbar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
}
.pane {
position: relative;
z-index: 1;
min-height: 0;
overflow: auto;
scrollbar-width: none;
}
.pane::-webkit-scrollbar {
display: none;
}
.source-pane {
flex: 0 0 auto;
background: var(--surface-panel);
box-shadow: inset 0 -1px 0 var(--border);
transition: box-shadow var(--duration-fast) var(--ease);
}
.source-pane:focus-within {
box-shadow: inset 0 -2px 0 var(--accent);
}
.result-pane {
flex: 1 1 auto;
min-height: 120px;
/* 底部留出窗口圆角的空间,避免内容直角压在圆角窗角上 */
padding-bottom: var(--space-2);
}
.input {
width: 100%;
height: 100%;
min-height: 100%;
resize: none;
border: none;
outline: none;
overflow: auto;
scrollbar-width: none;
background: transparent;
color: var(--text);
font-size: var(--text-lg);
line-height: 1.55;
font-family: inherit;
padding: var(--space-3) var(--space-4);
}
.input::-webkit-scrollbar {
display: none;
}
.input::placeholder {
color: var(--text-faint);
}
.language-strip {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
background: var(--surface);
box-shadow: inset 0 -1px 0 var(--border);
}
.resize-handle {
position: absolute;
inset: -7px 0 auto 0;
height: 14px;
border: none;
background: transparent;
cursor: ns-resize;
}
.toolbar-actions {
display: inline-flex;
align-items: center;
gap: 0;
}
.md-icon-button {
position: relative;
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
overflow: hidden;
transition:
color var(--duration-fast) var(--ease),
background var(--duration-fast) var(--ease);
}
.md-icon-button svg {
position: relative;
z-index: 1;
width: 18px;
height: 18px;
fill: currentColor;
}
.md-icon-button .state-layer {
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease);
}
.md-icon-button:hover {
color: var(--text);
}
.md-icon-button:hover .state-layer {
opacity: var(--state-hover);
}
.md-icon-button:active .state-layer {
opacity: var(--state-pressed);
}
.md-icon-button.pin.active {
background: var(--accent-soft);
color: var(--accent);
}
</style>
-12
View File
@@ -1,12 +0,0 @@
import { describe, expect, test } from "vitest";
import { clampSourceHeight } from "./panelSizing";
describe("clampSourceHeight", () => {
test("shrinks the source pane when the window is restored at 300px high", () => {
expect(clampSourceHeight(210, 300)).toBe(88);
});
test("keeps the default source height at the normal 460px window height", () => {
expect(clampSourceHeight(210, 460)).toBe(210);
});
});
-20
View File
@@ -1,20 +0,0 @@
export const MIN_SOURCE_HEIGHT = 88;
export const MIN_RESULT_HEIGHT = 120;
export const PANEL_CHROME_HEIGHT = 128;
export function maxSourceHeight(panelHeight: number): number {
return Math.max(
MIN_SOURCE_HEIGHT,
panelHeight - MIN_RESULT_HEIGHT - PANEL_CHROME_HEIGHT,
);
}
export function clampSourceHeight(
sourceHeight: number,
panelHeight: number,
): number {
return Math.min(
Math.max(sourceHeight, MIN_SOURCE_HEIGHT),
maxSourceHeight(panelHeight),
);
}
-286
View File
@@ -1,286 +0,0 @@
import { describe, expect, test } from "vitest";
import {
applyShortcutRecord,
clearShortcutField,
recordShortcutFromEvent,
type ShortcutDraft,
type ShortcutKeyboardEvent,
} from "./shortcutRecorder";
function keyboardEvent(
overrides: Partial<ShortcutKeyboardEvent>,
): ShortcutKeyboardEvent {
return {
key: "s",
code: "KeyS",
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
repeat: false,
...overrides,
};
}
describe("recordShortcutFromEvent", () => {
test("records command shift space in the backend shortcut format", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: " ",
code: "Space",
metaKey: true,
shiftKey: true,
}),
);
expect(result).toEqual({
status: "recorded",
shortcut: "CmdOrCtrl+Shift+Space",
});
});
test("records option space", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: " ",
code: "Space",
altKey: true,
}),
);
expect(result).toEqual({ status: "recorded", shortcut: "Option+Space" });
});
test("normalizes letter keys from the physical keyboard code", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: "s",
code: "KeyS",
metaKey: true,
shiftKey: true,
}),
);
expect(result).toEqual({ status: "recorded", shortcut: "CmdOrCtrl+Shift+S" });
});
test("outputs modifiers in a stable order", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: "k",
code: "KeyK",
shiftKey: true,
altKey: true,
ctrlKey: true,
metaKey: true,
}),
);
expect(result).toEqual({
status: "recorded",
shortcut: "CmdOrCtrl+Control+Option+Shift+K",
});
});
test("records function keys", () => {
const result = recordShortcutFromEvent(
keyboardEvent({ key: "F1", code: "F1", metaKey: true }),
);
expect(result).toEqual({ status: "recorded", shortcut: "CmdOrCtrl+F1" });
});
test("records arrow keys", () => {
const result = recordShortcutFromEvent(
keyboardEvent({ key: "ArrowUp", code: "ArrowUp", metaKey: true }),
);
expect(result).toEqual({
status: "recorded",
shortcut: "CmdOrCtrl+ArrowUp",
});
});
test("records named editing keys", () => {
const result = recordShortcutFromEvent(
keyboardEvent({ key: "Enter", code: "Enter", ctrlKey: true }),
);
expect(result).toEqual({ status: "recorded", shortcut: "Control+Enter" });
});
test("keeps waiting when the user only presses a modifier key", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: "Shift",
code: "ShiftLeft",
shiftKey: true,
}),
);
expect(result).toEqual({ status: "pending" });
});
test("rejects shift-only shortcuts", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: "s",
code: "KeyS",
shiftKey: true,
}),
);
expect(result.status).toBe("invalid");
if (result.status === "invalid") {
expect(result.message).toContain("Cmd/Ctrl/Option");
}
});
test("rejects main keys without a modifier", () => {
const result = recordShortcutFromEvent(
keyboardEvent({ key: "s", code: "KeyS" }),
);
expect(result.status).toBe("invalid");
if (result.status === "invalid") {
expect(result.message).toContain("修饰键");
}
});
test("cancels recording with Escape", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: "Escape",
code: "Escape",
metaKey: true,
}),
);
expect(result).toEqual({ status: "cancelled" });
});
test("ignores repeated keydown events", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: "s",
code: "KeyS",
metaKey: true,
repeat: true,
}),
);
expect(result).toEqual({ status: "pending" });
});
test("rejects unsupported physical keys", () => {
const result = recordShortcutFromEvent(
keyboardEvent({
key: "¥",
code: "IntlYen",
metaKey: true,
}),
);
expect(result.status).toBe("invalid");
if (result.status === "invalid") {
expect(result.message).toContain("不支持");
}
});
});
describe("applyShortcutRecord", () => {
const draft: ShortcutDraft = {
toggle: "CmdOrCtrl+Shift+Space",
ocr: "CmdOrCtrl+Shift+S",
ai_dialog: "",
selection_translate: "",
selection_ai_dialog: "",
};
test("updates only the selected shortcut field", () => {
const updated = applyShortcutRecord(draft, "ocr", {
status: "recorded",
shortcut: "Option+Space",
});
expect(updated).toEqual({
toggle: "CmdOrCtrl+Shift+Space",
ocr: "Option+Space",
ai_dialog: "",
selection_translate: "",
selection_ai_dialog: "",
});
expect(draft).toEqual({
toggle: "CmdOrCtrl+Shift+Space",
ocr: "CmdOrCtrl+Shift+S",
ai_dialog: "",
selection_translate: "",
selection_ai_dialog: "",
});
});
test("updates only the AI dialog shortcut field", () => {
const updated = applyShortcutRecord(draft, "ai_dialog", {
status: "recorded",
shortcut: "CmdOrCtrl+Shift+A",
});
expect(updated).toEqual({
toggle: "CmdOrCtrl+Shift+Space",
ocr: "CmdOrCtrl+Shift+S",
ai_dialog: "CmdOrCtrl+Shift+A",
selection_translate: "",
selection_ai_dialog: "",
});
expect(draft.ai_dialog).toBe("");
});
test("leaves the draft unchanged for invalid input", () => {
const updated = applyShortcutRecord(draft, "toggle", {
status: "invalid",
message: "快捷键必须包含至少一个修饰键",
});
expect(updated).toBe(draft);
});
test("leaves the draft unchanged for pending and cancelled input", () => {
expect(applyShortcutRecord(draft, "ai_dialog", { status: "pending" })).toBe(
draft,
);
expect(applyShortcutRecord(draft, "ai_dialog", { status: "cancelled" })).toBe(
draft,
);
});
test("clears only the selected shortcut field", () => {
const configured: ShortcutDraft = {
...draft,
ai_dialog: "CmdOrCtrl+Shift+A",
};
expect(clearShortcutField(configured, "ai_dialog")).toEqual({
toggle: "CmdOrCtrl+Shift+Space",
ocr: "CmdOrCtrl+Shift+S",
ai_dialog: "",
selection_translate: "",
selection_ai_dialog: "",
});
});
test("clears a selection shortcut field independently", () => {
const configured: ShortcutDraft = {
...draft,
selection_translate: "CmdOrCtrl+Shift+T",
selection_ai_dialog: "CmdOrCtrl+Shift+D",
};
expect(clearShortcutField(configured, "selection_translate")).toEqual({
toggle: "CmdOrCtrl+Shift+Space",
ocr: "CmdOrCtrl+Shift+S",
ai_dialog: "",
selection_translate: "",
selection_ai_dialog: "CmdOrCtrl+Shift+D",
});
});
});
-183
View File
@@ -1,183 +0,0 @@
export type ShortcutField =
| "toggle"
| "ocr"
| "ai_dialog"
| "selection_translate"
| "selection_ai_dialog";
export interface ShortcutDraft {
toggle: string;
ocr: string;
ai_dialog: string;
selection_translate: string;
selection_ai_dialog: string;
}
export interface ShortcutKeyboardEvent {
key: string;
code: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
repeat: boolean;
}
export type ShortcutRecordResult =
| { status: "recorded"; shortcut: string }
| { status: "invalid"; message: string }
| { status: "cancelled" }
| { status: "pending" };
const MODIFIER_KEYS = new Set([
"Alt",
"AltGraph",
"Control",
"Meta",
"Shift",
]);
const MODIFIER_CODES = new Set([
"AltLeft",
"AltRight",
"ControlLeft",
"ControlRight",
"MetaLeft",
"MetaRight",
"ShiftLeft",
"ShiftRight",
]);
const NAMED_KEYS = new Map<string, string>([
["ArrowDown", "ArrowDown"],
["ArrowLeft", "ArrowLeft"],
["ArrowRight", "ArrowRight"],
["ArrowUp", "ArrowUp"],
["Backspace", "Backspace"],
["Delete", "Delete"],
["End", "End"],
["Enter", "Enter"],
["Home", "Home"],
["Insert", "Insert"],
["PageDown", "PageDown"],
["PageUp", "PageUp"],
["Space", "Space"],
["Tab", "Tab"],
]);
const PUNCTUATION_KEYS = new Map<string, string>([
["Backquote", "`"],
["Backslash", "\\"],
["BracketLeft", "["],
["BracketRight", "]"],
["Comma", ","],
["Equal", "="],
["Minus", "-"],
["Period", "."],
["Quote", "'"],
["Semicolon", ";"],
["Slash", "/"],
]);
export function recordShortcutFromEvent(
event: ShortcutKeyboardEvent,
): ShortcutRecordResult {
if (event.key === "Escape" || event.code === "Escape") {
return { status: "cancelled" };
}
if (event.repeat || isModifierOnlyEvent(event)) {
return { status: "pending" };
}
if (!hasRequiredModifier(event)) {
return {
status: "invalid",
message: "快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键。",
};
}
const mainKey = getMainKey(event.code);
if (!mainKey) {
return {
status: "invalid",
message: "不支持这个按键,请选择字母、数字、方向键或功能键。",
};
}
return {
status: "recorded",
shortcut: [...getModifierParts(event), mainKey].join("+"),
};
}
export function applyShortcutRecord(
draft: ShortcutDraft,
field: ShortcutField,
result: ShortcutRecordResult,
): ShortcutDraft {
if (result.status !== "recorded") {
return draft;
}
return {
...draft,
[field]: result.shortcut,
};
}
export function clearShortcutField(
draft: ShortcutDraft,
field: ShortcutField,
): ShortcutDraft {
return {
...draft,
[field]: "",
};
}
function hasRequiredModifier(event: ShortcutKeyboardEvent): boolean {
return event.metaKey || event.ctrlKey || event.altKey;
}
function isModifierOnlyEvent(event: ShortcutKeyboardEvent): boolean {
return MODIFIER_KEYS.has(event.key) || MODIFIER_CODES.has(event.code);
}
function getModifierParts(event: ShortcutKeyboardEvent): string[] {
const parts: string[] = [];
if (event.metaKey) {
parts.push("CmdOrCtrl");
}
if (event.ctrlKey) {
parts.push("Control");
}
if (event.altKey) {
parts.push("Option");
}
if (event.shiftKey) {
parts.push("Shift");
}
return parts;
}
function getMainKey(code: string): string | null {
if (/^Key[A-Z]$/.test(code)) {
return code.slice(3);
}
if (/^Digit[0-9]$/.test(code)) {
return code.slice(5);
}
if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code)) {
return code;
}
return NAMED_KEYS.get(code) ?? PUNCTUATION_KEYS.get(code) ?? null;
}
-35
View File
@@ -1,35 +0,0 @@
/** 外观状态:面板透明度等。在应用根上设置 CSS 变量,供各面板背景引用。 */
import { getAppearanceConfig } from "../api/tauri";
const ROOT_VAR = "--panel-opacity";
/** 透明度下限与后端 config.rs PANEL_OPACITY_MIN(0.35) 对齐,避免前后端钳制范围不一致。 */
const OPACITY_MIN = 0.35;
const OPACITY_MAX = 1;
class AppearanceStore {
/** 面板透明度 0–1;默认完全不透明。 */
opacity = $state(1);
loaded = $state(false);
/** 拉取持久化的外观配置,并应用到 document root。 */
async load() {
try {
const cfg = await getAppearanceConfig();
this.set(cfg.panel_opacity);
} catch {
this.set(1);
}
this.loaded = true;
}
/** 更新透明度并实时应用到 DOM(不落盘,由调用方决定何时保存)。 */
set(opacity: number) {
const clamped = Math.min(OPACITY_MAX, Math.max(OPACITY_MIN, opacity));
this.opacity = clamped;
if (typeof document !== "undefined") {
document.documentElement.style.setProperty(ROOT_VAR, String(clamped));
}
}
}
export const appearance = new AppearanceStore();
-328
View File
@@ -1,328 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const api = vi.hoisted(() => ({
append: vi.fn(),
chat: vi.fn(),
clear: vi.fn(),
load: vi.fn(),
onChunk: vi.fn(),
start: vi.fn(),
}));
vi.mock("../api/tauri", () => ({
appendChatHistory: api.append,
chat: api.chat,
clearChatHistory: api.clear,
loadConversations: api.load,
onChatChunk: api.onChunk,
startNewConversation: api.start,
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("chat history persistence", () => {
beforeEach(() => {
vi.resetModules();
Object.values(api).forEach((mock) => mock.mockReset());
});
it("persists a follow-up against the opened historical conversation", async () => {
const conversations = [
{
id: 0,
summary: "A",
messages: [
{ role: "user" as const, content: "A1" },
{ role: "assistant" as const, content: "A2" },
],
},
{
id: 1,
summary: "B",
messages: [
{ role: "user" as const, content: "B1" },
{ role: "assistant" as const, content: "B2" },
],
},
];
api.load.mockResolvedValue(conversations);
api.chat.mockResolvedValue(undefined);
api.append.mockResolvedValue(undefined);
api.onChunk.mockResolvedValue(() => {});
const { chatStore } = await import("./chat.svelte");
await chatStore.loadHistory();
chatStore.openConversation(0);
chatStore.state.input = "A3";
await chatStore.send();
const chunkHandler = api.onChunk.mock.calls[0]?.[0] as (
chunk: { request_id: null; delta: string; done: boolean },
) => void;
chunkHandler({ request_id: null, delta: "A4", done: false });
chunkHandler({ request_id: null, delta: "", done: true });
await vi.waitFor(() => {
expect(api.append).toHaveBeenCalledWith(
[
{ role: "user", content: "A3" },
{ role: "assistant", content: "A4" },
],
0,
);
});
});
it("waits for the new-session marker before sending the first message", async () => {
let resolveStart!: () => void;
const start = new Promise<void>((resolve) => {
resolveStart = resolve;
});
api.load.mockResolvedValue([]);
api.start.mockReturnValue(start);
api.chat.mockResolvedValue(undefined);
api.onChunk.mockResolvedValue(() => {});
const { chatStore } = await import("./chat.svelte");
await chatStore.loadHistory();
chatStore.newConversation();
chatStore.state.input = "first message";
const sending = chatStore.send();
await vi.waitFor(() => expect(api.start).toHaveBeenCalledOnce());
expect(api.chat).not.toHaveBeenCalled();
resolveStart();
await sending;
expect(api.chat).toHaveBeenCalledOnce();
});
it("recovers when the chunk listener cannot be registered", async () => {
api.load.mockResolvedValue([]);
api.chat.mockResolvedValue(undefined);
api.onChunk
.mockRejectedValueOnce(new Error("listener unavailable"))
.mockResolvedValueOnce(() => {});
const { chatStore } = await import("./chat.svelte");
await chatStore.loadHistory();
chatStore.state.input = "first";
await expect(chatStore.send()).resolves.toBeUndefined();
expect(chatStore.state.loading).toBe(false);
expect(chatStore.state.error).toContain("listener unavailable");
expect(api.chat).not.toHaveBeenCalled();
chatStore.state.input = "second";
await chatStore.send();
expect(api.onChunk).toHaveBeenCalledTimes(2);
expect(api.chat).toHaveBeenCalledOnce();
});
it("waits for startup history before sending into the active conversation", async () => {
const history = deferred<
Array<{
id: number;
summary: string;
messages: Array<{ role: "user" | "assistant"; content: string }>;
}>
>();
api.load.mockReturnValue(history.promise);
api.chat.mockResolvedValue(undefined);
api.onChunk.mockResolvedValue(() => {});
const { chatStore } = await import("./chat.svelte");
const loading = chatStore.loadHistory();
chatStore.state.input = "follow up";
const sending = chatStore.send();
await Promise.resolve();
expect(api.chat).not.toHaveBeenCalled();
history.resolve([
{
id: 4,
summary: "existing",
messages: [
{ role: "user", content: "old question" },
{ role: "assistant", content: "old answer" },
],
},
]);
await Promise.all([loading, sending]);
expect(chatStore.state.currentId).toBe(4);
expect(api.chat).toHaveBeenCalledWith([
{ role: "user", content: "old question" },
{ role: "assistant", content: "old answer" },
{ role: "user", content: "follow up" },
]);
});
it("does not start two chat requests when sends queue behind startup loading", async () => {
const history = deferred<[]>();
api.load.mockReturnValue(history.promise);
api.chat.mockResolvedValue(undefined);
api.onChunk.mockResolvedValue(() => {});
const { chatStore } = await import("./chat.svelte");
const loading = chatStore.loadHistory();
const first = chatStore.sendText("first");
const second = chatStore.sendText("second");
history.resolve([]);
await Promise.all([loading, first, second]);
expect(api.chat).toHaveBeenCalledOnce();
expect(chatStore.state.input).toBe("second");
expect(chatStore.state.error).toContain("仍在生成");
});
it("does not start two chat requests behind a new-session marker", async () => {
const marker = deferred<void>();
api.load.mockResolvedValue([]);
api.start.mockReturnValue(marker.promise);
api.chat.mockResolvedValue(undefined);
api.onChunk.mockResolvedValue(() => {});
const { chatStore } = await import("./chat.svelte");
await chatStore.loadHistory();
chatStore.newConversation();
const first = chatStore.sendText("first");
const second = chatStore.sendText("second");
marker.resolve();
await Promise.all([first, second]);
expect(api.chat).toHaveBeenCalledOnce();
expect(chatStore.state.input).toBe("second");
});
it("serializes clear behind startup loading", async () => {
const history = deferred<
Array<{
id: number;
summary: string;
messages: Array<{ role: "user" | "assistant"; content: string }>;
}>
>();
api.load.mockReturnValue(history.promise);
api.clear.mockResolvedValue(undefined);
const { chatStore } = await import("./chat.svelte");
const loading = chatStore.loadHistory();
const clearing = chatStore.clearHistory();
await Promise.resolve();
expect(api.clear).not.toHaveBeenCalled();
history.resolve([
{
id: 2,
summary: "existing",
messages: [{ role: "user", content: "old" }],
},
]);
await Promise.all([loading, clearing]);
expect(api.clear).toHaveBeenCalledOnce();
expect(chatStore.state.conversations).toEqual([]);
expect(chatStore.state.messages).toEqual([]);
});
it("preserves the visible history when backend clearing fails", async () => {
const conversations = [
{
id: 3,
summary: "keep me",
messages: [
{ role: "user" as const, content: "question" },
{ role: "assistant" as const, content: "answer" },
],
},
];
api.load.mockResolvedValue(conversations);
api.clear.mockRejectedValue(new Error("disk denied"));
const { chatStore } = await import("./chat.svelte");
await chatStore.loadHistory();
await chatStore.clearHistory();
await vi.waitFor(() => expect(chatStore.state.error).toContain("disk denied"));
expect(chatStore.state.conversations).toEqual(conversations);
expect(chatStore.state.messages).toEqual(conversations[0].messages);
expect(chatStore.state.currentId).toBe(3);
});
it("retries history loading after a transient failure", async () => {
api.load
.mockRejectedValueOnce(new Error("temporary read failure"))
.mockResolvedValueOnce([
{
id: 8,
summary: "recovered",
messages: [{ role: "user", content: "restored" }],
},
]);
const { chatStore } = await import("./chat.svelte");
await chatStore.loadHistory();
expect(chatStore.state.loaded).toBe(false);
await chatStore.loadHistory();
expect(api.load).toHaveBeenCalledTimes(2);
expect(chatStore.state.loaded).toBe(true);
expect(chatStore.state.currentId).toBe(8);
});
it("keeps blocking sends after a new-session marker fails", async () => {
api.load.mockResolvedValue([]);
api.start.mockRejectedValue(new Error("marker write failed"));
api.chat.mockResolvedValue(undefined);
api.onChunk.mockResolvedValue(() => {});
const { chatStore } = await import("./chat.svelte");
await chatStore.loadHistory();
chatStore.newConversation();
await chatStore.sendText("first");
await chatStore.sendText("second");
expect(api.chat).not.toHaveBeenCalled();
expect(chatStore.state.input).toBe("second");
expect(chatStore.state.error).toContain("重新");
});
it("does not clear history while a send is waiting for the session marker", async () => {
const marker = deferred<void>();
api.load.mockResolvedValue([]);
api.start.mockReturnValue(marker.promise);
api.clear.mockResolvedValue(undefined);
api.chat.mockResolvedValue(undefined);
api.onChunk.mockResolvedValue(() => {});
const { chatStore } = await import("./chat.svelte");
await chatStore.loadHistory();
chatStore.newConversation();
const sending = chatStore.sendText("pending");
await chatStore.clearHistory();
expect(api.clear).not.toHaveBeenCalled();
expect(chatStore.state.error).toContain("正在");
marker.resolve();
await sending;
});
});
-390
View File
@@ -1,390 +0,0 @@
/** AI 对话状态与编排(Svelte 5 runes)。 */
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
appendChatHistory as apiAppendHistory,
chat as apiChat,
clearChatHistory as apiClearHistory,
loadConversations as apiLoadConversations,
onChatChunk,
startNewConversation as apiStartNewConversation,
type ChatMessage,
type Conversation,
} from "../api/tauri";
import { recentChatContext } from "./chatContext";
const SEND_BUSY_ERROR =
"上一条消息正在准备或 AI 回复仍在生成,已将内容放入输入框。";
const SEND_PREPARING_ERROR = "消息正在准备发送,请稍候。";
interface ChatState {
messages: ChatMessage[];
input: string;
loading: boolean;
error: string | null;
/** 全部历史对话(最久未活动 → 最近活动),供顶栏对话列表使用。 */
conversations: Conversation[];
/** 当前对话的 id;null 表示一段尚未落盘的新对话。 */
currentId: number | null;
/** 是否已从磁盘加载历史;避免空闪与重复覆盖。 */
loaded: boolean;
}
function createChatStore() {
const state = $state<ChatState>({
messages: [],
input: "",
loading: false,
error: null,
conversations: [],
currentId: null,
loaded: false,
});
let unlisten: UnlistenFn | null = null;
/** 标记用户已显式开新对话/切换对话:阻止 loadHistory 的自动恢复覆盖。 */
let userStartedSession = false;
/** 进行中的加载任务:避免并发调用重复请求并互相覆盖。 */
let loadPromise: Promise<void> | null = null;
/** 最近一次历史加载错误;成功重试时仅清除这条错误。 */
let loadError: string | null = null;
/** 新对话分隔标记的落盘任务;发送前必须等待它完成。 */
let sessionStartPromise: Promise<boolean> | null = null;
/** 新对话分隔标记失败后持续阻断发送,直到显式恢复到有效会话。 */
let sessionStartError: string | null = null;
/** 清空历史任务;发送/切换必须等待,避免与读取或追加并发。 */
let clearPromise: Promise<void> | null = null;
/** 发送正在等待历史加载或新会话标记;期间禁止改变会话边界。 */
let sendPreparing = false;
/** 启动时加载全部历史对话,并默认显示最新的一段(恢复上次离开的位置)。 */
function loadHistory(): Promise<void> {
if (state.loaded || loadPromise) return loadPromise ?? Promise.resolve();
loadPromise = doLoadHistory();
return loadPromise;
}
async function doLoadHistory() {
try {
const conversations = await apiLoadConversations();
state.conversations = conversations;
// 仅在用户尚未介入(开新对话/切换对话)时,自动恢复最新一段。
if (!userStartedSession) {
const latest = conversations[conversations.length - 1];
if (latest) {
state.messages = [...latest.messages];
state.currentId = latest.id;
} else {
state.currentId = null;
}
}
state.loaded = true;
if (loadError && state.error === loadError) {
state.error = null;
}
loadError = null;
} catch (e) {
state.loaded = false;
loadError = typeof e === "string" ? e : String(e);
state.error = loadError;
} finally {
loadPromise = null;
}
}
async function ensureChunkListener() {
if (unlisten) return;
unlisten = await onChatChunk((chunk) => {
const lastIndex = state.messages.length - 1;
const last = state.messages[lastIndex];
if (last && last.role === "assistant") {
state.messages = state.messages.map((message, index) =>
index === lastIndex
? { ...message, content: message.content + chunk.delta }
: message,
);
}
if (chunk.done) {
// 一轮结束:把这轮(用户消息 + 完整 AI 回复)持久化。
void persistLastRound().finally(() => {
state.loading = false;
});
}
});
}
/** 把最后一轮对话追加写入历史文件;AI 空回复不写入,避免污染历史。
* 落盘后以后端为唯一事实来源重读对话列表,避免本地 id 猜测与后端序号错位。 */
async function persistLastRound() {
const msgs = state.messages;
if (msgs.length < 2) return;
const user = msgs[msgs.length - 2];
const assistant = msgs[msgs.length - 1];
if (user.role !== "user" || assistant.role !== "assistant") return;
if (assistant.content.trim() === "") return;
const conversationId = state.currentId;
try {
await apiAppendHistory(
[
{ role: "user", content: user.content },
{ role: "assistant", content: assistant.content },
],
conversationId,
);
const conversations = await apiLoadConversations();
state.conversations = conversations;
if (conversationId === null) {
const latest = conversations[conversations.length - 1];
state.currentId = latest?.id ?? null;
}
} catch (e) {
state.error = `保存对话历史失败: ${typeof e === "string" ? e : String(e)}`;
}
}
async function send() {
const text = state.input.trim();
if (!text) return;
if (state.loading || sendPreparing) {
preserveBusyInput(text);
return;
}
state.input = "";
await sendMessage(text);
}
async function sendText(value: string) {
const text = value.trim();
if (!text) return;
if (state.loading || sendPreparing) {
preserveBusyInput(text);
return;
}
await sendMessage(text);
}
function preserveBusyInput(text: string) {
state.input = text;
state.error = SEND_BUSY_ERROR;
}
function preserveSessionBlockedInput(text: string) {
state.input = text;
state.error =
sessionStartError ??
"新对话尚未成功创建,请重新开启新对话、选择已有对话或清空历史后再发送。";
}
function clearSessionStartBlock() {
sessionStartPromise = null;
sessionStartError = null;
}
async function sendMessage(text: string) {
if (state.loading || sendPreparing) {
preserveBusyInput(text);
return;
}
sendPreparing = true;
try {
if (clearPromise) {
await clearPromise;
}
await loadHistory();
if (!state.loaded) {
state.input = text;
return;
}
if (clearPromise) {
await clearPromise;
}
if (state.loading) {
preserveBusyInput(text);
return;
}
if (sessionStartError) {
preserveSessionBlockedInput(text);
return;
}
if (sessionStartPromise) {
const started = await sessionStartPromise;
if (!started || sessionStartError) {
preserveSessionBlockedInput(text);
return;
}
}
if (state.loading) {
preserveBusyInput(text);
return;
}
const keepBusyNotice =
state.error === SEND_BUSY_ERROR &&
state.input.trim() !== "" &&
state.input.trim() !== text;
if (!keepBusyNotice) {
state.error = null;
}
state.messages = [
...state.messages,
{ role: "user", content: text },
{ role: "assistant", content: "" },
];
state.loading = true;
} finally {
sendPreparing = false;
}
try {
await ensureChunkListener();
await apiChat(recentChatContext(state.messages.slice(0, -1)));
} catch (e) {
state.error = typeof e === "string" ? e : String(e);
state.loading = false;
// 失败/中断:移除空的 assistant 占位,不写入历史
const last = state.messages[state.messages.length - 1];
if (last && last.role === "assistant" && last.content === "") {
state.messages = state.messages.slice(0, -1);
}
}
}
function clearHistory(): Promise<void> {
if (clearPromise) return clearPromise;
if (sendPreparing) {
state.error = SEND_PREPARING_ERROR;
return Promise.resolve();
}
if (state.loading) {
state.error = "AI 回复仍在生成,请等待完成后再清空历史。";
return Promise.resolve();
}
const clearing = clearHistoryAfterLoad().finally(() => {
if (clearPromise === clearing) {
clearPromise = null;
}
});
clearPromise = clearing;
return clearing;
}
async function clearHistoryAfterLoad() {
await loadHistory();
if (state.loading) {
state.error = "AI 回复仍在生成,请等待完成后再清空历史。";
return;
}
try {
await apiClearHistory();
userStartedSession = true;
state.messages = [];
state.conversations = [];
state.currentId = null;
state.loaded = true;
loadError = null;
clearSessionStartBlock();
state.error = null;
} catch (e) {
state.error = `清空对话历史失败: ${typeof e === "string" ? e : String(e)}`;
}
}
/** 开启一段新对话:写分隔标记 + 清空当前消息视图(历史保留在列表中)。 */
function newConversation() {
if (sendPreparing) {
state.error = SEND_PREPARING_ERROR;
return;
}
if (state.loading) {
state.error = "AI 回复仍在生成,请等待完成后再开新对话。";
return;
}
if (clearPromise) {
state.error = "正在清空对话历史,请稍候。";
return;
}
userStartedSession = true;
state.messages = [];
state.input = "";
state.error = null;
state.currentId = null;
sessionStartError = null;
const start = apiStartNewConversation()
.then(() => {
if (sessionStartPromise === start) {
sessionStartError = null;
}
return true;
})
.catch((e) => {
if (sessionStartPromise === start) {
sessionStartError = `开启新对话失败: ${
typeof e === "string" ? e : String(e)
}。请重新开启新对话、选择已有对话或清空历史后再发送。`;
state.error = sessionStartError;
}
return false;
})
.finally(() => {
if (sessionStartPromise === start) {
sessionStartPromise = null;
}
});
sessionStartPromise = start;
}
/** 打开指定历史对话,加载其全部消息到当前视图。 */
function openConversation(id: number) {
if (sendPreparing) {
state.error = SEND_PREPARING_ERROR;
return;
}
if (state.loading) {
state.error = "AI 回复仍在生成,无法切换对话。";
return;
}
if (clearPromise) {
state.error = "正在清空对话历史,请稍候。";
return;
}
const target = state.conversations.find((c) => c.id === id);
if (!target) return;
userStartedSession = true;
state.messages = [...target.messages];
state.currentId = id;
state.input = "";
clearSessionStartBlock();
state.error = null;
}
function reset() {
// 呼出快捷键使用:开新对话,与 UI「新对话」按钮一致。
newConversation();
}
return {
get state() {
return state;
},
loadHistory,
send,
sendText,
clearHistory,
newConversation,
openConversation,
reset,
};
}
export const chatStore = createChatStore();
-56
View File
@@ -1,56 +0,0 @@
import { describe, expect, test } from "vitest";
import type { ChatMessage } from "../api/tauri";
import { CHAT_CONTEXT_LIMIT, recentChatContext } from "./chatContext";
function message(index: number): ChatMessage {
return {
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index}`,
};
}
describe("recentChatContext", () => {
test("returns all messages when under the context limit", () => {
const messages = Array.from({ length: 4 }, (_, index) => message(index));
const context = recentChatContext(messages);
expect(context).toEqual(messages);
});
test("keeps only the latest messages when history exceeds the context limit", () => {
const messages = Array.from({ length: CHAT_CONTEXT_LIMIT + 6 }, (_, index) =>
message(index),
);
const context = recentChatContext(messages);
expect(context).toHaveLength(CHAT_CONTEXT_LIMIT);
expect(context[0].content).toBe("message-6");
expect(context.at(-1)?.content).toBe(`message-${CHAT_CONTEXT_LIMIT + 5}`);
});
test("drops a leading orphan assistant message after trimming", () => {
const messages = Array.from({ length: CHAT_CONTEXT_LIMIT + 5 }, (_, index) =>
message(index),
);
const context = recentChatContext(messages);
expect(context[0].role).toBe("user");
expect(context[0].content).toBe("message-6");
expect(context.at(-1)?.content).toBe(`message-${CHAT_CONTEXT_LIMIT + 4}`);
});
test("returns empty array when the only surviving message is an orphan assistant", () => {
// 切窗后首条恰好是 assistant(异常但需容错):应被丢弃,结果为空数组。
const messages: ChatMessage[] = [
{ role: "assistant", content: "orphan" },
{ role: "assistant", content: "orphan-2" },
];
const context = recentChatContext(messages);
expect(context).toEqual([]);
});
});
-16
View File
@@ -1,16 +0,0 @@
import type { ChatMessage } from "../api/tauri";
export const CHAT_CONTEXT_LIMIT = 24;
export function recentChatContext(messages: ChatMessage[]): ChatMessage[] {
const context = messages.slice(-CHAT_CONTEXT_LIMIT);
// 丢弃切窗后开头所有「孤儿」assistant 消息:把 AI 回复作为上下文首条无意义,
// 且会让上游认为对话由 assistant 开场。找到首条非 assistant,从其开始截取。
const firstUser = context.findIndex((m) => m.role !== "assistant");
if (firstUser <= 0) {
// 0:首条已是非 assistant,原样返回;-1:全部是 assistant,丢弃为空数组。
return firstUser === 0 ? context : [];
}
return context.slice(firstUser);
}
-18
View File
@@ -1,18 +0,0 @@
import { describe, expect, test } from "vitest";
import { resolveAdaptiveTarget } from "./languages";
describe("resolveAdaptiveTarget", () => {
test("returns English target when input contains Chinese", () => {
expect(resolveAdaptiveTarget("你好,世界")).toBe("en");
expect(resolveAdaptiveTarget("hello 世界")).toBe("en");
});
test("returns Simplified Chinese target for English input", () => {
expect(resolveAdaptiveTarget("hello world")).toBe("zh-CN");
expect(resolveAdaptiveTarget("Translate this sentence, please.")).toBe("zh-CN");
});
test("returns Simplified Chinese target when no Chinese is detected", () => {
expect(resolveAdaptiveTarget("12345!?")).toBe("zh-CN");
});
});
-45
View File
@@ -1,45 +0,0 @@
/** 支持的语言列表。 */
export interface Lang {
code: string;
name: string;
}
/** 自适应目标:根据输入在中/英之间自动切换。 */
export const ADAPTIVE_TARGET = "adaptive";
export const LANGUAGES: Lang[] = [
{ code: "auto", name: "自动检测" },
{ code: "zh-CN", name: "中文(简体)" },
{ code: "zh-TW", name: "中文(繁体)" },
{ code: "en", name: "英语" },
{ code: "ja", name: "日语" },
{ code: "ko", name: "韩语" },
{ code: "fr", name: "法语" },
{ code: "de", name: "德语" },
{ code: "es", name: "西班牙语" },
{ code: "ru", name: "俄语" },
];
/** 目标语言:首项为自适应,其余不含 auto。 */
export const TARGET_LANGUAGES: Lang[] = [
{ code: ADAPTIVE_TARGET, name: "自适应 中⇄英" },
...LANGUAGES.filter((l) => l.code !== "auto"),
];
export function langName(code: string): string {
if (code === ADAPTIVE_TARGET) return "自适应 中⇄英";
return LANGUAGES.find((l) => l.code === code)?.name ?? code;
}
/**
* 自适应目标策略:主语言为中/英。
* 输入含中日韩表意文字(以中文为主)时翻译为英文,否则翻译为中文。
*/
export function resolveAdaptiveTarget(text: string): "en" | "zh-CN" {
return containsChinese(text) ? "en" : "zh-CN";
}
/** 文本是否包含中文字符(CJK 统一表意文字基本区)。 */
function containsChinese(text: string): boolean {
return /[一-鿿]/.test(text);
}
-5
View File
@@ -1,5 +0,0 @@
/** 快捷键展示标签(与后端 shortcut.rs 默认值保持一致)。 */
export const shortcut = {
toggle: "⌘⇧Space",
ocr: "⌘⇧S",
} as const;
-330
View File
@@ -1,330 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { createTranslationStore } from "./translation.svelte";
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function fakeDependencies() {
type TestChunk = {
request_id: number | null;
delta: string;
done: boolean;
};
let chunkHandler: ((chunk: TestChunk) => void) | null = null;
return {
translate: vi.fn(),
screenshotOcr: vi.fn(async () => ""),
showMain: vi.fn(async () => {}),
onChunk: vi.fn(async (handler: (chunk: TestChunk) => void) => {
chunkHandler = handler;
return vi.fn();
}),
emitChunk(chunk: TestChunk) {
chunkHandler?.(chunk);
},
};
}
describe("translation store coordination", () => {
test("requests automatic free translation alongside AI", async () => {
const deps = fakeDependencies();
deps.translate.mockImplementation((req: { engine: string }) =>
Promise.resolve(req.engine === "auto" ? "automatic result" : ""),
);
const store = createTranslationStore(deps);
await store.translateText("hello");
expect(deps.translate).toHaveBeenCalledWith(
expect.objectContaining({ engine: "auto", text: "hello" }),
);
expect(deps.translate).toHaveBeenCalledWith(
expect.objectContaining({ engine: "openai", text: "hello" }),
);
expect(store.state.webOutput).toBe("automatic result");
});
test("ignores repeated OCR triggers while capture is pending", async () => {
const deps = fakeDependencies();
const capture = deferred<string>();
deps.screenshotOcr.mockReturnValue(capture.promise);
const store = createTranslationStore(deps);
const first = store.captureAndTranslate();
await store.captureAndTranslate();
expect(deps.screenshotOcr).toHaveBeenCalledOnce();
capture.resolve("");
await first;
await store.captureAndTranslate();
expect(deps.screenshotOcr).toHaveBeenCalledTimes(2);
expect(deps.showMain).not.toHaveBeenCalled();
});
test("OCR failure is visible and a later capture can retry", async () => {
const deps = fakeDependencies();
deps.screenshotOcr.mockRejectedValueOnce("OCR failed").mockResolvedValue("");
const store = createTranslationStore(deps);
await store.captureAndTranslate();
expect(store.state.error).toBe("OCR failed");
expect(deps.showMain).toHaveBeenCalledOnce();
await store.captureAndTranslate();
expect(deps.screenshotOcr).toHaveBeenCalledTimes(2);
expect(store.state.error).toBeNull();
});
test("queues the latest selected text while a translation is running", async () => {
const deps = fakeDependencies();
const pending = new Map<string, Deferred<string>>();
deps.translate.mockImplementation((req: { text: string; engine: string }) => {
const key = `${req.text}:${req.engine}`;
const task = deferred<string>();
pending.set(key, task);
return task.promise;
});
const store = createTranslationStore(deps);
const firstRun = store.translateText("first");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2));
await store.translateText("second");
expect(store.state.input).toBe("second");
expect(store.state.error).toContain("排队");
pending.get("first:auto")?.resolve("first web");
pending.get("first:openai")?.resolve("");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4));
pending.get("second:auto")?.resolve("second web");
pending.get("second:openai")?.resolve("");
await firstRun;
expect(store.state.input).toBe("second");
expect(store.state.webOutput).toBe("second web");
expect(store.state.error).toBeNull();
});
test("manual input replaces an older queued translation", async () => {
const deps = fakeDependencies();
const pending = new Map<string, Deferred<string>>();
deps.translate.mockImplementation((req: { text: string; engine: string }) => {
const key = `${req.text}:${req.engine}`;
const task = deferred<string>();
pending.set(key, task);
return task.promise;
});
const store = createTranslationStore(deps);
const firstRun = store.translateText("first");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2));
await store.translateText("second");
store.setInput("third");
pending.get("first:auto")?.resolve("first web");
pending.get("first:openai")?.resolve("");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4));
expect(deps.translate).toHaveBeenCalledWith(
expect.objectContaining({ text: "third", engine: "auto" }),
);
expect(deps.translate).not.toHaveBeenCalledWith(
expect.objectContaining({ text: "second", engine: "auto" }),
);
pending.get("third:auto")?.resolve("third web");
pending.get("third:openai")?.resolve("");
await firstRun;
});
test("captures OCR during an active translation and queues its text", async () => {
const deps = fakeDependencies();
const firstWeb = deferred<string>();
const firstAi = deferred<string>();
deps.translate.mockImplementation((req: { text: string; engine: string }) => {
if (req.text === "first" && req.engine === "auto") return firstWeb.promise;
if (req.text === "first" && req.engine === "openai") return firstAi.promise;
return Promise.resolve(req.engine === "openai" ? "" : "OCR web");
});
deps.screenshotOcr.mockResolvedValue("captured text");
const store = createTranslationStore(deps);
const firstRun = store.translateText("first");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2));
await store.captureAndTranslate();
expect(deps.screenshotOcr).toHaveBeenCalledOnce();
expect(deps.showMain).toHaveBeenCalledOnce();
expect(store.state.input).toBe("captured text");
expect(store.state.error).toContain("排队");
firstWeb.resolve("first web");
firstAi.resolve("");
await firstRun;
});
test("listener initialization failure clears loading and can be retried", async () => {
const deps = fakeDependencies();
deps.onChunk.mockRejectedValueOnce(new Error("listener unavailable"));
deps.translate.mockResolvedValue("");
const store = createTranslationStore(deps);
await store.translateText("first");
expect(store.state.loading).toBe(false);
expect(store.state.aiLoading).toBe(false);
expect(store.state.aiError).toContain("listener unavailable");
await store.translateText("second");
expect(deps.onChunk).toHaveBeenCalledTimes(2);
expect(store.state.loading).toBe(false);
});
test("an older invoke completion cannot clear a newer AI loading state", async () => {
const deps = fakeDependencies();
const firstAi = deferred<string>();
const secondAi = deferred<string>();
deps.translate.mockImplementation((req: { text: string; engine: string }) => {
if (req.engine !== "openai") return Promise.resolve(`${req.text} web`);
return req.text === "first" ? firstAi.promise : secondAi.promise;
});
const store = createTranslationStore(deps);
const firstRun = store.translateText("first");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2));
deps.emitChunk({ request_id: 1, delta: "", done: true });
await vi.waitFor(() => expect(store.state.loading).toBe(false));
const secondRun = store.translateText("second");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4));
expect(store.state.aiLoading).toBe(true);
firstAi.resolve("");
await firstRun;
expect(store.state.aiLoading).toBe(true);
secondAi.resolve("");
await secondRun;
});
test("ignores stream chunks from a superseded AI request", async () => {
const deps = fakeDependencies();
const aiTasks = new Map<number, Deferred<string>>();
deps.translate.mockImplementation(
(req: { text: string; engine: string; request_id?: number }) => {
if (req.engine !== "openai") return Promise.resolve(`${req.text} web`);
const task = deferred<string>();
aiTasks.set(req.request_id ?? -1, task);
return task.promise;
},
);
const store = createTranslationStore(deps);
const firstRun = store.translateText("first");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2));
const firstRequest = deps.translate.mock.calls.find(
([req]) => req.engine === "openai" && req.text === "first",
)?.[0] as { request_id: number };
store.reset();
const secondRun = store.translateText("second");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4));
const secondRequest = deps.translate.mock.calls.find(
([req]) => req.engine === "openai" && req.text === "second",
)?.[0] as { request_id: number };
expect(firstRequest.request_id).not.toBe(secondRequest.request_id);
deps.emitChunk({
request_id: firstRequest.request_id,
delta: "stale",
done: false,
});
expect(store.state.aiOutput).toBe("");
deps.emitChunk({
request_id: secondRequest.request_id,
delta: "fresh",
done: false,
});
expect(store.state.aiOutput).toBe("fresh");
deps.emitChunk({
request_id: secondRequest.request_id,
delta: "",
done: true,
});
aiTasks.get(firstRequest.request_id)?.resolve("");
aiTasks.get(secondRequest.request_id)?.resolve("");
await Promise.all([firstRun, secondRun]);
});
test("clears a queued replacement when input returns to the active text", async () => {
const deps = fakeDependencies();
const firstWeb = deferred<string>();
const firstAi = deferred<string>();
deps.translate.mockImplementation((req: { text: string; engine: string }) => {
if (req.text !== "first") return Promise.resolve("unexpected");
return req.engine === "openai" ? firstAi.promise : firstWeb.promise;
});
const store = createTranslationStore(deps);
const running = store.translateText("first");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2));
await store.translateText("second");
store.setInput("first");
firstWeb.resolve("first web");
firstAi.resolve("");
await running;
expect(deps.translate).toHaveBeenCalledTimes(2);
expect(store.state.input).toBe("first");
expect(store.state.webOutput).toBe("first web");
});
test("restarts both translations when the target language changes", async () => {
const deps = fakeDependencies();
const tasks = new Map<string, Deferred<string>>();
deps.translate.mockImplementation(
(req: { text: string; target: string; engine: string }) => {
const task = deferred<string>();
tasks.set(`${req.target}:${req.engine}`, task);
return task.promise;
},
);
const store = createTranslationStore(deps);
const first = store.translateText("hello");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2));
const switched = store.setTarget("ja");
await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4));
tasks.get("zh-CN:auto")?.resolve("stale web");
tasks.get("zh-CN:openai")?.resolve("");
tasks.get("ja:auto")?.resolve("fresh web");
tasks.get("ja:openai")?.resolve("");
await Promise.all([first, switched]);
expect(store.state.target).toBe("ja");
expect(store.state.webOutput).toBe("fresh web");
});
});
-365
View File
@@ -1,365 +0,0 @@
/** 翻译状态与编排(Svelte 5 runes)。 */
import {
translate as apiTranslate,
screenshotOcr,
showMain,
onChunk,
type TranslateChunk,
type TranslateRequest,
} from "../api/tauri";
import { ADAPTIVE_TARGET, resolveAdaptiveTarget } from "./languages";
import type { UnlistenFn } from "@tauri-apps/api/event";
const QUEUE_NOTICE = "上一条翻译仍在生成,已排队最新内容。";
interface TranslationState {
input: string;
webOutput: string;
aiOutput: string;
source: string;
target: string;
webLoading: boolean;
aiLoading: boolean;
webError: string | null;
aiError: string | null;
loading: boolean;
error: string | null;
}
export interface TranslationDependencies {
translate: (req: TranslateRequest) => Promise<string>;
screenshotOcr: () => Promise<string>;
showMain: () => Promise<void>;
onChunk: (
handler: (chunk: TranslateChunk) => void,
) => Promise<UnlistenFn>;
}
const defaultDependencies: TranslationDependencies = {
translate: apiTranslate,
screenshotOcr,
showMain,
onChunk,
};
export function createTranslationStore(
deps: TranslationDependencies = defaultDependencies,
) {
const state = $state<TranslationState>({
input: "",
webOutput: "",
aiOutput: "",
source: "auto",
target: ADAPTIVE_TARGET,
webLoading: false,
aiLoading: false,
webError: null,
aiError: null,
loading: false,
error: null,
});
let unlisten: UnlistenFn | null = null;
let listenerPromise: Promise<void> | null = null;
let lastRunText = "";
let activeText = "";
let queuedText: string | null = null;
let drainingQueue = false;
let drainGeneration = 0;
let runGeneration = 0;
let webRequestId = 0;
let aiRequestId = 0;
let capturing = false;
function refreshLoading() {
state.loading = state.webLoading || state.aiLoading;
}
async function ensureChunkListener() {
if (unlisten) return;
if (!listenerPromise) {
listenerPromise = deps
.onChunk((chunk) => {
if (!state.aiLoading || chunk.request_id !== aiRequestId) return;
state.aiOutput += chunk.delta;
if (chunk.done) {
state.aiLoading = false;
refreshLoading();
}
})
.then((stop) => {
unlisten = stop;
})
.finally(() => {
listenerPromise = null;
});
}
await listenerPromise;
}
async function run() {
const text = state.input.trim();
if (!text) return;
if (state.loading) {
if (text !== activeText) {
queueText(text);
}
return;
}
await runText(text);
}
/** 计算实际请求用的源/目标语言:自适应目标按输入在中/英间切换。 */
function resolveLangPair(text: string): { source: string; target: string } {
if (state.target === ADAPTIVE_TARGET) {
return { source: "auto", target: resolveAdaptiveTarget(text) };
}
return { source: state.source, target: state.target };
}
function toError(e: unknown): string {
return typeof e === "string" ? e : String(e);
}
function queueText(text: string) {
queuedText = text;
state.input = text;
state.error = QUEUE_NOTICE;
}
function clearQueuedText() {
queuedText = null;
if (state.error === QUEUE_NOTICE) state.error = null;
}
async function runWeb(
text: string,
langPair: { source: string; target: string },
) {
const requestId = ++webRequestId;
state.webError = null;
state.webOutput = "";
state.webLoading = true;
refreshLoading();
try {
const result = await deps.translate({
request_id: requestId,
text,
...langPair,
engine: "auto",
});
if (requestId === webRequestId) {
state.webOutput = result;
}
} catch (e) {
if (requestId === webRequestId) {
state.webError = toError(e);
}
} finally {
if (requestId === webRequestId) {
state.webLoading = false;
refreshLoading();
}
}
}
async function runAi(
text: string,
langPair: { source: string; target: string },
) {
const requestId = ++aiRequestId;
state.aiError = null;
state.aiOutput = "";
state.aiLoading = true;
refreshLoading();
try {
await ensureChunkListener();
await deps.translate({
request_id: requestId,
text,
...langPair,
engine: "openai",
});
} catch (e) {
if (requestId === aiRequestId) {
state.aiError = toError(e);
}
} finally {
if (requestId === aiRequestId) {
state.aiLoading = false;
refreshLoading();
}
}
}
async function runText(text: string) {
const generation = ++runGeneration;
lastRunText = text;
activeText = text;
state.error = null;
state.webError = null;
state.aiError = null;
state.webOutput = "";
state.aiOutput = "";
const langPair = resolveLangPair(text);
const webTask = runWeb(text, langPair);
const aiTask = runAi(text, langPair);
await Promise.allSettled([webTask, aiTask]);
if (generation !== runGeneration) return;
refreshLoading();
if (activeText === text) {
activeText = "";
}
await drainQueuedText();
}
async function drainQueuedText() {
if (drainingQueue || state.loading || !queuedText) return;
const generation = ++drainGeneration;
const text = queuedText;
queuedText = null;
drainingQueue = true;
try {
state.input = text;
await runText(text);
} finally {
if (generation === drainGeneration) {
drainingQueue = false;
}
}
if (generation !== drainGeneration) return;
if (queuedText && !state.loading) {
await drainQueuedText();
}
}
async function translateText(value: string) {
const text = value.trim();
if (!text) return;
state.input = text;
if (state.loading) {
queueText(text);
return;
}
await runText(text);
}
async function restartForLanguageChange() {
const text = state.input.trim();
clearQueuedText();
drainGeneration += 1;
drainingQueue = false;
if (text) {
await runText(text);
return;
}
runGeneration += 1;
webRequestId += 1;
aiRequestId += 1;
activeText = "";
state.webOutput = "";
state.aiOutput = "";
state.webError = null;
state.aiError = null;
state.webLoading = false;
state.aiLoading = false;
refreshLoading();
}
async function setSource(source: string) {
if (state.source === source) return;
state.source = source;
await restartForLanguageChange();
}
async function setTarget(target: string) {
if (state.target === target) return;
state.target = target;
await restartForLanguageChange();
}
function setInput(v: string) {
state.input = v;
if (!state.loading) return;
const text = v.trim();
if (!text) {
clearQueuedText();
return;
}
if (text === activeText) {
clearQueuedText();
return;
}
queueText(text);
}
function shouldAutoRun(text: string): boolean {
return Boolean(text.trim()) && !state.loading && text.trim() !== lastRunText;
}
/** 截图 OCR:Apple 自带自由框选 → 识别 → 填入输入框 → 自动翻译。 */
async function captureAndTranslate() {
if (capturing) return;
capturing = true;
state.error = null;
try {
let text: string;
try {
text = await deps.screenshotOcr();
} finally {
capturing = false;
}
if (!text.trim()) return;
await deps.showMain(); // OCR 有结果后再唤起窗口
await translateText(text);
} catch (e) {
state.error = toError(e);
await deps.showMain();
}
}
function reset() {
drainGeneration += 1;
runGeneration += 1;
webRequestId += 1;
aiRequestId += 1;
activeText = "";
queuedText = null;
drainingQueue = false;
lastRunText = "";
state.input = "";
state.webOutput = "";
state.aiOutput = "";
state.webError = null;
state.aiError = null;
state.error = null;
state.webLoading = false;
state.aiLoading = false;
state.loading = false;
}
return {
get state() {
return state;
},
run,
translateText,
setInput,
setSource,
setTarget,
shouldAutoRun,
captureAndTranslate,
reset,
};
}
export const translation = createTranslationStore();
-221
View File
@@ -1,221 +0,0 @@
/** 应用内更新状态:检查更新、下载、安装并自动重启。
*
* 采用 Tauri 官方 Updater 插件:在应用内下载并校验更新包,原地替换 .app,
* 随后自动重启加载新版本——无需重新安装 DMG 或手动拖拽。
*
* 注意:运行中的二进制无法热替换,新代码必须经重启才能生效;
* 这是 macOS 原生应用的固有约束,重启由本流程自动完成。
*/
import { getVersion } from "@tauri-apps/api/app";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { Store } from "@tauri-apps/plugin-store";
export type UpdateStatus =
| "idle"
| "checking"
| "available"
| "downloading"
| "installing"
| "uptodate"
| "error";
/** 自动检查间隔:7 天(毫秒)。 */
const AUTO_CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
/** 持久化上次自动检查时间的 store 文件(与设置共用 settings.json)。 */
const STORE_FILE = "settings.json";
const LAST_CHECK_KEY = "update.last_check";
class UpdateStore {
/** 当前安装版本号。 */
currentVersion = $state("");
/** 待安装的新版本号;无更新时为空串。 */
newVersion = $state("");
status = $state<UpdateStatus>("idle");
/** 下载进度 0–1。 */
progress = $state(0);
/** 用户可读的状态/错误文案。 */
message = $state("");
/** 发布说明(来自 latest.json 的 body 字段)。 */
releaseNotes = $state("");
private pending: Update | null = null;
/** 应用常驻期间的周期性检查句柄(兜底:连续运行超过一周时也能触发)。 */
private periodicTimer: ReturnType<typeof setInterval> | null = null;
/** 周期检查节流:间隔内最多触发一次实际网络请求。 */
private static readonly PERIODIC_TICK_MS = 6 * 60 * 60 * 1000;
/** 拉取当前版本号。 */
async loadVersion() {
if (this.currentVersion) {
return;
}
try {
this.currentVersion = await getVersion();
} catch {
this.currentVersion = "";
}
}
/** 后台静默检查(仅发现更新,不自动下载)。命中时进入 available 状态,
* 用户进设置页可见并自行决定安装。失败不打扰用户。 */
async autoCheckIfNeeded() {
this.startPeriodicCheck();
// 已有可用更新、或正在检查/下载/安装时,不重复检查以免打扰用户。
if (
this.status === "available"
|| this.status === "checking"
|| this.status === "downloading"
|| this.status === "installing"
) {
return;
}
if (!(await this.shouldAutoCheck())) {
return;
}
await this.checkForUpdates();
// 仅当「无更新」或「出错」时刷新时间戳;发现新版本时保留旧时间戳,
// 避免用户暂不安装、下次启动又因满一周被静默刷掉「已是最新」。
if (this.status === "uptodate" || this.status === "error") {
await this.markChecked();
}
}
/** 启动常驻期间周期性检查(每 6h 探一次是否满一周),重复启动安全。 */
startPeriodicCheck() {
if (this.periodicTimer) {
return;
}
this.periodicTimer = setInterval(() => {
void this.autoCheckIfNeeded();
}, UpdateStore.PERIODIC_TICK_MS);
}
/** 停止周期性检查(应用卸载/销毁时调用,避免泄漏定时器)。 */
stopPeriodicCheck() {
if (this.periodicTimer) {
clearInterval(this.periodicTimer);
this.periodicTimer = null;
}
}
/** 距上次检查是否已满一周(或从未检查过)。 */
private async shouldAutoCheck(): Promise<boolean> {
try {
const store = await Store.load(STORE_FILE);
const last = await store.get<number>(LAST_CHECK_KEY);
if (!last) {
return true;
}
return Date.now() - last >= AUTO_CHECK_INTERVAL_MS;
} catch {
return false;
}
}
/** 记录本次检查时间为 Unix 毫秒。 */
private async markChecked() {
try {
const store = await Store.load(STORE_FILE);
await store.set(LAST_CHECK_KEY, Date.now());
await store.save();
} catch {
// 持久化失败不影响本次检查结果。
}
}
/** 检查更新;命中则记录待安装版本并返回 true。 */
async checkForUpdates(): Promise<boolean> {
// 重入守卫:已在检查/下载/安装,或已发现待安装版本时,复用当前状态,
// 不重复发起请求,避免覆盖 pending 与 progress 造成状态抖动。
if (
this.status === "checking"
|| this.status === "downloading"
|| this.status === "installing"
|| this.status === "available"
) {
return this.status === "available";
}
this.status = "checking";
this.message = "正在检查更新…";
this.progress = 0;
this.newVersion = "";
this.releaseNotes = "";
try {
const update = await check();
if (!update) {
this.pending = null;
this.status = "uptodate";
this.message = "已是最新版本";
return false;
}
this.pending = update;
this.newVersion = update.version;
this.releaseNotes = update.body ?? "";
this.status = "available";
this.message = `发现新版本 ${update.version}`;
return true;
} catch (e) {
this.pending = null;
this.status = "error";
this.message = this.toMessage(e, "检查更新失败,请确认网络可达更新服务器");
return false;
}
}
/** 下载并安装已发现的更新,完成后自动重启。 */
async downloadAndInstall() {
const update = this.pending;
if (!update) {
this.status = "error";
this.message = "没有待安装的更新";
return;
}
try {
this.status = "downloading";
this.message = "下载中…";
this.progress = 0;
let total = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
total = event.data.contentLength ?? 0;
this.progress = 0;
break;
case "Progress": {
if (total > 0) {
const next = (event.data.chunkLength / total) + this.progress;
this.progress = Math.min(1, next);
}
break;
}
case "Finished":
this.progress = 1;
break;
}
});
this.status = "installing";
this.message = "安装完成,正在重启…";
await relaunch();
} catch (e) {
this.status = "error";
this.message = this.toMessage(e, "下载或安装更新失败");
}
}
private toMessage(e: unknown, fallback: string): string {
if (typeof e === "string" && e.trim()) {
return e;
}
if (e instanceof Error && e.message) {
return e.message;
}
return fallback;
}
}
export const updater = new UpdateStore();
-48
View File
@@ -1,48 +0,0 @@
import { describe, expect, test } from "vitest";
import capability from "../../../src-tauri/capabilities/default.json";
describe("updater store capability", () => {
test("grants only the store operations used by the updater timestamp", () => {
expect(
capability.permissions.filter((permission) => permission.startsWith("store:")),
).toEqual([
"store:allow-load",
"store:allow-get",
"store:allow-set",
"store:allow-save",
]);
});
test("grants only the updater and process commands used by the app", () => {
expect(
capability.permissions.filter((permission) =>
permission.startsWith("updater:"),
),
).toEqual([
"updater:allow-check",
"updater:allow-download-and-install",
]);
expect(
capability.permissions.filter((permission) =>
permission.startsWith("process:"),
),
).toEqual(["process:allow-restart"]);
});
test("does not expose unused positioner commands or redundant autostart defaults", () => {
expect(
capability.permissions.filter((permission) =>
permission.startsWith("positioner:"),
),
).toEqual([]);
expect(
capability.permissions.filter((permission) =>
permission.startsWith("autostart:"),
),
).toEqual([
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
]);
});
});
-932
View File
@@ -1,932 +0,0 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { onDestroy, onMount, tick } from "svelte";
import { getPinned, setPinned, type ChatMessage } from "../api/tauri";
import EngineTabs, { type MainMode } from "../components/EngineTabs.svelte";
import { chatStore } from "../stores/chat.svelte";
interface Props {
mode: MainMode;
onModeChange: (mode: MainMode) => void;
onSettings: () => void;
}
let { mode, onModeChange, onSettings }: Props = $props();
const c = chatStore;
const HISTORY_CONV_LIMIT = 100;
type HistoryItem = ChatMessage & { index: number };
let isPinned = $state(false);
let showHistory = $state(false);
let historyQuery = $state("");
let messagesEl: HTMLElement | null = $state(null);
let historyButton: HTMLButtonElement | null = $state(null);
let historySearch: HTMLInputElement | null = $state(null);
let historyPopover: HTMLElement | null = $state(null);
let composerEl: HTMLTextAreaElement | null = $state(null);
// 左侧悬浮呼出(hover 把手 → 浮层显示历史提问)
let railOpen = $state(false);
let closeRailTimer: ReturnType<typeof setTimeout> | null = null;
let railPanelEl: HTMLElement | null = $state(null);
// 左侧浮层用到的历史提问:略少、不参与搜索,避免与工具栏弹层争抢焦点。
// 顺序为「旧 → 新」(与对话区消息时间方向一致):先取最近 N 条,再反转。
const RAIL_ITEM_LIMIT = 40;
const RAIL_SCAN_LIMIT = 120;
let railItems = $derived.by(() => {
const userMsgs: HistoryItem[] = [];
let scannedUserCount = 0;
for (let index = c.state.messages.length - 1; index >= 0; index -= 1) {
const message = c.state.messages[index];
if (message.role !== "user") continue;
scannedUserCount += 1;
if (scannedUserCount > RAIL_SCAN_LIMIT) break;
userMsgs.push({ ...message, index });
if (userMsgs.length >= RAIL_ITEM_LIMIT) break;
}
// 复制后反转,避免就地修改累加器(与文件其余处的不可变风格一致)。
return [...userMsgs].reverse();
});
// 顶栏「对话历史」:以对话为单位(每段一条,标题=首句提问),最新在最上。
let historyConversations = $derived.by(() => {
const q = historyQuery.trim().toLowerCase();
const convs = [...c.state.conversations].reverse(); // 最新在前
const filtered = q
? convs.filter((conv) => {
if (conv.summary.toLowerCase().includes(q)) return true;
return conv.messages.some((m) => m.content.toLowerCase().includes(q));
})
: convs;
return filtered.slice(0, HISTORY_CONV_LIMIT);
});
onMount(() => {
getPinned().then((pinned) => (isPinned = pinned));
});
// 组件销毁时清理悬浮关闭计时器,避免在已卸载组件上执行过期回调(与 Settings 一致)。
onDestroy(() => {
if (closeRailTimer) {
clearTimeout(closeRailTimer);
closeRailTimer = null;
}
});
// 呼出后自动聚焦输入框(与翻译面板一致)
$effect(() => {
composerEl?.focus();
});
async function togglePinned() {
isPinned = await setPinned(!isPinned);
}
async function openHistory() {
// 每次打开回到「查看全部」状态
historyQuery = "";
showHistory = true;
await tick();
(historySearch ?? historyPopover)?.focus();
}
function closeHistory() {
showHistory = false;
historyQuery = "";
queueMicrotask(() => historyButton?.focus());
}
function toggleHistory() {
if (showHistory) {
closeHistory();
return;
}
void openHistory();
}
function onWindowKeydown(e: KeyboardEvent) {
if (!showHistory || e.key !== "Escape") return;
e.preventDefault();
closeHistory();
}
function onHistoryKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeHistory();
return;
}
if (e.key !== "Tab" || !historyPopover) return;
const focusables = Array.from(
historyPopover.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
);
if (focusables.length === 0) {
e.preventDefault();
historyPopover.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
return;
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
e.preventDefault();
c.send();
}
if (e.key === "Escape") {
e.preventDefault();
// 弹层 / 左侧悬浮呼出打开时,Esc 先收起它们而不是隐藏窗口
if (showHistory) {
closeHistory();
} else if (railOpen) {
railOpen = false;
} else {
getCurrentWindow().hide();
}
}
}
// 搜索框内的 Esc:清空搜索词而非收起弹层;空搜索词时交给弹层关闭。
function onSearchKeydown(e: KeyboardEvent) {
if (e.key !== "Escape" || !historyQuery) return;
e.preventDefault();
e.stopPropagation();
historyQuery = "";
}
/** 滚动到指定消息并高亮闪烁。DOM 更新后执行,确保坐标正确。 */
function scrollToMessage(index: number) {
const node = messagesEl?.querySelector<HTMLElement>(`[data-index="${index}"]`);
if (!node) return;
node.scrollIntoView({ behavior: "smooth", block: "center" });
node.classList.remove("flash");
// 强制重启动画
void node.offsetWidth;
node.classList.add("flash");
}
function jumpToMessage(index: number) {
// 左侧悬浮浮层(当前对话内跳转)使用
queueMicrotask(() => scrollToMessage(index));
}
/** 打开一段历史对话:替换当前视图,并滚到最新一条消息。 */
function openConversation(id: number) {
closeHistory();
c.openConversation(id);
queueMicrotask(() => {
messagesEl?.scrollTo({ top: messagesEl.scrollHeight, behavior: "auto" });
});
}
function startNewConversation() {
closeHistory();
c.newConversation();
queueMicrotask(() => {
messagesEl?.scrollTo({ top: 0, behavior: "auto" });
composerEl?.focus();
});
}
// ── 左侧悬浮呼出 ──
function openRail() {
if (closeRailTimer) {
clearTimeout(closeRailTimer);
closeRailTimer = null;
}
railOpen = true;
}
function scheduleCloseRail() {
if (closeRailTimer) clearTimeout(closeRailTimer);
closeRailTimer = setTimeout(() => {
railOpen = false;
closeRailTimer = null;
}, 160);
}
function jumpToMessageFromRail(index: number) {
railOpen = false;
queueMicrotask(() => scrollToMessage(index));
}
// 浮层展开后把列表滚到底部:顺序是「旧 → 新」,最新的提问默认可见。
$effect(() => {
if (!railOpen) return;
void tick().then(() => {
const list = railPanelEl?.querySelector<HTMLElement>(".rail-list");
if (list) list.scrollTop = list.scrollHeight;
});
});
function clearHistory() {
c.clearHistory();
closeHistory();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<main class="chat-panel">
<header class="toolbar" data-tauri-drag-region>
<EngineTabs {mode} onchange={onModeChange} />
<div class="toolbar-actions">
<button
class="md-icon-button pin"
class:active={isPinned}
onclick={togglePinned}
aria-pressed={isPinned}
aria-label={isPinned ? "取消固定窗口" : "固定窗口"}
title={isPinned ? "取消固定窗口" : "固定窗口"}
>
<span class="state-layer"></span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 4.5c0-.83.67-1.5 1.5-1.5S17 3.67 17 4.5v6.15l2.27 2.27c.47.47.14 1.28-.53 1.28H13v5.55c0 .41-.34.75-.75.75s-.75-.34-.75-.75V14.2H5.76c-.67 0-1-.81-.53-1.28L7.5 10.65V4.5C7.5 3.67 8.17 3 9 3s1.5.67 1.5 1.5v5.55c0 .2-.08.39-.22.53L8.36 12.5h7.28l-1.92-1.92a.75.75 0 0 1-.22-.53V4.5Z" />
</svg>
</button>
<button
class="md-icon-button new-chat"
onclick={startNewConversation}
disabled={c.state.loading}
aria-label="新对话"
title="新对话"
>
<span class="state-layer"></span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18.25 3a.75.75 0 0 1 .75.75V8h4.25a.75.75 0 0 1 0 1.5H19v4.25a.75.75 0 0 1-1.5 0V9.5h-4.25a.75.75 0 0 1 0-1.5H17.5V3.75a.75.75 0 0 1 .75-.75ZM5 6.5A2.5 2.5 0 0 1 7.5 4h4.75a.75.75 0 0 1 0 1.5H7.5a1 1 0 0 0-1 1V18a1 1 0 0 0 1 1h8.5a1 1 0 0 0 1-1v-3.25a.75.75 0 0 1 1.5 0V18a2.5 2.5 0 0 1-2.5 2.5h-8.5A2.5 2.5 0 0 1 5 18V6.5Z" />
</svg>
</button>
<button
bind:this={historyButton}
class="md-icon-button history"
class:active={showHistory}
onclick={toggleHistory}
aria-expanded={showHistory}
aria-haspopup="dialog"
aria-controls="chat-history-popover"
aria-label="查看对话历史"
title="查看对话历史"
>
<span class="state-layer"></span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M13 3a9 9 0 1 1-8.2 5.3.75.75 0 1 1 1.36.63A7.5 7.5 0 1 0 13 4.5c-2.2 0-4.17.95-5.54 2.46H10a.75.75 0 0 1 0 1.5H5.75A.75.75 0 0 1 5 7.71V3.5a.75.75 0 0 1 1.5 0v2.1A8.96 8.96 0 0 1 13 3Zm0 4.25c.41 0 .75.34.75.75v4.04l2.73 1.58a.75.75 0 1 1-.75 1.3l-3.1-1.8a.75.75 0 0 1-.38-.65V8c0-.41.34-.75.75-.75Z" />
</svg>
</button>
<button class="md-icon-button gear" onclick={onSettings} aria-label="设置" title="设置">
<span class="state-layer"></span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19.43 12.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.6-.22l-2.49 1a7.3 7.3 0 0 0-1.7-.98L14.5 2.42A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.42L9.12 5.07c-.61.24-1.18.56-1.7.98l-2.49-1a.5.5 0 0 0-.6.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.06.65-.06.98s.02.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46c.13.22.39.31.6.22l2.49-1c.52.4 1.09.73 1.7.98l.38 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.38-2.65c.61-.24 1.18-.56 1.7-.98l2.49 1c.22.09.48 0 .6-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.13-1.65ZM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z" />
</svg>
</button>
{#if showHistory}
<!-- 点击外部区域(弹层以外的背景)自动收起 -->
<button
class="history-scrim"
onclick={closeHistory}
tabindex="-1"
aria-label="关闭对话历史"
></button>
<div
bind:this={historyPopover}
id="chat-history-popover"
class="history-popover"
role="dialog"
aria-modal="true"
aria-label="对话历史"
tabindex="-1"
onkeydown={onHistoryKeydown}
>
<div class="history-head">
<span>对话历史</span>
<div class="history-head-actions">
<button onclick={startNewConversation} disabled={c.state.loading}>
{c.state.loading ? "生成中" : "新对话"}
</button>
{#if c.state.conversations.length > 0}
<button onclick={clearHistory} disabled={c.state.loading}>清空</button>
{/if}
<button onclick={closeHistory} aria-label="关闭对话历史">关闭</button>
</div>
</div>
{#if c.state.conversations.length > 0}
<div class="history-search">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9.5 3a6.5 6.5 0 1 0 4.03 11.6l4.43 4.44a.75.75 0 1 0 1.06-1.06l-4.43-4.44A6.5 6.5 0 0 0 9.5 3Zm-5 6.5a5 5 0 1 1 10 0 5 5 0 0 1-10 0Z" />
</svg>
<input
bind:this={historySearch}
type="search"
bind:value={historyQuery}
onkeydown={onSearchKeydown}
placeholder="搜索对话"
spellcheck="false"
autocomplete="off"
aria-label="搜索对话历史"
/>
</div>
{/if}
{#if historyConversations.length === 0}
<p class="history-empty">{historyQuery ? "没有找到相关对话" : "还没有对话记录"}</p>
{:else}
<div class="history-list">
{#each historyConversations as conv (conv.id)}
<button
class="history-item"
class:current={conv.id === c.state.currentId}
onclick={() => openConversation(conv.id)}
>
<span class="history-item-meta">{conv.messages.length} 条</span>
<p>{conv.summary || "(空对话)"}</p>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</header>
<!-- 左侧悬浮呼出:鼠标移到左缘把手即展开历史提问浮层 -->
<div
class="history-rail"
class:open={railOpen}
role="region"
aria-label="历史提问预览"
tabindex="-1"
onpointerenter={openRail}
onpointerleave={scheduleCloseRail}
onfocusin={openRail}
onfocusout={scheduleCloseRail}
>
<span class="rail-handle" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M9 6c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1s-1-.45-1-1V7c0-.55.45-1 1-1Zm6 0c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1s-1-.45-1-1V7c0-.55.45-1 1-1Z" /></svg>
</span>
{#if railOpen}
<div class="rail-panel" bind:this={railPanelEl}>
<div class="rail-head">
<span>历史提问</span>
<button onclick={() => openHistory()} aria-label="在工具栏打开完整历史">全部</button>
</div>
{#if railItems.length === 0}
<p class="rail-empty">还没有提问</p>
{:else}
<div class="rail-list">
{#each railItems as item (item.index)}
<button class="rail-item" onclick={() => jumpToMessageFromRail(item.index)}>
<p>{item.content}</p>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<section class="messages" bind:this={messagesEl} aria-label="AI 对话">
{#if c.state.messages.length === 0}
<p class="placeholder">和 AI 自由对话。</p>
{/if}
{#each c.state.messages as message, index (`${c.state.currentId}:${index}`)}
<article
class="message"
class:user={message.role === "user"}
class:assistant={message.role === "assistant"}
data-index={index}
>
<span class="role">{message.role === "user" ? "你" : "AI"}</span>
<p>{message.content}{#if c.state.loading && index === c.state.messages.length - 1}<span class="caret"></span>{/if}</p>
</article>
{/each}
{#if c.state.error}
<p class="error">{c.state.error}</p>
{/if}
</section>
<footer class="composer">
<textarea
bind:this={composerEl}
bind:value={c.state.input}
onkeydown={onKeydown}
placeholder="输入消息,回车发送"
spellcheck="false"
rows="1"
></textarea>
<button onclick={c.send} disabled={c.state.loading || !c.state.input.trim()}>{c.state.loading ? "…" : "发送"}</button>
</footer>
</main>
<style>
.chat-panel {
position: relative;
display: flex;
flex-direction: column;
height: 100vh;
min-height: 0;
overflow: hidden;
border-radius: var(--radius-window);
background: var(--panel-bg);
}
.toolbar {
position: relative;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
}
.toolbar-actions {
position: relative;
display: inline-flex;
align-items: center;
gap: 0;
}
.md-icon-button {
position: relative;
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
overflow: hidden;
transition:
color var(--duration-fast) var(--ease),
background var(--duration-fast) var(--ease);
}
.md-icon-button svg {
position: relative;
z-index: 1;
width: 18px;
height: 18px;
fill: currentColor;
}
.md-icon-button .state-layer {
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease);
}
.md-icon-button:hover {
color: var(--text);
}
.md-icon-button:hover .state-layer {
opacity: var(--state-hover);
}
.md-icon-button:active .state-layer {
opacity: var(--state-pressed);
}
.md-icon-button.active {
background: var(--accent-soft);
color: var(--accent);
}
.history-scrim {
position: fixed;
inset: 0;
z-index: 19;
padding: 0;
border: none;
background: transparent;
cursor: default;
}
.history-popover {
position: absolute;
top: 52px;
right: 36px;
z-index: 20;
width: min(330px, calc(100vw - 32px));
max-height: 370px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface-overlay);
box-shadow: var(--shadow-pop);
transform-origin: top right;
animation: md-pop-in var(--duration) var(--ease);
}
.history-head {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: var(--text-sm);
font-weight: 700;
}
.history-head-actions {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.history-head button {
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--accent);
font-size: var(--text-xs);
font-weight: 600;
padding: 4px 9px;
cursor: pointer;
transition: background var(--duration-fast) var(--ease);
}
.history-head button:hover:not(:disabled) {
background: var(--accent-soft);
}
.history-head button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.history-search {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-2) var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-high);
}
.history-search svg {
flex: none;
width: 16px;
height: 16px;
fill: var(--text-faint);
}
.history-search input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--text-sm);
outline: none;
}
/* 移除浏览器原生的搜索框清除按钮,统一用 Esc 清空 */
.history-search input::-webkit-search-cancel-button {
appearance: none;
}
.history-search input::placeholder {
color: var(--text-faint);
}
.history-empty {
margin: 0;
padding: var(--space-4);
color: var(--text-faint);
font-size: var(--text-sm);
}
.history-list {
max-height: 250px;
overflow: auto;
scrollbar-width: none;
}
.history-list::-webkit-scrollbar {
display: none;
}
.history-item {
width: calc(100% - var(--space-3));
margin: 0 var(--space-2) var(--space-2);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
padding: var(--space-3);
text-align: left;
cursor: pointer;
transition: background var(--duration-fast) var(--ease);
}
.history-item:hover {
background: var(--surface-high);
}
.history-item.current {
background: var(--accent-soft);
box-shadow: inset 2px 0 0 var(--accent);
}
.history-item.current p {
color: var(--accent-on-container);
font-weight: 600;
}
.history-item-meta {
display: block;
margin-bottom: 3px;
color: var(--text-faint);
font-size: var(--text-xs);
font-weight: 700;
}
.history-item p {
margin: 0;
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: var(--text-sm);
line-height: 1.45;
}
/* ── 左侧悬浮呼出 ── */
.history-rail {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
z-index: 18;
display: flex;
align-items: stretch;
/* 仅在悬浮区与浮层之间形成可命中的桥接 */
}
.rail-handle {
display: inline-grid;
place-items: center;
width: 14px;
align-self: stretch;
margin: 80px 0;
border-right: 1px solid transparent;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: transparent;
color: var(--text-faint);
cursor: default;
transition:
background var(--duration-fast) var(--ease),
color var(--duration-fast) var(--ease);
}
.rail-handle svg {
width: 12px;
height: 12px;
fill: currentColor;
opacity: 0.5;
transition: opacity var(--duration-fast) var(--ease);
}
.history-rail:hover .rail-handle,
.history-rail:focus-within .rail-handle {
background: var(--accent-softer);
color: var(--accent);
}
.history-rail:hover .rail-handle svg,
.history-rail:focus-within .rail-handle svg {
opacity: 1;
}
.rail-panel {
margin-left: -2px;
width: min(280px, 38vw);
max-height: min(60vh, 460px);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-left: none;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
background: var(--surface-overlay);
box-shadow: var(--shadow-pop);
transform-origin: left center;
animation: rail-slide-in var(--duration) var(--ease);
}
@keyframes rail-slide-in {
from {
opacity: 0;
transform: translateX(-8px) scale(0.98);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
.rail-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.02em;
}
.rail-head button {
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--accent);
font-size: var(--text-xs);
font-weight: 600;
padding: 3px 8px;
cursor: pointer;
transition: background var(--duration-fast) var(--ease);
}
.rail-head button:hover {
background: var(--accent-soft);
}
.rail-empty {
margin: 0;
padding: var(--space-4);
color: var(--text-faint);
font-size: var(--text-sm);
}
.rail-list {
flex: 1;
min-height: 0;
overflow: auto;
scrollbar-width: none;
padding: var(--space-2);
}
.rail-list::-webkit-scrollbar {
display: none;
}
.rail-item {
display: block;
width: 100%;
margin-bottom: var(--space-1);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
padding: var(--space-2) var(--space-3);
text-align: left;
cursor: pointer;
transition: background var(--duration-fast) var(--ease);
}
.rail-item:hover {
background: var(--surface-high);
}
.rail-item p {
margin: 0;
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: var(--text-sm);
line-height: 1.45;
}
.messages {
position: relative;
z-index: 1;
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-4);
scrollbar-width: none;
}
.messages::-webkit-scrollbar {
display: none;
}
.placeholder {
margin: 0;
color: var(--text-faint);
font-size: var(--text-base);
}
.message {
margin-bottom: var(--space-4);
}
.message.user {
text-align: right;
}
.role {
display: inline-block;
margin-bottom: 5px;
color: var(--text-faint);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.03em;
}
.message p {
display: inline-block;
max-width: 82%;
margin: 0;
padding: 9px 13px;
border-radius: var(--radius);
background: var(--surface-high);
color: var(--text);
line-height: 1.62;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
.message.user p {
background: var(--accent-container);
color: var(--accent-on-container);
}
.composer {
position: relative;
z-index: 2;
display: flex;
align-items: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4) var(--space-4);
}
.composer::before {
content: "";
position: absolute;
left: var(--space-4);
right: var(--space-4);
top: 0;
height: 1px;
background: var(--border);
}
.composer textarea {
flex: 1;
height: 44px;
min-height: 44px;
max-height: 44px;
resize: none;
overflow: auto;
scrollbar-width: none;
border: none;
border-bottom: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-high);
color: var(--text);
font: inherit;
line-height: 22px;
padding: 10px 15px;
outline: none;
transition:
border-color var(--duration-fast) var(--ease),
background var(--duration-fast) var(--ease);
}
.composer textarea:focus {
outline: none;
}
.composer textarea::-webkit-scrollbar {
display: none;
}
.composer button {
align-self: flex-end;
min-width: 64px;
height: 44px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
cursor: pointer;
transition:
background var(--duration-fast) var(--ease),
opacity var(--duration-fast) var(--ease);
}
.composer button:hover:not(:disabled) {
background: var(--accent-hover);
}
.composer button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.error {
margin: 0;
color: var(--danger);
}
.caret {
display: inline-block;
width: 2px;
height: 1.1em;
margin-left: 1px;
vertical-align: text-bottom;
background: var(--accent);
border-radius: var(--radius-xs);
animation: blink 1s steps(2, start) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
:global(.message.flash) p {
animation: msg-flash 1s var(--ease);
}
@keyframes msg-flash {
0% {
box-shadow: 0 0 0 3px var(--accent-soft);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
</style>
File diff suppressed because it is too large. Load diff
-100
View File
@@ -1,100 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
buildApiKeySavePayload,
createDebouncedSaver,
performUpdateAction,
saveAndReload,
toggleBooleanPreference,
} from "./settingsLogic";
describe("settings actions", () => {
test("checks first and installs only after a second explicit action", async () => {
const updater = {
status: "idle",
checkForUpdates: vi.fn(async () => {
updater.status = "available";
return true;
}),
downloadAndInstall: vi.fn(async () => {}),
};
await performUpdateAction(updater);
expect(updater.checkForUpdates).toHaveBeenCalledOnce();
expect(updater.downloadAndInstall).not.toHaveBeenCalled();
await performUpdateAction(updater);
expect(updater.downloadAndInstall).toHaveBeenCalledOnce();
});
test("returns a visible error when config saving is rejected", async () => {
const original = { model: "model-a" };
const load = vi.fn(async () => ({ model: "model-b" }));
const result = await saveAndReload(
original,
vi.fn(async () => {
throw new Error("invalid base URL");
}),
load,
"保存 AI 翻译配置失败",
);
expect(result).toEqual({
ok: false,
value: original,
error: "保存 AI 翻译配置失败: invalid base URL",
});
expect(load).not.toHaveBeenCalled();
});
test("preserves an existing API key unless clearing is explicit", () => {
const config = {
api_key: "",
has_api_key: true,
clear_api_key: false,
model: "model-a",
};
expect(buildApiKeySavePayload(config, false).clear_api_key).toBe(false);
expect(buildApiKeySavePayload(config, true).clear_api_key).toBe(true);
expect(
buildApiKeySavePayload({ ...config, api_key: "replacement" }, true)
.clear_api_key,
).toBe(false);
});
test("keeps the previous checkbox value when autostart update fails", async () => {
const result = await toggleBooleanPreference(
true,
vi.fn(async () => {
throw new Error("permission denied");
}),
"切换开机自启失败",
);
expect(result).toEqual({
value: true,
error: "切换开机自启失败: permission denied",
});
});
});
describe("debounced settings persistence", () => {
afterEach(() => {
vi.useRealTimers();
});
test("flushes the latest pending value exactly once on teardown", async () => {
vi.useFakeTimers();
const save = vi.fn(async () => {});
const saver = createDebouncedSaver(400, save);
saver.schedule(0.6);
saver.schedule(0.7);
await saver.flush();
await vi.runAllTimersAsync();
expect(save).toHaveBeenCalledOnce();
expect(save).toHaveBeenCalledWith(0.7);
});
});
-111
View File
@@ -1,111 +0,0 @@
export interface UpdateActionTarget {
status: string;
checkForUpdates: () => Promise<boolean>;
downloadAndInstall: () => Promise<void>;
}
export type SaveAndReloadResult<T> =
| { ok: true; value: T; error: null }
| { ok: false; value: T; error: string };
function errorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return String(error);
}
export async function performUpdateAction(
updater: UpdateActionTarget,
): Promise<"checked" | "installed"> {
if (updater.status === "available") {
await updater.downloadAndInstall();
return "installed";
}
await updater.checkForUpdates();
return "checked";
}
export async function saveAndReload<T>(
value: T,
save: (value: T) => Promise<void>,
load: () => Promise<T>,
errorPrefix: string,
): Promise<SaveAndReloadResult<T>> {
try {
await save(value);
return { ok: true, value: await load(), error: null };
} catch (error) {
return {
ok: false,
value,
error: `${errorPrefix}: ${errorMessage(error)}`,
};
}
}
export function buildApiKeySavePayload<
T extends { api_key: string; clear_api_key: boolean },
>(config: T, clearRequested: boolean): T {
return {
...config,
clear_api_key: clearRequested && config.api_key.trim() === "",
};
}
export async function toggleBooleanPreference(
current: boolean,
apply: (next: boolean) => Promise<void>,
errorPrefix: string,
): Promise<{ value: boolean; error: string | null }> {
const next = !current;
try {
await apply(next);
return { value: next, error: null };
} catch (error) {
return {
value: current,
error: `${errorPrefix}: ${errorMessage(error)}`,
};
}
}
export interface DebouncedSaver<T> {
schedule: (value: T) => void;
flush: () => Promise<void>;
}
export function createDebouncedSaver<T>(
delayMs: number,
save: (value: T) => Promise<void>,
): DebouncedSaver<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
let hasPending = false;
let pendingValue!: T;
function clearTimer() {
if (!timer) return;
clearTimeout(timer);
timer = null;
}
async function flush() {
clearTimer();
if (!hasPending) return;
const value = pendingValue;
hasPending = false;
await save(value);
}
function schedule(value: T) {
pendingValue = value;
hasPending = true;
clearTimer();
timer = setTimeout(() => {
timer = null;
void flush().catch(() => {});
}, delayMs);
}
return { schedule, flush };
}
-9
View File
@@ -1,9 +0,0 @@
import { mount } from "svelte";
import "./styles/global.css";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;
-135
View File
@@ -1,135 +0,0 @@
@import "tailwindcss";
@import "./tokens.css";
/* 透明背景 —— 让 macOS 原生 Vibrancy 透出作为底色 */
html,
body {
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
font-family:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"Helvetica Neue", sans-serif;
color: var(--text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
#app {
height: 100vh;
/* 在 webview 整层裁出窗口圆角,任何子元素都不会在圆角处漏出直角,
从根上消除「圆角 + 直角叠在一起」。圆角与原生 vibrancy 对齐。 */
border-radius: var(--radius-window);
overflow: hidden;
}
/* 全局聚焦态 —— MD3 primary 描边,克制分明 */
*:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
::selection {
background: var(--accent-soft);
}
/* ─────────────────────────────────────────────
MD3 扁平可复用表面
—— 实色填充、纤细描边、无玻璃高光
───────────────────────────────────────────── */
.md-surface,
.md-surface-raised,
.md-popover {
border: 1px solid var(--border);
}
.md-surface {
background: var(--surface);
}
.md-surface-raised {
background: var(--surface-raised);
box-shadow: var(--shadow-sm);
}
.md-popover {
background: var(--surface-popover);
box-shadow: var(--shadow-pop);
}
/* MD3 状态层 —— 控件 hover/focus/pressed 时叠加的半透明色 */
.md-state {
position: relative;
overflow: hidden;
}
.md-state::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: currentColor;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease);
}
.md-state:hover::before {
opacity: var(--state-hover);
}
.md-state:focus-visible::before {
opacity: var(--state-focus);
}
.md-state:active::before {
opacity: var(--state-pressed);
}
/* 滚动条 —— 简洁方块,圆角极小 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border: 2px solid transparent;
border-radius: var(--radius-sm);
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
background-clip: padding-box;
}
::-webkit-scrollbar-track {
background: transparent;
}
@keyframes md-pop-in {
from {
opacity: 0;
transform: scale(0.97) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes md-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.65;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
-166
View File
@@ -1,166 +0,0 @@
/* ─────────────────────────────────────────────
Material 3 设计令牌
—— 纯扁平化:实色表面 + 小圆角 + MD3 状态层 + 静音色调
保留原生 NSVisualEffectMaterial::Popover 通透底,但控件一律实色
───────────────────────────────────────────── */
:root {
/* ── MD3 色调方案(浅色) ── */
/* 以系统蓝为 seed 色,派生出 MD3 中性 / 强调色调 */
--md-primary: oklch(48% 0.19 264);
--md-on-primary: oklch(99% 0.01 264);
--md-primary-container: oklch(93% 0.04 268);
--md-on-primary-container: oklch(20% 0.05 268);
/* 面板透明度 —— 由「设置 > 通用」滑块实时改写(0–1,默认 1)。
用作面板底色 alpha,让底层毛玻璃透出。 */
--panel-opacity: 1;
/* 表面 —— 实色,让控件清晰可辨;整体仍半透明以透出毛玻璃底 */
--surface: oklch(98.5% 0.003 264 / 0.86);
--surface-raised: oklch(100% 0 0 / 0.92);
--surface-sunken: oklch(96% 0.004 264 / 0.86);
--surface-popover: oklch(98.5% 0.003 264 / 0.96);
--surface-thick: oklch(98.5% 0.003 264);
/* 弹层专用 —— 完全不透明,避免悬浮在文本之上时透出底层内容 */
--surface-overlay: oklch(99% 0.003 264);
--surface-card: oklch(99.5% 0.002 264 / 0.9);
--surface-panel: oklch(96.5% 0.004 264 / 0.8);
--surface-high: oklch(97% 0.004 264 / 0.92);
/* 面板底色 —— 跟随透明度滑块;面板容器统一引用此变量。 */
--panel-bg: oklch(98.5% 0.003 264 / var(--panel-opacity));
/* 描边 —— 极淡,默认几乎不可见;主要靠色调表面区分,框线只在交互时浮现 */
--border: oklch(72% 0.01 264 / 0.14);
--border-strong: oklch(55% 0.012 264 / 0.28);
/* 文本 */
--text: oklch(20% 0.01 264);
--text-muted: oklch(45% 0.014 264);
--text-faint: oklch(55% 0.014 264);
/* 强调色 —— MD3 primary,扁平实色 */
--accent: var(--md-primary);
--accent-hover: oklch(43% 0.18 264);
--accent-active: oklch(38% 0.17 264);
--accent-soft: oklch(48% 0.19 264 / 0.12);
--accent-softer: oklch(48% 0.19 264 / 0.08);
--accent-text: var(--md-on-primary);
--accent-container: var(--md-primary-container);
--accent-on-container: var(--md-on-primary-container);
--accent-border: oklch(48% 0.19 264 / 0.6);
--accent-border-soft: oklch(48% 0.19 264 / 0.32);
--accent-shadow: oklch(48% 0.19 264 / 0.28);
--focus-ring: oklch(48% 0.19 264 / 0.5);
/* 状态 */
--danger: oklch(58% 0.22 25);
--danger-soft: oklch(58% 0.22 25 / 0.12);
--danger-border: oklch(58% 0.22 25 / 0.4);
--success: oklch(58% 0.16 150);
/* 字号 —— 流式缩放 */
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-lg: 1.0625rem;
--text-xl: 1.375rem;
/* 间距节奏 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
/* 圆角 —— 偏小但有圆润感,避免直角生硬 */
--radius-xs: 4px;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 12px;
--radius-xl: 14px;
--radius-window: 14px;
/* 动效 —— MD3 标准缓动 */
--duration-fast: 120ms;
--duration: 200ms;
--duration-slow: 300ms;
--ease: cubic-bezier(0.2, 0, 0, 1);
--ease-spring: cubic-bezier(0.3, 0, 0, 1);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.3, 0, 0, 1);
/* 滚动条 */
--scrollbar-thumb: oklch(20% 0.01 264 / 0.22);
--scrollbar-thumb-hover: oklch(20% 0.01 264 / 0.34);
/* ── MD3 海拔阴影(elevation)—— 扁平、轻量、克制 ── */
/* E0: 无阴影(默认表面) */
/* E1: 悬浮的卡片/容器 */
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.1), 0 1px 3px oklch(0% 0 0 / 0.06);
/* E2: 浮起按钮 */
--shadow: 0 1px 2px oklch(0% 0 0 / 0.1), 0 2px 6px oklch(0% 0 0 / 0.08);
/* E3: 弹层/对话框 */
--shadow-lg: 0 4px 8px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08);
/* E4: 主弹层 */
--shadow-pop: 0 6px 12px oklch(0% 0 0 / 0.14), 0 2px 4px oklch(0% 0 0 / 0.1);
/* MD3 状态层透明度(hover/focus/pressed 叠加) */
--state-hover: 0.08;
--state-focus: 0.12;
--state-pressed: 0.12;
/* 选中态指示器(分段控件高亮块)—— 纯实色,无光晕 */
--pill-shadow: 0 1px 2px oklch(0% 0 0 / 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--md-primary: oklch(72% 0.16 264);
--md-on-primary: oklch(16% 0.03 264);
--md-primary-container: oklch(30% 0.07 268);
--md-on-primary-container: oklch(92% 0.03 268);
--surface: oklch(20% 0.01 264 / 0.88);
--surface-raised: oklch(26% 0.012 264 / 0.92);
--surface-sunken: oklch(17% 0.01 264 / 0.86);
--surface-popover: oklch(22% 0.012 264 / 0.97);
--surface-thick: oklch(20% 0.01 264);
--surface-overlay: oklch(26% 0.012 264);
--surface-card: oklch(24% 0.012 264 / 0.9);
--surface-panel: oklch(18% 0.01 264 / 0.8);
--surface-high: oklch(26% 0.012 264 / 0.92);
--panel-bg: oklch(20% 0.01 264 / var(--panel-opacity));
--border: oklch(80% 0.01 264 / 0.12);
--border-strong: oklch(90% 0.01 264 / 0.22);
--text: oklch(95% 0.003 264);
--text-muted: oklch(74% 0.01 264);
--text-faint: oklch(66% 0.01 264);
--accent: var(--md-primary);
--accent-hover: oklch(78% 0.15 264);
--accent-active: oklch(66% 0.16 264);
--accent-soft: oklch(72% 0.16 264 / 0.18);
--accent-softer: oklch(72% 0.16 264 / 0.1);
--accent-text: var(--md-on-primary);
--accent-container: var(--md-primary-container);
--accent-on-container: var(--md-on-primary-container);
--accent-border: oklch(72% 0.16 264 / 0.64);
--accent-border-soft: oklch(72% 0.16 264 / 0.34);
--accent-shadow: oklch(72% 0.16 264 / 0.32);
--focus-ring: oklch(72% 0.16 264 / 0.5);
--scrollbar-thumb: oklch(95% 0.003 264 / 0.22);
--scrollbar-thumb-hover: oklch(95% 0.003 264 / 0.34);
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.4), 0 1px 3px oklch(0% 0 0 / 0.3);
--shadow: 0 1px 3px oklch(0% 0 0 / 0.45), 0 2px 6px oklch(0% 0 0 / 0.32);
--shadow-lg: 0 4px 10px oklch(0% 0 0 / 0.5), 0 1px 4px oklch(0% 0 0 / 0.35);
--shadow-pop: 0 6px 14px oklch(0% 0 0 / 0.55), 0 2px 6px oklch(0% 0 0 / 0.4);
--pill-shadow: 0 1px 3px oklch(0% 0 0 / 0.45);
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

-5
View File
@@ -1,5 +0,0 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"types": ["svelte", "vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}
-31
View File
@@ -1,31 +0,0 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte(), tailwindcss()],
// prevent Vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
});