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:
1 parent
e77b364750
commit
46c2f3664b
18 files changed
+2364
-275
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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("你好世界"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+878
-170
File diff suppressed because it is too large.
Load diff
@@ -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(¤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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}"))?;
|
||||
|
||||
|
||||
Reference in new issue
Block a user