From ed3c091f5d66e8e7735205b05580183b46c0291c Mon Sep 17 00:00:00 2001 From: balibabu Date: Thu, 5 Nov 2026 18:08:19 +0800 Subject: [PATCH] Feat: Image parsing: DeepDoc is distinguished from other OCR models and is offered as a separate option. (#22047) --- .../layout-recognize-form-field.test.tsx | 24 +- .../layout-recognize-form-field.tsx | 43 ++-- web/src/components/model-tree-select.tsx | 88 +++++-- web/src/components/tree-select.tsx | 9 +- web/src/locales/en.ts | 14 +- web/src/locales/zh.ts | 14 +- .../parser-form/image-form-fields.test.tsx | 77 ++++-- .../form/parser-form/image-form-fields.tsx | 231 ++++++++++++------ 8 files changed, 352 insertions(+), 148 deletions(-) diff --git a/web/src/components/layout-recognize-form-field.test.tsx b/web/src/components/layout-recognize-form-field.test.tsx index b47464f18f..91273a15a5 100644 --- a/web/src/components/layout-recognize-form-field.test.tsx +++ b/web/src/components/layout-recognize-form-field.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { useForm } from 'react-hook-form'; -import { useFetchAllAddedModels } from '@/hooks/use-llm-request'; import { LayoutRecognizeFormField } from './layout-recognize-form-field'; +import { useModelTree } from './model-tree-select'; import { Form } from './ui/form'; import { TooltipProvider } from './ui/tooltip'; @@ -15,9 +15,22 @@ jest.mock('react-i18next', () => ({ }), })); +// The real hook module transitively boots the app shell (router), so the +// models query stays stubbed even though the component only sees useModelTree. jest.mock('@/hooks/use-llm-request', () => ({ useFetchAllAddedModels: jest.fn(() => ({ - data: [ + data: [], + isFetched: true, + isError: false, + })), +})); + +jest.mock('./model-tree-select', () => ({ + buildModelTree: () => [], + useModelTree: jest.fn(() => ({ + treeData: [], + models: [], + allAddedModels: [ { model_id: 'mineru-uuid', provider_name: 'MinerU' }, { model_id: 'minerunet-uuid', provider_name: 'MinerU.Net' }, { model_id: 'other-uuid', provider_name: 'Other' }, @@ -26,8 +39,6 @@ jest.mock('@/hooks/use-llm-request', () => ({ isError: false, })), })); - -jest.mock('./model-tree-select', () => ({ buildModelTree: () => [] })); jest.mock('./tree-select', () => { const React = jest.requireActual('react'); return { @@ -101,9 +112,8 @@ describe('MinerU PDF options', () => { expect( screen.getByRole('switch', { name: /Table Recognition/ }), ).toBeChecked(); - expect(useFetchAllAddedModels).toHaveBeenCalledWith( - undefined, - 'owner-tenant', + expect(useModelTree).toHaveBeenCalledWith( + expect.objectContaining({ ownerTenantId: 'owner-tenant' }), ); }); diff --git a/web/src/components/layout-recognize-form-field.tsx b/web/src/components/layout-recognize-form-field.tsx index 34f3312b6e..c9ce04d702 100644 --- a/web/src/components/layout-recognize-form-field.tsx +++ b/web/src/components/layout-recognize-form-field.tsx @@ -16,14 +16,13 @@ import { useTranslate } from '@/hooks/common-hooks'; import { LLMFactory } from '@/constants/llm'; -import { useFetchAllAddedModels } from '@/hooks/use-llm-request'; import { cn } from '@/lib/utils'; import { parseModelValue } from '@/utils/llm-util'; import { camelCase } from 'lodash'; import { ReactNode, useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { MinerUOptionsFormField } from './mineru-options-form-field'; -import { buildModelTree } from './model-tree-select'; +import { useModelTree } from './model-tree-select'; import { PaddleOCROptionsFormField } from './paddleocr-options-form-field'; import { TreeSelect, TreeSelectNode } from './tree-select'; import { @@ -43,6 +42,20 @@ export const enum ParseDocumentType { MonkeyOCRv2 = 'MonkeyOCRv2', } +// Stable reference: useModelTree keys its memoized tree on its identity. +const LayoutRecognizeModelTypes = ['image2text', 'ocr', 'vision']; + +function renderExperimentalLeaf(node: TreeSelectNode) { + return ( +
+ {node.label} + + Experimental + +
+ ); +} + /** Selects a parser model and scopes provider-specific options to its form path. */ export function LayoutRecognizeFormField({ name = 'parser_config.layout_recognize', @@ -67,10 +80,15 @@ export function LayoutRecognizeFormField({ const { t } = useTranslate('knowledgeDetails'); const { - data: allAddedModels, + treeData: modelTree, + allAddedModels, isFetched: modelsFetched, isError: modelsError, - } = useFetchAllAddedModels(undefined, ownerTenantId); + } = useModelTree({ + modelTypes: LayoutRecognizeModelTypes, + renderLeafLabel: renderExperimentalLeaf, + ownerTenantId, + }); const treeData = useMemo(() => { const list = optionsWithoutLLM @@ -92,23 +110,8 @@ export function LayoutRecognizeFormField({ title: x.label, })); - const modelTree = buildModelTree( - allAddedModels, - ['image2text', 'ocr', 'vision'], - (node) => ( -
- - {node.label} - - - Experimental - -
- ), - ); - return [...prependNodes, ...modelTree]; - }, [allAddedModels, optionsWithoutLLM, t]); + }, [modelTree, optionsWithoutLLM, t]); return ( = { tts_id: ['tts'], }; +/** The type/provider predicate shared by buildModelTree and useModelTree. */ +function filterAddedModels( + allModels: IAddedModel[], + modelTypes: string[], + providers?: string[], +): IAddedModel[] { + return allModels.filter( + (m) => + m.model_type?.some((t) => modelTypes.includes(t)) && + (!providers?.length || providers.includes(m.provider_name)), + ); +} + export function buildModelTree( allModels: IAddedModel[], modelTypes: string[], @@ -52,11 +65,7 @@ export function buildModelTree( ) => React.ReactNode, providers?: string[], ): TreeSelectNode[] { - const filtered = allModels.filter( - (m) => - m.model_type?.some((t) => modelTypes.includes(t)) && - (!providers?.length || providers.includes(m.provider_name)), - ); + const filtered = filterAddedModels(allModels, modelTypes, providers); const seenLeafIds = new Set(); const providerMap = new Map>(); @@ -123,6 +132,55 @@ export function buildModelTree( })); } +export interface UseModelTreeOptions { + modelTypes?: string[]; + /** + * Effective provider restriction. The ModelTypeProviderMap fallback stays + * with ModelTreeSelect, so callers that pass nothing get every provider. + */ + providers?: string[]; + renderLeafLabel?: ( + node: TreeSelectNode, + model: IAddedModel, + ) => React.ReactNode; + ownerTenantId?: string; +} + +/** + * Fetch + filter + tree assembly shared by ModelTreeSelect and the pickers + * that need the query result itself: layout_recognize derives the selected + * provider from allAddedModels, and the image OCR radio drives its state + * machine from the flat list and the request state. Array options must be + * stable references (module constants) — the memoized tree keys on their + * identity, and a fresh array per render would reset TreeSelect expansion on + * every parent render. + */ +export function useModelTree({ + modelTypes = ModelTypeMap.llm_id, + providers, + renderLeafLabel, + ownerTenantId, +}: UseModelTreeOptions = {}) { + const { + data: allAddedModels, + isFetched, + isError, + } = useFetchAllAddedModels(undefined, ownerTenantId); + + const models = useMemo( + () => filterAddedModels(allAddedModels, modelTypes, providers), + [allAddedModels, modelTypes, providers], + ); + + const treeData = useMemo( + () => + buildModelTree(allAddedModels, modelTypes, renderLeafLabel, providers), + [allAddedModels, modelTypes, renderLeafLabel, providers], + ); + + return { treeData, models, allAddedModels, isFetched, isError }; +} + export interface ModelTreeSelectProps { modelTypes?: string[]; /** @@ -163,12 +221,6 @@ export const ModelTreeSelect = forwardRef< }, ref, ) { - const { - data: allAddedModels, - isFetched: modelsFetched, - isError: modelsError, - } = useFetchAllAddedModels(undefined, ownerTenantId); - // An explicit prop wins; otherwise inherit the restriction declared for the // type, so the default-model page and the chat settings agree without each // call site having to remember. @@ -181,11 +233,15 @@ export const ModelTreeSelect = forwardRef< return undefined; }, [providers, modelTypes]); - const treeData = useMemo( - () => - buildModelTree(allAddedModels, modelTypes, undefined, allowedProviders), - [allAddedModels, modelTypes, allowedProviders], - ); + const { + treeData, + isFetched: modelsFetched, + isError: modelsError, + } = useModelTree({ + modelTypes, + providers: allowedProviders, + ownerTenantId, + }); // Backward compatibility: map legacy concatenated ids // ("modelName@instanceName@providerName") to new model_id-based ids so diff --git a/web/src/components/tree-select.tsx b/web/src/components/tree-select.tsx index 72c769de13..44f1b440ca 100644 --- a/web/src/components/tree-select.tsx +++ b/web/src/components/tree-select.tsx @@ -285,8 +285,15 @@ export const TreeSelect = forwardRef( ) { const [open, setOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + // Mount with defaultExpandAll means expanded from the first paint: the + // render-phase adjustment below only fires on a data identity change, so + // a component mounting with data already in hand would otherwise stay + // collapsed until the next fetch. const [internalExpandedIds, setInternalExpandedIds] = useState>( - new Set(), + () => + defaultExpandAll && controlledExpandedIds === undefined + ? collectBranchIds(data) + : new Set(), ); const isExpansionControlled = controlledExpandedIds !== undefined; diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 1b15138c70..17d119c103 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2882,11 +2882,15 @@ Best for: Documents with flowing, contextually connected content — such as boo 'Use a vision model to parse image and table blocks; when off, they are treated as plain text.', imageOcr: 'OCR', imageOcrTip: - 'Extract text from uploaded images with local OCR or the selected PaddleOCR model. Vision enhancement adds descriptions independently of this switch.', - imageOcrModel: 'OCR model', - imageOcrLocal: 'DeepDoc', - imageOcrModelTip: - 'Choose DeepDoc for local OCR, or an added PaddleOCR or PaddleOCR.local model. Turning OCR off keeps this choice but skips OCR; SVG text is read directly.', + 'Extract text from images. DeepDoc parses an image as a one-page document, so it includes layout analysis and table structure; an external OCR model returns text only. Vision enhancement adds descriptions independently of this switch.', + imageOcrParsingMethod: 'Parsing method', + imageOcrParsingMethodTip: + 'DeepDoc runs locally and performs OCR, layout analysis, and table structure recognition. An external OCR model uses an added PaddleOCR model and returns text only. Turning OCR off keeps this choice but skips OCR; SVG text is read directly.', + imageOcrDeepDoc: 'DeepDoc', + imageOcrDeepDocDescription: 'OCR + layout analysis + table structure', + imageOcrExternal: 'External OCR model', + imageOcrExternalDescription: 'Text only; no table structure', + imageOcrModelPlaceholder: 'Select an OCR model', imageOcrModelsError: 'Could not load OCR models. Your selection is unchanged. Try again later, or choose DeepDoc for local OCR.', imageOcrNoModels: diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 4a9dacba0e..4dc18357fc 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -2450,11 +2450,15 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取 Entities 和 R '所有解析后的 sections 会按原始顺序合并为 1 个 chunk。', imageOcr: 'OCR', imageOcrTip: - '使用本地 OCR 或所选 PaddleOCR 模型提取图片文字。视觉增强独立于该开关,为图片补充描述。', - imageOcrModel: 'OCR 模型', - imageOcrLocal: 'DeepDoc', - imageOcrModelTip: - '选择 DeepDoc 使用本地 OCR,或选择已添加的 PaddleOCR 或 PaddleOCR.local 模型。关闭 OCR 会保留选择但不执行 OCR;SVG 直接读取文字。', + '提取图片中的文字。DeepDoc 将图片视作单页文档解析,包含版面分析与表格结构;外部 OCR 模型仅返回文字。视觉增强独立于该开关,会为图片补充描述。', + imageOcrParsingMethod: '解析方式', + imageOcrParsingMethodTip: + 'DeepDoc 在本地运行,执行 OCR、版面分析与表格结构识别;外部 OCR 模型使用已添加的 PaddleOCR 模型,仅返回文字。关闭 OCR 会保留选择但不执行 OCR;SVG 直接读取文字。', + imageOcrDeepDoc: 'DeepDoc', + imageOcrDeepDocDescription: 'OCR + 版面分析 + 表格结构', + imageOcrExternal: '外部 OCR 模型', + imageOcrExternalDescription: '仅提取文字,无表格结构', + imageOcrModelPlaceholder: '选择 OCR 模型', imageOcrModelsError: 'OCR 模型加载失败,已保留当前选择。请稍后重试,或选择 DeepDoc 使用本地 OCR。', imageOcrNoModels: diff --git a/web/src/pages/agent/form/parser-form/image-form-fields.test.tsx b/web/src/pages/agent/form/parser-form/image-form-fields.test.tsx index a3aacbe7f7..71d9231c97 100644 --- a/web/src/pages/agent/form/parser-form/image-form-fields.test.tsx +++ b/web/src/pages/agent/form/parser-form/image-form-fields.test.tsx @@ -14,7 +14,13 @@ jest.mock('@/components/ui/message', () => ({ jest.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => (key === 'flow.imageOcrLocal' ? 'DeepDoc' : key), + t: (key: string) => { + const labels: Record = { + 'flow.imageOcrDeepDoc': 'DeepDoc', + 'flow.imageOcrExternal': 'External OCR model', + }; + return labels[key] ?? key; + }, }), })); @@ -101,7 +107,7 @@ beforeEach(() => { }); }); -describe('image OCR model selection', () => { +describe('image OCR engine selection', () => { const models = [ { model_id: 'local-b', @@ -133,37 +139,47 @@ describe('image OCR model selection', () => { }, ]; - it('defaults to local OCR and fetches the canvas owner models', () => { + it('defaults to DeepDoc and fetches the canvas owner models', () => { render(); - expect(screen.getByTestId('image-ocr-model')).toHaveTextContent('DeepDoc'); - expect(useFetchAllAddedModels).toHaveBeenCalledWith('ocr', 'canvas-owner'); + expect(screen.getByRole('radio', { name: 'DeepDoc' })).toBeChecked(); + expect( + screen.getByRole('radio', { name: 'External OCR model' }), + ).toBeDisabled(); + expect(screen.queryByTestId('image-ocr-model')).not.toBeInTheDocument(); + expect(useFetchAllAddedModels).toHaveBeenCalledWith( + undefined, + 'canvas-owner', + ); }); - it('names the built-in OCR model DeepDoc in English and Chinese', () => { - expect(en.translation.flow.imageOcrLocal).toBe('DeepDoc'); - expect(zh.translation.flow.imageOcrLocal).toBe('DeepDoc'); + it('names the built-in OCR engine DeepDoc in English and Chinese', () => { + expect(en.translation.flow.imageOcrDeepDoc).toBe('DeepDoc'); + expect(zh.translation.flow.imageOcrDeepDoc).toBe('DeepDoc'); }); - it('switches from an enrolled model back to DeepDoc without persisting a sentinel', () => { + it('auto-selects the first model for external and restores the last pick after a DeepDoc detour', () => { (useFetchAllAddedModels as jest.Mock).mockReturnValue({ data: models, isFetched: true, isError: false, }); render(); - fireEvent.click(screen.getByTestId('image-ocr-model')); - fireEvent.click(screen.getByText('PP-OCRv6')); + fireEvent.click(screen.getByRole('radio', { name: 'External OCR model' })); expect(screen.getByTestId('saved-values')).toHaveTextContent( '"ocr_model_id":"local-b"', ); fireEvent.click(screen.getByTestId('image-ocr-model')); - fireEvent.click(screen.getByText('DeepDoc')); - expect(screen.getByTestId('image-ocr-model')).toHaveTextContent('DeepDoc'); + fireEvent.click(screen.getByText('PaddleOCR-VL-1.5')); + expect(screen.getByTestId('saved-values')).toHaveTextContent( + '"ocr_model_id":"cloud-b"', + ); + fireEvent.click(screen.getByRole('radio', { name: 'DeepDoc' })); + expect(screen.getByRole('radio', { name: 'DeepDoc' })).toBeChecked(); + expect(screen.queryByTestId('image-ocr-model')).not.toBeInTheDocument(); expect(screen.getByTestId('saved-values')).toHaveTextContent( '"ocr_model_id":""', ); - fireEvent.click(screen.getByTestId('image-ocr-model')); - fireEvent.click(screen.getByText('PaddleOCR-VL-1.5')); + fireEvent.click(screen.getByRole('radio', { name: 'External OCR model' })); expect(screen.getByTestId('saved-values')).toHaveTextContent( '"ocr_model_id":"cloud-b"', ); @@ -175,8 +191,9 @@ describe('image OCR model selection', () => { 'flow.imageOcrNoModels', ); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - fireEvent.click(screen.getByTestId('image-ocr-model')); - expect(screen.getAllByText('DeepDoc')).toHaveLength(2); + expect( + screen.getByRole('radio', { name: 'External OCR model' }), + ).toBeDisabled(); }); it('shows a request failure and a missing saved model after loading ends', () => { @@ -201,6 +218,9 @@ describe('image OCR model selection', () => { expect(screen.getByRole('alert')).toHaveTextContent( 'flow.imageOcrModelsError', ); + expect( + screen.getByRole('radio', { name: 'External OCR model' }), + ).toBeChecked(); expect(screen.getByTestId('image-ocr-model')).toHaveTextContent( 'Missing: deleted-model', ); @@ -208,8 +228,7 @@ describe('image OCR model selection', () => { '"ocr_model_id":"deleted-model"', ); expect(screen.queryByRole('status')).not.toBeInTheDocument(); - fireEvent.click(screen.getByTestId('image-ocr-model')); - fireEvent.click(screen.getByText('DeepDoc')); + fireEvent.click(screen.getByRole('radio', { name: 'DeepDoc' })); expect(screen.getByTestId('saved-values')).toHaveTextContent( '"ocr_model_id":""', ); @@ -235,7 +254,7 @@ describe('image OCR model selection', () => { expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); - it('keeps DeepDoc selected when the first model request fails', () => { + it('keeps DeepDoc selected and external disabled when the first model request fails', () => { (useFetchAllAddedModels as jest.Mock).mockReturnValue({ data: [], isFetched: true, @@ -246,12 +265,13 @@ describe('image OCR model selection', () => { 'flow.imageOcrModelsError', ); expect(screen.queryByRole('status')).not.toBeInTheDocument(); - expect(screen.getByTestId('image-ocr-model')).toHaveTextContent('DeepDoc'); + expect(screen.getByRole('radio', { name: 'DeepDoc' })).toBeChecked(); + expect( + screen.getByRole('radio', { name: 'External OCR model' }), + ).toBeDisabled(); expect(screen.getByTestId('saved-values')).toHaveTextContent( '"ocr_model_id":""', ); - fireEvent.click(screen.getByTestId('image-ocr-model')); - expect(screen.getAllByText('DeepDoc')).toHaveLength(2); }); it('offers only supported enrolled OCR models and saves their IDs', () => { @@ -259,7 +279,7 @@ describe('image OCR model selection', () => { data: models, isFetched: true, }); - render(); + render(); fireEvent.click(screen.getByTestId('image-ocr-model')); expect(screen.queryByText('Unsupported OCR')).not.toBeInTheDocument(); expect(screen.queryByText('Not an OCR model')).not.toBeInTheDocument(); @@ -276,18 +296,25 @@ describe('image OCR model selection', () => { }); render(); fireEvent.click(screen.getByRole('switch')); + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); expect(screen.queryByTestId('image-ocr-model')).not.toBeInTheDocument(); expect(screen.getByTestId('saved-values')).toHaveTextContent( '"ocr_model_id":"cloud-b"', ); fireEvent.click(screen.getByRole('switch')); + expect( + screen.getByRole('radio', { name: 'External OCR model' }), + ).toBeChecked(); expect(screen.getByTestId('image-ocr-model')).toHaveTextContent( 'PaddleOCR-VL-1.5', ); }); - it('keeps a deleted model visible instead of silently choosing local OCR', () => { + it('keeps a deleted model visible instead of silently choosing DeepDoc', () => { render(); + expect( + screen.getByRole('radio', { name: 'External OCR model' }), + ).toBeChecked(); expect(screen.getByTestId('image-ocr-model')).toHaveTextContent( 'Missing: deleted-model', ); diff --git a/web/src/pages/agent/form/parser-form/image-form-fields.tsx b/web/src/pages/agent/form/parser-form/image-form-fields.tsx index 61700adb4c..2fd1910fd2 100644 --- a/web/src/pages/agent/form/parser-form/image-form-fields.tsx +++ b/web/src/pages/agent/form/parser-form/image-form-fields.tsx @@ -1,16 +1,13 @@ import { MissingModelLabel } from '@/components/llm-select/llm-label'; -import { buildModelTree } from '@/components/model-tree-select'; +import { useModelTree } from '@/components/model-tree-select'; import { RAGFlowFormItem } from '@/components/ragflow-form'; import { TreeSelect } from '@/components/tree-select'; import message from '@/components/ui/message'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Switch } from '@/components/ui/switch'; -import { useFetchAllAddedModels } from '@/hooks/use-llm-request'; -import { - ComponentPropsWithoutRef, - forwardRef, - useCallback, - useMemo, -} from 'react'; +import { cn } from '@/lib/utils'; +import { Cloud, LucideIcon, ScanText } from 'lucide-react'; +import { useCallback, useId, useMemo, useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useOwnerTenantId } from '../../context'; @@ -23,29 +20,16 @@ type ImageOcrSwitchProps = { onCheckedChange: (checked: boolean) => void; }; -const DeepDocModelId = '__image_ocr_deepdoc__'; +// The two OCR engines are a UI grouping over the backend contract: DeepDoc is +// an empty ocr_model_id, External is any enrolled PaddleOCR model ID. +const enum ImageOcrEngine { + DeepDoc = 'deepdoc', + External = 'external', +} -// Keep the UI-only option out of the form and serialized parser configuration. -const ImageOCRModelSelect = forwardRef< - HTMLButtonElement, - ComponentPropsWithoutRef ->(function ImageOCRModelSelect({ value, onChange, allowClear, ...props }, ref) { - const handleModelChange = useCallback( - (modelId: string) => { - onChange?.(modelId === DeepDocModelId ? '' : modelId); - }, - [onChange], - ); - return ( - - ); -}); +// Stable references: useModelTree keys its memoized tree on their identity. +const ImageOcrModelTypes = ['ocr']; +const ImageOcrProviders = ['PaddleOCR', 'PaddleOCR.local']; // OCR can only be turned off while the global vision enhancement is on: with // neither, the image carries no text and the backend rejects the canvas. @@ -71,27 +55,85 @@ function ImageOcrSwitch({ checked, onCheckedChange }: ImageOcrSwitchProps) { return ; } +type OcrEngineCardProps = { + id: string; + value: ImageOcrEngine; + checked: boolean; + disabled?: boolean; + icon: LucideIcon; + title: string; + description: string; +}; + +function OcrEngineCard({ + id, + value, + checked, + disabled, + icon: Icon, + title, + description, +}: OcrEngineCardProps) { + return ( + + ); +} + /** Lists enrolled PaddleOCR models using the canvas owner's tenant context. */ -function ImageOCRModelField({ prefix }: CommonProps) { +function ImageOcrEngineFields({ prefix }: CommonProps) { const { t } = useTranslation(); const ownerTenantId = useOwnerTenantId(); - const { data, isFetched, isError } = useFetchAllAddedModels( - 'ocr', + const groupId = useId(); + const { + treeData: modelTree, + models, + isFetched, + isError, + } = useModelTree({ + modelTypes: ImageOcrModelTypes, + providers: ImageOcrProviders, ownerTenantId, + }); + // Auto-pick and restore need tenant UUIDs; entries without a model_id still + // appear in the tree through buildModelTree's legacy id fallback. + const ocrModels = useMemo( + () => models.filter((model) => model.model_id), + [models], ); - const models = useMemo( - () => - buildModelTree( - data.filter( - (model) => - model.model_id && - ['paddleocr', 'paddleocr.local'].includes( - model.provider_name.toLowerCase(), - ), - ), - ['ocr'], - ), - [data], + const modelIds = useMemo( + () => new Set(ocrModels.map((model) => model.model_id)), + [ocrModels], ); const renderMissingModel = useCallback( (value: string) => ( @@ -99,40 +141,91 @@ function ImageOCRModelField({ prefix }: CommonProps) { ), [ownerTenantId], ); - const options = useMemo( - () => [{ id: DeepDocModelId, title: t('flow.imageOcrLocal') }, ...models], - [models, t], - ); + // The last external model survives a DeepDoc detour so switching back + // restores it instead of silently landing on the first listed model. + const [lastExternalModel, setLastExternalModel] = useState(''); + const description = isError ? ( {t('flow.imageOcrModelsError')} - ) : isFetched && models.length === 0 ? ( + ) : isFetched && ocrModels.length === 0 ? ( {t('flow.imageOcrNoModels')} ) : undefined; return ( - {(field) => ( - - )} + {(field) => { + const engine = field.value + ? ImageOcrEngine.External + : ImageOcrEngine.DeepDoc; + // External needs a model to point at: stay disabled until the list + // arrives, and when it is empty or failed to load. + const externalDisabled = + !field.value && (!isFetched || ocrModels.length === 0); + const handleEngineChange = (next: string) => { + if (next === ImageOcrEngine.DeepDoc) { + if (field.value) { + setLastExternalModel(field.value); + } + field.onChange(''); + return; + } + const restored = + lastExternalModel && modelIds.has(lastExternalModel) + ? lastExternalModel + : ocrModels[0]?.model_id; + if (restored) { + field.onChange(restored); + } + }; + return ( +
+ + + + + {engine === ImageOcrEngine.External && ( + + )} +
+ ); + }}
); } @@ -161,7 +254,7 @@ export function ImageFormFields({ prefix }: CommonProps) { /> )} - {ocrEnabled && } + {ocrEnabled && } ); }