Files

1 line
30 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "aonagiyuna/mahodown", "files": {"src-tauri/src/ai.rs": {"62d15cd56175ead199f3bce2c1e00d8b0667b295": {"t": 1785746257.445476, "n": 9818, "h": "49aae67bbf55bb4e", "c": "use serde_json::{json, Value};\nuse std::collections::HashMap;\n\nuse crate::settings::{get_secret, load_settings, AppSettings};\n\n/// OpenAI-compatible chat completion (DeepSeek / OpenAI / Ollama / 硅基流动 / Moonshot …).\npub fn complete(action: &str, text: &str, context: &str) -> Result<String, String> {\n let settings = load_settings();\n let base = settings\n .ai_base_url\n .trim()\n .trim_end_matches('/')\n .to_string();\n if base.is_empty() {\n return Err(\"请先在设置 → AI 中配置 API Base URL\".into());\n }\n let model = if settings.ai_model.trim().is_empty() {\n \"deepseek-chat\".to_string()\n } else {\n settings.ai_model.trim().to_string()\n };\n let key = get_secret(\"ai\", \"token\")\n .filter(|s| !s.is_empty())\n .ok_or(\"请先在设置 → AI 中填写 API Key\")?;\n\n let (system, user) = build_messages(action, text, context)?;\n let body = json!({\n \"model\": model,\n \"messages\": [\n { \"role\": \"system\", \"content\": system },\n { \"role\": \"user\", \"content\": user }\n ],\n \"temperature\": if action == \"continue\" { 0.8 } else { 0.4 },\n \"stream\": false\n });\n\n let content = post_completion(&base, &key, &body)?;\n // Strip common markdown fences if model wraps entire answer\n Ok(strip_outer_fence(&content))\n}\n\nconst DOC_START: &str = \"<<<MAHODOWN_DOC>>>\";\nconst DOC_END: &str = \"<<<END>>>\";\n\n/// Multi-turn chat that can also edit the whole document. `messages` is the\n/// panel's history (user/assistant). Returns `{ reply, document|null }`.\npub fn chat(messages: &[(String, String)], document: &str, model_override: &str) -> Result<Value, String> {\n let settings = load_settings();\n let base = settings.ai_base_url.trim().trim_end_matches('/').to_string();\n if base.is_empty() {\n return Err(\"请先在设置 → AI 中配置 API Base URL\".into());\n }\n let model = {\n let m = model_override.trim();\n if !m.is_empty() {\n m.to_string()\n } else if settings.ai_model.trim().is_empty() {\n \"deepseek-chat\".to_string()\n } else {\n settings.ai_model.trim().to_string()\n }\n };\n let key = get_secret(\"ai\", \"token\")\n .filter(|s| !s.is_empty())\n .ok_or(\"请先在设置 → AI 中填写 API Key\")?;\n\n let system = format!(\n \"你是 MahoDown 的写作助手,嵌入在一个 Markdown 编辑器里。\\n\\\n - 正常问答时用简洁中文回复。\\n\\\n - 当用户希望你修改文档(润色、改写、增删、翻译、调整结构等)时:先用一两句话说明你改了什么,\\\n 然后输出【完整】的修改后 Markdown 文档,并严格用下面两行标记包裹(每个标记独占一行):\\n\\\n {DOC_START}\\n(这里放完整文档内容)\\n{DOC_END}\\n\\\n 只有确实修改文档时才输出这个块;纯问答不要输出。必须输出整篇文档(含未改动部分),不要用省略号省略。\"\n );\n let doc_msg = format!(\"这是当前文档,全文如下:\\n{DOC_START}\\n{document}\\n{DOC_END}\");\n\n let mut msgs = vec![\n json!({ \"role\": \"system\", \"content\": system }),\n json!({ \"role\": \"system\", \"content\": doc_msg }),\n ];\n for (role, content) in messages {\n let r = if role == \"assistant\" { \"assistant\" } else { \"user\" };\n msgs.push(json!({ \"role\": r, \"content\": content }));\n }\n\n let body = json!({ \"model\": model, \"messages\": msgs, \"temperature\": 0.5, \"stream\": false });\n let content = post_completion(&base, &key, &body)?;\n let (reply, doc) = split_doc(&content);\n Ok(json!({ \"reply\": reply, \"document\": doc }))\n}\n\n/// Split an assistant reply into chat text + optional full-document block.\nfn split_doc(content: &str) -> (String, Option<String>) {\n if let Some(start) = content.find(DOC_START) {\n let before = content[..start].trim().to_string();\n let after = &content[start + DOC_START.len()..];\n let doc = match after.find(DOC_END) {\n Some(end) => &after[..end],\n None => after, // start marker but truncated / no end marker\n };\n let doc = doc.trim_matches(|c: char| c == '\\n' || c == '\\r').to_string();\n let reply = if before.is_empty() {\n \"已更新文档,请在下方查看改动。\".to_string()\n } else {\n before\n };\n return (reply, Some(doc));\n }\n (content.trim().to_string(), None)\n}\n\nfn chat_url(base: &str) -> String {\n if base.ends_with(\"/chat/completions\") {\n base.to_string()\n } else if base.ends_with(\"/v1\") {\n format!(\"{base}/chat/completions\")\n } else {\n format!(\"{base}/v1/chat/completions\")\n }\n}\n\n/// Shared OpenAI-compatible POST → assistant message content.\nfn post_completion(base: &str, key: &str, body: &Value) -> Result<String, String> {\n let client = reqwest::blocking::Client::builder()\n .timeout(std::time::Duration::from_secs(120))\n .build()\n .map_err(|e| e.to_string())?;\n let resp = client\n .post(chat_url(base))\n .header(\"Authorization\", format!(\"Bearer {key}\"))\n .header(\"Content-Type\", \"application/json\")\n .json(body)\n .send()\n .map_err(|e| format!(\"AI 请求失败: {e}\"))?;\n\n let status = resp.status();\n let raw = resp.text().map_err(|e| e.to_string())?;\n if !status.is_success() {\n let hint = serde_json::from_str::<Value>(&raw)\n .ok()\n .and_then(|v| {\n v.pointer(\"/error/message\")\n .and_then(|m| m.as_str())\n .map(|s| s.to_string())\n })\n .unwrap_or_else(|| raw.chars().take(200).collect());\n return Err(format!(\"AI 返回 {status}: {hint}\"));\n }\n let parsed: Value = serde_json::from_str(&raw).map_err(|e| format!(\"解析 AI 响应失败: {e}\"))?;\n parsed\n .pointer(\"/choices/0/message/content\")\n .and_then(|c| c.as_str())\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .ok_or_else(|| \"AI 未返回有效内容\".to_string())\n}\n\nfn build_messages(action: &str, text: &str, context: &str) -> Result<(String, String), String> {\n let text = text.trim();\n let context = context.trim();\n match action {\n \"polish\" => {\n if text.is_empty() {\n return Err(\"请先选中要润色的文字\".into());\n }\n Ok((\n \"你是中文写作润色助手。保持原意、人称与 Markdown 结构(加粗/列表/标题等),\\\n 让表达更通顺、准确、自然。只输出润色后的正文,不要解释、不加引号、不要前后缀。\"\n .into(),\n format!(\"请润色以下内容:\\n\\n{text}\"),\n ))\n }\n \"continue\" => {\n let ctx = if !context.is_empty() {\n context\n } else if !text.is_empty() {\n text\n } else {\n return Err(\"没有可续写的上下文\".into());\n };\n // Cap context size\n let ctx = if ctx.chars().count() > 3000 {\n let s: String = ctx.chars().rev().take(3000).collect::<String>().chars().rev().collect();\n s\n } else {\n ctx.to_string()\n };\n Ok((\n \"你是中文写作助手。根据给定 Markdown 上下文自然续写下一段,保持语气与格式一致。\\\n 只输出续写部分,不要重复原文,不要解释。\"\n .into(),\n format!(\"上下文:\\n\\n{ctx}\\n\\n请续写:\"),\n ))\n }\n \"translate\" => {\n if text.is_empty() {\n return Err(\"请先选中要翻译的文字\".into());\n }\n let mostly_cjk = text.chars().filter(|c| ('\\u{4e00}'..='\\u{9fff}').contains(c)).count()\n * 2\n > text.chars().filter(|c| !c.is_whitespace()).count();\n let direction = if mostly_cjk {\n \"将以下中文翻译成地道英文\"\n } else {\n \"将以下英文翻译成通顺中文\"\n };\n Ok((\n \"你是翻译助手。保持 Markdown 结构。只输出译文,不要解释。\"\n .into(),\n format!(\"{direction}:\\n\\n{text}\"),\n ))\n }\n other => Err(format!(\"未知 AI 动作: {other}\")),\n }\n}\n\nfn strip_outer_fence(s: &str) -> String {\n let t = s.trim();\n if let Some(rest) = t.strip_prefix(\"```\") {\n if let Some(nl) = rest.find('\\n') {\n let body = &rest[nl + 1..];\n if let Some(end) = body.rfind(\"```\") {\n return body[..end].trim().to_string();\n }\n }\n }\n t.to_string()\n}\n\npub fn ai_settings_public(settings: &AppSettings) -> Value {\n let has_key = get_secret(\"ai\", \"token\")\n .map(|s| !s.is_empty())\n .unwrap_or(false);\n json!({\n \"aiBaseUrl\": settings.ai_base_url,\n \"aiModel\": settings.ai_model,\n \"aiApiKey\": if has_key { \"********\" } else { \"\" },\n \"hasAiKey\": has_key\n })\n}\n\npub fn merge_ai_into_settings_json(mut value: Value, settings: &AppSettings) -> Value {\n if let Some(obj) = value.as_object_mut() {\n let pub_ai = ai_settings_public(settings);\n if let Some(map) = pub_ai.as_object() {\n for (k, v) in map {\n obj.insert(k.clone(), v.clone());\n }\n }\n }\n value\n}\n\n/// Preset catalog for UI (OpenAI-compatible endpoints).\npub fn presets() -> Vec<HashMap<&'static str, &'static str>> {\n vec![\n HashMap::from([\n (\"id\", \"deepseek\"),\n (\"title\", \"DeepSeek\"),\n (\"baseUrl\", \"https://api.deepseek.com/v1\"),\n (\"model\", \"deepseek-chat\"),\n ]),\n HashMap::from([\n (\"id\", \"openai\"),\n (\"title\", \"OpenAI\"),\n (\"baseUrl\", \"https://api.openai.com/v1\"),\n (\"model\", \"gpt-4o-mini\"),\n ]),\n HashMap::from([\n (\"id\", \"moonshot\"),\n (\"title\", \"Moonshot / Kimi\"),\n (\"baseUrl\", \"https://api.moonshot.cn/v1\"),\n (\"model\", \"moonshot-v1-8k\"),\n ]),\n HashMap::from([\n (\"id\", \"siliconflow\"),\n (\"title\", \"硅基流动\"),\n (\"baseUrl\", \"https://api.siliconflow.cn/v1\"),\n (\"model\", \"deepseek-ai/DeepSeek-V3\"),\n ]),\n HashMap::from([\n (\"id\", \"ollama\"),\n (\"title\", \"Ollama 本地\"),\n (\"baseUrl\", \"http://127.0.0.1:11434/v1\"),\n (\"model\", \"qwen2.5\"),\n ]),\n ]\n}\n"}, "6b2cdba812bd1ca643c9ff3d9e7a6858f2a420a4": {"t": 1785761180.3672643, "n": 9818, "h": "49aae67bbf55bb4e", "c": "use serde_json::{json, Value};\nuse std::collections::HashMap;\n\nuse crate::settings::{get_secret, load_settings, AppSettings};\n\n/// OpenAI-compatible chat completion (DeepSeek / OpenAI / Ollama / 硅基流动 / Moonshot …).\npub fn complete(action: &str, text: &str, context: &str) -> Result<String, String> {\n let settings = load_settings();\n let base = settings\n .ai_base_url\n .trim()\n .trim_end_matches('/')\n .to_string();\n if base.is_empty() {\n return Err(\"请先在设置 → AI 中配置 API Base URL\".into());\n }\n let model = if settings.ai_model.trim().is_empty() {\n \"deepseek-chat\".to_string()\n } else {\n settings.ai_model.trim().to_string()\n };\n let key = get_secret(\"ai\", \"token\")\n .filter(|s| !s.is_empty())\n .ok_or(\"请先在设置 → AI 中填写 API Key\")?;\n\n let (system, user) = build_messages(action, text, context)?;\n let body = json!({\n \"model\": model,\n \"messages\": [\n { \"role\": \"system\", \"content\": system },\n { \"role\": \"user\", \"content\": user }\n ],\n \"temperature\": if action == \"continue\" { 0.8 } else { 0.4 },\n \"stream\": false\n });\n\n let content = post_completion(&base, &key, &body)?;\n // Strip common markdown fences if model wraps entire answer\n Ok(strip_outer_fence(&content))\n}\n\nconst DOC_START: &str = \"<<<MAHODOWN_DOC>>>\";\nconst DOC_END: &str = \"<<<END>>>\";\n\n/// Multi-turn chat that can also edit the whole document. `messages` is the\n/// panel's history (user/assistant). Returns `{ reply, document|null }`.\npub fn chat(messages: &[(String, String)], document: &str, model_override: &str) -> Result<Value, String> {\n let settings = load_settings();\n let base = settings.ai_base_url.trim().trim_end_matches('/').to_string();\n if base.is_empty() {\n return Err(\"请先在设置 → AI 中配置 API Base URL\".into());\n }\n let model = {\n let m = model_override.trim();\n if !m.is_empty() {\n m.to_string()\n } else if settings.ai_model.trim().is_empty() {\n \"deepseek-chat\".to_string()\n } else {\n settings.ai_model.trim().to_string()\n }\n };\n let key = get_secret(\"ai\", \"token\")\n .filter(|s| !s.is_empty())\n .ok_or(\"请先在设置 → AI 中填写 API Key\")?;\n\n let system = format!(\n \"你是 MahoDown 的写作助手,嵌入在一个 Markdown 编辑器里。\\n\\\n - 正常问答时用简洁中文回复。\\n\\\n - 当用户希望你修改文档(润色、改写、增删、翻译、调整结构等)时:先用一两句话说明你改了什么,\\\n 然后输出【完整】的修改后 Markdown 文档,并严格用下面两行标记包裹(每个标记独占一行):\\n\\\n {DOC_START}\\n(这里放完整文档内容)\\n{DOC_END}\\n\\\n 只有确实修改文档时才输出这个块;纯问答不要输出。必须输出整篇文档(含未改动部分),不要用省略号省略。\"\n );\n let doc_msg = format!(\"这是当前文档,全文如下:\\n{DOC_START}\\n{document}\\n{DOC_END}\");\n\n let mut msgs = vec![\n json!({ \"role\": \"system\", \"content\": system }),\n json!({ \"role\": \"system\", \"content\": doc_msg }),\n ];\n for (role, content) in messages {\n let r = if role == \"assistant\" { \"assistant\" } else { \"user\" };\n msgs.push(json!({ \"role\": r, \"content\": content }));\n }\n\n let body = json!({ \"model\": model, \"messages\": msgs, \"temperature\": 0.5, \"stream\": false });\n let content = post_completion(&base, &key, &body)?;\n let (reply, doc) = split_doc(&content);\n Ok(json!({ \"reply\": reply, \"document\": doc }))\n}\n\n/// Split an assistant reply into chat text + optional full-document block.\nfn split_doc(content: &str) -> (String, Option<String>) {\n if let Some(start) = content.find(DOC_START) {\n let before = content[..start].trim().to_string();\n let after = &content[start + DOC_START.len()..];\n let doc = match after.find(DOC_END) {\n Some(end) => &after[..end],\n None => after, // start marker but truncated / no end marker\n };\n let doc = doc.trim_matches(|c: char| c == '\\n' || c == '\\r').to_string();\n let reply = if before.is_empty() {\n \"已更新文档,请在下方查看改动。\".to_string()\n } else {\n before\n };\n return (reply, Some(doc));\n }\n (content.trim().to_string(), None)\n}\n\nfn chat_url(base: &str) -> String {\n if base.ends_with(\"/chat/completions\") {\n base.to_string()\n } else if base.ends_with(\"/v1\") {\n format!(\"{base}/chat/completions\")\n } else {\n format!(\"{base}/v1/chat/completions\")\n }\n}\n\n/// Shared OpenAI-compatible POST → assistant message content.\nfn post_completion(base: &str, key: &str, body: &Value) -> Result<String, String> {\n let client = reqwest::blocking::Client::builder()\n .timeout(std::time::Duration::from_secs(120))\n .build()\n .map_err(|e| e.to_string())?;\n let resp = client\n .post(chat_url(base))\n .header(\"Authorization\", format!(\"Bearer {key}\"))\n .header(\"Content-Type\", \"application/json\")\n .json(body)\n .send()\n .map_err(|e| format!(\"AI 请求失败: {e}\"))?;\n\n let status = resp.status();\n let raw = resp.text().map_err(|e| e.to_string())?;\n if !status.is_success() {\n let hint = serde_json::from_str::<Value>(&raw)\n .ok()\n .and_then(|v| {\n v.pointer(\"/error/message\")\n .and_then(|m| m.as_str())\n .map(|s| s.to_string())\n })\n .unwrap_or_else(|| raw.chars().take(200).collect());\n return Err(format!(\"AI 返回 {status}: {hint}\"));\n }\n let parsed: Value = serde_json::from_str(&raw).map_err(|e| format!(\"解析 AI 响应失败: {e}\"))?;\n parsed\n .pointer(\"/choices/0/message/content\")\n .and_then(|c| c.as_str())\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .ok_or_else(|| \"AI 未返回有效内容\".to_string())\n}\n\nfn build_messages(action: &str, text: &str, context: &str) -> Result<(String, String), String> {\n let text = text.trim();\n let context = context.trim();\n match action {\n \"polish\" => {\n if text.is_empty() {\n return Err(\"请先选中要润色的文字\".into());\n }\n Ok((\n \"你是中文写作润色助手。保持原意、人称与 Markdown 结构(加粗/列表/标题等),\\\n 让表达更通顺、准确、自然。只输出润色后的正文,不要解释、不加引号、不要前后缀。\"\n .into(),\n format!(\"请润色以下内容:\\n\\n{text}\"),\n ))\n }\n \"continue\" => {\n let ctx = if !context.is_empty() {\n context\n } else if !text.is_empty() {\n text\n } else {\n return Err(\"没有可续写的上下文\".into());\n };\n // Cap context size\n let ctx = if ctx.chars().count() > 3000 {\n let s: String = ctx.chars().rev().take(3000).collect::<String>().chars().rev().collect();\n s\n } else {\n ctx.to_string()\n };\n Ok((\n \"你是中文写作助手。根据给定 Markdown 上下文自然续写下一段,保持语气与格式一致。\\\n 只输出续写部分,不要重复原文,不要解释。\"\n .into(),\n format!(\"上下文:\\n\\n{ctx}\\n\\n请续写:\"),\n ))\n }\n \"translate\" => {\n if text.is_empty() {\n return Err(\"请先选中要翻译的文字\".into());\n }\n let mostly_cjk = text.chars().filter(|c| ('\\u{4e00}'..='\\u{9fff}').contains(c)).count()\n * 2\n > text.chars().filter(|c| !c.is_whitespace()).count();\n let direction = if mostly_cjk {\n \"将以下中文翻译成地道英文\"\n } else {\n \"将以下英文翻译成通顺中文\"\n };\n Ok((\n \"你是翻译助手。保持 Markdown 结构。只输出译文,不要解释。\"\n .into(),\n format!(\"{direction}:\\n\\n{text}\"),\n ))\n }\n other => Err(format!(\"未知 AI 动作: {other}\")),\n }\n}\n\nfn strip_outer_fence(s: &str) -> String {\n let t = s.trim();\n if let Some(rest) = t.strip_prefix(\"```\") {\n if let Some(nl) = rest.find('\\n') {\n let body = &rest[nl + 1..];\n if let Some(end) = body.rfind(\"```\") {\n return body[..end].trim().to_string();\n }\n }\n }\n t.to_string()\n}\n\npub fn ai_settings_public(settings: &AppSettings) -> Value {\n let has_key = get_secret(\"ai\", \"token\")\n .map(|s| !s.is_empty())\n .unwrap_or(false);\n json!({\n \"aiBaseUrl\": settings.ai_base_url,\n \"aiModel\": settings.ai_model,\n \"aiApiKey\": if has_key { \"********\" } else { \"\" },\n \"hasAiKey\": has_key\n })\n}\n\npub fn merge_ai_into_settings_json(mut value: Value, settings: &AppSettings) -> Value {\n if let Some(obj) = value.as_object_mut() {\n let pub_ai = ai_settings_public(settings);\n if let Some(map) = pub_ai.as_object() {\n for (k, v) in map {\n obj.insert(k.clone(), v.clone());\n }\n }\n }\n value\n}\n\n/// Preset catalog for UI (OpenAI-compatible endpoints).\npub fn presets() -> Vec<HashMap<&'static str, &'static str>> {\n vec![\n HashMap::from([\n (\"id\", \"deepseek\"),\n (\"title\", \"DeepSeek\"),\n (\"baseUrl\", \"https://api.deepseek.com/v1\"),\n (\"model\", \"deepseek-chat\"),\n ]),\n HashMap::from([\n (\"id\", \"openai\"),\n (\"title\", \"OpenAI\"),\n (\"baseUrl\", \"https://api.openai.com/v1\"),\n (\"model\", \"gpt-4o-mini\"),\n ]),\n HashMap::from([\n (\"id\", \"moonshot\"),\n (\"title\", \"Moonshot / Kimi\"),\n (\"baseUrl\", \"https://api.moonshot.cn/v1\"),\n (\"model\", \"moonshot-v1-8k\"),\n ]),\n HashMap::from([\n (\"id\", \"siliconflow\"),\n (\"title\", \"硅基流动\"),\n (\"baseUrl\", \"https://api.siliconflow.cn/v1\"),\n (\"model\", \"deepseek-ai/DeepSeek-V3\"),\n ]),\n HashMap::from([\n (\"id\", \"ollama\"),\n (\"title\", \"Ollama 本地\"),\n (\"baseUrl\", \"http://127.0.0.1:11434/v1\"),\n (\"model\", \"qwen2.5\"),\n ]),\n ]\n}\n"}, "2a1f6a4f2e9e81f611efb2d80cb95d8e1dd74436": {"t": 1785761181.6146603, "n": 6849, "h": "815ebdb8c15ff73d", "c": "use serde_json::{json, Value};\nuse std::collections::HashMap;\n\nuse crate::settings::{get_secret, load_settings, AppSettings};\n\n/// OpenAI-compatible chat completion (DeepSeek / OpenAI / Ollama / 硅基流动 / Moonshot …).\npub fn complete(action: &str, text: &str, context: &str) -> Result<String, String> {\n let settings = load_settings();\n let base = settings\n .ai_base_url\n .trim()\n .trim_end_matches('/')\n .to_string();\n if base.is_empty() {\n return Err(\"请先在设置 → AI 中配置 API Base URL\".into());\n }\n let model = if settings.ai_model.trim().is_empty() {\n \"deepseek-chat\".to_string()\n } else {\n settings.ai_model.trim().to_string()\n };\n let key = get_secret(\"ai\", \"token\")\n .filter(|s| !s.is_empty())\n .ok_or(\"请先在设置 → AI 中填写 API Key\")?;\n\n let (system, user) = build_messages(action, text, context)?;\n let url = if base.ends_with(\"/chat/completions\") {\n base\n } else if base.ends_with(\"/v1\") {\n format!(\"{base}/chat/completions\")\n } else {\n format!(\"{base}/v1/chat/completions\")\n };\n\n let body = json!({\n \"model\": model,\n \"messages\": [\n { \"role\": \"system\", \"content\": system },\n { \"role\": \"user\", \"content\": user }\n ],\n \"temperature\": if action == \"continue\" { 0.8 } else { 0.4 },\n \"stream\": false\n });\n\n let client = reqwest::blocking::Client::builder()\n .timeout(std::time::Duration::from_secs(90))\n .build()\n .map_err(|e| e.to_string())?;\n\n let resp = client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {key}\"))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .map_err(|e| format!(\"AI 请求失败: {e}\"))?;\n\n let status = resp.status();\n let raw = resp.text().map_err(|e| e.to_string())?;\n if !status.is_success() {\n let hint = serde_json::from_str::<Value>(&raw)\n .ok()\n .and_then(|v| {\n v.pointer(\"/error/message\")\n .and_then(|m| m.as_str())\n .map(|s| s.to_string())\n })\n .unwrap_or_else(|| raw.chars().take(200).collect());\n return Err(format!(\"AI 返回 {status}: {hint}\"));\n }\n\n let parsed: Value = serde_json::from_str(&raw).map_err(|e| format!(\"解析 AI 响应失败: {e}\"))?;\n let content = parsed\n .pointer(\"/choices/0/message/content\")\n .and_then(|c| c.as_str())\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .ok_or(\"AI 未返回有效内容\")?;\n\n // Strip common markdown fences if model wraps entire answer\n Ok(strip_outer_fence(&content))\n}\n\nfn build_messages(action: &str, text: &str, context: &str) -> Result<(String, String), String> {\n let text = text.trim();\n let context = context.trim();\n match action {\n \"polish\" => {\n if text.is_empty() {\n return Err(\"请先选中要润色的文字\".into());\n }\n Ok((\n \"你是中文写作润色助手。保持原意、人称与 Markdown 结构(加粗/列表/标题等),\\\n 让表达更通顺、准确、自然。只输出润色后的正文,不要解释、不加引号、不要前后缀。\"\n .into(),\n format!(\"请润色以下内容:\\n\\n{text}\"),\n ))\n }\n \"continue\" => {\n let ctx = if !context.is_empty() {\n context\n } else if !text.is_empty() {\n text\n } else {\n return Err(\"没有可续写的上下文\".into());\n };\n // Cap context size\n let ctx = if ctx.chars().count() > 3000 {\n let s: String = ctx.chars().rev().take(3000).collect::<String>().chars().rev().collect();\n s\n } else {\n ctx.to_string()\n };\n Ok((\n \"你是中文写作助手。根据给定 Markdown 上下文自然续写下一段,保持语气与格式一致。\\\n 只输出续写部分,不要重复原文,不要解释。\"\n .into(),\n format!(\"上下文:\\n\\n{ctx}\\n\\n请续写:\"),\n ))\n }\n \"translate\" => {\n if text.is_empty() {\n return Err(\"请先选中要翻译的文字\".into());\n }\n let mostly_cjk = text.chars().filter(|c| ('\\u{4e00}'..='\\u{9fff}').contains(c)).count()\n * 2\n > text.chars().filter(|c| !c.is_whitespace()).count();\n let direction = if mostly_cjk {\n \"将以下中文翻译成地道英文\"\n } else {\n \"将以下英文翻译成通顺中文\"\n };\n Ok((\n \"你是翻译助手。保持 Markdown 结构。只输出译文,不要解释。\"\n .into(),\n format!(\"{direction}:\\n\\n{text}\"),\n ))\n }\n other => Err(format!(\"未知 AI 动作: {other}\")),\n }\n}\n\nfn strip_outer_fence(s: &str) -> String {\n let t = s.trim();\n if let Some(rest) = t.strip_prefix(\"```\") {\n if let Some(nl) = rest.find('\\n') {\n let body = &rest[nl + 1..];\n if let Some(end) = body.rfind(\"```\") {\n return body[..end].trim().to_string();\n }\n }\n }\n t.to_string()\n}\n\npub fn ai_settings_public(settings: &AppSettings) -> Value {\n let has_key = get_secret(\"ai\", \"token\")\n .map(|s| !s.is_empty())\n .unwrap_or(false);\n json!({\n \"aiBaseUrl\": settings.ai_base_url,\n \"aiModel\": settings.ai_model,\n \"aiApiKey\": if has_key { \"********\" } else { \"\" },\n \"hasAiKey\": has_key\n })\n}\n\npub fn merge_ai_into_settings_json(mut value: Value, settings: &AppSettings) -> Value {\n if let Some(obj) = value.as_object_mut() {\n let pub_ai = ai_settings_public(settings);\n if let Some(map) = pub_ai.as_object() {\n for (k, v) in map {\n obj.insert(k.clone(), v.clone());\n }\n }\n }\n value\n}\n\n/// Preset catalog for UI (OpenAI-compatible endpoints).\npub fn presets() -> Vec<HashMap<&'static str, &'static str>> {\n vec![\n HashMap::from([\n (\"id\", \"deepseek\"),\n (\"title\", \"DeepSeek\"),\n (\"baseUrl\", \"https://api.deepseek.com/v1\"),\n (\"model\", \"deepseek-chat\"),\n ]),\n HashMap::from([\n (\"id\", \"openai\"),\n (\"title\", \"OpenAI\"),\n (\"baseUrl\", \"https://api.openai.com/v1\"),\n (\"model\", \"gpt-4o-mini\"),\n ]),\n HashMap::from([\n (\"id\", \"moonshot\"),\n (\"title\", \"Moonshot / Kimi\"),\n (\"baseUrl\", \"https://api.moonshot.cn/v1\"),\n (\"model\", \"moonshot-v1-8k\"),\n ]),\n HashMap::from([\n (\"id\", \"siliconflow\"),\n (\"title\", \"硅基流动\"),\n (\"baseUrl\", \"https://api.siliconflow.cn/v1\"),\n (\"model\", \"deepseek-ai/DeepSeek-V3\"),\n ]),\n HashMap::from([\n (\"id\", \"ollama\"),\n (\"title\", \"Ollama 本地\"),\n (\"baseUrl\", \"http://127.0.0.1:11434/v1\"),\n (\"model\", \"qwen2.5\"),\n ]),\n ]\n}\n"}}}}