Feat: Support pipeline-related configuration at the document level. (#17212)

### Summary

Feat: Support pipeline-related configuration at the document level.
This commit is contained in:
balibabu
2026-07-22 22:24:50 +08:00
committed by GitHub
parent dccc0a4bee
commit b3d394954d
19 changed files with 702 additions and 263 deletions

View File

@@ -0,0 +1,67 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useFetchBuiltinPipelines } from '@/hooks/use-agent-request';
import { cn } from '@/lib/utils';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export function BuiltinPipelineItem({
line = 2,
name = 'parser_id',
}: {
line?: 1 | 2;
name?: string;
}) {
const { t } = useTranslation();
const form = useFormContext();
const { options: builtinPipelineOptions } = useFetchBuiltinPipelines();
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="items-center space-y-0">
<div
className={cn('flex', {
'items-center': line === 1,
'flex-col gap-1': line === 2,
})}
>
<FormLabel
required
className={cn('text-sm whitespace-wrap', {
'w-1/4': line === 1,
})}
>
{t('knowledgeConfiguration.builtIn')}
</FormLabel>
<div
className={cn('text-muted-foreground', { 'w-3/4': line === 1 })}
>
<FormControl>
<SelectWithSearch
{...field}
placeholder={t(
'knowledgeConfiguration.chunkMethodPlaceholder',
)}
options={builtinPipelineOptions}
/>
</FormControl>
</div>
</div>
<div className="flex pt-1">
<div className={line === 1 ? 'w-1/4' : ''}></div>
<FormMessage />
</div>
</FormItem>
)}
/>
);
}

View File

@@ -24,7 +24,6 @@ import {
ChunkMethodItem,
EnableTocToggle,
ImageContextWindow,
ParseTypeItem,
} from '@/pages/dataset/dataset-setting/configuration/common-item';
import { zodResolver } from '@hookform/resolvers/zod';
import omit from 'lodash/omit';
@@ -44,6 +43,7 @@ import { ExcelToHtmlFormField } from '../excel-to-html-form-field';
import { LayoutRecognizeFormField } from '../layout-recognize-form-field';
import { MaxTokenNumberFormField } from '../max-token-number-from-field';
import { MinerUOptionsFormField } from '../mineru-options-form-field';
import { ParseTypeItem } from '../parse-type-form-field';
import { ButtonLoading } from '../ui/button';
import { Input } from '../ui/input';
import { DynamicPageRange } from './dynamic-page-range';

View File

@@ -0,0 +1,109 @@
import { BuiltinPipelineItem } from '@/components/builtin-pipeline-form-field';
import { DataFlowSelect } from '@/components/data-pipeline-select';
import { ParseTypeItem } from '@/components/parse-type-form-field';
import PipelineOperatorTabs from '@/components/pipeline-operator-tabs';
import { ButtonLoading } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Form } from '@/components/ui/form';
import { ParseType } from '@/constants/knowledge';
import { IModalProps } from '@/interfaces/common';
import { IChangeParserRequestBody } from '@/interfaces/request/document';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
IDocumentPipelineDialogProps,
useDocumentPipelineForm,
} from './use-document-pipeline-form';
const FormId = 'DocumentPipelineDialogForm';
interface IProps
extends IModalProps<IChangeParserRequestBody>, IDocumentPipelineDialogProps {
loading: boolean;
}
export function DocumentPipelineDialog({
hideModal,
onOk,
parserId,
pipelineId,
parserConfig,
loading,
}: IProps) {
const { t } = useTranslation();
const {
form,
parseType,
operatorNodes,
operatorNodesLoading,
activeTab,
setActiveTab,
handleOperatorValuesChange,
showOperatorTabs,
buildSubmitData,
} = useDocumentPipelineForm({ parserId, pipelineId, parserConfig });
const onSubmit = useCallback(
async (data: Parameters<typeof buildSubmitData>[0]) => {
const ret = await onOk?.(buildSubmitData(data));
if (ret) {
hideModal?.();
}
},
[buildSubmitData, hideModal, onOk],
);
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent className="max-w-[50vw] text-text-primary">
<DialogHeader>
<DialogTitle>{t('knowledgeDetails.chunkMethod')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 max-h-[70vh] overflow-auto -mx-6 px-10 py-5"
id={FormId}
>
<ParseTypeItem />
{parseType === ParseType.BuiltIn && <BuiltinPipelineItem />}
{parseType === ParseType.Pipeline && (
<DataFlowSelect
isMult={false}
showToDataPipeline={true}
formFieldName="pipeline_id"
/>
)}
{showOperatorTabs && (
<PipelineOperatorTabs
nodes={operatorNodes}
value={activeTab}
onValueChange={setActiveTab}
onOperatorValuesChange={handleOperatorValuesChange}
/>
)}
</form>
</Form>
<DialogFooter>
<ButtonLoading
type="submit"
form={FormId}
loading={
loading || (operatorNodesLoading && operatorNodes.length === 0)
}
>
{t('common.save')}
</ButtonLoading>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,204 @@
import { ParseType } from '@/constants/knowledge';
import {
useActiveTab,
usePipelineOperatorNodes,
useResetParserConfigOnPipelineChange,
} from '@/hooks/use-pipeline-operator';
import { IChangeParserRequestBody } from '@/interfaces/request/document';
import {
getOperatorType,
transformApiConfigToForm,
transformFormConfigToApi,
} from '@/utils/pipeline-operator';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useEffect, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
export interface IDocumentPipelineDialogProps {
parserId: string;
pipelineId?: string;
parserConfig?: Record<string, any>;
}
/**
* Converts the saved parser_config (API format, keyed by operator id) to the
* form format expected by the operator tabs, mirroring
* useFetchDatasetSettingOnMount on the dataset setting page.
*/
function transformSavedParserConfigToForm(
parserConfig?: Record<string, any>,
): Record<string, any> {
if (
!parserConfig ||
typeof parserConfig !== 'object' ||
Array.isArray(parserConfig)
) {
return {};
}
const hasPipelineKeys = Object.keys(parserConfig).some((key) =>
key.includes(':'),
);
if (!hasPipelineKeys) {
return parserConfig;
}
const formParserConfig: Record<string, any> = {};
for (const [operatorId, config] of Object.entries(parserConfig)) {
formParserConfig[operatorId] = transformApiConfigToForm(
getOperatorType(operatorId),
config as Record<string, any>,
);
}
return formParserConfig;
}
export function useDocumentPipelineForm({
parserId,
pipelineId,
parserConfig,
}: IDocumentPipelineDialogProps) {
const { t } = useTranslation();
const FormSchema = useMemo(
() =>
z
.object({
parseType: z.nativeEnum(ParseType),
parser_id: z.string(),
pipeline_id: z.string().optional(),
parser_config: z.record(z.string(), z.any()).optional(),
})
.superRefine((data, ctx) => {
if (data.parseType === ParseType.BuiltIn && !data.parser_id.trim()) {
ctx.addIssue({
path: ['parser_id'],
message: t('common.pleaseSelect'),
code: 'custom',
});
}
if (data.parseType === ParseType.Pipeline && !data.pipeline_id) {
ctx.addIssue({
path: ['pipeline_id'],
message: t('common.pleaseSelect'),
code: 'custom',
});
}
}),
[t],
);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
parseType: pipelineId ? ParseType.Pipeline : ParseType.BuiltIn,
parser_id: parserId || '',
pipeline_id: pipelineId || '',
parser_config: transformSavedParserConfigToForm(parserConfig),
},
});
const parseType = useWatch({
control: form.control,
name: 'parseType',
});
const selectedDataFlowId = useWatch({
control: form.control,
name: 'pipeline_id',
});
const selectedBuiltinId = useWatch({
control: form.control,
name: 'parser_id',
});
const isPipelineMode = parseType === ParseType.Pipeline;
const selectedPipelineId = isPipelineMode
? selectedDataFlowId
: selectedBuiltinId;
// The saved parser_config only belongs to the pipeline (and parse type) it
// was saved with — expose its id only while that exact pipeline is selected,
// so that after switching, defaults come purely from the new pipeline's DSL.
const savedParseType = pipelineId ? ParseType.Pipeline : ParseType.BuiltIn;
const savedPipelineId =
parseType === savedParseType
? isPipelineMode
? pipelineId
: parserId
: undefined;
const { operatorNodes, loading: operatorNodesLoading } =
usePipelineOperatorNodes(
selectedPipelineId,
selectedPipelineId && selectedPipelineId === savedPipelineId
? parserConfig
: undefined,
!isPipelineMode,
);
useResetParserConfigOnPipelineChange(
form,
selectedPipelineId,
savedPipelineId,
operatorNodes,
);
const { activeTab, setActiveTab } = useActiveTab(operatorNodes);
useEffect(() => {
if (parseType === ParseType.BuiltIn) {
form.setValue('pipeline_id', '');
}
}, [parseType, form]);
const handleOperatorValuesChange = useCallback(
(operatorId: string, values: any) => {
const currentParserConfig = form.getValues('parser_config') || {};
form.setValue('parser_config', {
...currentParserConfig,
[operatorId]: values,
});
},
[form],
);
const showOperatorTabs =
operatorNodes.length > 0 &&
((parseType === ParseType.Pipeline && !!selectedDataFlowId) ||
(parseType === ParseType.BuiltIn && !!selectedBuiltinId));
const buildSubmitData = useCallback(
(data: z.infer<typeof FormSchema>): IChangeParserRequestBody => {
const transformedConfig: Record<string, any> = {};
for (const [operatorId, config] of Object.entries(
data.parser_config ?? {},
)) {
transformedConfig[operatorId] = transformFormConfigToApi(
getOperatorType(operatorId),
config as Record<string, any>,
);
}
const isPipeline = data.parseType === ParseType.Pipeline;
return {
parser_id: isPipeline ? '' : data.parser_id,
pipeline_id: isPipeline ? data.pipeline_id : '',
parser_config: transformedConfig,
};
},
[],
);
return {
form,
parseType,
operatorNodes,
operatorNodesLoading,
activeTab,
setActiveTab,
handleOperatorValuesChange,
showOperatorTabs,
buildSubmitData,
};
}

View File

@@ -0,0 +1,73 @@
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Radio } from '@/components/ui/radio';
import { ParseType } from '@/constants/knowledge';
import { cn } from '@/lib/utils';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export function ParseTypeItem({
line = 2,
name = 'parseType',
}: {
line?: number;
name?: string;
}) {
const { t } = useTranslation();
const form = useFormContext();
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="items-center space-y-0">
<div
className={cn('flex', {
'items-center': line === 1,
'flex-col gap-1': line === 2,
})}
>
<FormLabel
className={cn('text-sm whitespace-wrap ', {
'w-1/4': line === 1,
})}
>
{t('knowledgeConfiguration.parseType')}
</FormLabel>
<div
className={cn('text-muted-foreground', { 'w-3/4': line === 1 })}
>
<FormControl>
<Radio.Group {...field}>
<div
className={cn(
'flex gap-2 justify-between text-muted-foreground',
line === 1 ? 'w-1/2' : 'w-3/4',
)}
>
<Radio value={ParseType.BuiltIn}>
{t('knowledgeConfiguration.builtIn')}
</Radio>
<Radio value={ParseType.Pipeline}>
{t('knowledgeConfiguration.manualSetup')}
</Radio>
</div>
</Radio.Group>
</FormControl>
</div>
</div>
<div className="flex pt-1">
<div className={line === 1 ? 'w-1/4' : ''}></div>
<FormMessage />
</div>
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,66 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RAGFlowNodeType } from '@/interfaces/database/agent';
import { memo, useCallback } from 'react';
import PipelineOperatorForm from './pipeline-operator-form';
type PipelineOperatorTabsProps = {
nodes: RAGFlowNodeType[];
value: string;
onValueChange: (value: string) => void;
onOperatorValuesChange: (operatorId: string, values: any) => void;
};
const PipelineOperatorTabs = ({
nodes,
value,
onValueChange,
onOperatorValuesChange,
}: PipelineOperatorTabsProps) => {
const getOperatorId = useCallback((node: RAGFlowNodeType) => {
return (
(node.data as Record<string, any>)?.operatorId || node.data?.label || ''
);
}, []);
const getTabValue = useCallback(
(node: RAGFlowNodeType, index: number) => {
return getOperatorId(node) || String(index);
},
[getOperatorId],
);
const handleValuesChange = useCallback(
(node: RAGFlowNodeType) => (values: any) => {
onOperatorValuesChange(getOperatorId(node), values);
},
[getOperatorId, onOperatorValuesChange],
);
return (
<Tabs value={value} onValueChange={onValueChange} className="w-full">
<TabsList className="w-full justify-start">
{nodes.map((node, index) => {
const tabValue = getTabValue(node, index);
return (
<TabsTrigger key={tabValue} value={tabValue}>
{node.data?.name || node.data?.label || tabValue}
</TabsTrigger>
);
})}
</TabsList>
{nodes.map((node, index) => {
const tabValue = getTabValue(node, index);
return (
<TabsContent key={tabValue} value={tabValue}>
<PipelineOperatorForm
node={node}
onValuesChange={handleValuesChange(node)}
/>
</TabsContent>
);
})}
</Tabs>
);
};
export default memo(PipelineOperatorTabs);

View File

@@ -0,0 +1,77 @@
import { Operator } from '@/constants/agent';
import { RAGFlowNodeType } from '@/interfaces/database/agent';
import ExtractorForm from '@/pages/agent/form/extractor-form';
import ParserForm from '@/pages/agent/form/parser-form';
import TitleChunkerForm from '@/pages/agent/form/title-chunker-form';
import TokenChunkerForm from '@/pages/agent/form/token-chunker-form';
import TokenizerForm from '@/pages/agent/form/tokenizer-form';
import { getOperatorType } from '@/utils/pipeline-operator';
import { memo, useCallback } from 'react';
type PipelineOperatorFormProps = {
node: RAGFlowNodeType;
onValuesChange?: (values: any) => void;
};
const PipelineOperatorForm = ({
node,
onValuesChange,
}: PipelineOperatorFormProps) => {
const operatorType = getOperatorType(
(node.data as Record<string, any>)?.operatorId || node.data?.label || '',
);
const handleValuesChange = useCallback(
(values: any) => {
onValuesChange?.(values);
},
[onValuesChange],
);
switch (operatorType) {
case Operator.Parser:
return (
<ParserForm
node={node}
onValuesChange={handleValuesChange}
hideOutputs
/>
);
case Operator.TokenChunker:
return (
<TokenChunkerForm
node={node}
onValuesChange={handleValuesChange}
hideOutputs
/>
);
case Operator.TitleChunker:
return (
<TitleChunkerForm
node={node}
onValuesChange={handleValuesChange}
hideOutputs
/>
);
case Operator.Extractor:
return (
<ExtractorForm
node={node}
onValuesChange={handleValuesChange}
hideOutputs
/>
);
case Operator.Tokenizer:
return (
<TokenizerForm
node={node}
onValuesChange={handleValuesChange}
hideOutputs
/>
);
default:
return null;
}
};
export default memo(PipelineOperatorForm);