1 line
30 KiB
JSON
1 line
30 KiB
JSON
{"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"}}}} |