diff --git a/packages/web/src/locales/main/en.ts b/packages/web/src/locales/main/en.ts index e68d6fe0..6bcfdb58 100644 --- a/packages/web/src/locales/main/en.ts +++ b/packages/web/src/locales/main/en.ts @@ -1819,9 +1819,11 @@ export default { "ocr.openSkills": "Open Skills", "ocr.connectionTitle": "Connection & Defaults", "ocr.endpointLabel": "Hosted API endpoint", - "ocr.endpointPlaceholder": "https://your-domain/layout-parsing", + "ocr.endpointPlaceholder": "https://.aistudio-app.com/layout-parsing", + "ocr.endpointHint": "Paste the hosted layout-parsing URL from your AI Studio OCR page. Each deployment can expose a different endpoint.", "ocr.tokenLabel": "AI Studio token", "ocr.tokenPlaceholder": "Enter Baidu AI Studio token", + "ocr.tokenHint": "Use a PaddleOCR token from the AI Studio OCR page. The Models page AI Studio key is not reused here.", "ocr.defaultFileType": "Default upload type", "ocr.fileType.image": "Image", "ocr.fileType.pdf": "PDF", @@ -1843,6 +1845,13 @@ export default { "ocr.skillInstallFailed": "Failed to install PaddleOCR skill", "ocr.testFailedFallback": "Failed to test PaddleOCR connection", "ocr.testSuccess": "Connection OK. Sample parse returned {{pages}} page(s).", + "ocr.error.endpointRequired": "Enter the PaddleOCR endpoint before testing or saving.", + "ocr.error.tokenRequired": "Enter a PaddleOCR AI Studio token before testing or saving.", + "ocr.error.invalidToken": "AI Studio token rejected by PaddleOCR. Get a PaddleOCR token from the AI Studio OCR page and try again.", + "ocr.error.endpointNotFound": "PaddleOCR endpoint was not found. Confirm the hosted layout-parsing URL or your custom gateway URL.", + "ocr.error.serviceUnavailable": "AI Studio PaddleOCR is temporarily unavailable. Try again later.", + "ocr.error.network": "Unable to reach AI Studio PaddleOCR. Check your network and try again.", + "ocr.error.timeout": "PaddleOCR request timed out. Try again in a moment.", "ocr.presetsTitle": "Recommended presets", "ocr.preset.cleanPdf": "Clean PDF", "ocr.preset.cleanPdfDesc": "Multi-page reports and digital PDFs that need readable markdown and merged tables.", diff --git a/packages/web/src/locales/main/ja.ts b/packages/web/src/locales/main/ja.ts index 2c786096..6629b5de 100644 --- a/packages/web/src/locales/main/ja.ts +++ b/packages/web/src/locales/main/ja.ts @@ -1819,9 +1819,11 @@ export default { "ocr.openSkills": "Skills を開く", "ocr.connectionTitle": "接続と既定値", "ocr.endpointLabel": "ホスト API エンドポイント", - "ocr.endpointPlaceholder": "https://your-domain/layout-parsing", + "ocr.endpointPlaceholder": "https://.aistudio-app.com/layout-parsing", + "ocr.endpointHint": "AI Studio OCR ページに表示される hosted layout-parsing URL を入力してください。デプロイごとにエンドポイントが異なる場合があります。", "ocr.tokenLabel": "AI Studio Token", "ocr.tokenPlaceholder": "Baidu AI Studio Token を入力", + "ocr.tokenHint": "ここでは AI Studio OCR ページの PaddleOCR Token を使います。Models ページの AI Studio Key は再利用しません。", "ocr.defaultFileType": "既定アップロード種別", "ocr.fileType.image": "画像", "ocr.fileType.pdf": "PDF", @@ -1843,6 +1845,13 @@ export default { "ocr.skillInstallFailed": "PaddleOCR skill の導入に失敗しました", "ocr.testFailedFallback": "PaddleOCR 接続確認に失敗しました", "ocr.testSuccess": "接続成功。サンプル解析は {{pages}} ページを返しました。", + "ocr.error.endpointRequired": "テストまたは保存の前に PaddleOCR エンドポイントを入力してください。", + "ocr.error.tokenRequired": "テストまたは保存の前に PaddleOCR 用 AI Studio Token を入力してください。", + "ocr.error.invalidToken": "現在の AI Studio Token は PaddleOCR で拒否されました。AI Studio OCR ページで専用の PaddleOCR Token を取得して再試行してください。", + "ocr.error.endpointNotFound": "PaddleOCR エンドポイントが見つかりません。ホスト版 layout-parsing URL または独自ゲートウェイ URL を確認してください。", + "ocr.error.serviceUnavailable": "AI Studio PaddleOCR は一時的に利用できません。しばらくしてから再試行してください。", + "ocr.error.network": "AI Studio PaddleOCR に接続できません。ネットワークを確認して再試行してください。", + "ocr.error.timeout": "PaddleOCR リクエストがタイムアウトしました。少し待ってから再試行してください。", "ocr.presetsTitle": "推奨プリセット", "ocr.preset.cleanPdf": "クリーン PDF", "ocr.preset.cleanPdfDesc": "複数ページ報告書やデジタル PDF 向け。読みやすい Markdown と表結合を重視します。", diff --git a/packages/web/src/locales/main/zh.ts b/packages/web/src/locales/main/zh.ts index 4089689d..1afc6e43 100644 --- a/packages/web/src/locales/main/zh.ts +++ b/packages/web/src/locales/main/zh.ts @@ -1819,9 +1819,11 @@ export default { "ocr.openSkills": "打开技能页", "ocr.connectionTitle": "连接与默认参数", "ocr.endpointLabel": "托管 API 端点", - "ocr.endpointPlaceholder": "https://your-domain/layout-parsing", + "ocr.endpointPlaceholder": "https://.aistudio-app.com/layout-parsing", + "ocr.endpointHint": "请填写 AI Studio OCR 页面提供的托管 layout-parsing 地址。不同部署的端点可能不同。", "ocr.tokenLabel": "AI Studio Token", "ocr.tokenPlaceholder": "输入百度 AI Studio Token", + "ocr.tokenHint": "这里需要 AI Studio OCR 页面上的 PaddleOCR Token,不会复用 Models 页面里的 AI Studio Key。", "ocr.defaultFileType": "默认上传类型", "ocr.fileType.image": "图片", "ocr.fileType.pdf": "PDF", @@ -1843,6 +1845,13 @@ export default { "ocr.skillInstallFailed": "安装 PaddleOCR 技能失败", "ocr.testFailedFallback": "测试 PaddleOCR 连接失败", "ocr.testSuccess": "连接成功,样例解析返回 {{pages}} 页。", + "ocr.error.endpointRequired": "请先填写 PaddleOCR 端点,再进行测试或保存。", + "ocr.error.tokenRequired": "请先填写 PaddleOCR 的 AI Studio Token,再进行测试或保存。", + "ocr.error.invalidToken": "PaddleOCR 拒绝了当前 AI Studio Token。请到 AI Studio OCR 页面获取专用 PaddleOCR Token 后重试。", + "ocr.error.endpointNotFound": "找不到 PaddleOCR 端点。请确认托管版 layout-parsing 地址或你的自定义网关地址。", + "ocr.error.serviceUnavailable": "AI Studio PaddleOCR 当前暂时不可用,请稍后重试。", + "ocr.error.network": "无法连接到 AI Studio PaddleOCR。请检查网络后重试。", + "ocr.error.timeout": "PaddleOCR 请求超时,请稍后再试。", "ocr.presetsTitle": "推荐预设", "ocr.preset.cleanPdf": "干净 PDF", "ocr.preset.cleanPdfDesc": "适合多页报告和数字 PDF,重点是可读 Markdown 与跨页表格。", diff --git a/packages/web/src/modules/ocr/OcrPage.tsx b/packages/web/src/modules/ocr/OcrPage.tsx index e1bd9173..0081ae02 100644 --- a/packages/web/src/modules/ocr/OcrPage.tsx +++ b/packages/web/src/modules/ocr/OcrPage.tsx @@ -24,6 +24,7 @@ import { PADDLEOCR_SKILL_KEY, PADDLEOCR_TASK_URL, } from './catalog' +import { toUserFacingPaddleOcrError } from './errorMessages' type OcrFormState = Required & { endpoint: string @@ -37,19 +38,11 @@ type LocalSource = { sizeLabel: string } -function getSharedBaiduToken(config: OpenClawConfig | null): string { - const baiduProvider = config?.models?.providers?.['baidu-aistudio'] as - | { apiKey?: string; api_key?: string } - | undefined - return baiduProvider?.apiKey?.trim() || baiduProvider?.api_key?.trim() || '' -} - function buildInitialForm(config: OpenClawConfig | null): OcrFormState { const provider = config?.ocr?.providers?.[PADDLEOCR_PROVIDER_ID] - const sharedBaiduToken = getSharedBaiduToken(config) return { endpoint: provider?.endpoint ?? '', - accessToken: provider?.accessToken ?? sharedBaiduToken, + accessToken: provider?.accessToken ?? '', fileType: provider?.defaultFileType ?? DEFAULT_PADDLEOCR_OPTIONS.fileType, useDocOrientationClassify: provider?.useDocOrientationClassify ?? DEFAULT_PADDLEOCR_OPTIONS.useDocOrientationClassify, useDocUnwarping: provider?.useDocUnwarping ?? DEFAULT_PADDLEOCR_OPTIONS.useDocUnwarping, @@ -65,7 +58,7 @@ function buildInitialForm(config: OpenClawConfig | null): OcrFormState { function hasSavedPaddleOcrConfig(config: OpenClawConfig | null): boolean { const provider = config?.ocr?.providers?.[PADDLEOCR_PROVIDER_ID] - return Boolean(provider?.endpoint?.trim() && (provider?.accessToken?.trim() || getSharedBaiduToken(config))) + return Boolean(provider?.endpoint?.trim() && provider?.accessToken?.trim()) } function isSkillInstalled(skills: Awaited>['data'] | null | undefined) { @@ -249,12 +242,16 @@ export default function OcrPage() { } function updateOption(key: K, value: OcrFormState[K]) { + setTestError(null) + setTestMessage(null) setForm((current) => ({ ...current, [key]: value })) } function applyPreset(presetId: string) { const preset = PADDLEOCR_PRESETS.find((item) => item.id === presetId) if (!preset) return + setTestError(null) + setTestMessage(null) setForm((current) => ({ ...current, ...preset.options, @@ -284,6 +281,8 @@ export default function OcrPage() { setSaving(true) setSaveError(null) setSaveMessage(null) + setTestError(null) + setTestMessage(null) try { const currentConfig = config ?? {} const nextConfig = buildSkillEnabledConfig({ @@ -343,7 +342,7 @@ export default function OcrPage() { } setTestMessage(t('ocr.testSuccess', { pages: result.data?.pageCount ?? 0 })) } catch (testErr: unknown) { - setTestError(testErr instanceof Error ? testErr.message : String(testErr)) + setTestError(toUserFacingPaddleOcrError(testErr instanceof Error ? testErr.message : String(testErr), t)) } finally { setTesting(false) } @@ -381,7 +380,7 @@ export default function OcrPage() { } setParseResult(result.data ?? null) } catch (parseErr: unknown) { - setParseError(parseErr instanceof Error ? parseErr.message : String(parseErr)) + setParseError(toUserFacingPaddleOcrError(parseErr instanceof Error ? parseErr.message : String(parseErr), t)) } finally { setParsing(false) } @@ -458,6 +457,7 @@ export default function OcrPage() { placeholder={t('ocr.endpointPlaceholder')} className="input w-full" /> +

{t('ocr.endpointHint')}

diff --git a/packages/web/src/modules/ocr/__tests__/OcrPage.test.tsx b/packages/web/src/modules/ocr/__tests__/OcrPage.test.tsx index dba31411..5ad80d80 100644 --- a/packages/web/src/modules/ocr/__tests__/OcrPage.test.tsx +++ b/packages/web/src/modules/ocr/__tests__/OcrPage.test.tsx @@ -371,7 +371,7 @@ describe('OcrPage', () => { expect(await screen.findByDisplayValue(/# Parsed/)).toBeInTheDocument() }) - it('prefills the AI Studio token from the saved ERNIE provider when OCR is not configured yet', async () => { + it('leaves the OCR endpoint and token empty when only the models provider is configured', async () => { mockGetConfigResult.mockResolvedValue({ success: true, data: { @@ -388,6 +388,47 @@ describe('OcrPage', () => { renderPage() - expect(await screen.findByDisplayValue('shared-baidu-token')).toBeInTheDocument() + expect(await screen.findByPlaceholderText('https://.aistudio-app.com/layout-parsing')).toHaveValue('') + expect(screen.getByPlaceholderText('Enter Baidu AI Studio token')).toHaveValue('') }) + + it('clears stale connection errors after the OCR token changes and the config is saved', async () => { + mockGetConfigResult.mockResolvedValue({ + success: true, + data: { + models: { providers: {} }, + skills: { + entries: { 'paddleocr-doc-parsing': { enabled: true } }, + }, + ocr: { + providers: { + paddleocr: { + endpoint: 'https://example.com/layout-parsing', + accessToken: 'saved-token', + }, + }, + }, + }, + error: null, + }) + mockTestPaddleOcrResult.mockResolvedValueOnce({ + success: false, + data: null, + error: 'HTTP 500: fetch failed', + }) + + renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Test Connection' })) + expect(await screen.findByText('Unable to reach AI Studio PaddleOCR. Check your network and try again.')).toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText('Enter Baidu AI Studio token'), { + target: { value: 'fresh-token' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save & Enable OCR' })) + + expect(await screen.findByText('PaddleOCR saved and ready to use.')).toBeInTheDocument() + expect(screen.queryByText('Unable to reach AI Studio PaddleOCR. Check your network and try again.')).not.toBeInTheDocument() + }) + }) diff --git a/packages/web/src/modules/ocr/__tests__/errorMessages.test.ts b/packages/web/src/modules/ocr/__tests__/errorMessages.test.ts new file mode 100644 index 00000000..cba1917b --- /dev/null +++ b/packages/web/src/modules/ocr/__tests__/errorMessages.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { toUserFacingPaddleOcrError } from '../errorMessages' + +const messages: Record = { + 'ocr.testFailedFallback': 'Failed to test PaddleOCR connection', + 'ocr.error.endpointRequired': 'Enter the PaddleOCR endpoint before testing or saving.', + 'ocr.error.tokenRequired': 'Enter a PaddleOCR AI Studio token before testing or saving.', + 'ocr.error.invalidToken': 'AI Studio token rejected by PaddleOCR. Get a PaddleOCR token from the AI Studio OCR page and try again.', + 'ocr.error.endpointNotFound': 'PaddleOCR endpoint was not found. Confirm the hosted layout-parsing URL or your custom gateway URL.', + 'ocr.error.serviceUnavailable': 'AI Studio PaddleOCR is temporarily unavailable. Try again later.', + 'ocr.error.network': 'Unable to reach AI Studio PaddleOCR. Check your network and try again.', + 'ocr.error.timeout': 'PaddleOCR request timed out. Try again in a moment.', +} + +function t(key: string) { + return messages[key] ?? key +} + +describe('toUserFacingPaddleOcrError', () => { + it('maps unauthorized upstream failures to token guidance', () => { + expect(toUserFacingPaddleOcrError('HTTP 500: PaddleOCR request failed (401): Unauthorized', t)).toBe( + messages['ocr.error.invalidToken'], + ) + }) + + it('maps network failures to retry guidance', () => { + expect(toUserFacingPaddleOcrError('HTTP 500: fetch failed', t)).toBe(messages['ocr.error.network']) + }) + + it('maps missing endpoints to direct form guidance', () => { + expect(toUserFacingPaddleOcrError('Missing PaddleOCR endpoint', t)).toBe(messages['ocr.error.endpointRequired']) + }) +}) diff --git a/packages/web/src/modules/ocr/errorMessages.ts b/packages/web/src/modules/ocr/errorMessages.ts new file mode 100644 index 00000000..0f692f39 --- /dev/null +++ b/packages/web/src/modules/ocr/errorMessages.ts @@ -0,0 +1,29 @@ +export function toUserFacingPaddleOcrError(message: string, t: (key: string) => string): string { + const normalized = message.trim() + const lowered = normalized.toLowerCase() + + if (!normalized) return t('ocr.testFailedFallback') + if (normalized.includes('Missing PaddleOCR endpoint') || normalized.includes('PADDLEOCR_ENDPOINT_REQUIRED')) { + return t('ocr.error.endpointRequired') + } + if (normalized.includes('Missing PaddleOCR access token') || normalized.includes('PADDLEOCR_TOKEN_REQUIRED')) { + return t('ocr.error.tokenRequired') + } + if (lowered.includes('fetch failed') || lowered.includes('networkerror')) { + return t('ocr.error.network') + } + if (lowered.includes('timed out') || lowered.includes('timeout') || lowered.includes('aborted')) { + return t('ocr.error.timeout') + } + if (/\b401\b/.test(normalized) || /\b403\b/.test(normalized) || lowered.includes('unauthorized') || lowered.includes('forbidden')) { + return t('ocr.error.invalidToken') + } + if (/\b404\b/.test(normalized) || lowered.includes('not found')) { + return t('ocr.error.endpointNotFound') + } + if (/\b5\d\d\b/.test(normalized) || lowered.includes('internal server error')) { + return t('ocr.error.serviceUnavailable') + } + + return normalized +}