feat(ai,ui): add built-in HY-MT1.5-1.8B GGUF engine, unified settings UI, translation line spacing, and OCR newline toggle

This commit is contained in:
ZASENJC committed 2026-10-15 02:50:57 +08:00
1 parent e77b364750
commit 46c2f3664b
18 files changed
+2364 -275

No files matched your search

Generated
+2
View File
@@ -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",
+4
View File
@@ -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"] }
+11 -10
View File
@@ -74,7 +74,13 @@ impl GgufEmbeddedTokenizer {
{
eos_tokens.push(eos_id);
}
for special in ["<|im_end|>", "<|endoftext|>", "</s>", "<|eot_id|>", "<end_of_turn>"] {
for special in [
"<|im_end|>",
"<|endoftext|>",
"</s>",
"<|eot_id|>",
"<end_of_turn>",
] {
if let Some(&id) = token_to_id.get(special) {
if !eos_tokens.contains(&id) {
eos_tokens.push(id);
@@ -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<CachedSession, String> {
}
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),
+12 -4
View File
@@ -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::<Vec<_>>()
})
.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");
File diff suppressed because it is too large. Load diff
+29 -16
View File
@@ -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<F>(
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("你好世界"));
}
}
+5 -3
View File
@@ -22,8 +22,7 @@ pub fn parse_sse_line(line: &str) -> Result<Option<StreamDelta>, 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,
File diff suppressed because it is too large. Load diff
+41 -14
View File
@@ -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<SplitHandleView> = unsafe { msg_send![super(handle), initWithFrame: frame] };
let handle: Retained<SplitHandleView> =
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<Retained<NSImage>> = 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<AnyObject> = 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<Retained<AnyObject>> = msg_send![tv, typingAttributes];
if let Some(dict) = typing_dict {
let mutable_dict: Retained<AnyObject> = 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<Retained<AnyObject>> = 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(&current);
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);
}
+203 -5
View File
@@ -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", &"<redacted>")
.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", &"<redacted>")
.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);
}
}
+4 -1
View File
@@ -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())
}
+8 -7
View File
@@ -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,
+1 -1
View File
@@ -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);
+3 -1
View File
@@ -135,7 +135,9 @@ fn parse_optional_shortcut(label: &str, spec: &str) -> Result<Option<HotKey>, 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))
}
+121 -31
View File
@@ -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<String, Value>) -> AiTranslationConfig {
{
cfg.backend = backend;
}
if let Some(quant) = map
.get("openai.hy_mt_quant")
.and_then(|v| serde_json::from_value::<HyMtQuant>(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<String, Value>) -> 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<String, Value>) -> ChatAiConfig {
{
cfg.backend = backend;
}
if let Some(quant) = map
.get("chat_ai.hy_mt_quant")
.and_then(|v| serde_json::from_value::<HyMtQuant>(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<String, Value>) -> 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<String, Value>) -> 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);
}
}
+10 -5
View File
@@ -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);
+5 -3
View File
@@ -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
);
}
}
+5 -4
View File
@@ -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<String,
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}"))?;