diff --git a/web/src/assets/svg/data-source/discord.svg b/web/src/assets/svg/data-source/discord.svg new file mode 100644 index 000000000..2b4388101 --- /dev/null +++ b/web/src/assets/svg/data-source/discord.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/web/src/assets/svg/data-source/notion.svg b/web/src/assets/svg/data-source/notion.svg new file mode 100644 index 000000000..0ce75107c --- /dev/null +++ b/web/src/assets/svg/data-source/notion.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/svg/data-source/s3.svg b/web/src/assets/svg/data-source/s3.svg new file mode 100644 index 000000000..b897abe25 --- /dev/null +++ b/web/src/assets/svg/data-source/s3.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/web/src/components/back-button/index.tsx b/web/src/components/back-button/index.tsx new file mode 100644 index 000000000..81c08532e --- /dev/null +++ b/web/src/components/back-button/index.tsx @@ -0,0 +1,41 @@ +import { cn } from '@/lib/utils'; +import { ArrowBigLeft } from 'lucide-react'; +import React from 'react'; +import { useNavigate } from 'umi'; +import { Button } from '../ui/button'; + +interface BackButtonProps + extends React.ButtonHTMLAttributes { + to?: string; +} + +const BackButton: React.FC = ({ + to, + className, + children, + ...props +}) => { + const navigate = useNavigate(); + + const handleClick = () => { + if (to) { + navigate(to); + } else { + navigate(-1); + } + }; + + return ( + + ); +}; + +export default BackButton; diff --git a/web/src/components/file-status-badge.tsx b/web/src/components/file-status-badge.tsx index 61ebf5c39..0ef75bc35 100644 --- a/web/src/components/file-status-badge.tsx +++ b/web/src/components/file-status-badge.tsx @@ -8,9 +8,10 @@ interface StatusBadgeProps { // status: 'Success' | 'Failed' | 'Running' | 'Pending'; status: RunningStatus; name?: string; + className?: string; } -const FileStatusBadge: FC = ({ status, name }) => { +const FileStatusBadge: FC = ({ status, name, className }) => { const getStatusColor = () => { // #3ba05c → rgb(59, 160, 92) // state-success // #d8494b → rgb(216, 73, 75) // state-error @@ -51,7 +52,7 @@ const FileStatusBadge: FC = ({ status, name }) => { return (
{name || ''} diff --git a/web/src/components/ragflow-form.tsx b/web/src/components/ragflow-form.tsx index 4b1b06943..c59776824 100644 --- a/web/src/components/ragflow-form.tsx +++ b/web/src/components/ragflow-form.tsx @@ -39,7 +39,7 @@ export function RAGFlowFormItem({ { [navigate], ); + const navigateToDataSourceDetail = useCallback( + (id?: string) => { + navigate( + `${Routes.UserSetting}${Routes.DataSource}${Routes.DataSourceDetailPage}?id=${id}`, + ); + }, + [navigate], + ); + const navigateToDataflowResult = useCallback( (props: NavigateToDataflowResultProps) => () => { let params: string[] = []; Object.keys(props).forEach((key) => { - if (props[key]) { - params.push(`${key}=${props[key]}`); + if (props[key as keyof typeof props]) { + params.push(`${key}=${props[key as keyof typeof props]}`); } }); navigate( @@ -179,5 +188,6 @@ export const useNavigatePage = () => { navigateToOldProfile, navigateToDataflowResult, navigateToDataFile, + navigateToDataSourceDetail, }; }; diff --git a/web/src/interfaces/database/knowledge.ts b/web/src/interfaces/database/knowledge.ts index bc82ed24c..a88505f10 100644 --- a/web/src/interfaces/database/knowledge.ts +++ b/web/src/interfaces/database/knowledge.ts @@ -1,6 +1,12 @@ import { RunningStatus } from '@/constants/knowledge'; +import { DataSourceKey } from '@/pages/user-setting/data-source/contant'; import { TreeData } from '@antv/g6/lib/types'; - +export interface IConnector { + id: string; + name: string; + status: RunningStatus; + source: DataSourceKey; +} // knowledge base export interface IKnowledge { avatar?: any; @@ -35,6 +41,7 @@ export interface IKnowledge { mindmap_task_id?: string; graphrag_task_finish_at: string; graphrag_task_id: string; + connectors: IConnector[]; } export interface IKnowledgeResult { diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 1f3d39ce2..e411691fe 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -274,6 +274,9 @@ export default { reRankModelWaring: 'Re-rank model is very time consuming.', }, knowledgeConfiguration: { + dataSource: 'Data Source', + linkSourceSetTip: 'Manage data source linkage with this dataset', + linkDataSource: 'Link Data Source', tocExtraction: 'TOC Enhance', tocExtractionTip: " For existing chunks, generate a hierarchical table of contents (one directory per file). During queries, when Directory Enhancement is activated, the system will use a large model to determine which directory items are relevant to the user's question, thereby identifying the relevant chunks.", @@ -680,6 +683,19 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s tocEnhanceTip: ` During the parsing of the document, table of contents information was generated (see the 'Enable Table of Contents Extraction' option in the General method). This allows the large model to return table of contents items relevant to the user's query, thereby using these items to retrieve related chunks and apply weighting to these chunks during the sorting process. This approach is derived from mimicking the behavioral logic of how humans search for knowledge in books.`, }, setting: { + errorMsg: 'Error message', + newDocs: 'New Docs', + timeStarted: 'Time started', + log: 'Log', + s3Description: + 'Connect to your AWS S3 bucket to import and sync stored files.', + discordDescription: + 'Link your Discord server to access and analyze chat data.', + notionDescription: + 'Sync pages and databases from Notion for knowledge retrieval.', + availableSourcesDescription: 'Select a data source to add', + availableSources: 'Available Sources', + datasourceDescription: 'Manage your data source and connections', save: 'Save', search: 'Search', availableModels: 'Available models', @@ -697,6 +713,7 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s 'Please enter your current password to change your password.', model: 'Model providers', systemModelDescription: 'Please complete these settings before beginning', + dataSources: 'Data Sources', team: 'Team', system: 'System', logout: 'Log out', @@ -1837,12 +1854,16 @@ Important structured information may include: names, dates, locations, events, k changeStepModalConfirmText: 'Switch Anyway', changeStepModalCancelText: 'Cancel', unlinkPipelineModalTitle: 'Unlink Ingestion pipeline', + unlinkPipelineModalConfirmText: 'Unlink', unlinkPipelineModalContent: `

Once unlinked, this Dataset will no longer be connected to the current Ingestion pipeline.

Files that are already being parsed will continue until completion

Files that are not yet parsed will no longer be processed


Are you sure you want to proceed?

`, - unlinkPipelineModalConfirmText: 'Unlink', + unlinkSourceModalTitle: 'Unlink data source', + unlinkSourceModalContent: ` +

Are you sure to unlink this data source ?

`, + unlinkSourceModalConfirmText: 'Unlink', }, datasetOverview: { downloadTip: 'Files being downloaded from data sources. ', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index a8a7632f9..a637ee1db 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -260,6 +260,9 @@ export default { theDocumentBeingParsedCannotBeDeleted: '正在解析的文档不能被删除', }, knowledgeConfiguration: { + dataSource: '数据源', + linkSourceSetTip: '管理与此数据集的数据源链接', + linkDataSource: '链接数据源', tocExtractionTip: '对于已有的chunk生成层级结构的目录信息(每个文件一个目录)。在查询时,激活`目录增强`后,系统会用大模型去判断用户问题和哪些目录项相关,从而找到相关的chunk。', deleteGenerateModalContent: ` @@ -671,6 +674,16 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 tocEnhanceTip: `解析文档时生成了目录信息(见General方法的‘启用目录抽取’),让大模型返回和用户问题相关的目录项,从而利用目录项拿到相关chunk,对这些chunk在排序中进行加权。这种方法来源于模仿人类查询书本中知识的行为逻辑`, }, setting: { + errorMsg: '错误信息', + newDocs: '新文档', + timeStarted: '开始时间', + log: '日志', + s3Description: ' 连接你的 AWS S3 存储桶以导入和同步文件。', + discordDescription: ' 连接你的 Discord 服务器以访问和分析聊天数据。', + notionDescription: ' 同步 Notion 页面与数据库,用于知识检索。', + availableSourcesDescription: '选择要添加的数据源', + availableSources: '可用数据源', + datasourceDescription: '管理您的数据源和连接', save: '保存', search: '搜索', availableModels: '可选模型', @@ -688,6 +701,7 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 passwordDescription: '请输入您当前的密码以更改您的密码。', model: '模型提供商', systemModelDescription: '请在开始之前完成这些设置', + dataSources: '数据源', team: '团队', system: '系统', logout: '登出', @@ -1731,6 +1745,10 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,

尚未解析的文件将不再被处理。


你确定要继续吗?

`, unlinkPipelineModalConfirmText: '解绑', + unlinkSourceModalTitle: '取消链接数据源', + unlinkSourceModalContent: ` +

您确定要取消链接此数据源吗?

`, + unlinkSourceModalConfirmText: '取消链接', }, datasetOverview: { downloadTip: '正在从数据源下载文件。', diff --git a/web/src/pages/dataset/dataset-setting/components/added-source-card.tsx b/web/src/pages/dataset/dataset-setting/components/added-source-card.tsx new file mode 100644 index 000000000..d8936bed7 --- /dev/null +++ b/web/src/pages/dataset/dataset-setting/components/added-source-card.tsx @@ -0,0 +1,97 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { + IDataSorceInfo, + IDataSourceBase, +} from '@/pages/user-setting/data-source/interface'; +import { Check } from 'lucide-react'; +import { useMemo } from 'react'; + +export type IAddedSourceCardProps = IDataSorceInfo & { + filterString: string; + list: IDataSourceBase[]; + selectedList: IDataSourceBase[]; + setSelectedList: (list: IDataSourceBase[]) => void; +}; +export const AddedSourceCard = (props: IAddedSourceCardProps) => { + const { + list: originList, + name, + icon, + filterString, + selectedList, + setSelectedList, + } = props; + + const list = useMemo(() => { + return originList.map((item) => { + const checked = selectedList?.some((i) => i.id === item.id) || false; + return { + ...item, + checked: checked, + }; + }); + }, [originList, selectedList]); + + const filterList = useMemo( + () => list.filter((item) => item.name.indexOf(filterString) > -1), + [filterString, list], + ); + + // const { navigateToDataSourceDetail } = useNavigatePage(); + // const toDetail = (id: string) => { + // navigateToDataSourceDetail(id); + // }; + + const onCheck = (item: IDataSourceBase & { checked: boolean }) => { + if (item.checked) { + setSelectedList(selectedList.filter((i) => i.id !== item.id)); + } else { + setSelectedList([...(selectedList || []), item]); + } + }; + return ( + <> + {filterList.length > 0 && ( + + + {/* */} + + {icon} + {name} + + + + {filterList.map((item) => ( +
{ + console.log('item--->', item); + // toDetail(item.id); + onCheck(item); + }} + > +
{item.name}
+
+ {item.checked && ( + { + // toDetail(item.id); + // }} + /> + )} +
+
+ ))} +
+
+ )} + + ); +}; diff --git a/web/src/pages/dataset/dataset-setting/components/link-data-source-modal.tsx b/web/src/pages/dataset/dataset-setting/components/link-data-source-modal.tsx new file mode 100644 index 000000000..fa32a697d --- /dev/null +++ b/web/src/pages/dataset/dataset-setting/components/link-data-source-modal.tsx @@ -0,0 +1,86 @@ +import { Button } from '@/components/ui/button'; +import { SearchInput } from '@/components/ui/input'; +import { Modal } from '@/components/ui/modal/modal'; +import { IConnector } from '@/interfaces/database/knowledge'; +import { useListDataSource } from '@/pages/user-setting/data-source/hooks'; +import { IDataSourceBase } from '@/pages/user-setting/data-source/interface'; +import { t } from 'i18next'; +import { useEffect, useState } from 'react'; +import { AddedSourceCard } from './added-source-card'; + +const LinkDataSourceModal = ({ + selectedList, + open, + setOpen, + onSubmit, +}: { + selectedList: IConnector[]; + open: boolean; + setOpen: (open: boolean) => void; + onSubmit?: (list: IDataSourceBase[] | undefined) => void; +}) => { + const [list, setList] = useState(); + const [fileterString, setFileterString] = useState(''); + + useEffect(() => { + setList(selectedList); + }, [selectedList]); + + const { categorizedList } = useListDataSource(); + const handleFormSubmit = (values: any) => { + console.log(values, selectedList); + onSubmit?.(list); + }; + return ( + { + setList(selectedList); + }} + onOpenChange={setOpen} + showfooter={false} + > +
+ {/* {JSON.stringify(selectedList)} */} + setFileterString(e.target.value)} + /> +
+ {categorizedList.map((item, index) => ( + setList(list)} + filterString={fileterString} + {...item} + /> + ))} +
+
+ + +
+
+
+ ); +}; +export default LinkDataSourceModal; diff --git a/web/src/pages/dataset/dataset-setting/components/link-data-source.tsx b/web/src/pages/dataset/dataset-setting/components/link-data-source.tsx new file mode 100644 index 000000000..1207a8891 --- /dev/null +++ b/web/src/pages/dataset/dataset-setting/components/link-data-source.tsx @@ -0,0 +1,193 @@ +import { Button } from '@/components/ui/button'; +import { Modal } from '@/components/ui/modal/modal'; +import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; +import { IConnector } from '@/interfaces/database/knowledge'; +import { DataSourceInfo } from '@/pages/user-setting/data-source/contant'; +import { IDataSourceBase } from '@/pages/user-setting/data-source/interface'; +import { Link, Settings, Unlink } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LinkDataSourceModal from './link-data-source-modal'; + +export type IDataSourceNodeProps = IConnector & { + icon: React.ReactNode; +}; + +export interface ILinkDataSourceProps { + data?: IConnector[]; + handleLinkOrEditSubmit?: (data: IDataSourceBase[] | undefined) => void; + unbindFunc?: (item: DataSourceItemProps) => void; +} + +interface DataSourceItemProps extends IDataSourceNodeProps { + openLinkModalFunc?: (open: boolean, data?: IDataSourceNodeProps) => void; + unbindFunc?: (item: DataSourceItemProps) => void; +} + +const DataSourceItem = (props: DataSourceItemProps) => { + const { t } = useTranslation(); + const { id, name, icon, openLinkModalFunc, unbindFunc } = props; + + const { navigateToDataSourceDetail } = useNavigatePage(); + const toDetail = (id: string) => { + navigateToDataSourceDetail(id); + }; + const openUnlinkModal = () => { + Modal.show({ + visible: true, + className: '!w-[560px]', + title: t('dataflowParser.unlinkSourceModalTitle'), + children: ( +
+ ), + onVisibleChange: () => { + Modal.hide(); + }, + footer: ( +
+ + +
+ ), + }); + }; + + return ( +
+
+ {icon} +
{name}
+
+
+ + <> + + +
+
+ ); +}; + +const LinkDataSource = (props: ILinkDataSourceProps) => { + const { data, handleLinkOrEditSubmit: submit, unbindFunc } = props; + const { t } = useTranslation(); + const [openLinkModal, setOpenLinkModal] = useState(false); + + const pipelineNode: IDataSourceNodeProps[] = useMemo(() => { + if (data && data.length > 0) { + return data.map((item) => { + return { + ...item, + id: item?.id, + name: item?.name, + icon: + DataSourceInfo[item?.source as keyof typeof DataSourceInfo]?.icon || + '', + } as IDataSourceNodeProps; + }); + } + return []; + }, [data]); + + const openLinkModalFunc = (open: boolean, data?: IDataSourceNodeProps) => { + console.log('open', open, data); + setOpenLinkModal(open); + // if (data) { + // setCurrentDataSource(data); + // } else { + // setCurrentDataSource(undefined); + // } + }; + + const handleLinkOrEditSubmit = (data: IDataSourceBase[] | undefined) => { + console.log('handleLinkOrEditSubmit', data); + submit?.(data); + setOpenLinkModal(false); + }; + + return ( +
+
+
+ {t('knowledgeConfiguration.dataSource')} +
+
+
+ {t('knowledgeConfiguration.linkSourceSetTip')} +
+ +
+
+
+ {pipelineNode.map( + (item) => + item.id && ( + + ), + )} +
+ { + openLinkModalFunc(open); + }} + onSubmit={handleLinkOrEditSubmit} + /> +
+ ); +}; +export default LinkDataSource; diff --git a/web/src/pages/dataset/dataset-setting/form-schema.ts b/web/src/pages/dataset/dataset-setting/form-schema.ts index 490eb5d56..38c0810e6 100644 --- a/web/src/pages/dataset/dataset-setting/form-schema.ts +++ b/web/src/pages/dataset/dataset-setting/form-schema.ts @@ -76,6 +76,16 @@ export const formSchema = z }) .optional(), pagerank: z.number(), + connectors: z + .array( + z.object({ + id: z.string().optional(), + name: z.string().optional(), + source: z.string().optional(), + ststus: z.string().optional(), + }), + ) + .optional(), // icon: z.array(z.instanceof(File)), }) .superRefine((data, ctx) => { diff --git a/web/src/pages/dataset/dataset-setting/index.tsx b/web/src/pages/dataset/dataset-setting/index.tsx index c1da7ec4d..d83ad72eb 100644 --- a/web/src/pages/dataset/dataset-setting/index.tsx +++ b/web/src/pages/dataset/dataset-setting/index.tsx @@ -7,6 +7,8 @@ import { Form } from '@/components/ui/form'; import { FormLayout } from '@/constants/form'; import { DocumentParserType } from '@/constants/knowledge'; import { PermissionRole } from '@/constants/permission'; +import { DataSourceInfo } from '@/pages/user-setting/data-source/contant'; +import { IDataSourceBase } from '@/pages/user-setting/data-source/interface'; import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; @@ -19,6 +21,9 @@ import { } from '../dataset/generate-button/generate'; import { ChunkMethodForm } from './chunk-method-form'; import ChunkMethodLearnMore from './chunk-method-learn-more'; +import LinkDataSource, { + IDataSourceNodeProps, +} from './components/link-data-source'; import { MainContainer } from './configuration-form-container'; import { ChunkMethodItem, ParseTypeItem } from './configuration/common-item'; import { formSchema } from './form-schema'; @@ -78,10 +83,12 @@ export default function DatasetSettings() { pipeline_id: '', parseType: 1, pagerank: 0, + connectors: [], }, }); const knowledgeDetails = useFetchKnowledgeConfigurationOnMount(form); // const [pipelineData, setPipelineData] = useState(); + const [sourceData, setSourceData] = useState(); const [graphRagGenerateData, setGraphRagGenerateData] = useState(); const [raptorGenerateData, setRaptorGenerateData] = @@ -97,6 +104,19 @@ export default function DatasetSettings() { // linked: true, // }; // setPipelineData(data); + + const source_data: IDataSourceNodeProps[] = + knowledgeDetails?.connectors?.map((connector) => { + return { + ...connector, + icon: + DataSourceInfo[connector.source as keyof typeof DataSourceInfo] + ?.icon || '', + }; + }); + + setSourceData(source_data); + setGraphRagGenerateData({ finish_at: knowledgeDetails.graphrag_task_finish_at, task_id: knowledgeDetails.graphrag_task_id, @@ -129,6 +149,23 @@ export default function DatasetSettings() { // } // }; + const handleLinkOrEditSubmit = (data: IDataSourceBase[] | undefined) => { + if (data) { + const connectors = data.map((connector) => { + return { + ...connector, + icon: + DataSourceInfo[connector.source as keyof typeof DataSourceInfo] + ?.icon || '', + }; + }); + setSourceData(connectors as IDataSourceNodeProps[]); + form.setValue('connectors', connectors || []); + // form.setValue('pipeline_name', data.name || ''); + // form.setValue('pipeline_avatar', data.avatar || ''); + } + }; + const handleDeletePipelineTask = (type: GenerateType) => { if (type === GenerateType.KnowledgeGraph) { setGraphRagGenerateData({ @@ -158,6 +195,19 @@ export default function DatasetSettings() { } console.log('parseType', parseType); }, [parseType, form]); + + const unbindFunc = (data: IDataSourceBase) => { + if (data) { + const connectors = sourceData?.filter((connector) => { + return connector.id !== data.id; + }); + console.log('🚀 ~ DatasetSettings ~ connectors:', connectors); + setSourceData(connectors as IDataSourceNodeProps[]); + form.setValue('connectors', connectors || []); + // form.setValue('pipeline_name', data.name || ''); + // form.setValue('pipeline_avatar', data.avatar || ''); + } + }; return (
*/} + + +
diff --git a/web/src/pages/user-setting/data-source/add-datasource-modal.tsx b/web/src/pages/user-setting/data-source/add-datasource-modal.tsx new file mode 100644 index 000000000..79ee933a4 --- /dev/null +++ b/web/src/pages/user-setting/data-source/add-datasource-modal.tsx @@ -0,0 +1,80 @@ +import { Modal } from '@/components/ui/modal/modal'; +import { IModalProps } from '@/interfaces/common'; +import { useEffect, useState } from 'react'; +import { FieldValues } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { DynamicForm, FormFieldConfig } from './component/dynamic-form'; +import { + DataSourceFormBaseFields, + DataSourceFormDefaultValues, + DataSourceFormFields, +} from './contant'; +import { IDataSorceInfo } from './interface'; + +const AddDataSourceModal = ({ + visible, + hideModal, + loading, + sourceData, + onOk, +}: IModalProps & { sourceData?: IDataSorceInfo }) => { + const { t } = useTranslation(); + const [fields, setFields] = useState([]); + + useEffect(() => { + if (sourceData) { + setFields([ + ...DataSourceFormBaseFields, + ...DataSourceFormFields[ + sourceData.id as keyof typeof DataSourceFormFields + ], + ] as FormFieldConfig[]); + } + }, [sourceData]); + + const handleOk = async (values?: FieldValues) => { + await onOk?.(values); + hideModal?.(); + }; + + return ( + !open && hideModal?.()} + // onOk={() => handleOk()} + okText={t('common.ok')} + cancelText={t('common.cancel')} + showfooter={false} + > + { + console.log(data); + }} + defaultValues={ + DataSourceFormDefaultValues[ + sourceData?.id as keyof typeof DataSourceFormDefaultValues + ] as FieldValues + } + > +
+ { + hideModal?.(); + }} + /> + { + handleOk(values); + }} + /> +
+
+
+ ); +}; + +export default AddDataSourceModal; diff --git a/web/src/pages/user-setting/data-source/component/added-source-card.tsx b/web/src/pages/user-setting/data-source/component/added-source-card.tsx new file mode 100644 index 000000000..fa3e39401 --- /dev/null +++ b/web/src/pages/user-setting/data-source/component/added-source-card.tsx @@ -0,0 +1,51 @@ +import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; +import { Settings, Trash2 } from 'lucide-react'; +import { useDeleteDataSource } from '../hooks'; +import { IDataSorceInfo, IDataSourceBase } from '../interface'; + +export type IAddedSourceCardProps = IDataSorceInfo & { + list: IDataSourceBase[]; +}; +export const AddedSourceCard = (props: IAddedSourceCardProps) => { + const { list, name, icon } = props; + const { handleDelete } = useDeleteDataSource(); + const { navigateToDataSourceDetail } = useNavigatePage(); + const toDetail = (id: string) => { + navigateToDataSourceDetail(id); + }; + return ( + + + {/* */} + + {icon} + {name} + + + + {list.map((item) => ( +
+
{item.name}
+
+ { + toDetail(item.id); + }} + /> + handleDelete(item)}> + + +
+
+ ))} +
+
+ ); +}; diff --git a/web/src/pages/user-setting/data-source/component/dynamic-form.tsx b/web/src/pages/user-setting/data-source/component/dynamic-form.tsx new file mode 100644 index 000000000..ae0eb1be4 --- /dev/null +++ b/web/src/pages/user-setting/data-source/component/dynamic-form.tsx @@ -0,0 +1,725 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { forwardRef, useEffect, useImperativeHandle, useMemo } from 'react'; +import { + DefaultValues, + FieldValues, + SubmitHandler, + useForm, + useFormContext, +} from 'react-hook-form'; +import { ZodSchema, z } from 'zod'; + +import EditTag from '@/components/edit-tag'; +import { SelectWithSearch } from '@/components/originui/select-with-search'; +import { RAGFlowFormItem } from '@/components/ragflow-form'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { t } from 'i18next'; +import { Loader } from 'lucide-react'; + +// Field type enumeration +export enum FormFieldType { + Text = 'text', + Email = 'email', + Password = 'password', + Number = 'number', + Textarea = 'textarea', + Select = 'select', + Checkbox = 'checkbox', + Tag = 'tag', +} + +// Field configuration interface +export interface FormFieldConfig { + name: string; + label: string; + type: FormFieldType; + hidden?: boolean; + required?: boolean; + placeholder?: string; + options?: { label: string; value: string }[]; + defaultValue?: any; + validation?: { + pattern?: RegExp; + minLength?: number; + maxLength?: number; + min?: number; + max?: number; + message?: string; + }; + render?: (fieldProps: any) => React.ReactNode; + horizontal?: boolean; + onChange?: (value: any) => void; +} + +// Component props interface +interface DynamicFormProps { + fields: FormFieldConfig[]; + onSubmit: SubmitHandler; + className?: string; + children?: React.ReactNode; + defaultValues?: DefaultValues; +} + +// Form ref interface +export interface DynamicFormRef { + submit: () => void; + getValues: () => any; + reset: (values?: any) => void; +} + +// Generate Zod validation schema based on field configurations +const generateSchema = (fields: FormFieldConfig[]): ZodSchema => { + const schema: Record = {}; + const nestedSchemas: Record> = {}; + + fields.forEach((field) => { + let fieldSchema: ZodSchema; + + // Create base validation schema based on field type + switch (field.type) { + case FormFieldType.Email: + fieldSchema = z.string().email('Please enter a valid email address'); + break; + case FormFieldType.Number: + fieldSchema = z.coerce.number(); + if (field.validation?.min !== undefined) { + fieldSchema = (fieldSchema as z.ZodNumber).min( + field.validation.min, + field.validation.message || + `Value cannot be less than ${field.validation.min}`, + ); + } + if (field.validation?.max !== undefined) { + fieldSchema = (fieldSchema as z.ZodNumber).max( + field.validation.max, + field.validation.message || + `Value cannot be greater than ${field.validation.max}`, + ); + } + break; + case FormFieldType.Checkbox: + fieldSchema = z.boolean(); + break; + case FormFieldType.Tag: + fieldSchema = z.array(z.string()); + break; + default: + fieldSchema = z.string(); + break; + } + + // Handle required fields + if (field.required) { + if (field.type === FormFieldType.Checkbox) { + fieldSchema = (fieldSchema as z.ZodBoolean).refine( + (val) => val === true, + { + message: `${field.label} is required`, + }, + ); + } else if (field.type === FormFieldType.Tag) { + fieldSchema = (fieldSchema as z.ZodArray).min(1, { + message: `${field.label} is required`, + }); + } else { + fieldSchema = (fieldSchema as z.ZodString).min(1, { + message: `${field.label} is required`, + }); + } + } + + if (!field.required) { + fieldSchema = fieldSchema.optional(); + } + + // Handle other validation rules + if ( + field.type !== FormFieldType.Number && + field.type !== FormFieldType.Checkbox && + field.type !== FormFieldType.Tag && + field.required + ) { + fieldSchema = fieldSchema as z.ZodString; + + if (field.validation?.minLength !== undefined) { + fieldSchema = (fieldSchema as z.ZodString).min( + field.validation.minLength, + field.validation.message || + `Enter at least ${field.validation.minLength} characters`, + ); + } + + if (field.validation?.maxLength !== undefined) { + fieldSchema = (fieldSchema as z.ZodString).max( + field.validation.maxLength, + field.validation.message || + `Enter up to ${field.validation.maxLength} characters`, + ); + } + + if (field.validation?.pattern) { + fieldSchema = (fieldSchema as z.ZodString).regex( + field.validation.pattern, + field.validation.message || 'Invalid input format', + ); + } + } + + if (field.name.includes('.')) { + const keys = field.name.split('.'); + const firstKey = keys[0]; + + if (!nestedSchemas[firstKey]) { + nestedSchemas[firstKey] = {}; + } + + let currentSchema = nestedSchemas[firstKey]; + for (let i = 1; i < keys.length - 1; i++) { + const key = keys[i]; + if (!currentSchema[key]) { + currentSchema[key] = {}; + } + currentSchema = currentSchema[key]; + } + + const lastKey = keys[keys.length - 1]; + currentSchema[lastKey] = fieldSchema; + } else { + schema[field.name] = fieldSchema; + } + }); + + Object.keys(nestedSchemas).forEach((key) => { + const buildNestedSchema = (obj: Record): ZodSchema => { + const nestedSchema: Record = {}; + Object.keys(obj).forEach((subKey) => { + if ( + typeof obj[subKey] === 'object' && + !(obj[subKey] instanceof z.ZodType) + ) { + nestedSchema[subKey] = buildNestedSchema(obj[subKey]); + } else { + nestedSchema[subKey] = obj[subKey]; + } + }); + return z.object(nestedSchema); + }; + + schema[key] = buildNestedSchema(nestedSchemas[key]); + }); + return z.object(schema); +}; + +// Generate default values based on field configurations +const generateDefaultValues = ( + fields: FormFieldConfig[], +): DefaultValues => { + const defaultValues: Record = {}; + + fields.forEach((field) => { + if (field.name.includes('.')) { + const keys = field.name.split('.'); + let current = defaultValues; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + } + + const lastKey = keys[keys.length - 1]; + if (field.defaultValue !== undefined) { + current[lastKey] = field.defaultValue; + } else if (field.type === FormFieldType.Checkbox) { + current[lastKey] = false; + } else if (field.type === FormFieldType.Tag) { + current[lastKey] = []; + } else { + current[lastKey] = ''; + } + } else { + if (field.defaultValue !== undefined) { + defaultValues[field.name] = field.defaultValue; + } else if (field.type === FormFieldType.Checkbox) { + defaultValues[field.name] = false; + } else if (field.type === FormFieldType.Tag) { + defaultValues[field.name] = []; + } else { + defaultValues[field.name] = ''; + } + } + }); + + return defaultValues as DefaultValues; +}; + +// Dynamic form component +const DynamicForm = { + Root: forwardRef( + ( + { + fields, + onSubmit, + className = '', + children, + defaultValues: formDefaultValues = {} as DefaultValues, + }: DynamicFormProps, + ref: React.Ref, + ) => { + // Generate validation schema and default values + const schema = useMemo(() => generateSchema(fields), [fields]); + + const defaultValues = useMemo(() => { + const value = { + ...generateDefaultValues(fields), + ...formDefaultValues, + }; + console.log('generateDefaultValues', fields, value); + return value; + }, [fields, formDefaultValues]); + + // Initialize form + const form = useForm({ + resolver: zodResolver(schema), + defaultValues, + }); + + // Expose form methods via ref + useImperativeHandle(ref, () => ({ + submit: () => form.handleSubmit(onSubmit)(), + getValues: () => form.getValues(), + reset: (values?: T) => { + if (values) { + form.reset(values); + } else { + form.reset(); + } + }, + setError: form.setError, + clearErrors: form.clearErrors, + trigger: form.trigger, + })); + + useEffect(() => { + if (formDefaultValues && Object.keys(formDefaultValues).length > 0) { + form.reset({ + ...generateDefaultValues(fields), + ...formDefaultValues, + }); + } + }, [form, formDefaultValues, fields]); + + // Submit handler + // const handleSubmit = form.handleSubmit(onSubmit); + + // Render form fields + const renderField = (field: FormFieldConfig) => { + if (field.render) { + return ( + + {(fieldProps) => { + const finalFieldProps = field.onChange + ? { + ...fieldProps, + onChange: (e: any) => { + fieldProps.onChange(e); + field.onChange?.(e.target?.value ?? e); + }, + } + : fieldProps; + return field.render?.(finalFieldProps); + }} + + ); + } + switch (field.type) { + case FormFieldType.Textarea: + return ( + + {(fieldProps) => { + const finalFieldProps = field.onChange + ? { + ...fieldProps, + onChange: (e: any) => { + fieldProps.onChange(e); + field.onChange?.(e.target.value); + }, + } + : fieldProps; + return ( +