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