Files

1 line
37 KiB
JSON

{"repo": "amluckydave/shiyu", "files": {"src/stores/settingsStore.ts": {"df22b81d5197ba813cc8ab43dc66bec74c945f49": {"t": 1785746037.013799, "n": 2496, "h": "18ae3b97dcfd9ed8", "c": "import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\nimport { getSetting, setSetting, getAllSettings } from '../services/api'\n\n/**\n * 设置状态管理\n * 管理应用配置和用户偏好设置\n */\nexport const useSettingsStore = defineStore('settings', () => {\n // ── 状态 ──────────────────────────────────────────────\n\n const settings = ref<Record<string, string>>({})\n const loaded = ref(false)\n\n // 常用设置项的快捷访问\n const apiKey = computed({\n get: () => settings.value['api_key'] || '',\n set: (val: string) => setSettingValue('api_key', val),\n })\n\n const apiUrl = computed({\n get: () => settings.value['api_url'] || 'https://api.deepseek.com',\n set: (val: string) => setSettingValue('api_url', val),\n })\n\n const modelName = computed({\n get: () => settings.value['model_name'] || 'deepseek-chat',\n set: (val: string) => setSettingValue('model_name', val),\n })\n\n const theme = computed({\n get: () => settings.value['theme'] || 'light',\n set: (val: string) => setSettingValue('theme', val),\n })\n\n const fontSize = computed({\n get: () => settings.value['font_size'] || 'medium',\n set: (val: string) => setSettingValue('font_size', val),\n })\n\n // ── Actions ───────────────────────────────────────────\n\n async function loadSettings() {\n if (loaded.value) return\n\n try {\n const allSettings = await getAllSettings()\n settings.value = Object.fromEntries(\n allSettings.map(s => [s.key, s.value])\n )\n loaded.value = true\n } catch (e) {\n console.error('Failed to load settings:', e)\n }\n }\n\n async function setSettingValue(key: string, value: string) {\n try {\n await setSetting(key, value)\n settings.value[key] = value\n } catch (e) {\n console.error(`Failed to set setting ${key}:`, e)\n throw e\n }\n }\n\n async function getSettingValue(key: string): Promise<string | null> {\n if (key in settings.value) {\n return settings.value[key]\n }\n\n try {\n const value = await getSetting(key)\n if (value) {\n settings.value[key] = value\n }\n return value\n } catch (e) {\n console.error(`Failed to get setting ${key}:`, e)\n return null\n }\n }\n\n function getSettingImmediate(key: string): string | null {\n return settings.value[key] || null\n }\n\n return {\n settings,\n loaded,\n apiKey,\n apiUrl,\n modelName,\n theme,\n fontSize,\n loadSettings,\n setSetting: setSettingValue,\n getSetting: getSettingValue,\n getSettingImmediate,\n }\n})\n"}, "12631d811b8b00c41b0fbd7827dbba7dea13b237": {"t": 1785759086.58897, "n": 2496, "h": "18ae3b97dcfd9ed8", "c": "import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\nimport { getSetting, setSetting, getAllSettings } from '../services/api'\n\n/**\n * 设置状态管理\n * 管理应用配置和用户偏好设置\n */\nexport const useSettingsStore = defineStore('settings', () => {\n // ── 状态 ──────────────────────────────────────────────\n\n const settings = ref<Record<string, string>>({})\n const loaded = ref(false)\n\n // 常用设置项的快捷访问\n const apiKey = computed({\n get: () => settings.value['api_key'] || '',\n set: (val: string) => setSettingValue('api_key', val),\n })\n\n const apiUrl = computed({\n get: () => settings.value['api_url'] || 'https://api.deepseek.com',\n set: (val: string) => setSettingValue('api_url', val),\n })\n\n const modelName = computed({\n get: () => settings.value['model_name'] || 'deepseek-chat',\n set: (val: string) => setSettingValue('model_name', val),\n })\n\n const theme = computed({\n get: () => settings.value['theme'] || 'light',\n set: (val: string) => setSettingValue('theme', val),\n })\n\n const fontSize = computed({\n get: () => settings.value['font_size'] || 'medium',\n set: (val: string) => setSettingValue('font_size', val),\n })\n\n // ── Actions ───────────────────────────────────────────\n\n async function loadSettings() {\n if (loaded.value) return\n\n try {\n const allSettings = await getAllSettings()\n settings.value = Object.fromEntries(\n allSettings.map(s => [s.key, s.value])\n )\n loaded.value = true\n } catch (e) {\n console.error('Failed to load settings:', e)\n }\n }\n\n async function setSettingValue(key: string, value: string) {\n try {\n await setSetting(key, value)\n settings.value[key] = value\n } catch (e) {\n console.error(`Failed to set setting ${key}:`, e)\n throw e\n }\n }\n\n async function getSettingValue(key: string): Promise<string | null> {\n if (key in settings.value) {\n return settings.value[key]\n }\n\n try {\n const value = await getSetting(key)\n if (value) {\n settings.value[key] = value\n }\n return value\n } catch (e) {\n console.error(`Failed to get setting ${key}:`, e)\n return null\n }\n }\n\n function getSettingImmediate(key: string): string | null {\n return settings.value[key] || null\n }\n\n return {\n settings,\n loaded,\n apiKey,\n apiUrl,\n modelName,\n theme,\n fontSize,\n loadSettings,\n setSetting: setSettingValue,\n getSetting: getSettingValue,\n getSettingImmediate,\n }\n})\n"}}, "src-tauri/src/commands/ocr.rs": {"df22b81d5197ba813cc8ab43dc66bec74c945f49": {"t": 1785746243.6820881, "n": 12727, "h": "feb987880f8b1783", "c": "// OCR 图片识别(PP-StructureV3 + DeepSeek 校正)\nuse crate::db::Database;\nuse base64::Engine;\nuse regex::Regex;\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::collections::HashMap;\nuse std::path::PathBuf;\nuse tauri::{AppHandle, Emitter, State};\n\n// ── 数据结构 ──────────────────────────────────────────\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct OcrPageResult {\n /// 页码索引 (0-based)\n pub page_index: usize,\n /// OCR 提取的 Markdown 文本(图片引用已替换为本地路径)\n pub markdown: String,\n /// 已保存的本地图片路径列表\n pub saved_images: Vec<String>,\n}\n\n#[derive(Debug, Serialize, Clone)]\npub struct OcrProgress {\n pub current: usize,\n pub total: usize,\n pub status: String,\n}\n\n// ── PP-StructureV3 响应结构 ──────────────────────────\n\n#[derive(Debug, Deserialize)]\nstruct PpStructureResponse {\n result: Option<PpResult>,\n #[serde(rename = \"errorCode\")]\n error_code: Option<i32>,\n #[serde(rename = \"errorMsg\")]\n error_msg: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PpResult {\n #[serde(rename = \"layoutParsingResults\")]\n layout_parsing_results: Vec<PpLayoutResult>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PpLayoutResult {\n markdown: PpMarkdown,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PpMarkdown {\n text: String,\n #[serde(default)]\n images: HashMap<String, String>,\n}\n\n// ── 工具函数 ──────────────────────────────────────────\n\n/// OCR 图片的本地存储目录: ~/.shiyu/images/ (与 EPUB 图片共用,前端 imageResolver 统一处理)\nfn ocr_images_dir() -> PathBuf {\n let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(\".\"));\n path.push(\".shiyu\");\n path.push(\"images\");\n path\n}\n\n/// 从数据库读取 OCR 相关设置\nfn get_ocr_settings(db: &Database) -> Result<(String, String), String> {\n let conn = db.conn.lock().map_err(|e| e.to_string())?;\n\n let get_val = |key: &str| -> Option<String> {\n conn.prepare(\"SELECT value FROM settings WHERE key = ?1\")\n .ok()?\n .query_row(rusqlite::params![key], |row| row.get::<_, String>(0))\n .ok()\n };\n\n let api_url = get_val(\"ocr_api_url\")\n .filter(|s| !s.is_empty())\n .ok_or(\"OCR API URL 未配置,请先在设置中配置\")?;\n let token = get_val(\"ocr_api_token\")\n .filter(|s| !s.is_empty())\n .ok_or(\"OCR Token 未配置,请先在设置中配置\")?;\n\n Ok((api_url, token))\n}\n\n/// 从数据库读取 DeepSeek API 设置\nfn get_ai_settings(db: &Database) -> Result<(String, String, String), String> {\n let conn = db.conn.lock().map_err(|e| e.to_string())?;\n\n let get_val = |key: &str| -> Option<String> {\n conn.prepare(\"SELECT value FROM settings WHERE key = ?1\")\n .ok()?\n .query_row(rusqlite::params![key], |row| row.get::<_, String>(0))\n .ok()\n };\n\n let api_key = get_val(\"api_key\").ok_or(\"DeepSeek API Key 未配置\")?;\n let api_url = get_val(\"api_url\")\n .unwrap_or_else(|| \"https://api.deepseek.com/v1/chat/completions\".to_string());\n let model = get_val(\"api_model\").unwrap_or_else(|| \"deepseek-chat\".to_string());\n\n Ok((api_key, api_url, model))\n}\n\n/// 下载图片并保存到本地,返回本地文件路径\nasync fn download_and_save_image(\n client: &reqwest::Client,\n url: &str,\n page_index: usize,\n image_index: usize,\n) -> Result<String, String> {\n let images_dir = ocr_images_dir();\n std::fs::create_dir_all(&images_dir)\n .map_err(|e| format!(\"创建图片目录失败: {}\", e))?;\n\n // 从 URL 推断扩展名,默认 png\n let ext = url\n .rsplit('.')\n .next()\n .and_then(|e| {\n let e = e.split('?').next().unwrap_or(e).to_lowercase();\n match e.as_str() {\n \"jpg\" | \"jpeg\" | \"png\" | \"gif\" | \"webp\" | \"bmp\" => Some(e),\n _ => None,\n }\n })\n .unwrap_or_else(|| \"png\".to_string());\n\n let filename = format!(\"ocr_p{}_img{}_{}.{}\", page_index, image_index, uuid::Uuid::new_v4().as_simple(), ext);\n let file_path = images_dir.join(&filename);\n\n let response = client\n .get(url)\n .send()\n .await\n .map_err(|e| format!(\"下载图片失败: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"图片下载失败 ({}): {}\", response.status(), url));\n }\n\n let bytes = response\n .bytes()\n .await\n .map_err(|e| format!(\"读取图片数据失败: {}\", e))?;\n\n std::fs::write(&file_path, &bytes)\n .map_err(|e| format!(\"保存图片失败: {}\", e))?;\n\n // 使用正斜杠,保证 Markdown 兼容性 + 前端 imageResolver 正则匹配\n Ok(file_path.to_string_lossy().to_string().replace('\\\\', \"/\"))\n}\n\n/// 将 PP-StructureV3 返回的 HTML 图片标签转换为 Markdown 格式\n/// 例如: `<div style=\"text-align: center;\"><img src=\"path\" alt=\"图片\" width=\"57%\"></div>`\n/// 转换为: `![图片](path)`\nfn html_images_to_markdown(text: &str) -> String {\n let mut result = text.to_string();\n\n // 1. 匹配 <div...><img ...></div> 包裹的图片(PP-StructureV3 常见输出格式)\n let div_img_re = Regex::new(\n r#\"<div[^>]*>\\s*<img\\s+[^>]*src=[\"']([^\"']+)[\"'][^>]*>\\s*</div>\"#\n ).unwrap();\n\n result = div_img_re.replace_all(&result, |caps: &regex::Captures| {\n let src = &caps[1];\n // 尝试提取 alt 属性\n let alt = extract_img_alt(&caps[0]);\n format!(\"![{}]({})\", alt, src)\n }).to_string();\n\n // 2. 匹配剩余的独立 <img> 标签\n let img_re = Regex::new(\n r#\"<img\\s+[^>]*src=[\"']([^\"']+)[\"'][^>]*>\"#\n ).unwrap();\n\n result = img_re.replace_all(&result, |caps: &regex::Captures| {\n let src = &caps[1];\n let alt = extract_img_alt(&caps[0]);\n format!(\"![{}]({})\", alt, src)\n }).to_string();\n\n result\n}\n\n/// 从 <img> 标签中提取 alt 属性值\nfn extract_img_alt(img_tag: &str) -> String {\n let alt_re = Regex::new(r#\"alt=[\"']([^\"']*)[\"']\"#).unwrap();\n alt_re.captures(img_tag)\n .map(|c| c[1].to_string())\n .unwrap_or_default()\n}\n\n// ── Tauri 命令 ────────────────────────────────────────\n\n/// OCR 提取:逐页发送图片到 PP-StructureV3,下载图片到本地,返回处理后的 Markdown\n#[tauri::command]\npub async fn ocr_extract_pages(\n app: AppHandle,\n db: State<'_, Database>,\n image_paths: Vec<String>,\n) -> Result<Vec<OcrPageResult>, String> {\n if image_paths.is_empty() {\n return Err(\"请至少上传一张图片\".to_string());\n }\n\n let (api_url, token) = get_ocr_settings(&db)?;\n let client = reqwest::Client::new();\n let total = image_paths.len();\n let mut results: Vec<OcrPageResult> = Vec::with_capacity(total);\n\n for (i, path) in image_paths.iter().enumerate() {\n // 发送进度: OCR 识别\n let _ = app.emit(\n \"ocr-progress\",\n OcrProgress {\n current: i + 1,\n total,\n status: format!(\"正在识别第 {}/{} 页...\", i + 1, total),\n },\n );\n\n // 读取图片并 base64 编码\n let file_bytes = std::fs::read(path)\n .map_err(|e| format!(\"读取图片失败 ({}): {}\", path, e))?;\n let file_b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes);\n\n // 调用 PP-StructureV3 API\n let response = client\n .post(&api_url)\n .header(\"Authorization\", format!(\"token {}\", token))\n .header(\"Content-Type\", \"application/json\")\n .json(&json!({\n \"file\": file_b64,\n \"fileType\": 1,\n \"useDocOrientationClassify\": true,\n \"useDocUnwarping\": true,\n \"useTextlineOrientation\": true\n }))\n .send()\n .await\n .map_err(|e| format!(\"OCR 请求失败 (第{}页): {}\", i + 1, e))?;\n\n if !response.status().is_success() {\n let status = response.status();\n let body = response.text().await.unwrap_or_default();\n return Err(format!(\"OCR API 错误 (第{}页, {}): {}\", i + 1, status, body));\n }\n\n let pp_resp: PpStructureResponse = response\n .json()\n .await\n .map_err(|e| format!(\"解析 OCR 响应失败 (第{}页): {}\", i + 1, e))?;\n\n // 检查错误码\n if let Some(code) = pp_resp.error_code {\n if code != 0 {\n return Err(format!(\n \"OCR 识别失败 (第{}页): {}\",\n i + 1,\n pp_resp.error_msg.unwrap_or_else(|| \"未知错误\".to_string())\n ));\n }\n }\n\n // 提取结果\n let result = pp_resp\n .result\n .ok_or_else(|| format!(\"OCR 返回空结果 (第{}页)\", i + 1))?;\n\n // 合并该页所有 layout 结果\n let mut page_markdown = String::new();\n let mut all_images: HashMap<String, String> = HashMap::new();\n\n for layout in &result.layout_parsing_results {\n if !page_markdown.is_empty() {\n page_markdown.push_str(\"\\n\\n\");\n }\n page_markdown.push_str(&layout.markdown.text);\n all_images.extend(layout.markdown.images.clone());\n }\n\n // 下载图片到本地并替换 Markdown 中的引用\n let mut saved_images: Vec<String> = Vec::new();\n\n if !all_images.is_empty() {\n let _ = app.emit(\n \"ocr-progress\",\n OcrProgress {\n current: i + 1,\n total,\n status: format!(\"正在下载第 {}/{} 页的 {} 张图片...\", i + 1, total, all_images.len()),\n },\n );\n\n for (img_idx, (ref_path, url)) in all_images.iter().enumerate() {\n match download_and_save_image(&client, url, i, img_idx).await {\n Ok(local_path) => {\n // 替换 Markdown 中的图片引用: ![xxx](ref_path) -> ![xxx](local_path)\n page_markdown = page_markdown.replace(ref_path, &local_path);\n saved_images.push(local_path);\n }\n Err(e) => {\n // 图片下载失败不中断整个流程,保留原始 URL 引用\n eprintln!(\"图片下载失败 (第{}页, 第{}张): {}\", i + 1, img_idx + 1, e);\n }\n }\n }\n }\n\n // 将 HTML <img> 标签转为 Markdown ![alt](src) 格式\n page_markdown = html_images_to_markdown(&page_markdown);\n\n results.push(OcrPageResult {\n page_index: i,\n markdown: page_markdown,\n saved_images,\n });\n }\n\n // 完成进度\n let _ = app.emit(\n \"ocr-progress\",\n OcrProgress {\n current: total,\n total,\n status: \"OCR 识别完成\".to_string(),\n },\n );\n\n Ok(results)\n}\n\n/// 使用 DeepSeek 校正 OCR 结果\n#[tauri::command]\npub async fn ocr_refine_with_ai(\n db: State<'_, Database>,\n raw_markdown: String,\n) -> Result<String, String> {\n if raw_markdown.trim().is_empty() {\n return Err(\"OCR 文本为空,无法校正\".to_string());\n }\n\n let (api_key, api_url, model) = get_ai_settings(&db)?;\n\n let system_prompt = r#\"你是一个专业的英文文章 OCR 校对助手。用户会给你通过 OCR 识别出的英文文章文本(Markdown 格式)。请你严格按照以下规则校正:\n\n## 文字校正\n1. 修复明显的 OCR 识别错误(错字、多余字符、乱码、数字/字母混淆如 0↔O, 1↔l↔I)\n2. 修复段落连续性(将因分页/分栏导致的断句重新连接为完整段落)\n3. 合并跨页断行:如果上一段末尾无句号且下一段开头是小写字母,应合并为同一段\n\n## 标点与格式(非常重要)\n4. 英文标点后面必须有且仅有一个空格:句号`. `、逗号`, `、冒号`: `、分号`; `、感叹号`! `、问号`? `\n5. 左括号前加空格,右括号后加空格:`word (example) next`\n6. 引号使用标准英文引号 \"\" 和 '',不要用中文引号\n7. 连字符 `-` 和破折号 `—` 应正确区分使用\n8. 省略号使用 `...`(三个英文句号)\n9. 移除多余空格(连续空格合并为一个,行首行尾空格移除)\n10. 移除 OCR 产生的多余空行(连续两个以上空行合并为一个)\n\n## 公式与化学符号(非常重要)\n11. 将所有 LaTeX 数学公式转换为 Unicode 纯文本,不要保留 $ 符号和 LaTeX 语法\n - `$CO_{2}$` → `CO₂`\n - `$H_{2}O$` → `H₂O`\n - `$O_{3}$` → `O₃`\n - `$x^{2}$` → `x²`\n - `$10^{6}$` → `10⁶`\n - 下标使用 Unicode 下标字符:₀₁₂₃₄₅₆₇₈₉ₐₑₒₓ\n - 上标使用 Unicode 上标字符:⁰¹²³⁴⁵⁶⁷⁸⁹\n - 其他无法用 Unicode 表示的公式,用纯文本近似表达\n\n## 结构保持\n12. 保持原始段落和标题层级结构\n13. 保留原文的 Markdown 格式(加粗、斜体、列表等)\n14. 保留文中的图片引用标记 `![...](...)` 的路径和格式,不要修改图片引用路径\n\n## 禁止操作\n15. 不要翻译任何原文文字\n16. 不要添加、删除或重写原文内容\n17. 不要添加注释、说明或解释\n18. 不要改变原文的措辞或表达方式(只修正明显的 OCR 错误)\n\n只输出校正后的文章 Markdown 内容,不要输出其他任何内容。\"#;\n\n let client = reqwest::Client::new();\n let response = client\n .post(&api_url)\n .header(\"Authorization\", format!(\"Bearer {}\", api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&json!({\n \"model\": model,\n \"messages\": [\n { \"role\": \"system\", \"content\": system_prompt },\n { \"role\": \"user\", \"content\": raw_markdown }\n ],\n \"temperature\": 0.15,\n \"max_tokens\": 8192\n }))\n .send()\n .await\n .map_err(|e| format!(\"AI 校正请求失败: {}\", e))?;\n\n if !response.status().is_success() {\n let status = response.status();\n let body = response.text().await.unwrap_or_default();\n return Err(format!(\"AI 校正 API 错误 ({}): {}\", status, body));\n }\n\n let body: serde_json::Value = response\n .json()\n .await\n .map_err(|e| format!(\"解析 AI 校正响应失败: {}\", e))?;\n\n let result = body[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .unwrap_or(\"\")\n .to_string();\n\n if result.is_empty() {\n return Err(\"AI 校正返回空结果\".to_string());\n }\n\n Ok(result)\n}\n"}, "12631d811b8b00c41b0fbd7827dbba7dea13b237": {"t": 1785759087.7311714, "n": 12727, "h": "feb987880f8b1783", "c": "// OCR 图片识别(PP-StructureV3 + DeepSeek 校正)\nuse crate::db::Database;\nuse base64::Engine;\nuse regex::Regex;\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::collections::HashMap;\nuse std::path::PathBuf;\nuse tauri::{AppHandle, Emitter, State};\n\n// ── 数据结构 ──────────────────────────────────────────\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct OcrPageResult {\n /// 页码索引 (0-based)\n pub page_index: usize,\n /// OCR 提取的 Markdown 文本(图片引用已替换为本地路径)\n pub markdown: String,\n /// 已保存的本地图片路径列表\n pub saved_images: Vec<String>,\n}\n\n#[derive(Debug, Serialize, Clone)]\npub struct OcrProgress {\n pub current: usize,\n pub total: usize,\n pub status: String,\n}\n\n// ── PP-StructureV3 响应结构 ──────────────────────────\n\n#[derive(Debug, Deserialize)]\nstruct PpStructureResponse {\n result: Option<PpResult>,\n #[serde(rename = \"errorCode\")]\n error_code: Option<i32>,\n #[serde(rename = \"errorMsg\")]\n error_msg: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PpResult {\n #[serde(rename = \"layoutParsingResults\")]\n layout_parsing_results: Vec<PpLayoutResult>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PpLayoutResult {\n markdown: PpMarkdown,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PpMarkdown {\n text: String,\n #[serde(default)]\n images: HashMap<String, String>,\n}\n\n// ── 工具函数 ──────────────────────────────────────────\n\n/// OCR 图片的本地存储目录: ~/.shiyu/images/ (与 EPUB 图片共用,前端 imageResolver 统一处理)\nfn ocr_images_dir() -> PathBuf {\n let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(\".\"));\n path.push(\".shiyu\");\n path.push(\"images\");\n path\n}\n\n/// 从数据库读取 OCR 相关设置\nfn get_ocr_settings(db: &Database) -> Result<(String, String), String> {\n let conn = db.conn.lock().map_err(|e| e.to_string())?;\n\n let get_val = |key: &str| -> Option<String> {\n conn.prepare(\"SELECT value FROM settings WHERE key = ?1\")\n .ok()?\n .query_row(rusqlite::params![key], |row| row.get::<_, String>(0))\n .ok()\n };\n\n let api_url = get_val(\"ocr_api_url\")\n .filter(|s| !s.is_empty())\n .ok_or(\"OCR API URL 未配置,请先在设置中配置\")?;\n let token = get_val(\"ocr_api_token\")\n .filter(|s| !s.is_empty())\n .ok_or(\"OCR Token 未配置,请先在设置中配置\")?;\n\n Ok((api_url, token))\n}\n\n/// 从数据库读取 DeepSeek API 设置\nfn get_ai_settings(db: &Database) -> Result<(String, String, String), String> {\n let conn = db.conn.lock().map_err(|e| e.to_string())?;\n\n let get_val = |key: &str| -> Option<String> {\n conn.prepare(\"SELECT value FROM settings WHERE key = ?1\")\n .ok()?\n .query_row(rusqlite::params![key], |row| row.get::<_, String>(0))\n .ok()\n };\n\n let api_key = get_val(\"api_key\").ok_or(\"DeepSeek API Key 未配置\")?;\n let api_url = get_val(\"api_url\")\n .unwrap_or_else(|| \"https://api.deepseek.com/v1/chat/completions\".to_string());\n let model = get_val(\"api_model\").unwrap_or_else(|| \"deepseek-chat\".to_string());\n\n Ok((api_key, api_url, model))\n}\n\n/// 下载图片并保存到本地,返回本地文件路径\nasync fn download_and_save_image(\n client: &reqwest::Client,\n url: &str,\n page_index: usize,\n image_index: usize,\n) -> Result<String, String> {\n let images_dir = ocr_images_dir();\n std::fs::create_dir_all(&images_dir)\n .map_err(|e| format!(\"创建图片目录失败: {}\", e))?;\n\n // 从 URL 推断扩展名,默认 png\n let ext = url\n .rsplit('.')\n .next()\n .and_then(|e| {\n let e = e.split('?').next().unwrap_or(e).to_lowercase();\n match e.as_str() {\n \"jpg\" | \"jpeg\" | \"png\" | \"gif\" | \"webp\" | \"bmp\" => Some(e),\n _ => None,\n }\n })\n .unwrap_or_else(|| \"png\".to_string());\n\n let filename = format!(\"ocr_p{}_img{}_{}.{}\", page_index, image_index, uuid::Uuid::new_v4().as_simple(), ext);\n let file_path = images_dir.join(&filename);\n\n let response = client\n .get(url)\n .send()\n .await\n .map_err(|e| format!(\"下载图片失败: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"图片下载失败 ({}): {}\", response.status(), url));\n }\n\n let bytes = response\n .bytes()\n .await\n .map_err(|e| format!(\"读取图片数据失败: {}\", e))?;\n\n std::fs::write(&file_path, &bytes)\n .map_err(|e| format!(\"保存图片失败: {}\", e))?;\n\n // 使用正斜杠,保证 Markdown 兼容性 + 前端 imageResolver 正则匹配\n Ok(file_path.to_string_lossy().to_string().replace('\\\\', \"/\"))\n}\n\n/// 将 PP-StructureV3 返回的 HTML 图片标签转换为 Markdown 格式\n/// 例如: `<div style=\"text-align: center;\"><img src=\"path\" alt=\"图片\" width=\"57%\"></div>`\n/// 转换为: `![图片](path)`\nfn html_images_to_markdown(text: &str) -> String {\n let mut result = text.to_string();\n\n // 1. 匹配 <div...><img ...></div> 包裹的图片(PP-StructureV3 常见输出格式)\n let div_img_re = Regex::new(\n r#\"<div[^>]*>\\s*<img\\s+[^>]*src=[\"']([^\"']+)[\"'][^>]*>\\s*</div>\"#\n ).unwrap();\n\n result = div_img_re.replace_all(&result, |caps: &regex::Captures| {\n let src = &caps[1];\n // 尝试提取 alt 属性\n let alt = extract_img_alt(&caps[0]);\n format!(\"![{}]({})\", alt, src)\n }).to_string();\n\n // 2. 匹配剩余的独立 <img> 标签\n let img_re = Regex::new(\n r#\"<img\\s+[^>]*src=[\"']([^\"']+)[\"'][^>]*>\"#\n ).unwrap();\n\n result = img_re.replace_all(&result, |caps: &regex::Captures| {\n let src = &caps[1];\n let alt = extract_img_alt(&caps[0]);\n format!(\"![{}]({})\", alt, src)\n }).to_string();\n\n result\n}\n\n/// 从 <img> 标签中提取 alt 属性值\nfn extract_img_alt(img_tag: &str) -> String {\n let alt_re = Regex::new(r#\"alt=[\"']([^\"']*)[\"']\"#).unwrap();\n alt_re.captures(img_tag)\n .map(|c| c[1].to_string())\n .unwrap_or_default()\n}\n\n// ── Tauri 命令 ────────────────────────────────────────\n\n/// OCR 提取:逐页发送图片到 PP-StructureV3,下载图片到本地,返回处理后的 Markdown\n#[tauri::command]\npub async fn ocr_extract_pages(\n app: AppHandle,\n db: State<'_, Database>,\n image_paths: Vec<String>,\n) -> Result<Vec<OcrPageResult>, String> {\n if image_paths.is_empty() {\n return Err(\"请至少上传一张图片\".to_string());\n }\n\n let (api_url, token) = get_ocr_settings(&db)?;\n let client = reqwest::Client::new();\n let total = image_paths.len();\n let mut results: Vec<OcrPageResult> = Vec::with_capacity(total);\n\n for (i, path) in image_paths.iter().enumerate() {\n // 发送进度: OCR 识别\n let _ = app.emit(\n \"ocr-progress\",\n OcrProgress {\n current: i + 1,\n total,\n status: format!(\"正在识别第 {}/{} 页...\", i + 1, total),\n },\n );\n\n // 读取图片并 base64 编码\n let file_bytes = std::fs::read(path)\n .map_err(|e| format!(\"读取图片失败 ({}): {}\", path, e))?;\n let file_b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes);\n\n // 调用 PP-StructureV3 API\n let response = client\n .post(&api_url)\n .header(\"Authorization\", format!(\"token {}\", token))\n .header(\"Content-Type\", \"application/json\")\n .json(&json!({\n \"file\": file_b64,\n \"fileType\": 1,\n \"useDocOrientationClassify\": true,\n \"useDocUnwarping\": true,\n \"useTextlineOrientation\": true\n }))\n .send()\n .await\n .map_err(|e| format!(\"OCR 请求失败 (第{}页): {}\", i + 1, e))?;\n\n if !response.status().is_success() {\n let status = response.status();\n let body = response.text().await.unwrap_or_default();\n return Err(format!(\"OCR API 错误 (第{}页, {}): {}\", i + 1, status, body));\n }\n\n let pp_resp: PpStructureResponse = response\n .json()\n .await\n .map_err(|e| format!(\"解析 OCR 响应失败 (第{}页): {}\", i + 1, e))?;\n\n // 检查错误码\n if let Some(code) = pp_resp.error_code {\n if code != 0 {\n return Err(format!(\n \"OCR 识别失败 (第{}页): {}\",\n i + 1,\n pp_resp.error_msg.unwrap_or_else(|| \"未知错误\".to_string())\n ));\n }\n }\n\n // 提取结果\n let result = pp_resp\n .result\n .ok_or_else(|| format!(\"OCR 返回空结果 (第{}页)\", i + 1))?;\n\n // 合并该页所有 layout 结果\n let mut page_markdown = String::new();\n let mut all_images: HashMap<String, String> = HashMap::new();\n\n for layout in &result.layout_parsing_results {\n if !page_markdown.is_empty() {\n page_markdown.push_str(\"\\n\\n\");\n }\n page_markdown.push_str(&layout.markdown.text);\n all_images.extend(layout.markdown.images.clone());\n }\n\n // 下载图片到本地并替换 Markdown 中的引用\n let mut saved_images: Vec<String> = Vec::new();\n\n if !all_images.is_empty() {\n let _ = app.emit(\n \"ocr-progress\",\n OcrProgress {\n current: i + 1,\n total,\n status: format!(\"正在下载第 {}/{} 页的 {} 张图片...\", i + 1, total, all_images.len()),\n },\n );\n\n for (img_idx, (ref_path, url)) in all_images.iter().enumerate() {\n match download_and_save_image(&client, url, i, img_idx).await {\n Ok(local_path) => {\n // 替换 Markdown 中的图片引用: ![xxx](ref_path) -> ![xxx](local_path)\n page_markdown = page_markdown.replace(ref_path, &local_path);\n saved_images.push(local_path);\n }\n Err(e) => {\n // 图片下载失败不中断整个流程,保留原始 URL 引用\n eprintln!(\"图片下载失败 (第{}页, 第{}张): {}\", i + 1, img_idx + 1, e);\n }\n }\n }\n }\n\n // 将 HTML <img> 标签转为 Markdown ![alt](src) 格式\n page_markdown = html_images_to_markdown(&page_markdown);\n\n results.push(OcrPageResult {\n page_index: i,\n markdown: page_markdown,\n saved_images,\n });\n }\n\n // 完成进度\n let _ = app.emit(\n \"ocr-progress\",\n OcrProgress {\n current: total,\n total,\n status: \"OCR 识别完成\".to_string(),\n },\n );\n\n Ok(results)\n}\n\n/// 使用 DeepSeek 校正 OCR 结果\n#[tauri::command]\npub async fn ocr_refine_with_ai(\n db: State<'_, Database>,\n raw_markdown: String,\n) -> Result<String, String> {\n if raw_markdown.trim().is_empty() {\n return Err(\"OCR 文本为空,无法校正\".to_string());\n }\n\n let (api_key, api_url, model) = get_ai_settings(&db)?;\n\n let system_prompt = r#\"你是一个专业的英文文章 OCR 校对助手。用户会给你通过 OCR 识别出的英文文章文本(Markdown 格式)。请你严格按照以下规则校正:\n\n## 文字校正\n1. 修复明显的 OCR 识别错误(错字、多余字符、乱码、数字/字母混淆如 0↔O, 1↔l↔I)\n2. 修复段落连续性(将因分页/分栏导致的断句重新连接为完整段落)\n3. 合并跨页断行:如果上一段末尾无句号且下一段开头是小写字母,应合并为同一段\n\n## 标点与格式(非常重要)\n4. 英文标点后面必须有且仅有一个空格:句号`. `、逗号`, `、冒号`: `、分号`; `、感叹号`! `、问号`? `\n5. 左括号前加空格,右括号后加空格:`word (example) next`\n6. 引号使用标准英文引号 \"\" 和 '',不要用中文引号\n7. 连字符 `-` 和破折号 `—` 应正确区分使用\n8. 省略号使用 `...`(三个英文句号)\n9. 移除多余空格(连续空格合并为一个,行首行尾空格移除)\n10. 移除 OCR 产生的多余空行(连续两个以上空行合并为一个)\n\n## 公式与化学符号(非常重要)\n11. 将所有 LaTeX 数学公式转换为 Unicode 纯文本,不要保留 $ 符号和 LaTeX 语法\n - `$CO_{2}$` → `CO₂`\n - `$H_{2}O$` → `H₂O`\n - `$O_{3}$` → `O₃`\n - `$x^{2}$` → `x²`\n - `$10^{6}$` → `10⁶`\n - 下标使用 Unicode 下标字符:₀₁₂₃₄₅₆₇₈₉ₐₑₒₓ\n - 上标使用 Unicode 上标字符:⁰¹²³⁴⁵⁶⁷⁸⁹\n - 其他无法用 Unicode 表示的公式,用纯文本近似表达\n\n## 结构保持\n12. 保持原始段落和标题层级结构\n13. 保留原文的 Markdown 格式(加粗、斜体、列表等)\n14. 保留文中的图片引用标记 `![...](...)` 的路径和格式,不要修改图片引用路径\n\n## 禁止操作\n15. 不要翻译任何原文文字\n16. 不要添加、删除或重写原文内容\n17. 不要添加注释、说明或解释\n18. 不要改变原文的措辞或表达方式(只修正明显的 OCR 错误)\n\n只输出校正后的文章 Markdown 内容,不要输出其他任何内容。\"#;\n\n let client = reqwest::Client::new();\n let response = client\n .post(&api_url)\n .header(\"Authorization\", format!(\"Bearer {}\", api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&json!({\n \"model\": model,\n \"messages\": [\n { \"role\": \"system\", \"content\": system_prompt },\n { \"role\": \"user\", \"content\": raw_markdown }\n ],\n \"temperature\": 0.15,\n \"max_tokens\": 8192\n }))\n .send()\n .await\n .map_err(|e| format!(\"AI 校正请求失败: {}\", e))?;\n\n if !response.status().is_success() {\n let status = response.status();\n let body = response.text().await.unwrap_or_default();\n return Err(format!(\"AI 校正 API 错误 ({}): {}\", status, body));\n }\n\n let body: serde_json::Value = response\n .json()\n .await\n .map_err(|e| format!(\"解析 AI 校正响应失败: {}\", e))?;\n\n let result = body[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .unwrap_or(\"\")\n .to_string();\n\n if result.is_empty() {\n return Err(\"AI 校正返回空结果\".to_string());\n }\n\n Ok(result)\n}\n"}}}}