Feature: Added data source functionality #10703 (#11046)

### What problem does this PR solve?

Feature: Added data source functionality

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
chanx
2025-11-06 11:53:46 +08:00
committed by GitHub
parent 15c75bbf15
commit f581a1c4e5
31 changed files with 2526 additions and 16 deletions

View File

@ -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<FieldValues> & { sourceData?: IDataSorceInfo }) => {
const { t } = useTranslation();
const [fields, setFields] = useState<FormFieldConfig[]>([]);
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 (
<Modal
title={t('setting.add')}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
// onOk={() => handleOk()}
okText={t('common.ok')}
cancelText={t('common.cancel')}
showfooter={false}
>
<DynamicForm.Root
fields={fields}
onSubmit={(data) => {
console.log(data);
}}
defaultValues={
DataSourceFormDefaultValues[
sourceData?.id as keyof typeof DataSourceFormDefaultValues
] as FieldValues
}
>
<div className="flex items-center justify-end w-full gap-2">
<DynamicForm.CancelButton
handleCancel={() => {
hideModal?.();
}}
/>
<DynamicForm.SavingButton
submitLoading={loading || false}
buttonText={t('common.ok')}
submitFunc={(values: FieldValues) => {
handleOk(values);
}}
/>
</div>
</DynamicForm.Root>
</Modal>
);
};
export default AddDataSourceModal;

View File

@ -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 (
<Card className="bg-transparent border border-border-button px-5 pt-[10px] pb-5 rounded-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-0 pb-3">
{/* <Users className="mr-2 h-5 w-5 text-[#1677ff]" /> */}
<CardTitle className="text-base flex gap-1 font-normal">
{icon}
{name}
</CardTitle>
</CardHeader>
<CardContent className="p-2 flex flex-col gap-2">
{list.map((item) => (
<div
key={item.id}
className="flex flex-row items-center justify-between rounded-md bg-bg-input px-[10px] py-4"
>
<div className="text-sm text-text-secondary ">{item.name}</div>
<div className="text-sm text-text-secondary flex gap-2">
<Settings
className="cursor-pointer"
size={14}
onClick={() => {
toDetail(item.id);
}}
/>
<ConfirmDeleteDialog onOk={() => handleDelete(item)}>
<Trash2 className="cursor-pointer" size={14} />
</ConfirmDeleteDialog>
</div>
</div>
))}
</CardContent>
</Card>
);
};

View File

@ -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<T extends FieldValues> {
fields: FormFieldConfig[];
onSubmit: SubmitHandler<T>;
className?: string;
children?: React.ReactNode;
defaultValues?: DefaultValues<T>;
}
// 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<any> => {
const schema: Record<string, ZodSchema> = {};
const nestedSchemas: Record<string, Record<string, ZodSchema>> = {};
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<z.ZodString>).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<string, any>): ZodSchema => {
const nestedSchema: Record<string, ZodSchema> = {};
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 = <T extends FieldValues>(
fields: FormFieldConfig[],
): DefaultValues<T> => {
const defaultValues: Record<string, any> = {};
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<T>;
};
// Dynamic form component
const DynamicForm = {
Root: forwardRef(
<T extends FieldValues>(
{
fields,
onSubmit,
className = '',
children,
defaultValues: formDefaultValues = {} as DefaultValues<T>,
}: DynamicFormProps<T>,
ref: React.Ref<any>,
) => {
// 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<T>({
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 (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (e: any) => {
fieldProps.onChange(e);
field.onChange?.(e.target?.value ?? e);
},
}
: fieldProps;
return field.render?.(finalFieldProps);
}}
</RAGFlowFormItem>
);
}
switch (field.type) {
case FormFieldType.Textarea:
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (e: any) => {
fieldProps.onChange(e);
field.onChange?.(e.target.value);
},
}
: fieldProps;
return (
<Textarea
{...finalFieldProps}
placeholder={field.placeholder}
className="resize-none"
/>
);
}}
</RAGFlowFormItem>
);
case FormFieldType.Select:
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (value: string) => {
console.log('select value', value);
if (fieldProps.onChange) {
fieldProps.onChange(value);
}
field.onChange?.(value);
},
}
: fieldProps;
return (
<SelectWithSearch
triggerClassName="!shrink"
{...finalFieldProps}
options={field.options}
/>
);
}}
</RAGFlowFormItem>
);
case FormFieldType.Checkbox:
return (
<FormField
control={form.control}
name={field.name as any}
render={({ field: formField }) => (
<FormItem
className={cn('flex items-center', {
'flex-row items-start space-x-3 space-y-0':
!field.horizontal,
})}
>
{field.label && !field.horizontal && (
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">
{field.label}{' '}
{field.required && (
<span className="text-destructive">*</span>
)}
</FormLabel>
</div>
)}
<FormControl>
<Checkbox
checked={formField.value}
onCheckedChange={(checked) => {
formField.onChange(checked);
field.onChange?.(checked);
}}
/>
</FormControl>
{field.label && field.horizontal && (
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">
{field.label}{' '}
{field.required && (
<span className="text-destructive">*</span>
)}
</FormLabel>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
);
case FormFieldType.Tag:
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (value: string[]) => {
fieldProps.onChange(value);
field.onChange?.(value);
},
}
: fieldProps;
return (
// <TagInput {...fieldProps} placeholder={field.placeholder} />
<div className="w-full">
<EditTag {...finalFieldProps}></EditTag>
</div>
);
}}
</RAGFlowFormItem>
);
default:
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (e: any) => {
fieldProps.onChange(e);
field.onChange?.(e.target.value);
},
}
: fieldProps;
return (
<Input
{...finalFieldProps}
type={field.type}
placeholder={field.placeholder}
/>
);
}}
</RAGFlowFormItem>
);
}
};
return (
<Form {...form}>
<form
className={`space-y-6 ${className}`}
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit(onSubmit)(e);
}}
>
<>
{fields.map((field) => (
<div key={field.name} className={cn({ hidden: field.hidden })}>
{renderField(field)}
</div>
))}
{children}
</>
</form>
</Form>
);
},
) as <T extends FieldValues>(
props: DynamicFormProps<T> & { ref?: React.Ref<DynamicFormRef> },
) => React.ReactElement,
SavingButton: ({
submitLoading,
buttonText,
submitFunc,
}: {
submitLoading: boolean;
buttonText?: string;
submitFunc?: (values: FieldValues) => void;
}) => {
const form = useFormContext();
return (
<button
type="button"
disabled={submitLoading}
onClick={() => {
console.log('form submit');
(async () => {
console.log('form submit2');
try {
let beValid = await form.formControl.trigger();
console.log('form valid', beValid, form, form.formControl);
if (beValid) {
form.handleSubmit(async (values) => {
console.log('form values', values);
submitFunc?.(values);
})();
}
} catch (e) {
console.error(e);
} finally {
console.log('form submit3');
}
})();
}}
className={cn(
'px-2 py-1 bg-primary text-primary-foreground rounded-md hover:bg-primary/90',
)}
>
{submitLoading && (
<Loader className="inline-block mr-2 h-4 w-4 animate-spin" />
)}
{buttonText ?? t('modal.okText')}
</button>
);
},
CancelButton: ({
handleCancel,
cancelText,
}: {
handleCancel: () => void;
cancelText?: string;
}) => {
return (
<button
type="button"
onClick={() => handleCancel()}
className="px-2 py-1 border border-input rounded-md hover:bg-muted"
>
{cancelText ?? t('modal.cancelText')}
</button>
);
},
};
export { DynamicForm };
/**
* Usage Example 1: Basic Form
*
* <DynamicForm
* fields={[
* {
* name: "username",
* label: "Username",
* type: FormFieldType.Text,
* required: true,
* placeholder: "Please enter username"
* },
* {
* name: "email",
* label: "Email",
* type: FormFieldType.Email,
* required: true,
* placeholder: "Please enter email address"
* }
* ]}
* onSubmit={(data) => {
* console.log(data); // { username: "...", email: "..." }
* }}
* />
*
* Usage Example 2: Nested Object Form
*
* <DynamicForm
* fields={[
* {
* name: "user.name",
* label: "Name",
* type: FormFieldType.Text,
* required: true,
* placeholder: "Please enter name"
* },
* {
* name: "user.email",
* label: "Email",
* type: FormFieldType.Email,
* required: true,
* placeholder: "Please enter email address"
* },
* {
* name: "user.profile.age",
* label: "Age",
* type: FormFieldType.Number,
* required: true,
* validation: {
* min: 18,
* max: 100,
* message: "Age must be between 18 and 100"
* }
* },
* {
* name: "user.profile.bio",
* label: "Bio",
* type: FormFieldType.Textarea,
* placeholder: "Please briefly introduce yourself"
* },
* {
* name: "settings.notifications",
* label: "Enable Notifications",
* type: FormFieldType.Checkbox
* }
* ]}
* onSubmit={(data) => {
* console.log(data);
* // {
* // user: {
* // name: "...",
* // email: "...",
* // profile: {
* // age: ...,
* // bio: "..."
* // }
* // },
* // settings: {
* // notifications: true/false
* // }
* // }
* }}
* />
*
* Usage Example 3: Tag Type Form
*
* <DynamicForm
* fields={[
* {
* name: "skills",
* label: "Skill Tags",
* type: FormFieldType.Tag,
* required: true,
* placeholder: "Enter skill and press Enter to add tag"
* },
* {
* name: "user.hobbies",
* label: "Hobbies",
* type: FormFieldType.Tag,
* placeholder: "Enter hobby and press Enter to add tag"
* }
* ]}
* onSubmit={(data) => {
* console.log(data);
* // {
* // skills: ["JavaScript", "React", "TypeScript"],
* // user: {
* // hobbies: ["Reading", "Swimming", "Travel"]
* // }
* // }
* }}
* />
*/

View File

@ -0,0 +1,173 @@
import SvgIcon from '@/components/svg-icon';
import { t } from 'i18next';
import { FormFieldType } from './component/dynamic-form';
export enum DataSourceKey {
S3 = 's3',
NOTION = 'notion',
DISCORD = 'discord',
// CONFLUENNCE = 'confluence',
// GMAIL = 'gmail',
// GOOGLE_DRIVER = 'google_driver',
// JIRA = 'jira',
// SHAREPOINT = 'sharepoint',
// SLACK = 'slack',
// TEAMS = 'teams',
}
export const DataSourceInfo = {
[DataSourceKey.S3]: {
name: 'S3',
description: t(`setting.${DataSourceKey.S3}Description`),
icon: <SvgIcon name={'data-source/s3'} width={28} />,
},
[DataSourceKey.NOTION]: {
name: 'Notion',
description: t(`setting.${DataSourceKey.NOTION}Description`),
icon: <SvgIcon name={'data-source/notion'} width={28} />,
},
[DataSourceKey.DISCORD]: {
name: 'Discord',
description: t(`setting.${DataSourceKey.DISCORD}Description`),
icon: <SvgIcon name={'data-source/discord'} width={28} />,
},
};
export const DataSourceFormBaseFields = [
{
id: 'Id',
name: 'id',
type: FormFieldType.Text,
required: false,
hidden: true,
},
{
label: 'Name',
name: 'name',
type: FormFieldType.Text,
required: true,
},
{
label: 'Source',
name: 'source',
type: FormFieldType.Select,
required: true,
hidden: true,
options: Object.keys(DataSourceKey).map((item) => ({
label: item,
value: DataSourceKey[item as keyof typeof DataSourceKey],
})),
},
];
export const DataSourceFormFields = {
[DataSourceKey.S3]: [
{
label: 'AWS Access Key ID',
name: 'config.credentials.aws_access_key_id',
type: FormFieldType.Text,
required: true,
},
{
label: 'AWS Secret Access Key',
name: 'config.credentials.aws_secret_access_key',
type: FormFieldType.Text,
required: true,
},
{
label: 'Bucket Name',
name: 'config.bucket_name',
type: FormFieldType.Text,
required: true,
},
{
label: 'Bucket Type',
name: 'config.bucket_type',
type: FormFieldType.Select,
options: [
{ label: 'S3', value: 's3' },
{ label: 'R2', value: 'r2' },
{ label: 'Google Cloud Storage', value: 'google_cloud_storage' },
{ label: 'OCI Storage', value: 'oci_storage' },
],
required: true,
},
{
label: 'Prefix',
name: 'config.prefix',
type: FormFieldType.Text,
required: false,
},
],
[DataSourceKey.NOTION]: [
{
label: 'Notion Integration Token',
name: 'config.credentials.notion_integration_token',
type: FormFieldType.Text,
required: true,
},
{
label: 'Root Page Id',
name: 'config.root_page_id',
type: FormFieldType.Text,
required: false,
},
],
[DataSourceKey.DISCORD]: [
{
label: 'Discord Bot Token',
name: 'config.credentials.discord_bot_token',
type: FormFieldType.Text,
required: true,
},
{
label: 'Server IDs',
name: 'config.server_ids',
type: FormFieldType.Tag,
required: false,
},
{
label: 'Channels',
name: 'config.channels',
type: FormFieldType.Tag,
required: false,
},
],
};
export const DataSourceFormDefaultValues = {
[DataSourceKey.S3]: {
name: '',
source: DataSourceKey.S3,
config: {
bucket_name: '',
bucket_type: 's3',
prefix: '',
credentials: {
aws_access_key_id: '',
aws_secret_access_key: '',
},
},
},
[DataSourceKey.NOTION]: {
name: '',
source: DataSourceKey.NOTION,
config: {
root_page_id: '',
credentials: {
notion_integration_token: '',
},
},
},
[DataSourceKey.DISCORD]: {
name: '',
source: DataSourceKey.DISCORD,
config: {
server_ids: [],
channels: [],
credentials: {
discord_bot_token: '',
},
},
},
};

View File

@ -0,0 +1,193 @@
import BackButton from '@/components/back-button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { RunningStatus } from '@/constants/knowledge';
import { t } from 'i18next';
import { debounce } from 'lodash';
import { CirclePause, Repeat } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FieldValues } from 'react-hook-form';
import {
DynamicForm,
DynamicFormRef,
FormFieldConfig,
FormFieldType,
} from '../component/dynamic-form';
import {
DataSourceFormBaseFields,
DataSourceFormDefaultValues,
DataSourceFormFields,
DataSourceInfo,
} from '../contant';
import {
useAddDataSource,
useDataSourceResume,
useFetchDataSourceDetail,
} from '../hooks';
import { DataSourceLogsTable } from './log-table';
const SourceDetailPage = () => {
const formRef = useRef<DynamicFormRef>(null);
const { data: detail } = useFetchDataSourceDetail();
const { handleResume } = useDataSourceResume();
const detailInfo = useMemo(() => {
if (detail) {
return DataSourceInfo[detail.source];
}
}, [detail]);
const [fields, setFields] = useState<FormFieldConfig[]>([]);
const [defaultValues, setDefaultValues] = useState<FieldValues>(
DataSourceFormDefaultValues[
detail?.source as keyof typeof DataSourceFormDefaultValues
] as FieldValues,
);
const runSchedule = useCallback(() => {
handleResume({
resume:
detail?.status === RunningStatus.RUNNING ||
detail?.status === RunningStatus.SCHEDULE
? false
: true,
});
}, [detail, handleResume]);
const customFields = useMemo(() => {
return [
{
label: 'Refresh Freq',
name: 'refresh_freq',
type: FormFieldType.Number,
required: false,
render: (fieldProps: FormFieldConfig) => (
<div className="flex items-center gap-1 w-full relative">
<Input {...fieldProps} type={FormFieldType.Number} />
<span className="absolute right-0 -translate-x-12 text-text-secondary italic ">
minutes
</span>
<button
type="button"
className="text-text-secondary bg-bg-input rounded-sm text-xs h-full p-2 border border-border-button "
onClick={() => {
runSchedule();
}}
>
{detail?.status === RunningStatus.RUNNING ||
detail?.status === RunningStatus.SCHEDULE ? (
<CirclePause size={12} />
) : (
<Repeat size={12} />
)}
</button>
</div>
),
},
{
label: 'Prune Freq',
name: 'prune_freq',
type: FormFieldType.Number,
required: false,
hidden: true,
render: (fieldProps: FormFieldConfig) => {
return (
<div className="flex items-center gap-1 w-full relative">
<Input {...fieldProps} type={FormFieldType.Number} />
<span className="absolute right-0 -translate-x-3 text-text-secondary italic ">
hours
</span>
</div>
);
},
},
{
label: 'Timeout Secs',
name: 'timeout_secs',
type: FormFieldType.Number,
required: false,
render: (fieldProps: FormFieldConfig) => (
<div className="flex items-center gap-1 w-full relative">
<Input {...fieldProps} type={FormFieldType.Number} />
<span className="absolute right-0 -translate-x-3 text-text-secondary italic ">
minutes
</span>
</div>
),
},
];
}, [detail, runSchedule]);
const { handleAddOk } = useAddDataSource();
const onSubmit = useCallback(() => {
formRef?.current?.submit();
}, [formRef]);
useEffect(() => {
if (detail) {
const fields = [
...DataSourceFormBaseFields,
...DataSourceFormFields[
detail.source as keyof typeof DataSourceFormFields
],
...customFields,
] as FormFieldConfig[];
const neweFields = fields.map((field) => {
return {
...field,
horizontal: true,
onChange: () => {
onSubmit();
},
};
});
setFields(neweFields);
const defultValueTemp = {
...(DataSourceFormDefaultValues[
detail?.source as keyof typeof DataSourceFormDefaultValues
] as FieldValues),
...detail,
};
console.log('defaultValue', defultValueTemp);
setDefaultValues(defultValueTemp);
}
}, [detail, customFields, onSubmit]);
return (
<div className="px-10 py-5">
<BackButton />
<Card className="bg-transparent border border-border-button px-5 pt-[10px] pb-5 rounded-md mt-5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-0 pb-3">
{/* <Users className="mr-2 h-5 w-5 text-[#1677ff]" /> */}
<CardTitle className="text-2xl text-text-primary flex gap-1 items-center font-normal pb-3">
{detailInfo?.icon}
{detail?.name}
</CardTitle>
</CardHeader>
<Separator className="border-border-button bg-border-button w-[calc(100%+2rem)] -translate-x-4 -translate-y-4" />
<CardContent className="p-2 flex flex-col gap-2 max-h-[calc(100vh-190px)] overflow-y-auto scrollbar-auto">
<div className="max-w-[1200px]">
<DynamicForm.Root
ref={formRef}
fields={fields}
onSubmit={debounce((data) => {
handleAddOk(data);
}, 500)}
defaultValues={defaultValues}
/>
</div>
<section className="flex flex-col gap-2 mt-6">
<div className="text-2xl text-text-primary">{t('setting.log')}</div>
<DataSourceLogsTable />
</section>
</CardContent>
</Card>
</div>
);
};
export default SourceDetailPage;

View File

@ -0,0 +1,240 @@
import FileStatusBadge from '@/components/file-status-badge';
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { Button } from '@/components/ui/button';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { RunningStatusMap } from '@/constants/knowledge';
import { RunningStatus } from '@/pages/dataset/dataset/constant';
import { Routes } from '@/routes';
import { formatDate } from '@/utils/date';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@radix-ui/react-hover-card';
import {
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { t } from 'i18next';
import { pick } from 'lodash';
import { Eye } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'umi';
import { useLogListDataSource } from '../hooks';
const columns = ({
handleToDataSetDetail,
}: {
handleToDataSetDetail: (id: string) => void;
}) => {
return [
{
accessorKey: 'update_date',
header: t('setting.timeStarted'),
cell: ({ row }) => (
<div className="flex items-center gap-2 text-text-primary">
{row.original.update_date
? formatDate(row.original.update_date)
: '-'}
</div>
),
},
{
accessorKey: 'status',
header: t('knowledgeDetails.status'),
cell: ({ row }) => (
<FileStatusBadge
status={row.original.status as RunningStatus}
name={RunningStatusMap[row.original.status as RunningStatus]}
className="!w-20"
/>
),
},
{
accessorKey: 'kb_name',
header: t('knowledgeDetails.dataset'),
cell: ({ row }) => {
return (
<div
className="flex items-center gap-2 text-text-primary cursor-pointer"
onClick={() => {
console.log('handleToDataSetDetail', row.original.kb_id);
handleToDataSetDetail(row.original.kb_id);
}}
>
<RAGFlowAvatar
avatar={row.original.avatar}
name={row.original.kb_name}
className="size-4"
/>
{row.original.kb_name}
</div>
);
},
},
{
accessorKey: 'new_docs_indexed',
header: t('setting.newDocs'),
},
{
id: 'operations',
header: t('setting.errorMsg'),
cell: ({ row }) => (
<div className="flex gap-1 items-center">
{row.original.error_msg}
{row.original.error_msg && (
<div className="flex justify-start space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<HoverCard>
<HoverCardTrigger>
<Button
variant="ghost"
size="sm"
className="p-1"
// onClick={() => {
// showLog(row, LogTabs.FILE_LOGS);
// }}
>
<Eye />
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-[40vw] max-h-[40vh] overflow-auto bg-bg-base z-[999] px-3 py-2 rounded-md border border-border-default">
<div className="space-y-2">
{row.original.full_exception_trace}
</div>
</HoverCardContent>
</HoverCard>
</div>
)}
</div>
),
},
] as ColumnDef<any>[];
};
// const paginationInit = {
// current: 1,
// pageSize: 10,
// total: 0,
// };
export const DataSourceLogsTable = () => {
// const [pagination, setPagination] = useState(paginationInit);
const { data, pagination, setPagination } = useLogListDataSource();
const navigate = useNavigate();
const currentPagination = useMemo(
() => ({
pageIndex: (pagination.current || 1) - 1,
pageSize: pagination.pageSize || 10,
}),
[pagination],
);
const handleToDataSetDetail = useCallback(
(id: string) => {
console.log('handleToDataSetDetail', id);
navigate(`${Routes.DatasetBase}${Routes.DataSetSetting}/${id}`);
},
[navigate],
);
const table = useReactTable<any>({
data: data || [],
columns: columns({ handleToDataSetDetail }),
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
// onSortingChange: setSorting,
// onColumnFiltersChange: setColumnFilters,
// onRowSelectionChange: setRowSelection,
state: {
// sorting,
// columnFilters,
// rowSelection,
pagination: currentPagination,
},
// pageCount: pagination.total
// ? Math.ceil(pagination.total / pagination.pageSize)
// : 0,
rowCount: pagination.total ?? 0,
});
return (
// <div className="w-full h-[calc(100vh-360px)]">
// <Table rootClassName="max-h-[calc(100vh-380px)]">
<div className="w-full">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody className="relative min-w-[1280px] overflow-auto">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className="group"
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cell.column.columnDef.meta?.cellClassName}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center justify-end mt-4">
<div className="space-x-2">
{/* <RAGFlowPagination
{...{ current: pagination.current, pageSize: pagination.pageSize }}
total={pagination.total}
onChange={(page, pageSize) => setPagination({ page, pageSize })}
/> */}
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total}
onChange={(page, pageSize) => {
setPagination({ page, pageSize });
}}
></RAGFlowPagination>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,188 @@
import message from '@/components/ui/message';
import { useSetModalState } from '@/hooks/common-hooks';
import { useGetPaginationWithRouter } from '@/hooks/logic-hooks';
import dataSourceService, {
dataSourceResume,
deleteDataSource,
featchDataSourceDetail,
getDataSourceLogs,
} from '@/services/data-source-service';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'umi';
import { DataSourceInfo, DataSourceKey } from './contant';
import { IDataSorceInfo, IDataSource, IDataSourceBase } from './interface';
export const useListDataSource = () => {
const { data: list, isFetching } = useQuery<IDataSource[]>({
queryKey: ['data-source'],
queryFn: async () => {
const { data } = await dataSourceService.dataSourceList();
return data.data;
},
});
const categorizeDataBySource = (data: IDataSourceBase[]) => {
const categorizedData: Record<DataSourceKey, any[]> = {} as Record<
DataSourceKey,
any[]
>;
data.forEach((item) => {
const source = item.source;
if (!categorizedData[source]) {
categorizedData[source] = [];
}
categorizedData[source].push({
...item,
});
});
return categorizedData;
};
const updatedDataSourceTemplates = useMemo(() => {
const categorizedData = categorizeDataBySource(list || []);
let sourcelist: Array<IDataSorceInfo & { list: Array<IDataSourceBase> }> =
[];
Object.keys(categorizedData).forEach((key: string) => {
const k = key as DataSourceKey;
sourcelist.push({
id: k,
name: DataSourceInfo[k].name,
description: DataSourceInfo[k].description,
icon: DataSourceInfo[k].icon,
list: categorizedData[k] || [],
});
});
console.log('🚀 ~ useListDataSource ~ sourcelist:', sourcelist);
return sourcelist;
}, [list]);
return { list, categorizedList: updatedDataSourceTemplates, isFetching };
};
export const useAddDataSource = () => {
const [addSource, setAddSource] = useState<IDataSorceInfo | undefined>(
undefined,
);
const [addLoading, setAddLoading] = useState<boolean>(false);
const {
visible: addingModalVisible,
hideModal: hideAddingModal,
showModal,
} = useSetModalState();
const showAddingModal = useCallback(
(data: IDataSorceInfo) => {
setAddSource(data);
showModal();
},
[showModal],
);
const queryClient = useQueryClient();
const handleAddOk = useCallback(
async (data: any) => {
setAddLoading(true);
const { data: res } = await dataSourceService.dataSourceSet(data);
console.log('🚀 ~ handleAddOk ~ code:', res.code);
if (res.code === 0) {
queryClient.invalidateQueries({ queryKey: ['data-source'] });
message.success(t(`message.operated`));
hideAddingModal();
}
setAddLoading(false);
},
[hideAddingModal, queryClient],
);
return {
addSource,
addLoading,
setAddSource,
addingModalVisible,
hideAddingModal,
showAddingModal,
handleAddOk,
};
};
export const useLogListDataSource = () => {
const { pagination, setPagination } = useGetPaginationWithRouter();
const [currentQueryParameters] = useSearchParams();
const id = currentQueryParameters.get('id');
const { data, isFetching } = useQuery<{ logs: IDataSource[]; total: number }>(
{
queryKey: ['data-source-logs', id, pagination],
queryFn: async () => {
const { data } = await getDataSourceLogs(id as string, {
page_size: pagination.pageSize,
page: pagination.current,
});
return data.data;
},
},
);
return {
data: data?.logs,
isFetching,
pagination: { ...pagination, total: data?.total },
setPagination,
};
};
export const useDeleteDataSource = () => {
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
const { hideModal, showModal } = useSetModalState();
const queryClient = useQueryClient();
const handleDelete = useCallback(
async ({ id }: { id: string }) => {
setDeleteLoading(true);
const { data } = await deleteDataSource(id);
if (data.code === 0) {
message.success(t(`message.deleted`));
queryClient.invalidateQueries({ queryKey: ['data-source'] });
}
setDeleteLoading(false);
},
[setDeleteLoading, queryClient],
);
return { deleteLoading, hideModal, showModal, handleDelete };
};
export const useFetchDataSourceDetail = () => {
const [currentQueryParameters] = useSearchParams();
const id = currentQueryParameters.get('id');
const { data } = useQuery<IDataSource>({
queryKey: ['data-source-detail', id],
enabled: !!id,
queryFn: async () => {
const { data } = await featchDataSourceDetail(id as string);
// if (data.code === 0) {
// }
return data.data;
},
});
return { data };
};
export const useDataSourceResume = () => {
const [currentQueryParameters] = useSearchParams();
const id = currentQueryParameters.get('id');
const queryClient = useQueryClient();
const handleResume = useCallback(
async (param: { resume: boolean }) => {
const { data } = await dataSourceResume(id as string, param);
if (data.code === 0) {
queryClient.invalidateQueries({ queryKey: ['data-source-detail', id] });
message.success(t(`message.operated`));
}
},
[id, queryClient],
);
return { handleResume };
};

View File

@ -0,0 +1,151 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useTranslation } from 'react-i18next';
import Spotlight from '@/components/spotlight';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Plus } from 'lucide-react';
import AddDataSourceModal from './add-datasource-modal';
import { AddedSourceCard } from './component/added-source-card';
import { DataSourceInfo, DataSourceKey } from './contant';
import { useAddDataSource, useListDataSource } from './hooks';
import { IDataSorceInfo } from './interface';
const dataSourceTemplates = [
{
id: DataSourceKey.S3,
name: DataSourceInfo[DataSourceKey.S3].name,
description: DataSourceInfo[DataSourceKey.S3].description,
icon: DataSourceInfo[DataSourceKey.S3].icon,
list: [
{
id: '1',
name: 'S3 Bucket 1',
},
{
id: '2',
name: 'S3 Bucket 1',
},
],
},
{
id: DataSourceKey.DISCORD,
name: DataSourceInfo[DataSourceKey.DISCORD].name,
description: DataSourceInfo[DataSourceKey.DISCORD].description,
icon: DataSourceInfo[DataSourceKey.DISCORD].icon,
},
{
id: DataSourceKey.NOTION,
name: DataSourceInfo[DataSourceKey.NOTION].name,
description: DataSourceInfo[DataSourceKey.NOTION].description,
icon: DataSourceInfo[DataSourceKey.NOTION].icon,
},
];
const DataSource = () => {
const { t } = useTranslation();
// useListTenantUser();
const { categorizedList } = useListDataSource();
const {
addSource,
addLoading,
addingModalVisible,
handleAddOk,
hideAddingModal,
showAddingModal,
} = useAddDataSource();
const AbailableSourceCard = ({
id,
name,
description,
icon,
}: IDataSorceInfo) => {
return (
<div className="p-[10px] border border-border-button rounded-lg relative group hover:bg-bg-card">
<div className="flex gap-2">
<div className="w-6 h-6">{icon}</div>
<div className="flex flex-1 flex-col items-start gap-2">
<div className="text-base text-text-primary">{name}</div>
<div className="text-xs text-text-secondary">{description}</div>
</div>
</div>
<div className=" absolute top-2 right-2">
<Button
onClick={() =>
showAddingModal({
id,
name,
description,
icon,
})
}
className=" rounded-md px-1 text-bg-base gap-1 bg-text-primary text-xs py-0 h-6 items-center hidden group-hover:flex"
>
<Plus size={12} />
{t('setting.add')}
</Button>
</div>
</div>
);
};
return (
<div className="w-full flex flex-col gap-4 relative ">
<Spotlight />
<Card className="bg-transparent border-none px-0">
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 pt-4 pb-0">
<CardTitle className="text-2xl font-medium">
{t('setting.dataSources')}
<div className="text-sm text-text-secondary">
{t('setting.datasourceDescription')}
</div>
</CardTitle>
</CardHeader>
</Card>
<Separator className="border-border-button bg-border-button " />
<div className=" flex flex-col gap-4 p-4 max-h-[calc(100vh-120px)] overflow-y-auto overflow-x-hidden scrollbar-auto">
<div className="flex flex-col gap-3">
{categorizedList.map((item, index) => (
<AddedSourceCard key={index} {...item} />
))}
</div>
<Card className="bg-transparent border-none mt-8">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-0 pb-4">
{/* <Users className="mr-2 h-5 w-5 text-[#1677ff]" /> */}
<CardTitle className="text-2xl font-semibold">
{t('setting.availableSources')}
<div className="text-sm text-text-secondary font-normal">
{t('setting.availableSourcesDescription')}
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* <TenantTable searchTerm={searchTerm}></TenantTable> */}
<div className="grid sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-4 3xl:grid-cols-4 gap-4">
{dataSourceTemplates.map((item, index) => (
<AbailableSourceCard {...item} key={index} />
))}
</div>
</CardContent>
</Card>
</div>
{addingModalVisible && (
<AddDataSourceModal
visible
loading={addLoading}
hideModal={hideAddingModal}
onOk={(data) => {
console.log(data);
handleAddOk(data);
}}
sourceData={addSource}
></AddDataSourceModal>
)}
</div>
);
};
export default DataSource;

View File

@ -0,0 +1,45 @@
import { RunningStatus } from '@/constants/knowledge';
import { DataSourceKey } from './contant';
export interface IDataSorceInfo {
id: DataSourceKey;
name: string;
description: string;
icon: React.ReactNode;
}
export type IDataSource = IDataSourceBase & {
config: any;
indexing_start: null | string;
input_type: string;
prune_freq: number;
refresh_freq: number;
status: string;
tenant_id: string;
update_date: string;
update_time: number;
};
export interface IDataSourceBase {
id: string;
name: string;
source: DataSourceKey;
}
export interface IDataSourceLog {
connector_id: string;
error_count: number;
error_msg: string;
id: string;
kb_id: string;
kb_name: string;
name: string;
new_docs_indexed: number;
poll_range_end: null | string;
poll_range_start: null | string;
reindex: string;
source: DataSourceKey;
status: RunningStatus;
tenant_id: string;
timeout_secs: number;
}