feat: rewrite TTREE into 100% pure-Rust modular workspace with native AppKit UI, fast hedged translation, reliable OCR, and Ollama/LMStudio/GGUF AI backends
This commit is contained in:
1 parent
fd8c685baa
commit
0f04fefbcc
39 files changed
+11521
No files matched your search
@@ -10,3 +10,5 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
/target
|
||||
|
||||
Generated
+3773
File diff suppressed because it is too large.
Load diff
+117
@@ -0,0 +1,117 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/ttree-core",
|
||||
"crates/ttree-storage",
|
||||
"crates/ttree-platform-macos",
|
||||
"crates/ttree-ocr",
|
||||
"crates/ttree-translate",
|
||||
"crates/ttree-ai",
|
||||
"crates/ttree-app",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
authors = ["ZASENJC"]
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "cookies"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
tempfile = "3"
|
||||
dirs = "6"
|
||||
auto-launch = "0.5"
|
||||
global-hotkey = "0.8"
|
||||
core-foundation = "0.10"
|
||||
security-framework = "3"
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = { version = "0.3", features = [
|
||||
"NSArray",
|
||||
"NSAttributedString",
|
||||
"NSBundle",
|
||||
"NSData",
|
||||
"NSDictionary",
|
||||
"NSError",
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSProcessInfo",
|
||||
"NSRange",
|
||||
"NSRunLoop",
|
||||
"NSString",
|
||||
"NSThread",
|
||||
"NSTimer",
|
||||
"NSURL",
|
||||
"NSValue",
|
||||
] }
|
||||
objc2-core-foundation = "0.3"
|
||||
objc2-core-graphics = { version = "0.3", features = [
|
||||
"CGBitmapContext",
|
||||
"CGColorSpace",
|
||||
"CGContext",
|
||||
"CGEvent",
|
||||
"CGEventTypes",
|
||||
"CGImage",
|
||||
"CGRemoteOperation",
|
||||
] }
|
||||
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-vision = { version = "0.3", features = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-ml",
|
||||
"VNRequest",
|
||||
"VNRecognizeTextRequest",
|
||||
"VNObservation",
|
||||
"VNRequestHandler",
|
||||
"VNTypes",
|
||||
] }
|
||||
objc2-app-kit = { version = "0.3", features = [
|
||||
"NSApplication",
|
||||
"NSBezierPath",
|
||||
"NSBox",
|
||||
"NSButton",
|
||||
"NSButtonCell",
|
||||
"NSCell",
|
||||
"NSColor",
|
||||
"NSControl",
|
||||
"NSEvent",
|
||||
"NSFont",
|
||||
"NSGraphics",
|
||||
"NSImage",
|
||||
"NSMenu",
|
||||
"NSMenuItem",
|
||||
"NSOpenPanel",
|
||||
"NSPanel",
|
||||
"NSPasteboard",
|
||||
"NSPasteboardItem",
|
||||
"NSPopUpButton",
|
||||
"NSResponder",
|
||||
"NSRunningApplication",
|
||||
"NSSavePanel",
|
||||
"NSScreen",
|
||||
"NSScrollView",
|
||||
"NSSecureTextField",
|
||||
"NSSegmentedControl",
|
||||
"NSSlider",
|
||||
"NSStatusBar",
|
||||
"NSStatusBarButton",
|
||||
"NSStatusItem",
|
||||
"NSText",
|
||||
"NSTextContainer",
|
||||
"NSTextField",
|
||||
"NSTextFieldCell",
|
||||
"NSTextStorage",
|
||||
"NSTextView",
|
||||
"NSView",
|
||||
"NSVisualEffectView",
|
||||
"NSWindow",
|
||||
"objc2-core-foundation",
|
||||
] }
|
||||
objc2-quartz-core = { version = "0.3", features = ["CALayer"] }
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "ttree-ai"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ttree-core = { path = "../ttree-core" }
|
||||
anyhow.workspace = true
|
||||
futures-util.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
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" }
|
||||
@@ -0,0 +1,396 @@
|
||||
//! 纯 Rust `Candle` 本地 `.gguf` 量化模型直接加载与流式推理(支持 macOS Metal GPU 加速)。
|
||||
//!
|
||||
//! 核心亮点:
|
||||
//! 1. 直接从 `.gguf` 文件内置的 `tokenizer.ggml.tokens` 元数据构建分词器与解码器,无需额外准备 `tokenizer.json`;
|
||||
//! 2. 自动识别 `qwen2`、`qwen3`、`llama`(含 Llama3 / Mistral / DeepSeek 等)主流 GGUF 架构;
|
||||
//! 3. 优先启用 Apple Silicon `Device::new_metal(0)` GPU 加速,并在内存中常驻缓存已加载的模型权重,二次翻译 0 加载延迟;
|
||||
//! 4. 每生成一个 Token 即刻回调 UI 并检查 `CancelToken`,支持随时毫秒级打断。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use candle_core::quantized::gguf_file::{self, Value as GgufValue};
|
||||
use candle_core::{Device, Tensor};
|
||||
use candle_transformers::models::{quantized_llama, quantized_qwen2, quantized_qwen3};
|
||||
use ttree_core::{CancelToken, ChatMessage};
|
||||
|
||||
const MAX_NEW_TOKENS: usize = 512;
|
||||
|
||||
enum QuantizedArch {
|
||||
Qwen2(quantized_qwen2::ModelWeights),
|
||||
Qwen3(quantized_qwen3::ModelWeights),
|
||||
Llama(quantized_llama::ModelWeights),
|
||||
}
|
||||
|
||||
impl QuantizedArch {
|
||||
fn clear_kv_cache(&mut self) {
|
||||
match self {
|
||||
Self::Qwen2(m) => m.clear_kv_cache(),
|
||||
Self::Qwen3(m) => m.clear_kv_cache(),
|
||||
Self::Llama(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn forward(&mut self, input: &Tensor, index_pos: usize) -> candle_core::Result<Tensor> {
|
||||
match self {
|
||||
Self::Qwen2(m) => m.forward(input, index_pos),
|
||||
Self::Qwen3(m) => m.forward(input, index_pos),
|
||||
Self::Llama(m) => m.forward(input, index_pos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 GGUF 内嵌的 `tokenizer.ggml.tokens` 词表构建的轻量分词/解码器。
|
||||
pub struct GgufEmbeddedTokenizer {
|
||||
id_to_token: Vec<String>,
|
||||
token_to_id: HashMap<String, u32>,
|
||||
eos_tokens: Vec<u32>,
|
||||
bos_token: Option<u32>,
|
||||
}
|
||||
|
||||
impl GgufEmbeddedTokenizer {
|
||||
pub fn from_gguf_metadata(md: &HashMap<String, GgufValue>) -> Result<Self, String> {
|
||||
let raw_tokens = md
|
||||
.get("tokenizer.ggml.tokens")
|
||||
.and_then(|v| v.to_vec().ok())
|
||||
.ok_or_else(|| "GGUF 文件缺少内置词表 (tokenizer.ggml.tokens)".to_string())?;
|
||||
|
||||
let mut id_to_token = Vec::with_capacity(raw_tokens.len());
|
||||
let mut token_to_id = HashMap::with_capacity(raw_tokens.len());
|
||||
|
||||
for (idx, val) in raw_tokens.iter().enumerate() {
|
||||
let s = val.to_string().map(|s| s.clone()).unwrap_or_default();
|
||||
token_to_id.insert(s.clone(), idx as u32);
|
||||
id_to_token.push(s);
|
||||
}
|
||||
|
||||
let mut eos_tokens = Vec::new();
|
||||
if let Some(eos_id) = md
|
||||
.get("tokenizer.ggml.eos_token_id")
|
||||
.and_then(|v| v.to_u32().ok())
|
||||
{
|
||||
eos_tokens.push(eos_id);
|
||||
}
|
||||
for special in ["<|im_end|>", "<|endoftext|>", "</s>", "<|eot_id|>", "<end_of_turn>"] {
|
||||
if let Some(&id) = token_to_id.get(special) {
|
||||
if !eos_tokens.contains(&id) {
|
||||
eos_tokens.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bos_token = md
|
||||
.get("tokenizer.ggml.bos_token_id")
|
||||
.and_then(|v| v.to_u32().ok());
|
||||
|
||||
Ok(Self {
|
||||
id_to_token,
|
||||
token_to_id,
|
||||
eos_tokens,
|
||||
bos_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_eos(&self, token_id: u32) -> bool {
|
||||
self.eos_tokens.contains(&token_id)
|
||||
}
|
||||
|
||||
/// 最长前缀贪心匹配 + GPT2/SentencePiece 字节回退分词。
|
||||
pub fn encode(&self, text: &str) -> Vec<u32> {
|
||||
let mut ids = Vec::new();
|
||||
if let Some(bos) = self.bos_token {
|
||||
ids.push(bos);
|
||||
}
|
||||
// 将空格映射为 SentencePiece `▁` 或 GPT-2 `Ġ` 候选以匹配主流 GGUF 词表
|
||||
let uses_sp_space = self.token_to_id.contains_key("▁the")
|
||||
|| self.token_to_id.contains_key("▁")
|
||||
|| self.token_to_id.contains_key("▁a");
|
||||
let normalized = if uses_sp_space {
|
||||
text.replace(' ', "▁")
|
||||
} else {
|
||||
text.replace(' ', "Ġ").replace('\n', "Ċ")
|
||||
};
|
||||
|
||||
let mut rest = normalized.as_str();
|
||||
while !rest.is_empty() {
|
||||
// 先匹配特殊控制标记(如 `<|im_start|>`、`<|im_end|>`)
|
||||
if rest.starts_with("<|") {
|
||||
if let Some(end_idx) = rest.find("|>") {
|
||||
let special = &rest[..end_idx + 2];
|
||||
if let Some(&id) = self.token_to_id.get(special) {
|
||||
ids.push(id);
|
||||
rest = &rest[end_idx + 2..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched = None;
|
||||
let max_bytes = rest.len().min(32);
|
||||
let mut end = max_bytes;
|
||||
while end > 0 {
|
||||
if rest.is_char_boundary(end) {
|
||||
let candidate = &rest[..end];
|
||||
if let Some(&id) = self.token_to_id.get(candidate) {
|
||||
matched = Some((id, end));
|
||||
break;
|
||||
}
|
||||
}
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
if let Some((id, len)) = matched {
|
||||
ids.push(id);
|
||||
rest = &rest[len..];
|
||||
} else {
|
||||
// 单字节 `<0xXX>` 回退
|
||||
let ch = rest.chars().next().unwrap();
|
||||
let ch_len = ch.len_utf8();
|
||||
for &b in &rest.as_bytes()[..ch_len] {
|
||||
let hex_tok = format!("<0x{b:02X}>");
|
||||
if let Some(&id) = self.token_to_id.get(&hex_tok) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
rest = &rest[ch_len..];
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn decode_token_bytes(&self, token_id: u32) -> Vec<u8> {
|
||||
let Some(piece) = self.id_to_token.get(token_id as usize) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if piece.starts_with("<|") && piece.ends_with("|>") {
|
||||
return Vec::new();
|
||||
}
|
||||
if piece.len() == 6 && piece.starts_with("<0x") && piece.ends_with('>') {
|
||||
if let Ok(b) = u8::from_str_radix(&piece[3..5], 16) {
|
||||
return vec![b];
|
||||
}
|
||||
}
|
||||
let decoded = piece
|
||||
.replace('▁', " ")
|
||||
.replace("Ġ", " ")
|
||||
.replace("Ċ", "\n");
|
||||
decoded.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
struct CachedSession {
|
||||
model_path: String,
|
||||
device: Device,
|
||||
arch: QuantizedArch,
|
||||
tokenizer: GgufEmbeddedTokenizer,
|
||||
}
|
||||
|
||||
static GGUF_SESSION: OnceLock<Mutex<Option<CachedSession>>> = OnceLock::new();
|
||||
|
||||
fn select_device() -> Device {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Ok(metal) = Device::new_metal(0) {
|
||||
return metal;
|
||||
}
|
||||
Device::Cpu
|
||||
}
|
||||
|
||||
fn load_session(model_path: &str) -> Result<CachedSession, String> {
|
||||
let path = Path::new(model_path);
|
||||
if !path.is_file() {
|
||||
return Err(format!("找不到本地 GGUF 模型文件: {model_path}"));
|
||||
}
|
||||
let file = File::open(path).map_err(|e| format!("打开 GGUF 文件失败: {e}"))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let content = gguf_file::Content::read(&mut reader)
|
||||
.map_err(|e| format!("解析 GGUF 文件头失败: {e}"))?;
|
||||
|
||||
let arch_name = content
|
||||
.metadata
|
||||
.get("general.architecture")
|
||||
.and_then(|v| v.to_string().ok())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "llama".to_string());
|
||||
|
||||
let tokenizer = GgufEmbeddedTokenizer::from_gguf_metadata(&content.metadata)?;
|
||||
let device = select_device();
|
||||
|
||||
let arch = match arch_name.as_str() {
|
||||
"qwen2" => {
|
||||
let weights = quantized_qwen2::ModelWeights::from_gguf(content, &mut reader, &device)
|
||||
.map_err(|e| format!("加载 Qwen2 GGUF 权重失败: {e}"))?;
|
||||
QuantizedArch::Qwen2(weights)
|
||||
}
|
||||
"qwen3" => {
|
||||
let weights = quantized_qwen3::ModelWeights::from_gguf(content, &mut reader, &device)
|
||||
.map_err(|e| format!("加载 Qwen3 GGUF 权重失败: {e}"))?;
|
||||
QuantizedArch::Qwen3(weights)
|
||||
}
|
||||
_ => {
|
||||
let weights = quantized_llama::ModelWeights::from_gguf(content, &mut reader, &device)
|
||||
.map_err(|e| format!("加载 {arch_name} GGUF 权重失败: {e}"))?;
|
||||
QuantizedArch::Llama(weights)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CachedSession {
|
||||
model_path: model_path.to_string(),
|
||||
device,
|
||||
arch,
|
||||
tokenizer,
|
||||
})
|
||||
}
|
||||
|
||||
/// 将对话消息格式化为通用 ChatML 提示词模板(兼容 Qwen / Llama / OpenChat 等 GGUF 模型)。
|
||||
pub fn format_chatml_prompt(messages: &[ChatMessage]) -> String {
|
||||
let mut prompt = String::new();
|
||||
for msg in messages {
|
||||
prompt.push_str("<|im_start|>");
|
||||
prompt.push_str(&msg.role);
|
||||
prompt.push('\n');
|
||||
prompt.push_str(&msg.content);
|
||||
prompt.push_str("<|im_end|>\n");
|
||||
}
|
||||
prompt.push_str("<|im_start|>assistant\n");
|
||||
prompt
|
||||
}
|
||||
|
||||
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,
|
||||
{
|
||||
let model_path = gguf_model_path.trim().to_string();
|
||||
if model_path.is_empty() {
|
||||
return Err("未选择本地 .gguf 模型文件路径,请在设置中指定文件位置".to_string());
|
||||
}
|
||||
|
||||
let prompt = format_chatml_prompt(messages);
|
||||
let cancel_clone = cancel.clone();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Result<String, String>>();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let slot = GGUF_SESSION.get_or_init(|| Mutex::new(None));
|
||||
let mut guard = match slot.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(format!("GGUF 引擎锁异常: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let needs_reload = guard
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.model_path != model_path);
|
||||
if needs_reload {
|
||||
match load_session(&model_path) {
|
||||
Ok(session) => *guard = Some(session),
|
||||
Err(err) => {
|
||||
let _ = tx.send(Err(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session = guard.as_mut().expect("session loaded");
|
||||
session.arch.clear_kv_cache();
|
||||
|
||||
let prompt_tokens = session.tokenizer.encode(&prompt);
|
||||
if prompt_tokens.is_empty() {
|
||||
let _ = tx.send(Err("提示词分词为空".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut next_token = match run_step(&mut session.arch, &session.device, &prompt_tokens, 0) {
|
||||
Ok(tok) => tok,
|
||||
Err(err) => {
|
||||
let _ = tx.send(Err(err));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut utf8_pending = Vec::new();
|
||||
let mut pos = prompt_tokens.len();
|
||||
|
||||
for _ in 0..MAX_NEW_TOKENS {
|
||||
if cancel_clone.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if session.tokenizer.is_eos(next_token) {
|
||||
break;
|
||||
}
|
||||
utf8_pending.extend_from_slice(&session.tokenizer.decode_token_bytes(next_token));
|
||||
if let Ok(valid_str) = std::str::from_utf8(&utf8_pending) {
|
||||
if !valid_str.is_empty() {
|
||||
let _ = tx.send(Ok(valid_str.to_string()));
|
||||
}
|
||||
utf8_pending.clear();
|
||||
}
|
||||
|
||||
next_token = match run_step(&mut session.arch, &session.device, &[next_token], pos) {
|
||||
Ok(tok) => tok,
|
||||
Err(err) => {
|
||||
let _ = tx.send(Err(err));
|
||||
return;
|
||||
}
|
||||
};
|
||||
pos += 1;
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(item) = rx.recv().await {
|
||||
if cancel.is_cancelled() {
|
||||
return Err("请求已取消".to_string());
|
||||
}
|
||||
let delta = item?;
|
||||
on_delta(&delta);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行一步前向推理并采用 Argmax 贪心采样(翻译任务使用 temperature=0 最准确稳定)。
|
||||
fn run_step(
|
||||
arch: &mut QuantizedArch,
|
||||
device: &Device,
|
||||
tokens: &[u32],
|
||||
index_pos: usize,
|
||||
) -> Result<u32, String> {
|
||||
let input = Tensor::new(tokens, device)
|
||||
.and_then(|t| t.unsqueeze(0))
|
||||
.map_err(|e| format!("构造输入张量失败: {e}"))?;
|
||||
let logits = arch
|
||||
.forward(&input, index_pos)
|
||||
.map_err(|e| format!("GGUF 模型前向推理失败: {e}"))?;
|
||||
let logits = logits
|
||||
.squeeze(0)
|
||||
.map_err(|e| format!("张量维度压缩失败: {e}"))?;
|
||||
let next_id = logits
|
||||
.argmax(0)
|
||||
.and_then(|t| t.to_scalar::<u32>())
|
||||
.map_err(|e| format!("采样下一个 Token 失败: {e}"))?;
|
||||
Ok(next_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_chatml_prompt_correctly() {
|
||||
let msgs = vec![
|
||||
ChatMessage::system("Translate to Chinese."),
|
||||
ChatMessage::user("Hello"),
|
||||
];
|
||||
let p = format_chatml_prompt(&msgs);
|
||||
assert!(p.contains("<|im_start|>system\nTranslate to Chinese.<|im_end|>"));
|
||||
assert!(p.ends_with("<|im_start|>assistant\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 云端 OpenAI 兼容接口与本地服务(Ollama / LM Studio / MLX Server)HTTP 流式客户端 + 本地模型自动探测。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use ttree_core::{AiBackendKind, CancelToken, ChatMessage};
|
||||
|
||||
use crate::sse::{LineStreamDecoder, StreamDelta};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const STREAM_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
fn http_client() -> &'static Client {
|
||||
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.timeout(STREAM_TIMEOUT)
|
||||
.tcp_keepalive(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("failed to build AI HTTP client")
|
||||
})
|
||||
}
|
||||
|
||||
/// 自动探测本机运行中的 Ollama (`/api/tags`) 或 LM Studio / MLX (`/v1/models`) 模型列表。
|
||||
pub async fn discover_local_models(
|
||||
backend: AiBackendKind,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let base = if base_url.trim().is_empty() {
|
||||
backend.default_base_url()
|
||||
} else {
|
||||
base_url.trim().trim_end_matches('/')
|
||||
};
|
||||
|
||||
match backend {
|
||||
AiBackendKind::Ollama => {
|
||||
let root = base.trim_end_matches("/v1");
|
||||
let url = format!("{root}/api/tags");
|
||||
let resp = http_client()
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(3))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("无法连接本机 Ollama 服务 ({url}): {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Ollama 返回状态 {}", resp.status()));
|
||||
}
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析 Ollama 模型列表失败: {e}"))?;
|
||||
let models = body
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.get("name").and_then(Value::as_str).map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(models)
|
||||
}
|
||||
AiBackendKind::LocalServer | AiBackendKind::OpenAiCompatible => {
|
||||
let root = if base.ends_with("/v1") {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/v1")
|
||||
};
|
||||
let url = format!("{root}/models");
|
||||
let resp = http_client()
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(3))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("无法连接本地模型服务 ({url}): {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("模型服务返回状态 {}", resp.status()));
|
||||
}
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析模型列表失败: {e}"))?;
|
||||
let models = body
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.get("id").and_then(Value::as_str).map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(models)
|
||||
}
|
||||
AiBackendKind::CandleGguf => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用 OpenAI 兼容端点(云端或 LM Studio / MLX / llama.cpp)执行 SSE 流式请求。
|
||||
pub async fn stream_openai_compatible<F>(
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
model: &str,
|
||||
messages: &[ChatMessage],
|
||||
cancel: &CancelToken,
|
||||
on_delta: &mut F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(&str) + Send,
|
||||
{
|
||||
let base = base_url.trim().trim_end_matches('/');
|
||||
let url = if base.ends_with("/chat/completions") {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/chat/completions")
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
"stream": true,
|
||||
"temperature": 0.2,
|
||||
"messages": messages,
|
||||
});
|
||||
|
||||
let mut req = http_client().post(&url).json(&payload);
|
||||
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
|
||||
let resp = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err("请求已取消".to_string()),
|
||||
r = req.send() => r.map_err(|e| format!("AI 请求失败: {e}"))?,
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let snippet: String = text.chars().take(300).collect();
|
||||
return Err(format!("AI 接口返回状态 {status}: {snippet}"));
|
||||
}
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut decoder = LineStreamDecoder::new_sse();
|
||||
|
||||
loop {
|
||||
let next_chunk = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err("请求已取消".to_string()),
|
||||
chunk = stream.next() => chunk,
|
||||
};
|
||||
let Some(chunk) = next_chunk else {
|
||||
break;
|
||||
};
|
||||
let bytes = chunk.map_err(|e| format!("读取 AI 响应流失败: {e}"))?;
|
||||
for delta in decoder.push(&bytes)? {
|
||||
match delta {
|
||||
StreamDelta::Content(text) => on_delta(&text),
|
||||
StreamDelta::Done => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for delta in decoder.finish()? {
|
||||
if let StreamDelta::Content(text) = delta {
|
||||
on_delta(&text);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 调用本机 Ollama 原生 `/api/chat` NDJSON 流式接口(自动保持模型热加载 `keep_alive: "15m"`)。
|
||||
pub async fn stream_ollama<F>(
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
messages: &[ChatMessage],
|
||||
cancel: &CancelToken,
|
||||
on_delta: &mut F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(&str) + Send,
|
||||
{
|
||||
let base = if base_url.trim().is_empty() {
|
||||
"http://127.0.0.1:11434"
|
||||
} else {
|
||||
base_url.trim().trim_end_matches('/').trim_end_matches("/v1")
|
||||
};
|
||||
let url = format!("{base}/api/chat");
|
||||
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
"stream": true,
|
||||
"keep_alive": "15m",
|
||||
"options": {
|
||||
"temperature": 0.2
|
||||
},
|
||||
"messages": messages,
|
||||
});
|
||||
|
||||
let resp = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err("请求已取消".to_string()),
|
||||
r = http_client().post(&url).json(&payload).send() => {
|
||||
r.map_err(|e| format!("连接本机 Ollama 失败 ({url}): {e}"))?
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let snippet: String = text.chars().take(300).collect();
|
||||
return Err(format!("Ollama 返回状态 {status}: {snippet}"));
|
||||
}
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut decoder = LineStreamDecoder::new_ollama_ndjson();
|
||||
|
||||
loop {
|
||||
let next_chunk = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err("请求已取消".to_string()),
|
||||
chunk = stream.next() => chunk,
|
||||
};
|
||||
let Some(chunk) = next_chunk else {
|
||||
break;
|
||||
};
|
||||
let bytes = chunk.map_err(|e| format!("读取 Ollama 流失败: {e}"))?;
|
||||
for delta in decoder.push(&bytes)? {
|
||||
match delta {
|
||||
StreamDelta::Content(text) => on_delta(&text),
|
||||
StreamDelta::Done => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for delta in decoder.finish()? {
|
||||
if let StreamDelta::Content(text) = delta {
|
||||
on_delta(&text);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! `ttree-ai` —— 独立重写的 AI 翻译与对话模块。
|
||||
//!
|
||||
//! 核心特性:
|
||||
//! 1. 与 GUI 框架完全解耦,通过回调流式输出增量文本(`on_delta`)并支持 `CancelToken` 毫秒级中断;
|
||||
//! 2. 消除旧版重复发送两遍 Prompt 的缺陷,大幅降低首字延迟(TTFT);
|
||||
//! 3. 支持 4 类模型后端:
|
||||
//! - `OpenAiCompatible`:云端 OpenAI / DeepSeek / OpenRouter 兼容接口
|
||||
//! - `Ollama`:本机 Ollama 服务(免 Key,支持 `/api/tags` 自动探测本地已装模型)
|
||||
//! - `LocalServer`:本机 LM Studio / MLX Server / llama.cpp 服务(免 Key,支持 `/v1/models` 探测)
|
||||
//! - `CandleGguf`:基于纯 Rust `candle` (Metal GPU 加速) 直接加载本地 `.gguf` 量化模型,常驻内存复用
|
||||
|
||||
pub mod gguf;
|
||||
pub mod http_provider;
|
||||
pub mod sse;
|
||||
|
||||
use ttree_core::language::language_display_name;
|
||||
use ttree_core::{
|
||||
AiBackendKind, AiTranslationConfig, CancelToken, ChatAiConfig, ChatMessage,
|
||||
validate_language_pair, validate_messages,
|
||||
};
|
||||
|
||||
pub use http_provider::discover_local_models;
|
||||
|
||||
/// 渲染精简高效的翻译 System Prompt(不再在 User 消息中重复拼接第二遍 Prompt)。
|
||||
pub fn render_translate_prompt(template: &str, source: &str, target: &str) -> String {
|
||||
let src_name = language_display_name(source);
|
||||
let tgt_name = language_display_name(target);
|
||||
template
|
||||
.replace("{source}", src_name)
|
||||
.replace("{target}", tgt_name)
|
||||
}
|
||||
|
||||
/// 组装翻译消息:System 放指令,User 仅放待译原文(省去 50% 冗余 Prompt Token)。
|
||||
pub fn build_translate_messages(
|
||||
cfg: &AiTranslationConfig,
|
||||
text: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Vec<ChatMessage> {
|
||||
let prompt = render_translate_prompt(&cfg.translate_prompt, source, target);
|
||||
vec![
|
||||
ChatMessage::system(prompt),
|
||||
ChatMessage::user(text.trim()),
|
||||
]
|
||||
}
|
||||
|
||||
/// 组装 AI 对话消息。
|
||||
pub fn build_chat_messages(cfg: &ChatAiConfig, history: &[ChatMessage]) -> Vec<ChatMessage> {
|
||||
let mut out = Vec::with_capacity(history.len() + 1);
|
||||
if !cfg.chat_prompt.trim().is_empty() {
|
||||
out.push(ChatMessage::system(cfg.chat_prompt.trim()));
|
||||
}
|
||||
out.extend_from_slice(history);
|
||||
out
|
||||
}
|
||||
|
||||
/// 执行流式 AI 翻译。
|
||||
pub async fn stream_translate<F>(
|
||||
cfg: &AiTranslationConfig,
|
||||
text: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
cancel: &CancelToken,
|
||||
mut on_delta: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(&str) + Send,
|
||||
{
|
||||
if !cfg.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
validate_language_pair(source, target)?;
|
||||
let messages = build_translate_messages(cfg, trimmed, source, target);
|
||||
|
||||
dispatch_stream(
|
||||
cfg.backend,
|
||||
&cfg.base_url,
|
||||
&cfg.api_key,
|
||||
&cfg.model,
|
||||
&cfg.gguf_model_path,
|
||||
&cfg.gguf_tokenizer_path,
|
||||
&messages,
|
||||
cancel,
|
||||
&mut on_delta,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 执行流式 AI 对话。
|
||||
pub async fn stream_chat<F>(
|
||||
cfg: &ChatAiConfig,
|
||||
history: &[ChatMessage],
|
||||
cancel: &CancelToken,
|
||||
mut on_delta: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(&str) + Send,
|
||||
{
|
||||
if history.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
validate_messages(history)?;
|
||||
let messages = build_chat_messages(cfg, history);
|
||||
|
||||
dispatch_stream(
|
||||
cfg.backend,
|
||||
&cfg.base_url,
|
||||
&cfg.api_key,
|
||||
&cfg.model,
|
||||
&cfg.gguf_model_path,
|
||||
&cfg.gguf_tokenizer_path,
|
||||
&messages,
|
||||
cancel,
|
||||
&mut on_delta,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dispatch_stream<F>(
|
||||
backend: AiBackendKind,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
gguf_model_path: &str,
|
||||
gguf_tokenizer_path: &str,
|
||||
messages: &[ChatMessage],
|
||||
cancel: &CancelToken,
|
||||
on_delta: &mut F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(&str) + Send,
|
||||
{
|
||||
match backend {
|
||||
AiBackendKind::OpenAiCompatible => {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("未配置云端 API Key(可在设置中填入,或切换为本地 Ollama / LM Studio / GGUF 模型)".to_string());
|
||||
}
|
||||
http_provider::stream_openai_compatible(
|
||||
base_url,
|
||||
Some(api_key.trim()),
|
||||
model,
|
||||
messages,
|
||||
cancel,
|
||||
on_delta,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiBackendKind::Ollama => {
|
||||
http_provider::stream_ollama(base_url, model, messages, cancel, on_delta).await
|
||||
}
|
||||
AiBackendKind::LocalServer => {
|
||||
let opt_key = (!api_key.trim().is_empty()).then_some(api_key.trim());
|
||||
http_provider::stream_openai_compatible(
|
||||
base_url,
|
||||
opt_key,
|
||||
model,
|
||||
messages,
|
||||
cancel,
|
||||
on_delta,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiBackendKind::CandleGguf => {
|
||||
gguf::stream_gguf_inference(
|
||||
gguf_model_path,
|
||||
gguf_tokenizer_path,
|
||||
messages,
|
||||
cancel,
|
||||
on_delta,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_compact_translate_messages_without_duplicate_prompt() {
|
||||
let cfg = AiTranslationConfig::default();
|
||||
let msgs = build_translate_messages(&cfg, "Hello world", "en", "zh-CN");
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].role, "system");
|
||||
assert!(msgs[0].content.contains("简体中文"));
|
||||
assert_eq!(msgs[1].role, "user");
|
||||
// User 消息仅包含待译原文,不再重复拼接 system prompt
|
||||
assert_eq!(msgs[1].content, "Hello world");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! SSE (Server-Sent Events) 与 NDJSON (Ollama) 流式帧解码器。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub const MAX_SSE_FRAME_BYTES: usize = 256 * 1024;
|
||||
pub const MAX_STREAM_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StreamDelta {
|
||||
Content(String),
|
||||
Done,
|
||||
}
|
||||
|
||||
pub fn parse_sse_line(line: &str) -> Result<Option<StreamDelta>, 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(StreamDelta::Done));
|
||||
}
|
||||
let v: Value =
|
||||
serde_json::from_str(data).map_err(|e| format!("SSE JSON 解析失败: {e}"))?;
|
||||
let content = v
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("delta"))
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(Value::as_str);
|
||||
Ok(content.map(|s| StreamDelta::Content(s.to_string())))
|
||||
}
|
||||
|
||||
pub fn parse_ollama_ndjson_line(line: &str) -> Result<Option<StreamDelta>, String> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let v: Value =
|
||||
serde_json::from_str(trimmed).map_err(|e| format!("Ollama JSON 解析失败: {e}"))?;
|
||||
if let Some(err) = v.get("error").and_then(Value::as_str) {
|
||||
return Err(format!("Ollama 返回错误: {err}"));
|
||||
}
|
||||
let done = v.get("done").and_then(Value::as_bool).unwrap_or(false);
|
||||
let content = v
|
||||
.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
if !content.is_empty() {
|
||||
return Ok(Some(StreamDelta::Content(content.to_string())));
|
||||
}
|
||||
if done {
|
||||
return Ok(Some(StreamDelta::Done));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub struct LineStreamDecoder {
|
||||
buffer: Vec<u8>,
|
||||
frame_limit: usize,
|
||||
output_limit: usize,
|
||||
output_bytes: usize,
|
||||
saw_done: bool,
|
||||
ndjson_mode: bool,
|
||||
}
|
||||
|
||||
impl LineStreamDecoder {
|
||||
pub fn new_sse() -> Self {
|
||||
Self {
|
||||
buffer: Vec::new(),
|
||||
frame_limit: MAX_SSE_FRAME_BYTES,
|
||||
output_limit: MAX_STREAM_OUTPUT_BYTES,
|
||||
output_bytes: 0,
|
||||
saw_done: false,
|
||||
ndjson_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_ollama_ndjson() -> Self {
|
||||
Self {
|
||||
buffer: Vec::new(),
|
||||
frame_limit: MAX_SSE_FRAME_BYTES,
|
||||
output_limit: MAX_STREAM_OUTPUT_BYTES,
|
||||
output_bytes: 0,
|
||||
saw_done: false,
|
||||
ndjson_mode: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<StreamDelta>, String> {
|
||||
let mut deltas = Vec::new();
|
||||
for &byte in bytes {
|
||||
if byte == b'\n' {
|
||||
if let Some(delta) = self.take_line()? {
|
||||
let is_done = delta == StreamDelta::Done;
|
||||
deltas.push(delta);
|
||||
if is_done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.buffer.len() >= self.frame_limit {
|
||||
return Err(format!("流式单帧超出 {} 字节上限", self.frame_limit));
|
||||
}
|
||||
self.buffer.push(byte);
|
||||
}
|
||||
Ok(deltas)
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> Result<Vec<StreamDelta>, 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 && self.output_bytes == 0 {
|
||||
return Err("AI 流提前结束且未返回有效内容".to_string());
|
||||
}
|
||||
Ok(deltas)
|
||||
}
|
||||
|
||||
fn take_line(&mut self) -> Result<Option<StreamDelta>, String> {
|
||||
let line_bytes = std::mem::take(&mut self.buffer);
|
||||
let line = std::str::from_utf8(&line_bytes)
|
||||
.map_err(|e| format!("流式响应包含非法 UTF-8: {e}"))?
|
||||
.trim();
|
||||
let parsed = if self.ndjson_mode {
|
||||
parse_ollama_ndjson_line(line)?
|
||||
} else {
|
||||
parse_sse_line(line)?
|
||||
};
|
||||
let Some(delta) = parsed else {
|
||||
return Ok(None);
|
||||
};
|
||||
match &delta {
|
||||
StreamDelta::Content(c) => {
|
||||
self.output_bytes = self
|
||||
.output_bytes
|
||||
.checked_add(c.len())
|
||||
.ok_or_else(|| "流式输出大小溢出".to_string())?;
|
||||
if self.output_bytes > self.output_limit {
|
||||
return Err(format!("流式输出超出 {} 字节上限", self.output_limit));
|
||||
}
|
||||
}
|
||||
StreamDelta::Done => {
|
||||
self.saw_done = true;
|
||||
}
|
||||
}
|
||||
Ok(Some(delta))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_sse_and_ollama_ndjson_streams() {
|
||||
let mut sse = LineStreamDecoder::new_sse();
|
||||
let out = sse
|
||||
.push("data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: [DONE]\n".as_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![StreamDelta::Content("你好".to_string()), StreamDelta::Done]
|
||||
);
|
||||
|
||||
let mut ndjson = LineStreamDecoder::new_ollama_ndjson();
|
||||
let out2 = ndjson
|
||||
.push("{\"message\":{\"content\":\"世界\"},\"done\":false}\n{\"message\":{\"content\":\"\"},\"done\":true}\n".as_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out2,
|
||||
vec![StreamDelta::Content("世界".to_string()), StreamDelta::Done]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "ttree-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "ttree"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ttree-core = { path = "../ttree-core" }
|
||||
ttree-storage = { path = "../ttree-storage" }
|
||||
ttree-platform-macos = { path = "../ttree-platform-macos" }
|
||||
ttree-ocr = { path = "../ttree-ocr" }
|
||||
ttree-translate = { path = "../ttree-translate" }
|
||||
ttree-ai = { path = "../ttree-ai" }
|
||||
anyhow.workspace = true
|
||||
global-hotkey.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2.workspace = true
|
||||
objc2-app-kit.workspace = true
|
||||
objc2-core-foundation.workspace = true
|
||||
objc2-foundation.workspace = true
|
||||
objc2-quartz-core.workspace = true
|
||||
@@ -0,0 +1,351 @@
|
||||
//! `ttree` —— 100% 纯 Rust 原生 macOS AI 翻译与 OCR 桌面端入口。
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod state;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod ui_helpers;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {
|
||||
eprintln!("TTREE 纯 Rust 原生 AppKit 客户端仅支持 macOS 运行。");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
macos_app::run();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_app {
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::{AnyObject, Bool, Sel};
|
||||
use objc2::{define_class, msg_send, sel, MainThreadMarker, MainThreadOnly};
|
||||
use objc2_app_kit::{
|
||||
NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSColor, NSEvent,
|
||||
NSEventModifierFlags, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSTextView,
|
||||
NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState,
|
||||
NSVisualEffectView, NSWindow, NSWindowStyleMask, NSWindowTitleVisibility,
|
||||
};
|
||||
use objc2_foundation::{
|
||||
NSObject, NSObjectProtocol, NSPoint, NSRect, NSRunLoop, NSRunLoopCommonModes, NSSize,
|
||||
NSString, NSTimer,
|
||||
};
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
||||
|
||||
use ttree_core::{WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH};
|
||||
use ttree_storage::{HistoryStore, SettingsStore};
|
||||
|
||||
use crate::state::{ActionTag, AppController, TabMode, UiEvent};
|
||||
|
||||
struct GlobalUiContext {
|
||||
ctrl: AppController,
|
||||
ui_rx: UnboundedReceiver<UiEvent>,
|
||||
_status_item: Retained<NSStatusItem>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static UI_CTX: RefCell<Option<GlobalUiContext>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn with_ctx<F: FnOnce(&mut GlobalUiContext)>(f: F) {
|
||||
UI_CTX.with(|cell| {
|
||||
if let Ok(mut borrow) = cell.try_borrow_mut() {
|
||||
if let Some(ctx) = borrow.as_mut() {
|
||||
f(ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
define_class!(
|
||||
#[unsafe(super(NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "TTreeActionDispatcher"]
|
||||
pub struct ActionDispatcher;
|
||||
|
||||
unsafe impl NSObjectProtocol for ActionDispatcher {}
|
||||
|
||||
impl ActionDispatcher {
|
||||
#[unsafe(method(handleControl:))]
|
||||
fn handle_control(&self, sender: &AnyObject) {
|
||||
let raw_tag: isize = unsafe { msg_send![sender, tag] };
|
||||
if let Some(tag) = ActionTag::from_raw(raw_tag) {
|
||||
with_ctx(|ctx| ctx.ctrl.on_action(tag));
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(method(handleTimer:))]
|
||||
fn handle_timer(&self, _timer: &AnyObject) {
|
||||
with_ctx(|ctx| {
|
||||
while let Ok(hk_ev) = GlobalHotKeyEvent::receiver().try_recv() {
|
||||
if hk_ev.state == HotKeyState::Pressed {
|
||||
if let Some(action) = ctx.ctrl.parsed_shortcuts.action_for_id(hk_ev.id)
|
||||
{
|
||||
ctx.ctrl.handle_hotkey_action(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(ui_ev) = ctx.ui_rx.try_recv() {
|
||||
ctx.ctrl.handle_ui_event(ui_ev);
|
||||
}
|
||||
ctx.ctrl.poll_tick();
|
||||
});
|
||||
}
|
||||
|
||||
#[unsafe(method(textView:doCommandBySelector:))]
|
||||
fn text_view_do_command(&self, _text_view: &NSTextView, command: Sel) -> Bool {
|
||||
if command == sel!(cancelOperation:) {
|
||||
with_ctx(|ctx| ctx.ctrl.on_action(ActionTag::HideOrEscape));
|
||||
return Bool::YES;
|
||||
}
|
||||
if command == sel!(insertNewline:) {
|
||||
let flags = NSEvent::modifierFlags_class();
|
||||
if flags.contains(NSEventModifierFlags::Shift) {
|
||||
return Bool::NO;
|
||||
}
|
||||
with_ctx(|ctx| match ctx.ctrl.active_tab {
|
||||
TabMode::Translate => ctx.ctrl.start_translation(),
|
||||
TabMode::Chat => ctx.ctrl.send_chat_from_input(),
|
||||
TabMode::Settings => {}
|
||||
});
|
||||
return Bool::YES;
|
||||
}
|
||||
Bool::NO
|
||||
}
|
||||
|
||||
#[unsafe(method(windowDidResignKey:))]
|
||||
fn window_did_resign_key(&self, _notification: &AnyObject) {
|
||||
with_ctx(|ctx| ctx.ctrl.on_window_did_resign_key());
|
||||
}
|
||||
|
||||
#[unsafe(method(windowDidResize:))]
|
||||
fn window_did_resize(&self, _notification: &AnyObject) {
|
||||
with_ctx(|ctx| ctx.ctrl.on_window_did_resize());
|
||||
}
|
||||
|
||||
#[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))]
|
||||
fn app_should_handle_reopen(&self, _app: &AnyObject, _has_visible: Bool) -> Bool {
|
||||
with_ctx(|ctx| {
|
||||
ctx.ctrl.switch_tab(TabMode::Translate);
|
||||
ctx.ctrl.show_and_focus_window();
|
||||
});
|
||||
Bool::YES
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl ActionDispatcher {
|
||||
fn new(mtm: MainThreadMarker) -> Retained<Self> {
|
||||
unsafe { msg_send![mtm.alloc::<Self>(), init] }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("初始化 Tokio 异步运行时失败");
|
||||
|
||||
let mtm = MainThreadMarker::new().expect("必须在 macOS 主线程启动 AppKit UI");
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
||||
|
||||
let dispatcher = ActionDispatcher::new(mtm);
|
||||
unsafe {
|
||||
let _: () = msg_send![&*app, setDelegate: Some(&*dispatcher)];
|
||||
}
|
||||
|
||||
install_standard_main_menu(mtm, &app);
|
||||
|
||||
let settings = Arc::new(SettingsStore::open_default());
|
||||
let history = Arc::new(HistoryStore::open_default());
|
||||
let hotkey_mgr = GlobalHotKeyManager::new().expect("初始化全局快捷键管理器失败");
|
||||
|
||||
let (win_w, win_h) = settings.window_size();
|
||||
let appearance = settings.appearance();
|
||||
let (window, effect_view) =
|
||||
build_main_window(mtm, win_w, win_h, appearance.panel_opacity, &dispatcher);
|
||||
|
||||
let status_item = build_status_bar_item(mtm, &dispatcher);
|
||||
let (ui_tx, ui_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let mut ctrl = AppController::build(
|
||||
mtm,
|
||||
rt.handle().clone(),
|
||||
ui_tx,
|
||||
settings,
|
||||
history,
|
||||
hotkey_mgr,
|
||||
window,
|
||||
effect_view,
|
||||
&dispatcher,
|
||||
);
|
||||
|
||||
if std::env::args().any(|a| a == "--show") {
|
||||
ctrl.show_and_focus_window();
|
||||
}
|
||||
|
||||
UI_CTX.with(|cell| {
|
||||
*cell.borrow_mut() = Some(GlobalUiContext {
|
||||
ctrl,
|
||||
ui_rx,
|
||||
_status_item: status_item,
|
||||
});
|
||||
});
|
||||
|
||||
unsafe {
|
||||
let timer = NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats(
|
||||
0.025,
|
||||
&dispatcher,
|
||||
sel!(handleTimer:),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
let run_loop = NSRunLoop::mainRunLoop();
|
||||
run_loop.addTimer_forMode(&timer, NSRunLoopCommonModes);
|
||||
}
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
fn build_main_window(
|
||||
mtm: MainThreadMarker,
|
||||
width: f64,
|
||||
height: f64,
|
||||
opacity: f64,
|
||||
dispatcher: &ActionDispatcher,
|
||||
) -> (Retained<NSWindow>, Retained<NSVisualEffectView>) {
|
||||
unsafe {
|
||||
let frame = NSRect::new(NSPoint::new(240.0, 240.0), NSSize::new(width, height));
|
||||
let style = NSWindowStyleMask::Titled
|
||||
| NSWindowStyleMask::Closable
|
||||
| NSWindowStyleMask::Resizable
|
||||
| NSWindowStyleMask::FullSizeContentView;
|
||||
let window = NSWindow::initWithContentRect_styleMask_backing_defer(
|
||||
mtm.alloc(),
|
||||
frame,
|
||||
style,
|
||||
NSBackingStoreType::Buffered,
|
||||
false,
|
||||
);
|
||||
window.setTitle(&NSString::from_str("TTREE"));
|
||||
window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
|
||||
window.setTitlebarAppearsTransparent(true);
|
||||
window.setMovableByWindowBackground(true);
|
||||
window.setReleasedWhenClosed(false);
|
||||
window.setOpaque(false);
|
||||
window.setBackgroundColor(Some(&NSColor::clearColor()));
|
||||
window.setAlphaValue(opacity);
|
||||
window.setMinSize(NSSize::new(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT));
|
||||
|
||||
let _: () = msg_send![&*window, setDelegate: Some(dispatcher)];
|
||||
|
||||
let effect_view = NSVisualEffectView::initWithFrame(mtm.alloc(), frame);
|
||||
effect_view.setMaterial(NSVisualEffectMaterial::Popover);
|
||||
effect_view.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow);
|
||||
effect_view.setState(NSVisualEffectState::Active);
|
||||
|
||||
window.setContentView(Some(&effect_view));
|
||||
(window, effect_view)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_status_bar_item(
|
||||
mtm: MainThreadMarker,
|
||||
dispatcher: &ActionDispatcher,
|
||||
) -> Retained<NSStatusItem> {
|
||||
unsafe {
|
||||
let status_bar = NSStatusBar::systemStatusBar();
|
||||
let item = status_bar.statusItemWithLength(-1.0);
|
||||
if let Some(btn) = item.button(mtm) {
|
||||
btn.setTitle(&NSString::from_str("译"));
|
||||
}
|
||||
|
||||
let menu = NSMenu::new(mtm);
|
||||
let action_sel = sel!(handleControl:);
|
||||
|
||||
let add_item = |title: &str, tag: ActionTag, key: &str| {
|
||||
let mi = NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
mtm.alloc(),
|
||||
&NSString::from_str(title),
|
||||
Some(action_sel),
|
||||
&NSString::from_str(key),
|
||||
);
|
||||
mi.setTag(tag as isize);
|
||||
mi.setTarget(Some(dispatcher));
|
||||
menu.addItem(&mi);
|
||||
};
|
||||
|
||||
add_item("翻译窗口", ActionTag::TabTranslate, "");
|
||||
add_item("截图 OCR 翻译", ActionTag::TriggerOcr, "");
|
||||
add_item("AI 对话", ActionTag::TabChat, "");
|
||||
menu.addItem(&NSMenuItem::separatorItem(mtm));
|
||||
add_item("偏好设置…", ActionTag::TabSettings, ",");
|
||||
menu.addItem(&NSMenuItem::separatorItem(mtm));
|
||||
add_item("退出 TTREE", ActionTag::TrayQuit, "q");
|
||||
|
||||
// 点击菜单项时如果窗口尚未显示则自动唤起(除了截图 OCR 与退出)
|
||||
item.setMenu(Some(&menu));
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
/// 为 Accessory 模式应用安装标准 Edit 菜单,使 NSTextView / NSTextField 原生支持 Cmd+C / V / X / A / Z。
|
||||
fn install_standard_main_menu(mtm: MainThreadMarker, app: &NSApplication) {
|
||||
unsafe {
|
||||
let main_menu = NSMenu::new(mtm);
|
||||
|
||||
// 1. App 子菜单
|
||||
let app_item = NSMenuItem::new(mtm);
|
||||
let app_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("TTREE"));
|
||||
let close_item = NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
mtm.alloc(),
|
||||
&NSString::from_str("隐藏窗口"),
|
||||
Some(sel!(orderOut:)),
|
||||
&NSString::from_str("w"),
|
||||
);
|
||||
let quit_item = NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
mtm.alloc(),
|
||||
&NSString::from_str("退出 TTREE"),
|
||||
Some(sel!(terminate:)),
|
||||
&NSString::from_str("q"),
|
||||
);
|
||||
app_menu.addItem(&close_item);
|
||||
app_menu.addItem(&NSMenuItem::separatorItem(mtm));
|
||||
app_menu.addItem(&quit_item);
|
||||
app_item.setSubmenu(Some(&app_menu));
|
||||
main_menu.addItem(&app_item);
|
||||
|
||||
// 2. Edit 子菜单 (支持 Cmd+Z / Cmd+X / Cmd+C / Cmd+V / Cmd+A)
|
||||
let edit_item = NSMenuItem::new(mtm);
|
||||
let edit_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("编辑"));
|
||||
|
||||
let edit_entries: [(&str, Sel, &str); 5] = [
|
||||
("撤销", sel!(undo:), "z"),
|
||||
("剪切", sel!(cut:), "x"),
|
||||
("复制", sel!(copy:), "c"),
|
||||
("粘贴", sel!(paste:), "v"),
|
||||
("全选", sel!(selectAll:), "a"),
|
||||
];
|
||||
for (title, action, key) in edit_entries {
|
||||
let mi = NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
mtm.alloc(),
|
||||
&NSString::from_str(title),
|
||||
Some(action),
|
||||
&NSString::from_str(key),
|
||||
);
|
||||
edit_menu.addItem(&mi);
|
||||
}
|
||||
edit_item.setSubmenu(Some(&edit_menu));
|
||||
main_menu.addItem(&edit_item);
|
||||
|
||||
app.setMainMenu(Some(&main_menu));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,302 @@
|
||||
//! macOS 原生 AppKit (`objc2-app-kit`) 控件构建与读写辅助函数。
|
||||
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::{AnyObject, Sel};
|
||||
use objc2::{msg_send, MainThreadMarker};
|
||||
use objc2_app_kit::{
|
||||
NSBezelStyle, NSBox, NSBoxType, NSButton, NSButtonType, NSColor, NSControlStateValueOff,
|
||||
NSControlStateValueOn, NSFont, NSPasteboard, NSPasteboardTypeString, NSPopUpButton,
|
||||
NSScrollView, NSSecureTextField, NSSlider, NSTextField, NSTextView, NSView,
|
||||
};
|
||||
use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize};
|
||||
use objc2_foundation::{NSPoint, NSRange, NSRect, NSSize, NSString};
|
||||
|
||||
/// 将以顶部为原点的 `(x, top_y, width, height)` 转换为 AppKit 默认左下角原点的 `NSRect`。
|
||||
pub fn top_rect(parent_height: f64, x: f64, top_y: f64, width: f64, height: f64) -> NSRect {
|
||||
let y = (parent_height - top_y - height).max(0.0);
|
||||
NSRect::new(NSPoint::new(x, y), NSSize::new(width, height))
|
||||
}
|
||||
|
||||
/// 创建静态文本标签 (`NSTextField` 设为只读无边框)。
|
||||
pub fn make_label(
|
||||
mtm: MainThreadMarker,
|
||||
frame: NSRect,
|
||||
text: &str,
|
||||
font_size: f64,
|
||||
bold: bool,
|
||||
secondary: bool,
|
||||
) -> Retained<NSTextField> {
|
||||
let label = NSTextField::initWithFrame(mtm.alloc(), frame);
|
||||
let ns_text = NSString::from_str(text);
|
||||
label.setStringValue(&ns_text);
|
||||
label.setBezeled(false);
|
||||
label.setDrawsBackground(false);
|
||||
label.setEditable(false);
|
||||
label.setSelectable(false);
|
||||
let font = if bold {
|
||||
NSFont::boldSystemFontOfSize(font_size as CGFloat)
|
||||
} else {
|
||||
NSFont::systemFontOfSize(font_size as CGFloat)
|
||||
};
|
||||
label.setFont(Some(&font));
|
||||
let color = if secondary {
|
||||
NSColor::secondaryLabelColor()
|
||||
} else {
|
||||
NSColor::labelColor()
|
||||
};
|
||||
label.setTextColor(Some(&color));
|
||||
label
|
||||
}
|
||||
|
||||
/// 创建圆角操作按钮 (`NSButton`) 并绑定 `target` / `action` / `tag`。
|
||||
pub fn make_button(
|
||||
mtm: MainThreadMarker,
|
||||
frame: NSRect,
|
||||
title: &str,
|
||||
tag: isize,
|
||||
target: &AnyObject,
|
||||
action: Sel,
|
||||
) -> Retained<NSButton> {
|
||||
unsafe {
|
||||
let btn = NSButton::initWithFrame(mtm.alloc(), frame);
|
||||
let ns_title = NSString::from_str(title);
|
||||
btn.setTitle(&ns_title);
|
||||
#[allow(deprecated)]
|
||||
btn.setBezelStyle(NSBezelStyle::Rounded);
|
||||
btn.setFont(Some(&NSFont::systemFontOfSize(12.5)));
|
||||
btn.setTag(tag);
|
||||
btn.setTarget(Some(target));
|
||||
btn.setAction(Some(action));
|
||||
btn
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建复选框按钮 (`NSButtonType::Switch`)。
|
||||
pub fn make_checkbox(
|
||||
mtm: MainThreadMarker,
|
||||
frame: NSRect,
|
||||
title: &str,
|
||||
checked: bool,
|
||||
tag: isize,
|
||||
target: &AnyObject,
|
||||
action: Sel,
|
||||
) -> Retained<NSButton> {
|
||||
unsafe {
|
||||
let btn = NSButton::initWithFrame(mtm.alloc(), frame);
|
||||
btn.setButtonType(NSButtonType::Switch);
|
||||
let ns_title = NSString::from_str(title);
|
||||
btn.setTitle(&ns_title);
|
||||
btn.setFont(Some(&NSFont::systemFontOfSize(12.5)));
|
||||
btn.setState(if checked {
|
||||
NSControlStateValueOn
|
||||
} else {
|
||||
NSControlStateValueOff
|
||||
});
|
||||
btn.setTag(tag);
|
||||
btn.setTarget(Some(target));
|
||||
btn.setAction(Some(action));
|
||||
btn
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建下拉选择框 (`NSPopUpButton`)。
|
||||
pub fn make_popup(
|
||||
mtm: MainThreadMarker,
|
||||
frame: NSRect,
|
||||
items: &[&str],
|
||||
selected_idx: usize,
|
||||
tag: isize,
|
||||
target: &AnyObject,
|
||||
action: Sel,
|
||||
) -> Retained<NSPopUpButton> {
|
||||
unsafe {
|
||||
let popup = NSPopUpButton::initWithFrame_pullsDown(mtm.alloc(), frame, false);
|
||||
popup.setFont(Some(&NSFont::systemFontOfSize(12.5)));
|
||||
for item in items {
|
||||
let s = NSString::from_str(item);
|
||||
popup.addItemWithTitle(&s);
|
||||
}
|
||||
if selected_idx < items.len() {
|
||||
popup.selectItemAtIndex(selected_idx as isize);
|
||||
}
|
||||
popup.setTag(tag);
|
||||
popup.setTarget(Some(target));
|
||||
popup.setAction(Some(action));
|
||||
popup
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建单行可编辑输入框 (`NSTextField`)。
|
||||
pub fn make_text_field(
|
||||
mtm: MainThreadMarker,
|
||||
frame: NSRect,
|
||||
value: &str,
|
||||
placeholder: &str,
|
||||
) -> Retained<NSTextField> {
|
||||
let field = NSTextField::initWithFrame(mtm.alloc(), frame);
|
||||
let ns_val = NSString::from_str(value);
|
||||
field.setStringValue(&ns_val);
|
||||
let ns_ph = NSString::from_str(placeholder);
|
||||
field.setPlaceholderString(Some(&ns_ph));
|
||||
field.setEditable(true);
|
||||
field.setSelectable(true);
|
||||
field.setBezeled(true);
|
||||
field.setDrawsBackground(true);
|
||||
field.setFont(Some(&NSFont::systemFontOfSize(12.5)));
|
||||
field
|
||||
}
|
||||
|
||||
/// 创建单行密码/密钥输入框 (`NSSecureTextField`)。
|
||||
pub fn make_secure_field(
|
||||
mtm: MainThreadMarker,
|
||||
frame: NSRect,
|
||||
placeholder: &str,
|
||||
) -> Retained<NSSecureTextField> {
|
||||
let field = NSSecureTextField::initWithFrame(mtm.alloc(), frame);
|
||||
let ns_ph = NSString::from_str(placeholder);
|
||||
field.setPlaceholderString(Some(&ns_ph));
|
||||
field.setEditable(true);
|
||||
field.setSelectable(true);
|
||||
field.setBezeled(true);
|
||||
field.setDrawsBackground(true);
|
||||
field.setFont(Some(&NSFont::systemFontOfSize(12.5)));
|
||||
field
|
||||
}
|
||||
|
||||
/// 创建滑块控件 (`NSSlider`)。
|
||||
pub fn make_slider(
|
||||
mtm: MainThreadMarker,
|
||||
frame: NSRect,
|
||||
min: f64,
|
||||
max: f64,
|
||||
value: f64,
|
||||
tag: isize,
|
||||
target: &AnyObject,
|
||||
action: Sel,
|
||||
) -> Retained<NSSlider> {
|
||||
unsafe {
|
||||
let slider = NSSlider::initWithFrame(mtm.alloc(), frame);
|
||||
slider.setMinValue(min);
|
||||
slider.setMaxValue(max);
|
||||
slider.setDoubleValue(value);
|
||||
slider.setContinuous(true);
|
||||
slider.setTag(tag);
|
||||
slider.setTarget(Some(target));
|
||||
slider.setAction(Some(action));
|
||||
slider
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建圆角卡片容器 (`NSBox`)。
|
||||
pub fn make_card_box(mtm: MainThreadMarker, frame: NSRect) -> Retained<NSBox> {
|
||||
let card = NSBox::initWithFrame(mtm.alloc(), frame);
|
||||
card.setBoxType(NSBoxType::Custom);
|
||||
card.setCornerRadius(10.0);
|
||||
card.setBorderWidth(1.0);
|
||||
card.setBorderColor(&NSColor::separatorColor());
|
||||
card.setFillColor(&NSColor::controlBackgroundColor().colorWithAlphaComponent(0.55));
|
||||
let zero_margins = CGSize::new(0.0, 0.0);
|
||||
card.setContentViewMargins(zero_margins);
|
||||
card
|
||||
}
|
||||
|
||||
/// 创建带垂直滚动条的多行 `NSTextView`。
|
||||
pub fn make_scrollable_text_view(
|
||||
mtm: MainThreadMarker,
|
||||
frame: NSRect,
|
||||
editable: bool,
|
||||
font_size: f64,
|
||||
delegate: Option<&AnyObject>,
|
||||
) -> (Retained<NSScrollView>, Retained<NSTextView>) {
|
||||
unsafe {
|
||||
let scroll = NSScrollView::initWithFrame(mtm.alloc(), frame);
|
||||
scroll.setHasVerticalScroller(true);
|
||||
scroll.setHasHorizontalScroller(false);
|
||||
scroll.setDrawsBackground(editable);
|
||||
if editable {
|
||||
scroll.setBackgroundColor(&NSColor::textBackgroundColor().colorWithAlphaComponent(0.65));
|
||||
}
|
||||
|
||||
let content_size = scroll.contentSize();
|
||||
let text_frame = CGRect::new(CGPoint::new(0.0, 0.0), content_size);
|
||||
let text_view = NSTextView::initWithFrame(mtm.alloc(), text_frame);
|
||||
text_view.setEditable(editable);
|
||||
text_view.setSelectable(true);
|
||||
text_view.setRichText(false);
|
||||
text_view.setDrawsBackground(false);
|
||||
text_view.setFont(Some(&NSFont::systemFontOfSize(font_size as CGFloat)));
|
||||
text_view.setTextColor(Some(&NSColor::labelColor()));
|
||||
text_view.setVerticallyResizable(true);
|
||||
text_view.setHorizontallyResizable(false);
|
||||
let inset = NSSize::new(8.0, 8.0);
|
||||
text_view.setTextContainerInset(inset);
|
||||
if let Some(container) = text_view.textContainer() {
|
||||
container.setWidthTracksTextView(true);
|
||||
container.setContainerSize(NSSize::new(content_size.width, 10_000_000.0));
|
||||
}
|
||||
if editable {
|
||||
let _: () = msg_send![&*text_view, setAutomaticQuoteSubstitutionEnabled: false];
|
||||
let _: () = msg_send![&*text_view, setAutomaticDashSubstitutionEnabled: false];
|
||||
let _: () = msg_send![&*text_view, setAutomaticSpellingCorrectionEnabled: false];
|
||||
}
|
||||
if let Some(del) = delegate {
|
||||
let _: () = msg_send![&*text_view, setDelegate: Some(del)];
|
||||
}
|
||||
|
||||
scroll.setDocumentView(Some(&text_view));
|
||||
(scroll, text_view)
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 `NSTextView` 内的全部纯文本。
|
||||
pub fn get_text_view_string(tv: &NSTextView) -> String {
|
||||
tv.string().to_string()
|
||||
}
|
||||
|
||||
/// 设置 `NSTextView` 的纯文本内容。
|
||||
pub fn set_text_view_string(tv: &NSTextView, text: &str) {
|
||||
let s = NSString::from_str(text);
|
||||
tv.setString(&s);
|
||||
}
|
||||
|
||||
/// 向 `NSTextView` 末尾追加流式增量文本并自动滚动到底部。
|
||||
pub fn append_text_view_string(tv: &NSTextView, delta: &str, scroll_to_end: bool) {
|
||||
let mut current = tv.string().to_string();
|
||||
current.push_str(delta);
|
||||
let s = NSString::from_str(¤t);
|
||||
tv.setString(&s);
|
||||
if scroll_to_end {
|
||||
let len = s.length();
|
||||
tv.scrollRangeToVisible(NSRange::new(len, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 `NSTextField` 或 `NSSecureTextField` 文本。
|
||||
pub fn get_field_string(field: &NSTextField) -> String {
|
||||
field.stringValue().to_string()
|
||||
}
|
||||
|
||||
/// 设置 `NSTextField` 文本。
|
||||
pub fn set_field_string(field: &NSTextField, value: &str) {
|
||||
let s = NSString::from_str(value);
|
||||
field.setStringValue(&s);
|
||||
}
|
||||
|
||||
/// 将字符串写入 macOS 系统剪贴板。
|
||||
pub fn copy_to_pasteboard(text: &str) -> bool {
|
||||
if text.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let pb = NSPasteboard::generalPasteboard();
|
||||
pb.clearContents();
|
||||
let ns_str = NSString::from_str(text);
|
||||
let pb_type = unsafe { NSPasteboardTypeString };
|
||||
pb.setString_forType(&ns_str, pb_type)
|
||||
}
|
||||
|
||||
/// 将子视图添加到父视图。
|
||||
pub fn add_subview(parent: &NSView, child: &NSView) {
|
||||
parent.addSubview(child);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "ttree-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
url.workspace = true
|
||||
@@ -0,0 +1,119 @@
|
||||
//! 轻量任务取消令牌(`CancelToken`)与单槽抢占器(`CancelSlot`)。
|
||||
//!
|
||||
//! 解决痛点:当用户快速修改输入或连续触发划词/OCR 时,立即取消正在进行的旧请求
|
||||
//! (尤其是耗时较长的 AI 流式生成),绝不让上一条慢请求排队阻塞新翻译。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
cancelled: AtomicBool,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
/// 可克隆的异步取消令牌。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CancelToken {
|
||||
request_id: u64,
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl CancelToken {
|
||||
pub fn new(request_id: u64) -> Self {
|
||||
Self {
|
||||
request_id,
|
||||
inner: Arc::new(Inner::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前令牌对应的单调递增请求 ID。
|
||||
pub fn request_id(&self) -> u64 {
|
||||
self.request_id
|
||||
}
|
||||
|
||||
/// 触发取消并唤醒所有正在等待的异步任务。
|
||||
pub fn cancel(&self) {
|
||||
if !self.inner.cancelled.swap(true, Ordering::SeqCst) {
|
||||
self.inner.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否已被取消。
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.inner.cancelled.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 异步等待直至令牌被取消(配合 `tokio::select!` 中断网络流)。
|
||||
pub async fn cancelled(&self) {
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let notified = self.inner.notify.notified();
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 单槽抢占管理器:每次签发新 `CancelToken` 时自动取消上一个未完成的令牌。
|
||||
#[derive(Default)]
|
||||
pub struct CancelSlot {
|
||||
state: Mutex<(u64, Option<CancelToken>)>,
|
||||
}
|
||||
|
||||
impl CancelSlot {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
state: Mutex::new((0, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消旧令牌并签发新一代 `CancelToken`。
|
||||
pub fn next(&self) -> CancelToken {
|
||||
let mut guard = self.state.lock().expect("CancelSlot lock poisoned");
|
||||
if let Some(prev) = guard.1.take() {
|
||||
prev.cancel();
|
||||
}
|
||||
guard.0 = guard.0.wrapping_add(1);
|
||||
let token = CancelToken::new(guard.0);
|
||||
guard.1 = Some(token.clone());
|
||||
token
|
||||
}
|
||||
|
||||
/// 显式取消当前槽位中的活跃任务。
|
||||
pub fn cancel_current(&self) {
|
||||
let mut guard = self.state.lock().expect("CancelSlot lock poisoned");
|
||||
if let Some(prev) = guard.1.take() {
|
||||
prev.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前最新请求 ID。
|
||||
pub fn current_id(&self) -> u64 {
|
||||
self.state.lock().map(|g| g.0).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn issuing_next_token_cancels_previous_token() {
|
||||
let slot = CancelSlot::new();
|
||||
let first = slot.next();
|
||||
assert_eq!(first.request_id(), 1);
|
||||
assert!(!first.is_cancelled());
|
||||
|
||||
let second = slot.next();
|
||||
assert_eq!(second.request_id(), 2);
|
||||
assert!(first.is_cancelled());
|
||||
assert!(!second.is_cancelled());
|
||||
|
||||
// cancelled() future on first resolves immediately
|
||||
first.cancelled().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 对话消息与会话上下文裁剪。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ALLOWED_ROLES: &[&str] = &["user", "assistant", "system"];
|
||||
pub const MAX_CONTENT_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_MESSAGES: usize = 200;
|
||||
pub const MAX_CONTEXT_MESSAGES: usize = 24;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: "user".to_string(),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: "assistant".to_string(),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: "system".to_string(),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub id: usize,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// 校验消息角色白名单、单条长度与总条数。
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 截取最近 24 条非空消息,并丢弃前导孤立的 `assistant` 消息,确保上下文以 `user` 开头。
|
||||
pub fn recent_chat_context(messages: &[ChatMessage]) -> Vec<ChatMessage> {
|
||||
let filtered: Vec<ChatMessage> = messages
|
||||
.iter()
|
||||
.filter(|m| !m.content.trim().is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
let start = filtered.len().saturating_sub(MAX_CONTEXT_MESSAGES);
|
||||
let mut slice = &filtered[start..];
|
||||
while let Some(first) = slice.first() {
|
||||
if first.role == "assistant" {
|
||||
slice = &slice[1..];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
slice.to_vec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trims_context_and_drops_leading_orphan_assistant() {
|
||||
let mut msgs = Vec::new();
|
||||
for i in 0..30 {
|
||||
let role = if i % 2 == 0 { "user" } else { "assistant" };
|
||||
msgs.push(ChatMessage {
|
||||
role: role.to_string(),
|
||||
content: format!("msg-{i}"),
|
||||
});
|
||||
}
|
||||
let ctx = recent_chat_context(&msgs);
|
||||
assert!(ctx.len() <= MAX_CONTEXT_MESSAGES);
|
||||
assert_eq!(ctx.first().unwrap().role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_roles_and_lengths() {
|
||||
assert!(validate_messages(&[ChatMessage::user("hi")]).is_ok());
|
||||
assert!(validate_messages(&[ChatMessage {
|
||||
role: "tool".to_string(),
|
||||
content: "x".to_string()
|
||||
}])
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! 核心配置数据结构与校验规则。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const MAX_BASE_URL_BYTES: usize = 2 * 1024;
|
||||
pub const MAX_MODEL_BYTES: usize = 256;
|
||||
pub const MAX_PATH_BYTES: usize = 4 * 1024;
|
||||
pub const MAX_PROMPT_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_API_KEY_BYTES: usize = 16 * 1024;
|
||||
|
||||
pub const PANEL_OPACITY_MIN: f64 = 0.35;
|
||||
pub const PANEL_OPACITY_MAX: f64 = 1.0;
|
||||
|
||||
pub const WINDOW_MIN_WIDTH: f64 = 520.0;
|
||||
pub const WINDOW_MIN_HEIGHT: f64 = 340.0;
|
||||
|
||||
/// AI 引擎后端类型:支持云端 OpenAI 兼容、本机 Ollama/LM Studio/MLX 服务、以及直接加载 GGUF。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiBackendKind {
|
||||
/// 云端 OpenAI 兼容接口(OpenAI / DeepSeek / OpenRouter 等,需 Keychain API Key)
|
||||
#[default]
|
||||
OpenAiCompatible,
|
||||
/// 本机 Ollama 服务(默认 `http://127.0.0.1:11434`,免 API Key,支持 `/api/tags` 枚举模型)
|
||||
Ollama,
|
||||
/// 本机 LM Studio / MLX Server / llama.cpp 服务(默认 `http://127.0.0.1:1234/v1`,免或可选 Key)
|
||||
LocalServer,
|
||||
/// 纯 Rust Candle 框架直接加载本地 `.gguf` 量化模型文件(Metal GPU 加速)
|
||||
CandleGguf,
|
||||
}
|
||||
|
||||
impl AiBackendKind {
|
||||
pub const ALL: [AiBackendKind; 4] = [
|
||||
AiBackendKind::OpenAiCompatible,
|
||||
AiBackendKind::Ollama,
|
||||
AiBackendKind::LocalServer,
|
||||
AiBackendKind::CandleGguf,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiCompatible => "云端 API (OpenAI 兼容)",
|
||||
Self::Ollama => "本地服务: Ollama",
|
||||
Self::LocalServer => "本地服务: LM Studio / MLX",
|
||||
Self::CandleGguf => "本地文件: 直接加载 .gguf (Candle Metal)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_base_url(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiCompatible => "https://api.openai.com/v1",
|
||||
Self::Ollama => "http://127.0.0.1:11434",
|
||||
Self::LocalServer => "http://127.0.0.1:1234/v1",
|
||||
Self::CandleGguf => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_api_key(self) -> bool {
|
||||
matches!(self.into(), Self::OpenAiCompatible)
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_empty_api_key<S>(_value: &String, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str("")
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// AI 翻译独立配置(支持开关、切换云端/Ollama/LM Studio/MLX/GGUF)。
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AiTranslationConfig {
|
||||
/// 是否在翻译面板右侧启用 AI 翻译对照列。
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub backend: AiBackendKind,
|
||||
pub base_url: String,
|
||||
#[serde(default, serialize_with = "serialize_empty_api_key")]
|
||||
pub api_key: String,
|
||||
#[serde(default)]
|
||||
pub has_api_key: bool,
|
||||
#[serde(default)]
|
||||
pub clear_api_key: bool,
|
||||
pub model: String,
|
||||
/// 当 `backend == CandleGguf` 时使用的 `.gguf` 权重文件路径。
|
||||
#[serde(default)]
|
||||
pub gguf_model_path: String,
|
||||
/// 当 `backend == CandleGguf` 时使用的 `tokenizer.json` 路径(留空则自动在同目录查找)。
|
||||
#[serde(default)]
|
||||
pub gguf_tokenizer_path: String,
|
||||
pub translate_prompt: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AiTranslationConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AiTranslationConfig")
|
||||
.field("enabled", &self.enabled)
|
||||
.field("backend", &self.backend)
|
||||
.field("base_url", &self.base_url)
|
||||
.field("api_key", &"<redacted>")
|
||||
.field("model", &self.model)
|
||||
.field("gguf_model_path", &self.gguf_model_path)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AiTranslationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
backend: AiBackendKind::OpenAiCompatible,
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
api_key: String::new(),
|
||||
has_api_key: false,
|
||||
clear_api_key: false,
|
||||
model: "gpt-4o-mini".to_string(),
|
||||
gguf_model_path: String::new(),
|
||||
gguf_tokenizer_path: String::new(),
|
||||
translate_prompt: "你是一个专业、地道的翻译引擎。将用户输入从 {source} 翻译成 {target}。只输出译文,不要解释、不要引号、不要附加任何说明。".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 对话配置(与翻译独立)。
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ChatAiConfig {
|
||||
#[serde(default)]
|
||||
pub backend: AiBackendKind,
|
||||
pub base_url: String,
|
||||
#[serde(default, serialize_with = "serialize_empty_api_key")]
|
||||
pub api_key: String,
|
||||
#[serde(default)]
|
||||
pub has_api_key: bool,
|
||||
#[serde(default)]
|
||||
pub clear_api_key: bool,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub gguf_model_path: String,
|
||||
#[serde(default)]
|
||||
pub gguf_tokenizer_path: String,
|
||||
#[serde(default)]
|
||||
pub chat_prompt: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChatAiConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChatAiConfig")
|
||||
.field("backend", &self.backend)
|
||||
.field("base_url", &self.base_url)
|
||||
.field("api_key", &"<redacted>")
|
||||
.field("model", &self.model)
|
||||
.field("gguf_model_path", &self.gguf_model_path)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatAiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: AiBackendKind::OpenAiCompatible,
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
api_key: String::new(),
|
||||
has_api_key: false,
|
||||
clear_api_key: false,
|
||||
model: "gpt-4o-mini".to_string(),
|
||||
gguf_model_path: String::new(),
|
||||
gguf_tokenizer_path: String::new(),
|
||||
chat_prompt: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局快捷键配置。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShortcutConfig {
|
||||
pub toggle: String,
|
||||
pub ocr: String,
|
||||
#[serde(default)]
|
||||
pub ai_dialog: String,
|
||||
#[serde(default)]
|
||||
pub selection_translate: String,
|
||||
#[serde(default)]
|
||||
pub selection_ai_dialog: String,
|
||||
}
|
||||
|
||||
impl Default for ShortcutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: String::new(),
|
||||
selection_translate: String::new(),
|
||||
selection_ai_dialog: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 外观配置。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AppearanceConfig {
|
||||
#[serde(default = "default_panel_opacity")]
|
||||
pub panel_opacity: f64,
|
||||
}
|
||||
|
||||
fn default_panel_opacity() -> f64 {
|
||||
0.96
|
||||
}
|
||||
|
||||
impl Default for AppearanceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
panel_opacity: default_panel_opacity(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppearanceConfig {
|
||||
pub fn clamp_opacity(value: f64) -> f64 {
|
||||
if value.is_nan() {
|
||||
return default_panel_opacity();
|
||||
}
|
||||
value.clamp(PANEL_OPACITY_MIN, PANEL_OPACITY_MAX)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_field_len(label: &str, value: &str, max_bytes: usize) -> Result<(), String> {
|
||||
if value.len() > max_bytes {
|
||||
return Err(format!("{label}超出 {max_bytes} 字节上限"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验并规范化接口地址:
|
||||
/// - 远程地址要求 HTTPS
|
||||
/// - 本机回环地址(`localhost` / `127.0.0.1` / `::1`)或局域网本地模型服务允许 HTTP
|
||||
/// - 拒绝包含用户名/密码(userinfo)或非 HTTP(S) 协议
|
||||
pub fn normalize_base_url(raw: &str) -> Result<String, String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("base_url 不能为空".to_string());
|
||||
}
|
||||
validate_field_len("base_url", trimmed, MAX_BASE_URL_BYTES)?;
|
||||
let url = url::Url::parse(trimmed).map_err(|e| format!("base_url 非法: {e}"))?;
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err("base_url 不允许包含内嵌用户名或密码".to_string());
|
||||
}
|
||||
match url.scheme() {
|
||||
"https" => {}
|
||||
"http" if is_loopback_url(&url) => {}
|
||||
"http" => return Err("远程 base_url 必须使用 HTTPS;HTTP 仅允许本机回环地址".to_string()),
|
||||
other => return Err(format!("base_url 必须为 HTTP(S),不支持 {other}")),
|
||||
}
|
||||
if url.host().is_none() {
|
||||
return Err("base_url 必须包含主机名或 IP 地址".to_string());
|
||||
}
|
||||
let out = trimmed.trim_end_matches('/');
|
||||
Ok(if out.is_empty() {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
out.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn is_loopback_url(url: &url::Url) -> bool {
|
||||
match url.host() {
|
||||
Some(url::Host::Domain(host)) => {
|
||||
host.trim_end_matches('.').eq_ignore_ascii_case("localhost")
|
||||
}
|
||||
Some(url::Host::Ipv4(host)) => host.is_loopback(),
|
||||
Some(url::Host::Ipv6(host)) => host.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allows_https_and_loopback_http_urls() {
|
||||
assert_eq!(
|
||||
normalize_base_url("https://api.openai.com/v1/").unwrap(),
|
||||
"https://api.openai.com/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_base_url("http://127.0.0.1:11434/").unwrap(),
|
||||
"http://127.0.0.1:11434"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_base_url("http://localhost:1234/v1").unwrap(),
|
||||
"http://localhost:1234/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_remote_http_and_file_urls() {
|
||||
assert!(normalize_base_url("http://example.com/v1").is_err());
|
||||
assert!(normalize_base_url("file:///etc/passwd").is_err());
|
||||
assert!(normalize_base_url("https://user:pass@example.com/v1").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! 语言列表与自适应目标方向判定(中 ⇄ 英)。
|
||||
|
||||
pub const ADAPTIVE_TARGET: &str = "adaptive";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LanguageOption {
|
||||
pub code: &'static str,
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
pub const SOURCE_LANGUAGES: &[LanguageOption] = &[
|
||||
LanguageOption {
|
||||
code: "auto",
|
||||
label: "自动检测",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "zh-CN",
|
||||
label: "简体中文",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "zh-TW",
|
||||
label: "繁体中文",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "en",
|
||||
label: "英语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "ja",
|
||||
label: "日语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "ko",
|
||||
label: "韩语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "fr",
|
||||
label: "法语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "de",
|
||||
label: "德语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "es",
|
||||
label: "西班牙语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "ru",
|
||||
label: "俄语",
|
||||
},
|
||||
];
|
||||
|
||||
pub const TARGET_LANGUAGES: &[LanguageOption] = &[
|
||||
LanguageOption {
|
||||
code: ADAPTIVE_TARGET,
|
||||
label: "中 ⇄ 英(自适应)",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "zh-CN",
|
||||
label: "简体中文",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "zh-TW",
|
||||
label: "繁体中文",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "en",
|
||||
label: "英语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "ja",
|
||||
label: "日语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "ko",
|
||||
label: "韩语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "fr",
|
||||
label: "法语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "de",
|
||||
label: "德语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "es",
|
||||
label: "西班牙语",
|
||||
},
|
||||
LanguageOption {
|
||||
code: "ru",
|
||||
label: "俄语",
|
||||
},
|
||||
];
|
||||
|
||||
const SUPPORTED_SOURCE_CODES: &[&str] = &[
|
||||
"auto", "zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru",
|
||||
];
|
||||
const SUPPORTED_TARGET_CODES: &[&str] =
|
||||
&["zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru"];
|
||||
|
||||
/// 判断字符是否属于 CJK 统一表意文字(含扩展区)。
|
||||
fn is_han_char(ch: char) -> bool {
|
||||
matches!(
|
||||
ch as u32,
|
||||
0x3400..=0x4DBF
|
||||
| 0x4E00..=0x9FFF
|
||||
| 0xF900..=0xFAFF
|
||||
| 0x20000..=0x2A6DF
|
||||
| 0x2A700..=0x2B73F
|
||||
| 0x2B740..=0x2B81F
|
||||
| 0x2B820..=0x2CEAF
|
||||
| 0x2CEB0..=0x2EBEF
|
||||
| 0x30000..=0x3134F
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据输入文本自动选择目标语言:包含汉字则译为英语 (`en`),否则译为简体中文 (`zh-CN`)。
|
||||
pub fn resolve_adaptive_target(text: &str) -> &'static str {
|
||||
if text.chars().any(is_han_char) {
|
||||
"en"
|
||||
} else {
|
||||
"zh-CN"
|
||||
}
|
||||
}
|
||||
|
||||
/// 将用户选择的 `(source, target)`(可能含 `adaptive`)解析为实际请求的语言对。
|
||||
pub fn resolve_language_pair(source: &str, target: &str, text: &str) -> (String, String) {
|
||||
if target == ADAPTIVE_TARGET {
|
||||
("auto".to_string(), resolve_adaptive_target(text).to_string())
|
||||
} else {
|
||||
(source.to_string(), target.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验实际发送给引擎的语言对是否合法。
|
||||
pub fn validate_language_pair(source: &str, target: &str) -> Result<(), String> {
|
||||
if !SUPPORTED_SOURCE_CODES.contains(&source) {
|
||||
return Err(format!("不支持的源语言代码: {source}"));
|
||||
}
|
||||
if !SUPPORTED_TARGET_CODES.contains(&target) {
|
||||
return Err(format!("不支持的目标语言代码: {target}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据目标语言代码返回中文可读名称(供 AI 提示词渲染使用)。
|
||||
pub fn language_display_name(code: &str) -> &str {
|
||||
match code {
|
||||
"auto" => "自动检测的源语言",
|
||||
"zh-CN" => "简体中文",
|
||||
"zh-TW" => "繁体中文",
|
||||
"en" => "英语 (English)",
|
||||
"ja" => "日语 (Japanese)",
|
||||
"ko" => "韩语 (Korean)",
|
||||
"fr" => "法语 (French)",
|
||||
"de" => "德语 (German)",
|
||||
"es" => "西班牙语 (Spanish)",
|
||||
"ru" => "俄语 (Russian)",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adaptive_target_detects_chinese_and_non_chinese() {
|
||||
assert_eq!(resolve_adaptive_target("你好,世界"), "en");
|
||||
assert_eq!(resolve_adaptive_target("Hello, world!"), "zh-CN");
|
||||
assert_eq!(resolve_adaptive_target("Rust 性能优化"), "en");
|
||||
assert_eq!(resolve_adaptive_target("12345 !@#"), "zh-CN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_adaptive_pair_to_concrete_codes() {
|
||||
let (src, tgt) = resolve_language_pair("en", ADAPTIVE_TARGET, "今天天气不错");
|
||||
assert_eq!(src, "auto");
|
||||
assert_eq!(tgt, "en");
|
||||
|
||||
let (src, tgt) = resolve_language_pair("zh-CN", "ja", "今天天气不错");
|
||||
assert_eq!(src, "zh-CN");
|
||||
assert_eq!(tgt, "ja");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_known_language_codes() {
|
||||
assert!(validate_language_pair("auto", "zh-CN").is_ok());
|
||||
assert!(validate_language_pair("en", "zh-TW").is_ok());
|
||||
assert!(validate_language_pair("en", "auto").is_err());
|
||||
assert!(validate_language_pair("unknown", "en").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! `ttree-core` —— 核心领域模型、语言自适应解析、取消令牌、配置模型与对话上下文处理。
|
||||
|
||||
pub mod cancel;
|
||||
pub mod chat;
|
||||
pub mod config;
|
||||
pub mod language;
|
||||
|
||||
pub use cancel::{CancelSlot, CancelToken};
|
||||
pub use chat::{recent_chat_context, validate_messages, ChatMessage, Conversation};
|
||||
pub use config::{
|
||||
normalize_base_url, AiBackendKind, AiTranslationConfig, AppearanceConfig, ChatAiConfig,
|
||||
ShortcutConfig, PANEL_OPACITY_MAX, PANEL_OPACITY_MIN, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH,
|
||||
};
|
||||
pub use language::{
|
||||
resolve_adaptive_target, resolve_language_pair, validate_language_pair, LanguageOption,
|
||||
ADAPTIVE_TARGET, SOURCE_LANGUAGES, TARGET_LANGUAGES,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "ttree-ocr"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2.workspace = true
|
||||
objc2-core-foundation.workspace = true
|
||||
objc2-core-graphics.workspace = true
|
||||
objc2-core-image.workspace = true
|
||||
objc2-core-ml.workspace = true
|
||||
objc2-foundation.workspace = true
|
||||
objc2-image-io.workspace = true
|
||||
objc2-vision.workspace = true
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 图像预处理:解码 PNG 并以截图边缘自适应背景色填充边距,生成 8-bit sRGB `CGImage`。
|
||||
//!
|
||||
//! 关键改进:
|
||||
//! 旧版在 <128px 的小截图四周填充纯白 `(1.0, 1.0, 1.0)`,导致深色模式(白字黑底)单行截图
|
||||
//! 产生刺眼的白框伪影,干扰 Vision 行检测。新版自动采样原图四周边缘像素的平均色进行无缝填充。
|
||||
|
||||
#![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 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());
|
||||
}
|
||||
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);
|
||||
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)?;
|
||||
|
||||
let color_space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB }))
|
||||
.ok_or_else(|| "创建 OCR 色彩空间失败".to_string())?;
|
||||
let bitmap_info = CGImageAlphaInfo::NoneSkipLast.0 | CGImageByteOrderInfo::Order32Big.0;
|
||||
|
||||
// 先将原图解码到一块小缓冲区以采样四周边缘背景色,确保深色/浅色截图都能平滑延展背景
|
||||
let (bg_r, bg_g, bg_b) = sample_border_rgb(&image, width, height, &color_space, bitmap_info);
|
||||
|
||||
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), bg_r, bg_g, bg_b, 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())
|
||||
}
|
||||
|
||||
/// 采样原图外圈像素的平均 RGB 值(0.0..=1.0),失败时回退白色。
|
||||
fn sample_border_rgb(
|
||||
image: &CGImage,
|
||||
width: usize,
|
||||
height: usize,
|
||||
color_space: &CGColorSpace,
|
||||
bitmap_info: u32,
|
||||
) -> (f64, f64, f64) {
|
||||
let mut buf = vec![255u8; width * height * 4];
|
||||
let Some(ctx) = (unsafe {
|
||||
CGBitmapContextCreate(
|
||||
buf.as_mut_ptr().cast(),
|
||||
width,
|
||||
height,
|
||||
8,
|
||||
width * 4,
|
||||
Some(color_space),
|
||||
bitmap_info,
|
||||
)
|
||||
}) else {
|
||||
return (1.0, 1.0, 1.0);
|
||||
};
|
||||
CGContext::set_rgb_fill_color(Some(&ctx), 1.0, 1.0, 1.0, 1.0);
|
||||
CGContext::fill_rect(Some(&ctx), rect(0, 0, width, height));
|
||||
CGContext::draw_image(Some(&ctx), rect(0, 0, width, height), Some(image));
|
||||
|
||||
let mut r_sum: u64 = 0;
|
||||
let mut g_sum: u64 = 0;
|
||||
let mut b_sum: u64 = 0;
|
||||
let mut count: u64 = 0;
|
||||
|
||||
let mut add_pixel = |x: usize, y: usize| {
|
||||
let idx = (y * width + x) * 4;
|
||||
r_sum += buf[idx] as u64;
|
||||
g_sum += buf[idx + 1] as u64;
|
||||
b_sum += buf[idx + 2] as u64;
|
||||
count += 1;
|
||||
};
|
||||
|
||||
for x in 0..width {
|
||||
add_pixel(x, 0);
|
||||
if height > 1 {
|
||||
add_pixel(x, height - 1);
|
||||
}
|
||||
}
|
||||
if height > 2 {
|
||||
for y in 1..(height - 1) {
|
||||
add_pixel(0, y);
|
||||
if width > 1 {
|
||||
add_pixel(width - 1, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return (1.0, 1.0, 1.0);
|
||||
}
|
||||
(
|
||||
(r_sum as f64 / count as f64) / 255.0,
|
||||
(g_sum as f64 / count as f64) / 255.0,
|
||||
(b_sum as f64 / count as f64) / 255.0,
|
||||
)
|
||||
}
|
||||
|
||||
pub 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(|(w, h)| (w.max(MIN_DIMENSION), h.max(MIN_DIMENSION)))
|
||||
.filter(|(w, h)| w.checked_mul(*h).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_small_captures_to_minimum_dimension() {
|
||||
assert_eq!(canvas_size(320, 24).unwrap(), (352, 128));
|
||||
assert_eq!(canvas_size(8, 8).unwrap(), (128, 128));
|
||||
assert!(canvas_size(0, 10).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! `ttree-ocr` —— macOS 原生截图与 Apple Vision OCR 引擎。
|
||||
//!
|
||||
//! 针对旧版“莫名其妙无法使用”的 4 项核心根治:
|
||||
//! 1. 强类型 `OcrOutcome` 区分「成功」「用户取消」「未识别出文字」「录屏权限未开启」「正在截图中」,杜绝静默吞掉错误;
|
||||
//! 2. 调用 `CGPreflightScreenCaptureAccess` 预检 macOS 录屏权限,区分无权限导致的空截图与按 Esc 取消;
|
||||
//! 3. `image.rs` 自动采样截图边缘背景色填充 Padding(替代旧版硬编码纯白底,彻底修复深色模式单行小图识别失败);
|
||||
//! 4. `vision.rs` 移除主路径上的 `PreferBackgroundProcessing(true)` 后台降级标志,确保菜单栏配件应用极速响应。
|
||||
|
||||
pub mod screenshot;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod image;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod vision;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
static OCR_BUSY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
struct BusyGuard;
|
||||
|
||||
impl BusyGuard {
|
||||
fn try_acquire() -> Option<Self> {
|
||||
OCR_BUSY
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.ok()
|
||||
.map(|_| Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BusyGuard {
|
||||
fn drop(&mut self) {
|
||||
OCR_BUSY.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图 OCR 强类型结果,彻底避免把权限失败或未识别出文字误判为用户取消。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum OcrOutcome {
|
||||
/// 成功识别出文字。
|
||||
Text(String),
|
||||
/// 用户按 Esc 主动取消了区域框选。
|
||||
Cancelled,
|
||||
/// 截图成功但所选图片区域内未检测到任何文字。
|
||||
EmptyText,
|
||||
/// 上一次截图框选仍在进行中。
|
||||
Busy,
|
||||
/// macOS 屏幕录制权限未授予或已失效。
|
||||
PermissionDenied(String),
|
||||
}
|
||||
|
||||
/// 完整执行一次「macOS 原生交互式框选 (`screencapture -i -x -r`) + Apple Vision OCR」。
|
||||
pub fn capture_and_recognize() -> Result<OcrOutcome, String> {
|
||||
let Some(_guard) = BusyGuard::try_acquire() else {
|
||||
return Ok(OcrOutcome::Busy);
|
||||
};
|
||||
|
||||
match screenshot::capture_interactive()? {
|
||||
screenshot::CaptureResult::Captured(temp_path) => {
|
||||
recognize_file(&temp_path.to_string_lossy())
|
||||
}
|
||||
screenshot::CaptureResult::Cancelled => Ok(OcrOutcome::Cancelled),
|
||||
screenshot::CaptureResult::PermissionDenied(msg) => Ok(OcrOutcome::PermissionDenied(msg)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn recognize_file(path: &str) -> Result<OcrOutcome, String> {
|
||||
vision::recognize_file(path)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn recognize_file(_path: &str) -> Result<OcrOutcome, String> {
|
||||
Err("当前平台暂不支持 OCR".to_string())
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! macOS 原生屏幕区域框选(`/usr/sbin/screencapture -i -x -r`)与 TCC 录屏权限预检。
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tempfile::{NamedTempFile, TempPath};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[link(name = "CoreGraphics", kind = "framework")]
|
||||
extern "C" {
|
||||
fn CGPreflightScreenCaptureAccess() -> bool;
|
||||
fn CGRequestScreenCaptureAccess() -> bool;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn has_screen_capture_permission() -> bool {
|
||||
unsafe { CGPreflightScreenCaptureAccess() }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn request_screen_capture_permission() -> bool {
|
||||
unsafe { CGRequestScreenCaptureAccess() }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn has_screen_capture_permission() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn request_screen_capture_permission() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn screencapture_args() -> [&'static str; 3] {
|
||||
["-i", "-x", "-r"]
|
||||
}
|
||||
|
||||
pub enum CaptureResult {
|
||||
Captured(TempPath),
|
||||
Cancelled,
|
||||
PermissionDenied(String),
|
||||
}
|
||||
|
||||
const PERMISSION_HINT: &str = "未获得 macOS 屏幕录制权限。请打开「系统设置 → 隐私与安全性 → 屏幕与系统音频录制」,开启 TTREE 权限后重试。";
|
||||
|
||||
/// 调用 Apple 原生 `/usr/sbin/screencapture -i -x -r` 进行自由框选截图。
|
||||
pub fn capture_interactive() -> Result<CaptureResult, String> {
|
||||
let had_permission = has_screen_capture_permission();
|
||||
if !had_permission {
|
||||
// 主动请求一次系统权限弹窗,若仍未授权则稍后给出明确提示而非静默返回空
|
||||
let _ = request_screen_capture_permission();
|
||||
}
|
||||
|
||||
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);
|
||||
if !has_capture
|
||||
&& (detail.trim().is_empty() || detail.to_ascii_lowercase().contains("cancel"))
|
||||
{
|
||||
if !had_permission && !has_screen_capture_permission() {
|
||||
return Ok(CaptureResult::PermissionDenied(PERMISSION_HINT.to_string()));
|
||||
}
|
||||
return Ok(CaptureResult::Cancelled);
|
||||
}
|
||||
let detail: String = detail.trim().chars().take(300).collect();
|
||||
return Err(format!(
|
||||
"系统截图失败({}): {detail}。{PERMISSION_HINT}",
|
||||
output.status
|
||||
));
|
||||
}
|
||||
|
||||
if !has_capture {
|
||||
if !had_permission && !has_screen_capture_permission() {
|
||||
return Ok(CaptureResult::PermissionDenied(PERMISSION_HINT.to_string()));
|
||||
}
|
||||
return Ok(CaptureResult::Cancelled);
|
||||
}
|
||||
|
||||
Ok(CaptureResult::Captured(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}"))
|
||||
}
|
||||
|
||||
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 super::*;
|
||||
|
||||
#[test]
|
||||
fn preserves_native_screencapture_flags() {
|
||||
assert_eq!(screencapture_args(), ["-i", "-x", "-r"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_deleted_temp_file_is_not_captured() {
|
||||
let target = new_capture_target().unwrap();
|
||||
assert!(!validate_capture_output(target.path()).unwrap());
|
||||
let path = target.path().to_path_buf();
|
||||
drop(target);
|
||||
assert!(!validate_capture_output(&path).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! macOS Apple Vision (`VNRecognizeTextRequest`) 原生文字识别。
|
||||
|
||||
#![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,
|
||||
};
|
||||
|
||||
use crate::OcrOutcome;
|
||||
|
||||
pub fn recognize_file(path: &str) -> Result<OcrOutcome, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("读取截图失败: {e}"))?;
|
||||
recognize_bytes(&bytes)
|
||||
}
|
||||
|
||||
pub fn recognize_bytes(bytes: &[u8]) -> Result<OcrOutcome, String> {
|
||||
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(OcrOutcome::Text(text)),
|
||||
Ok(_) => None,
|
||||
Err(error) => Some(error),
|
||||
};
|
||||
|
||||
// 兼容回退:切换软件 CIContext + CPU 计算设备 + revision 2 再试一次
|
||||
match recognize_once(&image, true) {
|
||||
Ok(text) if !text.trim().is_empty() => Ok(OcrOutcome::Text(text)),
|
||||
Ok(_) => Ok(OcrOutcome::EmptyText),
|
||||
Err(second_error) => match first_error {
|
||||
Some(first) => Err(format!("OCR 识别及兼容重试失败: {first}; {second_error}")),
|
||||
None => Ok(OcrOutcome::EmptyText),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn recognize_once(image: &CGImage, compatibility: bool) -> Result<String, String> {
|
||||
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];
|
||||
NSDictionary::from_slices(&[VNImageOptionCIContext], &values)
|
||||
} else {
|
||||
NSDictionary::new()
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
let revision = if !compatibility && objc2::available!(macos = 13.0) {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
};
|
||||
unsafe {
|
||||
request.setRevision(revision);
|
||||
// 关键修复:不再将 preferBackgroundProcessing 设为 true,保证菜单栏隐藏窗口状态下拥有前台级识别优先级
|
||||
request.setPreferBackgroundProcessing(false);
|
||||
}
|
||||
if objc2::available!(macos = 13.0) {
|
||||
request.setAutomaticallyDetectsLanguage(!compatibility);
|
||||
}
|
||||
|
||||
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, revision)
|
||||
.into_iter()
|
||||
.map(NSString::from_str)
|
||||
.collect();
|
||||
if !languages.is_empty() {
|
||||
request.setRecognitionLanguages(&NSArray::from_retained_slice(&languages));
|
||||
}
|
||||
|
||||
if compatibility || objc2::available!(macos = 27.0) {
|
||||
configure_cpu(request)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_cpu(request: &VNRecognizeTextRequest) -> Result<(), String> {
|
||||
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], revision: usize) -> Vec<&str> {
|
||||
let candidates: &[&str] = if revision >= 3 {
|
||||
&[
|
||||
"zh-Hans", "zh-Hant", "en-US", "ja-JP", "ko-KR", "fr-FR", "de-DE", "es-ES", "ru-RU",
|
||||
]
|
||||
} else {
|
||||
// revision 2 中文模型仅允许与 en-US 组合
|
||||
&["zh-Hans", "zh-Hant", "en-US"]
|
||||
};
|
||||
candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|code| supported.iter().any(|s| s.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 configures_foreground_priority_and_auto_language_detection() {
|
||||
let request = VNRecognizeTextRequest::new();
|
||||
configure_request(&request, false).unwrap();
|
||||
assert!(unsafe { !request.preferBackgroundProcessing() });
|
||||
if objc2::available!(macos = 13.0) {
|
||||
assert!(request.automaticallyDetectsLanguage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "ttree-platform-macos"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ttree-core = { path = "../ttree-core" }
|
||||
anyhow.workspace = true
|
||||
auto-launch.workspace = true
|
||||
global-hotkey.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation.workspace = true
|
||||
security-framework.workspace = true
|
||||
objc2.workspace = true
|
||||
objc2-app-kit.workspace = true
|
||||
objc2-core-graphics.workspace = true
|
||||
objc2-foundation.workspace = true
|
||||
@@ -0,0 +1,40 @@
|
||||
//! macOS LaunchAgent 开机自启管理。
|
||||
|
||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
||||
|
||||
const APP_NAME: &str = "TTREE";
|
||||
|
||||
fn build_auto_launch() -> Result<AutoLaunch, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| format!("获取当前可执行文件路径失败: {e}"))?;
|
||||
// 若运行在 TTREE.app/Contents/MacOS/ttree 内,优先传入 .app bundle 路径
|
||||
let app_path = exe
|
||||
.parent()
|
||||
.and_then(|macos| macos.parent())
|
||||
.and_then(|contents| contents.parent())
|
||||
.filter(|bundle| bundle.extension().is_some_and(|ext| ext == "app"))
|
||||
.unwrap_or(exe.as_path());
|
||||
|
||||
AutoLaunchBuilder::new()
|
||||
.set_app_name(APP_NAME)
|
||||
.set_app_path(&app_path.to_string_lossy())
|
||||
.set_use_launch_agent(true)
|
||||
.build()
|
||||
.map_err(|e| format!("初始化开机自启失败: {e}"))
|
||||
}
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
build_auto_launch()
|
||||
.and_then(|al| al.is_enabled().map_err(|e| e.to_string()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn set_enabled(enable: bool) -> Result<bool, String> {
|
||||
let al = build_auto_launch()?;
|
||||
if enable {
|
||||
al.enable().map_err(|e| format!("启用开机自启失败: {e}"))?;
|
||||
} else if al.is_enabled().unwrap_or(false) {
|
||||
al.disable().map_err(|e| format!("关闭开机自启失败: {e}"))?;
|
||||
}
|
||||
al.is_enabled()
|
||||
.map_err(|e| format!("读取开机自启状态失败: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! 全局快捷键解析、冲突校验与内存级 O(1) 分发(不读盘)。
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use global_hotkey::hotkey::{HotKey, Modifiers};
|
||||
use ttree_core::ShortcutConfig;
|
||||
|
||||
const MAX_SHORTCUT_SPEC_BYTES: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShortcutAction {
|
||||
Toggle,
|
||||
Ocr,
|
||||
AiDialog,
|
||||
SelectionTranslate,
|
||||
SelectionAiDialog,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ParsedShortcuts {
|
||||
pub toggle: Option<HotKey>,
|
||||
pub ocr: Option<HotKey>,
|
||||
pub ai_dialog: Option<HotKey>,
|
||||
pub selection_translate: Option<HotKey>,
|
||||
pub selection_ai_dialog: Option<HotKey>,
|
||||
}
|
||||
|
||||
impl ParsedShortcuts {
|
||||
/// 划词动作排在对应的纯呼出动作之前,共用同一快捷键时优先走划词逻辑。
|
||||
pub fn entries(&self) -> [(ShortcutAction, &Option<HotKey>); 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),
|
||||
]
|
||||
}
|
||||
|
||||
/// 去重后的待注册热键列表。
|
||||
pub fn unique_hotkeys(&self) -> Vec<HotKey> {
|
||||
let mut out: Vec<HotKey> = Vec::new();
|
||||
for (_, hk) in self.entries() {
|
||||
if let Some(hk) = hk {
|
||||
if !out.iter().any(|existing| existing.id() == hk.id()) {
|
||||
out.push(*hk);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 根据 `global-hotkey` 的 `id` 在内存中 O(1) 匹配动作。
|
||||
pub fn action_for_id(&self, hotkey_id: u32) -> Option<ShortcutAction> {
|
||||
self.entries()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| candidate.as_ref().is_some_and(|hk| hk.id() == hotkey_id))
|
||||
.map(|(action, _)| action)
|
||||
}
|
||||
|
||||
fn reject_disallowed_duplicates(&self) -> Result<(), anyhow::Error> {
|
||||
let fields: [(&str, &Option<HotKey>); 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.id() == right.id() && !is_allowed_shared_pair(left_label, right_label) {
|
||||
return Err(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))
|
||||
}
|
||||
|
||||
/// 将 Tauri 风格的 `CmdOrCtrl+Shift+Space` / `Option+Space` 规范化为 `global-hotkey` 支持的格式。
|
||||
fn normalize_spec(spec: &str) -> String {
|
||||
spec.trim()
|
||||
.split('+')
|
||||
.map(|part| match part.trim() {
|
||||
"CmdOrCtrl" | "CommandOrControl" | "Cmd" | "Command" => "Super",
|
||||
"Option" => "Alt",
|
||||
other => other,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("+")
|
||||
}
|
||||
|
||||
pub fn parse_shortcut_config(cfg: &ShortcutConfig) -> Result<ParsedShortcuts, anyhow::Error> {
|
||||
let parsed = ParsedShortcuts {
|
||||
toggle: parse_optional_shortcut("呼出翻译快捷键", &cfg.toggle)?,
|
||||
ocr: parse_optional_shortcut("截图 OCR 快捷键", &cfg.ocr)?,
|
||||
ai_dialog: parse_optional_shortcut("呼出 AI 对话快捷键", &cfg.ai_dialog)?,
|
||||
selection_translate: parse_optional_shortcut("划词翻译快捷键", &cfg.selection_translate)?,
|
||||
selection_ai_dialog: parse_optional_shortcut(
|
||||
"划词 AI 对话快捷键",
|
||||
&cfg.selection_ai_dialog,
|
||||
)?,
|
||||
};
|
||||
parsed.reject_disallowed_duplicates()?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn parse_optional_shortcut(label: &str, spec: &str) -> Result<Option<HotKey>, anyhow::Error> {
|
||||
if spec.len() > MAX_SHORTCUT_SPEC_BYTES {
|
||||
return Err(anyhow!("{label}超出 {MAX_SHORTCUT_SPEC_BYTES} 字节上限"));
|
||||
}
|
||||
if spec.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let normalized = normalize_spec(spec);
|
||||
let hotkey = HotKey::from_str(&normalized).map_err(|e| anyhow!("{label}无效: {e}"))?;
|
||||
if !hotkey
|
||||
.mods
|
||||
.intersects(Modifiers::SUPER | Modifiers::CONTROL | Modifiers::ALT)
|
||||
{
|
||||
return Err(anyhow!("{label}必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
|
||||
}
|
||||
Ok(Some(hotkey))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_default_and_shared_shortcuts() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: "Option+Space".to_string(),
|
||||
selection_translate: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
selection_ai_dialog: String::new(),
|
||||
};
|
||||
let parsed = parse_shortcut_config(&cfg).unwrap();
|
||||
assert_eq!(parsed.unique_hotkeys().len(), 3);
|
||||
let toggle_id = parsed.toggle.unwrap().id();
|
||||
assert_eq!(
|
||||
parsed.action_for_id(toggle_id),
|
||||
Some(ShortcutAction::SelectionTranslate)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_or_conflicting_shortcuts() {
|
||||
let no_mod = ShortcutConfig {
|
||||
toggle: "Space".to_string(),
|
||||
..ShortcutConfig::default()
|
||||
};
|
||||
assert!(parse_shortcut_config(&no_mod).is_err());
|
||||
|
||||
let conflict = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
..ShortcutConfig::default()
|
||||
};
|
||||
assert!(parse_shortcut_config(&conflict).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! macOS Keychain 封装 —— 兼容 `com.samwstu.ttree` 既有凭据格式。
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use security_framework::passwords;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SERVICE: &str = "com.samwstu.ttree";
|
||||
pub const ACCOUNT_OPENAI_KEY: &str = "openai.api_key";
|
||||
pub const ACCOUNT_CHAT_AI_KEY: &str = "chat_ai.api_key";
|
||||
|
||||
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 },
|
||||
}
|
||||
|
||||
impl SecretRecord {
|
||||
pub fn value(&self) -> &str {
|
||||
match self {
|
||||
Self::Legacy(v) => v,
|
||||
Self::Bound { value, .. } => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[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) => {
|
||||
if err.code() == ERR_SEC_ITEM_NOT_FOUND {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(format!("读取钥匙串失败 (code {}): {err}", err.code()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn get_secret(_account: &str) -> Result<Option<String>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn set_secret(_account: &str, _value: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn delete_secret(_account: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_bound_secret() {
|
||||
let encoded = encode_bound_secret("https://api.openai.com/v1", "sk-test").unwrap();
|
||||
let decoded = decode_secret_record(&encoded).unwrap();
|
||||
assert_eq!(decoded.value(), "sk-test");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! `ttree-platform-macos` —— macOS 原生系统能力封装:
|
||||
//! - Keychain 安全凭据存取(兼容既有 `com.samwstu.ttree` 钥匙串记录)
|
||||
//! - Accessibility + 剪贴板回退的划词捕获
|
||||
//! - 全局热键解析、冲突校验与内存级 O(1) 分发
|
||||
//! - LaunchAgent 开机自启管理
|
||||
|
||||
pub mod autostart;
|
||||
pub mod hotkey;
|
||||
pub mod keychain;
|
||||
pub mod selection;
|
||||
@@ -0,0 +1,253 @@
|
||||
//! macOS 选中文本捕获:优先走 Accessibility API (`AXSelectedText`),
|
||||
//! 仅当辅助功能未授权或焦点控件不支持 AX 属性时才回退到临时剪贴板复制。
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub 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(target_os = "macos")]
|
||||
mod imp {
|
||||
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 AX_ERROR_NO_VALUE: i32 = -25212;
|
||||
const COPY_KEY_CODE: u16 = 8;
|
||||
const COPY_TIMEOUT: Duration = Duration::from_millis(320);
|
||||
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(16);
|
||||
|
||||
#[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, PartialEq, Eq)]
|
||||
enum AxOutcome {
|
||||
Selected(String),
|
||||
EmptySelection,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PasteboardSnapshot {
|
||||
entries: Vec<PasteboardEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PasteboardEntry {
|
||||
type_name: String,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn is_accessibility_trusted() -> bool {
|
||||
unsafe { AXIsProcessTrusted() != 0 }
|
||||
}
|
||||
|
||||
pub fn capture_selected_text() -> Result<Option<String>> {
|
||||
match capture_accessibility() {
|
||||
AxOutcome::Selected(text) => Ok(Some(text)),
|
||||
// Accessibility 已明确告知当前焦点控件为空选区:不合成 Cmd+C,避免误触蜂鸣音与 320ms 轮询等待
|
||||
AxOutcome::EmptySelection => Ok(None),
|
||||
AxOutcome::Unavailable => capture_clipboard_selected_text(),
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_accessibility() -> AxOutcome {
|
||||
unsafe {
|
||||
if AXIsProcessTrusted() == 0 {
|
||||
return AxOutcome::Unavailable;
|
||||
}
|
||||
|
||||
let system_element = AXUIElementCreateSystemWide();
|
||||
if system_element.is_null() {
|
||||
return AxOutcome::Unavailable;
|
||||
}
|
||||
|
||||
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 AxOutcome::Unavailable;
|
||||
}
|
||||
|
||||
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_NO_VALUE {
|
||||
return AxOutcome::EmptySelection;
|
||||
}
|
||||
if selected_error != AX_ERROR_SUCCESS || selected_value.is_null() {
|
||||
return AxOutcome::Unavailable;
|
||||
}
|
||||
|
||||
let selected =
|
||||
CFString::wrap_under_create_rule(selected_value as CFStringRef).to_string();
|
||||
match normalize_selected_text(selected) {
|
||||
Some(text) => AxOutcome::Selected(text),
|
||||
None => AxOutcome::EmptySelection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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> {
|
||||
let pb_type: &'static NSPasteboardType = unsafe { NSPasteboardTypeString };
|
||||
pasteboard
|
||||
.stringForType(pb_type)
|
||||
.map(|text| text.to_string())
|
||||
}
|
||||
|
||||
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(target_os = "macos")]
|
||||
pub fn capture_selected_text() -> Result<Option<String>> {
|
||||
imp::capture_selected_text()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn is_accessibility_trusted() -> bool {
|
||||
imp::is_accessibility_trusted()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn capture_selected_text() -> Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn is_accessibility_trusted() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_whitespace_only_to_none() {
|
||||
assert_eq!(normalize_selected_text(" \n\t "), None);
|
||||
assert_eq!(
|
||||
normalize_selected_text(" hello world \n"),
|
||||
Some("hello world".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "ttree-storage"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ttree-core = { path = "../ttree-core" }
|
||||
ttree-platform-macos = { path = "../ttree-platform-macos" }
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,238 @@
|
||||
//! AI 对话历史持久化(JSONL 追加写,100% 兼容既有 `chat-history.jsonl`)。
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Deserialize;
|
||||
use ttree_core::{validate_messages, ChatMessage, Conversation};
|
||||
|
||||
use crate::settings::SettingsStore;
|
||||
|
||||
pub const HISTORY_FILE: &str = "chat-history.jsonl";
|
||||
const SESSION_START_MARKER: &str = r#"{"type":"session_start"}"#;
|
||||
const SUMMARY_MAX_CHARS: usize = 40;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum HistoryLine {
|
||||
SessionResume {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
id: usize,
|
||||
},
|
||||
SessionStart {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
},
|
||||
Message(ChatMessage),
|
||||
}
|
||||
|
||||
pub struct HistoryStore {
|
||||
path: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl HistoryStore {
|
||||
pub fn open_default() -> Self {
|
||||
let dir = SettingsStore::default_config_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
Self::open_at(dir.join(HISTORY_FILE))
|
||||
}
|
||||
|
||||
pub fn open_at(path: PathBuf) -> Self {
|
||||
Self {
|
||||
path,
|
||||
lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_new_conversation(&self) -> Result<(), String> {
|
||||
let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?;
|
||||
let needs_marker = fs::metadata(&self.path)
|
||||
.map(|m| m.len() > 0)
|
||||
.unwrap_or(false);
|
||||
if !needs_marker {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.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}"))
|
||||
}
|
||||
|
||||
pub fn append_round(
|
||||
&self,
|
||||
conversation_id: Option<usize>,
|
||||
msgs: &[ChatMessage],
|
||||
) -> Result<(), String> {
|
||||
if msgs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
validate_messages(msgs)?;
|
||||
let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?;
|
||||
let mut buf = String::with_capacity(128 * msgs.len());
|
||||
if let Some(id) = conversation_id {
|
||||
let conversations = read_conversations_from_path(&self.path)?;
|
||||
if !conversations.iter().any(|c| c.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');
|
||||
}
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)
|
||||
.map_err(|e| format!("打开历史文件失败: {e}"))?;
|
||||
file.write_all(buf.as_bytes())
|
||||
.map_err(|e| format!("写入历史文件失败: {e}"))
|
||||
}
|
||||
|
||||
pub fn read_conversations(&self) -> Result<Vec<Conversation>, String> {
|
||||
let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?;
|
||||
read_conversations_from_path(&self.path)
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Result<(), String> {
|
||||
let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?;
|
||||
match fs::remove_file(&self.path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("删除历史文件失败: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
))
|
||||
}
|
||||
|
||||
pub 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 Ok(parsed) = serde_json::from_str::<HistoryLine>(trimmed) else {
|
||||
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 conv = conversations.remove(index);
|
||||
conversations.push(conv);
|
||||
}
|
||||
}
|
||||
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(|c| c.id == id) {
|
||||
conversations.push(Conversation {
|
||||
id,
|
||||
messages: Vec::new(),
|
||||
summary: String::new(),
|
||||
});
|
||||
}
|
||||
let conv = conversations
|
||||
.iter_mut()
|
||||
.find(|c| c.id == id)
|
||||
.expect("conversation exists");
|
||||
if conv.summary.is_empty() && msg.role == "user" {
|
||||
conv.summary = truncate_summary(&msg.content);
|
||||
}
|
||||
conv.messages.push(msg);
|
||||
}
|
||||
_ => 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}…")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_conversations_and_session_splits() {
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let store = HistoryStore::open_at(tmp.path().to_path_buf());
|
||||
|
||||
store
|
||||
.append_round(
|
||||
None,
|
||||
&[
|
||||
ChatMessage::user("第一段问题"),
|
||||
ChatMessage::assistant("第一段回答"),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
store.start_new_conversation().unwrap();
|
||||
store
|
||||
.append_round(
|
||||
None,
|
||||
&[
|
||||
ChatMessage::user("第二段问题"),
|
||||
ChatMessage::assistant("第二段回答"),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let convs = store.read_conversations().unwrap();
|
||||
assert_eq!(convs.len(), 2);
|
||||
assert_eq!(convs[0].summary, "第一段问题");
|
||||
assert_eq!(convs[1].summary, "第二段问题");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! `ttree-storage` —— 内存级配置缓存(兼容既有 `settings.json` 与 Keychain)与 JSONL 对话历史持久化。
|
||||
|
||||
pub mod history;
|
||||
pub mod settings;
|
||||
|
||||
pub use history::HistoryStore;
|
||||
pub use settings::SettingsStore;
|
||||
@@ -0,0 +1,442 @@
|
||||
//! 内存缓存 + 磁盘持久化的配置管理。
|
||||
//!
|
||||
//! 关键性能优化:
|
||||
//! - 启动时一次性加载 `settings.json` 与 Keychain 密钥到内存 `RwLock`
|
||||
//! - 翻译/对话/快捷键分发热路径直接从内存读取(0 磁盘 I/O,0 Keychain IPC)
|
||||
//! - 100% 兼容旧版 Tauri `tauri-plugin-store` 生成的扁平点号键名(如 `"openai.base_url"`)
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use ttree_core::config::{
|
||||
validate_field_len, MAX_API_KEY_BYTES, MAX_MODEL_BYTES, MAX_PATH_BYTES, MAX_PROMPT_BYTES,
|
||||
};
|
||||
use ttree_core::{
|
||||
normalize_base_url, AiBackendKind, AiTranslationConfig, AppearanceConfig, ChatAiConfig,
|
||||
ShortcutConfig, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH,
|
||||
};
|
||||
use ttree_platform_macos::keychain::{self, ACCOUNT_CHAT_AI_KEY, ACCOUNT_OPENAI_KEY};
|
||||
|
||||
pub const APP_BUNDLE_ID: &str = "com.samwstu.ttree";
|
||||
pub const STORE_FILE: &str = "settings.json";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedSettings {
|
||||
raw_map: Map<String, Value>,
|
||||
ai_translate: AiTranslationConfig,
|
||||
chat_ai: ChatAiConfig,
|
||||
shortcuts: ShortcutConfig,
|
||||
appearance: AppearanceConfig,
|
||||
window_size: (f64, f64),
|
||||
}
|
||||
|
||||
pub struct SettingsStore {
|
||||
path: PathBuf,
|
||||
cache: RwLock<CachedSettings>,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
pub fn default_config_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(APP_BUNDLE_ID)
|
||||
}
|
||||
|
||||
pub fn open_default() -> Self {
|
||||
let dir = Self::default_config_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
Self::open_at(dir.join(STORE_FILE))
|
||||
}
|
||||
|
||||
pub fn open_at(path: PathBuf) -> Self {
|
||||
let raw_map = read_json_map(&path);
|
||||
let ai_translate = load_ai_translation(&raw_map);
|
||||
let chat_ai = load_chat_ai(&raw_map);
|
||||
let shortcuts = load_shortcuts(&raw_map);
|
||||
let appearance = load_appearance(&raw_map);
|
||||
let window_size = load_window_size(&raw_map).unwrap_or((680.0, 520.0));
|
||||
|
||||
Self {
|
||||
path,
|
||||
cache: RwLock::new(CachedSettings {
|
||||
raw_map,
|
||||
ai_translate,
|
||||
chat_ai,
|
||||
shortcuts,
|
||||
appearance,
|
||||
window_size,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 供翻译请求直接读取(含内存缓存的真实 API Key,零 I/O)。
|
||||
pub fn ai_translation_for_request(&self) -> AiTranslationConfig {
|
||||
self.cache.read().expect("settings lock poisoned").ai_translate.clone()
|
||||
}
|
||||
|
||||
/// 供对话请求直接读取(含内存缓存的真实 API Key,零 I/O)。
|
||||
pub fn chat_ai_for_request(&self) -> ChatAiConfig {
|
||||
self.cache.read().expect("settings lock poisoned").chat_ai.clone()
|
||||
}
|
||||
|
||||
pub fn shortcuts(&self) -> ShortcutConfig {
|
||||
self.cache.read().expect("settings lock poisoned").shortcuts.clone()
|
||||
}
|
||||
|
||||
pub fn appearance(&self) -> AppearanceConfig {
|
||||
self.cache.read().expect("settings lock poisoned").appearance
|
||||
}
|
||||
|
||||
pub fn window_size(&self) -> (f64, f64) {
|
||||
self.cache.read().expect("settings lock poisoned").window_size
|
||||
}
|
||||
|
||||
pub fn save_ai_translation(&self, input: &AiTranslationConfig) -> Result<(), String> {
|
||||
validate_field_len("模型", &input.model, MAX_MODEL_BYTES)?;
|
||||
validate_field_len("GGUF 模型路径", &input.gguf_model_path, MAX_PATH_BYTES)?;
|
||||
validate_field_len("Tokenizer 路径", &input.gguf_tokenizer_path, MAX_PATH_BYTES)?;
|
||||
validate_field_len("翻译提示词", &input.translate_prompt, MAX_PROMPT_BYTES)?;
|
||||
|
||||
let base_url = if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
normalize_base_url(&input.base_url)?
|
||||
};
|
||||
|
||||
let mut guard = self.cache.write().expect("settings lock poisoned");
|
||||
let resolved_key = update_secret_if_needed(
|
||||
ACCOUNT_OPENAI_KEY,
|
||||
&base_url,
|
||||
&guard.ai_translate.api_key,
|
||||
&input.api_key,
|
||||
input.clear_api_key,
|
||||
)?;
|
||||
|
||||
let updated = AiTranslationConfig {
|
||||
enabled: input.enabled,
|
||||
backend: input.backend,
|
||||
base_url: base_url.clone(),
|
||||
has_api_key: !resolved_key.is_empty(),
|
||||
api_key: resolved_key,
|
||||
clear_api_key: false,
|
||||
model: input.model.trim().to_string(),
|
||||
gguf_model_path: input.gguf_model_path.trim().to_string(),
|
||||
gguf_tokenizer_path: input.gguf_tokenizer_path.trim().to_string(),
|
||||
translate_prompt: input.translate_prompt.clone(),
|
||||
};
|
||||
|
||||
guard.raw_map.insert("openai.enabled".into(), Value::Bool(updated.enabled));
|
||||
guard.raw_map.insert(
|
||||
"openai.backend".into(),
|
||||
serde_json::to_value(updated.backend).unwrap_or(Value::String("open_ai_compatible".into())),
|
||||
);
|
||||
guard.raw_map.insert("openai.base_url".into(), Value::String(updated.base_url.clone()));
|
||||
guard.raw_map.insert("openai.model".into(), Value::String(updated.model.clone()));
|
||||
guard.raw_map.insert(
|
||||
"openai.gguf_model_path".into(),
|
||||
Value::String(updated.gguf_model_path.clone()),
|
||||
);
|
||||
guard.raw_map.insert(
|
||||
"openai.gguf_tokenizer_path".into(),
|
||||
Value::String(updated.gguf_tokenizer_path.clone()),
|
||||
);
|
||||
guard.raw_map.insert(
|
||||
"openai.translate_prompt".into(),
|
||||
Value::String(updated.translate_prompt.clone()),
|
||||
);
|
||||
guard.raw_map.remove("openai.api_key");
|
||||
|
||||
write_json_map(&self.path, &guard.raw_map)?;
|
||||
guard.ai_translate = updated;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_chat_ai(&self, input: &ChatAiConfig) -> Result<(), String> {
|
||||
validate_field_len("模型", &input.model, MAX_MODEL_BYTES)?;
|
||||
validate_field_len("GGUF 模型路径", &input.gguf_model_path, MAX_PATH_BYTES)?;
|
||||
validate_field_len("Tokenizer 路径", &input.gguf_tokenizer_path, MAX_PATH_BYTES)?;
|
||||
validate_field_len("AI 对话提示词", &input.chat_prompt, MAX_PROMPT_BYTES)?;
|
||||
|
||||
let base_url = if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
normalize_base_url(&input.base_url)?
|
||||
};
|
||||
|
||||
let mut guard = self.cache.write().expect("settings lock poisoned");
|
||||
let resolved_key = update_secret_if_needed(
|
||||
ACCOUNT_CHAT_AI_KEY,
|
||||
&base_url,
|
||||
&guard.chat_ai.api_key,
|
||||
&input.api_key,
|
||||
input.clear_api_key,
|
||||
)?;
|
||||
|
||||
let updated = ChatAiConfig {
|
||||
backend: input.backend,
|
||||
base_url: base_url.clone(),
|
||||
has_api_key: !resolved_key.is_empty(),
|
||||
api_key: resolved_key,
|
||||
clear_api_key: false,
|
||||
model: input.model.trim().to_string(),
|
||||
gguf_model_path: input.gguf_model_path.trim().to_string(),
|
||||
gguf_tokenizer_path: input.gguf_tokenizer_path.trim().to_string(),
|
||||
chat_prompt: input.chat_prompt.clone(),
|
||||
};
|
||||
|
||||
guard.raw_map.insert(
|
||||
"chat_ai.backend".into(),
|
||||
serde_json::to_value(updated.backend).unwrap_or(Value::String("open_ai_compatible".into())),
|
||||
);
|
||||
guard.raw_map.insert("chat_ai.base_url".into(), Value::String(updated.base_url.clone()));
|
||||
guard.raw_map.insert("chat_ai.model".into(), Value::String(updated.model.clone()));
|
||||
guard.raw_map.insert(
|
||||
"chat_ai.gguf_model_path".into(),
|
||||
Value::String(updated.gguf_model_path.clone()),
|
||||
);
|
||||
guard.raw_map.insert(
|
||||
"chat_ai.gguf_tokenizer_path".into(),
|
||||
Value::String(updated.gguf_tokenizer_path.clone()),
|
||||
);
|
||||
guard.raw_map.insert(
|
||||
"chat_ai.chat_prompt".into(),
|
||||
Value::String(updated.chat_prompt.clone()),
|
||||
);
|
||||
guard.raw_map.remove("chat_ai.api_key");
|
||||
|
||||
write_json_map(&self.path, &guard.raw_map)?;
|
||||
guard.chat_ai = updated;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_shortcuts(&self, cfg: &ShortcutConfig) -> Result<(), String> {
|
||||
let mut guard = self.cache.write().expect("settings lock poisoned");
|
||||
guard.raw_map.insert("shortcut.toggle".into(), Value::String(cfg.toggle.clone()));
|
||||
guard.raw_map.insert("shortcut.ocr".into(), Value::String(cfg.ocr.clone()));
|
||||
guard.raw_map.insert("shortcut.ai_dialog".into(), Value::String(cfg.ai_dialog.clone()));
|
||||
guard.raw_map.insert(
|
||||
"shortcut.selection_translate".into(),
|
||||
Value::String(cfg.selection_translate.clone()),
|
||||
);
|
||||
guard.raw_map.insert(
|
||||
"shortcut.selection_ai_dialog".into(),
|
||||
Value::String(cfg.selection_ai_dialog.clone()),
|
||||
);
|
||||
write_json_map(&self.path, &guard.raw_map)?;
|
||||
guard.shortcuts = cfg.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_appearance(&self, cfg: AppearanceConfig) -> Result<(), String> {
|
||||
let clamped = AppearanceConfig {
|
||||
panel_opacity: AppearanceConfig::clamp_opacity(cfg.panel_opacity),
|
||||
};
|
||||
let mut guard = self.cache.write().expect("settings lock poisoned");
|
||||
guard
|
||||
.raw_map
|
||||
.insert("appearance.panel_opacity".into(), Value::from(clamped.panel_opacity));
|
||||
write_json_map(&self.path, &guard.raw_map)?;
|
||||
guard.appearance = clamped;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_window_size(&self, width: f64, height: f64) {
|
||||
let w = width.max(WINDOW_MIN_WIDTH);
|
||||
let h = height.max(WINDOW_MIN_HEIGHT);
|
||||
if let Ok(mut guard) = self.cache.write() {
|
||||
if (guard.window_size.0 - w).abs() < 1.0 && (guard.window_size.1 - h).abs() < 1.0 {
|
||||
return;
|
||||
}
|
||||
guard.window_size = (w, h);
|
||||
guard.raw_map.insert("window.width".into(), Value::from(w));
|
||||
guard.raw_map.insert("window.height".into(), Value::from(h));
|
||||
let _ = write_json_map(&self.path, &guard.raw_map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_secret_if_needed(
|
||||
account: &str,
|
||||
endpoint: &str,
|
||||
existing: &str,
|
||||
new_key: &str,
|
||||
clear: bool,
|
||||
) -> Result<String, String> {
|
||||
let trimmed = new_key.trim();
|
||||
validate_field_len("API Key", trimmed, MAX_API_KEY_BYTES)?;
|
||||
if clear {
|
||||
let _ = keychain::delete_secret(account);
|
||||
return Ok(String::new());
|
||||
}
|
||||
if !trimmed.is_empty() {
|
||||
let bind_url = if endpoint.is_empty() {
|
||||
"local://gguf"
|
||||
} else {
|
||||
endpoint
|
||||
};
|
||||
let _ = keychain::set_bound_secret(account, bind_url, trimmed);
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
Ok(existing.to_string())
|
||||
}
|
||||
|
||||
fn read_json_map(path: &Path) -> Map<String, Value> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Map<String, Value>>(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_json_map(path: &Path, map: &Map<String, Value>) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(map).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||
fs::write(path, bytes).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||
}
|
||||
|
||||
fn load_ai_translation(map: &Map<String, Value>) -> AiTranslationConfig {
|
||||
let mut cfg = AiTranslationConfig::default();
|
||||
if let Some(enabled) = map.get("openai.enabled").and_then(Value::as_bool) {
|
||||
cfg.enabled = enabled;
|
||||
}
|
||||
if let Some(backend) = map
|
||||
.get("openai.backend")
|
||||
.and_then(|v| serde_json::from_value::<AiBackendKind>(v.clone()).ok())
|
||||
{
|
||||
cfg.backend = backend;
|
||||
}
|
||||
if let Some(url) = map.get("openai.base_url").and_then(Value::as_str) {
|
||||
if let Ok(norm) = normalize_base_url(url) {
|
||||
cfg.base_url = norm;
|
||||
}
|
||||
}
|
||||
if let Some(m) = map.get("openai.model").and_then(Value::as_str) {
|
||||
if !m.trim().is_empty() {
|
||||
cfg.model = m.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Some(p) = map.get("openai.gguf_model_path").and_then(Value::as_str) {
|
||||
cfg.gguf_model_path = p.trim().to_string();
|
||||
}
|
||||
if let Some(p) = map.get("openai.gguf_tokenizer_path").and_then(Value::as_str) {
|
||||
cfg.gguf_tokenizer_path = p.trim().to_string();
|
||||
}
|
||||
if let Some(prompt) = map.get("openai.translate_prompt").and_then(Value::as_str) {
|
||||
if !prompt.trim().is_empty() {
|
||||
cfg.translate_prompt = prompt.to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(Some(record)) = keychain::get_secret_record(ACCOUNT_OPENAI_KEY) {
|
||||
cfg.api_key = record.value().to_string();
|
||||
cfg.has_api_key = !cfg.api_key.is_empty();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn load_chat_ai(map: &Map<String, Value>) -> ChatAiConfig {
|
||||
let mut cfg = ChatAiConfig::default();
|
||||
if let Some(backend) = map
|
||||
.get("chat_ai.backend")
|
||||
.and_then(|v| serde_json::from_value::<AiBackendKind>(v.clone()).ok())
|
||||
{
|
||||
cfg.backend = backend;
|
||||
}
|
||||
if let Some(url) = map.get("chat_ai.base_url").and_then(Value::as_str) {
|
||||
if let Ok(norm) = normalize_base_url(url) {
|
||||
cfg.base_url = norm;
|
||||
}
|
||||
}
|
||||
if let Some(m) = map.get("chat_ai.model").and_then(Value::as_str) {
|
||||
if !m.trim().is_empty() {
|
||||
cfg.model = m.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Some(p) = map.get("chat_ai.gguf_model_path").and_then(Value::as_str) {
|
||||
cfg.gguf_model_path = p.trim().to_string();
|
||||
}
|
||||
if let Some(p) = map.get("chat_ai.gguf_tokenizer_path").and_then(Value::as_str) {
|
||||
cfg.gguf_tokenizer_path = p.trim().to_string();
|
||||
}
|
||||
if let Some(prompt) = map.get("chat_ai.chat_prompt").and_then(Value::as_str) {
|
||||
cfg.chat_prompt = prompt.to_string();
|
||||
}
|
||||
if let Ok(Some(record)) = keychain::get_secret_record(ACCOUNT_CHAT_AI_KEY) {
|
||||
cfg.api_key = record.value().to_string();
|
||||
cfg.has_api_key = !cfg.api_key.is_empty();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn load_shortcuts(map: &Map<String, Value>) -> ShortcutConfig {
|
||||
let defaults = ShortcutConfig::default();
|
||||
ShortcutConfig {
|
||||
toggle: map
|
||||
.get("shortcut.toggle")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&defaults.toggle)
|
||||
.to_string(),
|
||||
ocr: map
|
||||
.get("shortcut.ocr")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&defaults.ocr)
|
||||
.to_string(),
|
||||
ai_dialog: map
|
||||
.get("shortcut.ai_dialog")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&defaults.ai_dialog)
|
||||
.to_string(),
|
||||
selection_translate: map
|
||||
.get("shortcut.selection_translate")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&defaults.selection_translate)
|
||||
.to_string(),
|
||||
selection_ai_dialog: map
|
||||
.get("shortcut.selection_ai_dialog")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&defaults.selection_ai_dialog)
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_appearance(map: &Map<String, Value>) -> AppearanceConfig {
|
||||
let mut cfg = AppearanceConfig::default();
|
||||
if let Some(v) = map.get("appearance.panel_opacity").and_then(Value::as_f64) {
|
||||
cfg.panel_opacity = AppearanceConfig::clamp_opacity(v);
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn load_window_size(map: &Map<String, Value>) -> Option<(f64, f64)> {
|
||||
let w = map.get("window.width").and_then(Value::as_f64)?;
|
||||
let h = map.get("window.height").and_then(Value::as_f64)?;
|
||||
if w < WINDOW_MIN_WIDTH || h < WINDOW_MIN_HEIGHT {
|
||||
return None;
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn persists_and_reloads_settings_via_memory_cache() {
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let store = SettingsStore::open_at(tmp.path().to_path_buf());
|
||||
|
||||
let mut cfg = store.ai_translation_for_request();
|
||||
cfg.backend = AiBackendKind::Ollama;
|
||||
cfg.base_url = "http://127.0.0.1:11434".to_string();
|
||||
cfg.model = "qwen2.5:3b".to_string();
|
||||
store.save_ai_translation(&cfg).unwrap();
|
||||
|
||||
let reloaded = SettingsStore::open_at(tmp.path().to_path_buf());
|
||||
let loaded_cfg = reloaded.ai_translation_for_request();
|
||||
assert_eq!(loaded_cfg.backend, AiBackendKind::Ollama);
|
||||
assert_eq!(loaded_cfg.base_url, "http://127.0.0.1:11434");
|
||||
assert_eq!(loaded_cfg.model, "qwen2.5:3b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ttree-translate"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ttree-core = { path = "../ttree-core" }
|
||||
futures-util.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
urlencoding.workspace = true
|
||||
@@ -0,0 +1,253 @@
|
||||
//! 免费网页翻译智能竞速路由(Hedged Request Racing + 延迟加权主备优选 + 并发分块)。
|
||||
//!
|
||||
//! 关键提速:
|
||||
//! 1. 根据最近一次响应延迟选出最快的服务作为 Primary,另一方作为 Secondary;
|
||||
//! 2. Primary 发出后若在 `HEDGE_DELAY (550ms)` 内未返回,立即并发启动 Secondary 竞速(谁先成功用谁);
|
||||
//! 3. 长文本分块后支持并发请求,而非旧版的串行逐块阻塞等待。
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::future::join_all;
|
||||
|
||||
use crate::{bing, google};
|
||||
|
||||
const HEDGE_DELAY: Duration = Duration::from_millis(550);
|
||||
const BASE_COOLDOWN: Duration = Duration::from_secs(45);
|
||||
const MAX_COOLDOWN: Duration = Duration::from_secs(300);
|
||||
const CHUNK_UTF16_LIMIT: usize = 1_200;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Provider {
|
||||
Google,
|
||||
Bing,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Google => 0,
|
||||
Self::Bing => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn other(self) -> Self {
|
||||
match self {
|
||||
Self::Google => Self::Bing,
|
||||
Self::Bing => Self::Google,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ProviderStats {
|
||||
ewma_ms: u64,
|
||||
failures: u32,
|
||||
retry_after: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for ProviderStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ewma_ms: 400,
|
||||
failures: 0,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RouterState {
|
||||
stats: [ProviderStats; 2],
|
||||
}
|
||||
|
||||
impl RouterState {
|
||||
fn is_available(&self, p: Provider, now: Instant) -> bool {
|
||||
self.stats[p.index()]
|
||||
.retry_after
|
||||
.is_none_or(|t| now >= t)
|
||||
}
|
||||
|
||||
/// 优先返回当前可用且历史延迟更低的服务。
|
||||
fn ordered_candidates(&self, now: Instant) -> Vec<Provider> {
|
||||
let mut list: Vec<Provider> = [Provider::Google, Provider::Bing]
|
||||
.into_iter()
|
||||
.filter(|&p| self.is_available(p, now))
|
||||
.collect();
|
||||
if list.is_empty() {
|
||||
// 若两个都在冷却期,允许全部重探
|
||||
list.push(Provider::Bing);
|
||||
list.push(Provider::Google);
|
||||
} else if list.len() == 2 {
|
||||
list.sort_by_key(|&p| self.stats[p.index()].ewma_ms);
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
fn record_success(&mut self, p: Provider, elapsed: Duration) {
|
||||
let st = &mut self.stats[p.index()];
|
||||
st.failures = 0;
|
||||
st.retry_after = None;
|
||||
let sample = elapsed.as_millis() as u64;
|
||||
st.ewma_ms = (st.ewma_ms * 3 + sample) / 4;
|
||||
}
|
||||
|
||||
fn record_failure(&mut self, p: Provider, now: Instant) {
|
||||
let st = &mut self.stats[p.index()];
|
||||
st.failures = st.failures.saturating_add(1);
|
||||
let mult = 1 << st.failures.saturating_sub(1).min(3);
|
||||
let cooldown = (BASE_COOLDOWN * mult).min(MAX_COOLDOWN);
|
||||
st.retry_after = Some(now + cooldown);
|
||||
st.ewma_ms = st.ewma_ms.saturating_add(1500);
|
||||
}
|
||||
}
|
||||
|
||||
static ROUTER: OnceLock<Mutex<RouterState>> = OnceLock::new();
|
||||
|
||||
fn router() -> &'static Mutex<RouterState> {
|
||||
ROUTER.get_or_init(|| Mutex::new(RouterState::default()))
|
||||
}
|
||||
|
||||
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
|
||||
let chunks = split_text(text);
|
||||
if chunks.len() == 1 {
|
||||
return translate_chunk_hedged(chunks[0].trim(), source, target).await;
|
||||
}
|
||||
|
||||
// 多段长文本并发分块翻译,保留段间空白与换行
|
||||
let futs = chunks.iter().map(|&chunk| async move {
|
||||
let content = chunk.trim();
|
||||
if content.is_empty() {
|
||||
return Ok::<String, String>(chunk.to_string());
|
||||
}
|
||||
let translated = translate_chunk_hedged(content, source, target).await?;
|
||||
let mut piece = String::new();
|
||||
piece.push_str(&chunk[..chunk.len() - chunk.trim_start().len()]);
|
||||
piece.push_str(translated.trim());
|
||||
piece.push_str(&chunk[chunk.trim_end().len()..]);
|
||||
Ok(piece)
|
||||
});
|
||||
|
||||
let results = join_all(futs).await;
|
||||
let mut output = String::new();
|
||||
for res in results {
|
||||
output.push_str(&res?);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn translate_chunk_hedged(text: &str, source: &str, target: &str) -> Result<String, String> {
|
||||
let candidates = router()
|
||||
.lock()
|
||||
.map(|g| g.ordered_candidates(Instant::now()))
|
||||
.unwrap_or_else(|_| vec![Provider::Bing, Provider::Google]);
|
||||
|
||||
let primary = candidates[0];
|
||||
let secondary = candidates.get(1).copied().unwrap_or_else(|| primary.other());
|
||||
|
||||
let primary_fut = call_provider(primary, text, source, target);
|
||||
tokio::pin!(primary_fut);
|
||||
|
||||
// 先给主服务 HEDGE_DELAY (550ms) 单独响应窗口
|
||||
tokio::select! {
|
||||
res = &mut primary_fut => {
|
||||
match res {
|
||||
Ok(out) => return Ok(out),
|
||||
Err(_) => {
|
||||
// 主服务立即报错,直接等待备用服务
|
||||
return call_provider(secondary, text, source, target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(HEDGE_DELAY) => {}
|
||||
}
|
||||
|
||||
// 主服务超过 550ms 未返回:并发拉起备用服务竞速!
|
||||
let secondary_fut = call_provider(secondary, text, source, target);
|
||||
tokio::pin!(secondary_fut);
|
||||
|
||||
tokio::select! {
|
||||
res1 = &mut primary_fut => {
|
||||
match res1 {
|
||||
Ok(out) => Ok(out),
|
||||
Err(_) => secondary_fut.await,
|
||||
}
|
||||
}
|
||||
res2 = &mut secondary_fut => {
|
||||
match res2 {
|
||||
Ok(out) => Ok(out),
|
||||
Err(_) => primary_fut.await,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_provider(
|
||||
provider: Provider,
|
||||
text: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Result<String, String> {
|
||||
let start = Instant::now();
|
||||
let result = match provider {
|
||||
Provider::Google => google::translate(text, source, target).await,
|
||||
Provider::Bing => bing::translate(text, source, target).await,
|
||||
};
|
||||
if let Ok(mut guard) = router().lock() {
|
||||
match &result {
|
||||
Ok(_) => guard.record_success(provider, start.elapsed()),
|
||||
Err(_) => guard.record_failure(provider, Instant::now()),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub 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::*;
|
||||
|
||||
#[test]
|
||||
fn router_prefers_lower_latency_healthy_provider() {
|
||||
let mut state = RouterState::default();
|
||||
let now = Instant::now();
|
||||
state.record_success(Provider::Bing, Duration::from_millis(120));
|
||||
state.record_success(Provider::Google, Duration::from_millis(900));
|
||||
assert_eq!(state.ordered_candidates(now)[0], Provider::Bing);
|
||||
|
||||
state.record_failure(Provider::Bing, now);
|
||||
assert_eq!(state.ordered_candidates(now), vec![Provider::Google]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_long_text_without_losing_characters() {
|
||||
let input = format!("{}\n{}", "测试文本 ".repeat(400), "hello world. ".repeat(300));
|
||||
let chunks = split_text(&input);
|
||||
assert!(chunks.len() > 1);
|
||||
assert_eq!(chunks.concat(), input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! 必应翻译 —— 带有内存级 `BingToken` 缓存(默认 45 分钟复用,失效自动刷新)。
|
||||
//!
|
||||
//! 关键提速:
|
||||
//! 旧版每次翻译都先请求并下载数 MB 的 `https://www.bing.com/translator` 网页解析 Token,
|
||||
//! 导致必应翻译耗时翻倍且分块时重复抓网页。新版将 Token 缓存在内存中,后续请求全部单跳直达。
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
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(8);
|
||||
const TOKEN_TTL: Duration = Duration::from_secs(45 * 60);
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
|
||||
(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BingToken {
|
||||
pub ig: String,
|
||||
pub iid: String,
|
||||
pub key: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedToken {
|
||||
token: BingToken,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
static TOKEN_CACHE: OnceLock<Mutex<Option<CachedToken>>> = OnceLock::new();
|
||||
|
||||
fn get_cached_token(now: Instant) -> Option<BingToken> {
|
||||
let cache = TOKEN_CACHE.get_or_init(|| Mutex::new(None));
|
||||
let mut guard = cache.lock().ok()?;
|
||||
if let Some(entry) = guard.as_ref() {
|
||||
if now < entry.expires_at {
|
||||
return Some(entry.token.clone());
|
||||
}
|
||||
}
|
||||
*guard = None;
|
||||
None
|
||||
}
|
||||
|
||||
fn store_cached_token(token: BingToken, now: Instant) {
|
||||
let cache = TOKEN_CACHE.get_or_init(|| Mutex::new(None));
|
||||
if let Ok(mut guard) = cache.lock() {
|
||||
*guard = Some(CachedToken {
|
||||
token,
|
||||
expires_at: now + TOKEN_TTL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_cached_token() {
|
||||
if let Some(cache) = TOKEN_CACHE.get() {
|
||||
if let Ok(mut guard) = cache.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, from_cache) = match get_cached_token(Instant::now()) {
|
||||
Some(t) => (t, true),
|
||||
None => {
|
||||
let fresh = fetch_token(&client).await?;
|
||||
store_cached_token(fresh.clone(), Instant::now());
|
||||
(fresh, false)
|
||||
}
|
||||
};
|
||||
|
||||
match translate_with_token(&client, &token, text, source, target).await {
|
||||
Ok(out) => Ok(out),
|
||||
Err(_first_err) if from_cache => {
|
||||
// 缓存令牌可能提前失效,立即淘汰并刷新重试一次
|
||||
invalidate_cached_token();
|
||||
let fresh = fetch_token(&client).await?;
|
||||
store_cached_token(fresh.clone(), Instant::now());
|
||||
translate_with_token(&client, &fresh, text, source, target).await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn translate_with_token(
|
||||
client: &reqwest::Client,
|
||||
token: &BingToken,
|
||||
text: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Result<String, String> {
|
||||
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(|t| !t.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())
|
||||
}
|
||||
|
||||
pub fn parse_token(html: &str) -> Option<BingToken> {
|
||||
let ig = extract_between(html, "IG:\"", "\"")?;
|
||||
let iid = format!(
|
||||
"translator.{}",
|
||||
extract_between(html, "data-iid=\"translator.", "\"")?
|
||||
);
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
pub 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::*;
|
||||
|
||||
#[test]
|
||||
fn parses_bing_html_token_and_caches_with_ttl() {
|
||||
let html = r#"IG:"FF00"; <div data-iid="translator.5028"></div>
|
||||
params_AbusePreventionHelper = [167060,"abcdef",3600000];"#;
|
||||
let tok = parse_token(html).unwrap();
|
||||
assert_eq!(tok.ig, "FF00");
|
||||
assert_eq!(tok.iid, "translator.5028");
|
||||
assert_eq!(tok.token, "abcdef");
|
||||
|
||||
let now = Instant::now();
|
||||
store_cached_token(tok.clone(), now);
|
||||
assert_eq!(get_cached_token(now + Duration::from_secs(60)), Some(tok));
|
||||
assert_eq!(get_cached_token(now + TOKEN_TTL + Duration::from_secs(1)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 谷歌翻译 —— 改用 POST 表单请求避免长文本 URL 膨胀,并配合 1.8s 快速连接超时。
|
||||
|
||||
use std::time::Duration;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{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(8);
|
||||
|
||||
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",
|
||||
sl = urlencoding::encode(source),
|
||||
tl = urlencoding::encode(target),
|
||||
);
|
||||
|
||||
let resp = shared_client(REQUEST_TIMEOUT, false)
|
||||
.post(&url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.form(&[("q", text)])
|
||||
.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 out = parse_response(&body);
|
||||
if out.trim().is_empty() {
|
||||
return Err("谷歌翻译未返回有效译文".to_string());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub 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_multi_segment_google_response() {
|
||||
let body = json!([[["你好", "hello"], ["世界", "world"]], null, "en"]);
|
||||
assert_eq!(parse_response(&body), "你好世界");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! `ttree-translate` —— 极速网页翻译模块(Google POST + Bing 内存 Token 缓存 + Hedged 主备竞速路由)。
|
||||
|
||||
pub mod auto;
|
||||
pub mod bing;
|
||||
pub mod google;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::{Client, Response};
|
||||
use ttree_core::{validate_language_pair, CancelToken};
|
||||
|
||||
pub const MAX_TRANSLATION_TEXT_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_TRANSLATION_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
|
||||
pub const MAX_BING_PAGE_BYTES: usize = 4 * 1024 * 1024;
|
||||
pub const MAX_ERROR_BODY_BYTES: usize = 16 * 1024;
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_millis(1800);
|
||||
|
||||
/// 共享连接池客户端:内置 1.8s 快速连接超时与 TCP Keepalive,彻底消除不可达节点卡死 15s 的问题。
|
||||
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 client cache lock poisoned");
|
||||
clients
|
||||
.entry((timeout, cookie_store))
|
||||
.or_insert_with(|| {
|
||||
Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.timeout(timeout)
|
||||
.tcp_keepalive(Duration::from_secs(30))
|
||||
.pool_idle_timeout(Duration::from_secs(90))
|
||||
.cookie_store(cookie_store)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("failed to build reqwest client")
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn validate_translation_text(text: &str) -> Result<(), String> {
|
||||
if text.len() > MAX_TRANSLATION_TEXT_BYTES {
|
||||
return Err(format!(
|
||||
"待翻译文本超出 {MAX_TRANSLATION_TEXT_BYTES} 字节上限"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 顶层网页翻译入口(支持 `CancelToken` 即时中断与主备竞速)。
|
||||
pub async fn translate_web(
|
||||
text: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
cancel: &CancelToken,
|
||||
) -> Result<String, String> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
validate_translation_text(trimmed)?;
|
||||
validate_language_pair(source, target)?;
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => Err("翻译已取消".to_string()),
|
||||
res = auto::translate(trimmed, source, target) => res,
|
||||
}
|
||||
}
|
||||
|
||||
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 bytes = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("读取{label}失败: {e}"))?;
|
||||
if bytes.len().saturating_add(chunk.len()) > limit {
|
||||
return Err(format!("{label}超出 {limit} 字节上限"));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_error_body(response: Response) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
let mut truncated = false;
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(chunk) => {
|
||||
let remaining = MAX_ERROR_BODY_BYTES.saturating_sub(bytes.len());
|
||||
if chunk.len() > remaining {
|
||||
bytes.extend_from_slice(&chunk[..remaining]);
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Err(e) => return format!("<读取错误响应失败: {e}>"),
|
||||
}
|
||||
}
|
||||
let mut out = String::from_utf8_lossy(&bytes).into_owned();
|
||||
if truncated {
|
||||
out.push_str("…[truncated]");
|
||||
}
|
||||
out
|
||||
}
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.cargo/bin:$PATH"
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_NAME="TTREE"
|
||||
BUNDLE_IDENTIFIER="com.samwstu.ttree"
|
||||
PINNED_SIGNING_IDENTITY="TTREE Open Source Code Signing"
|
||||
PINNED_CERT_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08"
|
||||
VERSION="0.2.0"
|
||||
BUILD_NUMBER="${1:-1}"
|
||||
|
||||
TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/target}"
|
||||
BUNDLE_DIR="$TARGET_DIR/release/bundle/macos"
|
||||
APP_PATH="$BUNDLE_DIR/$APP_NAME.app"
|
||||
CONTENTS_DIR="$APP_PATH/Contents"
|
||||
MACOS_BIN_DIR="$CONTENTS_DIR/MacOS"
|
||||
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||
|
||||
echo "[1/4] Compiling 100% pure-Rust TTREE release binary..."
|
||||
cd "$ROOT_DIR"
|
||||
cargo build --release -p ttree-app
|
||||
|
||||
echo "[2/4] Assembling macOS App Bundle at $APP_PATH..."
|
||||
rm -rf "$APP_PATH"
|
||||
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"
|
||||
fi
|
||||
|
||||
cat > "$CONTENTS_DIR/Info.plist" <<EOF
|
||||
<?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>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${APP_NAME}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ttree</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${BUNDLE_IDENTIFIER}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>${APP_NAME}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${BUILD_NUMBER}</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSScreenCaptureUsageDescription</key>
|
||||
<string>TTREE 需要屏幕录制权限以完成自由框选截图 OCR 翻译。</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
echo "[3/4] Signing $APP_NAME.app with pinned community certificate ($PINNED_CERT_SHA1)..."
|
||||
TEMP_KEYCHAIN=""
|
||||
cleanup() {
|
||||
if [[ -n "$TEMP_KEYCHAIN" && -f "$TEMP_KEYCHAIN" ]]; then
|
||||
security delete-keychain "$TEMP_KEYCHAIN" >/dev/null 2>&1 || rm -f "$TEMP_KEYCHAIN"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
SIGNING_KEYCHAIN="${MACOS_COMMUNITY_SIGNING_KEYCHAIN:-}"
|
||||
if [[ -z "$SIGNING_KEYCHAIN" ]]; then
|
||||
P12_PATH="$HOME/.config/ttree-code-signing/community-signing.p12"
|
||||
if [[ -f "$P12_PATH" ]]; then
|
||||
P12_PASS="$(security find-generic-password -s "com.samwstu.ttree" -a "community-code-signing-password" -w)"
|
||||
TEMP_KEYCHAIN="$(mktemp -u /tmp/ttree-signing.XXXXXX).keychain-db"
|
||||
KC_PASS="$(uuidgen)"
|
||||
security create-keychain -p "$KC_PASS" "$TEMP_KEYCHAIN"
|
||||
security set-keychain-settings -lut 300 "$TEMP_KEYCHAIN"
|
||||
security unlock-keychain -p "$KC_PASS" "$TEMP_KEYCHAIN"
|
||||
security import "$P12_PATH" -k "$TEMP_KEYCHAIN" -P "$P12_PASS" -T /usr/bin/codesign -T /usr/bin/security >/dev/null
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$TEMP_KEYCHAIN" >/dev/null
|
||||
SIGNING_KEYCHAIN="$TEMP_KEYCHAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
REQUIREMENT="=designated => identifier \"$BUNDLE_IDENTIFIER\" and certificate leaf = H\"$PINNED_CERT_SHA1\""
|
||||
CODESIGN_ARGS=(
|
||||
--force
|
||||
--options runtime
|
||||
--timestamp=none
|
||||
--sign "$PINNED_CERT_SHA1"
|
||||
--identifier "$BUNDLE_IDENTIFIER"
|
||||
--requirements "$REQUIREMENT"
|
||||
)
|
||||
if [[ -n "$SIGNING_KEYCHAIN" ]]; then
|
||||
CODESIGN_ARGS+=(--keychain "$SIGNING_KEYCHAIN")
|
||||
fi
|
||||
|
||||
/usr/bin/codesign "${CODESIGN_ARGS[@]}" "$APP_PATH"
|
||||
|
||||
echo "[4/4] Verifying macOS community signature & TCC designated requirement..."
|
||||
"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$APP_PATH"
|
||||
|
||||
printf '\nPure-Rust TTREE.app packaged and verified at: %s\n' "$APP_PATH"
|
||||
Reference in new issue
Block a user