Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/web/src/locales/main/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-deployment>.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",
Expand All @@ -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.",
Expand Down
11 changes: 10 additions & 1 deletion packages/web/src/locales/main/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1819,9 +1819,11 @@ export default {
"ocr.openSkills": "Skills を開く",
"ocr.connectionTitle": "接続と既定値",
"ocr.endpointLabel": "ホスト API エンドポイント",
"ocr.endpointPlaceholder": "https://your-domain/layout-parsing",
"ocr.endpointPlaceholder": "https://<your-deployment>.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",
Expand All @@ -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 と表結合を重視します。",
Expand Down
11 changes: 10 additions & 1 deletion packages/web/src/locales/main/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1819,9 +1819,11 @@ export default {
"ocr.openSkills": "打开技能页",
"ocr.connectionTitle": "连接与默认参数",
"ocr.endpointLabel": "托管 API 端点",
"ocr.endpointPlaceholder": "https://your-domain/layout-parsing",
"ocr.endpointPlaceholder": "https://<your-deployment>.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",
Expand All @@ -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 与跨页表格。",
Expand Down
25 changes: 13 additions & 12 deletions packages/web/src/modules/ocr/OcrPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
PADDLEOCR_SKILL_KEY,
PADDLEOCR_TASK_URL,
} from './catalog'
import { toUserFacingPaddleOcrError } from './errorMessages'

type OcrFormState = Required<PaddleOcrRequestOptions> & {
endpoint: string
Expand All @@ -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,
Expand All @@ -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<ReturnType<typeof getSkillsResult>>['data'] | null | undefined) {
Expand Down Expand Up @@ -249,12 +242,16 @@ export default function OcrPage() {
}

function updateOption<K extends keyof OcrFormState>(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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -458,6 +457,7 @@ export default function OcrPage() {
placeholder={t('ocr.endpointPlaceholder')}
className="input w-full"
/>
<p className="text-xs text-muted-foreground">{t('ocr.endpointHint')}</p>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-foreground">{t('ocr.tokenLabel')}</span>
Expand All @@ -468,6 +468,7 @@ export default function OcrPage() {
placeholder={t('ocr.tokenPlaceholder')}
className="input w-full"
/>
<p className="text-xs text-muted-foreground">{t('ocr.tokenHint')}</p>
</label>
</div>

Expand Down
45 changes: 43 additions & 2 deletions packages/web/src/modules/ocr/__tests__/OcrPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -388,6 +388,47 @@ describe('OcrPage', () => {

renderPage()

expect(await screen.findByDisplayValue('shared-baidu-token')).toBeInTheDocument()
expect(await screen.findByPlaceholderText('https://<your-deployment>.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()
})

})
33 changes: 33 additions & 0 deletions packages/web/src/modules/ocr/__tests__/errorMessages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { toUserFacingPaddleOcrError } from '../errorMessages'

const messages: Record<string, string> = {
'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'])
})
})
29 changes: 29 additions & 0 deletions packages/web/src/modules/ocr/errorMessages.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading