mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 21:32:43 +08:00
Feat: Image parsing: DeepDoc is distinguished from other OCR models and is offered as a separate option. (#22047)
This commit is contained in:
1 parent
4d20ef45f6
commit
ed3c091f5d
8 files changed
+352
-148
No files matched your search
@@ -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' }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex justify-between items-center gap-2 w-full">
|
||||
<span className="flex items-center gap-1.5 truncate">{node.label}</span>
|
||||
<span className="text-state-error text-sm flex-shrink-0">
|
||||
Experimental
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) => (
|
||||
<div className="flex justify-between items-center gap-2 w-full">
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
{node.label}
|
||||
</span>
|
||||
<span className="text-state-error text-sm flex-shrink-0">
|
||||
Experimental
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
return [...prependNodes, ...modelTree];
|
||||
}, [allAddedModels, optionsWithoutLLM, t]);
|
||||
}, [modelTree, optionsWithoutLLM, t]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
|
||||
@@ -43,6 +43,19 @@ export const ModelTypeMap: Record<string, string[]> = {
|
||||
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<string>();
|
||||
const providerMap = new Map<string, Map<string, IAddedModel[]>>();
|
||||
@@ -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
|
||||
|
||||
@@ -285,8 +285,15 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(
|
||||
) {
|
||||
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<Set<string>>(
|
||||
new Set(),
|
||||
() =>
|
||||
defaultExpandAll && controlledExpandedIds === undefined
|
||||
? collectBranchIds(data)
|
||||
: new Set(),
|
||||
);
|
||||
|
||||
const isExpansionControlled = controlledExpandedIds !== undefined;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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(<ParserImageForm />);
|
||||
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(<ParserImageForm />);
|
||||
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(<ParserImageForm />);
|
||||
render(<ParserImageForm modelId="cloud-b" />);
|
||||
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(<ParserImageForm visionEnabled modelId="cloud-b" />);
|
||||
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(<ParserImageForm modelId="deleted-model" />);
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'External OCR model' }),
|
||||
).toBeChecked();
|
||||
expect(screen.getByTestId('image-ocr-model')).toHaveTextContent(
|
||||
'Missing: deleted-model',
|
||||
);
|
||||
|
||||
@@ -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<typeof TreeSelect>
|
||||
>(function ImageOCRModelSelect({ value, onChange, allowClear, ...props }, ref) {
|
||||
const handleModelChange = useCallback(
|
||||
(modelId: string) => {
|
||||
onChange?.(modelId === DeepDocModelId ? '' : modelId);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
return (
|
||||
<TreeSelect
|
||||
{...props}
|
||||
ref={ref}
|
||||
value={value || DeepDocModelId}
|
||||
onChange={handleModelChange}
|
||||
allowClear={allowClear && !!value}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// 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 <Switch checked={checked} onCheckedChange={handleCheckedChange} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border px-3 py-2.5 transition-colors',
|
||||
checked
|
||||
? 'border-accent-primary bg-accent-primary-5'
|
||||
: 'border-border-button',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:border-border-default',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={value}
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
aria-label={title}
|
||||
className="mt-0.5 data-[state=checked]:border-accent-primary data-[state=checked]:text-accent-primary"
|
||||
/>
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0',
|
||||
checked ? 'text-accent-primary' : 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-text-primary">
|
||||
{title}
|
||||
</span>
|
||||
<span className="block text-xs text-text-secondary">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 ? (
|
||||
<span role="alert" className="text-state-error">
|
||||
{t('flow.imageOcrModelsError')}
|
||||
</span>
|
||||
) : isFetched && models.length === 0 ? (
|
||||
) : isFetched && ocrModels.length === 0 ? (
|
||||
<span role="status">{t('flow.imageOcrNoModels')}</span>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<RAGFlowFormItem
|
||||
name={buildFieldNameWithPrefix('ocr_model_id', prefix)}
|
||||
label={t('flow.imageOcrModel')}
|
||||
tooltip={t('flow.imageOcrModelTip')}
|
||||
label={t('flow.imageOcrParsingMethod')}
|
||||
tooltip={t('flow.imageOcrParsingMethodTip')}
|
||||
description={description}
|
||||
>
|
||||
{(field) => (
|
||||
<ImageOCRModelSelect
|
||||
ref={field.ref}
|
||||
data={options}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t('flow.imageOcrLocal')}
|
||||
loading={!isFetched}
|
||||
renderMissingValue={renderMissingModel}
|
||||
allowClear
|
||||
showSearch
|
||||
defaultExpandAll
|
||||
testId="image-ocr-model"
|
||||
/>
|
||||
)}
|
||||
{(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 (
|
||||
<div className="w-full space-y-2">
|
||||
<RadioGroup
|
||||
value={engine}
|
||||
onValueChange={handleEngineChange}
|
||||
className="gap-2"
|
||||
aria-label={t('flow.imageOcrParsingMethod')}
|
||||
>
|
||||
<OcrEngineCard
|
||||
id={`${groupId}-deepdoc`}
|
||||
value={ImageOcrEngine.DeepDoc}
|
||||
checked={engine === ImageOcrEngine.DeepDoc}
|
||||
icon={ScanText}
|
||||
title={t('flow.imageOcrDeepDoc')}
|
||||
description={t('flow.imageOcrDeepDocDescription')}
|
||||
/>
|
||||
<OcrEngineCard
|
||||
id={`${groupId}-external`}
|
||||
value={ImageOcrEngine.External}
|
||||
checked={engine === ImageOcrEngine.External}
|
||||
disabled={externalDisabled}
|
||||
icon={Cloud}
|
||||
title={t('flow.imageOcrExternal')}
|
||||
description={t('flow.imageOcrExternalDescription')}
|
||||
/>
|
||||
</RadioGroup>
|
||||
{engine === ImageOcrEngine.External && (
|
||||
<TreeSelect
|
||||
data={modelTree}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t('flow.imageOcrModelPlaceholder')}
|
||||
loading={!isFetched}
|
||||
renderMissingValue={renderMissingModel}
|
||||
showSearch
|
||||
defaultExpandAll
|
||||
testId="image-ocr-model"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</RAGFlowFormItem>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +254,7 @@ export function ImageFormFields({ prefix }: CommonProps) {
|
||||
/>
|
||||
)}
|
||||
</RAGFlowFormItem>
|
||||
{ocrEnabled && <ImageOCRModelField prefix={prefix} />}
|
||||
{ocrEnabled && <ImageOcrEngineFields prefix={prefix} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user