Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/callioper_ebook-pdf-downloader.json

1 line
207 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": "callioper/ebook-pdf-downloader", "files": {"frontend/src/components/settings/OCRNetworkSection.tsx": {"0be0544d107358c0fa422cb66ffa7d9d9faf1af0": {"t": 1785747357.4945219, "n": 49257, "h": "57f1b74e63731383", "c": "import React, { useState, useEffect, useRef, useCallback } from 'react'\nimport { SectionProps } from './types'\nimport { LLM_OCR_RECOMMENDED, API_BASE } from '../../constants'\n\nfunction StatusDot({ status }: { status: 'green' | 'red' | 'yellow' | null }) {\n const colors: Record<string, string> = {\n green: 'bg-green-500',\n red: 'bg-red-500',\n yellow: 'bg-yellow-500',\n }\n return (\n <span className={`inline-block w-2 h-2 rounded-full ${status ? colors[status] : 'bg-gray-300'}`} />\n )\n}\n\nconst OCR_ENGINES = [\n { key: 'tesseract', name: 'Tesseract OCR', desc: '内置引擎,需 chi_sim 语言包' },\n { key: 'paddleocr', name: 'PaddleOCR', desc: '百度引擎,需 Python 3.11 虚拟环境' },\n { key: 'llm_ocr', name: 'LLM OCR', desc: '大模型视觉识别,无需本地引擎' },\n { key: 'mineru', name: 'MinerU', desc: '线上 API 精准解析,需 Token' },\n { key: 'paddleocr_online', name: 'PaddleOCR-VL-1.5', desc: '线上 API,需 Token' },\n]\n\nconst OCR_INSTALL_GUIDE = `## 安装 OCR 引擎\n\n**注意**:Python 3.14 与 \\`paddlepaddle\\` 不兼容。建议使用 Python 3.11 创建虚拟环境。\n\n---\n\n### 1. 创建/重建虚拟环境(Python 3.11)\n\n\\`\\`\\`powershell\n# 如果原 venv 不支持 Python 3.11,先删除\nRemove-Item -Recurse -Force backend\\\\venv\n\n# 使用 Python 3.11 创建新 venv\nC:\\\\Python311\\\\python.exe -m venv backend\\\\venv\n\\`\\`\\`\n\n### 2. 激活虚拟环境(PowerShell)\n\n\\`\\`\\`powershell\n# 方法一:PowerShell 执行策略问题\npowershell -ExecutionPolicy Bypass -Command \"backend\\\\venv\\\\Scripts\\\\Activate.ps1\"\n\n# 方法二:直接用 python 运行(无需激活)\nC:\\\\Python311\\\\python.exe -m pip install ...\n\\`\\`\\`\n\n### 3. 安装 OCRmyPDF 核心\n\n\\`\\`\\`powershell\npython -m pip install ocrmypdf\n\\`\\`\\`\n\n### 4. 安装引擎插件\n\n\\`\\`\\`powershell\n# 安装所有 OCR 引擎(推荐,一步到位)\npython -m pip install ocrmypdf-paddleocr paddleocr\n\n# 或分开安装\npython -m pip install paddleocr\npython -m pip install ocrmypdf-paddleocr # 需要先装好 paddleocr\n\\`\\`\\`\n\n### 5. 安装 Tesseract OCR 系统依赖\n\n\\`\\`\\`powershell\nwinget install --id UB-Mannheim.TesseractOCR --accept-package-agreements --accept-source-agreements\n\\`\\`\\`\n\n### 6. 将 Tesseract 添加到 PATH\n\n\\`\\`\\`powershell\n[Environment]::SetEnvironmentVariable(\"Path\", [Environment]::GetEnvironmentVariable(\"Path\", \"User\") + \";C:\\\\Program Files\\\\Tesseract-OCR\", \"User\")\n\\`\\`\\`\n\n> ⚠️ 添加后需**重启终端或IDE**使 PATH 生效,或使用完整路径验证:\n> \\`\\`\\`powershell\n> & \"C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\" --version\n> \\`\\`\\`\n\n### 7. 验证安装\n\n\\`\\`\\`powershell\npython -c \"import ocrmypdf; print('ocrmypdf:', ocrmypdf.__version__)\"\npython -c \"from paddle import paddle; print('paddle:', paddle.__version__)\"\n\"C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\" --version\n\\`\\`\\`\n\n### 常见问题\n\n| 问题 | 解决 |\n|------|------|\n| \\`ModuleNotFoundError: No module named 'paddlepaddle'\\` | \\`import paddle\\` 而非 \\`import paddlepaddle\\` |\n| \\`ocrmypdf-paddleocr\\` 依赖冲突 | 先装 \\`paddleocr\\`,再装 \\`ocrmypdf-paddleocr\\` |\n| Tesseract 找不到 | 重启终端,或使用完整路径 \\`C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\\` |`\n\nconst AI_VISION_ENDPOINTS: Record<string, string> = {\n openai_compatible: 'https://api.openai.com/v1',\n openai_responses: 'https://api.openai.com/v1',\n azure: 'https://RESOURCE_NAME.openai.azure.com',\n anthropic: 'https://api.anthropic.com',\n gemini: 'https://generativelanguage.googleapis.com/v1beta',\n minimax_openai: 'https://api.minimaxi.com/v1',\n minimax_anthropic: 'https://api.minimaxi.com',\n custom: '',\n}\n\nfunction OCRNetworkSection({ form, updateForm, mountedRef }: SectionProps) {\n const [ocrChecking, setOcrChecking] = useState(false)\n const [ocrStatus, setOcrStatus] = useState<'green' | 'red' | 'yellow' | null>(null)\n const [ocrMsg, setOcrMsg] = useState('')\n const [ocrEngines, setOcrEngines] = useState<Record<string, { installed: boolean; installing: boolean; msg: string }>>({})\n const [llmModels, setLlmModels] = useState<any[]>([])\n const [llmFetchMsg, setLlmFetchMsg] = useState('')\n const [onlineEngineStatus, setOnlineEngineStatus] = useState<Record<string, 'ok' | 'fail' | ''>>({ mineru: '', paddleocr: '' })\n const [onlineTesting, setOnlineTesting] = useState<Record<string, boolean>>({ mineru: false, paddleocr: false })\n const [llmFetching, setLlmFetching] = useState(false)\n const [llmTestMsg, setLlmTestMsg] = useState('')\n const [llmTesting, setLlmTesting] = useState(false)\n const [aiModels, setAiModels] = useState<{ id: string; name: string }[]>([])\n const [fetchingModels, setFetchingModels] = useState(false)\n const [fetchModelsMsg, setFetchModelsMsg] = useState('')\n const [aiVisionTest, setAiVisionTest] = useState<'testing' | 'ok' | 'fail' | null>(null)\n const [aiVisionMsg, setAiVisionMsg] = useState('')\n const [saving, setSaving] = useState(false)\n const [saveMsg, setSaveMsg] = useState('')\n const [updateChecking, setUpdateChecking] = useState(false)\n const [updateResult, setUpdateResult] = useState('')\n\n // --- OCR logic ---\n const checkOcr = useCallback(async (engine?: string) => {\n setOcrChecking(true)\n try {\n const eng = engine || form.ocr_engine || 'tesseract'\n const res = await fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(eng)}`)\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrStatus(data.ok ? 'green' : 'red')\n setOcrMsg(data.message || (data.ok ? data.version || '已安装' : '未检测到'))\n } catch (e) {\n if (mountedRef.current) {\n setOcrStatus('red')\n setOcrMsg('检测失败')\n }\n } finally {\n if (mountedRef.current) setOcrChecking(false)\n }\n }, [form.ocr_engine, mountedRef])\n\n const handleDetectOcrEngine = useCallback(async (engine: string) => {\n setOcrEngines(prev => ({ ...prev, [engine]: { ...prev[engine], installing: false, msg: '检测中...' } }))\n try {\n const res = await fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(engine)}`)\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrEngines(prev => ({\n ...prev,\n [engine]: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n } catch (e) {\n if (mountedRef.current) {\n setOcrEngines(prev => ({\n ...prev,\n [engine]: { installed: false, installing: false, msg: '检测失败' },\n }))\n }\n }\n }, [mountedRef])\n\n const handleInstallOcrEngine = useCallback(async (engine: string) => {\n setOcrEngines(prev => ({ ...prev, [engine]: { ...prev[engine], installing: true, msg: '安装中...' } }))\n try {\n const res = await fetch('/api/v1/install-ocr', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ engine }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrEngines(prev => ({\n ...prev,\n [engine]: {\n installed: data.ok || false,\n installing: false,\n msg: data.message || (data.ok ? '安装成功' : '安装失败'),\n },\n }))\n } catch (e) {\n if (mountedRef.current) {\n setOcrEngines(prev => ({\n ...prev,\n [engine]: { installed: false, installing: false, msg: '安装请求失败' },\n }))\n }\n }\n }, [mountedRef])\n\n const handleTestMineru = useCallback(async () => {\n setOnlineTesting(prev => ({ ...prev, mineru: true }))\n setOnlineEngineStatus(prev => ({ ...prev, mineru: '' }))\n try {\n const res = await fetch('/api/v1/check-mineru', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: (form as any).mineru_token || '' }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOnlineEngineStatus(prev => ({ ...prev, mineru: data.ok ? 'ok' : 'fail' }))\n } catch {\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: 'fail' }))\n }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, mineru: false }))\n }, [form, mountedRef])\n\n const handleTestPaddleocrOnline = useCallback(async () => {\n setOnlineTesting(prev => ({ ...prev, paddleocr: true }))\n setOnlineEngineStatus(prev => ({ ...prev, paddleocr: '' }))\n try {\n const res = await fetch('/api/v1/check-paddleocr-online', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: (form as any).paddleocr_online_token || '' }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOnlineEngineStatus(prev => ({ ...prev, paddleocr: data.ok ? 'ok' : 'fail' }))\n } catch {\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: 'fail' }))\n }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, paddleocr: false }))\n }, [form, mountedRef])\n\n // Auto-detect OCR engine statuses\n const autoOcrRef = useRef(false)\n useEffect(() => {\n if (autoOcrRef.current) return\n autoOcrRef.current = true\n const engines = ['tesseract', 'paddleocr']\n engines.forEach((eng) => {\n fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(eng)}`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrEngines((prev) => ({\n ...prev,\n [eng]: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n })\n .catch(() => {})\n })\n const defaultEngine = form.ocr_engine || 'tesseract'\n fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(defaultEngine)}`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrStatus(data.ok ? 'green' : 'red')\n setOcrMsg(data.version || data.message || (data.ok ? '已安装' : '未检测到'))\n })\n .catch(() => {})\n }, [form.ocr_engine, mountedRef])\n\n // Auto-detect PaddleOCR availability on mount\n // PaddleOCR already covered by B1's forEach loop\n useEffect(() => {\n fetch(`${API_BASE}/check-ocr?engine=paddleocr`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrEngines((prev) => ({\n ...prev,\n paddleocr: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n })\n .catch(() => {})\n }, [form.ocr_engine, mountedRef])\n\n // Update OCR header status when engine selection changes\n useEffect(() => {\n const eng = form.ocr_engine || 'tesseract'\n const info = ocrEngines[eng]\n if (info) {\n setOcrStatus(info.installed ? 'green' : 'red')\n setOcrMsg(info.msg || (info.installed ? '已安装' : '未检测到'))\n }\n }, [form.ocr_engine, ocrEngines])\n\n // Auto-detect online API connectivity\n const autoOnlineRef = useRef(false)\n useEffect(() => {\n if (autoOnlineRef.current) return\n autoOnlineRef.current = true\n const check = async () => {\n if ((form as any).mineru_token) {\n setOnlineTesting(prev => ({ ...prev, mineru: true }))\n try {\n const r = await fetch('/api/v1/check-mineru', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: (form as any).mineru_token }) })\n const d = await r.json()\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: d.ok ? 'ok' : 'fail' }))\n } catch { if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: 'fail' })) }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, mineru: false }))\n }\n if ((form as any).paddleocr_online_token) {\n setOnlineTesting(prev => ({ ...prev, paddleocr: true }))\n try {\n const r = await fetch('/api/v1/check-paddleocr-online', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: (form as any).paddleocr_online_token }) })\n const d = await r.json()\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: d.ok ? 'ok' : 'fail' }))\n } catch { if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: 'fail' })) }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, paddleocr: false }))\n }\n }\n check()\n }, [mountedRef])\n\n // --- Save / Update ---\n const handleSave = useCallback(async () => {\n setSaving(true)\n setSaveMsg('')\n try {\n const body = JSON.stringify(form)\n const res = await fetch('/api/v1/config', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body,\n })\n if (!res.ok) {\n setSaveMsg('保存失败: HTTP ' + res.status)\n setSaving(false)\n return\n }\n setSaveMsg('保存成功')\n setTimeout(() => setSaveMsg(''), 2000)\n } catch (e: any) {\n setSaveMsg('保存失败: ' + (e.message || '未知错误'))\n }\n setSaving(false)\n }, [form, mountedRef])\n\n const handleCheckUpdate = useCallback(async () => {\n setUpdateChecking(true)\n setUpdateResult('')\n try {\n const res = await fetch('/api/v1/check-update')\n const data = await res.json()\n if (!mountedRef.current) return\n if (data.has_update) {\n setUpdateResult(`新版本 v${data.latest} 可用 (当前 v${data.current})`)\n } else {\n setUpdateResult(`已是最新版本 v${data.current}`)\n }\n } catch (e) {\n if (mountedRef.current) setUpdateResult('检查失败')\n } finally {\n if (mountedRef.current) setUpdateChecking(false)\n }\n }, [mountedRef])\n\n return (\n <div className=\"space-y-3\">\n {/* OCRmyPDF 独立状态区 */}\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">OCRmyPDF 状态</label>\n <div className=\"flex items-center gap-2\">\n <StatusDot status={(ocrEngines['ocrmypdf'] as any)?.installed ? 'green' : (ocrEngines['ocrmypdf'] as any)?.msg ? 'red' : null} />\n <span className=\"text-xs text-gray-500\">\n {(ocrEngines['ocrmypdf'] as any)?.msg || '点击检测'}\n </span>\n <button\n type=\"button\"\n onClick={() => handleDetectOcrEngine('ocrmypdf')}\n className=\"px-2 py-1 text-xs rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500\"\n >\n 检测\n </button>\n <button\n type=\"button\"\n onClick={() => handleInstallOcrEngine('ocrmypdf')}\n disabled={(ocrEngines['ocrmypdf'] as any)?.installing}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50\"\n >\n {(ocrEngines['ocrmypdf'] as any)?.installing ? '安装中...' : '一键安装'}\n </button>\n </div>\n </div>\n\n {/* 引擎切换区 */}\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">引擎切换</span>\n <div className=\"grid grid-cols-2 gap-2\">\n {OCR_ENGINES.filter(eng => eng.key !== 'llm_ocr' && eng.key !== 'mineru' && eng.key !== 'paddleocr_online').map((eng) => {\n const info = (ocrEngines[eng.key] as any) || { installed: eng.key === 'llm_ocr', msg: '' }\n const isSelected = form.ocr_engine === eng.key\n return (\n <div\n key={eng.key}\n className={`rounded border p-2.5 ${isSelected ? 'border-blue-400 bg-blue-50' : 'border-gray-200 bg-white'}`}\n >\n <div className=\"flex items-center justify-between mb-1\">\n <span className=\"text-xs font-medium text-gray-700\">{eng.name}</span>\n <div className=\"flex items-center gap-1\">\n <StatusDot status={info?.installed ? 'green' : info?.msg ? 'red' : null} />\n </div>\n </div>\n <p className=\"text-xs text-gray-400 mb-2\">{eng.desc}</p>\n {eng.key !== 'llm_ocr' && info?.msg && (\n <p className={`text-xs mb-1.5 ${info.installed ? 'text-green-600' : 'text-red-500'}`}>\n {info.msg}\n </p>\n )}\n {eng.key !== 'llm_ocr' && eng.key === 'paddleocr' && !info?.installed && (\n <p className=\"text-xs text-gray-400 mb-1.5\">需要 Python 3.11 虚拟环境,点击安装自动搭建</p>\n )}\n {eng.key !== 'llm_ocr' && eng.key === 'paddleocr' && info?.installed && (info as any)?.venv && (\n <p className=\"text-xs text-green-600 mb-1.5\">运行环境: {(info as any).venv}</p>\n )}\n <div className=\"flex items-center gap-1.5\">\n {eng.key !== 'llm_ocr' && (\n <>\n <button\n type=\"button\"\n onClick={() => handleDetectOcrEngine(eng.key)}\n className=\"px-2 py-1 text-xs rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500\"\n >\n 检测\n </button>\n <button\n type=\"button\"\n onClick={() => handleInstallOcrEngine(eng.key)}\n disabled={info?.installing}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50\"\n >\n {info?.installing ? '安装中...' : '安装'}\n </button>\n </>\n )}\n {eng.key === form.ocr_engine ? null : (\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: eng.key })}\n className=\"px-2 py-1 text-xs rounded text-blue-600 hover:bg-blue-50 ml-auto\"\n >\n 选用\n </button>\n )}\n </div>\n </div>\n )\n })}\n </div>\n </div>\n\n {/* Tesseract 语言包状态 */}\n {form.ocr_engine !== 'llm_ocr' && (\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">Tesseract 语言包</span>\n <div className=\"flex items-center gap-2\">\n <StatusDot status={\n form.ocr_engine === 'tesseract' && (ocrEngines['tesseract'] as any)?.has_chi_sim\n ? 'green'\n : form.ocr_engine === 'tesseract'\n ? 'red'\n : null\n } />\n <span className=\"text-xs text-gray-500\">\n {(ocrEngines['tesseract'] as any)?.has_chi_sim\n ? 'chi_sim 已安装'\n : (ocrEngines['tesseract'] as any)?.languages\n ? 'chi_sim 未安装'\n : '请先检测 Tesseract'}\n </span>\n {!((ocrEngines['tesseract'] as any)?.has_chi_sim) && (\n <button\n type=\"button\"\n onClick={async () => {\n try {\n await fetch('/api/v1/install-tesseract-lang?lang=chi_sim')\n handleDetectOcrEngine('tesseract')\n } catch (e) {\n }\n }}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600\"\n >\n 安装语言包\n </button>\n )}\n </div>\n </div>\n )}\n\n <div className=\"grid grid-cols-4 gap-3 pt-2\">\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">语言</label>\n <select\n value={form.ocr_languages || 'chi_sim+eng'}\n onChange={(e) => updateForm({ ocr_languages: e.target.value })}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n >\n <option value=\"chi_sim+eng\">chi_sim+eng</option>\n <option value=\"chi_sim\">chi_sim</option>\n <option value=\"eng\">eng</option>\n <option value=\"chi_sim+eng+jpn\">chi_sim+eng+jpn</option>\n </select>\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">并行线程</label>\n <select\n value={form.ocr_jobs ?? 1}\n onChange={(e) => updateForm({ ocr_jobs: parseInt(e.target.value) || 1 })}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n >\n <option value={1}>1</option>\n <option value={2}>2</option>\n <option value={4}>4</option>\n </select>\n <p className=\"text-[10px] text-gray-400 mt-0.5 leading-tight\">Tesseract 支持多线程,PaddleOCR 仅单线程</p>\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">超时 (秒)</label>\n <input\n type=\"number\"\n value={form.ocr_timeout ?? 1800}\n onChange={(e) => updateForm({ ocr_timeout: parseInt(e.target.value) || 3600 })}\n min={60}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n />\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">OCR 采样 DPI</label>\n <input\n type=\"number\"\n min={150}\n max={400}\n step={50}\n value={(form as any).ocr_oversample || 200}\n onChange={(e) => updateForm({ ocr_oversample: parseInt(e.target.value) } as any)}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n />\n <span className=\"text-xs text-gray-400\">越低越快,150-400,推荐 200</span>\n </div>\n </div>\n\n {/* OCR 安装指引 */}\n {form.ocr_engine !== 'llm_ocr' && (\n <details className=\"group\">\n <summary className=\"text-xs font-medium text-gray-600 cursor-pointer list-none flex items-center gap-1 select-none hover:text-gray-800\">\n <svg className=\"w-3 h-3 text-gray-400 transition-transform group-open:rotate-90\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n </svg>\n OCR 命令行安装指引\n </summary>\n <div className=\"mt-2 bg-blue-50 border border-blue-200 rounded p-3\">\n <p className=\"text-xs text-blue-800 font-medium mb-2\">📋 将以下提示词复制并发送给 OpenCode:</p>\n <pre className=\"text-xs text-blue-700 bg-blue-100 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono\">{OCR_INSTALL_GUIDE}</pre>\n <p className=\"text-xs text-blue-600 mt-2\">安装后返回设置页点击\"检测\"按钮确认状态。</p>\n </div>\n </details>\n )}\n\n {/* LLM OCR */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'llm_ocr' ? (\n <span className=\"text-blue-600\">LLM OCR 已启用</span>\n ) : (\n <span className=\"text-gray-500\">LLM OCR</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'llm_ocr' ? 'tesseract' : 'llm_ocr' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'llm_ocr'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'llm_ocr' ? '停用并回到 OCRmyPDF' : '启用 LLM OCR'}\n </button>\n </div>\n </div>\n\n {form.ocr_engine === 'llm_ocr' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用视觉大模型逐框识别文字层。需要运行 lmstudio / ollama 加载对应模型。\n 推荐模型已验证中文 PDF 可用。\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">接口地址</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).llm_ocr_endpoint || 'http://127.0.0.1:1234/v1'}\n onChange={(e) => updateForm({ llm_ocr_endpoint: e.target.value } as any)}\n placeholder=\"http://127.0.0.1:1234/v1\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button onClick={async () => {\n setLlmTesting(true); setLlmTestMsg('');\n try {\n const r = await fetch('/api/v1/check-llm-ocr', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ endpoint: (form as any).llm_ocr_endpoint, model: (form as any).llm_ocr_model })\n });\n const d = await r.json();\n setLlmTestMsg(d.message || (d.ok ? 'OK' : 'Failed'));\n } catch (e) { setLlmTestMsg(String(e)); }\n setLlmTesting(false);\n }} disabled={llmTesting}\n className=\"px-3 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap\">\n {llmTesting ? '测试中...' : '检测'}\n </button>\n </div>\n {llmTestMsg && <p className={`text-xs mt-1 ${llmTestMsg.includes('成功') || llmTestMsg.includes('OK') ? 'text-green-600' : 'text-red-500'}`}>{llmTestMsg}</p>}\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">模型名称</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).llm_ocr_model || ''}\n onChange={(e) => updateForm({ llm_ocr_model: e.target.value } as any)}\n placeholder=\"qwen3-vl-4b-instruct\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button onClick={async () => {\n setLlmFetching(true); setLlmFetchMsg('');\n try {\n const r = await fetch('/api/v1/fetch-llm-models', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ endpoint: (form as any).llm_ocr_endpoint })\n });\n const d = await r.json();\n if (d.ok && d.models.length > 0) {\n setLlmModels(d.models);\n setLlmFetchMsg(`${d.models.length} 个模型`);\n } else {\n setLlmFetchMsg(d.message || '无可用模型');\n }\n } catch (e) { setLlmFetchMsg(String(e)); }\n setLlmFetching(false);\n }} disabled={llmFetching || !(form as any).llm_ocr_endpoint}\n className=\"px-2 py-1.5 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap\">\n {llmFetching ? '...' : '拉取模型'}\n </button>\n </div>\n {llmModels.length > 0 && (\n <select value={(form as any).llm_ocr_model || ''}\n onChange={(e) => updateForm({ llm_ocr_model: e.target.value } as any)}\n className=\"w-full mt-1 rounded border border-blue-300 px-2 py-1 text-xs font-mono\"\n size={Math.min(llmModels.length + 1, 8)}>\n <option value=\"\" disabled>-- 选择模型 --</option>\n {llmModels.map((m: any) => (\n <option key={m.id} value={m.id}>{m.name}</option>\n ))}\n </select>\n )}\n {llmFetchMsg && <p className={`text-xs mt-1 ${llmFetchMsg.includes('个模型') ? 'text-green-600' : 'text-red-500'}`}>{llmFetchMsg}</p>}\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">\n 并发数: {(form as any).llm_ocr_concurrency || 1}\n </label>\n <input type=\"range\" min=\"1\" max=\"5\" step=\"1\"\n value={(form as any).llm_ocr_concurrency || 1}\n onChange={(e) => updateForm({ llm_ocr_concurrency: parseInt(e.target.value) } as any)}\n className=\"w-full\" />\n <div className=\"flex justify-between text-xs text-gray-400\">\n <span>1 (默认)</span><span>5 (最快)</span>\n </div>\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">\n 版面检测批次: {(form as any).llm_ocr_detect_batch || 20} 页/批\n </label>\n <input type=\"range\" min=\"5\" max=\"50\" step=\"5\"\n value={(form as any).llm_ocr_detect_batch || 20}\n onChange={(e) => updateForm({ llm_ocr_detect_batch: parseInt(e.target.value) } as any)}\n className=\"w-full\" />\n <div className=\"flex justify-between text-xs text-gray-400\">\n <span>5 页 (低内存)</span><span>50 页 (更快)</span>\n </div>\n <p className=\"text-xs text-gray-400 mt-0.5\">Surya 版面检测每批处理页数。内存不足时降低。</p>\n </div>\n <details className=\"text-xs\">\n <summary className=\"cursor-pointer text-blue-600 hover:text-blue-800\">\n 推荐模型 (已验证中文 PDF)\n </summary>\n <div className=\"mt-2 space-y-1\">\n {LLM_OCR_RECOMMENDED.map(m => (\n <button key={m.model}\n onClick={() => updateForm({ llm_ocr_model: m.model } as any)}\n title={m.note}\n className=\"block w-full text-left px-2 py-1 rounded hover:bg-blue-100 text-gray-700 text-xs\">\n <span className=\"font-mono\">{m.model}</span>\n <span className=\"text-gray-400 ml-2\">{m.note}</span>\n </button>\n ))}\n </div>\n </details>\n </div>\n )}\n\n {/* MinerU */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'mineru' ? (\n <span className=\"text-blue-600\">MinerU 线上 API 已启用</span>\n ) : (\n <span className=\"text-gray-500\">MinerU 线上 API</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'mineru' ? 'tesseract' : 'mineru' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'mineru'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'mineru' ? '停用 MinerU' : '启用 MinerU'}\n </button>\n </div>\n </div>\n {form.ocr_engine === 'mineru' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用 MinerU v4 精准解析 API 进行文档 OCR。PDF 将上传至 MinerU 服务器处理。\n 仅限中国大陆网络访问,单文件 ≤200MB / ≤200页。\n <span className=\"text-orange-500 ml-1\">实验功能,识别可能会有重复</span>\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API Token</label>\n <input value={(form as any).mineru_token || ''}\n onChange={(e) => updateForm({ mineru_token: e.target.value } as any)}\n placeholder=\"输入 MinerU API Token(Bearer 认证)\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"flex items-center gap-2\">\n <button\n type=\"button\"\n onClick={handleTestMineru}\n disabled={onlineTesting.mineru || !(form as any).mineru_token}\n className=\"px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed\">\n {onlineTesting.mineru ? '测试中...' : '检测'}\n </button>\n {onlineEngineStatus.mineru === 'ok' && (\n <span className=\"text-xs text-green-600\">连接正常</span>\n )}\n {onlineEngineStatus.mineru === 'fail' && (\n <span className=\"text-xs text-red-500\">连接失败</span>\n )}\n </div>\n <p className=\"text-xs text-gray-400\">\n 需要先申请 Token:<a href=\"https://mineru.net/apiManage/docs\" target=\"_blank\" className=\"text-blue-500 hover:underline\">MinerU API 文档</a>\n </p>\n </div>\n )}\n\n {/* PaddleOCR-VL-1.5 */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'paddleocr_online' ? (\n <span className=\"text-blue-600\">PaddleOCR-VL-1.5 线上 API 已启用</span>\n ) : (\n <span className=\"text-gray-500\">PaddleOCR-VL-1.5 线上 API</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'paddleocr_online' ? 'tesseract' : 'paddleocr_online' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'paddleocr_online'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'paddleocr_online' ? '停用 PaddleOCR-VL-1.5' : '启用 PaddleOCR-VL-1.5'}\n </button>\n </div>\n </div>\n {form.ocr_engine === 'paddleocr_online' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用百度 PaddleOCR-VL-1.5 视觉大模型进行文档版面解析。\n 支持中英文文档,无需本地 GPU。\n <span className=\"text-orange-500 ml-1\">实验功能,识别可能会有重复</span>\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1.5\">识别模式 <span className=\"text-[11px] text-blue-500 ml-1\">推荐逐框识别,次选混合识别</span></label>\n <div className=\"flex rounded border border-gray-300 overflow-hidden text-xs\">\n {[\n { key: 'spatial', label: '空间分配(快)' },\n { key: 'perbox', label: '逐框识别(慢)' },\n { key: 'hybrid', label: '混合识别' },\n ].map(({ key, label }) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => updateForm({ paddleocr_online_mode: key } as any)}\n className={`flex-1 py-1.5 text-center transition-colors ${\n ((form as any).paddleocr_online_mode || 'spatial') === key\n ? 'bg-blue-500 text-white'\n : 'bg-white text-gray-600 hover:bg-gray-50'\n }`}\n >\n {label}\n </button>\n ))}\n </div>\n <p className=\"text-[11px] text-gray-400 mt-1\">\n {((form as any).paddleocr_online_mode || 'spatial') === 'spatial' && '段落文字精准,行识别不精确'}\n {((form as any).paddleocr_online_mode || 'spatial') === 'perbox' && '行识别精准,有乱码风险'}\n {((form as any).paddleocr_online_mode || 'spatial') === 'hybrid' && '逐框为主、空间填补,可能产生识别重复'}\n </p>\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">Access Token</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).paddleocr_online_token || ''}\n onChange={(e) => updateForm({ paddleocr_online_token: e.target.value } as any)}\n placeholder=\"输入 PaddleOCR access token\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button\n type=\"button\"\n onClick={handleTestPaddleocrOnline}\n disabled={onlineTesting.paddleocr || !(form as any).paddleocr_online_token}\n className=\"px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap\">\n {onlineTesting.paddleocr ? '测试中...' : '检测'}\n </button>\n </div>\n {onlineEngineStatus.paddleocr === 'ok' && (\n <p className=\"text-xs text-green-600 mt-1\">连接正常</p>\n )}\n {onlineEngineStatus.paddleocr === 'fail' && (\n <p className=\"text-xs text-red-500 mt-1\">连接失败</p>\n )}\n </div>\n <p className=\"text-xs text-gray-400\">\n 从 <a href=\"https://aistudio.baidu.com/paddleocr/task\" target=\"_blank\" className=\"text-blue-500 hover:underline\">PaddleOCR 控制台</a> 获取 Access Token。\n <a href=\"https://ai.baidu.com/ai-doc/AISTUDIO/Cmkz2m0ma\" target=\"_blank\" className=\"text-blue-500 hover:underline ml-2\">API 文档</a>\n </p>\n </div>\n )}\n\n <div className=\"flex items-center gap-2 pt-3\">\n <input type=\"checkbox\" id=\"ocr_confirm_enabled\"\n checked={(form as any).ocr_confirm_enabled ?? false}\n onChange={(e) => updateForm({ ocr_confirm_enabled: e.target.checked } as any)}\n className=\"rounded\" />\n <label htmlFor=\"ocr_confirm_enabled\" className=\"text-xs font-medium text-gray-600\">\n 管道执行到 OCR 步骤时弹出确认对话框\n </label>\n </div>\n\n {/* ============ AI Vision / 书签 ============ */}\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">AI Vision 目录提取</span>\n <div className=\"flex items-center gap-2\">\n <input type=\"checkbox\" id=\"ai_vision_enabled\"\n checked={(form as any).ai_vision_enabled ?? true}\n onChange={(e) => updateForm({ ai_vision_enabled: e.target.checked } as any)} className=\"rounded\" />\n <label htmlFor=\"ai_vision_enabled\" className=\"text-xs\">启用 AI Vision 目录提取</label>\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API 端点</label>\n <input type=\"text\" value={(form as any).ai_vision_endpoint || ''}\n onChange={(e) => updateForm({ ai_vision_endpoint: e.target.value } as any)}\n placeholder={\n (form as any).ai_vision_provider === 'gemini' ? 'https://generativelanguage.googleapis.com/v1beta' :\n (form as any).ai_vision_provider === 'azure' ? 'https://{resource}.openai.azure.com' :\n (form as any).ai_vision_provider === 'anthropic' || (form as any).ai_vision_provider === 'minimax_anthropic' ? 'https://api.anthropic.com' :\n (form as any).ai_vision_provider === 'minimax_openai' ? 'https://api.minimaxi.com/v1' :\n 'http://127.0.0.1:12345/v1'\n }\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">模型名称</label>\n <div className=\"flex gap-1\">\n <input type=\"text\" value={(form as any).ai_vision_model || ''}\n onChange={(e) => updateForm({ ai_vision_model: e.target.value } as any)}\n placeholder=\"sabafallah/deepseek-ocr\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button\n type=\"button\"\n onClick={async () => {\n setFetchingModels(true)\n setFetchModelsMsg('')\n try {\n const res = await fetch('/api/v1/fetch-models', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n endpoint: (form as any).ai_vision_endpoint,\n api_key: (form as any).ai_vision_api_key,\n provider: (form as any).ai_vision_provider,\n }),\n })\n const data = await res.json()\n if (data.ok && data.models.length > 0) {\n setAiModels(data.models)\n setFetchModelsMsg(`${data.models.length} 个模型`)\n } else {\n setFetchModelsMsg(data.message || '无可用模型')\n }\n } catch (e) {\n setFetchModelsMsg(String(e))\n }\n setFetchingModels(false)\n }}\n disabled={fetchingModels || !(form as any).ai_vision_endpoint}\n className=\"px-2 py-1.5 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap\"\n title=\"从提供商拉取可用模型列表\"\n >\n {fetchingModels ? '...' : '获取模型'}\n </button>\n </div>\n {aiModels.length > 0 && (\n <select\n value={(form as any).ai_vision_model || ''}\n onChange={(e) => updateForm({ ai_vision_model: e.target.value } as any)}\n className=\"w-full mt-1 rounded border border-blue-300 px-2 py-1 text-xs font-mono\"\n size={Math.min(aiModels.length + 1, 8)}\n >\n <option value=\"\" disabled>-- 选择模型 --</option>\n {aiModels.map(m => (\n <option key={m.id} value={m.id}>{m.name}</option>\n ))}\n </select>\n )}\n {fetchModelsMsg && (\n <p className={`text-xs mt-1 ${fetchModelsMsg.includes('个模型') ? 'text-green-600' : 'text-red-500'}`}>\n {fetchModelsMsg}\n </p>\n )}\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API Key</label>\n <input type=\"password\" value={(form as any).ai_vision_api_key || ''}\n onChange={(e) => updateForm({ ai_vision_api_key: e.target.value } as any)}\n placeholder=\"sk-... (支持 {env:VAR_NAME})\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API 格式</label>\n <select value={(form as any).ai_vision_provider || 'openai_compatible'}\n onChange={(e) => {\n const newProvider = e.target.value\n const defaultEndpoint = AI_VISION_ENDPOINTS[newProvider] || ''\n updateForm({\n ai_vision_provider: newProvider,\n ai_vision_endpoint: defaultEndpoint,\n } as any)\n }}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs\">\n <option value=\"openai_compatible\">OpenAI / DeepSeek / 智谱 / Qwen / Ollama / lmstudio</option>\n <option value=\"openai_responses\">OpenAI Responses (新版 /v1/responses)</option>\n <option value=\"azure\">Azure OpenAI</option>\n <option value=\"anthropic\">Anthropic Claude</option>\n <option value=\"gemini\">Google Gemini</option>\n <option value=\"minimax_openai\">MiniMax (OpenAI 兼容 · 国内/国际通用)</option>\n <option value=\"minimax_anthropic\">MiniMax (Anthropic 兼容 · Token Plan)</option>\n <option value=\"custom\">自定义 (Custom)</option>\n </select>\n </div>\n {(form as any).ai_vision_provider === 'custom' && (\n <div className=\"flex items-center gap-2 mt-2\">\n <input type=\"checkbox\" id=\"ai_vision_messages_api\"\n checked={(form as any).ai_vision_messages_api ?? false}\n onChange={(e) => updateForm({ ai_vision_messages_api: e.target.checked } as any)} className=\"rounded\" />\n <label htmlFor=\"ai_vision_messages_api\" className=\"text-xs font-medium text-gray-600\">\n 使用 Anthropic Messages API 格式 (默认 OpenAI Chat Completions)\n </label>\n </div>\n )}\n <div className=\"grid grid-cols-2 gap-3 mt-2\">\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">DPI</label>\n <input type=\"number\" value={(form as any).ai_vision_dpi ?? 150}\n onChange={(e) => updateForm({ ai_vision_dpi: parseInt(e.target.value) || 150 } as any)}\n min={72} max={300} className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs\" />\n </div>\n </div>\n <button\n type=\"button\"\n onClick={async () => {\n setAiVisionTest('testing');\n try {\n const res = await fetch('/api/v1/check-ai-vision', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n endpoint: (form as any).ai_vision_endpoint,\n model: (form as any).ai_vision_model,\n api_key: (form as any).ai_vision_api_key,\n provider: (form as any).ai_vision_provider,\n messages_api: (form as any).ai_vision_messages_api,\n }),\n });\n const data = await res.json();\n setAiVisionTest(data.ok ? 'ok' : 'fail');\n setAiVisionMsg(data.message || data.error || '');\n } catch (e) {\n setAiVisionTest('fail');\n setAiVisionMsg(String(e));\n }\n }}\n disabled={aiVisionTest === 'testing'}\n className=\"px-3 py-1.5 mt-2 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50\"\n >\n {aiVisionTest === 'testing' ? '测试中...' : '检测'}\n </button>\n {aiVisionMsg && (\n <p className={`text-xs mt-1 ${aiVisionTest === 'ok' ? 'text-green-600' : 'text-red-600'}`}>\n {aiVisionMsg}\n </p>\n )}\n <p className=\"text-xs text-gray-400 mt-1\">\n 阶段1 从 OCR 文字层提取目录(免费),阶段2 用 AI Vision 从目录页图片提取。\n 支持 OpenAI 兼容 / Gemini / MiniMax 格式。\n </p>\n <div className=\"flex items-center gap-2 mt-2\">\n <input type=\"checkbox\" id=\"bookmark_confirm_enabled\"\n checked={(form as any).bookmark_confirm_enabled ?? false}\n onChange={(e) => updateForm({ bookmark_confirm_enabled: e.target.checked } as any)}\n className=\"rounded\" />\n <label htmlFor=\"bookmark_confirm_enabled\" className=\"text-xs font-medium text-gray-600\">\n 管道执行到书签步骤时弹出确认对话框\n </label>\n </div>\n </div>\n\n {/* ============ 主题 ============ */}\n <div className=\"border-t border-gray-200 pt-3\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1.5\">主题</label>\n <div className=\"flex rounded border border-gray-300 overflow-hidden text-xs\">\n {[\n { key: 'auto', label: '自动' },\n { key: 'light', label: '白昼' },\n { key: 'dark', label: '黑夜' },\n ].map(({ key, label }) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => updateForm({ theme: key })}\n className={`flex-1 py-1.5 text-center transition-colors ${\n ((form as any).theme || 'auto') === key\n ? 'bg-blue-500 text-white'\n : 'bg-white text-gray-600 hover:bg-gray-50'\n }`}\n >\n {label}\n </button>\n ))}\n </div>\n </div>\n\n {/* ============ 保存按钮 ============ */}\n <div className=\"flex items-center gap-3 pt-2\">\n <button\n type=\"button\"\n onClick={handleSave}\n disabled={saving}\n className=\"px-5 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 font-medium\"\n >\n {saving ? '保存中...' : '保存设置'}\n </button>\n <button\n type=\"button\"\n onClick={handleCheckUpdate}\n disabled={updateChecking}\n className=\"px-5 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 font-medium\"\n >\n {updateChecking ? '检测中...' : '检查更新'}\n </button>\n {saveMsg && (\n <span className={`text-xs ${saveMsg.includes('成功') ? 'text-green-600' : 'text-red-500'}`}>\n {saveMsg}\n </span>\n )}\n {updateResult && (\n <span className={`text-xs px-2 py-1 rounded ${updateResult.includes('失败') ? 'text-red-600 bg-red-50' : updateResult.includes('新版本') ? 'text-blue-600 bg-blue-50 font-semibold' : 'text-green-600 bg-green-50'}`}>\n {updateResult}\n </span>\n )}\n </div>\n </div>\n )\n}\n\nexport default React.memo(OCRNetworkSection)\n"}, "edc250d5d4ab762c3915f5cd33fdce3ac09d9fce": {"t": 1785772779.7189453, "n": 49257, "h": "57f1b74e63731383", "c": "import React, { useState, useEffect, useRef, useCallback } from 'react'\nimport { SectionProps } from './types'\nimport { LLM_OCR_RECOMMENDED, API_BASE } from '../../constants'\n\nfunction StatusDot({ status }: { status: 'green' | 'red' | 'yellow' | null }) {\n const colors: Record<string, string> = {\n green: 'bg-green-500',\n red: 'bg-red-500',\n yellow: 'bg-yellow-500',\n }\n return (\n <span className={`inline-block w-2 h-2 rounded-full ${status ? colors[status] : 'bg-gray-300'}`} />\n )\n}\n\nconst OCR_ENGINES = [\n { key: 'tesseract', name: 'Tesseract OCR', desc: '内置引擎,需 chi_sim 语言包' },\n { key: 'paddleocr', name: 'PaddleOCR', desc: '百度引擎,需 Python 3.11 虚拟环境' },\n { key: 'llm_ocr', name: 'LLM OCR', desc: '大模型视觉识别,无需本地引擎' },\n { key: 'mineru', name: 'MinerU', desc: '线上 API 精准解析,需 Token' },\n { key: 'paddleocr_online', name: 'PaddleOCR-VL-1.5', desc: '线上 API,需 Token' },\n]\n\nconst OCR_INSTALL_GUIDE = `## 安装 OCR 引擎\n\n**注意**:Python 3.14 与 \\`paddlepaddle\\` 不兼容。建议使用 Python 3.11 创建虚拟环境。\n\n---\n\n### 1. 创建/重建虚拟环境(Python 3.11)\n\n\\`\\`\\`powershell\n# 如果原 venv 不支持 Python 3.11,先删除\nRemove-Item -Recurse -Force backend\\\\venv\n\n# 使用 Python 3.11 创建新 venv\nC:\\\\Python311\\\\python.exe -m venv backend\\\\venv\n\\`\\`\\`\n\n### 2. 激活虚拟环境(PowerShell)\n\n\\`\\`\\`powershell\n# 方法一:PowerShell 执行策略问题\npowershell -ExecutionPolicy Bypass -Command \"backend\\\\venv\\\\Scripts\\\\Activate.ps1\"\n\n# 方法二:直接用 python 运行(无需激活)\nC:\\\\Python311\\\\python.exe -m pip install ...\n\\`\\`\\`\n\n### 3. 安装 OCRmyPDF 核心\n\n\\`\\`\\`powershell\npython -m pip install ocrmypdf\n\\`\\`\\`\n\n### 4. 安装引擎插件\n\n\\`\\`\\`powershell\n# 安装所有 OCR 引擎(推荐,一步到位)\npython -m pip install ocrmypdf-paddleocr paddleocr\n\n# 或分开安装\npython -m pip install paddleocr\npython -m pip install ocrmypdf-paddleocr # 需要先装好 paddleocr\n\\`\\`\\`\n\n### 5. 安装 Tesseract OCR 系统依赖\n\n\\`\\`\\`powershell\nwinget install --id UB-Mannheim.TesseractOCR --accept-package-agreements --accept-source-agreements\n\\`\\`\\`\n\n### 6. 将 Tesseract 添加到 PATH\n\n\\`\\`\\`powershell\n[Environment]::SetEnvironmentVariable(\"Path\", [Environment]::GetEnvironmentVariable(\"Path\", \"User\") + \";C:\\\\Program Files\\\\Tesseract-OCR\", \"User\")\n\\`\\`\\`\n\n> ⚠️ 添加后需**重启终端或IDE**使 PATH 生效,或使用完整路径验证:\n> \\`\\`\\`powershell\n> & \"C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\" --version\n> \\`\\`\\`\n\n### 7. 验证安装\n\n\\`\\`\\`powershell\npython -c \"import ocrmypdf; print('ocrmypdf:', ocrmypdf.__version__)\"\npython -c \"from paddle import paddle; print('paddle:', paddle.__version__)\"\n\"C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\" --version\n\\`\\`\\`\n\n### 常见问题\n\n| 问题 | 解决 |\n|------|------|\n| \\`ModuleNotFoundError: No module named 'paddlepaddle'\\` | \\`import paddle\\` 而非 \\`import paddlepaddle\\` |\n| \\`ocrmypdf-paddleocr\\` 依赖冲突 | 先装 \\`paddleocr\\`,再装 \\`ocrmypdf-paddleocr\\` |\n| Tesseract 找不到 | 重启终端,或使用完整路径 \\`C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\\` |`\n\nconst AI_VISION_ENDPOINTS: Record<string, string> = {\n openai_compatible: 'https://api.openai.com/v1',\n openai_responses: 'https://api.openai.com/v1',\n azure: 'https://RESOURCE_NAME.openai.azure.com',\n anthropic: 'https://api.anthropic.com',\n gemini: 'https://generativelanguage.googleapis.com/v1beta',\n minimax_openai: 'https://api.minimaxi.com/v1',\n minimax_anthropic: 'https://api.minimaxi.com',\n custom: '',\n}\n\nfunction OCRNetworkSection({ form, updateForm, mountedRef }: SectionProps) {\n const [ocrChecking, setOcrChecking] = useState(false)\n const [ocrStatus, setOcrStatus] = useState<'green' | 'red' | 'yellow' | null>(null)\n const [ocrMsg, setOcrMsg] = useState('')\n const [ocrEngines, setOcrEngines] = useState<Record<string, { installed: boolean; installing: boolean; msg: string }>>({})\n const [llmModels, setLlmModels] = useState<any[]>([])\n const [llmFetchMsg, setLlmFetchMsg] = useState('')\n const [onlineEngineStatus, setOnlineEngineStatus] = useState<Record<string, 'ok' | 'fail' | ''>>({ mineru: '', paddleocr: '' })\n const [onlineTesting, setOnlineTesting] = useState<Record<string, boolean>>({ mineru: false, paddleocr: false })\n const [llmFetching, setLlmFetching] = useState(false)\n const [llmTestMsg, setLlmTestMsg] = useState('')\n const [llmTesting, setLlmTesting] = useState(false)\n const [aiModels, setAiModels] = useState<{ id: string; name: string }[]>([])\n const [fetchingModels, setFetchingModels] = useState(false)\n const [fetchModelsMsg, setFetchModelsMsg] = useState('')\n const [aiVisionTest, setAiVisionTest] = useState<'testing' | 'ok' | 'fail' | null>(null)\n const [aiVisionMsg, setAiVisionMsg] = useState('')\n const [saving, setSaving] = useState(false)\n const [saveMsg, setSaveMsg] = useState('')\n const [updateChecking, setUpdateChecking] = useState(false)\n const [updateResult, setUpdateResult] = useState('')\n\n // --- OCR logic ---\n const checkOcr = useCallback(async (engine?: string) => {\n setOcrChecking(true)\n try {\n const eng = engine || form.ocr_engine || 'tesseract'\n const res = await fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(eng)}`)\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrStatus(data.ok ? 'green' : 'red')\n setOcrMsg(data.message || (data.ok ? data.version || '已安装' : '未检测到'))\n } catch (e) {\n if (mountedRef.current) {\n setOcrStatus('red')\n setOcrMsg('检测失败')\n }\n } finally {\n if (mountedRef.current) setOcrChecking(false)\n }\n }, [form.ocr_engine, mountedRef])\n\n const handleDetectOcrEngine = useCallback(async (engine: string) => {\n setOcrEngines(prev => ({ ...prev, [engine]: { ...prev[engine], installing: false, msg: '检测中...' } }))\n try {\n const res = await fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(engine)}`)\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrEngines(prev => ({\n ...prev,\n [engine]: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n } catch (e) {\n if (mountedRef.current) {\n setOcrEngines(prev => ({\n ...prev,\n [engine]: { installed: false, installing: false, msg: '检测失败' },\n }))\n }\n }\n }, [mountedRef])\n\n const handleInstallOcrEngine = useCallback(async (engine: string) => {\n setOcrEngines(prev => ({ ...prev, [engine]: { ...prev[engine], installing: true, msg: '安装中...' } }))\n try {\n const res = await fetch('/api/v1/install-ocr', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ engine }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrEngines(prev => ({\n ...prev,\n [engine]: {\n installed: data.ok || false,\n installing: false,\n msg: data.message || (data.ok ? '安装成功' : '安装失败'),\n },\n }))\n } catch (e) {\n if (mountedRef.current) {\n setOcrEngines(prev => ({\n ...prev,\n [engine]: { installed: false, installing: false, msg: '安装请求失败' },\n }))\n }\n }\n }, [mountedRef])\n\n const handleTestMineru = useCallback(async () => {\n setOnlineTesting(prev => ({ ...prev, mineru: true }))\n setOnlineEngineStatus(prev => ({ ...prev, mineru: '' }))\n try {\n const res = await fetch('/api/v1/check-mineru', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: (form as any).mineru_token || '' }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOnlineEngineStatus(prev => ({ ...prev, mineru: data.ok ? 'ok' : 'fail' }))\n } catch {\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: 'fail' }))\n }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, mineru: false }))\n }, [form, mountedRef])\n\n const handleTestPaddleocrOnline = useCallback(async () => {\n setOnlineTesting(prev => ({ ...prev, paddleocr: true }))\n setOnlineEngineStatus(prev => ({ ...prev, paddleocr: '' }))\n try {\n const res = await fetch('/api/v1/check-paddleocr-online', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: (form as any).paddleocr_online_token || '' }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOnlineEngineStatus(prev => ({ ...prev, paddleocr: data.ok ? 'ok' : 'fail' }))\n } catch {\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: 'fail' }))\n }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, paddleocr: false }))\n }, [form, mountedRef])\n\n // Auto-detect OCR engine statuses\n const autoOcrRef = useRef(false)\n useEffect(() => {\n if (autoOcrRef.current) return\n autoOcrRef.current = true\n const engines = ['tesseract', 'paddleocr']\n engines.forEach((eng) => {\n fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(eng)}`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrEngines((prev) => ({\n ...prev,\n [eng]: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n })\n .catch(() => {})\n })\n const defaultEngine = form.ocr_engine || 'tesseract'\n fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(defaultEngine)}`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrStatus(data.ok ? 'green' : 'red')\n setOcrMsg(data.version || data.message || (data.ok ? '已安装' : '未检测到'))\n })\n .catch(() => {})\n }, [form.ocr_engine, mountedRef])\n\n // Auto-detect PaddleOCR availability on mount\n // PaddleOCR already covered by B1's forEach loop\n useEffect(() => {\n fetch(`${API_BASE}/check-ocr?engine=paddleocr`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrEngines((prev) => ({\n ...prev,\n paddleocr: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n })\n .catch(() => {})\n }, [form.ocr_engine, mountedRef])\n\n // Update OCR header status when engine selection changes\n useEffect(() => {\n const eng = form.ocr_engine || 'tesseract'\n const info = ocrEngines[eng]\n if (info) {\n setOcrStatus(info.installed ? 'green' : 'red')\n setOcrMsg(info.msg || (info.installed ? '已安装' : '未检测到'))\n }\n }, [form.ocr_engine, ocrEngines])\n\n // Auto-detect online API connectivity\n const autoOnlineRef = useRef(false)\n useEffect(() => {\n if (autoOnlineRef.current) return\n autoOnlineRef.current = true\n const check = async () => {\n if ((form as any).mineru_token) {\n setOnlineTesting(prev => ({ ...prev, mineru: true }))\n try {\n const r = await fetch('/api/v1/check-mineru', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: (form as any).mineru_token }) })\n const d = await r.json()\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: d.ok ? 'ok' : 'fail' }))\n } catch { if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: 'fail' })) }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, mineru: false }))\n }\n if ((form as any).paddleocr_online_token) {\n setOnlineTesting(prev => ({ ...prev, paddleocr: true }))\n try {\n const r = await fetch('/api/v1/check-paddleocr-online', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: (form as any).paddleocr_online_token }) })\n const d = await r.json()\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: d.ok ? 'ok' : 'fail' }))\n } catch { if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: 'fail' })) }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, paddleocr: false }))\n }\n }\n check()\n }, [mountedRef])\n\n // --- Save / Update ---\n const handleSave = useCallback(async () => {\n setSaving(true)\n setSaveMsg('')\n try {\n const body = JSON.stringify(form)\n const res = await fetch('/api/v1/config', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body,\n })\n if (!res.ok) {\n setSaveMsg('保存失败: HTTP ' + res.status)\n setSaving(false)\n return\n }\n setSaveMsg('保存成功')\n setTimeout(() => setSaveMsg(''), 2000)\n } catch (e: any) {\n setSaveMsg('保存失败: ' + (e.message || '未知错误'))\n }\n setSaving(false)\n }, [form, mountedRef])\n\n const handleCheckUpdate = useCallback(async () => {\n setUpdateChecking(true)\n setUpdateResult('')\n try {\n const res = await fetch('/api/v1/check-update')\n const data = await res.json()\n if (!mountedRef.current) return\n if (data.has_update) {\n setUpdateResult(`新版本 v${data.latest} 可用 (当前 v${data.current})`)\n } else {\n setUpdateResult(`已是最新版本 v${data.current}`)\n }\n } catch (e) {\n if (mountedRef.current) setUpdateResult('检查失败')\n } finally {\n if (mountedRef.current) setUpdateChecking(false)\n }\n }, [mountedRef])\n\n return (\n <div className=\"space-y-3\">\n {/* OCRmyPDF 独立状态区 */}\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">OCRmyPDF 状态</label>\n <div className=\"flex items-center gap-2\">\n <StatusDot status={(ocrEngines['ocrmypdf'] as any)?.installed ? 'green' : (ocrEngines['ocrmypdf'] as any)?.msg ? 'red' : null} />\n <span className=\"text-xs text-gray-500\">\n {(ocrEngines['ocrmypdf'] as any)?.msg || '点击检测'}\n </span>\n <button\n type=\"button\"\n onClick={() => handleDetectOcrEngine('ocrmypdf')}\n className=\"px-2 py-1 text-xs rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500\"\n >\n 检测\n </button>\n <button\n type=\"button\"\n onClick={() => handleInstallOcrEngine('ocrmypdf')}\n disabled={(ocrEngines['ocrmypdf'] as any)?.installing}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50\"\n >\n {(ocrEngines['ocrmypdf'] as any)?.installing ? '安装中...' : '一键安装'}\n </button>\n </div>\n </div>\n\n {/* 引擎切换区 */}\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">引擎切换</span>\n <div className=\"grid grid-cols-2 gap-2\">\n {OCR_ENGINES.filter(eng => eng.key !== 'llm_ocr' && eng.key !== 'mineru' && eng.key !== 'paddleocr_online').map((eng) => {\n const info = (ocrEngines[eng.key] as any) || { installed: eng.key === 'llm_ocr', msg: '' }\n const isSelected = form.ocr_engine === eng.key\n return (\n <div\n key={eng.key}\n className={`rounded border p-2.5 ${isSelected ? 'border-blue-400 bg-blue-50' : 'border-gray-200 bg-white'}`}\n >\n <div className=\"flex items-center justify-between mb-1\">\n <span className=\"text-xs font-medium text-gray-700\">{eng.name}</span>\n <div className=\"flex items-center gap-1\">\n <StatusDot status={info?.installed ? 'green' : info?.msg ? 'red' : null} />\n </div>\n </div>\n <p className=\"text-xs text-gray-400 mb-2\">{eng.desc}</p>\n {eng.key !== 'llm_ocr' && info?.msg && (\n <p className={`text-xs mb-1.5 ${info.installed ? 'text-green-600' : 'text-red-500'}`}>\n {info.msg}\n </p>\n )}\n {eng.key !== 'llm_ocr' && eng.key === 'paddleocr' && !info?.installed && (\n <p className=\"text-xs text-gray-400 mb-1.5\">需要 Python 3.11 虚拟环境,点击安装自动搭建</p>\n )}\n {eng.key !== 'llm_ocr' && eng.key === 'paddleocr' && info?.installed && (info as any)?.venv && (\n <p className=\"text-xs text-green-600 mb-1.5\">运行环境: {(info as any).venv}</p>\n )}\n <div className=\"flex items-center gap-1.5\">\n {eng.key !== 'llm_ocr' && (\n <>\n <button\n type=\"button\"\n onClick={() => handleDetectOcrEngine(eng.key)}\n className=\"px-2 py-1 text-xs rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500\"\n >\n 检测\n </button>\n <button\n type=\"button\"\n onClick={() => handleInstallOcrEngine(eng.key)}\n disabled={info?.installing}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50\"\n >\n {info?.installing ? '安装中...' : '安装'}\n </button>\n </>\n )}\n {eng.key === form.ocr_engine ? null : (\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: eng.key })}\n className=\"px-2 py-1 text-xs rounded text-blue-600 hover:bg-blue-50 ml-auto\"\n >\n 选用\n </button>\n )}\n </div>\n </div>\n )\n })}\n </div>\n </div>\n\n {/* Tesseract 语言包状态 */}\n {form.ocr_engine !== 'llm_ocr' && (\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">Tesseract 语言包</span>\n <div className=\"flex items-center gap-2\">\n <StatusDot status={\n form.ocr_engine === 'tesseract' && (ocrEngines['tesseract'] as any)?.has_chi_sim\n ? 'green'\n : form.ocr_engine === 'tesseract'\n ? 'red'\n : null\n } />\n <span className=\"text-xs text-gray-500\">\n {(ocrEngines['tesseract'] as any)?.has_chi_sim\n ? 'chi_sim 已安装'\n : (ocrEngines['tesseract'] as any)?.languages\n ? 'chi_sim 未安装'\n : '请先检测 Tesseract'}\n </span>\n {!((ocrEngines['tesseract'] as any)?.has_chi_sim) && (\n <button\n type=\"button\"\n onClick={async () => {\n try {\n await fetch('/api/v1/install-tesseract-lang?lang=chi_sim')\n handleDetectOcrEngine('tesseract')\n } catch (e) {\n }\n }}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600\"\n >\n 安装语言包\n </button>\n )}\n </div>\n </div>\n )}\n\n <div className=\"grid grid-cols-4 gap-3 pt-2\">\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">语言</label>\n <select\n value={form.ocr_languages || 'chi_sim+eng'}\n onChange={(e) => updateForm({ ocr_languages: e.target.value })}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n >\n <option value=\"chi_sim+eng\">chi_sim+eng</option>\n <option value=\"chi_sim\">chi_sim</option>\n <option value=\"eng\">eng</option>\n <option value=\"chi_sim+eng+jpn\">chi_sim+eng+jpn</option>\n </select>\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">并行线程</label>\n <select\n value={form.ocr_jobs ?? 1}\n onChange={(e) => updateForm({ ocr_jobs: parseInt(e.target.value) || 1 })}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n >\n <option value={1}>1</option>\n <option value={2}>2</option>\n <option value={4}>4</option>\n </select>\n <p className=\"text-[10px] text-gray-400 mt-0.5 leading-tight\">Tesseract 支持多线程,PaddleOCR 仅单线程</p>\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">超时 (秒)</label>\n <input\n type=\"number\"\n value={form.ocr_timeout ?? 1800}\n onChange={(e) => updateForm({ ocr_timeout: parseInt(e.target.value) || 3600 })}\n min={60}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n />\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">OCR 采样 DPI</label>\n <input\n type=\"number\"\n min={150}\n max={400}\n step={50}\n value={(form as any).ocr_oversample || 200}\n onChange={(e) => updateForm({ ocr_oversample: parseInt(e.target.value) } as any)}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n />\n <span className=\"text-xs text-gray-400\">越低越快,150-400,推荐 200</span>\n </div>\n </div>\n\n {/* OCR 安装指引 */}\n {form.ocr_engine !== 'llm_ocr' && (\n <details className=\"group\">\n <summary className=\"text-xs font-medium text-gray-600 cursor-pointer list-none flex items-center gap-1 select-none hover:text-gray-800\">\n <svg className=\"w-3 h-3 text-gray-400 transition-transform group-open:rotate-90\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n </svg>\n OCR 命令行安装指引\n </summary>\n <div className=\"mt-2 bg-blue-50 border border-blue-200 rounded p-3\">\n <p className=\"text-xs text-blue-800 font-medium mb-2\">📋 将以下提示词复制并发送给 OpenCode:</p>\n <pre className=\"text-xs text-blue-700 bg-blue-100 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono\">{OCR_INSTALL_GUIDE}</pre>\n <p className=\"text-xs text-blue-600 mt-2\">安装后返回设置页点击\"检测\"按钮确认状态。</p>\n </div>\n </details>\n )}\n\n {/* LLM OCR */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'llm_ocr' ? (\n <span className=\"text-blue-600\">LLM OCR 已启用</span>\n ) : (\n <span className=\"text-gray-500\">LLM OCR</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'llm_ocr' ? 'tesseract' : 'llm_ocr' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'llm_ocr'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'llm_ocr' ? '停用并回到 OCRmyPDF' : '启用 LLM OCR'}\n </button>\n </div>\n </div>\n\n {form.ocr_engine === 'llm_ocr' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用视觉大模型逐框识别文字层。需要运行 lmstudio / ollama 加载对应模型。\n 推荐模型已验证中文 PDF 可用。\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">接口地址</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).llm_ocr_endpoint || 'http://127.0.0.1:1234/v1'}\n onChange={(e) => updateForm({ llm_ocr_endpoint: e.target.value } as any)}\n placeholder=\"http://127.0.0.1:1234/v1\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button onClick={async () => {\n setLlmTesting(true); setLlmTestMsg('');\n try {\n const r = await fetch('/api/v1/check-llm-ocr', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ endpoint: (form as any).llm_ocr_endpoint, model: (form as any).llm_ocr_model })\n });\n const d = await r.json();\n setLlmTestMsg(d.message || (d.ok ? 'OK' : 'Failed'));\n } catch (e) { setLlmTestMsg(String(e)); }\n setLlmTesting(false);\n }} disabled={llmTesting}\n className=\"px-3 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap\">\n {llmTesting ? '测试中...' : '检测'}\n </button>\n </div>\n {llmTestMsg && <p className={`text-xs mt-1 ${llmTestMsg.includes('成功') || llmTestMsg.includes('OK') ? 'text-green-600' : 'text-red-500'}`}>{llmTestMsg}</p>}\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">模型名称</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).llm_ocr_model || ''}\n onChange={(e) => updateForm({ llm_ocr_model: e.target.value } as any)}\n placeholder=\"qwen3-vl-4b-instruct\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button onClick={async () => {\n setLlmFetching(true); setLlmFetchMsg('');\n try {\n const r = await fetch('/api/v1/fetch-llm-models', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ endpoint: (form as any).llm_ocr_endpoint })\n });\n const d = await r.json();\n if (d.ok && d.models.length > 0) {\n setLlmModels(d.models);\n setLlmFetchMsg(`${d.models.length} 个模型`);\n } else {\n setLlmFetchMsg(d.message || '无可用模型');\n }\n } catch (e) { setLlmFetchMsg(String(e)); }\n setLlmFetching(false);\n }} disabled={llmFetching || !(form as any).llm_ocr_endpoint}\n className=\"px-2 py-1.5 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap\">\n {llmFetching ? '...' : '拉取模型'}\n </button>\n </div>\n {llmModels.length > 0 && (\n <select value={(form as any).llm_ocr_model || ''}\n onChange={(e) => updateForm({ llm_ocr_model: e.target.value } as any)}\n className=\"w-full mt-1 rounded border border-blue-300 px-2 py-1 text-xs font-mono\"\n size={Math.min(llmModels.length + 1, 8)}>\n <option value=\"\" disabled>-- 选择模型 --</option>\n {llmModels.map((m: any) => (\n <option key={m.id} value={m.id}>{m.name}</option>\n ))}\n </select>\n )}\n {llmFetchMsg && <p className={`text-xs mt-1 ${llmFetchMsg.includes('个模型') ? 'text-green-600' : 'text-red-500'}`}>{llmFetchMsg}</p>}\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">\n 并发数: {(form as any).llm_ocr_concurrency || 1}\n </label>\n <input type=\"range\" min=\"1\" max=\"5\" step=\"1\"\n value={(form as any).llm_ocr_concurrency || 1}\n onChange={(e) => updateForm({ llm_ocr_concurrency: parseInt(e.target.value) } as any)}\n className=\"w-full\" />\n <div className=\"flex justify-between text-xs text-gray-400\">\n <span>1 (默认)</span><span>5 (最快)</span>\n </div>\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">\n 版面检测批次: {(form as any).llm_ocr_detect_batch || 20} 页/批\n </label>\n <input type=\"range\" min=\"5\" max=\"50\" step=\"5\"\n value={(form as any).llm_ocr_detect_batch || 20}\n onChange={(e) => updateForm({ llm_ocr_detect_batch: parseInt(e.target.value) } as any)}\n className=\"w-full\" />\n <div className=\"flex justify-between text-xs text-gray-400\">\n <span>5 页 (低内存)</span><span>50 页 (更快)</span>\n </div>\n <p className=\"text-xs text-gray-400 mt-0.5\">Surya 版面检测每批处理页数。内存不足时降低。</p>\n </div>\n <details className=\"text-xs\">\n <summary className=\"cursor-pointer text-blue-600 hover:text-blue-800\">\n 推荐模型 (已验证中文 PDF)\n </summary>\n <div className=\"mt-2 space-y-1\">\n {LLM_OCR_RECOMMENDED.map(m => (\n <button key={m.model}\n onClick={() => updateForm({ llm_ocr_model: m.model } as any)}\n title={m.note}\n className=\"block w-full text-left px-2 py-1 rounded hover:bg-blue-100 text-gray-700 text-xs\">\n <span className=\"font-mono\">{m.model}</span>\n <span className=\"text-gray-400 ml-2\">{m.note}</span>\n </button>\n ))}\n </div>\n </details>\n </div>\n )}\n\n {/* MinerU */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'mineru' ? (\n <span className=\"text-blue-600\">MinerU 线上 API 已启用</span>\n ) : (\n <span className=\"text-gray-500\">MinerU 线上 API</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'mineru' ? 'tesseract' : 'mineru' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'mineru'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'mineru' ? '停用 MinerU' : '启用 MinerU'}\n </button>\n </div>\n </div>\n {form.ocr_engine === 'mineru' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用 MinerU v4 精准解析 API 进行文档 OCR。PDF 将上传至 MinerU 服务器处理。\n 仅限中国大陆网络访问,单文件 ≤200MB / ≤200页。\n <span className=\"text-orange-500 ml-1\">实验功能,识别可能会有重复</span>\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API Token</label>\n <input value={(form as any).mineru_token || ''}\n onChange={(e) => updateForm({ mineru_token: e.target.value } as any)}\n placeholder=\"输入 MinerU API Token(Bearer 认证)\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"flex items-center gap-2\">\n <button\n type=\"button\"\n onClick={handleTestMineru}\n disabled={onlineTesting.mineru || !(form as any).mineru_token}\n className=\"px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed\">\n {onlineTesting.mineru ? '测试中...' : '检测'}\n </button>\n {onlineEngineStatus.mineru === 'ok' && (\n <span className=\"text-xs text-green-600\">连接正常</span>\n )}\n {onlineEngineStatus.mineru === 'fail' && (\n <span className=\"text-xs text-red-500\">连接失败</span>\n )}\n </div>\n <p className=\"text-xs text-gray-400\">\n 需要先申请 Token:<a href=\"https://mineru.net/apiManage/docs\" target=\"_blank\" className=\"text-blue-500 hover:underline\">MinerU API 文档</a>\n </p>\n </div>\n )}\n\n {/* PaddleOCR-VL-1.5 */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'paddleocr_online' ? (\n <span className=\"text-blue-600\">PaddleOCR-VL-1.5 线上 API 已启用</span>\n ) : (\n <span className=\"text-gray-500\">PaddleOCR-VL-1.5 线上 API</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'paddleocr_online' ? 'tesseract' : 'paddleocr_online' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'paddleocr_online'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'paddleocr_online' ? '停用 PaddleOCR-VL-1.5' : '启用 PaddleOCR-VL-1.5'}\n </button>\n </div>\n </div>\n {form.ocr_engine === 'paddleocr_online' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用百度 PaddleOCR-VL-1.5 视觉大模型进行文档版面解析。\n 支持中英文文档,无需本地 GPU。\n <span className=\"text-orange-500 ml-1\">实验功能,识别可能会有重复</span>\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1.5\">识别模式 <span className=\"text-[11px] text-blue-500 ml-1\">推荐逐框识别,次选混合识别</span></label>\n <div className=\"flex rounded border border-gray-300 overflow-hidden text-xs\">\n {[\n { key: 'spatial', label: '空间分配(快)' },\n { key: 'perbox', label: '逐框识别(慢)' },\n { key: 'hybrid', label: '混合识别' },\n ].map(({ key, label }) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => updateForm({ paddleocr_online_mode: key } as any)}\n className={`flex-1 py-1.5 text-center transition-colors ${\n ((form as any).paddleocr_online_mode || 'spatial') === key\n ? 'bg-blue-500 text-white'\n : 'bg-white text-gray-600 hover:bg-gray-50'\n }`}\n >\n {label}\n </button>\n ))}\n </div>\n <p className=\"text-[11px] text-gray-400 mt-1\">\n {((form as any).paddleocr_online_mode || 'spatial') === 'spatial' && '段落文字精准,行识别不精确'}\n {((form as any).paddleocr_online_mode || 'spatial') === 'perbox' && '行识别精准,有乱码风险'}\n {((form as any).paddleocr_online_mode || 'spatial') === 'hybrid' && '逐框为主、空间填补,可能产生识别重复'}\n </p>\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">Access Token</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).paddleocr_online_token || ''}\n onChange={(e) => updateForm({ paddleocr_online_token: e.target.value } as any)}\n placeholder=\"输入 PaddleOCR access token\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button\n type=\"button\"\n onClick={handleTestPaddleocrOnline}\n disabled={onlineTesting.paddleocr || !(form as any).paddleocr_online_token}\n className=\"px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap\">\n {onlineTesting.paddleocr ? '测试中...' : '检测'}\n </button>\n </div>\n {onlineEngineStatus.paddleocr === 'ok' && (\n <p className=\"text-xs text-green-600 mt-1\">连接正常</p>\n )}\n {onlineEngineStatus.paddleocr === 'fail' && (\n <p className=\"text-xs text-red-500 mt-1\">连接失败</p>\n )}\n </div>\n <p className=\"text-xs text-gray-400\">\n 从 <a href=\"https://aistudio.baidu.com/paddleocr/task\" target=\"_blank\" className=\"text-blue-500 hover:underline\">PaddleOCR 控制台</a> 获取 Access Token。\n <a href=\"https://ai.baidu.com/ai-doc/AISTUDIO/Cmkz2m0ma\" target=\"_blank\" className=\"text-blue-500 hover:underline ml-2\">API 文档</a>\n </p>\n </div>\n )}\n\n <div className=\"flex items-center gap-2 pt-3\">\n <input type=\"checkbox\" id=\"ocr_confirm_enabled\"\n checked={(form as any).ocr_confirm_enabled ?? false}\n onChange={(e) => updateForm({ ocr_confirm_enabled: e.target.checked } as any)}\n className=\"rounded\" />\n <label htmlFor=\"ocr_confirm_enabled\" className=\"text-xs font-medium text-gray-600\">\n 管道执行到 OCR 步骤时弹出确认对话框\n </label>\n </div>\n\n {/* ============ AI Vision / 书签 ============ */}\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">AI Vision 目录提取</span>\n <div className=\"flex items-center gap-2\">\n <input type=\"checkbox\" id=\"ai_vision_enabled\"\n checked={(form as any).ai_vision_enabled ?? true}\n onChange={(e) => updateForm({ ai_vision_enabled: e.target.checked } as any)} className=\"rounded\" />\n <label htmlFor=\"ai_vision_enabled\" className=\"text-xs\">启用 AI Vision 目录提取</label>\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API 端点</label>\n <input type=\"text\" value={(form as any).ai_vision_endpoint || ''}\n onChange={(e) => updateForm({ ai_vision_endpoint: e.target.value } as any)}\n placeholder={\n (form as any).ai_vision_provider === 'gemini' ? 'https://generativelanguage.googleapis.com/v1beta' :\n (form as any).ai_vision_provider === 'azure' ? 'https://{resource}.openai.azure.com' :\n (form as any).ai_vision_provider === 'anthropic' || (form as any).ai_vision_provider === 'minimax_anthropic' ? 'https://api.anthropic.com' :\n (form as any).ai_vision_provider === 'minimax_openai' ? 'https://api.minimaxi.com/v1' :\n 'http://127.0.0.1:12345/v1'\n }\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">模型名称</label>\n <div className=\"flex gap-1\">\n <input type=\"text\" value={(form as any).ai_vision_model || ''}\n onChange={(e) => updateForm({ ai_vision_model: e.target.value } as any)}\n placeholder=\"sabafallah/deepseek-ocr\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button\n type=\"button\"\n onClick={async () => {\n setFetchingModels(true)\n setFetchModelsMsg('')\n try {\n const res = await fetch('/api/v1/fetch-models', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n endpoint: (form as any).ai_vision_endpoint,\n api_key: (form as any).ai_vision_api_key,\n provider: (form as any).ai_vision_provider,\n }),\n })\n const data = await res.json()\n if (data.ok && data.models.length > 0) {\n setAiModels(data.models)\n setFetchModelsMsg(`${data.models.length} 个模型`)\n } else {\n setFetchModelsMsg(data.message || '无可用模型')\n }\n } catch (e) {\n setFetchModelsMsg(String(e))\n }\n setFetchingModels(false)\n }}\n disabled={fetchingModels || !(form as any).ai_vision_endpoint}\n className=\"px-2 py-1.5 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap\"\n title=\"从提供商拉取可用模型列表\"\n >\n {fetchingModels ? '...' : '获取模型'}\n </button>\n </div>\n {aiModels.length > 0 && (\n <select\n value={(form as any).ai_vision_model || ''}\n onChange={(e) => updateForm({ ai_vision_model: e.target.value } as any)}\n className=\"w-full mt-1 rounded border border-blue-300 px-2 py-1 text-xs font-mono\"\n size={Math.min(aiModels.length + 1, 8)}\n >\n <option value=\"\" disabled>-- 选择模型 --</option>\n {aiModels.map(m => (\n <option key={m.id} value={m.id}>{m.name}</option>\n ))}\n </select>\n )}\n {fetchModelsMsg && (\n <p className={`text-xs mt-1 ${fetchModelsMsg.includes('个模型') ? 'text-green-600' : 'text-red-500'}`}>\n {fetchModelsMsg}\n </p>\n )}\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API Key</label>\n <input type=\"password\" value={(form as any).ai_vision_api_key || ''}\n onChange={(e) => updateForm({ ai_vision_api_key: e.target.value } as any)}\n placeholder=\"sk-... (支持 {env:VAR_NAME})\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API 格式</label>\n <select value={(form as any).ai_vision_provider || 'openai_compatible'}\n onChange={(e) => {\n const newProvider = e.target.value\n const defaultEndpoint = AI_VISION_ENDPOINTS[newProvider] || ''\n updateForm({\n ai_vision_provider: newProvider,\n ai_vision_endpoint: defaultEndpoint,\n } as any)\n }}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs\">\n <option value=\"openai_compatible\">OpenAI / DeepSeek / 智谱 / Qwen / Ollama / lmstudio</option>\n <option value=\"openai_responses\">OpenAI Responses (新版 /v1/responses)</option>\n <option value=\"azure\">Azure OpenAI</option>\n <option value=\"anthropic\">Anthropic Claude</option>\n <option value=\"gemini\">Google Gemini</option>\n <option value=\"minimax_openai\">MiniMax (OpenAI 兼容 · 国内/国际通用)</option>\n <option value=\"minimax_anthropic\">MiniMax (Anthropic 兼容 · Token Plan)</option>\n <option value=\"custom\">自定义 (Custom)</option>\n </select>\n </div>\n {(form as any).ai_vision_provider === 'custom' && (\n <div className=\"flex items-center gap-2 mt-2\">\n <input type=\"checkbox\" id=\"ai_vision_messages_api\"\n checked={(form as any).ai_vision_messages_api ?? false}\n onChange={(e) => updateForm({ ai_vision_messages_api: e.target.checked } as any)} className=\"rounded\" />\n <label htmlFor=\"ai_vision_messages_api\" className=\"text-xs font-medium text-gray-600\">\n 使用 Anthropic Messages API 格式 (默认 OpenAI Chat Completions)\n </label>\n </div>\n )}\n <div className=\"grid grid-cols-2 gap-3 mt-2\">\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">DPI</label>\n <input type=\"number\" value={(form as any).ai_vision_dpi ?? 150}\n onChange={(e) => updateForm({ ai_vision_dpi: parseInt(e.target.value) || 150 } as any)}\n min={72} max={300} className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs\" />\n </div>\n </div>\n <button\n type=\"button\"\n onClick={async () => {\n setAiVisionTest('testing');\n try {\n const res = await fetch('/api/v1/check-ai-vision', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n endpoint: (form as any).ai_vision_endpoint,\n model: (form as any).ai_vision_model,\n api_key: (form as any).ai_vision_api_key,\n provider: (form as any).ai_vision_provider,\n messages_api: (form as any).ai_vision_messages_api,\n }),\n });\n const data = await res.json();\n setAiVisionTest(data.ok ? 'ok' : 'fail');\n setAiVisionMsg(data.message || data.error || '');\n } catch (e) {\n setAiVisionTest('fail');\n setAiVisionMsg(String(e));\n }\n }}\n disabled={aiVisionTest === 'testing'}\n className=\"px-3 py-1.5 mt-2 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50\"\n >\n {aiVisionTest === 'testing' ? '测试中...' : '检测'}\n </button>\n {aiVisionMsg && (\n <p className={`text-xs mt-1 ${aiVisionTest === 'ok' ? 'text-green-600' : 'text-red-600'}`}>\n {aiVisionMsg}\n </p>\n )}\n <p className=\"text-xs text-gray-400 mt-1\">\n 阶段1 从 OCR 文字层提取目录(免费),阶段2 用 AI Vision 从目录页图片提取。\n 支持 OpenAI 兼容 / Gemini / MiniMax 格式。\n </p>\n <div className=\"flex items-center gap-2 mt-2\">\n <input type=\"checkbox\" id=\"bookmark_confirm_enabled\"\n checked={(form as any).bookmark_confirm_enabled ?? false}\n onChange={(e) => updateForm({ bookmark_confirm_enabled: e.target.checked } as any)}\n className=\"rounded\" />\n <label htmlFor=\"bookmark_confirm_enabled\" className=\"text-xs font-medium text-gray-600\">\n 管道执行到书签步骤时弹出确认对话框\n </label>\n </div>\n </div>\n\n {/* ============ 主题 ============ */}\n <div className=\"border-t border-gray-200 pt-3\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1.5\">主题</label>\n <div className=\"flex rounded border border-gray-300 overflow-hidden text-xs\">\n {[\n { key: 'auto', label: '自动' },\n { key: 'light', label: '白昼' },\n { key: 'dark', label: '黑夜' },\n ].map(({ key, label }) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => updateForm({ theme: key })}\n className={`flex-1 py-1.5 text-center transition-colors ${\n ((form as any).theme || 'auto') === key\n ? 'bg-blue-500 text-white'\n : 'bg-white text-gray-600 hover:bg-gray-50'\n }`}\n >\n {label}\n </button>\n ))}\n </div>\n </div>\n\n {/* ============ 保存按钮 ============ */}\n <div className=\"flex items-center gap-3 pt-2\">\n <button\n type=\"button\"\n onClick={handleSave}\n disabled={saving}\n className=\"px-5 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 font-medium\"\n >\n {saving ? '保存中...' : '保存设置'}\n </button>\n <button\n type=\"button\"\n onClick={handleCheckUpdate}\n disabled={updateChecking}\n className=\"px-5 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 font-medium\"\n >\n {updateChecking ? '检测中...' : '检查更新'}\n </button>\n {saveMsg && (\n <span className={`text-xs ${saveMsg.includes('成功') ? 'text-green-600' : 'text-red-500'}`}>\n {saveMsg}\n </span>\n )}\n {updateResult && (\n <span className={`text-xs px-2 py-1 rounded ${updateResult.includes('失败') ? 'text-red-600 bg-red-50' : updateResult.includes('新版本') ? 'text-blue-600 bg-blue-50 font-semibold' : 'text-green-600 bg-green-50'}`}>\n {updateResult}\n </span>\n )}\n </div>\n </div>\n )\n}\n\nexport default React.memo(OCRNetworkSection)\n"}, "82e44dcd228c35b00a7ff5d95587319cc6f55f04": {"t": 1785772781.3517742, "n": 49236, "h": "d1a56a20ffac91c5", "c": "import React, { useState, useEffect, useRef, useCallback } from 'react'\nimport { SectionProps } from './types'\nimport { LLM_OCR_RECOMMENDED, API_BASE } from '../../constants'\n\nfunction StatusDot({ status }: { status: 'green' | 'red' | 'yellow' | null }) {\n const colors: Record<string, string> = {\n green: 'bg-green-500',\n red: 'bg-red-500',\n yellow: 'bg-yellow-500',\n }\n return (\n <span className={`inline-block w-2 h-2 rounded-full ${status ? colors[status] : 'bg-gray-300'}`} />\n )\n}\n\nconst OCR_ENGINES = [\n { key: 'tesseract', name: 'Tesseract OCR', desc: '内置引擎,需 chi_sim 语言包' },\n { key: 'paddleocr', name: 'PaddleOCR', desc: '百度引擎,需 Python 3.11 虚拟环境' },\n { key: 'llm_ocr', name: 'LLM OCR', desc: '大模型视觉识别,无需本地引擎' },\n { key: 'mineru', name: 'MinerU', desc: '线上 API 精准解析,需 Token' },\n { key: 'paddleocr_online', name: 'PaddleOCR-VL-1.5', desc: '线上 API,需 Token' },\n]\n\nconst OCR_INSTALL_GUIDE = `## 安装 OCR 引擎\n\n**注意**:Python 3.14 与 \\`paddlepaddle\\` 不兼容。建议使用 Python 3.11 创建虚拟环境。\n\n---\n\n### 1. 创建/重建虚拟环境(Python 3.11)\n\n\\`\\`\\`powershell\n# 如果原 venv 不支持 Python 3.11,先删除\nRemove-Item -Recurse -Force backend\\\\venv\n\n# 使用 Python 3.11 创建新 venv\nC:\\\\Python311\\\\python.exe -m venv backend\\\\venv\n\\`\\`\\`\n\n### 2. 激活虚拟环境(PowerShell)\n\n\\`\\`\\`powershell\n# 方法一:PowerShell 执行策略问题\npowershell -ExecutionPolicy Bypass -Command \"backend\\\\venv\\\\Scripts\\\\Activate.ps1\"\n\n# 方法二:直接用 python 运行(无需激活)\nC:\\\\Python311\\\\python.exe -m pip install ...\n\\`\\`\\`\n\n### 3. 安装 OCRmyPDF 核心\n\n\\`\\`\\`powershell\npython -m pip install ocrmypdf\n\\`\\`\\`\n\n### 4. 安装引擎插件\n\n\\`\\`\\`powershell\n# 安装所有 OCR 引擎(推荐,一步到位)\npython -m pip install ocrmypdf-paddleocr paddleocr\n\n# 或分开安装\npython -m pip install paddleocr\npython -m pip install ocrmypdf-paddleocr # 需要先装好 paddleocr\n\\`\\`\\`\n\n### 5. 安装 Tesseract OCR 系统依赖\n\n\\`\\`\\`powershell\nwinget install --id UB-Mannheim.TesseractOCR --accept-package-agreements --accept-source-agreements\n\\`\\`\\`\n\n### 6. 将 Tesseract 添加到 PATH\n\n\\`\\`\\`powershell\n[Environment]::SetEnvironmentVariable(\"Path\", [Environment]::GetEnvironmentVariable(\"Path\", \"User\") + \";C:\\\\Program Files\\\\Tesseract-OCR\", \"User\")\n\\`\\`\\`\n\n> ⚠️ 添加后需**重启终端或IDE**使 PATH 生效,或使用完整路径验证:\n> \\`\\`\\`powershell\n> & \"C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\" --version\n> \\`\\`\\`\n\n### 7. 验证安装\n\n\\`\\`\\`powershell\npython -c \"import ocrmypdf; print('ocrmypdf:', ocrmypdf.__version__)\"\npython -c \"from paddle import paddle; print('paddle:', paddle.__version__)\"\n\"C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\" --version\n\\`\\`\\`\n\n### 常见问题\n\n| 问题 | 解决 |\n|------|------|\n| \\`ModuleNotFoundError: No module named 'paddlepaddle'\\` | \\`import paddle\\` 而非 \\`import paddlepaddle\\` |\n| \\`ocrmypdf-paddleocr\\` 依赖冲突 | 先装 \\`paddleocr\\`,再装 \\`ocrmypdf-paddleocr\\` |\n| Tesseract 找不到 | 重启终端,或使用完整路径 \\`C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\\` |`\n\nconst AI_VISION_ENDPOINTS: Record<string, string> = {\n openai_compatible: 'https://api.openai.com/v1',\n openai_responses: 'https://api.openai.com/v1',\n azure: 'https://RESOURCE_NAME.openai.azure.com',\n anthropic: 'https://api.anthropic.com',\n gemini: 'https://generativelanguage.googleapis.com/v1beta',\n minimax_openai: 'https://api.minimaxi.com/v1',\n minimax_anthropic: 'https://api.minimaxi.com',\n custom: '',\n}\n\nfunction OCRNetworkSection({ form, updateForm, mountedRef }: SectionProps) {\n const [ocrChecking, setOcrChecking] = useState(false)\n const [ocrStatus, setOcrStatus] = useState<'green' | 'red' | 'yellow' | null>(null)\n const [ocrMsg, setOcrMsg] = useState('')\n const [ocrEngines, setOcrEngines] = useState<Record<string, { installed: boolean; installing: boolean; msg: string }>>({})\n const [llmModels, setLlmModels] = useState<any[]>([])\n const [llmFetchMsg, setLlmFetchMsg] = useState('')\n const [onlineEngineStatus, setOnlineEngineStatus] = useState<Record<string, 'ok' | 'fail' | ''>>({ mineru: '', paddleocr: '' })\n const [onlineTesting, setOnlineTesting] = useState<Record<string, boolean>>({ mineru: false, paddleocr: false })\n const [llmFetching, setLlmFetching] = useState(false)\n const [llmTestMsg, setLlmTestMsg] = useState('')\n const [llmTesting, setLlmTesting] = useState(false)\n const [aiModels, setAiModels] = useState<{ id: string; name: string }[]>([])\n const [fetchingModels, setFetchingModels] = useState(false)\n const [fetchModelsMsg, setFetchModelsMsg] = useState('')\n const [aiVisionTest, setAiVisionTest] = useState<'testing' | 'ok' | 'fail' | null>(null)\n const [aiVisionMsg, setAiVisionMsg] = useState('')\n const [saving, setSaving] = useState(false)\n const [saveMsg, setSaveMsg] = useState('')\n const [updateChecking, setUpdateChecking] = useState(false)\n const [updateResult, setUpdateResult] = useState('')\n\n // --- OCR logic ---\n const checkOcr = useCallback(async (engine?: string) => {\n setOcrChecking(true)\n try {\n const eng = engine || form.ocr_engine || 'tesseract'\n const res = await fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(eng)}`)\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrStatus(data.ok ? 'green' : 'red')\n setOcrMsg(data.message || (data.ok ? data.version || '已安装' : '未检测到'))\n } catch (e) {\n if (mountedRef.current) {\n setOcrStatus('red')\n setOcrMsg('检测失败')\n }\n } finally {\n if (mountedRef.current) setOcrChecking(false)\n }\n }, [form.ocr_engine, mountedRef])\n\n const handleDetectOcrEngine = useCallback(async (engine: string) => {\n setOcrEngines(prev => ({ ...prev, [engine]: { ...prev[engine], installing: false, msg: '检测中...' } }))\n try {\n const res = await fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(engine)}`)\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrEngines(prev => ({\n ...prev,\n [engine]: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n } catch (e) {\n if (mountedRef.current) {\n setOcrEngines(prev => ({\n ...prev,\n [engine]: { installed: false, installing: false, msg: '检测失败' },\n }))\n }\n }\n }, [mountedRef])\n\n const handleInstallOcrEngine = useCallback(async (engine: string) => {\n setOcrEngines(prev => ({ ...prev, [engine]: { ...prev[engine], installing: true, msg: '安装中...' } }))\n try {\n const res = await fetch('/api/v1/install-ocr', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ engine }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrEngines(prev => ({\n ...prev,\n [engine]: {\n installed: data.ok || false,\n installing: false,\n msg: data.message || (data.ok ? '安装成功' : '安装失败'),\n },\n }))\n } catch (e) {\n if (mountedRef.current) {\n setOcrEngines(prev => ({\n ...prev,\n [engine]: { installed: false, installing: false, msg: '安装请求失败' },\n }))\n }\n }\n }, [mountedRef])\n\n const handleTestMineru = useCallback(async () => {\n setOnlineTesting(prev => ({ ...prev, mineru: true }))\n setOnlineEngineStatus(prev => ({ ...prev, mineru: '' }))\n try {\n const res = await fetch('/api/v1/check-mineru', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: (form as any).mineru_token || '' }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOnlineEngineStatus(prev => ({ ...prev, mineru: data.ok ? 'ok' : 'fail' }))\n } catch {\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: 'fail' }))\n }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, mineru: false }))\n }, [form, mountedRef])\n\n const handleTestPaddleocrOnline = useCallback(async () => {\n setOnlineTesting(prev => ({ ...prev, paddleocr: true }))\n setOnlineEngineStatus(prev => ({ ...prev, paddleocr: '' }))\n try {\n const res = await fetch('/api/v1/check-paddleocr-online', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: (form as any).paddleocr_online_token || '' }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOnlineEngineStatus(prev => ({ ...prev, paddleocr: data.ok ? 'ok' : 'fail' }))\n } catch {\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: 'fail' }))\n }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, paddleocr: false }))\n }, [form, mountedRef])\n\n // Auto-detect OCR engine statuses\n const autoOcrRef = useRef(false)\n useEffect(() => {\n if (autoOcrRef.current) return\n autoOcrRef.current = true\n const engines = ['tesseract', 'paddleocr']\n engines.forEach((eng) => {\n fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(eng)}`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrEngines((prev) => ({\n ...prev,\n [eng]: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n })\n .catch(() => {})\n })\n const defaultEngine = form.ocr_engine || 'tesseract'\n fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(defaultEngine)}`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrStatus(data.ok ? 'green' : 'red')\n setOcrMsg(data.version || data.message || (data.ok ? '已安装' : '未检测到'))\n })\n .catch(() => {})\n }, [form.ocr_engine, mountedRef])\n\n // Auto-detect PaddleOCR availability on mount\n useEffect(() => {\n fetch(`${API_BASE}/check-ocr?engine=paddleocr`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrEngines((prev) => ({\n ...prev,\n paddleocr: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n })\n .catch(() => {})\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n // Update OCR header status when engine selection changes\n useEffect(() => {\n const eng = form.ocr_engine || 'tesseract'\n const info = ocrEngines[eng]\n if (info) {\n setOcrStatus(info.installed ? 'green' : 'red')\n setOcrMsg(info.msg || (info.installed ? '已安装' : '未检测到'))\n }\n }, [form.ocr_engine, ocrEngines])\n\n // Auto-detect online API connectivity\n const autoOnlineRef = useRef(false)\n useEffect(() => {\n if (autoOnlineRef.current) return\n autoOnlineRef.current = true\n const check = async () => {\n if ((form as any).mineru_token) {\n setOnlineTesting(prev => ({ ...prev, mineru: true }))\n try {\n const r = await fetch('/api/v1/check-mineru', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: (form as any).mineru_token }) })\n const d = await r.json()\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: d.ok ? 'ok' : 'fail' }))\n } catch { if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: 'fail' })) }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, mineru: false }))\n }\n if ((form as any).paddleocr_online_token) {\n setOnlineTesting(prev => ({ ...prev, paddleocr: true }))\n try {\n const r = await fetch('/api/v1/check-paddleocr-online', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: (form as any).paddleocr_online_token }) })\n const d = await r.json()\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: d.ok ? 'ok' : 'fail' }))\n } catch { if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: 'fail' })) }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, paddleocr: false }))\n }\n }\n check()\n }, [mountedRef])\n\n // --- Save / Update ---\n const handleSave = useCallback(async () => {\n setSaving(true)\n setSaveMsg('')\n try {\n const body = JSON.stringify(form)\n const res = await fetch('/api/v1/config', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body,\n })\n if (!res.ok) {\n setSaveMsg('保存失败: HTTP ' + res.status)\n setSaving(false)\n return\n }\n setSaveMsg('保存成功')\n setTimeout(() => setSaveMsg(''), 2000)\n } catch (e: any) {\n setSaveMsg('保存失败: ' + (e.message || '未知错误'))\n }\n setSaving(false)\n }, [form, mountedRef])\n\n const handleCheckUpdate = useCallback(async () => {\n setUpdateChecking(true)\n setUpdateResult('')\n try {\n const res = await fetch('/api/v1/check-update')\n const data = await res.json()\n if (!mountedRef.current) return\n if (data.has_update) {\n setUpdateResult(`新版本 v${data.latest} 可用 (当前 v${data.current})`)\n } else {\n setUpdateResult(`已是最新版本 v${data.current}`)\n }\n } catch (e) {\n if (mountedRef.current) setUpdateResult('检查失败')\n } finally {\n if (mountedRef.current) setUpdateChecking(false)\n }\n }, [mountedRef])\n\n return (\n <div className=\"space-y-3\">\n {/* OCRmyPDF 独立状态区 */}\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">OCRmyPDF 状态</label>\n <div className=\"flex items-center gap-2\">\n <StatusDot status={(ocrEngines['ocrmypdf'] as any)?.installed ? 'green' : (ocrEngines['ocrmypdf'] as any)?.msg ? 'red' : null} />\n <span className=\"text-xs text-gray-500\">\n {(ocrEngines['ocrmypdf'] as any)?.msg || '点击检测'}\n </span>\n <button\n type=\"button\"\n onClick={() => handleDetectOcrEngine('ocrmypdf')}\n className=\"px-2 py-1 text-xs rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500\"\n >\n 检测\n </button>\n <button\n type=\"button\"\n onClick={() => handleInstallOcrEngine('ocrmypdf')}\n disabled={(ocrEngines['ocrmypdf'] as any)?.installing}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50\"\n >\n {(ocrEngines['ocrmypdf'] as any)?.installing ? '安装中...' : '一键安装'}\n </button>\n </div>\n </div>\n\n {/* 引擎切换区 */}\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">引擎切换</span>\n <div className=\"grid grid-cols-2 gap-2\">\n {OCR_ENGINES.filter(eng => eng.key !== 'llm_ocr' && eng.key !== 'mineru' && eng.key !== 'paddleocr_online').map((eng) => {\n const info = (ocrEngines[eng.key] as any) || { installed: eng.key === 'llm_ocr', msg: '' }\n const isSelected = form.ocr_engine === eng.key\n return (\n <div\n key={eng.key}\n className={`rounded border p-2.5 ${isSelected ? 'border-blue-400 bg-blue-50' : 'border-gray-200 bg-white'}`}\n >\n <div className=\"flex items-center justify-between mb-1\">\n <span className=\"text-xs font-medium text-gray-700\">{eng.name}</span>\n <div className=\"flex items-center gap-1\">\n <StatusDot status={info?.installed ? 'green' : info?.msg ? 'red' : null} />\n </div>\n </div>\n <p className=\"text-xs text-gray-400 mb-2\">{eng.desc}</p>\n {eng.key !== 'llm_ocr' && info?.msg && (\n <p className={`text-xs mb-1.5 ${info.installed ? 'text-green-600' : 'text-red-500'}`}>\n {info.msg}\n </p>\n )}\n {eng.key !== 'llm_ocr' && eng.key === 'paddleocr' && !info?.installed && (\n <p className=\"text-xs text-gray-400 mb-1.5\">需要 Python 3.11 虚拟环境,点击安装自动搭建</p>\n )}\n {eng.key !== 'llm_ocr' && eng.key === 'paddleocr' && info?.installed && (info as any)?.venv && (\n <p className=\"text-xs text-green-600 mb-1.5\">运行环境: {(info as any).venv}</p>\n )}\n <div className=\"flex items-center gap-1.5\">\n {eng.key !== 'llm_ocr' && (\n <>\n <button\n type=\"button\"\n onClick={() => handleDetectOcrEngine(eng.key)}\n className=\"px-2 py-1 text-xs rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500\"\n >\n 检测\n </button>\n <button\n type=\"button\"\n onClick={() => handleInstallOcrEngine(eng.key)}\n disabled={info?.installing}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50\"\n >\n {info?.installing ? '安装中...' : '安装'}\n </button>\n </>\n )}\n {eng.key === form.ocr_engine ? null : (\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: eng.key })}\n className=\"px-2 py-1 text-xs rounded text-blue-600 hover:bg-blue-50 ml-auto\"\n >\n 选用\n </button>\n )}\n </div>\n </div>\n )\n })}\n </div>\n </div>\n\n {/* Tesseract 语言包状态 */}\n {form.ocr_engine !== 'llm_ocr' && (\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">Tesseract 语言包</span>\n <div className=\"flex items-center gap-2\">\n <StatusDot status={\n form.ocr_engine === 'tesseract' && (ocrEngines['tesseract'] as any)?.has_chi_sim\n ? 'green'\n : form.ocr_engine === 'tesseract'\n ? 'red'\n : null\n } />\n <span className=\"text-xs text-gray-500\">\n {(ocrEngines['tesseract'] as any)?.has_chi_sim\n ? 'chi_sim 已安装'\n : (ocrEngines['tesseract'] as any)?.languages\n ? 'chi_sim 未安装'\n : '请先检测 Tesseract'}\n </span>\n {!((ocrEngines['tesseract'] as any)?.has_chi_sim) && (\n <button\n type=\"button\"\n onClick={async () => {\n try {\n await fetch('/api/v1/install-tesseract-lang?lang=chi_sim')\n handleDetectOcrEngine('tesseract')\n } catch (e) {\n }\n }}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600\"\n >\n 安装语言包\n </button>\n )}\n </div>\n </div>\n )}\n\n <div className=\"grid grid-cols-4 gap-3 pt-2\">\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">语言</label>\n <select\n value={form.ocr_languages || 'chi_sim+eng'}\n onChange={(e) => updateForm({ ocr_languages: e.target.value })}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n >\n <option value=\"chi_sim+eng\">chi_sim+eng</option>\n <option value=\"chi_sim\">chi_sim</option>\n <option value=\"eng\">eng</option>\n <option value=\"chi_sim+eng+jpn\">chi_sim+eng+jpn</option>\n </select>\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">并行线程</label>\n <select\n value={form.ocr_jobs ?? 1}\n onChange={(e) => updateForm({ ocr_jobs: parseInt(e.target.value) || 1 })}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n >\n <option value={1}>1</option>\n <option value={2}>2</option>\n <option value={4}>4</option>\n </select>\n <p className=\"text-[10px] text-gray-400 mt-0.5 leading-tight\">Tesseract 支持多线程,PaddleOCR 仅单线程</p>\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">超时 (秒)</label>\n <input\n type=\"number\"\n value={form.ocr_timeout ?? 1800}\n onChange={(e) => updateForm({ ocr_timeout: parseInt(e.target.value) || 3600 })}\n min={60}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n />\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">OCR 采样 DPI</label>\n <input\n type=\"number\"\n min={150}\n max={400}\n step={50}\n value={(form as any).ocr_oversample || 200}\n onChange={(e) => updateForm({ ocr_oversample: parseInt(e.target.value) } as any)}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n />\n <span className=\"text-xs text-gray-400\">越低越快,150-400,推荐 200</span>\n </div>\n </div>\n\n {/* OCR 安装指引 */}\n {form.ocr_engine !== 'llm_ocr' && (\n <details className=\"group\">\n <summary className=\"text-xs font-medium text-gray-600 cursor-pointer list-none flex items-center gap-1 select-none hover:text-gray-800\">\n <svg className=\"w-3 h-3 text-gray-400 transition-transform group-open:rotate-90\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n </svg>\n OCR 命令行安装指引\n </summary>\n <div className=\"mt-2 bg-blue-50 border border-blue-200 rounded p-3\">\n <p className=\"text-xs text-blue-800 font-medium mb-2\">📋 将以下提示词复制并发送给 OpenCode:</p>\n <pre className=\"text-xs text-blue-700 bg-blue-100 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono\">{OCR_INSTALL_GUIDE}</pre>\n <p className=\"text-xs text-blue-600 mt-2\">安装后返回设置页点击\"检测\"按钮确认状态。</p>\n </div>\n </details>\n )}\n\n {/* LLM OCR */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'llm_ocr' ? (\n <span className=\"text-blue-600\">LLM OCR 已启用</span>\n ) : (\n <span className=\"text-gray-500\">LLM OCR</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'llm_ocr' ? 'tesseract' : 'llm_ocr' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'llm_ocr'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'llm_ocr' ? '停用并回到 OCRmyPDF' : '启用 LLM OCR'}\n </button>\n </div>\n </div>\n\n {form.ocr_engine === 'llm_ocr' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用视觉大模型逐框识别文字层。需要运行 lmstudio / ollama 加载对应模型。\n 推荐模型已验证中文 PDF 可用。\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">接口地址</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).llm_ocr_endpoint || 'http://127.0.0.1:1234/v1'}\n onChange={(e) => updateForm({ llm_ocr_endpoint: e.target.value } as any)}\n placeholder=\"http://127.0.0.1:1234/v1\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button onClick={async () => {\n setLlmTesting(true); setLlmTestMsg('');\n try {\n const r = await fetch('/api/v1/check-llm-ocr', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ endpoint: (form as any).llm_ocr_endpoint, model: (form as any).llm_ocr_model })\n });\n const d = await r.json();\n setLlmTestMsg(d.message || (d.ok ? 'OK' : 'Failed'));\n } catch (e) { setLlmTestMsg(String(e)); }\n setLlmTesting(false);\n }} disabled={llmTesting}\n className=\"px-3 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap\">\n {llmTesting ? '测试中...' : '检测'}\n </button>\n </div>\n {llmTestMsg && <p className={`text-xs mt-1 ${llmTestMsg.includes('成功') || llmTestMsg.includes('OK') ? 'text-green-600' : 'text-red-500'}`}>{llmTestMsg}</p>}\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">模型名称</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).llm_ocr_model || ''}\n onChange={(e) => updateForm({ llm_ocr_model: e.target.value } as any)}\n placeholder=\"qwen3-vl-4b-instruct\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button onClick={async () => {\n setLlmFetching(true); setLlmFetchMsg('');\n try {\n const r = await fetch('/api/v1/fetch-llm-models', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ endpoint: (form as any).llm_ocr_endpoint })\n });\n const d = await r.json();\n if (d.ok && d.models.length > 0) {\n setLlmModels(d.models);\n setLlmFetchMsg(`${d.models.length} 个模型`);\n } else {\n setLlmFetchMsg(d.message || '无可用模型');\n }\n } catch (e) { setLlmFetchMsg(String(e)); }\n setLlmFetching(false);\n }} disabled={llmFetching || !(form as any).llm_ocr_endpoint}\n className=\"px-2 py-1.5 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap\">\n {llmFetching ? '...' : '拉取模型'}\n </button>\n </div>\n {llmModels.length > 0 && (\n <select value={(form as any).llm_ocr_model || ''}\n onChange={(e) => updateForm({ llm_ocr_model: e.target.value } as any)}\n className=\"w-full mt-1 rounded border border-blue-300 px-2 py-1 text-xs font-mono\"\n size={Math.min(llmModels.length + 1, 8)}>\n <option value=\"\" disabled>-- 选择模型 --</option>\n {llmModels.map((m: any) => (\n <option key={m.id} value={m.id}>{m.name}</option>\n ))}\n </select>\n )}\n {llmFetchMsg && <p className={`text-xs mt-1 ${llmFetchMsg.includes('个模型') ? 'text-green-600' : 'text-red-500'}`}>{llmFetchMsg}</p>}\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">\n 并发数: {(form as any).llm_ocr_concurrency || 1}\n </label>\n <input type=\"range\" min=\"1\" max=\"5\" step=\"1\"\n value={(form as any).llm_ocr_concurrency || 1}\n onChange={(e) => updateForm({ llm_ocr_concurrency: parseInt(e.target.value) } as any)}\n className=\"w-full\" />\n <div className=\"flex justify-between text-xs text-gray-400\">\n <span>1 (默认)</span><span>5 (最快)</span>\n </div>\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">\n 版面检测批次: {(form as any).llm_ocr_detect_batch || 20} 页/批\n </label>\n <input type=\"range\" min=\"5\" max=\"50\" step=\"5\"\n value={(form as any).llm_ocr_detect_batch || 20}\n onChange={(e) => updateForm({ llm_ocr_detect_batch: parseInt(e.target.value) } as any)}\n className=\"w-full\" />\n <div className=\"flex justify-between text-xs text-gray-400\">\n <span>5 页 (低内存)</span><span>50 页 (更快)</span>\n </div>\n <p className=\"text-xs text-gray-400 mt-0.5\">Surya 版面检测每批处理页数。内存不足时降低。</p>\n </div>\n <details className=\"text-xs\">\n <summary className=\"cursor-pointer text-blue-600 hover:text-blue-800\">\n 推荐模型 (已验证中文 PDF)\n </summary>\n <div className=\"mt-2 space-y-1\">\n {LLM_OCR_RECOMMENDED.map(m => (\n <button key={m.model}\n onClick={() => updateForm({ llm_ocr_model: m.model } as any)}\n title={m.note}\n className=\"block w-full text-left px-2 py-1 rounded hover:bg-blue-100 text-gray-700 text-xs\">\n <span className=\"font-mono\">{m.model}</span>\n <span className=\"text-gray-400 ml-2\">{m.note}</span>\n </button>\n ))}\n </div>\n </details>\n </div>\n )}\n\n {/* MinerU */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'mineru' ? (\n <span className=\"text-blue-600\">MinerU 线上 API 已启用</span>\n ) : (\n <span className=\"text-gray-500\">MinerU 线上 API</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'mineru' ? 'tesseract' : 'mineru' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'mineru'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'mineru' ? '停用 MinerU' : '启用 MinerU'}\n </button>\n </div>\n </div>\n {form.ocr_engine === 'mineru' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用 MinerU v4 精准解析 API 进行文档 OCR。PDF 将上传至 MinerU 服务器处理。\n 仅限中国大陆网络访问,单文件 ≤200MB / ≤200页。\n <span className=\"text-orange-500 ml-1\">实验功能,识别可能会有重复</span>\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API Token</label>\n <input value={(form as any).mineru_token || ''}\n onChange={(e) => updateForm({ mineru_token: e.target.value } as any)}\n placeholder=\"输入 MinerU API Token(Bearer 认证)\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"flex items-center gap-2\">\n <button\n type=\"button\"\n onClick={handleTestMineru}\n disabled={onlineTesting.mineru || !(form as any).mineru_token}\n className=\"px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed\">\n {onlineTesting.mineru ? '测试中...' : '检测'}\n </button>\n {onlineEngineStatus.mineru === 'ok' && (\n <span className=\"text-xs text-green-600\">连接正常</span>\n )}\n {onlineEngineStatus.mineru === 'fail' && (\n <span className=\"text-xs text-red-500\">连接失败</span>\n )}\n </div>\n <p className=\"text-xs text-gray-400\">\n 需要先申请 Token:<a href=\"https://mineru.net/apiManage/docs\" target=\"_blank\" className=\"text-blue-500 hover:underline\">MinerU API 文档</a>\n </p>\n </div>\n )}\n\n {/* PaddleOCR-VL-1.5 */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'paddleocr_online' ? (\n <span className=\"text-blue-600\">PaddleOCR-VL-1.5 线上 API 已启用</span>\n ) : (\n <span className=\"text-gray-500\">PaddleOCR-VL-1.5 线上 API</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'paddleocr_online' ? 'tesseract' : 'paddleocr_online' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'paddleocr_online'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'paddleocr_online' ? '停用 PaddleOCR-VL-1.5' : '启用 PaddleOCR-VL-1.5'}\n </button>\n </div>\n </div>\n {form.ocr_engine === 'paddleocr_online' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用百度 PaddleOCR-VL-1.5 视觉大模型进行文档版面解析。\n 支持中英文文档,无需本地 GPU。\n <span className=\"text-orange-500 ml-1\">实验功能,识别可能会有重复</span>\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1.5\">识别模式 <span className=\"text-[11px] text-blue-500 ml-1\">推荐逐框识别,次选混合识别</span></label>\n <div className=\"flex rounded border border-gray-300 overflow-hidden text-xs\">\n {[\n { key: 'spatial', label: '空间分配(快)' },\n { key: 'perbox', label: '逐框识别(慢)' },\n { key: 'hybrid', label: '混合识别' },\n ].map(({ key, label }) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => updateForm({ paddleocr_online_mode: key } as any)}\n className={`flex-1 py-1.5 text-center transition-colors ${\n ((form as any).paddleocr_online_mode || 'spatial') === key\n ? 'bg-blue-500 text-white'\n : 'bg-white text-gray-600 hover:bg-gray-50'\n }`}\n >\n {label}\n </button>\n ))}\n </div>\n <p className=\"text-[11px] text-gray-400 mt-1\">\n {((form as any).paddleocr_online_mode || 'spatial') === 'spatial' && '段落文字精准,行识别不精确'}\n {((form as any).paddleocr_online_mode || 'spatial') === 'perbox' && '行识别精准,有乱码风险'}\n {((form as any).paddleocr_online_mode || 'spatial') === 'hybrid' && '逐框为主、空间填补,可能产生识别重复'}\n </p>\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">Access Token</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).paddleocr_online_token || ''}\n onChange={(e) => updateForm({ paddleocr_online_token: e.target.value } as any)}\n placeholder=\"输入 PaddleOCR access token\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button\n type=\"button\"\n onClick={handleTestPaddleocrOnline}\n disabled={onlineTesting.paddleocr || !(form as any).paddleocr_online_token}\n className=\"px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap\">\n {onlineTesting.paddleocr ? '测试中...' : '检测'}\n </button>\n </div>\n {onlineEngineStatus.paddleocr === 'ok' && (\n <p className=\"text-xs text-green-600 mt-1\">连接正常</p>\n )}\n {onlineEngineStatus.paddleocr === 'fail' && (\n <p className=\"text-xs text-red-500 mt-1\">连接失败</p>\n )}\n </div>\n <p className=\"text-xs text-gray-400\">\n 从 <a href=\"https://aistudio.baidu.com/paddleocr/task\" target=\"_blank\" className=\"text-blue-500 hover:underline\">PaddleOCR 控制台</a> 获取 Access Token。\n <a href=\"https://ai.baidu.com/ai-doc/AISTUDIO/Cmkz2m0ma\" target=\"_blank\" className=\"text-blue-500 hover:underline ml-2\">API 文档</a>\n </p>\n </div>\n )}\n\n <div className=\"flex items-center gap-2 pt-3\">\n <input type=\"checkbox\" id=\"ocr_confirm_enabled\"\n checked={(form as any).ocr_confirm_enabled ?? false}\n onChange={(e) => updateForm({ ocr_confirm_enabled: e.target.checked } as any)}\n className=\"rounded\" />\n <label htmlFor=\"ocr_confirm_enabled\" className=\"text-xs font-medium text-gray-600\">\n 管道执行到 OCR 步骤时弹出确认对话框\n </label>\n </div>\n\n {/* ============ AI Vision / 书签 ============ */}\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">AI Vision 目录提取</span>\n <div className=\"flex items-center gap-2\">\n <input type=\"checkbox\" id=\"ai_vision_enabled\"\n checked={(form as any).ai_vision_enabled ?? true}\n onChange={(e) => updateForm({ ai_vision_enabled: e.target.checked } as any)} className=\"rounded\" />\n <label htmlFor=\"ai_vision_enabled\" className=\"text-xs\">启用 AI Vision 目录提取</label>\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API 端点</label>\n <input type=\"text\" value={(form as any).ai_vision_endpoint || ''}\n onChange={(e) => updateForm({ ai_vision_endpoint: e.target.value } as any)}\n placeholder={\n (form as any).ai_vision_provider === 'gemini' ? 'https://generativelanguage.googleapis.com/v1beta' :\n (form as any).ai_vision_provider === 'azure' ? 'https://{resource}.openai.azure.com' :\n (form as any).ai_vision_provider === 'anthropic' || (form as any).ai_vision_provider === 'minimax_anthropic' ? 'https://api.anthropic.com' :\n (form as any).ai_vision_provider === 'minimax_openai' ? 'https://api.minimaxi.com/v1' :\n 'http://127.0.0.1:12345/v1'\n }\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">模型名称</label>\n <div className=\"flex gap-1\">\n <input type=\"text\" value={(form as any).ai_vision_model || ''}\n onChange={(e) => updateForm({ ai_vision_model: e.target.value } as any)}\n placeholder=\"sabafallah/deepseek-ocr\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button\n type=\"button\"\n onClick={async () => {\n setFetchingModels(true)\n setFetchModelsMsg('')\n try {\n const res = await fetch('/api/v1/fetch-models', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n endpoint: (form as any).ai_vision_endpoint,\n api_key: (form as any).ai_vision_api_key,\n provider: (form as any).ai_vision_provider,\n }),\n })\n const data = await res.json()\n if (data.ok && data.models.length > 0) {\n setAiModels(data.models)\n setFetchModelsMsg(`${data.models.length} 个模型`)\n } else {\n setFetchModelsMsg(data.message || '无可用模型')\n }\n } catch (e) {\n setFetchModelsMsg(String(e))\n }\n setFetchingModels(false)\n }}\n disabled={fetchingModels || !(form as any).ai_vision_endpoint}\n className=\"px-2 py-1.5 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap\"\n title=\"从提供商拉取可用模型列表\"\n >\n {fetchingModels ? '...' : '获取模型'}\n </button>\n </div>\n {aiModels.length > 0 && (\n <select\n value={(form as any).ai_vision_model || ''}\n onChange={(e) => updateForm({ ai_vision_model: e.target.value } as any)}\n className=\"w-full mt-1 rounded border border-blue-300 px-2 py-1 text-xs font-mono\"\n size={Math.min(aiModels.length + 1, 8)}\n >\n <option value=\"\" disabled>-- 选择模型 --</option>\n {aiModels.map(m => (\n <option key={m.id} value={m.id}>{m.name}</option>\n ))}\n </select>\n )}\n {fetchModelsMsg && (\n <p className={`text-xs mt-1 ${fetchModelsMsg.includes('个模型') ? 'text-green-600' : 'text-red-500'}`}>\n {fetchModelsMsg}\n </p>\n )}\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API Key</label>\n <input type=\"password\" value={(form as any).ai_vision_api_key || ''}\n onChange={(e) => updateForm({ ai_vision_api_key: e.target.value } as any)}\n placeholder=\"sk-... (支持 {env:VAR_NAME})\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API 格式</label>\n <select value={(form as any).ai_vision_provider || 'openai_compatible'}\n onChange={(e) => {\n const newProvider = e.target.value\n const defaultEndpoint = AI_VISION_ENDPOINTS[newProvider] || ''\n updateForm({\n ai_vision_provider: newProvider,\n ai_vision_endpoint: defaultEndpoint,\n } as any)\n }}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs\">\n <option value=\"openai_compatible\">OpenAI / DeepSeek / 智谱 / Qwen / Ollama / lmstudio</option>\n <option value=\"openai_responses\">OpenAI Responses (新版 /v1/responses)</option>\n <option value=\"azure\">Azure OpenAI</option>\n <option value=\"anthropic\">Anthropic Claude</option>\n <option value=\"gemini\">Google Gemini</option>\n <option value=\"minimax_openai\">MiniMax (OpenAI 兼容 · 国内/国际通用)</option>\n <option value=\"minimax_anthropic\">MiniMax (Anthropic 兼容 · Token Plan)</option>\n <option value=\"custom\">自定义 (Custom)</option>\n </select>\n </div>\n {(form as any).ai_vision_provider === 'custom' && (\n <div className=\"flex items-center gap-2 mt-2\">\n <input type=\"checkbox\" id=\"ai_vision_messages_api\"\n checked={(form as any).ai_vision_messages_api ?? false}\n onChange={(e) => updateForm({ ai_vision_messages_api: e.target.checked } as any)} className=\"rounded\" />\n <label htmlFor=\"ai_vision_messages_api\" className=\"text-xs font-medium text-gray-600\">\n 使用 Anthropic Messages API 格式 (默认 OpenAI Chat Completions)\n </label>\n </div>\n )}\n <div className=\"grid grid-cols-2 gap-3 mt-2\">\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">DPI</label>\n <input type=\"number\" value={(form as any).ai_vision_dpi ?? 150}\n onChange={(e) => updateForm({ ai_vision_dpi: parseInt(e.target.value) || 150 } as any)}\n min={72} max={300} className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs\" />\n </div>\n </div>\n <button\n type=\"button\"\n onClick={async () => {\n setAiVisionTest('testing');\n try {\n const res = await fetch('/api/v1/check-ai-vision', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n endpoint: (form as any).ai_vision_endpoint,\n model: (form as any).ai_vision_model,\n api_key: (form as any).ai_vision_api_key,\n provider: (form as any).ai_vision_provider,\n messages_api: (form as any).ai_vision_messages_api,\n }),\n });\n const data = await res.json();\n setAiVisionTest(data.ok ? 'ok' : 'fail');\n setAiVisionMsg(data.message || data.error || '');\n } catch (e) {\n setAiVisionTest('fail');\n setAiVisionMsg(String(e));\n }\n }}\n disabled={aiVisionTest === 'testing'}\n className=\"px-3 py-1.5 mt-2 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50\"\n >\n {aiVisionTest === 'testing' ? '测试中...' : '检测'}\n </button>\n {aiVisionMsg && (\n <p className={`text-xs mt-1 ${aiVisionTest === 'ok' ? 'text-green-600' : 'text-red-600'}`}>\n {aiVisionMsg}\n </p>\n )}\n <p className=\"text-xs text-gray-400 mt-1\">\n 阶段1 从 OCR 文字层提取目录(免费),阶段2 用 AI Vision 从目录页图片提取。\n 支持 OpenAI 兼容 / Gemini / MiniMax 格式。\n </p>\n <div className=\"flex items-center gap-2 mt-2\">\n <input type=\"checkbox\" id=\"bookmark_confirm_enabled\"\n checked={(form as any).bookmark_confirm_enabled ?? false}\n onChange={(e) => updateForm({ bookmark_confirm_enabled: e.target.checked } as any)}\n className=\"rounded\" />\n <label htmlFor=\"bookmark_confirm_enabled\" className=\"text-xs font-medium text-gray-600\">\n 管道执行到书签步骤时弹出确认对话框\n </label>\n </div>\n </div>\n\n {/* ============ 主题 ============ */}\n <div className=\"border-t border-gray-200 pt-3\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1.5\">主题</label>\n <div className=\"flex rounded border border-gray-300 overflow-hidden text-xs\">\n {[\n { key: 'auto', label: '自动' },\n { key: 'light', label: '白昼' },\n { key: 'dark', label: '黑夜' },\n ].map(({ key, label }) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => updateForm({ theme: key })}\n className={`flex-1 py-1.5 text-center transition-colors ${\n ((form as any).theme || 'auto') === key\n ? 'bg-blue-500 text-white'\n : 'bg-white text-gray-600 hover:bg-gray-50'\n }`}\n >\n {label}\n </button>\n ))}\n </div>\n </div>\n\n {/* ============ 保存按钮 ============ */}\n <div className=\"flex items-center gap-3 pt-2\">\n <button\n type=\"button\"\n onClick={handleSave}\n disabled={saving}\n className=\"px-5 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 font-medium\"\n >\n {saving ? '保存中...' : '保存设置'}\n </button>\n <button\n type=\"button\"\n onClick={handleCheckUpdate}\n disabled={updateChecking}\n className=\"px-5 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 font-medium\"\n >\n {updateChecking ? '检测中...' : '检查更新'}\n </button>\n {saveMsg && (\n <span className={`text-xs ${saveMsg.includes('成功') ? 'text-green-600' : 'text-red-500'}`}>\n {saveMsg}\n </span>\n )}\n {updateResult && (\n <span className={`text-xs px-2 py-1 rounded ${updateResult.includes('失败') ? 'text-red-600 bg-red-50' : updateResult.includes('新版本') ? 'text-blue-600 bg-blue-50 font-semibold' : 'text-green-600 bg-green-50'}`}>\n {updateResult}\n </span>\n )}\n </div>\n </div>\n )\n}\n\nexport default React.memo(OCRNetworkSection)\n"}, "f52fa61576070eef83250d867ac8ce14ab5406c2": {"t": 1785772783.7645154, "n": 48543, "h": "3e5e5070f2570bc2", "c": "import React, { useState, useEffect, useRef, useCallback } from 'react'\nimport { SectionProps } from './types'\nimport { LLM_OCR_RECOMMENDED } from '../../constants'\n\nfunction StatusDot({ status }: { status: 'green' | 'red' | 'yellow' | null }) {\n const colors: Record<string, string> = {\n green: 'bg-green-500',\n red: 'bg-red-500',\n yellow: 'bg-yellow-500',\n }\n return (\n <span className={`inline-block w-2 h-2 rounded-full ${status ? colors[status] : 'bg-gray-300'}`} />\n )\n}\n\nconst OCR_ENGINES = [\n { key: 'tesseract', name: 'Tesseract OCR', desc: '内置引擎,需 chi_sim 语言包' },\n { key: 'paddleocr', name: 'PaddleOCR', desc: '百度引擎,需 Python 3.11 虚拟环境' },\n { key: 'llm_ocr', name: 'LLM OCR', desc: '大模型视觉识别,无需本地引擎' },\n { key: 'mineru', name: 'MinerU', desc: '线上 API 精准解析,需 Token' },\n { key: 'paddleocr_online', name: 'PaddleOCR-VL-1.5', desc: '线上 API,需 Token' },\n]\n\nconst OCR_INSTALL_GUIDE = `## 安装 OCR 引擎\n\n**注意**:Python 3.14 与 \\`paddlepaddle\\` 不兼容。建议使用 Python 3.11 创建虚拟环境。\n\n---\n\n### 1. 创建/重建虚拟环境(Python 3.11)\n\n\\`\\`\\`powershell\n# 如果原 venv 不支持 Python 3.11,先删除\nRemove-Item -Recurse -Force backend\\\\venv\n\n# 使用 Python 3.11 创建新 venv\nC:\\\\Python311\\\\python.exe -m venv backend\\\\venv\n\\`\\`\\`\n\n### 2. 激活虚拟环境(PowerShell)\n\n\\`\\`\\`powershell\n# 方法一:PowerShell 执行策略问题\npowershell -ExecutionPolicy Bypass -Command \"backend\\\\venv\\\\Scripts\\\\Activate.ps1\"\n\n# 方法二:直接用 python 运行(无需激活)\nC:\\\\Python311\\\\python.exe -m pip install ...\n\\`\\`\\`\n\n### 3. 安装 OCRmyPDF 核心\n\n\\`\\`\\`powershell\npython -m pip install ocrmypdf\n\\`\\`\\`\n\n### 4. 安装引擎插件\n\n\\`\\`\\`powershell\n# 安装所有 OCR 引擎(推荐,一步到位)\npython -m pip install ocrmypdf-paddleocr paddleocr\n\n# 或分开安装\npython -m pip install paddleocr\npython -m pip install ocrmypdf-paddleocr # 需要先装好 paddleocr\n\\`\\`\\`\n\n### 5. 安装 Tesseract OCR 系统依赖\n\n\\`\\`\\`powershell\nwinget install --id UB-Mannheim.TesseractOCR --accept-package-agreements --accept-source-agreements\n\\`\\`\\`\n\n### 6. 将 Tesseract 添加到 PATH\n\n\\`\\`\\`powershell\n[Environment]::SetEnvironmentVariable(\"Path\", [Environment]::GetEnvironmentVariable(\"Path\", \"User\") + \";C:\\\\Program Files\\\\Tesseract-OCR\", \"User\")\n\\`\\`\\`\n\n> ⚠️ 添加后需**重启终端或IDE**使 PATH 生效,或使用完整路径验证:\n> \\`\\`\\`powershell\n> & \"C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\" --version\n> \\`\\`\\`\n\n### 7. 验证安装\n\n\\`\\`\\`powershell\npython -c \"import ocrmypdf; print('ocrmypdf:', ocrmypdf.__version__)\"\npython -c \"from paddle import paddle; print('paddle:', paddle.__version__)\"\n\"C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\" --version\n\\`\\`\\`\n\n### 常见问题\n\n| 问题 | 解决 |\n|------|------|\n| \\`ModuleNotFoundError: No module named 'paddlepaddle'\\` | \\`import paddle\\` 而非 \\`import paddlepaddle\\` |\n| \\`ocrmypdf-paddleocr\\` 依赖冲突 | 先装 \\`paddleocr\\`,再装 \\`ocrmypdf-paddleocr\\` |\n| Tesseract 找不到 | 重启终端,或使用完整路径 \\`C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe\\` |`\n\nconst AI_VISION_ENDPOINTS: Record<string, string> = {\n openai_compatible: 'https://api.openai.com/v1',\n openai_responses: 'https://api.openai.com/v1',\n azure: 'https://RESOURCE_NAME.openai.azure.com',\n anthropic: 'https://api.anthropic.com',\n gemini: 'https://generativelanguage.googleapis.com/v1beta',\n minimax_openai: 'https://api.minimaxi.com/v1',\n minimax_anthropic: 'https://api.minimaxi.com',\n custom: '',\n}\n\nfunction OCRNetworkSection({ form, updateForm, mountedRef }: SectionProps) {\n const [ocrChecking, setOcrChecking] = useState(false)\n const [ocrStatus, setOcrStatus] = useState<'green' | 'red' | 'yellow' | null>(null)\n const [ocrMsg, setOcrMsg] = useState('')\n const [ocrEngines, setOcrEngines] = useState<Record<string, { installed: boolean; installing: boolean; msg: string }>>({})\n const [llmModels, setLlmModels] = useState<any[]>([])\n const [llmFetchMsg, setLlmFetchMsg] = useState('')\n const [onlineEngineStatus, setOnlineEngineStatus] = useState<Record<string, 'ok' | 'fail' | ''>>({ mineru: '', paddleocr: '' })\n const [onlineTesting, setOnlineTesting] = useState<Record<string, boolean>>({ mineru: false, paddleocr: false })\n const [llmFetching, setLlmFetching] = useState(false)\n const [llmTestMsg, setLlmTestMsg] = useState('')\n const [llmTesting, setLlmTesting] = useState(false)\n const [aiModels, setAiModels] = useState<{ id: string; name: string }[]>([])\n const [fetchingModels, setFetchingModels] = useState(false)\n const [fetchModelsMsg, setFetchModelsMsg] = useState('')\n const [aiVisionTest, setAiVisionTest] = useState<'testing' | 'ok' | 'fail' | null>(null)\n const [aiVisionMsg, setAiVisionMsg] = useState('')\n const [saving, setSaving] = useState(false)\n const [saveMsg, setSaveMsg] = useState('')\n const [updateChecking, setUpdateChecking] = useState(false)\n const [updateResult, setUpdateResult] = useState('')\n\n // --- OCR logic ---\n const checkOcr = useCallback(async (engine?: string) => {\n setOcrChecking(true)\n try {\n const eng = engine || form.ocr_engine || 'tesseract'\n const res = await fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(eng)}`)\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrStatus(data.ok ? 'green' : 'red')\n setOcrMsg(data.message || (data.ok ? data.version || '已安装' : '未检测到'))\n } catch (e) {\n if (mountedRef.current) {\n setOcrStatus('red')\n setOcrMsg('检测失败')\n }\n } finally {\n if (mountedRef.current) setOcrChecking(false)\n }\n }, [form.ocr_engine, mountedRef])\n\n const handleDetectOcrEngine = useCallback(async (engine: string) => {\n setOcrEngines(prev => ({ ...prev, [engine]: { ...prev[engine], installing: false, msg: '检测中...' } }))\n try {\n const res = await fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(engine)}`)\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrEngines(prev => ({\n ...prev,\n [engine]: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n } catch (e) {\n if (mountedRef.current) {\n setOcrEngines(prev => ({\n ...prev,\n [engine]: { installed: false, installing: false, msg: '检测失败' },\n }))\n }\n }\n }, [mountedRef])\n\n const handleInstallOcrEngine = useCallback(async (engine: string) => {\n setOcrEngines(prev => ({ ...prev, [engine]: { ...prev[engine], installing: true, msg: '安装中...' } }))\n try {\n const res = await fetch('/api/v1/install-ocr', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ engine }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOcrEngines(prev => ({\n ...prev,\n [engine]: {\n installed: data.ok || false,\n installing: false,\n msg: data.message || (data.ok ? '安装成功' : '安装失败'),\n },\n }))\n } catch (e) {\n if (mountedRef.current) {\n setOcrEngines(prev => ({\n ...prev,\n [engine]: { installed: false, installing: false, msg: '安装请求失败' },\n }))\n }\n }\n }, [mountedRef])\n\n const handleTestMineru = useCallback(async () => {\n setOnlineTesting(prev => ({ ...prev, mineru: true }))\n setOnlineEngineStatus(prev => ({ ...prev, mineru: '' }))\n try {\n const res = await fetch('/api/v1/check-mineru', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: (form as any).mineru_token || '' }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOnlineEngineStatus(prev => ({ ...prev, mineru: data.ok ? 'ok' : 'fail' }))\n } catch {\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: 'fail' }))\n }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, mineru: false }))\n }, [form, mountedRef])\n\n const handleTestPaddleocrOnline = useCallback(async () => {\n setOnlineTesting(prev => ({ ...prev, paddleocr: true }))\n setOnlineEngineStatus(prev => ({ ...prev, paddleocr: '' }))\n try {\n const res = await fetch('/api/v1/check-paddleocr-online', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: (form as any).paddleocr_online_token || '' }),\n })\n const data = await res.json()\n if (!mountedRef.current) return\n setOnlineEngineStatus(prev => ({ ...prev, paddleocr: data.ok ? 'ok' : 'fail' }))\n } catch {\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: 'fail' }))\n }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, paddleocr: false }))\n }, [form, mountedRef])\n\n // Auto-detect OCR engine statuses\n const autoOcrRef = useRef(false)\n useEffect(() => {\n if (autoOcrRef.current) return\n autoOcrRef.current = true\n const engines = ['tesseract', 'paddleocr']\n engines.forEach((eng) => {\n fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(eng)}`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrEngines((prev) => ({\n ...prev,\n [eng]: {\n installed: data.ok || false,\n installing: false,\n msg: data.version || data.message || (data.ok ? '已安装' : '未检测到'),\n has_chi_sim: data.has_chi_sim,\n languages: data.languages,\n venv: data.venv,\n } as any,\n }))\n })\n .catch(() => {})\n })\n const defaultEngine = form.ocr_engine || 'tesseract'\n fetch(`/api/v1/check-ocr?engine=${encodeURIComponent(defaultEngine)}`)\n .then((r) => r.json())\n .then((data) => {\n if (!mountedRef.current) return\n setOcrStatus(data.ok ? 'green' : 'red')\n setOcrMsg(data.version || data.message || (data.ok ? '已安装' : '未检测到'))\n })\n .catch(() => {})\n }, [form.ocr_engine, mountedRef])\n\n // Update OCR header status when engine selection changes\n useEffect(() => {\n const eng = form.ocr_engine || 'tesseract'\n const info = ocrEngines[eng]\n if (info) {\n setOcrStatus(info.installed ? 'green' : 'red')\n setOcrMsg(info.msg || (info.installed ? '已安装' : '未检测到'))\n }\n }, [form.ocr_engine, ocrEngines])\n\n // Auto-detect online API connectivity\n const autoOnlineRef = useRef(false)\n useEffect(() => {\n if (autoOnlineRef.current) return\n autoOnlineRef.current = true\n const check = async () => {\n if ((form as any).mineru_token) {\n setOnlineTesting(prev => ({ ...prev, mineru: true }))\n try {\n const r = await fetch('/api/v1/check-mineru', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: (form as any).mineru_token }) })\n const d = await r.json()\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: d.ok ? 'ok' : 'fail' }))\n } catch { if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, mineru: 'fail' })) }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, mineru: false }))\n }\n if ((form as any).paddleocr_online_token) {\n setOnlineTesting(prev => ({ ...prev, paddleocr: true }))\n try {\n const r = await fetch('/api/v1/check-paddleocr-online', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: (form as any).paddleocr_online_token }) })\n const d = await r.json()\n if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: d.ok ? 'ok' : 'fail' }))\n } catch { if (mountedRef.current) setOnlineEngineStatus(prev => ({ ...prev, paddleocr: 'fail' })) }\n if (mountedRef.current) setOnlineTesting(prev => ({ ...prev, paddleocr: false }))\n }\n }\n check()\n }, [mountedRef])\n\n // --- Save / Update ---\n const handleSave = useCallback(async () => {\n setSaving(true)\n setSaveMsg('')\n try {\n const body = JSON.stringify(form)\n const res = await fetch('/api/v1/config', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body,\n })\n if (!res.ok) {\n setSaveMsg('保存失败: HTTP ' + res.status)\n setSaving(false)\n return\n }\n setSaveMsg('保存成功')\n setTimeout(() => setSaveMsg(''), 2000)\n } catch (e: any) {\n setSaveMsg('保存失败: ' + (e.message || '未知错误'))\n }\n setSaving(false)\n }, [form, mountedRef])\n\n const handleCheckUpdate = useCallback(async () => {\n setUpdateChecking(true)\n setUpdateResult('')\n try {\n const res = await fetch('/api/v1/check-update')\n const data = await res.json()\n if (!mountedRef.current) return\n if (data.has_update) {\n setUpdateResult(`新版本 v${data.latest} 可用 (当前 v${data.current})`)\n } else {\n setUpdateResult(`已是最新版本 v${data.current}`)\n }\n } catch (e) {\n if (mountedRef.current) setUpdateResult('检查失败')\n } finally {\n if (mountedRef.current) setUpdateChecking(false)\n }\n }, [mountedRef])\n\n return (\n <div className=\"space-y-3\">\n {/* OCRmyPDF 独立状态区 */}\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">OCRmyPDF 状态</label>\n <div className=\"flex items-center gap-2\">\n <StatusDot status={(ocrEngines['ocrmypdf'] as any)?.installed ? 'green' : (ocrEngines['ocrmypdf'] as any)?.msg ? 'red' : null} />\n <span className=\"text-xs text-gray-500\">\n {(ocrEngines['ocrmypdf'] as any)?.msg || '点击检测'}\n </span>\n <button\n type=\"button\"\n onClick={() => handleDetectOcrEngine('ocrmypdf')}\n className=\"px-2 py-1 text-xs rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500\"\n >\n 检测\n </button>\n <button\n type=\"button\"\n onClick={() => handleInstallOcrEngine('ocrmypdf')}\n disabled={(ocrEngines['ocrmypdf'] as any)?.installing}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50\"\n >\n {(ocrEngines['ocrmypdf'] as any)?.installing ? '安装中...' : '一键安装'}\n </button>\n </div>\n </div>\n\n {/* 引擎切换区 */}\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">引擎切换</span>\n <div className=\"grid grid-cols-2 gap-2\">\n {OCR_ENGINES.filter(eng => eng.key !== 'llm_ocr' && eng.key !== 'mineru' && eng.key !== 'paddleocr_online').map((eng) => {\n const info = (ocrEngines[eng.key] as any) || { installed: eng.key === 'llm_ocr', msg: '' }\n const isSelected = form.ocr_engine === eng.key\n return (\n <div\n key={eng.key}\n className={`rounded border p-2.5 ${isSelected ? 'border-blue-400 bg-blue-50' : 'border-gray-200 bg-white'}`}\n >\n <div className=\"flex items-center justify-between mb-1\">\n <span className=\"text-xs font-medium text-gray-700\">{eng.name}</span>\n <div className=\"flex items-center gap-1\">\n <StatusDot status={info?.installed ? 'green' : info?.msg ? 'red' : null} />\n </div>\n </div>\n <p className=\"text-xs text-gray-400 mb-2\">{eng.desc}</p>\n {eng.key !== 'llm_ocr' && info?.msg && (\n <p className={`text-xs mb-1.5 ${info.installed ? 'text-green-600' : 'text-red-500'}`}>\n {info.msg}\n </p>\n )}\n {eng.key !== 'llm_ocr' && eng.key === 'paddleocr' && !info?.installed && (\n <p className=\"text-xs text-gray-400 mb-1.5\">需要 Python 3.11 虚拟环境,点击安装自动搭建</p>\n )}\n {eng.key !== 'llm_ocr' && eng.key === 'paddleocr' && info?.installed && (info as any)?.venv && (\n <p className=\"text-xs text-green-600 mb-1.5\">运行环境: {(info as any).venv}</p>\n )}\n <div className=\"flex items-center gap-1.5\">\n {eng.key !== 'llm_ocr' && (\n <>\n <button\n type=\"button\"\n onClick={() => handleDetectOcrEngine(eng.key)}\n className=\"px-2 py-1 text-xs rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500\"\n >\n 检测\n </button>\n <button\n type=\"button\"\n onClick={() => handleInstallOcrEngine(eng.key)}\n disabled={info?.installing}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50\"\n >\n {info?.installing ? '安装中...' : '安装'}\n </button>\n </>\n )}\n {eng.key === form.ocr_engine ? null : (\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: eng.key })}\n className=\"px-2 py-1 text-xs rounded text-blue-600 hover:bg-blue-50 ml-auto\"\n >\n 选用\n </button>\n )}\n </div>\n </div>\n )\n })}\n </div>\n </div>\n\n {/* Tesseract 语言包状态 */}\n {form.ocr_engine !== 'llm_ocr' && (\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">Tesseract 语言包</span>\n <div className=\"flex items-center gap-2\">\n <StatusDot status={\n form.ocr_engine === 'tesseract' && (ocrEngines['tesseract'] as any)?.has_chi_sim\n ? 'green'\n : form.ocr_engine === 'tesseract'\n ? 'red'\n : null\n } />\n <span className=\"text-xs text-gray-500\">\n {(ocrEngines['tesseract'] as any)?.has_chi_sim\n ? 'chi_sim 已安装'\n : (ocrEngines['tesseract'] as any)?.languages\n ? 'chi_sim 未安装'\n : '请先检测 Tesseract'}\n </span>\n {!((ocrEngines['tesseract'] as any)?.has_chi_sim) && (\n <button\n type=\"button\"\n onClick={async () => {\n try {\n await fetch('/api/v1/install-tesseract-lang?lang=chi_sim')\n handleDetectOcrEngine('tesseract')\n } catch (e) {\n }\n }}\n className=\"px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600\"\n >\n 安装语言包\n </button>\n )}\n </div>\n </div>\n )}\n\n <div className=\"grid grid-cols-4 gap-3 pt-2\">\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">语言</label>\n <select\n value={form.ocr_languages || 'chi_sim+eng'}\n onChange={(e) => updateForm({ ocr_languages: e.target.value })}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n >\n <option value=\"chi_sim+eng\">chi_sim+eng</option>\n <option value=\"chi_sim\">chi_sim</option>\n <option value=\"eng\">eng</option>\n <option value=\"chi_sim+eng+jpn\">chi_sim+eng+jpn</option>\n </select>\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">并行线程</label>\n <select\n value={form.ocr_jobs ?? 1}\n onChange={(e) => updateForm({ ocr_jobs: parseInt(e.target.value) || 1 })}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n >\n <option value={1}>1</option>\n <option value={2}>2</option>\n <option value={4}>4</option>\n </select>\n <p className=\"text-[10px] text-gray-400 mt-0.5 leading-tight\">Tesseract 支持多线程,PaddleOCR 仅单线程</p>\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">超时 (秒)</label>\n <input\n type=\"number\"\n value={form.ocr_timeout ?? 1800}\n onChange={(e) => updateForm({ ocr_timeout: parseInt(e.target.value) || 3600 })}\n min={60}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n />\n </div>\n <div>\n <label className=\"block text-xs font-medium text-gray-600 mb-1\">OCR 采样 DPI</label>\n <input\n type=\"number\"\n min={150}\n max={400}\n step={50}\n value={(form as any).ocr_oversample || 200}\n onChange={(e) => updateForm({ ocr_oversample: parseInt(e.target.value) } as any)}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"\n />\n <span className=\"text-xs text-gray-400\">越低越快,150-400,推荐 200</span>\n </div>\n </div>\n\n {/* OCR 安装指引 */}\n {form.ocr_engine !== 'llm_ocr' && (\n <details className=\"group\">\n <summary className=\"text-xs font-medium text-gray-600 cursor-pointer list-none flex items-center gap-1 select-none hover:text-gray-800\">\n <svg className=\"w-3 h-3 text-gray-400 transition-transform group-open:rotate-90\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n </svg>\n OCR 命令行安装指引\n </summary>\n <div className=\"mt-2 bg-blue-50 border border-blue-200 rounded p-3\">\n <p className=\"text-xs text-blue-800 font-medium mb-2\">📋 将以下提示词复制并发送给 OpenCode:</p>\n <pre className=\"text-xs text-blue-700 bg-blue-100 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono\">{OCR_INSTALL_GUIDE}</pre>\n <p className=\"text-xs text-blue-600 mt-2\">安装后返回设置页点击\"检测\"按钮确认状态。</p>\n </div>\n </details>\n )}\n\n {/* LLM OCR */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'llm_ocr' ? (\n <span className=\"text-blue-600\">LLM OCR 已启用</span>\n ) : (\n <span className=\"text-gray-500\">LLM OCR</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'llm_ocr' ? 'tesseract' : 'llm_ocr' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'llm_ocr'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'llm_ocr' ? '停用并回到 OCRmyPDF' : '启用 LLM OCR'}\n </button>\n </div>\n </div>\n\n {form.ocr_engine === 'llm_ocr' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用视觉大模型逐框识别文字层。需要运行 lmstudio / ollama 加载对应模型。\n 推荐模型已验证中文 PDF 可用。\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">接口地址</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).llm_ocr_endpoint || 'http://127.0.0.1:1234/v1'}\n onChange={(e) => updateForm({ llm_ocr_endpoint: e.target.value } as any)}\n placeholder=\"http://127.0.0.1:1234/v1\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button onClick={async () => {\n setLlmTesting(true); setLlmTestMsg('');\n try {\n const r = await fetch('/api/v1/check-llm-ocr', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ endpoint: (form as any).llm_ocr_endpoint, model: (form as any).llm_ocr_model })\n });\n const d = await r.json();\n setLlmTestMsg(d.message || (d.ok ? 'OK' : 'Failed'));\n } catch (e) { setLlmTestMsg(String(e)); }\n setLlmTesting(false);\n }} disabled={llmTesting}\n className=\"px-3 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap\">\n {llmTesting ? '测试中...' : '检测'}\n </button>\n </div>\n {llmTestMsg && <p className={`text-xs mt-1 ${llmTestMsg.includes('成功') || llmTestMsg.includes('OK') ? 'text-green-600' : 'text-red-500'}`}>{llmTestMsg}</p>}\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">模型名称</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).llm_ocr_model || ''}\n onChange={(e) => updateForm({ llm_ocr_model: e.target.value } as any)}\n placeholder=\"qwen3-vl-4b-instruct\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button onClick={async () => {\n setLlmFetching(true); setLlmFetchMsg('');\n try {\n const r = await fetch('/api/v1/fetch-llm-models', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ endpoint: (form as any).llm_ocr_endpoint })\n });\n const d = await r.json();\n if (d.ok && d.models.length > 0) {\n setLlmModels(d.models);\n setLlmFetchMsg(`${d.models.length} 个模型`);\n } else {\n setLlmFetchMsg(d.message || '无可用模型');\n }\n } catch (e) { setLlmFetchMsg(String(e)); }\n setLlmFetching(false);\n }} disabled={llmFetching || !(form as any).llm_ocr_endpoint}\n className=\"px-2 py-1.5 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap\">\n {llmFetching ? '...' : '拉取模型'}\n </button>\n </div>\n {llmModels.length > 0 && (\n <select value={(form as any).llm_ocr_model || ''}\n onChange={(e) => updateForm({ llm_ocr_model: e.target.value } as any)}\n className=\"w-full mt-1 rounded border border-blue-300 px-2 py-1 text-xs font-mono\"\n size={Math.min(llmModels.length + 1, 8)}>\n <option value=\"\" disabled>-- 选择模型 --</option>\n {llmModels.map((m: any) => (\n <option key={m.id} value={m.id}>{m.name}</option>\n ))}\n </select>\n )}\n {llmFetchMsg && <p className={`text-xs mt-1 ${llmFetchMsg.includes('个模型') ? 'text-green-600' : 'text-red-500'}`}>{llmFetchMsg}</p>}\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">\n 并发数: {(form as any).llm_ocr_concurrency || 1}\n </label>\n <input type=\"range\" min=\"1\" max=\"5\" step=\"1\"\n value={(form as any).llm_ocr_concurrency || 1}\n onChange={(e) => updateForm({ llm_ocr_concurrency: parseInt(e.target.value) } as any)}\n className=\"w-full\" />\n <div className=\"flex justify-between text-xs text-gray-400\">\n <span>1 (默认)</span><span>5 (最快)</span>\n </div>\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">\n 版面检测批次: {(form as any).llm_ocr_detect_batch || 20} 页/批\n </label>\n <input type=\"range\" min=\"5\" max=\"50\" step=\"5\"\n value={(form as any).llm_ocr_detect_batch || 20}\n onChange={(e) => updateForm({ llm_ocr_detect_batch: parseInt(e.target.value) } as any)}\n className=\"w-full\" />\n <div className=\"flex justify-between text-xs text-gray-400\">\n <span>5 页 (低内存)</span><span>50 页 (更快)</span>\n </div>\n <p className=\"text-xs text-gray-400 mt-0.5\">Surya 版面检测每批处理页数。内存不足时降低。</p>\n </div>\n <details className=\"text-xs\">\n <summary className=\"cursor-pointer text-blue-600 hover:text-blue-800\">\n 推荐模型 (已验证中文 PDF)\n </summary>\n <div className=\"mt-2 space-y-1\">\n {LLM_OCR_RECOMMENDED.map(m => (\n <button key={m.model}\n onClick={() => updateForm({ llm_ocr_model: m.model } as any)}\n title={m.note}\n className=\"block w-full text-left px-2 py-1 rounded hover:bg-blue-100 text-gray-700 text-xs\">\n <span className=\"font-mono\">{m.model}</span>\n <span className=\"text-gray-400 ml-2\">{m.note}</span>\n </button>\n ))}\n </div>\n </details>\n </div>\n )}\n\n {/* MinerU */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'mineru' ? (\n <span className=\"text-blue-600\">MinerU 线上 API 已启用</span>\n ) : (\n <span className=\"text-gray-500\">MinerU 线上 API</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'mineru' ? 'tesseract' : 'mineru' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'mineru'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'mineru' ? '停用 MinerU' : '启用 MinerU'}\n </button>\n </div>\n </div>\n {form.ocr_engine === 'mineru' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用 MinerU v4 精准解析 API 进行文档 OCR。PDF 将上传至 MinerU 服务器处理。\n 仅限中国大陆网络访问,单文件 ≤200MB / ≤200页。\n <span className=\"text-orange-500 ml-1\">实验功能,识别可能会有重复</span>\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API Token</label>\n <input value={(form as any).mineru_token || ''}\n onChange={(e) => updateForm({ mineru_token: e.target.value } as any)}\n placeholder=\"输入 MinerU API Token(Bearer 认证)\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"flex items-center gap-2\">\n <button\n type=\"button\"\n onClick={handleTestMineru}\n disabled={onlineTesting.mineru || !(form as any).mineru_token}\n className=\"px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed\">\n {onlineTesting.mineru ? '测试中...' : '检测'}\n </button>\n {onlineEngineStatus.mineru === 'ok' && (\n <span className=\"text-xs text-green-600\">连接正常</span>\n )}\n {onlineEngineStatus.mineru === 'fail' && (\n <span className=\"text-xs text-red-500\">连接失败</span>\n )}\n </div>\n <p className=\"text-xs text-gray-400\">\n 需要先申请 Token:<a href=\"https://mineru.net/apiManage/docs\" target=\"_blank\" className=\"text-blue-500 hover:underline\">MinerU API 文档</a>\n </p>\n </div>\n )}\n\n {/* PaddleOCR-VL-1.5 */}\n <div className=\"border-t border-gray-200 pt-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-gray-700\">\n {form.ocr_engine === 'paddleocr_online' ? (\n <span className=\"text-blue-600\">PaddleOCR-VL-1.5 线上 API 已启用</span>\n ) : (\n <span className=\"text-gray-500\">PaddleOCR-VL-1.5 线上 API</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => updateForm({ ocr_engine: form.ocr_engine === 'paddleocr_online' ? 'tesseract' : 'paddleocr_online' })}\n className={`px-3 py-1 text-xs rounded ${\n form.ocr_engine === 'paddleocr_online'\n ? 'bg-gray-200 text-gray-600 hover:bg-gray-300'\n : 'bg-blue-500 text-white hover:bg-blue-600'\n }`}\n >\n {form.ocr_engine === 'paddleocr_online' ? '停用 PaddleOCR-VL-1.5' : '启用 PaddleOCR-VL-1.5'}\n </button>\n </div>\n </div>\n {form.ocr_engine === 'paddleocr_online' && (\n <div className=\"space-y-3 pt-2\">\n <p className=\"text-xs text-gray-500\">\n 使用百度 PaddleOCR-VL-1.5 视觉大模型进行文档版面解析。\n 支持中英文文档,无需本地 GPU。\n <span className=\"text-orange-500 ml-1\">实验功能,识别可能会有重复</span>\n </p>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1.5\">识别模式 <span className=\"text-[11px] text-blue-500 ml-1\">推荐逐框识别,次选混合识别</span></label>\n <div className=\"flex rounded border border-gray-300 overflow-hidden text-xs\">\n {[\n { key: 'spatial', label: '空间分配(快)' },\n { key: 'perbox', label: '逐框识别(慢)' },\n { key: 'hybrid', label: '混合识别' },\n ].map(({ key, label }) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => updateForm({ paddleocr_online_mode: key } as any)}\n className={`flex-1 py-1.5 text-center transition-colors ${\n ((form as any).paddleocr_online_mode || 'spatial') === key\n ? 'bg-blue-500 text-white'\n : 'bg-white text-gray-600 hover:bg-gray-50'\n }`}\n >\n {label}\n </button>\n ))}\n </div>\n <p className=\"text-[11px] text-gray-400 mt-1\">\n {((form as any).paddleocr_online_mode || 'spatial') === 'spatial' && '段落文字精准,行识别不精确'}\n {((form as any).paddleocr_online_mode || 'spatial') === 'perbox' && '行识别精准,有乱码风险'}\n {((form as any).paddleocr_online_mode || 'spatial') === 'hybrid' && '逐框为主、空间填补,可能产生识别重复'}\n </p>\n </div>\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">Access Token</label>\n <div className=\"flex gap-2\">\n <input value={(form as any).paddleocr_online_token || ''}\n onChange={(e) => updateForm({ paddleocr_online_token: e.target.value } as any)}\n placeholder=\"输入 PaddleOCR access token\"\n className=\"flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button\n type=\"button\"\n onClick={handleTestPaddleocrOnline}\n disabled={onlineTesting.paddleocr || !(form as any).paddleocr_online_token}\n className=\"px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap\">\n {onlineTesting.paddleocr ? '测试中...' : '检测'}\n </button>\n </div>\n {onlineEngineStatus.paddleocr === 'ok' && (\n <p className=\"text-xs text-green-600 mt-1\">连接正常</p>\n )}\n {onlineEngineStatus.paddleocr === 'fail' && (\n <p className=\"text-xs text-red-500 mt-1\">连接失败</p>\n )}\n </div>\n <p className=\"text-xs text-gray-400\">\n 从 <a href=\"https://aistudio.baidu.com/paddleocr/task\" target=\"_blank\" className=\"text-blue-500 hover:underline\">PaddleOCR 控制台</a> 获取 Access Token。\n <a href=\"https://ai.baidu.com/ai-doc/AISTUDIO/Cmkz2m0ma\" target=\"_blank\" className=\"text-blue-500 hover:underline ml-2\">API 文档</a>\n </p>\n </div>\n )}\n\n <div className=\"flex items-center gap-2 pt-3\">\n <input type=\"checkbox\" id=\"ocr_confirm_enabled\"\n checked={(form as any).ocr_confirm_enabled ?? false}\n onChange={(e) => updateForm({ ocr_confirm_enabled: e.target.checked } as any)}\n className=\"rounded\" />\n <label htmlFor=\"ocr_confirm_enabled\" className=\"text-xs font-medium text-gray-600\">\n 管道执行到 OCR 步骤时弹出确认对话框\n </label>\n </div>\n\n {/* ============ AI Vision / 书签 ============ */}\n <div className=\"border-t border-gray-200 pt-3\">\n <span className=\"text-xs font-medium text-gray-600 mb-2 block\">AI Vision 目录提取</span>\n <div className=\"flex items-center gap-2\">\n <input type=\"checkbox\" id=\"ai_vision_enabled\"\n checked={(form as any).ai_vision_enabled ?? true}\n onChange={(e) => updateForm({ ai_vision_enabled: e.target.checked } as any)} className=\"rounded\" />\n <label htmlFor=\"ai_vision_enabled\" className=\"text-xs\">启用 AI Vision 目录提取</label>\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API 端点</label>\n <input type=\"text\" value={(form as any).ai_vision_endpoint || ''}\n onChange={(e) => updateForm({ ai_vision_endpoint: e.target.value } as any)}\n placeholder={\n (form as any).ai_vision_provider === 'gemini' ? 'https://generativelanguage.googleapis.com/v1beta' :\n (form as any).ai_vision_provider === 'azure' ? 'https://{resource}.openai.azure.com' :\n (form as any).ai_vision_provider === 'anthropic' || (form as any).ai_vision_provider === 'minimax_anthropic' ? 'https://api.anthropic.com' :\n (form as any).ai_vision_provider === 'minimax_openai' ? 'https://api.minimaxi.com/v1' :\n 'http://127.0.0.1:12345/v1'\n }\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">模型名称</label>\n <div className=\"flex gap-1\">\n <input type=\"text\" value={(form as any).ai_vision_model || ''}\n onChange={(e) => updateForm({ ai_vision_model: e.target.value } as any)}\n placeholder=\"sabafallah/deepseek-ocr\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n <button\n type=\"button\"\n onClick={async () => {\n setFetchingModels(true)\n setFetchModelsMsg('')\n try {\n const res = await fetch('/api/v1/fetch-models', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n endpoint: (form as any).ai_vision_endpoint,\n api_key: (form as any).ai_vision_api_key,\n provider: (form as any).ai_vision_provider,\n }),\n })\n const data = await res.json()\n if (data.ok && data.models.length > 0) {\n setAiModels(data.models)\n setFetchModelsMsg(`${data.models.length} 个模型`)\n } else {\n setFetchModelsMsg(data.message || '无可用模型')\n }\n } catch (e) {\n setFetchModelsMsg(String(e))\n }\n setFetchingModels(false)\n }}\n disabled={fetchingModels || !(form as any).ai_vision_endpoint}\n className=\"px-2 py-1.5 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap\"\n title=\"从提供商拉取可用模型列表\"\n >\n {fetchingModels ? '...' : '获取模型'}\n </button>\n </div>\n {aiModels.length > 0 && (\n <select\n value={(form as any).ai_vision_model || ''}\n onChange={(e) => updateForm({ ai_vision_model: e.target.value } as any)}\n className=\"w-full mt-1 rounded border border-blue-300 px-2 py-1 text-xs font-mono\"\n size={Math.min(aiModels.length + 1, 8)}\n >\n <option value=\"\" disabled>-- 选择模型 --</option>\n {aiModels.map(m => (\n <option key={m.id} value={m.id}>{m.name}</option>\n ))}\n </select>\n )}\n {fetchModelsMsg && (\n <p className={`text-xs mt-1 ${fetchModelsMsg.includes('个模型') ? 'text-green-600' : 'text-red-500'}`}>\n {fetchModelsMsg}\n </p>\n )}\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API Key</label>\n <input type=\"password\" value={(form as any).ai_vision_api_key || ''}\n onChange={(e) => updateForm({ ai_vision_api_key: e.target.value } as any)}\n placeholder=\"sk-... (支持 {env:VAR_NAME})\"\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs font-mono\" />\n </div>\n <div className=\"mt-2\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">API 格式</label>\n <select value={(form as any).ai_vision_provider || 'openai_compatible'}\n onChange={(e) => {\n const newProvider = e.target.value\n const defaultEndpoint = AI_VISION_ENDPOINTS[newProvider] || ''\n updateForm({\n ai_vision_provider: newProvider,\n ai_vision_endpoint: defaultEndpoint,\n } as any)\n }}\n className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs\">\n <option value=\"openai_compatible\">OpenAI / DeepSeek / 智谱 / Qwen / Ollama / lmstudio</option>\n <option value=\"openai_responses\">OpenAI Responses (新版 /v1/responses)</option>\n <option value=\"azure\">Azure OpenAI</option>\n <option value=\"anthropic\">Anthropic Claude</option>\n <option value=\"gemini\">Google Gemini</option>\n <option value=\"minimax_openai\">MiniMax (OpenAI 兼容 · 国内/国际通用)</option>\n <option value=\"minimax_anthropic\">MiniMax (Anthropic 兼容 · Token Plan)</option>\n <option value=\"custom\">自定义 (Custom)</option>\n </select>\n </div>\n {(form as any).ai_vision_provider === 'custom' && (\n <div className=\"flex items-center gap-2 mt-2\">\n <input type=\"checkbox\" id=\"ai_vision_messages_api\"\n checked={(form as any).ai_vision_messages_api ?? false}\n onChange={(e) => updateForm({ ai_vision_messages_api: e.target.checked } as any)} className=\"rounded\" />\n <label htmlFor=\"ai_vision_messages_api\" className=\"text-xs font-medium text-gray-600\">\n 使用 Anthropic Messages API 格式 (默认 OpenAI Chat Completions)\n </label>\n </div>\n )}\n <div className=\"grid grid-cols-2 gap-3 mt-2\">\n <div>\n <label className=\"text-xs font-medium text-gray-600 block mb-1\">DPI</label>\n <input type=\"number\" value={(form as any).ai_vision_dpi ?? 150}\n onChange={(e) => updateForm({ ai_vision_dpi: parseInt(e.target.value) || 150 } as any)}\n min={72} max={300} className=\"w-full rounded border border-gray-300 px-2 py-1.5 text-xs\" />\n </div>\n </div>\n <button\n type=\"button\"\n onClick={async () => {\n setAiVisionTest('testing');\n try {\n const res = await fetch('/api/v1/check-ai-vision', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n endpoint: (form as any).ai_vision_endpoint,\n model: (form as any).ai_vision_model,\n api_key: (form as any).ai_vision_api_key,\n provider: (form as any).ai_vision_provider,\n messages_api: (form as any).ai_vision_messages_api,\n }),\n });\n const data = await res.json();\n setAiVisionTest(data.ok ? 'ok' : 'fail');\n setAiVisionMsg(data.message || data.error || '');\n } catch (e) {\n setAiVisionTest('fail');\n setAiVisionMsg(String(e));\n }\n }}\n disabled={aiVisionTest === 'testing'}\n className=\"px-3 py-1.5 mt-2 text-xs rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-50\"\n >\n {aiVisionTest === 'testing' ? '测试中...' : '检测'}\n </button>\n {aiVisionMsg && (\n <p className={`text-xs mt-1 ${aiVisionTest === 'ok' ? 'text-green-600' : 'text-red-600'}`}>\n {aiVisionMsg}\n </p>\n )}\n <p className=\"text-xs text-gray-400 mt-1\">\n 阶段1 从 OCR 文字层提取目录(免费),阶段2 用 AI Vision 从目录页图片提取。\n 支持 OpenAI 兼容 / Gemini / MiniMax 格式。\n </p>\n <div className=\"flex items-center gap-2 mt-2\">\n <input type=\"checkbox\" id=\"bookmark_confirm_enabled\"\n checked={(form as any).bookmark_confirm_enabled ?? false}\n onChange={(e) => updateForm({ bookmark_confirm_enabled: e.target.checked } as any)}\n className=\"rounded\" />\n <label htmlFor=\"bookmark_confirm_enabled\" className=\"text-xs font-medium text-gray-600\">\n 管道执行到书签步骤时弹出确认对话框\n </label>\n </div>\n </div>\n\n {/* ============ 主题 ============ */}\n <div className=\"border-t border-gray-200 pt-3\">\n <label className=\"text-xs font-medium text-gray-600 block mb-1.5\">主题</label>\n <div className=\"flex rounded border border-gray-300 overflow-hidden text-xs\">\n {[\n { key: 'auto', label: '自动' },\n { key: 'light', label: '白昼' },\n { key: 'dark', label: '黑夜' },\n ].map(({ key, label }) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => updateForm({ theme: key })}\n className={`flex-1 py-1.5 text-center transition-colors ${\n ((form as any).theme || 'auto') === key\n ? 'bg-blue-500 text-white'\n : 'bg-white text-gray-600 hover:bg-gray-50'\n }`}\n >\n {label}\n </button>\n ))}\n </div>\n </div>\n\n {/* ============ 保存按钮 ============ */}\n <div className=\"flex items-center gap-3 pt-2\">\n <button\n type=\"button\"\n onClick={handleSave}\n disabled={saving}\n className=\"px-5 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 font-medium\"\n >\n {saving ? '保存中...' : '保存设置'}\n </button>\n <button\n type=\"button\"\n onClick={handleCheckUpdate}\n disabled={updateChecking}\n className=\"px-5 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 font-medium\"\n >\n {updateChecking ? '检测中...' : '检查更新'}\n </button>\n {saveMsg && (\n <span className={`text-xs ${saveMsg.includes('成功') ? 'text-green-600' : 'text-red-500'}`}>\n {saveMsg}\n </span>\n )}\n {updateResult && (\n <span className={`text-xs px-2 py-1 rounded ${updateResult.includes('失败') ? 'text-red-600 bg-red-50' : updateResult.includes('新版本') ? 'text-blue-600 bg-blue-50 font-semibold' : 'text-green-600 bg-green-50'}`}>\n {updateResult}\n </span>\n )}\n </div>\n </div>\n )\n}\n\nexport default React.memo(OCRNetworkSection)\n"}}}}