diff --git a/Cargo.lock b/Cargo.lock index 8ad367c..400f93a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3105,10 +3105,12 @@ dependencies = [ "anyhow", "candle-core", "candle-transformers", + "dirs 6.0.0", "futures-util", "reqwest", "serde", "serde_json", + "tempfile", "tokio", "ttree-core", "url", diff --git a/crates/ttree-ai/Cargo.toml b/crates/ttree-ai/Cargo.toml index 64c1423..740f400 100644 --- a/crates/ttree-ai/Cargo.toml +++ b/crates/ttree-ai/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] ttree-core = { path = "../ttree-core" } anyhow.workspace = true +dirs.workspace = true futures-util.workspace = true reqwest.workspace = true serde.workspace = true @@ -15,6 +16,9 @@ serde_json.workspace = true tokio.workspace = true url.workspace = true +[dev-dependencies] +tempfile.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"] } diff --git a/crates/ttree-ai/src/gguf.rs b/crates/ttree-ai/src/gguf.rs index 6e76770..e3f2c42 100644 --- a/crates/ttree-ai/src/gguf.rs +++ b/crates/ttree-ai/src/gguf.rs @@ -74,7 +74,13 @@ impl GgufEmbeddedTokenizer { { eos_tokens.push(eos_id); } - for special in ["<|im_end|>", "<|endoftext|>", "", "<|eot_id|>", ""] { + for special in [ + "<|im_end|>", + "<|endoftext|>", + "", + "<|eot_id|>", + "", + ] { if let Some(&id) = token_to_id.get(special) { if !eos_tokens.contains(&id) { eos_tokens.push(id); @@ -173,10 +179,7 @@ impl GgufEmbeddedTokenizer { return vec![b]; } } - let decoded = piece - .replace('▁', " ") - .replace("Ġ", " ") - .replace("Ċ", "\n"); + let decoded = piece.replace('▁', " ").replace("Ġ", " ").replace("Ċ", "\n"); decoded.into_bytes() } } @@ -205,8 +208,8 @@ fn load_session(model_path: &str) -> Result { } 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 content = + gguf_file::Content::read(&mut reader).map_err(|e| format!("解析 GGUF 文件头失败: {e}"))?; let arch_name = content .metadata @@ -287,9 +290,7 @@ where } }; - let needs_reload = guard - .as_ref() - .is_none_or(|s| s.model_path != model_path); + 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), diff --git a/crates/ttree-ai/src/http_provider.rs b/crates/ttree-ai/src/http_provider.rs index 1ae39ef..d777ea1 100644 --- a/crates/ttree-ai/src/http_provider.rs +++ b/crates/ttree-ai/src/http_provider.rs @@ -60,7 +60,9 @@ pub async fn discover_local_models( .and_then(Value::as_array) .map(|arr| { arr.iter() - .filter_map(|item| item.get("name").and_then(Value::as_str).map(String::from)) + .filter_map(|item| { + item.get("name").and_then(Value::as_str).map(String::from) + }) .collect::>() }) .unwrap_or_default(); @@ -99,7 +101,7 @@ pub async fn discover_local_models( .unwrap_or_default(); Ok(models) } - AiBackendKind::CandleGguf => Ok(Vec::new()), + AiBackendKind::CandleGguf | AiBackendKind::BuiltInHyMt => Ok(Vec::new()), } } @@ -122,7 +124,9 @@ where format!("{base}/chat/completions") }; - let is_hy_mt = model.to_ascii_lowercase().contains("hy-mt") || base.contains(":18082"); + let is_hy_mt = model.to_ascii_lowercase().contains("hy-mt") + || base.contains(":18082") + || base.contains(":28190"); let payload = if is_hy_mt { json!({ "model": model, @@ -130,6 +134,7 @@ where "temperature": 0.7, "top_p": 0.6, "top_k": 20, + "repeat_penalty": 1.05, "repetition_penalty": 1.05, "messages": messages, }) @@ -203,7 +208,10 @@ where 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") + base_url + .trim() + .trim_end_matches('/') + .trim_end_matches("/v1") }; let url = format!("{base}/api/chat"); diff --git a/crates/ttree-ai/src/hy_mt_manager.rs b/crates/ttree-ai/src/hy_mt_manager.rs new file mode 100644 index 0000000..73b51da --- /dev/null +++ b/crates/ttree-ai/src/hy_mt_manager.rs @@ -0,0 +1,1022 @@ +//! 腾讯混元 `HY-MT1.5-1.8B` 内置翻译模型管理器。 +//! +//! 职责: +//! 1. **一键环境与硬件兼容性检测** (`check_compatibility`):检测 CPU 架构、型号、GPU 加速能力(Metal / Vulkan)、系统内存、可用磁盘空间、推荐量化精度,以及当前已下载的精度档位与服务运行状态; +//! 2. **运行时无感准备** (`ensure_llama_server_runtime`):优先复用应用托管目录或本机已有的 `llama-server`,若缺失则自动从 `ggml-org/llama.cpp` 官方 Release 下载对应平台的轻量预编译包(~11 MB)并自动解压赋予权限; +//! 3. **Hugging Face 全精度 GGUF 断点续传下载** (`download_model_quant`):支持全部 8 个精度档位(`Q2_K` ~ `F16`),支持 `.part` 断点续传、`GGUF` 魔数校验、实时进度与网速回调、`CancelToken` 随时取消,并在直连 Hugging Face 超时时自动回退镜像; +//! 4. **后台推理进程托管** (`ensure_server_running` / `stop_server`):静默拉起 `llama-server` 守护进程,轮询 `/health` 就绪后通过本地回环接口提供毫秒级流式翻译,切换精度或退出应用时自动回收子进程。 + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use futures_util::StreamExt; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use ttree_core::{CancelToken, HyMtQuant}; + +pub const APP_BUNDLE_ID: &str = "com.samwstu.ttree"; +const LLAMA_CPP_PINNED_TAG: &str = "b8792"; +const PREFERRED_PORT: u16 = 28190; +const GGUF_MAGIC: &[u8; 4] = b"GGUF"; + +/// 下载阶段与实时进度信息。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HyMtDownloadProgress { + /// 当前阶段描述(如 `"正在准备推理引擎"` / `"正在下载 HY-MT1.5-1.8B (Q8_0)"`) + pub stage: String, + pub downloaded_bytes: u64, + pub total_bytes: u64, + pub speed_mbps: f64, +} + +impl HyMtDownloadProgress { + pub fn percent(&self) -> f64 { + if self.total_bytes == 0 { + return 0.0; + } + ((self.downloaded_bytes as f64 / self.total_bytes as f64) * 100.0).clamp(0.0, 100.0) + } + + pub fn format_status(&self) -> String { + if self.total_bytes > 0 { + let dl_mb = self.downloaded_bytes as f64 / (1024.0 * 1024.0); + let tot_mb = self.total_bytes as f64 / (1024.0 * 1024.0); + format!( + "{}: {:.1}% ({:.0} MB / {:.0} MB · {:.1} MB/s)", + self.stage, + self.percent(), + dl_mb, + tot_mb, + self.speed_mbps + ) + } else { + format!("{}...", self.stage) + } + } +} + +/// 一键检测硬件与运行环境的完整报告。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HyMtCompatibilityReport { + pub supported: bool, + pub os_arch: String, + pub cpu_brand: String, + pub gpu_backend: String, + pub total_ram_gb: f64, + pub free_disk_gb: f64, + pub recommended_quant: HyMtQuant, + pub runtime_installed: bool, + pub runtime_path: Option, + pub models_dir: String, + pub downloaded_quants: Vec, + pub running_quant: Option, + pub running_port: Option, +} + +impl HyMtCompatibilityReport { + /// 生成面向设置面板展示的两行紧凑状态摘要。 + pub fn summary_text(&self, selected: HyMtQuant) -> String { + let env_icon = if self.supported { "✓" } else { "⚠" }; + let env_line = format!( + "{} {} · 内存 {:.0}GB · 磁盘 {:.0}GB · 推荐 {}", + env_icon, + self.cpu_brand, + self.total_ram_gb, + self.free_disk_gb, + self.recommended_quant.code() + ); + + let model_line = if self.running_quant == Some(selected) { + let port = self.running_port.unwrap_or(PREFERRED_PORT); + format!( + "● 运行中: HY-MT1.5 ({}) · 127.0.0.1:{}", + selected.code(), + port + ) + } else if self.downloaded_quants.contains(&selected) { + format!( + "✓ 已下载: HY-MT1.5 ({}) · 点击「启动服务」运行", + selected.code() + ) + } else { + let size_gb = selected.expected_bytes() as f64 / (1024.0 * 1024.0 * 1024.0); + let size_label = if size_gb >= 1.0 { + format!("{size_gb:.2} GB") + } else { + format!( + "{:.0} MB", + selected.expected_bytes() as f64 / (1024.0 * 1024.0) + ) + }; + format!( + "○ 未下载: {} (约 {}) · 点击「下载并运行」获取", + selected.code(), + size_label + ) + }; + + format!("{env_line}\n{model_line}") + } +} + +struct RunningHyMtServer { + child: Child, + quant: HyMtQuant, + port: u16, + base_url: String, +} + +static RUNNING_SERVER: OnceLock>> = OnceLock::new(); +static STARTUP_LOCK: OnceLock> = OnceLock::new(); +static CUSTOM_MODELS_DIR: OnceLock>> = OnceLock::new(); + +fn server_slot() -> &'static Mutex> { + RUNNING_SERVER.get_or_init(|| Mutex::new(None)) +} + +fn startup_lock() -> &'static tokio::sync::Mutex<()> { + STARTUP_LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +fn custom_models_dir_slot() -> &'static Mutex> { + CUSTOM_MODELS_DIR.get_or_init(|| { + let marker = local_llm_root_dir().join("models-dir.txt"); + let loaded = fs::read_to_string(&marker) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from); + Mutex::new(loaded) + }) +} + +fn download_http_client() -> &'static Client { + static DL_CLIENT: OnceLock = OnceLock::new(); + DL_CLIENT.get_or_init(|| { + Client::builder() + .connect_timeout(Duration::from_secs(10)) + .tcp_keepalive(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::limited(10)) + .user_agent("ttree-local-llm/0.2.0") + .build() + .expect("failed to build download HTTP client") + }) +} + +/// 本地模型与推理引擎的根存储目录:`/com.samwstu.ttree/local-llm` +pub fn local_llm_root_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(APP_BUNDLE_ID) + .join("local-llm") +} + +pub fn runtime_dir() -> PathBuf { + local_llm_root_dir().join("runtime") +} + +/// 获取当前配置的模型存储文件夹(若用户通过弹窗选择过自定义文件夹则优先使用)。 +pub fn models_dir() -> PathBuf { + if let Ok(guard) = custom_models_dir_slot().lock() { + if let Some(custom) = guard.as_ref() { + return custom.clone(); + } + } + local_llm_root_dir().join("models") +} + +/// 设置并持久化用户选择的模型下载保存文件夹。 +pub fn set_custom_models_dir(dir: PathBuf) -> Result { + fs::create_dir_all(&dir).map_err(|e| format!("无法创建所选模型目录 {}: {e}", dir.display()))?; + let root = local_llm_root_dir(); + let _ = fs::create_dir_all(&root); + let marker = root.join("models-dir.txt"); + fs::write(&marker, dir.to_string_lossy().as_bytes()) + .map_err(|e| format!("保存模型目录配置失败: {e}"))?; + if let Ok(mut guard) = custom_models_dir_slot().lock() { + *guard = Some(dir.clone()); + } + Ok(dir) +} + +pub fn model_file_path(quant: HyMtQuant) -> PathBuf { + models_dir().join(quant.file_name()) +} + +pub fn model_part_path(quant: HyMtQuant) -> PathBuf { + models_dir().join(format!("{}.part", quant.file_name())) +} + +/// 检查指定精度的 GGUF 模型是否已完整下载并通过 `GGUF` 魔数校验。 +pub fn is_model_downloaded(quant: HyMtQuant) -> bool { + let path = model_file_path(quant); + validate_gguf_file(&path, quant.expected_bytes()) +} + +fn validate_gguf_file(path: &Path, expected_bytes: u64) -> bool { + let Ok(meta) = fs::metadata(path) else { + return false; + }; + let min_valid = expected_bytes.saturating_mul(95) / 100; + if meta.len() < min_valid.max(1024 * 1024) { + return false; + } + let Ok(mut file) = File::open(path) else { + return false; + }; + let mut magic = [0u8; 4]; + if file.read_exact(&mut magic).is_err() { + return false; + } + &magic == GGUF_MAGIC +} + +/// 列出本地已完整下载的所有 `HyMtQuant` 档位。 +pub fn list_downloaded_quants() -> Vec { + HyMtQuant::ALL + .into_iter() + .filter(|&q| is_model_downloaded(q)) + .collect() +} + +/// 删除指定精度的已下载模型文件(及残留 `.part` 文件),若该精度正在运行则先停止服务。 +pub fn delete_model_quant(quant: HyMtQuant) -> Result<(), String> { + if daemon_status().0 == Some(quant) { + stop_server(); + } + let path = model_file_path(quant); + if path.exists() { + fs::remove_file(&path).map_err(|e| format!("删除模型文件失败: {e}"))?; + } + let part = model_part_path(quant); + if part.exists() { + let _ = fs::remove_file(&part); + } + Ok(()) +} + +/// 查找本机已存在的 `llama-server` 可执行文件路径。 +pub fn resolve_existing_llama_server() -> Option { + let bin_name = if cfg!(target_os = "windows") { + "llama-server.exe" + } else { + "llama-server" + }; + + // 1. 应用托管的 runtime 目录 + let managed = runtime_dir().join(bin_name); + if is_executable_file(&managed) { + return Some(managed); + } + + // 2. 应用包内置同级目录(若打包时附带) + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let bundled = dir.join(bin_name); + if is_executable_file(&bundled) { + return Some(bundled); + } + } + } + + // 3. 常见本机路径(Homebrew / 本地编译) + let candidates = [ + "/opt/homebrew/bin/llama-server", + "/usr/local/bin/llama-server", + "/Volumes/STU/Ai/llama.cpp/build/bin/llama-server", + ]; + for c in candidates { + let p = PathBuf::from(c); + if is_executable_file(&p) { + return Some(p); + } + } + + // 4. 系统 PATH + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + let candidate = dir.join(bin_name); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + } + + None +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(meta) = fs::metadata(path) else { + return false; + }; + if !meta.is_file() || meta.len() < 4096 { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// 查询当前后台 `llama-server` 守护进程状态:`(Some(quant), Some(port))` 或 `(None, None)`。 +pub fn daemon_status() -> (Option, Option) { + let mut guard = server_slot().lock().expect("server lock poisoned"); + if let Some(srv) = guard.as_mut() { + match srv.child.try_wait() { + Ok(None) => return (Some(srv.quant), Some(srv.port)), + _ => { + *guard = None; + } + } + } + (None, None) +} + +/// 停止后台 `llama-server` 守护进程。 +pub fn stop_server() { + let mut guard = server_slot().lock().expect("server lock poisoned"); + if let Some(mut srv) = guard.take() { + let _ = srv.child.kill(); + let _ = srv.child.wait(); + } +} + +/// 执行一键硬件与环境兼容性检测。 +pub fn check_compatibility() -> HyMtCompatibilityReport { + let root = local_llm_root_dir(); + let _ = fs::create_dir_all(&root); + let mdir = models_dir(); + let _ = fs::create_dir_all(&mdir); + + let os_arch = format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH); + let cpu_brand = detect_cpu_brand(); + let gpu_backend = detect_gpu_backend(); + let total_ram_gb = detect_total_ram_gb(); + let free_disk_gb = detect_free_disk_gb(&mdir); + let recommended_quant = HyMtQuant::recommended_for_ram_gb(total_ram_gb.round() as u64); + + let existing_rt = resolve_existing_llama_server(); + let runtime_installed = existing_rt.is_some(); + let runtime_path = existing_rt.map(|p| p.to_string_lossy().to_string()); + let models_dir_str = mdir.to_string_lossy().to_string(); + + let downloaded_quants = list_downloaded_quants(); + let (running_quant, running_port) = daemon_status(); + + let supported = total_ram_gb >= 4.0 && free_disk_gb >= 1.2; + + HyMtCompatibilityReport { + supported, + os_arch, + cpu_brand, + gpu_backend, + total_ram_gb, + free_disk_gb, + recommended_quant, + runtime_installed, + runtime_path, + models_dir: models_dir_str, + downloaded_quants, + running_quant, + running_port, + } +} + +fn detect_cpu_brand() -> String { + #[cfg(target_os = "macos")] + { + if let Ok(out) = Command::new("sysctl") + .args(["-n", "machdep.cpu.brand_string"]) + .output() + { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return s; + } + } + } + #[cfg(target_os = "linux")] + { + if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") { + for line in cpuinfo.lines() { + if let Some((k, v)) = line.split_once(':') { + if k.trim() == "model name" { + return v.trim().to_string(); + } + } + } + } + } + format!("{} CPU", std::env::consts::ARCH) +} + +fn detect_gpu_backend() -> String { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "Apple Metal GPU 加速".to_string() + } else if cfg!(target_os = "macos") { + "macOS Accelerate / CPU".to_string() + } else if cfg!(target_os = "windows") { + "Vulkan / CPU 加速".to_string() + } else { + "CPU / Vulkan 加速".to_string() + } +} + +fn detect_total_ram_gb() -> f64 { + #[cfg(target_os = "macos")] + { + if let Ok(out) = Command::new("sysctl").args(["-n", "hw.memsize"]).output() { + if let Ok(bytes) = String::from_utf8_lossy(&out.stdout).trim().parse::() { + return (bytes as f64) / (1024.0 * 1024.0 * 1024.0); + } + } + } + #[cfg(target_os = "linux")] + { + if let Ok(meminfo) = fs::read_to_string("/proc/meminfo") { + for line in meminfo.lines() { + if let Some(rest) = line.strip_prefix("MemTotal:") { + let kb_str = rest.trim().trim_end_matches("kB").trim(); + if let Ok(kb) = kb_str.parse::() { + return (kb as f64) / (1024.0 * 1024.0); + } + } + } + } + } + 16.0 +} + +fn detect_free_disk_gb(path: &Path) -> f64 { + let check_dir = if path.exists() { + path.to_path_buf() + } else { + std::env::temp_dir() + }; + #[cfg(unix)] + { + if let Ok(out) = Command::new("df").arg("-k").arg(&check_dir).output() { + let stdout = String::from_utf8_lossy(&out.stdout); + if let Some(line) = stdout.lines().nth(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() >= 4 { + if let Ok(avail_kb) = cols[3].parse::() { + return (avail_kb as f64) / (1024.0 * 1024.0); + } + } + } + } + } + 50.0 +} + +/// 平台对应的 `llama.cpp` 官方预编译包下载链接。 +fn platform_llama_cpp_asset_url(tag: &str) -> Result<(&'static str, String), String> { + let asset_suffix = match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => ("tar.gz", format!("llama-{tag}-bin-macos-arm64.tar.gz")), + ("macos", "x86_64") => ("tar.gz", format!("llama-{tag}-bin-macos-x64.tar.gz")), + ("linux", "x86_64") => ("tar.gz", format!("llama-{tag}-bin-ubuntu-x64.tar.gz")), + ("linux", "aarch64") => ("tar.gz", format!("llama-{tag}-bin-ubuntu-arm64.tar.gz")), + ("windows", "x86_64") => ("zip", format!("llama-{tag}-bin-win-vulkan-x64.zip")), + (os, arch) => { + return Err(format!( + "暂不支持自动下载 {os}/{arch} 平台的 llama-server 预编译包" + )) + } + }; + let url = format!( + "https://github.com/ggml-org/llama.cpp/releases/download/{tag}/{}", + asset_suffix.1 + ); + Ok((asset_suffix.0, url)) +} + +/// 确保本机存在可用的 `llama-server` 推理引擎;若不存在则自动从 GitHub Release 下载解压。 +pub async fn ensure_llama_server_runtime( + cancel: &CancelToken, + on_progress: &mut F, +) -> Result +where + F: FnMut(HyMtDownloadProgress) + Send, +{ + if let Some(existing) = resolve_existing_llama_server() { + return Ok(existing); + } + + let rt_dir = runtime_dir(); + fs::create_dir_all(&rt_dir).map_err(|e| format!("创建运行时目录失败: {e}"))?; + + let (ext, url) = platform_llama_cpp_asset_url(LLAMA_CPP_PINNED_TAG)?; + let archive_path = rt_dir.join(format!("llama-runtime.{ext}")); + + download_file_with_progress( + &url, + None, + &archive_path, + None, + "正在下载轻量推理引擎 (llama-server)", + cancel, + on_progress, + ) + .await?; + + on_progress(HyMtDownloadProgress { + stage: "正在解压并配置推理引擎".to_string(), + downloaded_bytes: 1, + total_bytes: 1, + speed_mbps: 0.0, + }); + + if ext == "tar.gz" { + let status = Command::new("tar") + .arg("-xzf") + .arg(&archive_path) + .arg("-C") + .arg(&rt_dir) + .arg("--strip-components=1") + .status() + .map_err(|e| format!("解压推理引擎失败: {e}"))?; + if !status.success() { + return Err("解压 llama-server 归档失败".to_string()); + } + } else if ext == "zip" { + let status = Command::new("tar") + .arg("-xf") + .arg(&archive_path) + .arg("-C") + .arg(&rt_dir) + .status() + .map_err(|e| format!("解压推理引擎 zip 失败: {e}"))?; + if !status.success() { + return Err("解压 llama-server zip 归档失败".to_string()); + } + } + + let _ = fs::remove_file(&archive_path); + + let bin_name = if cfg!(target_os = "windows") { + "llama-server.exe" + } else { + "llama-server" + }; + let server_bin = rt_dir.join(bin_name); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if server_bin.exists() { + let _ = fs::set_permissions(&server_bin, fs::Permissions::from_mode(0o755)); + } + } + + #[cfg(target_os = "macos")] + { + let _ = Command::new("xattr").arg("-cr").arg(&rt_dir).status(); + } + + if is_executable_file(&server_bin) { + Ok(server_bin) + } else { + Err("解压完成但未找到可执行的 llama-server 二进制文件".to_string()) + } +} + +/// 从 Hugging Face 下载指定精度的 `HY-MT1.5-1.8B` GGUF 模型文件(支持断点续传与魔数校验)。 +pub async fn download_model_quant( + quant: HyMtQuant, + cancel: &CancelToken, + on_progress: &mut F, +) -> Result +where + F: FnMut(HyMtDownloadProgress) + Send, +{ + let target_path = model_file_path(quant); + if is_model_downloaded(quant) { + on_progress(HyMtDownloadProgress { + stage: format!("模型 {} 已就绪", quant.code()), + downloaded_bytes: quant.expected_bytes(), + total_bytes: quant.expected_bytes(), + speed_mbps: 0.0, + }); + return Ok(target_path); + } + + let dir = models_dir(); + fs::create_dir_all(&dir).map_err(|e| format!("创建模型目录失败: {e}"))?; + + let primary_url = if let Ok(custom_ep) = std::env::var("HF_ENDPOINT") { + let ep = custom_ep.trim().trim_end_matches('/'); + quant.hf_url().replace("https://huggingface.co", ep) + } else { + quant.hf_url().to_string() + }; + let mirror_url = quant + .hf_url() + .replace("https://huggingface.co", "https://hf-mirror.com"); + + let part_path = model_part_path(quant); + let stage_title = format!("正在下载 HY-MT1.5-1.8B ({})", quant.code()); + + download_file_with_progress( + &primary_url, + Some(&mirror_url), + &part_path, + Some(quant.expected_bytes()), + &stage_title, + cancel, + on_progress, + ) + .await?; + + if !validate_gguf_file(&part_path, quant.expected_bytes()) { + let _ = fs::remove_file(&part_path); + return Err(format!( + "下载的 {} 模型文件校验未通过(非有效 GGUF 或文件不完整)", + quant.code() + )); + } + + fs::rename(&part_path, &target_path).map_err(|e| format!("保存模型文件失败: {e}"))?; + Ok(target_path) +} + +async fn download_file_with_progress( + primary_url: &str, + fallback_url: Option<&str>, + dest_path: &Path, + expected_total: Option, + stage_label: &str, + cancel: &CancelToken, + on_progress: &mut F, +) -> Result<(), String> +where + F: FnMut(HyMtDownloadProgress) + Send, +{ + let mut existing_len = fs::metadata(dest_path).map(|m| m.len()).unwrap_or(0); + if let Some(exp) = expected_total { + if existing_len > exp { + let _ = fs::remove_file(dest_path); + existing_len = 0; + } + } + + let send_req = |url: &str, range_start: u64| { + let mut req = download_http_client().get(url); + if range_start > 0 { + req = req.header(reqwest::header::RANGE, format!("bytes={range_start}-")); + } + req + }; + + let resp = match tokio::select! { + biased; + _ = cancel.cancelled() => return Err("下载已取消".to_string()), + r = send_req(primary_url, existing_len).send() => r, + } { + Ok(r) if r.status().is_success() || r.status().as_u16() == 206 => r, + Ok(r) if r.status().as_u16() == 416 => { + // Range 越界,重置文件重下 + let _ = fs::remove_file(dest_path); + existing_len = 0; + tokio::select! { + biased; + _ = cancel.cancelled() => return Err("下载已取消".to_string()), + r2 = send_req(primary_url, 0).send() => r2.map_err(|e| format!("下载请求失败: {e}"))?, + } + } + Ok(r) => { + if let Some(fb) = fallback_url { + tokio::select! { + biased; + _ = cancel.cancelled() => return Err("下载已取消".to_string()), + r2 = send_req(fb, existing_len).send() => r2.map_err(|e| format!("Hugging Face 下载失败 ({}): {e}", r.status()))?, + } + } else { + return Err(format!("下载服务器返回状态 {}", r.status())); + } + } + Err(err) => { + if let Some(fb) = fallback_url { + tokio::select! { + biased; + _ = cancel.cancelled() => return Err("下载已取消".to_string()), + r2 = send_req(fb, existing_len).send() => r2.map_err(|e| format!("连接 Hugging Face 失败 ({err}),备用节点亦失败: {e}"))?, + } + } else { + return Err(format!("网络连接失败: {err}")); + } + } + }; + + if !resp.status().is_success() && resp.status().as_u16() != 206 { + return Err(format!("下载返回 HTTP {}", resp.status())); + } + + let is_partial = resp.status().as_u16() == 206; + if !is_partial { + existing_len = 0; + } + + let content_len = resp.content_length().unwrap_or(0); + let total_bytes = expected_total.unwrap_or_else(|| { + if content_len > 0 { + existing_len + content_len + } else { + 0 + } + }); + + let mut file = if is_partial && existing_len > 0 { + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(dest_path) + .map_err(|e| format!("打开断点续传文件失败: {e}"))?; + let _ = f.seek(SeekFrom::End(0)); + f + } else { + File::create(dest_path).map_err(|e| format!("创建下载文件失败: {e}"))? + }; + + let mut downloaded = existing_len; + let start_instant = Instant::now(); + let start_bytes = existing_len; + let mut last_report = Instant::now() - Duration::from_secs(1); + + on_progress(HyMtDownloadProgress { + stage: stage_label.to_string(), + downloaded_bytes: downloaded, + total_bytes, + speed_mbps: 0.0, + }); + + let mut stream = resp.bytes_stream(); + loop { + let next = tokio::select! { + biased; + _ = cancel.cancelled() => { + let _ = file.flush(); + return Err("下载已取消(已保存断点进度,下次可继续下载)".to_string()); + } + chunk = stream.next() => chunk, + }; + let Some(chunk) = next else { + break; + }; + let bytes = chunk.map_err(|e| format!("下载数据流中断: {e}(可再次点击继续断点续传)"))?; + file.write_all(&bytes) + .map_err(|e| format!("写入磁盘失败: {e}"))?; + downloaded += bytes.len() as u64; + + let now = Instant::now(); + if now.duration_since(last_report) >= Duration::from_millis(120) { + last_report = now; + let elapsed = now.duration_since(start_instant).as_secs_f64().max(0.05); + let delta_mb = (downloaded.saturating_sub(start_bytes)) as f64 / (1024.0 * 1024.0); + on_progress(HyMtDownloadProgress { + stage: stage_label.to_string(), + downloaded_bytes: downloaded, + total_bytes, + speed_mbps: delta_mb / elapsed, + }); + } + } + + file.flush().map_err(|e| format!("刷新磁盘缓存失败: {e}"))?; + on_progress(HyMtDownloadProgress { + stage: stage_label.to_string(), + downloaded_bytes: downloaded, + total_bytes: total_bytes.max(downloaded), + speed_mbps: 0.0, + }); + Ok(()) +} + +fn pick_available_port() -> Result { + if TcpListener::bind(("127.0.0.1", PREFERRED_PORT)).is_ok() { + return Ok(PREFERRED_PORT); + } + let listener = + TcpListener::bind(("127.0.0.1", 0)).map_err(|e| format!("分配本地端口失败: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("读取本地端口失败: {e}"))? + .port(); + Ok(port) +} + +/// 一键下载(若尚未下载)并启动指定精度的 `HY-MT1.5-1.8B` 服务。 +pub async fn download_and_start( + quant: HyMtQuant, + cancel: &CancelToken, + mut on_progress: F, +) -> Result +where + F: FnMut(HyMtDownloadProgress) + Send, +{ + let _server_bin = ensure_llama_server_runtime(cancel, &mut on_progress).await?; + let _model_path = download_model_quant(quant, cancel, &mut on_progress).await?; + on_progress(HyMtDownloadProgress { + stage: format!("正在启动 HY-MT1.5-1.8B ({}) 本地推理服务", quant.code()), + downloaded_bytes: 1, + total_bytes: 1, + speed_mbps: 0.0, + }); + ensure_server_running(quant, cancel).await +} + +/// 确保指定精度的 `llama-server` 守护进程正在运行,返回其 OpenAI 兼容 `base_url`(如 `http://127.0.0.1:28190/v1`)。 +pub async fn ensure_server_running( + quant: HyMtQuant, + cancel: &CancelToken, +) -> Result { + // 快路径:检查是否已在运行相同精度 + { + let mut guard = server_slot().lock().expect("server lock poisoned"); + if let Some(srv) = guard.as_mut() { + if srv.quant == quant && matches!(srv.child.try_wait(), Ok(None)) { + return Ok(srv.base_url.clone()); + } + } + } + + let _guard = startup_lock().lock().await; + + // 二次检查 + { + let mut guard = server_slot().lock().expect("server lock poisoned"); + if let Some(srv) = guard.as_mut() { + if srv.quant == quant && matches!(srv.child.try_wait(), Ok(None)) { + return Ok(srv.base_url.clone()); + } + // 精度不同或已退出,清理旧进程 + if let Some(mut old) = guard.take() { + let _ = old.child.kill(); + let _ = old.child.wait(); + } + } + } + + if !is_model_downloaded(quant) { + return Err(format!( + "内置模型 HY-MT1.5-1.8B ({}) 尚未下载,请先在「设置 → AI 翻译」点击「一键下载并运行」", + quant.code() + )); + } + + let mut noop_progress = |_: HyMtDownloadProgress| {}; + let server_bin = ensure_llama_server_runtime(cancel, &mut noop_progress).await?; + let model_path = model_file_path(quant); + let port = pick_available_port()?; + + let root = local_llm_root_dir(); + let _ = fs::create_dir_all(&root); + let log_path = root.join("llama-server.log"); + let log_file = File::create(&log_path).unwrap_or_else(|_| File::open("/dev/null").unwrap()); + let log_err = log_file + .try_clone() + .unwrap_or_else(|_| File::open("/dev/null").unwrap()); + + let mut cmd = Command::new(&server_bin); + cmd.arg("-m") + .arg(&model_path) + .arg("--host") + .arg("127.0.0.1") + .arg("--port") + .arg(port.to_string()) + .arg("--ctx-size") + .arg("4096") + .arg("-ngl") + .arg("auto") + .arg("--temp") + .arg("0.7") + .arg("--top-k") + .arg("20") + .arg("--top-p") + .arg("0.6") + .arg("--repeat-penalty") + .arg("1.05") + .arg("--no-webui") + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(log_err)); + + if let Some(bin_dir) = server_bin.parent() { + #[cfg(target_os = "macos")] + cmd.env("DYLD_LIBRARY_PATH", bin_dir); + #[cfg(target_os = "linux")] + cmd.env("LD_LIBRARY_PATH", bin_dir); + } + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let child = cmd + .spawn() + .map_err(|e| format!("启动 llama-server 失败 ({}): {e}", server_bin.display()))?; + + let base_url = format!("http://127.0.0.1:{port}/v1"); + let health_url = format!("http://127.0.0.1:{port}/health"); + + { + let mut slot = server_slot().lock().expect("server lock poisoned"); + *slot = Some(RunningHyMtServer { + child, + quant, + port, + base_url: base_url.clone(), + }); + } + + // 轮询 /health 等待模型加载就绪 + let deadline = Instant::now() + Duration::from_secs(45); + loop { + if cancel.is_cancelled() { + stop_server(); + return Err("启动已取消".to_string()); + } + + // 检查子进程是否异常退出 + { + let mut slot = server_slot().lock().expect("server lock poisoned"); + if let Some(srv) = slot.as_mut() { + if let Ok(Some(status)) = srv.child.try_wait() { + *slot = None; + let tail = fs::read_to_string(&log_path) + .unwrap_or_default() + .lines() + .rev() + .take(5) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" | "); + return Err(format!("llama-server 意外退出 ({status}): {tail}")); + } + } + } + + if let Ok(resp) = download_http_client() + .get(&health_url) + .timeout(Duration::from_millis(800)) + .send() + .await + { + if resp.status().is_success() { + return Ok(base_url); + } + } + + if Instant::now() >= deadline { + stop_server(); + return Err("等待内置 HY-MT1.5 模型加载超时(45s)".to_string()); + } + + tokio::time::sleep(Duration::from_millis(150)).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compatibility_report_detects_system_resources() { + let report = check_compatibility(); + assert!(!report.os_arch.is_empty()); + assert!(!report.cpu_brand.is_empty()); + assert!(report.total_ram_gb >= 1.0); + assert!(report.free_disk_gb >= 0.1); + let summary = report.summary_text(HyMtQuant::Q8_0); + assert!(summary.contains("内存")); + assert!(summary.contains("Q8_0")); + } + + #[test] + fn validates_gguf_magic_and_size() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.gguf"); + { + let mut f = File::create(&file_path).unwrap(); + f.write_all(b"GGUF").unwrap(); + f.write_all(&vec![0u8; 2 * 1024 * 1024]).unwrap(); + } + assert!(validate_gguf_file(&file_path, 2 * 1024 * 1024)); + assert!(!validate_gguf_file(&file_path, 100 * 1024 * 1024)); + } +} diff --git a/crates/ttree-ai/src/lib.rs b/crates/ttree-ai/src/lib.rs index 26dfa4c..9e631a7 100644 --- a/crates/ttree-ai/src/lib.rs +++ b/crates/ttree-ai/src/lib.rs @@ -11,12 +11,13 @@ pub mod gguf; pub mod http_provider; +pub mod hy_mt_manager; pub mod sse; use ttree_core::language::language_display_name; use ttree_core::{ - AiBackendKind, AiTranslationConfig, CancelToken, ChatAiConfig, ChatMessage, - validate_language_pair, validate_messages, + validate_language_pair, validate_messages, AiBackendKind, AiTranslationConfig, CancelToken, + ChatAiConfig, ChatMessage, HyMtQuant, }; pub use http_provider::discover_local_models; @@ -46,7 +47,7 @@ fn hy_mt_target_english_name(target: &str) -> &'static str { } /// 组装翻译消息: -/// - 若模型为腾讯混元专用翻译模型 `HY-MT`(或端口 18082)且使用默认提示词,自动采用 HY-MT 官方单轮 User 指令模板; +/// - 若模型为腾讯混元专用翻译模型 `HY-MT`(内置后端或端口 18082)且使用默认提示词,自动采用 HY-MT 官方单轮 User 指令模板; /// - 若自定义提示词包含 `{text}` 占位符,渲染为单条 User 消息; /// - 其余通用大模型:System 放指令,User 仅放待译原文(省去 50% 冗余 Prompt Token)。 pub fn build_translate_messages( @@ -63,7 +64,8 @@ pub fn build_translate_messages( return vec![ChatMessage::user(rendered)]; } - let is_hy_mt = cfg.model.to_ascii_lowercase().contains("hy-mt") + let is_hy_mt = cfg.backend == AiBackendKind::BuiltInHyMt + || cfg.model.to_ascii_lowercase().contains("hy-mt") || cfg.gguf_model_path.to_ascii_lowercase().contains("hy-mt") || cfg.base_url.contains(":18082"); @@ -127,6 +129,7 @@ where dispatch_stream( cfg.backend, + cfg.hy_mt_quant, &cfg.base_url, &cfg.api_key, &cfg.model, @@ -157,6 +160,7 @@ where dispatch_stream( cfg.backend, + cfg.hy_mt_quant, &cfg.base_url, &cfg.api_key, &cfg.model, @@ -172,6 +176,7 @@ where #[allow(clippy::too_many_arguments)] async fn dispatch_stream( backend: AiBackendKind, + hy_mt_quant: HyMtQuant, base_url: &str, api_key: &str, model: &str, @@ -185,9 +190,24 @@ where F: FnMut(&str) + Send, { match backend { + AiBackendKind::BuiltInHyMt => { + let local_base_url = hy_mt_manager::ensure_server_running(hy_mt_quant, cancel).await?; + http_provider::stream_openai_compatible( + &local_base_url, + None, + "hy-mt1.5-1.8b", + messages, + cancel, + on_delta, + ) + .await + } AiBackendKind::OpenAiCompatible => { if api_key.trim().is_empty() { - return Err("未配置云端 API Key(可在设置中填入,或切换为本地 Ollama / LM Studio / GGUF 模型)".to_string()); + return Err( + "未配置云端 API Key(可在设置中填入,或切换为内置 HY-MT1.5 / 本地模型)" + .to_string(), + ); } http_provider::stream_openai_compatible( base_url, @@ -205,12 +225,7 @@ where 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, + base_url, opt_key, model, messages, cancel, on_delta, ) .await } @@ -260,11 +275,9 @@ mod tests { let to_en = build_translate_messages(&cfg, "你好世界", "zh-CN", "en"); assert_eq!(to_en.len(), 1); assert_eq!(to_en[0].role, "user"); - assert!( - to_en[0] - .content - .starts_with("Translate the following segment into English") - ); + assert!(to_en[0] + .content + .starts_with("Translate the following segment into English")); assert!(to_en[0].content.ends_with("你好世界")); } } diff --git a/crates/ttree-ai/src/sse.rs b/crates/ttree-ai/src/sse.rs index 3a23507..539de1d 100644 --- a/crates/ttree-ai/src/sse.rs +++ b/crates/ttree-ai/src/sse.rs @@ -22,8 +22,7 @@ pub fn parse_sse_line(line: &str) -> Result, String> { if data == "[DONE]" { return Ok(Some(StreamDelta::Done)); } - let v: Value = - serde_json::from_str(data).map_err(|e| format!("SSE JSON 解析失败: {e}"))?; + 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)) @@ -163,7 +162,10 @@ mod tests { 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()) + .push( + "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: [DONE]\n" + .as_bytes(), + ) .unwrap(); assert_eq!( out, diff --git a/crates/ttree-app/src/state.rs b/crates/ttree-app/src/state.rs index 4e4f5f2..73e62e3 100644 --- a/crates/ttree-app/src/state.rs +++ b/crates/ttree-app/src/state.rs @@ -23,21 +23,21 @@ use tokio::sync::mpsc::UnboundedSender; use ttree_core::{ recent_chat_context, resolve_language_pair, AiBackendKind, AiTranslationConfig, - AppearanceConfig, CancelSlot, ChatAiConfig, ChatMessage, Conversation, PaneLayoutConfig, - ShortcutConfig, ADAPTIVE_TARGET, PANEL_OPACITY_MAX, PANEL_OPACITY_MIN, SOURCE_LANGUAGES, - TARGET_LANGUAGES, TOP_BAR_HEIGHT, WINDOW_MAX_HEIGHT, WINDOW_MAX_WIDTH, WINDOW_MIN_HEIGHT, - WINDOW_MIN_WIDTH, + AppearanceConfig, CancelSlot, ChatAiConfig, ChatMessage, Conversation, HyMtQuant, + PaneLayoutConfig, ShortcutConfig, ADAPTIVE_TARGET, PANEL_OPACITY_MAX, PANEL_OPACITY_MIN, + SOURCE_LANGUAGES, TARGET_LANGUAGES, TOP_BAR_HEIGHT, WINDOW_MAX_HEIGHT, WINDOW_MAX_WIDTH, + WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, }; use ttree_ocr::OcrOutcome; use ttree_platform_macos::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction}; use ttree_storage::{HistoryStore, SettingsStore}; use crate::ui_helpers::{ - add_subview, append_text_view_string, clean_markdown_for_bubble, clear_subviews, - copy_to_pasteboard, get_field_string, get_text_view_string, make_button, make_checkbox, - make_divider, make_flat_button, make_flipped_view, make_label, make_multiline_input, - make_popup, make_scrollable_text_view, make_secure_field, make_slider, make_split_bar, - make_surface_box, make_symbol_button, make_text_field, make_wrapping_label, + add_subview, append_text_view_string, apply_text_view_line_spacing, clean_markdown_for_bubble, + clear_subviews, copy_to_pasteboard, get_field_string, get_text_view_string, make_button, + make_checkbox, make_divider, make_flat_button, make_flipped_view, make_label, + make_multiline_input, make_popup, make_scrollable_text_view, make_secure_field, make_slider, + make_split_bar, make_surface_box, make_symbol_button, make_text_field, make_wrapping_label, make_wrapping_message_label, pale_ink, set_button_symbol, set_field_string, set_flat_button_active, set_label_alignment_right, set_multiline_input_frame, set_multiline_input_hidden, set_split_bar_frame, set_text_view_string, set_text_view_styled, @@ -121,11 +121,20 @@ pub enum ActionTag { ResetDefaultShortcuts = 50, OpacitySliderChanged = 51, AutostartToggled = 52, + OcrStripNewlinesToggled = 53, SettingsSecTranslate = 60, SettingsSecChat = 61, SettingsSecShortcuts = 62, SettingsSecGeneral = 63, + + HyMtQuantChanged = 70, + HyMtCheckCompatibility = 71, + HyMtDownloadAndRun = 72, + HyMtStopServer = 73, + HyMtDeleteModel = 74, + ChatHyMtQuantChanged = 75, + ChatHyMtDownloadAndRun = 76, } impl ActionTag { @@ -171,10 +180,18 @@ impl ActionTag { 50 => Some(Self::ResetDefaultShortcuts), 51 => Some(Self::OpacitySliderChanged), 52 => Some(Self::AutostartToggled), + 53 => Some(Self::OcrStripNewlinesToggled), 60 => Some(Self::SettingsSecTranslate), 61 => Some(Self::SettingsSecChat), 62 => Some(Self::SettingsSecShortcuts), 63 => Some(Self::SettingsSecGeneral), + 70 => Some(Self::HyMtQuantChanged), + 71 => Some(Self::HyMtCheckCompatibility), + 72 => Some(Self::HyMtDownloadAndRun), + 73 => Some(Self::HyMtStopServer), + 74 => Some(Self::HyMtDeleteModel), + 75 => Some(Self::ChatHyMtQuantChanged), + 76 => Some(Self::ChatHyMtDownloadAndRun), _ => None, } } @@ -217,6 +234,13 @@ pub enum UiEvent { for_chat: bool, result: Result, String>, }, + HyMtProgress { + progress: ttree_ai::hy_mt_manager::HyMtDownloadProgress, + }, + HyMtActionFinished { + quant: HyMtQuant, + result: Result, + }, } /// 翻译视图(原版四层上下分栏:上方原文输入框 → 语言选择条 → 谷歌/微软译文框 → AI 译文框,每栏间均可上下拖拽调整高度) @@ -286,6 +310,18 @@ pub struct SettingsViewWidgets { pub t_lbl_backend: Retained, pub ai_backend_popup: Retained, pub btn_ai_def_url: Retained, + // BuiltInHyMt 专属控件(全精度选择 + 一键检测 + 一键下载运行 + 停止 + 删除 + 状态卡片) + pub t_lbl_hymt_quant: Retained, + pub ai_hymt_quant_popup: Retained, + pub t_lbl_hymt_action: Retained, + pub btn_hymt_check: Retained, + pub btn_hymt_download_run: Retained, + pub btn_hymt_stop: Retained, + pub btn_hymt_delete: Retained, + pub t_lbl_hymt_status: Retained, + pub hymt_info_surface: Retained, + pub hymt_info_label: Retained, + pub t_lbl_url: Retained, pub ai_base_url_field: Retained, pub t_lbl_key: Retained, @@ -312,6 +348,9 @@ pub struct SettingsViewWidgets { pub c_lbl_backend: Retained, pub chat_backend_popup: Retained, pub btn_chat_def_url: Retained, + pub c_lbl_hymt_quant: Retained, + pub chat_hymt_quant_popup: Retained, + pub btn_chat_hymt_run: Retained, pub c_lbl_url: Retained, pub chat_base_url_field: Retained, pub c_lbl_key: Retained, @@ -351,6 +390,7 @@ pub struct SettingsViewWidgets { pub opacity_slider: Retained, pub opacity_val_label: Retained, pub autostart_chk: Retained, + pub ocr_strip_newlines_chk: Retained, pub ver_label: Retained, pub gen_hint: Retained, @@ -412,6 +452,10 @@ pub struct AppController { pub current_messages: Vec, pub streaming_assistant_buffer: String, + pub hymt_cancel: CancelSlot, + pub hymt_downloading: bool, + pub last_hymt_report: Option, + pub pending_clear_ai_key: bool, pub pending_clear_chat_key: bool, @@ -562,8 +606,7 @@ impl AppController { dispatcher, action_sel, ); - let chat_ui = - Self::build_chat_view(mtm, body_frame, &pane_layout, dispatcher, action_sel); + let chat_ui = Self::build_chat_view(mtm, body_frame, &pane_layout, dispatcher, action_sel); let settings_ui = Self::build_settings_view(mtm, body_frame, &settings, dispatcher, action_sel); @@ -634,6 +677,9 @@ impl AppController { active_conversation_id, current_messages, streaming_assistant_buffer: String::new(), + hymt_cancel: CancelSlot::new(), + hymt_downloading: false, + last_hymt_report: None, pending_clear_ai_key: false, pending_clear_chat_key: false, pane_layout, @@ -642,6 +688,7 @@ impl AppController { pending_resize_save: None, }; + ctrl.refresh_hymt_ui_state(); ctrl.update_window_min_max_constraints(); ctrl.refresh_ai_header_badge(); ctrl.refresh_conversations_popup(); @@ -674,8 +721,7 @@ impl AppController { let ai_h = layout.ai_height; // 1. 上方翻译输入框 - let source_surface = - make_surface_box(mtm, top_rect(h, 0.0, 0.0, w, source_h), 0.0, 0.25); + let source_surface = make_surface_box(mtm, top_rect(h, 0.0, 0.0, w, source_h), 0.0, 0.25); let (input_scroll, input_tv) = make_scrollable_text_view( mtm, top_rect(h, 2.0, 2.0, w - 4.0, (source_h - 6.0).max(40.0)), @@ -704,8 +750,7 @@ impl AppController { ); // 2. 下方选择语言条 - let lang_surface = - make_surface_box(mtm, top_rect(h, 0.0, source_h, w, lang_h), 0.0, 0.35); + let lang_surface = make_surface_box(mtm, top_rect(h, 0.0, source_h, w, lang_h), 0.0, 0.35); let src_labels: Vec<&str> = SOURCE_LANGUAGES.iter().map(|l| l.label).collect(); let tgt_labels: Vec<&str> = TARGET_LANGUAGES.iter().map(|l| l.label).collect(); let popup_top = source_h + ((lang_h - 24.0) * 0.5).max(2.0); @@ -785,7 +830,13 @@ impl AppController { let (web_scroll, web_tv) = make_scrollable_text_view( mtm, - top_rect(h, 2.0, results_top + 22.0, w - 4.0, (web_h - 26.0).max(40.0)), + top_rect( + h, + 2.0, + results_top + 22.0, + w - 4.0, + (web_h - 26.0).max(40.0), + ), false, 14.5, None, @@ -947,10 +998,8 @@ impl AppController { let composer_h = layout.chat_input_height; let transcript_h = (h - hist_h - composer_h).max(120.0); - let transcript_scroll = NSScrollView::initWithFrame( - mtm.alloc(), - top_rect(h, 0.0, hist_h, w, transcript_h), - ); + let transcript_scroll = + NSScrollView::initWithFrame(mtm.alloc(), top_rect(h, 0.0, hist_h, w, transcript_h)); transcript_scroll.setHasVerticalScroller(true); transcript_scroll.setHasHorizontalScroller(false); transcript_scroll.setDrawsBackground(false); @@ -980,7 +1029,13 @@ impl AppController { ); let (input_scroll, input_tv) = make_scrollable_text_view( mtm, - top_rect(h, 6.0, composer_top + 6.0, w - 88.0, (composer_h - 12.0).max(40.0)), + top_rect( + h, + 6.0, + composer_top + 6.0, + w - 88.0, + (composer_h - 12.0).max(40.0), + ), true, 14.0, Some(dispatcher), @@ -995,7 +1050,13 @@ impl AppController { ); let send_btn = make_button( mtm, - top_rect(h, w - 74.0, composer_top + (composer_h - 28.0) * 0.5, 62.0, 28.0), + top_rect( + h, + w - 74.0, + composer_top + (composer_h - 28.0) * 0.5, + 62.0, + 28.0, + ), "发送", ActionTag::SendChat as isize, dispatcher, @@ -1127,13 +1188,23 @@ impl AppController { .position(|&b| b == chat_cfg.backend) .unwrap_or(0); + let quant_labels: Vec<&str> = HyMtQuant::ALL.iter().map(|q| q.label()).collect(); + let ai_quant_idx = HyMtQuant::ALL + .iter() + .position(|&q| q == ai_cfg.hy_mt_quant) + .unwrap_or(6); + let chat_quant_idx = HyMtQuant::ALL + .iter() + .position(|&q| q == chat_cfg.hy_mt_quant) + .unwrap_or(6); + let dummy = top_rect(sec_h, 0.0, 0.0, 100.0, 24.0); // ── Section 1: AI 翻译(根据所选接入方式动态展示对应字段,多行提示词大框) ── let t_head = make_label( mtm, dummy, - "AI 翻译(云端 / 本地服务 / GGUF)", + "AI 翻译(内置 HY-MT1.5 / 云端 / 本地模型)", 12.5, true, true, @@ -1167,6 +1238,60 @@ impl AppController { action_sel, ); + let t_lbl_hymt_quant = make_label(mtm, dummy, "模型精度", 12.5, false, true); + let ai_hymt_quant_popup = make_popup( + mtm, + dummy, + &quant_labels, + ai_quant_idx, + true, + ActionTag::HyMtQuantChanged as isize, + dispatcher, + action_sel, + ); + let t_lbl_hymt_action = make_label(mtm, dummy, "服务操作", 12.5, false, true); + let btn_hymt_check = make_button( + mtm, + dummy, + "检测环境", + ActionTag::HyMtCheckCompatibility as isize, + dispatcher, + action_sel, + ); + let btn_hymt_download_run = make_button( + mtm, + dummy, + "下载并运行", + ActionTag::HyMtDownloadAndRun as isize, + dispatcher, + action_sel, + ); + let btn_hymt_stop = make_button( + mtm, + dummy, + "停止", + ActionTag::HyMtStopServer as isize, + dispatcher, + action_sel, + ); + let btn_hymt_delete = make_button( + mtm, + dummy, + "删除", + ActionTag::HyMtDeleteModel as isize, + dispatcher, + action_sel, + ); + let t_lbl_hymt_status = make_label(mtm, dummy, "环境状态", 12.5, false, true); + let hymt_info_surface = make_surface_box(mtm, dummy, 6.0, 0.35); + let hymt_info_label = make_wrapping_label( + mtm, + dummy, + "点击「检测环境」检查内存与磁盘,或点击「下载并运行」从 Hugging Face 获取模型。", + 11.0, + true, + ); + let t_lbl_url = make_label(mtm, dummy, "接口地址", 12.5, false, true); let ai_base_url_field = make_text_field( mtm, @@ -1188,7 +1313,11 @@ impl AppController { let ai_key_status_label = make_label( mtm, dummy, - if ai_cfg.has_api_key { "已保存" } else { "未设置" }, + if ai_cfg.has_api_key { + "已保存" + } else { + "未设置" + }, 11.5, false, true, @@ -1289,6 +1418,16 @@ impl AppController { add_subview(&sec_translate, &t_lbl_backend); add_subview(&sec_translate, &ai_backend_popup); add_subview(&sec_translate, &btn_ai_def_url); + add_subview(&sec_translate, &t_lbl_hymt_quant); + add_subview(&sec_translate, &ai_hymt_quant_popup); + add_subview(&sec_translate, &t_lbl_hymt_action); + add_subview(&sec_translate, &btn_hymt_check); + add_subview(&sec_translate, &btn_hymt_download_run); + add_subview(&sec_translate, &btn_hymt_stop); + add_subview(&sec_translate, &btn_hymt_delete); + add_subview(&sec_translate, &t_lbl_hymt_status); + add_subview(&sec_translate, &hymt_info_surface); + add_subview(&sec_translate, &hymt_info_label); add_subview(&sec_translate, &t_lbl_url); add_subview(&sec_translate, &ai_base_url_field); add_subview(&sec_translate, &t_lbl_key); @@ -1346,6 +1485,25 @@ impl AppController { dispatcher, action_sel, ); + let c_lbl_hymt_quant = make_label(mtm, dummy, "模型精度", 12.5, false, true); + let chat_hymt_quant_popup = make_popup( + mtm, + dummy, + &quant_labels, + chat_quant_idx, + true, + ActionTag::ChatHyMtQuantChanged as isize, + dispatcher, + action_sel, + ); + let btn_chat_hymt_run = make_button( + mtm, + dummy, + "下载并运行", + ActionTag::ChatHyMtDownloadAndRun as isize, + dispatcher, + action_sel, + ); let c_lbl_url = make_label(mtm, dummy, "接口地址", 12.5, false, true); let chat_base_url_field = make_text_field( @@ -1368,7 +1526,11 @@ impl AppController { let chat_key_status_label = make_label( mtm, dummy, - if chat_cfg.has_api_key { "已保存" } else { "未设置" }, + if chat_cfg.has_api_key { + "已保存" + } else { + "未设置" + }, 11.5, false, true, @@ -1453,6 +1615,9 @@ impl AppController { add_subview(&sec_chat, &c_lbl_backend); add_subview(&sec_chat, &chat_backend_popup); add_subview(&sec_chat, &btn_chat_def_url); + add_subview(&sec_chat, &c_lbl_hymt_quant); + add_subview(&sec_chat, &chat_hymt_quant_popup); + add_subview(&sec_chat, &btn_chat_hymt_run); add_subview(&sec_chat, &c_lbl_url); add_subview(&sec_chat, &chat_base_url_field); add_subview(&sec_chat, &c_lbl_key); @@ -1482,8 +1647,7 @@ impl AppController { action_sel, ); let sc_lbl_toggle = make_label(mtm, dummy, "呼出翻译", 12.5, false, true); - let sc_toggle_field = - make_text_field(mtm, dummy, &sc_cfg.toggle, "CmdOrCtrl+Shift+Space"); + let sc_toggle_field = make_text_field(mtm, dummy, &sc_cfg.toggle, "CmdOrCtrl+Shift+Space"); let sc_lbl_ocr = make_label(mtm, dummy, "截图 OCR", 12.5, false, true); let sc_ocr_field = make_text_field(mtm, dummy, &sc_cfg.ocr, "CmdOrCtrl+Shift+S"); let sc_lbl_ai = make_label(mtm, dummy, "呼出 AI 对话", 12.5, false, true); @@ -1564,6 +1728,15 @@ impl AppController { dispatcher, action_sel, ); + let ocr_strip_newlines_chk = make_checkbox( + mtm, + dummy, + "OCR 识别文字自动去掉换行(智能合并为单段)", + app_cfg.ocr_strip_newlines, + ActionTag::OcrStripNewlinesToggled as isize, + dispatcher, + action_sel, + ); let ver_label = make_label( mtm, dummy, @@ -1574,7 +1747,7 @@ impl AppController { ); let gen_hint = make_wrapping_label( mtm, - top_rect(sec_h, 0.0, 176.0, content_w, 88.0), + top_rect(sec_h, 0.0, 204.0, content_w, 88.0), "调整面板透明度可让底层桌面毛玻璃透出。\n截图 OCR 需要 macOS「屏幕录制」权限;划词翻译优先使用「辅助功能」权限,未授权时会临时使用剪贴板回退。\n所有配置与分栏尺寸均保存在本地 JSON,不再调用 macOS 钥匙串。", 11.5, true, @@ -1584,6 +1757,7 @@ impl AppController { add_subview(&sec_general, &opacity_val_label); add_subview(&sec_general, &opacity_slider); add_subview(&sec_general, &autostart_chk); + add_subview(&sec_general, &ocr_strip_newlines_chk); add_subview(&sec_general, &ver_label); add_subview(&sec_general, &gen_hint); @@ -1619,6 +1793,16 @@ impl AppController { t_lbl_backend, ai_backend_popup, btn_ai_def_url, + t_lbl_hymt_quant, + ai_hymt_quant_popup, + t_lbl_hymt_action, + btn_hymt_check, + btn_hymt_download_run, + btn_hymt_stop, + btn_hymt_delete, + t_lbl_hymt_status, + hymt_info_surface, + hymt_info_label, t_lbl_url, ai_base_url_field, t_lbl_key, @@ -1643,6 +1827,9 @@ impl AppController { c_lbl_backend, chat_backend_popup, btn_chat_def_url, + c_lbl_hymt_quant, + chat_hymt_quant_popup, + btn_chat_hymt_run, c_lbl_url, chat_base_url_field, c_lbl_key, @@ -1678,6 +1865,7 @@ impl AppController { opacity_slider, opacity_val_label, autostart_chk, + ocr_strip_newlines_chk, ver_label, gen_hint, settings_status_label, @@ -1818,7 +2006,9 @@ impl AppController { match tab { TabMode::Translate => { - let _ = self.window.makeFirstResponder(Some(&self.translate_ui.input_tv)); + let _ = self + .window + .makeFirstResponder(Some(&self.translate_ui.input_tv)); } TabMode::Chat => { let _ = self.window.makeFirstResponder(Some(&self.chat_ui.input_tv)); @@ -1858,7 +2048,9 @@ impl AppController { self.window.makeKeyAndOrderFront(None); match self.active_tab { TabMode::Translate => { - let _ = self.window.makeFirstResponder(Some(&self.translate_ui.input_tv)); + let _ = self + .window + .makeFirstResponder(Some(&self.translate_ui.input_tv)); } TabMode::Chat => { let _ = self.window.makeFirstResponder(Some(&self.chat_ui.input_tv)); @@ -1887,6 +2079,9 @@ impl AppController { "AI (未开启)".to_string() } else { match cfg.backend { + AiBackendKind::BuiltInHyMt => { + format!("AI · HY-MT1.5 ({})", cfg.hy_mt_quant.code()) + } AiBackendKind::OpenAiCompatible => format!("AI · {short_model}"), AiBackendKind::Ollama => format!("AI · OLLAMA ({short_model})"), AiBackendKind::LocalServer => format!("AI · MLX/LOCAL ({short_model})"), @@ -2110,6 +2305,7 @@ impl AppController { self.show_and_focus_window(); } ActionTag::TabSettings => { + self.refresh_hymt_ui_state(); self.switch_tab(TabMode::Settings); self.show_and_focus_window(); } @@ -2117,9 +2313,11 @@ impl AppController { self.switch_tab(self.last_main_mode); } ActionTag::SettingsSecTranslate => { + self.refresh_hymt_ui_state(); self.switch_settings_section(SettingsSection::Translate); } ActionTag::SettingsSecChat => { + self.refresh_hymt_ui_state(); self.switch_settings_section(SettingsSection::Chat); } ActionTag::SettingsSecShortcuts => { @@ -2131,6 +2329,7 @@ impl AppController { ActionTag::TriggerOcr => self.trigger_ocr(), ActionTag::TogglePin => self.set_pinned(!self.is_pinned), ActionTag::TrayQuit => { + ttree_ai::hy_mt_manager::stop_server(); let app = NSApplication::sharedApplication(self.mtm); app.terminate(None); } @@ -2270,17 +2469,23 @@ impl AppController { } ActionTag::AiTransBackendChanged => { let backend = self.selected_ai_trans_backend(); - let cur_url = get_field_string(&self.settings_ui.ai_base_url_field); - if cur_url.trim().is_empty() - || cur_url == "https://api.openai.com/v1" - || cur_url == "http://127.0.0.1:11434" - || cur_url == "http://127.0.0.1:1234/v1" - { - set_field_string( - &self.settings_ui.ai_base_url_field, - backend.default_base_url(), - ); + if !matches!( + backend, + AiBackendKind::BuiltInHyMt | AiBackendKind::CandleGguf + ) { + let cur_url = get_field_string(&self.settings_ui.ai_base_url_field); + if cur_url.trim().is_empty() + || cur_url == "https://api.openai.com/v1" + || cur_url == "http://127.0.0.1:11434" + || cur_url == "http://127.0.0.1:1234/v1" + { + set_field_string( + &self.settings_ui.ai_base_url_field, + backend.default_base_url(), + ); + } } + self.refresh_hymt_ui_state(); let (w, h) = self.last_known_size; self.relayout_settings_view(w, (h - TOP_BAR_HEIGHT).max(200.0)); } @@ -2303,7 +2508,13 @@ impl AppController { ActionTag::AiTransBrowseGguf => { if let Some(path) = self.pick_file(&["gguf"]) { set_field_string(&self.settings_ui.ai_gguf_field, &path); - self.settings_ui.ai_backend_popup.selectItemAtIndex(3); + let gguf_idx = AiBackendKind::ALL + .iter() + .position(|&b| b == AiBackendKind::CandleGguf) + .unwrap_or(4); + self.settings_ui + .ai_backend_popup + .selectItemAtIndex(gguf_idx as isize); let (w, h) = self.last_known_size; self.relayout_settings_view(w, (h - TOP_BAR_HEIGHT).max(200.0)); } @@ -2320,17 +2531,23 @@ impl AppController { } ActionTag::ChatBackendChanged => { let backend = self.selected_chat_backend(); - let cur_url = get_field_string(&self.settings_ui.chat_base_url_field); - if cur_url.trim().is_empty() - || cur_url == "https://api.openai.com/v1" - || cur_url == "http://127.0.0.1:11434" - || cur_url == "http://127.0.0.1:1234/v1" - { - set_field_string( - &self.settings_ui.chat_base_url_field, - backend.default_base_url(), - ); + if !matches!( + backend, + AiBackendKind::BuiltInHyMt | AiBackendKind::CandleGguf + ) { + let cur_url = get_field_string(&self.settings_ui.chat_base_url_field); + if cur_url.trim().is_empty() + || cur_url == "https://api.openai.com/v1" + || cur_url == "http://127.0.0.1:11434" + || cur_url == "http://127.0.0.1:1234/v1" + { + set_field_string( + &self.settings_ui.chat_base_url_field, + backend.default_base_url(), + ); + } } + self.refresh_hymt_ui_state(); let (w, h) = self.last_known_size; self.relayout_settings_view(w, (h - TOP_BAR_HEIGHT).max(200.0)); } @@ -2353,7 +2570,13 @@ impl AppController { ActionTag::ChatBrowseGguf => { if let Some(path) = self.pick_file(&["gguf"]) { set_field_string(&self.settings_ui.chat_gguf_field, &path); - self.settings_ui.chat_backend_popup.selectItemAtIndex(3); + let gguf_idx = AiBackendKind::ALL + .iter() + .position(|&b| b == AiBackendKind::CandleGguf) + .unwrap_or(4); + self.settings_ui + .chat_backend_popup + .selectItemAtIndex(gguf_idx as isize); let (w, h) = self.last_known_size; self.relayout_settings_view(w, (h - TOP_BAR_HEIGHT).max(200.0)); } @@ -2361,6 +2584,10 @@ impl AppController { ActionTag::ChatCopyFromTrans => { let idx = self.settings_ui.ai_backend_popup.indexOfSelectedItem(); self.settings_ui.chat_backend_popup.selectItemAtIndex(idx); + let q_idx = self.settings_ui.ai_hymt_quant_popup.indexOfSelectedItem(); + self.settings_ui + .chat_hymt_quant_popup + .selectItemAtIndex(q_idx); set_field_string( &self.settings_ui.chat_base_url_field, &get_field_string(&self.settings_ui.ai_base_url_field), @@ -2395,13 +2622,12 @@ impl AppController { self.set_status("已填入默认快捷键,请点击「保存快捷键」"); } ActionTag::OpacitySliderChanged => { - let val = AppearanceConfig::clamp_opacity( - self.settings_ui.opacity_slider.doubleValue(), - ); + let val = + AppearanceConfig::clamp_opacity(self.settings_ui.opacity_slider.doubleValue()); self.window.setAlphaValue(val); - let _ = self.settings.save_appearance(AppearanceConfig { - panel_opacity: val, - }); + let mut app_cfg = self.settings.appearance(); + app_cfg.panel_opacity = val; + let _ = self.settings.save_appearance(app_cfg); set_field_string( &self.settings_ui.opacity_val_label, &format!("{:.0}%", val * 100.0), @@ -2411,7 +2637,65 @@ impl AppController { let want = self.settings_ui.autostart_chk.state() == NSControlStateValueOn; let _ = ttree_platform_macos::autostart::set_enabled(want); } + ActionTag::OcrStripNewlinesToggled => { + let want = self.settings_ui.ocr_strip_newlines_chk.state() == NSControlStateValueOn; + let mut app_cfg = self.settings.appearance(); + app_cfg.ocr_strip_newlines = want; + let _ = self.settings.save_appearance(app_cfg); + self.set_status(if want { + "✓ 已开启 OCR 自动去掉换行" + } else { + "已关闭 OCR 自动去掉换行(保留原始多行格式)" + }); + } ActionTag::SaveSettings => self.save_all_settings(), + ActionTag::HyMtQuantChanged | ActionTag::ChatHyMtQuantChanged => { + self.refresh_hymt_ui_state(); + } + ActionTag::HyMtCheckCompatibility => { + let report = self.refresh_hymt_ui_state(); + if report.supported { + self.set_status(&format!( + "✓ 环境支持 HY-MT1.5:{} ({:.0} GB 内存) · 推荐精度 {}", + report.cpu_brand, + report.total_ram_gb, + report.recommended_quant.code() + )); + } else { + self.set_status(&format!( + "⚠ 环境资源紧张(内存 {:.1} GB / 可用磁盘 {:.1} GB),建议选择 Q2_K 轻量精度", + report.total_ram_gb, report.free_disk_gb + )); + } + } + ActionTag::HyMtDownloadAndRun => { + let quant = self.selected_ai_hymt_quant(); + self.trigger_hymt_download_and_run(quant, false); + } + ActionTag::ChatHyMtDownloadAndRun => { + let quant = self.selected_chat_hymt_quant(); + self.trigger_hymt_download_and_run(quant, true); + } + ActionTag::HyMtStopServer => { + ttree_ai::hy_mt_manager::stop_server(); + self.refresh_hymt_ui_state(); + self.set_status("✓ 已停止内置 HY-MT1.5 后台推理服务"); + } + ActionTag::HyMtDeleteModel => { + let quant = self.selected_ai_hymt_quant(); + match ttree_ai::hy_mt_manager::delete_model_quant(quant) { + Ok(()) => { + self.refresh_hymt_ui_state(); + self.set_status(&format!( + "✓ 已删除本地模型 HY-MT1.5-1.8B ({})", + quant.code() + )); + } + Err(e) => { + self.set_status(&format!("❌ {e}")); + } + } + } } } @@ -2448,6 +2732,153 @@ impl AppController { .unwrap_or(AiBackendKind::OpenAiCompatible) } + fn selected_ai_hymt_quant(&self) -> HyMtQuant { + let idx = self.settings_ui.ai_hymt_quant_popup.indexOfSelectedItem(); + HyMtQuant::ALL + .get(idx.max(0) as usize) + .copied() + .unwrap_or(HyMtQuant::Q8_0) + } + + fn selected_chat_hymt_quant(&self) -> HyMtQuant { + let idx = self.settings_ui.chat_hymt_quant_popup.indexOfSelectedItem(); + HyMtQuant::ALL + .get(idx.max(0) as usize) + .copied() + .unwrap_or(HyMtQuant::Q8_0) + } + + pub fn refresh_hymt_ui_state(&mut self) -> ttree_ai::hy_mt_manager::HyMtCompatibilityReport { + let report = ttree_ai::hy_mt_manager::check_compatibility(); + let ai_q = self.selected_ai_hymt_quant(); + let chat_q = self.selected_chat_hymt_quant(); + + let rebuild_quant_popup = |popup: &NSPopUpButton, current: HyMtQuant| { + popup.removeAllItems(); + for q in HyMtQuant::ALL { + let tag = if report.running_quant == Some(q) { + " · 运行中" + } else if report.downloaded_quants.contains(&q) { + " · 已下载" + } else { + "" + }; + let title = format!("{}{}", q.label(), tag); + popup.addItemWithTitle(&NSString::from_str(&title)); + } + if let Some(pos) = HyMtQuant::ALL.iter().position(|&q| q == current) { + popup.selectItemAtIndex(pos as isize); + } + }; + + rebuild_quant_popup(&self.settings_ui.ai_hymt_quant_popup, ai_q); + rebuild_quant_popup(&self.settings_ui.chat_hymt_quant_popup, chat_q); + + if !self.hymt_downloading { + let summary = report.summary_text(ai_q); + set_field_string(&self.settings_ui.hymt_info_label, &summary); + + let dl_btn_title = if report.running_quant == Some(ai_q) { + "● 运行中" + } else if report.downloaded_quants.contains(&ai_q) { + "启动服务" + } else { + "下载并运行" + }; + self.settings_ui + .btn_hymt_download_run + .setTitle(&NSString::from_str(dl_btn_title)); + + let chat_btn_title = if report.running_quant == Some(chat_q) { + "● 运行中" + } else if report.downloaded_quants.contains(&chat_q) { + "启动服务" + } else { + "下载并运行" + }; + self.settings_ui + .btn_chat_hymt_run + .setTitle(&NSString::from_str(chat_btn_title)); + } else { + self.settings_ui + .btn_hymt_download_run + .setTitle(&NSString::from_str("取消下载")); + self.settings_ui + .btn_chat_hymt_run + .setTitle(&NSString::from_str("取消下载")); + } + + self.settings_ui + .btn_hymt_stop + .setEnabled(report.running_quant.is_some()); + self.settings_ui + .btn_hymt_delete + .setEnabled(report.downloaded_quants.contains(&ai_q) && !self.hymt_downloading); + + self.last_hymt_report = Some(report.clone()); + report + } + + fn trigger_hymt_download_and_run(&mut self, quant: HyMtQuant, for_chat: bool) { + if self.hymt_downloading { + self.hymt_cancel.cancel_current(); + self.hymt_downloading = false; + self.refresh_hymt_ui_state(); + self.set_status("已取消下载(断点进度已保留,再次点击可继续)"); + return; + } + + // 若当前所选精度尚未在当前目录下载完成,先弹窗让用户选择/确认模型保存文件夹 + if !ttree_ai::hy_mt_manager::is_model_downloaded(quant) { + let Some(chosen_dir) = self.pick_hymt_download_dir(quant) else { + self.set_status("已取消选择模型下载文件夹"); + return; + }; + if let Err(e) = ttree_ai::hy_mt_manager::set_custom_models_dir(chosen_dir) { + self.set_status(&format!("❌ {e}")); + return; + } + self.refresh_hymt_ui_state(); + } + + // 自动保存当前选择的内置模型与精度配置,无需用户额外点击“保存配置” + if for_chat { + let mut chat_cfg = self.settings.chat_ai_for_request(); + chat_cfg.backend = AiBackendKind::BuiltInHyMt; + chat_cfg.hy_mt_quant = quant; + let _ = self.settings.save_chat_ai(&chat_cfg); + } else { + let mut ai_cfg = self.settings.ai_translation_for_request(); + ai_cfg.enabled = true; + self.settings_ui + .ai_enabled_chk + .setState(NSControlStateValueOn); + ai_cfg.backend = AiBackendKind::BuiltInHyMt; + ai_cfg.hy_mt_quant = quant; + let _ = self.settings.save_ai_translation(&ai_cfg); + self.refresh_ai_header_badge(); + } + + let cancel_token = self.hymt_cancel.next(); + self.hymt_downloading = true; + self.refresh_hymt_ui_state(); + self.set_status(&format!( + "正在准备 HY-MT1.5-1.8B ({}) → {}…", + quant.code(), + ttree_ai::hy_mt_manager::models_dir().display() + )); + + let tx = self.ui_tx.clone(); + self.rt.spawn(async move { + let progress_tx = tx.clone(); + let res = ttree_ai::hy_mt_manager::download_and_start(quant, &cancel_token, move |p| { + let _ = progress_tx.send(UiEvent::HyMtProgress { progress: p }); + }) + .await; + let _ = tx.send(UiEvent::HyMtActionFinished { quant, result: res }); + }); + } + fn discover_models(&mut self, for_chat: bool) { let (backend, base_url, api_key) = if for_chat { let typed_key = get_field_string(&self.settings_ui.chat_key_field); @@ -2486,6 +2917,42 @@ impl AppController { }); } + fn pick_hymt_download_dir(&mut self, quant: HyMtQuant) -> Option { + self.is_modal_open = true; + let panel = NSOpenPanel::openPanel(self.mtm); + panel.setCanChooseFiles(false); + panel.setCanChooseDirectories(true); + panel.setCanCreateDirectories(true); + panel.setAllowsMultipleSelection(false); + panel.setTitle(Some(&NSString::from_str("选择模型下载保存文件夹"))); + let size_gb = quant.expected_bytes() as f64 / (1024.0 * 1024.0 * 1024.0); + let msg = format!( + "请选择存放混元翻译模型 {} ({:.2} GB) 的文件夹:", + quant.file_name(), + size_gb + ); + panel.setMessage(Some(&NSString::from_str(&msg))); + panel.setPrompt(Some(&NSString::from_str("选择此文件夹并下载"))); + + let current_dir = ttree_ai::hy_mt_manager::models_dir(); + let _ = std::fs::create_dir_all(¤t_dir); + if let Some(dir_str) = current_dir.to_str() { + let ns_path = NSString::from_str(dir_str); + let url = objc2_foundation::NSURL::fileURLWithPath(&ns_path); + panel.setDirectoryURL(Some(&url)); + } + + let response = panel.runModal(); + self.is_modal_open = false; + if response != NSModalResponseOK { + return None; + } + let urls = panel.URLs(); + let url = urls.firstObject()?; + let path = url.path()?.to_string(); + Some(std::path::PathBuf::from(path)) + } + fn pick_file(&mut self, allowed_exts: &[&str]) -> Option { self.is_modal_open = true; let panel = NSOpenPanel::openPanel(self.mtm); @@ -2517,6 +2984,7 @@ impl AppController { let ai_input = AiTranslationConfig { enabled: self.settings_ui.ai_enabled_chk.state() == NSControlStateValueOn, backend: self.selected_ai_trans_backend(), + hy_mt_quant: self.selected_ai_hymt_quant(), base_url: get_field_string(&self.settings_ui.ai_base_url_field), has_api_key: false, api_key: get_field_string(&self.settings_ui.ai_key_field), @@ -2534,6 +3002,7 @@ impl AppController { let chat_input = ChatAiConfig { backend: self.selected_chat_backend(), + hy_mt_quant: self.selected_chat_hymt_quant(), base_url: get_field_string(&self.settings_ui.chat_base_url_field), has_api_key: false, api_key: get_field_string(&self.settings_ui.chat_key_field), @@ -2580,12 +3049,20 @@ impl AppController { set_field_string(&self.settings_ui.ai_key_field, ""); set_field_string( &self.settings_ui.ai_key_status_label, - if saved_ai.has_api_key { "已保存" } else { "未设置" }, + if saved_ai.has_api_key { + "已保存" + } else { + "未设置" + }, ); set_field_string(&self.settings_ui.chat_key_field, ""); set_field_string( &self.settings_ui.chat_key_status_label, - if saved_chat.has_api_key { "已保存" } else { "未设置" }, + if saved_chat.has_api_key { + "已保存" + } else { + "未设置" + }, ); self.refresh_ai_header_badge(); self.set_status("✓ 已保存"); @@ -2837,7 +3314,12 @@ impl AppController { } } UiEvent::OcrCompleted { outcome } => match outcome { - Ok(OcrOutcome::Text(text)) => { + Ok(OcrOutcome::Text(raw_text)) => { + let text = if self.settings.appearance().ocr_strip_newlines { + ttree_core::normalize_ocr_newlines(&raw_text) + } else { + raw_text + }; self.switch_tab(TabMode::Translate); self.show_and_focus_window(); set_text_view_string(&self.translate_ui.input_tv, &text); @@ -2964,6 +3446,27 @@ impl AppController { self.set_status(&format!("⚠️ {err}")); } }, + UiEvent::HyMtProgress { progress } => { + let text = progress.format_status(); + set_field_string(&self.settings_ui.hymt_info_label, &text); + self.set_status(&text); + } + UiEvent::HyMtActionFinished { quant, result } => { + self.hymt_downloading = false; + self.refresh_hymt_ui_state(); + self.refresh_ai_header_badge(); + match result { + Ok(url) => { + self.set_status(&format!( + "✓ HY-MT1.5-1.8B ({}) 已启动并就绪 ({url})", + quant.code() + )); + } + Err(err) => { + self.set_status(&format!("⚠️ {err}")); + } + } + } } } @@ -2976,6 +3479,7 @@ impl AppController { .input_placeholder .setHidden(!current_src.is_empty()); if current_src != self.last_observed_source { + apply_text_view_line_spacing(&self.translate_ui.input_tv, 5.5); self.last_observed_source = current_src; self.pending_debounce_since = Some(Instant::now()); } @@ -2991,7 +3495,9 @@ impl AppController { } TabMode::Chat => { let chat_in = get_text_view_string(&self.chat_ui.input_tv); - self.chat_ui.input_placeholder.setHidden(!chat_in.is_empty()); + self.chat_ui + .input_placeholder + .setHidden(!chat_in.is_empty()); } TabMode::Settings => {} } @@ -3045,10 +3551,7 @@ impl AppController { return; }; let bounds = content_view.bounds(); - let w = bounds - .size - .width - .clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); + let w = bounds.size.width.clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); let h = bounds .size .height @@ -3075,10 +3578,8 @@ impl AppController { } pub fn relayout_all(&self, w: f64, h: f64) { - self.effect_view.setFrame(NSRect::new( - NSPoint::new(0.0, 0.0), - NSSize::new(w, h), - )); + self.effect_view + .setFrame(NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(w, h))); // 顶栏重排(距顶部边缘 10px–13px,距左右边缘 16px–18px) self.segmented_bg @@ -3185,12 +3686,20 @@ impl AppController { ), ); - self.translate_ui - .web_header_label - .setFrame(top_rect(body_h, 16.0, results_top + 7.0, 240.0, 15.0)); - self.translate_ui - .web_copy_btn - .setFrame(top_rect(body_h, w - 58.0, results_top + 4.0, 46.0, 20.0)); + self.translate_ui.web_header_label.setFrame(top_rect( + body_h, + 16.0, + results_top + 7.0, + 240.0, + 15.0, + )); + self.translate_ui.web_copy_btn.setFrame(top_rect( + body_h, + w - 58.0, + results_top + 4.0, + 46.0, + 20.0, + )); self.translate_ui.web_scroll.setFrame(top_rect( body_h, 2.0, @@ -3221,12 +3730,20 @@ impl AppController { self.translate_ui.ai_copy_btn.setHidden(false); } - self.translate_ui - .ai_header_label - .setFrame(top_rect(body_h, 16.0, ai_top + 7.0, 280.0, 15.0)); - self.translate_ui - .ai_copy_btn - .setFrame(top_rect(body_h, w - 58.0, ai_top + 4.0, 46.0, 20.0)); + self.translate_ui.ai_header_label.setFrame(top_rect( + body_h, + 16.0, + ai_top + 7.0, + 280.0, + 15.0, + )); + self.translate_ui.ai_copy_btn.setFrame(top_rect( + body_h, + w - 58.0, + ai_top + 4.0, + 46.0, + 20.0, + )); self.translate_ui.ai_scroll.setFrame(top_rect( body_h, 2.0, @@ -3246,7 +3763,11 @@ impl AppController { let body_frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(w, body_h)); self.chat_ui.root.setFrame(body_frame); let hist_h = 38.0; - let hist_offset = if self.show_chat_history_bar { hist_h } else { 0.0 }; + let hist_offset = if self.show_chat_history_bar { + hist_h + } else { + 0.0 + }; self.chat_ui .history_bar .setFrame(top_rect(body_h, 0.0, 0.0, w, hist_h)); @@ -3256,13 +3777,9 @@ impl AppController { self.chat_ui .history_label .setFrame(top_rect(hist_h, 14.0, 10.0, 56.0, 18.0)); - self.chat_ui.conv_popup.setFrame(top_rect( - hist_h, - 72.0, - 6.0, - (w - 216.0).max(110.0), - 25.0, - )); + self.chat_ui + .conv_popup + .setFrame(top_rect(hist_h, 72.0, 6.0, (w - 216.0).max(110.0), 25.0)); self.chat_ui .new_conv_btn .setFrame(top_rect(hist_h, w - 136.0, 7.0, 64.0, 24.0)); @@ -3311,9 +3828,13 @@ impl AppController { w - 88.0, (composer_h - 12.0).max(40.0), )); - self.chat_ui - .input_placeholder - .setFrame(top_rect(body_h, 20.0, composer_top + 15.0, 240.0, 20.0)); + self.chat_ui.input_placeholder.setFrame(top_rect( + body_h, + 20.0, + composer_top + 15.0, + 240.0, + 20.0, + )); self.chat_ui.send_btn.setFrame(top_rect( body_h, w - 74.0, @@ -3332,21 +3853,41 @@ impl AppController { .top_divider .setFrame(top_rect(body_h, 12.0, 0.0, w - 24.0, 1.0)); let sidebar_w = 114.0; - self.settings_ui - .sidebar_divider - .setFrame(top_rect(body_h, sidebar_w, 1.0, 1.0, body_h - 1.0)); - self.settings_ui - .nav_translate_btn - .setFrame(top_rect(body_h, 10.0, 14.0, sidebar_w - 20.0, 32.0)); - self.settings_ui - .nav_chat_btn - .setFrame(top_rect(body_h, 10.0, 50.0, sidebar_w - 20.0, 32.0)); - self.settings_ui - .nav_shortcuts_btn - .setFrame(top_rect(body_h, 10.0, 86.0, sidebar_w - 20.0, 32.0)); - self.settings_ui - .nav_general_btn - .setFrame(top_rect(body_h, 10.0, 122.0, sidebar_w - 20.0, 32.0)); + self.settings_ui.sidebar_divider.setFrame(top_rect( + body_h, + sidebar_w, + 1.0, + 1.0, + body_h - 1.0, + )); + self.settings_ui.nav_translate_btn.setFrame(top_rect( + body_h, + 10.0, + 14.0, + sidebar_w - 20.0, + 32.0, + )); + self.settings_ui.nav_chat_btn.setFrame(top_rect( + body_h, + 10.0, + 50.0, + sidebar_w - 20.0, + 32.0, + )); + self.settings_ui.nav_shortcuts_btn.setFrame(top_rect( + body_h, + 10.0, + 86.0, + sidebar_w - 20.0, + 32.0, + )); + self.settings_ui.nav_general_btn.setFrame(top_rect( + body_h, + 10.0, + 122.0, + sidebar_w - 20.0, + 32.0, + )); let content_x = sidebar_w + 18.0; let content_w = (w - content_x - 18.0).max(280.0); @@ -3368,11 +3909,13 @@ impl AppController { let lbl_w = 72.0; let field_x = 78.0; let field_w = (content_w - field_x).max(160.0); - let row_step = 38.0; + let row_step = 36.0; // ── 1. AI 翻译分区(按所选接入方式动态排布,底部留出宽敞多行提示词编辑框) ── let ai_backend = self.selected_ai_trans_backend(); + let ai_is_hymt = ai_backend == AiBackendKind::BuiltInHyMt; let ai_is_gguf = ai_backend == AiBackendKind::CandleGguf; + let ai_is_http = !ai_is_hymt && !ai_is_gguf; let ai_needs_key = matches!( ai_backend, AiBackendKind::OpenAiCompatible | AiBackendKind::LocalServer @@ -3393,11 +3936,11 @@ impl AppController { 22.0, )); - let mut y = 36.0; + let mut y = 34.0; self.settings_ui .t_lbl_backend .setFrame(top_rect(sec_h, 0.0, y + 3.0, lbl_w, 18.0)); - if ai_is_gguf { + if !ai_is_http { self.settings_ui.btn_ai_def_url.setHidden(true); self.settings_ui .ai_backend_popup @@ -3411,16 +3954,97 @@ impl AppController { (field_w - 74.0).max(120.0), 26.0, )); - self.settings_ui - .btn_ai_def_url - .setFrame(top_rect(sec_h, content_w - 68.0, y, 68.0, 25.0)); + self.settings_ui.btn_ai_def_url.setFrame(top_rect( + sec_h, + content_w - 68.0, + y, + 68.0, + 25.0, + )); } y += row_step; + // 内置混元 HY-MT1.5 模式:与其它模式完全统一的左右两栏对齐(左 72px 标签 + 右 field_x..content_w 控件区) + self.settings_ui.t_lbl_hymt_quant.setHidden(!ai_is_hymt); + self.settings_ui.ai_hymt_quant_popup.setHidden(!ai_is_hymt); + self.settings_ui.t_lbl_hymt_action.setHidden(!ai_is_hymt); + self.settings_ui.btn_hymt_check.setHidden(!ai_is_hymt); + self.settings_ui + .btn_hymt_download_run + .setHidden(!ai_is_hymt); + self.settings_ui.btn_hymt_stop.setHidden(!ai_is_hymt); + self.settings_ui.btn_hymt_delete.setHidden(!ai_is_hymt); + self.settings_ui.t_lbl_hymt_status.setHidden(!ai_is_hymt); + self.settings_ui.hymt_info_surface.setHidden(!ai_is_hymt); + self.settings_ui.hymt_info_label.setHidden(!ai_is_hymt); + + if ai_is_hymt { + self.settings_ui + .t_lbl_hymt_quant + .setFrame(top_rect(sec_h, 0.0, y + 3.0, lbl_w, 18.0)); + self.settings_ui + .ai_hymt_quant_popup + .setFrame(top_rect(sec_h, field_x, y, field_w, 26.0)); + y += row_step; + + self.settings_ui + .t_lbl_hymt_action + .setFrame(top_rect(sec_h, 0.0, y + 3.0, lbl_w, 18.0)); + let gap = 6.0; + let w_check = 66.0; + let w_stop = 44.0; + let w_del = 44.0; + let w_run = (field_w - w_check - w_stop - w_del - gap * 3.0).max(74.0); + self.settings_ui + .btn_hymt_check + .setFrame(top_rect(sec_h, field_x, y, w_check, 25.0)); + self.settings_ui.btn_hymt_download_run.setFrame(top_rect( + sec_h, + field_x + w_check + gap, + y, + w_run, + 25.0, + )); + self.settings_ui.btn_hymt_stop.setFrame(top_rect( + sec_h, + content_w - w_del - gap - w_stop, + y, + w_stop, + 25.0, + )); + self.settings_ui.btn_hymt_delete.setFrame(top_rect( + sec_h, + content_w - w_del, + y, + w_del, + 25.0, + )); + y += row_step; + + self.settings_ui + .t_lbl_hymt_status + .setFrame(top_rect(sec_h, 0.0, y + 3.0, lbl_w, 18.0)); + let card_h = 42.0; + self.settings_ui + .hymt_info_surface + .setFrame(top_rect(sec_h, field_x, y, field_w, card_h)); + set_wrapping_label_frame( + &self.settings_ui.hymt_info_label, + top_rect( + sec_h, + field_x + 8.0, + y + 5.0, + (field_w - 16.0).max(120.0), + card_h - 8.0, + ), + ); + y += card_h + 10.0; + } + // HTTP 模式:显示接口地址 + 模型名称;云端 API 或本地 MLX/LM Studio 显示 API 密钥(可选) - self.settings_ui.t_lbl_url.setHidden(ai_is_gguf); - self.settings_ui.ai_base_url_field.setHidden(ai_is_gguf); - if !ai_is_gguf { + self.settings_ui.t_lbl_url.setHidden(!ai_is_http); + self.settings_ui.ai_base_url_field.setHidden(!ai_is_http); + if ai_is_http { self.settings_ui .t_lbl_url .setFrame(top_rect(sec_h, 0.0, y + 3.0, lbl_w, 18.0)); @@ -3454,17 +4078,21 @@ impl AppController { 50.0, 18.0, )); - self.settings_ui - .btn_ai_clr_key - .setFrame(top_rect(sec_h, content_w - 58.0, y, 58.0, 25.0)); + self.settings_ui.btn_ai_clr_key.setFrame(top_rect( + sec_h, + content_w - 58.0, + y, + 58.0, + 25.0, + )); y += row_step; } - self.settings_ui.t_lbl_model.setHidden(ai_is_gguf); - self.settings_ui.ai_model_field.setHidden(ai_is_gguf); - self.settings_ui.btn_ai_discover.setHidden(ai_is_gguf); - self.settings_ui.ai_discovered_popup.setHidden(ai_is_gguf); - if !ai_is_gguf { + self.settings_ui.t_lbl_model.setHidden(!ai_is_http); + self.settings_ui.ai_model_field.setHidden(!ai_is_http); + self.settings_ui.btn_ai_discover.setHidden(!ai_is_http); + self.settings_ui.ai_discovered_popup.setHidden(!ai_is_http); + if ai_is_http { self.settings_ui .t_lbl_model .setFrame(top_rect(sec_h, 0.0, y + 3.0, lbl_w, 18.0)); @@ -3475,12 +4103,20 @@ impl AppController { (field_w - 148.0).max(100.0), 25.0, )); - self.settings_ui - .btn_ai_discover - .setFrame(top_rect(sec_h, content_w - 142.0, y, 68.0, 25.0)); - self.settings_ui - .ai_discovered_popup - .setFrame(top_rect(sec_h, content_w - 70.0, y, 70.0, 25.0)); + self.settings_ui.btn_ai_discover.setFrame(top_rect( + sec_h, + content_w - 142.0, + y, + 68.0, + 25.0, + )); + self.settings_ui.ai_discovered_popup.setFrame(top_rect( + sec_h, + content_w - 70.0, + y, + 70.0, + 25.0, + )); y += row_step; } @@ -3524,25 +4160,27 @@ impl AppController { } // 多行翻译提示词大编辑框(上方标题 + 下方全宽多行滚动框) - y += 4.0; + y += 2.0; self.settings_ui .t_lbl_prompt .setFrame(top_rect(sec_h, 0.0, y, content_w, 18.0)); - y += 22.0; - let ai_prompt_h = (sec_h - y - 42.0).clamp(84.0, 220.0); + y += 20.0; + let ai_prompt_h = (sec_h - y - 40.0).clamp(64.0, 220.0); set_multiline_input_frame( &self.settings_ui.ai_prompt_input, top_rect(sec_h, 0.0, y, content_w, ai_prompt_h), ); set_multiline_input_hidden(&self.settings_ui.ai_prompt_input, false); - y += ai_prompt_h + 10.0; + y += ai_prompt_h + 8.0; self.settings_ui .save_ai_btn .setFrame(top_rect(sec_h, 0.0, y, 104.0, 30.0)); // ── 2. AI 对话分区(按所选接入方式动态排布,底部留出宽敞多行系统提示词编辑框) ── let chat_backend = self.selected_chat_backend(); + let chat_is_hymt = chat_backend == AiBackendKind::BuiltInHyMt; let chat_is_gguf = chat_backend == AiBackendKind::CandleGguf; + let chat_is_http = !chat_is_hymt && !chat_is_gguf; let chat_needs_key = matches!( chat_backend, AiBackendKind::OpenAiCompatible | AiBackendKind::LocalServer @@ -3555,15 +4193,19 @@ impl AppController { (content_w - 146.0).max(150.0), 20.0, )); - self.settings_ui - .btn_sync_from_trans - .setFrame(top_rect(sec_h, content_w - 138.0, 2.0, 138.0, 25.0)); + self.settings_ui.btn_sync_from_trans.setFrame(top_rect( + sec_h, + content_w - 138.0, + 2.0, + 138.0, + 25.0, + )); - let mut cy = 36.0; + let mut cy = 34.0; self.settings_ui .c_lbl_backend .setFrame(top_rect(sec_h, 0.0, cy + 3.0, lbl_w, 18.0)); - if chat_is_gguf { + if !chat_is_http { self.settings_ui.btn_chat_def_url.setHidden(true); self.settings_ui .chat_backend_popup @@ -3577,15 +4219,47 @@ impl AppController { (field_w - 74.0).max(120.0), 26.0, )); - self.settings_ui - .btn_chat_def_url - .setFrame(top_rect(sec_h, content_w - 68.0, cy, 68.0, 25.0)); + self.settings_ui.btn_chat_def_url.setFrame(top_rect( + sec_h, + content_w - 68.0, + cy, + 68.0, + 25.0, + )); } cy += row_step; - self.settings_ui.c_lbl_url.setHidden(chat_is_gguf); - self.settings_ui.chat_base_url_field.setHidden(chat_is_gguf); - if !chat_is_gguf { + self.settings_ui.c_lbl_hymt_quant.setHidden(!chat_is_hymt); + self.settings_ui + .chat_hymt_quant_popup + .setHidden(!chat_is_hymt); + self.settings_ui.btn_chat_hymt_run.setHidden(!chat_is_hymt); + if chat_is_hymt { + self.settings_ui + .c_lbl_hymt_quant + .setFrame(top_rect(sec_h, 0.0, cy + 3.0, lbl_w, 18.0)); + self.settings_ui.chat_hymt_quant_popup.setFrame(top_rect( + sec_h, + field_x, + cy, + (field_w - 118.0).max(120.0), + 26.0, + )); + self.settings_ui.btn_chat_hymt_run.setFrame(top_rect( + sec_h, + content_w - 112.0, + cy, + 112.0, + 26.0, + )); + cy += row_step; + } + + self.settings_ui.c_lbl_url.setHidden(!chat_is_http); + self.settings_ui + .chat_base_url_field + .setHidden(!chat_is_http); + if chat_is_http { self.settings_ui .c_lbl_url .setFrame(top_rect(sec_h, 0.0, cy + 3.0, lbl_w, 18.0)); @@ -3619,19 +4293,23 @@ impl AppController { 50.0, 18.0, )); - self.settings_ui - .btn_chat_clr_key - .setFrame(top_rect(sec_h, content_w - 58.0, cy, 58.0, 25.0)); + self.settings_ui.btn_chat_clr_key.setFrame(top_rect( + sec_h, + content_w - 58.0, + cy, + 58.0, + 25.0, + )); cy += row_step; } - self.settings_ui.c_lbl_model.setHidden(chat_is_gguf); - self.settings_ui.chat_model_field.setHidden(chat_is_gguf); - self.settings_ui.btn_chat_discover.setHidden(chat_is_gguf); + self.settings_ui.c_lbl_model.setHidden(!chat_is_http); + self.settings_ui.chat_model_field.setHidden(!chat_is_http); + self.settings_ui.btn_chat_discover.setHidden(!chat_is_http); self.settings_ui .chat_discovered_popup - .setHidden(chat_is_gguf); - if !chat_is_gguf { + .setHidden(!chat_is_http); + if chat_is_http { self.settings_ui .c_lbl_model .setFrame(top_rect(sec_h, 0.0, cy + 3.0, lbl_w, 18.0)); @@ -3642,12 +4320,20 @@ impl AppController { (field_w - 148.0).max(100.0), 25.0, )); - self.settings_ui - .btn_chat_discover - .setFrame(top_rect(sec_h, content_w - 142.0, cy, 68.0, 25.0)); - self.settings_ui - .chat_discovered_popup - .setFrame(top_rect(sec_h, content_w - 70.0, cy, 70.0, 25.0)); + self.settings_ui.btn_chat_discover.setFrame(top_rect( + sec_h, + content_w - 142.0, + cy, + 68.0, + 25.0, + )); + self.settings_ui.chat_discovered_popup.setFrame(top_rect( + sec_h, + content_w - 70.0, + cy, + 70.0, + 25.0, + )); cy += row_step; } @@ -3665,9 +4351,13 @@ impl AppController { (field_w - 68.0).max(120.0), 25.0, )); - self.settings_ui - .btn_chat_gguf - .setFrame(top_rect(sec_h, content_w - 62.0, cy, 62.0, 25.0)); + self.settings_ui.btn_chat_gguf.setFrame(top_rect( + sec_h, + content_w - 62.0, + cy, + 62.0, + 25.0, + )); cy += row_step; } @@ -3676,7 +4366,7 @@ impl AppController { .c_lbl_prompt .setFrame(top_rect(sec_h, 0.0, cy, content_w, 18.0)); cy += 22.0; - let chat_prompt_h = (sec_h - cy - 42.0).clamp(88.0, 220.0); + let chat_prompt_h = (sec_h - cy - 42.0).clamp(80.0, 220.0); set_multiline_input_frame( &self.settings_ui.chat_prompt_input, top_rect(sec_h, 0.0, cy, content_w, chat_prompt_h), @@ -3703,7 +4393,10 @@ impl AppController { .setFrame(top_rect(sec_h, content_w - 96.0, 2.0, 96.0, 25.0)); let sc_rows = [ - (&self.settings_ui.sc_lbl_toggle, &self.settings_ui.sc_toggle_field), + ( + &self.settings_ui.sc_lbl_toggle, + &self.settings_ui.sc_toggle_field, + ), (&self.settings_ui.sc_lbl_ocr, &self.settings_ui.sc_ocr_field), (&self.settings_ui.sc_lbl_ai, &self.settings_ui.sc_ai_field), ( @@ -3738,21 +4431,28 @@ impl AppController { self.settings_ui .gen_lbl_opacity .setFrame(top_rect(sec_h, 0.0, 40.0, 120.0, 18.0)); - self.settings_ui - .opacity_val_label - .setFrame(top_rect(sec_h, content_w - 60.0, 40.0, 60.0, 18.0)); + self.settings_ui.opacity_val_label.setFrame(top_rect( + sec_h, + content_w - 60.0, + 40.0, + 60.0, + 18.0, + )); self.settings_ui .opacity_slider .setFrame(top_rect(sec_h, 0.0, 64.0, content_w, 24.0)); self.settings_ui .autostart_chk .setFrame(top_rect(sec_h, 0.0, 104.0, 220.0, 24.0)); + self.settings_ui + .ocr_strip_newlines_chk + .setFrame(top_rect(sec_h, 0.0, 134.0, 260.0, 24.0)); self.settings_ui .ver_label - .setFrame(top_rect(sec_h, 0.0, 144.0, content_w, 20.0)); + .setFrame(top_rect(sec_h, 0.0, 174.0, content_w, 20.0)); set_wrapping_label_frame( &self.settings_ui.gen_hint, - top_rect(sec_h, 0.0, 178.0, content_w, (sec_h - 178.0).max(80.0)), + top_rect(sec_h, 0.0, 206.0, content_w, (sec_h - 206.0).max(80.0)), ); } } @@ -3804,10 +4504,18 @@ mod tests { ActionTag::ResetDefaultShortcuts, ActionTag::OpacitySliderChanged, ActionTag::AutostartToggled, + ActionTag::OcrStripNewlinesToggled, ActionTag::SettingsSecTranslate, ActionTag::SettingsSecChat, ActionTag::SettingsSecShortcuts, ActionTag::SettingsSecGeneral, + ActionTag::HyMtQuantChanged, + ActionTag::HyMtCheckCompatibility, + ActionTag::HyMtDownloadAndRun, + ActionTag::HyMtStopServer, + ActionTag::HyMtDeleteModel, + ActionTag::ChatHyMtQuantChanged, + ActionTag::ChatHyMtDownloadAndRun, ]; for tag in tags { assert_eq!(ActionTag::from_raw(tag as isize), Some(tag)); diff --git a/crates/ttree-app/src/ui_helpers.rs b/crates/ttree-app/src/ui_helpers.rs index 56c6672..d6620b5 100644 --- a/crates/ttree-app/src/ui_helpers.rs +++ b/crates/ttree-app/src/ui_helpers.rs @@ -147,7 +147,8 @@ pub fn make_split_bar(mtm: MainThreadMarker, frame: NSRect, splitter_id: u8) -> splitter_id: Cell::new(splitter_id), last_screen_y: Cell::new(0.0), }); - let handle: Retained = unsafe { msg_send![super(handle), initWithFrame: frame] }; + let handle: Retained = + unsafe { msg_send![super(handle), initWithFrame: frame] }; let line = make_divider( mtm, @@ -348,12 +349,7 @@ pub fn make_symbol_button( } /// 更新图标按钮的 SF Symbol 与淡雅激活态颜色(不使用刺眼蓝色)。 -pub fn set_button_symbol( - btn: &NSButton, - symbol_name: &str, - fallback_title: &str, - active: bool, -) { +pub fn set_button_symbol(btn: &NSButton, symbol_name: &str, fallback_title: &str, active: bool) { unsafe { let ns_sym = NSString::from_str(symbol_name); let img: Option> = msg_send![ @@ -409,7 +405,7 @@ pub fn make_checkbox( NSControlStateValueOn } else { NSControlStateValueOff - } ); + }); btn.setTag(tag); btn.setTarget(Some(target)); btn.setAction(Some(action)); @@ -520,7 +516,39 @@ pub fn make_slider( } } -/// 创建无边框、通透扁平的多行 `NSTextView`(采用淡雅文字与柔和光标色)。 +/// 为 `NSTextView` 应用舒适的段落行间距(同时更新 `defaultParagraphStyle`、`typingAttributes` 与现有 `textStorage`)。 +pub fn apply_text_view_line_spacing(tv: &NSTextView, line_spacing: f64) { + unsafe { + let style: Retained = msg_send![objc2::class!(NSMutableParagraphStyle), new]; + let _: () = msg_send![&*style, setLineSpacing: line_spacing as CGFloat]; + let _: () = msg_send![&*style, setParagraphSpacing: 3.0 as CGFloat]; + let _: () = msg_send![tv, setDefaultParagraphStyle: Some(&*style)]; + + let attr_key = NSString::from_str("NSParagraphStyle"); + let typing_dict: Option> = msg_send![tv, typingAttributes]; + if let Some(dict) = typing_dict { + let mutable_dict: Retained = msg_send![&*dict, mutableCopy]; + let _: () = msg_send![&*mutable_dict, setObject: &*style, forKey: &*attr_key]; + let _: () = msg_send![tv, setTypingAttributes: &*mutable_dict]; + } + + let len = tv.string().length(); + if len > 0 { + let storage: Option> = msg_send![tv, textStorage]; + if let Some(st) = storage { + let range = NSRange::new(0, len); + let _: () = msg_send![ + &*st, + addAttribute: &*attr_key, + value: &*style, + range: range + ]; + } + } + } +} + +/// 创建无边框、通透扁平的多行 `NSTextView`(采用淡雅文字、柔和光标色与舒适行间距)。 pub fn make_scrollable_text_view( mtm: MainThreadMarker, frame: NSRect, @@ -558,6 +586,7 @@ pub fn make_scrollable_text_view( let _: () = msg_send![&*text_view, setAutomaticDashSubstitutionEnabled: false]; let _: () = msg_send![&*text_view, setAutomaticSpellingCorrectionEnabled: false]; } + apply_text_view_line_spacing(&text_view, 5.5); if let Some(del) = delegate { let _: () = msg_send![&*text_view, setDelegate: Some(del)]; } @@ -582,6 +611,7 @@ pub fn set_text_view_styled(tv: &NSTextView, text: &str, is_muted: bool) { pale_ink(0.78) }; tv.setTextColor(Some(&color)); + apply_text_view_line_spacing(tv, 5.5); } /// 设置 `NSTextView` 的纯文本内容(默认柔和正文色)。 @@ -596,6 +626,7 @@ pub fn append_text_view_string(tv: &NSTextView, delta: &str, scroll_to_end: bool let s = NSString::from_str(¤t); tv.setString(&s); tv.setTextColor(Some(&pale_ink(0.78))); + apply_text_view_line_spacing(tv, 5.5); if scroll_to_end { let len = s.length(); tv.scrollRangeToVisible(NSRange::new(len, 0)); @@ -726,10 +757,7 @@ pub fn make_wrapping_message_label( let _: () = msg_send![&*cell, setScrollable: Bool::NO]; // NSLineBreakByWordWrapping = 0 let _: () = msg_send![&*cell, setLineBreakMode: 0usize]; - let bounds = NSRect::new( - NSPoint::new(0.0, 0.0), - NSSize::new(safe_max_w, 20_000.0), - ); + let bounds = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(safe_max_w, 20_000.0)); let sz: NSSize = msg_send![&*cell, cellSizeForBounds: bounds]; NSSize::new( (sz.width.ceil() + 2.0).clamp(18.0, safe_max_w), @@ -800,4 +828,3 @@ pub fn set_multiline_input_hidden(widget: &MultilineInputWidget, hidden: bool) { widget.surface.setHidden(hidden); widget.scroll.setHidden(hidden); } - diff --git a/crates/ttree-core/src/config.rs b/crates/ttree-core/src/config.rs index 7ec0e5c..a9b29f3 100644 --- a/crates/ttree-core/src/config.rs +++ b/crates/ttree-core/src/config.rs @@ -45,11 +45,126 @@ pub const WINDOW_MAX_HEIGHT: f64 = TOP_BAR_HEIGHT + WEB_PANE_MAX_HEIGHT + AI_PANE_MAX_HEIGHT; -/// AI 引擎后端类型:支持云端 OpenAI 兼容、本机 Ollama/LM Studio/MLX 服务、以及直接加载 GGUF。 +/// 腾讯混元 HY-MT1.5-1.8B GGUF 全精度档位(直连 Hugging Face 下载)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum HyMtQuant { + /// 2-bit 超轻量量化(约 742 MB,适合 4–8GB 极限内存设备) + Q2K, + /// 3-bit 轻量量化(约 907 MB,适合 8GB 内存设备) + Q3KM, + /// 4-bit 紧凑非对称量化(约 994 MB,适合 8GB 内存设备) + Iq4Xs, + /// 4-bit 腾讯官方均衡量化(约 1.08 GB,8GB 内存首选) + Q4KM, + /// 5-bit 进阶高质量量化(约 1.24 GB,8–16GB 内存优选) + Q5KM, + /// 6-bit 腾讯官方高精度量化(约 1.41 GB,16GB 内存优选) + Q6K, + /// 8-bit 腾讯官方准无损量化(约 1.82 GB,16GB+ 内存推荐默认) + #[default] + Q8_0, + /// 16-bit 原生无损满血精度(约 3.42 GB,24GB+ 大内存极致质量) + F16, +} + +impl HyMtQuant { + pub const ALL: [HyMtQuant; 8] = [ + HyMtQuant::Q2K, + HyMtQuant::Q3KM, + HyMtQuant::Iq4Xs, + HyMtQuant::Q4KM, + HyMtQuant::Q5KM, + HyMtQuant::Q6K, + HyMtQuant::Q8_0, + HyMtQuant::F16, + ]; + + pub fn code(self) -> &'static str { + match self { + Self::Q2K => "Q2_K", + Self::Q3KM => "Q3_K_M", + Self::Iq4Xs => "IQ4_XS", + Self::Q4KM => "Q4_K_M", + Self::Q5KM => "Q5_K_M", + Self::Q6K => "Q6_K", + Self::Q8_0 => "Q8_0", + Self::F16 => "F16", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Q2K => "Q2_K · 2-bit 超轻量 (742 MB)", + Self::Q3KM => "Q3_K_M · 3-bit 轻量 (907 MB)", + Self::Iq4Xs => "IQ4_XS · 4-bit 紧凑 (994 MB)", + Self::Q4KM => "Q4_K_M · 4-bit 均衡 (1.08 GB)", + Self::Q5KM => "Q5_K_M · 5-bit 高质 (1.24 GB)", + Self::Q6K => "Q6_K · 6-bit 高精 (1.41 GB)", + Self::Q8_0 => "Q8_0 · 8-bit 准无损 (1.82 GB)", + Self::F16 => "F16 · 16-bit 满血 (3.42 GB)", + } + } + + pub fn file_name(self) -> &'static str { + match self { + Self::Q2K => "HY-MT1.5-1.8B.Q2_K.gguf", + Self::Q3KM => "HY-MT1.5-1.8B.Q3_K_M.gguf", + Self::Iq4Xs => "HY-MT1.5-1.8B.IQ4_XS.gguf", + Self::Q4KM => "HY-MT1.5-1.8B-Q4_K_M.gguf", + Self::Q5KM => "HY-MT1.5-1.8B.Q5_K_M.gguf", + Self::Q6K => "HY-MT1.5-1.8B-Q6_K.gguf", + Self::Q8_0 => "HY-MT1.5-1.8B-Q8_0.gguf", + Self::F16 => "HY-MT1.5-1.8B.f16.gguf", + } + } + + pub fn expected_bytes(self) -> u64 { + match self { + Self::Q2K => 777_483_008, + Self::Q3KM => 951_022_336, + Self::Iq4Xs => 1_041_724_160, + Self::Q4KM => 1_133_080_512, + Self::Q5KM => 1_298_756_352, + Self::Q6K => 1_474_785_216, + Self::Q8_0 => 1_908_528_288, + Self::F16 => 3_587_534_560, + } + } + + pub fn hf_url(self) -> &'static str { + match self { + Self::Q2K => "https://huggingface.co/mradermacher/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B.Q2_K.gguf", + Self::Q3KM => "https://huggingface.co/mradermacher/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B.Q3_K_M.gguf", + Self::Iq4Xs => "https://huggingface.co/mradermacher/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B.IQ4_XS.gguf", + Self::Q4KM => "https://huggingface.co/tencent/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B-Q4_K_M.gguf", + Self::Q5KM => "https://huggingface.co/mradermacher/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B.Q5_K_M.gguf", + Self::Q6K => "https://huggingface.co/tencent/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B-Q6_K.gguf", + Self::Q8_0 => "https://huggingface.co/tencent/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B-Q8_0.gguf", + Self::F16 => "https://huggingface.co/mradermacher/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B.f16.gguf", + } + } + + pub fn recommended_for_ram_gb(ram_gb: u64) -> Self { + if ram_gb >= 24 { + Self::Q8_0 + } else if ram_gb >= 14 { + Self::Q8_0 + } else if ram_gb >= 8 { + Self::Q4KM + } else { + Self::Q2K + } + } +} + +/// AI 引擎后端类型:支持内置混元 HY-MT1.5 一键下载运行、云端 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) + /// 内置腾讯混元 HY-MT1.5-1.8B 本地翻译模型(自动检测环境、从 Hugging Face 下载全精度 GGUF 并后台托管运行) + BuiltInHyMt, + /// 云端 OpenAI 兼容接口(OpenAI / DeepSeek / OpenRouter 等) #[default] OpenAiCompatible, /// 本机 Ollama 服务(默认 `http://127.0.0.1:11434`,免 API Key,支持 `/api/tags` 枚举模型) @@ -61,7 +176,8 @@ pub enum AiBackendKind { } impl AiBackendKind { - pub const ALL: [AiBackendKind; 4] = [ + pub const ALL: [AiBackendKind; 5] = [ + AiBackendKind::BuiltInHyMt, AiBackendKind::OpenAiCompatible, AiBackendKind::Ollama, AiBackendKind::LocalServer, @@ -70,6 +186,7 @@ impl AiBackendKind { pub fn label(self) -> &'static str { match self { + Self::BuiltInHyMt => "内置模型: 腾讯混元 HY-MT1.5", Self::OpenAiCompatible => "云端 API (OpenAI 兼容)", Self::Ollama => "本地服务: Ollama", Self::LocalServer => "本地服务: LM Studio / MLX", @@ -79,6 +196,7 @@ impl AiBackendKind { pub fn default_base_url(self) -> &'static str { match self { + Self::BuiltInHyMt => "", Self::OpenAiCompatible => "https://api.openai.com/v1", Self::Ollama => "http://127.0.0.1:11434", Self::LocalServer => "http://127.0.0.1:1234/v1", @@ -95,7 +213,7 @@ fn default_true() -> bool { true } -/// AI 翻译独立配置(支持开关、切换云端/Ollama/LM Studio/MLX/GGUF)。 +/// AI 翻译独立配置(支持开关、切换内置 HY-MT1.5 / 云端 / Ollama / LM Studio / MLX / GGUF)。 #[derive(Clone, Serialize, Deserialize)] pub struct AiTranslationConfig { /// 是否在翻译面板右侧启用 AI 翻译对照列。 @@ -103,6 +221,9 @@ pub struct AiTranslationConfig { pub enabled: bool, #[serde(default)] pub backend: AiBackendKind, + /// 当 `backend == BuiltInHyMt` 时选择的 GGUF 精度档位。 + #[serde(default)] + pub hy_mt_quant: HyMtQuant, pub base_url: String, #[serde(default)] pub api_key: String, @@ -125,6 +246,7 @@ impl std::fmt::Debug for AiTranslationConfig { f.debug_struct("AiTranslationConfig") .field("enabled", &self.enabled) .field("backend", &self.backend) + .field("hy_mt_quant", &self.hy_mt_quant) .field("base_url", &self.base_url) .field("api_key", &"") .field("model", &self.model) @@ -138,6 +260,7 @@ impl Default for AiTranslationConfig { Self { enabled: true, backend: AiBackendKind::OpenAiCompatible, + hy_mt_quant: HyMtQuant::default(), base_url: "https://api.openai.com/v1".to_string(), api_key: String::new(), has_api_key: false, @@ -155,6 +278,8 @@ impl Default for AiTranslationConfig { pub struct ChatAiConfig { #[serde(default)] pub backend: AiBackendKind, + #[serde(default)] + pub hy_mt_quant: HyMtQuant, pub base_url: String, #[serde(default)] pub api_key: String, @@ -175,6 +300,7 @@ 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("hy_mt_quant", &self.hy_mt_quant) .field("base_url", &self.base_url) .field("api_key", &"") .field("model", &self.model) @@ -187,6 +313,7 @@ impl Default for ChatAiConfig { fn default() -> Self { Self { backend: AiBackendKind::OpenAiCompatible, + hy_mt_quant: HyMtQuant::default(), base_url: "https://api.openai.com/v1".to_string(), api_key: String::new(), has_api_key: false, @@ -224,11 +351,13 @@ impl Default for ShortcutConfig { } } -/// 外观配置。 +/// 外观与通用行为配置。 #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct AppearanceConfig { #[serde(default = "default_panel_opacity")] pub panel_opacity: f64, + #[serde(default = "default_true")] + pub ocr_strip_newlines: bool, } fn default_panel_opacity() -> f64 { @@ -239,6 +368,7 @@ impl Default for AppearanceConfig { fn default() -> Self { Self { panel_opacity: default_panel_opacity(), + ocr_strip_newlines: true, } } } @@ -252,6 +382,57 @@ impl AppearanceConfig { } } +fn is_cjk_or_fullwidth(c: char) -> bool { + matches!( + c as u32, + 0x3000..=0x303F + | 0x3040..=0x30FF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xAC00..=0xD7AF + | 0xFF00..=0xFFEF + ) +} + +/// 将 OCR 识别出的多行文本智能合并为单段: +/// - 英文/拉丁单词跨行折行自动补充单个空格(若行尾为 `/` 或连字符单词折行则自然拼接) +/// - 中文/全角字符之间跨行直接无缝拼接,不插入多余空格 +pub fn normalize_ocr_newlines(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if out.is_empty() { + out.push_str(trimmed); + continue; + } + let last_ch = out.chars().last().unwrap_or(' '); + let first_ch = trimmed.chars().next().unwrap_or(' '); + if last_ch == '-' + && out + .chars() + .rev() + .nth(1) + .is_some_and(|c| c.is_ascii_alphabetic()) + && first_ch.is_ascii_lowercase() + { + out.pop(); + out.push_str(trimmed); + } else if is_cjk_or_fullwidth(last_ch) + || is_cjk_or_fullwidth(first_ch) + || matches!(last_ch, '/' | '(' | '[' | '{') + { + out.push_str(trimmed); + } else { + out.push(' '); + out.push_str(trimmed); + } + } + out +} + /// 窗口宽度与各分栏高度记忆配置(每一栏均带有严格的最小/最大高度限制)。 #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct PaneLayoutConfig { @@ -548,4 +729,21 @@ mod tests { PaneLayoutConfig::min_window_height(true) ); } + + #[test] + fn hy_mt_quant_round_trips_serde_and_recommendations() { + assert_eq!(HyMtQuant::ALL.len(), 8); + for quant in HyMtQuant::ALL { + let json = serde_json::to_string(&quant).unwrap(); + let decoded: HyMtQuant = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, quant); + assert!(!quant.code().is_empty()); + assert!(quant.file_name().ends_with(".gguf")); + assert!(quant.expected_bytes() > 700_000_000); + assert!(quant.hf_url().starts_with("https://huggingface.co/")); + } + assert_eq!(HyMtQuant::recommended_for_ram_gb(4), HyMtQuant::Q2K); + assert_eq!(HyMtQuant::recommended_for_ram_gb(8), HyMtQuant::Q4KM); + assert_eq!(HyMtQuant::recommended_for_ram_gb(16), HyMtQuant::Q8_0); + } } diff --git a/crates/ttree-core/src/language.rs b/crates/ttree-core/src/language.rs index 93975ac..b54ab21 100644 --- a/crates/ttree-core/src/language.rs +++ b/crates/ttree-core/src/language.rs @@ -128,7 +128,10 @@ pub fn resolve_adaptive_target(text: &str) -> &'static str { /// 将用户选择的 `(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()) + ( + "auto".to_string(), + resolve_adaptive_target(text).to_string(), + ) } else { (source.to_string(), target.to_string()) } diff --git a/crates/ttree-core/src/lib.rs b/crates/ttree-core/src/lib.rs index 93117b9..0eae3cb 100644 --- a/crates/ttree-core/src/lib.rs +++ b/crates/ttree-core/src/lib.rs @@ -8,13 +8,14 @@ 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, - PaneLayoutConfig, ShortcutConfig, AI_PANE_DEFAULT_HEIGHT, AI_PANE_MAX_HEIGHT, - AI_PANE_MIN_HEIGHT, CHAT_INPUT_DEFAULT_HEIGHT, CHAT_INPUT_MAX_HEIGHT, CHAT_INPUT_MIN_HEIGHT, - LANG_PANE_DEFAULT_HEIGHT, LANG_PANE_MAX_HEIGHT, LANG_PANE_MIN_HEIGHT, PANEL_OPACITY_MAX, - PANEL_OPACITY_MIN, SOURCE_PANE_DEFAULT_HEIGHT, SOURCE_PANE_MAX_HEIGHT, SOURCE_PANE_MIN_HEIGHT, - TOP_BAR_HEIGHT, WEB_PANE_DEFAULT_HEIGHT, WEB_PANE_MAX_HEIGHT, WEB_PANE_MIN_HEIGHT, - WINDOW_DEFAULT_WIDTH, WINDOW_MAX_HEIGHT, WINDOW_MAX_WIDTH, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, + normalize_base_url, normalize_ocr_newlines, AiBackendKind, AiTranslationConfig, + AppearanceConfig, ChatAiConfig, HyMtQuant, PaneLayoutConfig, ShortcutConfig, + AI_PANE_DEFAULT_HEIGHT, AI_PANE_MAX_HEIGHT, AI_PANE_MIN_HEIGHT, CHAT_INPUT_DEFAULT_HEIGHT, + CHAT_INPUT_MAX_HEIGHT, CHAT_INPUT_MIN_HEIGHT, LANG_PANE_DEFAULT_HEIGHT, LANG_PANE_MAX_HEIGHT, + LANG_PANE_MIN_HEIGHT, PANEL_OPACITY_MAX, PANEL_OPACITY_MIN, SOURCE_PANE_DEFAULT_HEIGHT, + SOURCE_PANE_MAX_HEIGHT, SOURCE_PANE_MIN_HEIGHT, TOP_BAR_HEIGHT, WEB_PANE_DEFAULT_HEIGHT, + WEB_PANE_MAX_HEIGHT, WEB_PANE_MIN_HEIGHT, WINDOW_DEFAULT_WIDTH, WINDOW_MAX_HEIGHT, + WINDOW_MAX_WIDTH, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, }; pub use language::{ resolve_adaptive_target, resolve_language_pair, validate_language_pair, LanguageOption, diff --git a/crates/ttree-ocr/src/lib.rs b/crates/ttree-ocr/src/lib.rs index d3701a7..7037146 100644 --- a/crates/ttree-ocr/src/lib.rs +++ b/crates/ttree-ocr/src/lib.rs @@ -13,8 +13,8 @@ pub mod image; #[cfg(target_os = "macos")] pub mod vision; -use std::sync::atomic::{AtomicBool, Ordering}; use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; static OCR_BUSY: AtomicBool = AtomicBool::new(false); diff --git a/crates/ttree-platform-macos/src/hotkey.rs b/crates/ttree-platform-macos/src/hotkey.rs index de99102..9e7225d 100644 --- a/crates/ttree-platform-macos/src/hotkey.rs +++ b/crates/ttree-platform-macos/src/hotkey.rs @@ -135,7 +135,9 @@ fn parse_optional_shortcut(label: &str, spec: &str) -> Result, an .mods .intersects(Modifiers::SUPER | Modifiers::CONTROL | Modifiers::ALT) { - return Err(anyhow!("{label}必须包含 Cmd/Ctrl/Option 中的至少一个修饰键")); + return Err(anyhow!( + "{label}必须包含 Cmd/Ctrl/Option 中的至少一个修饰键" + )); } Ok(Some(hotkey)) } diff --git a/crates/ttree-storage/src/settings.rs b/crates/ttree-storage/src/settings.rs index cfaa437..e8b53bf 100644 --- a/crates/ttree-storage/src/settings.rs +++ b/crates/ttree-storage/src/settings.rs @@ -15,7 +15,7 @@ use ttree_core::config::{ }; use ttree_core::{ normalize_base_url, AiBackendKind, AiTranslationConfig, AppearanceConfig, ChatAiConfig, - PaneLayoutConfig, ShortcutConfig, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, + HyMtQuant, PaneLayoutConfig, ShortcutConfig, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, }; pub const APP_BUNDLE_ID: &str = "com.samwstu.ttree"; @@ -101,7 +101,10 @@ impl SettingsStore { } pub fn appearance(&self) -> AppearanceConfig { - self.cache.read().expect("settings lock poisoned").appearance + self.cache + .read() + .expect("settings lock poisoned") + .appearance } pub fn pane_layout(&self) -> PaneLayoutConfig { @@ -112,7 +115,10 @@ impl SettingsStore { } pub fn window_size(&self) -> (f64, f64) { - self.cache.read().expect("settings lock poisoned").window_size + self.cache + .read() + .expect("settings lock poisoned") + .window_size } pub fn save_ai_translation(&self, input: &AiTranslationConfig) -> Result<(), String> { @@ -121,20 +127,27 @@ impl SettingsStore { 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 base_url = if matches!( + input.backend, + AiBackendKind::CandleGguf | AiBackendKind::BuiltInHyMt + ) && 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 = - resolve_api_key(&guard.ai_translate.api_key, &input.api_key, input.clear_api_key)?; + let resolved_key = resolve_api_key( + &guard.ai_translate.api_key, + &input.api_key, + input.clear_api_key, + )?; let updated = AiTranslationConfig { enabled: input.enabled, backend: input.backend, + hy_mt_quant: input.hy_mt_quant, base_url: base_url.clone(), has_api_key: !resolved_key.is_empty(), api_key: resolved_key.clone(), @@ -153,14 +166,17 @@ impl SettingsStore { serde_json::to_value(updated.backend) .unwrap_or(Value::String("open_ai_compatible".into())), ); + guard.raw_map.insert( + "openai.hy_mt_quant".into(), + serde_json::to_value(updated.hy_mt_quant).unwrap_or(Value::String("q8_0".into())), + ); guard.raw_map.insert( "openai.base_url".into(), Value::String(updated.base_url.clone()), ); - guard.raw_map.insert( - "openai.api_key".into(), - Value::String(resolved_key), - ); + guard + .raw_map + .insert("openai.api_key".into(), Value::String(resolved_key)); guard .raw_map .insert("openai.model".into(), Value::String(updated.model.clone())); @@ -188,12 +204,15 @@ impl SettingsStore { 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 base_url = if matches!( + input.backend, + AiBackendKind::CandleGguf | AiBackendKind::BuiltInHyMt + ) && 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 = @@ -201,6 +220,7 @@ impl SettingsStore { let updated = ChatAiConfig { backend: input.backend, + hy_mt_quant: input.hy_mt_quant, base_url: base_url.clone(), has_api_key: !resolved_key.is_empty(), api_key: resolved_key.clone(), @@ -216,18 +236,20 @@ impl SettingsStore { serde_json::to_value(updated.backend) .unwrap_or(Value::String("open_ai_compatible".into())), ); + guard.raw_map.insert( + "chat_ai.hy_mt_quant".into(), + serde_json::to_value(updated.hy_mt_quant).unwrap_or(Value::String("q8_0".into())), + ); guard.raw_map.insert( "chat_ai.base_url".into(), Value::String(updated.base_url.clone()), ); - guard.raw_map.insert( - "chat_ai.api_key".into(), - Value::String(resolved_key), - ); - guard.raw_map.insert( - "chat_ai.model".into(), - Value::String(updated.model.clone()), - ); + guard + .raw_map + .insert("chat_ai.api_key".into(), Value::String(resolved_key)); + 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()), @@ -274,12 +296,17 @@ impl SettingsStore { pub fn save_appearance(&self, cfg: AppearanceConfig) -> Result<(), String> { let clamped = AppearanceConfig { panel_opacity: AppearanceConfig::clamp_opacity(cfg.panel_opacity), + ocr_strip_newlines: cfg.ocr_strip_newlines, }; let mut guard = self.cache.write().expect("settings lock poisoned"); guard.raw_map.insert( "appearance.panel_opacity".into(), Value::from(clamped.panel_opacity), ); + guard.raw_map.insert( + "appearance.ocr_strip_newlines".into(), + Value::from(clamped.ocr_strip_newlines), + ); write_json_map(&self.path, &guard.raw_map)?; guard.appearance = clamped; Ok(()) @@ -389,9 +416,21 @@ fn load_ai_translation(map: &Map) -> AiTranslationConfig { { cfg.backend = backend; } + if let Some(quant) = map + .get("openai.hy_mt_quant") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + cfg.hy_mt_quant = quant; + } 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; + } else if matches!( + cfg.backend, + AiBackendKind::CandleGguf | AiBackendKind::BuiltInHyMt + ) && url.trim().is_empty() + { + cfg.base_url = String::new(); } } if let Some(key) = map.get("openai.api_key").and_then(Value::as_str) { @@ -406,7 +445,10 @@ fn load_ai_translation(map: &Map) -> AiTranslationConfig { 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) { + 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) { @@ -425,9 +467,21 @@ fn load_chat_ai(map: &Map) -> ChatAiConfig { { cfg.backend = backend; } + if let Some(quant) = map + .get("chat_ai.hy_mt_quant") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + cfg.hy_mt_quant = quant; + } 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; + } else if matches!( + cfg.backend, + AiBackendKind::CandleGguf | AiBackendKind::BuiltInHyMt + ) && url.trim().is_empty() + { + cfg.base_url = String::new(); } } if let Some(key) = map.get("chat_ai.api_key").and_then(Value::as_str) { @@ -442,7 +496,10 @@ fn load_chat_ai(map: &Map) -> ChatAiConfig { 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) { + 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) { @@ -487,6 +544,12 @@ fn load_appearance(map: &Map) -> AppearanceConfig { if let Some(v) = map.get("appearance.panel_opacity").and_then(Value::as_f64) { cfg.panel_opacity = AppearanceConfig::clamp_opacity(v); } + if let Some(v) = map + .get("appearance.ocr_strip_newlines") + .and_then(Value::as_bool) + { + cfg.ocr_strip_newlines = v; + } cfg } @@ -559,4 +622,31 @@ mod tests { let loaded_layout = reloaded.pane_layout(); assert_eq!(loaded_layout, custom_layout); } + + #[test] + fn persists_and_reloads_builtin_hy_mt_and_quant_with_empty_base_url() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let store = SettingsStore::open_at(tmp.path().to_path_buf()); + + let mut ai_cfg = store.ai_translation_for_request(); + ai_cfg.backend = AiBackendKind::BuiltInHyMt; + ai_cfg.base_url.clear(); + ai_cfg.hy_mt_quant = HyMtQuant::Q5KM; + store.save_ai_translation(&ai_cfg).unwrap(); + + let mut chat_cfg = store.chat_ai_for_request(); + chat_cfg.backend = AiBackendKind::BuiltInHyMt; + chat_cfg.base_url.clear(); + chat_cfg.hy_mt_quant = HyMtQuant::F16; + store.save_chat_ai(&chat_cfg).unwrap(); + + let reloaded = SettingsStore::open_at(tmp.path().to_path_buf()); + let loaded_ai = reloaded.ai_translation_for_request(); + assert_eq!(loaded_ai.backend, AiBackendKind::BuiltInHyMt); + assert_eq!(loaded_ai.hy_mt_quant, HyMtQuant::Q5KM); + + let loaded_chat = reloaded.chat_ai_for_request(); + assert_eq!(loaded_chat.backend, AiBackendKind::BuiltInHyMt); + assert_eq!(loaded_chat.hy_mt_quant, HyMtQuant::F16); + } } diff --git a/crates/ttree-translate/src/auto.rs b/crates/ttree-translate/src/auto.rs index 90a73af..a9f2b6d 100644 --- a/crates/ttree-translate/src/auto.rs +++ b/crates/ttree-translate/src/auto.rs @@ -63,9 +63,7 @@ struct RouterState { impl RouterState { fn is_available(&self, p: Provider, now: Instant) -> bool { - self.stats[p.index()] - .retry_after - .is_none_or(|t| now >= t) + self.stats[p.index()].retry_after.is_none_or(|t| now >= t) } /// 优先返回当前可用且历史延迟更低的服务。 @@ -143,7 +141,10 @@ async fn translate_chunk_hedged(text: &str, source: &str, target: &str) -> Resul .unwrap_or_else(|_| vec![Provider::Bing, Provider::Google]); let primary = candidates[0]; - let secondary = candidates.get(1).copied().unwrap_or_else(|| primary.other()); + let secondary = candidates + .get(1) + .copied() + .unwrap_or_else(|| primary.other()); let primary_fut = call_provider(primary, text, source, target); tokio::pin!(primary_fut); @@ -245,7 +246,11 @@ mod tests { #[test] fn splits_long_text_without_losing_characters() { - let input = format!("{}\n{}", "测试文本 ".repeat(400), "hello world. ".repeat(300)); + let input = format!( + "{}\n{}", + "测试文本 ".repeat(400), + "hello world. ".repeat(300) + ); let chunks = split_text(&input); assert!(chunks.len() > 1); assert_eq!(chunks.concat(), input); diff --git a/crates/ttree-translate/src/bing.rs b/crates/ttree-translate/src/bing.rs index 620e877..ca43363 100644 --- a/crates/ttree-translate/src/bing.rs +++ b/crates/ttree-translate/src/bing.rs @@ -135,8 +135,7 @@ async fn translate_with_token( return Err(format!("必应翻译返回状态 {status}: {body}")); } - let body = - read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "必应翻译响应").await?; + let body = read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "必应翻译响应").await?; let body: Value = serde_json::from_slice(&body).map_err(|e| format!("必应翻译响应解析失败: {e}"))?; @@ -224,6 +223,9 @@ mod tests { 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); + assert_eq!( + get_cached_token(now + TOKEN_TTL + Duration::from_secs(1)), + None + ); } } diff --git a/crates/ttree-translate/src/google.rs b/crates/ttree-translate/src/google.rs index 30a01f6..b22d808 100644 --- a/crates/ttree-translate/src/google.rs +++ b/crates/ttree-translate/src/google.rs @@ -1,9 +1,11 @@ //! 谷歌翻译 —— 改用 POST 表单请求避免长文本 URL 膨胀,并配合 1.8s 快速连接超时。 -use std::time::Duration; use serde_json::Value; +use std::time::Duration; -use crate::{read_error_body, read_limited_response, shared_client, MAX_TRANSLATION_RESPONSE_BYTES}; +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); @@ -29,8 +31,7 @@ pub async fn translate(text: &str, source: &str, target: &str) -> Result