mirror of
https://git.mirrors.martin98.com/https://github.com/infiniflow/ragflow.git
synced 2025-08-14 01:35:59 +08:00
### What problem does this PR solve? Feat: Add FileUploadDialog #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
parent
50f209204e
commit
5883493c7d
1115
web/package-lock.json
generated
1115
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -33,6 +33,8 @@
|
|||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.1",
|
"@radix-ui/react-navigation-menu": "^1.2.1",
|
||||||
"@radix-ui/react-popover": "^1.1.2",
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slider": "^1.2.1",
|
"@radix-ui/react-slider": "^1.2.1",
|
||||||
@ -70,8 +72,8 @@
|
|||||||
"openai-speech-stream-player": "^1.0.8",
|
"openai-speech-stream-player": "^1.0.8",
|
||||||
"rc-tween-one": "^3.0.6",
|
"rc-tween-one": "^3.0.6",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
|
"react-dropzone": "^14.3.5",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.0.13",
|
||||||
"react-force-graph": "^1.44.4",
|
|
||||||
"react-hook-form": "^7.53.1",
|
"react-hook-form": "^7.53.1",
|
||||||
"react-i18next": "^14.0.0",
|
"react-i18next": "^14.0.0",
|
||||||
"react-infinite-scroll-component": "^6.1.0",
|
"react-infinite-scroll-component": "^6.1.0",
|
||||||
@ -85,6 +87,7 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"umi": "^4.0.90",
|
"umi": "^4.0.90",
|
||||||
|
57
web/src/components/file-upload-dialog/index.tsx
Normal file
57
web/src/components/file-upload-dialog/index.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { IModalProps } from '@/interfaces/common';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FileUploader } from '../file-uploader';
|
||||||
|
|
||||||
|
export function UploaderTabs() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
console.log('🚀 ~ TabsDemo ~ files:', files);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="account">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||||
|
<TabsTrigger value="account">{t('fileManager.local')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">{t('fileManager.s3')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="account">
|
||||||
|
<FileUploader
|
||||||
|
maxFileCount={8}
|
||||||
|
maxSize={8 * 1024 * 1024}
|
||||||
|
onValueChange={setFiles}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="password">{t('common.comingSoon')}</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploadDialog({ hideModal }: IModalProps<any>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={hideModal}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('fileManager.uploadFile')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<UploaderTabs></UploaderTabs>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" variant={'tertiary'} size={'sm'}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
332
web/src/components/file-uploader.tsx
Normal file
332
web/src/components/file-uploader.tsx
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
// https://github.com/sadmann7/file-uploader
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FileText, Upload, X } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import Dropzone, {
|
||||||
|
type DropzoneProps,
|
||||||
|
type FileRejection,
|
||||||
|
} from 'react-dropzone';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { useControllableState } from '@/hooks/use-controllable-state';
|
||||||
|
import { cn, formatBytes } from '@/lib/utils';
|
||||||
|
|
||||||
|
function isFileWithPreview(file: File): file is File & { preview: string } {
|
||||||
|
return 'preview' in file && typeof file.preview === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCardProps {
|
||||||
|
file: File;
|
||||||
|
onRemove: () => void;
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
file: File & { preview: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePreview({ file }: FilePreviewProps) {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={file.preview}
|
||||||
|
alt={file.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
loading="lazy"
|
||||||
|
className="aspect-square shrink-0 rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileText className="size-10 text-muted-foreground" aria-hidden="true" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileCard({ file, progress, onRemove }: FileCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center gap-2.5">
|
||||||
|
<div className="flex flex-1 gap-2.5">
|
||||||
|
{isFileWithPreview(file) ? <FilePreview file={file} /> : null}
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
<p className="line-clamp-1 text-sm font-medium text-foreground/80">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{progress ? <Progress value={progress} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Remove file</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/**
|
||||||
|
* Value of the uploader.
|
||||||
|
* @type File[]
|
||||||
|
* @default undefined
|
||||||
|
* @example value={files}
|
||||||
|
*/
|
||||||
|
value?: File[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to be called when the value changes.
|
||||||
|
* @type (files: File[]) => void
|
||||||
|
* @default undefined
|
||||||
|
* @example onValueChange={(files) => setFiles(files)}
|
||||||
|
*/
|
||||||
|
onValueChange?: (files: File[]) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to be called when files are uploaded.
|
||||||
|
* @type (files: File[]) => Promise<void>
|
||||||
|
* @default undefined
|
||||||
|
* @example onUpload={(files) => uploadFiles(files)}
|
||||||
|
*/
|
||||||
|
onUpload?: (files: File[]) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress of the uploaded files.
|
||||||
|
* @type Record<string, number> | undefined
|
||||||
|
* @default undefined
|
||||||
|
* @example progresses={{ "file1.png": 50 }}
|
||||||
|
*/
|
||||||
|
progresses?: Record<string, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepted file types for the uploader.
|
||||||
|
* @type { [key: string]: string[]}
|
||||||
|
* @default
|
||||||
|
* ```ts
|
||||||
|
* { "image/*": [] }
|
||||||
|
* ```
|
||||||
|
* @example accept={["image/png", "image/jpeg"]}
|
||||||
|
*/
|
||||||
|
accept?: DropzoneProps['accept'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum file size for the uploader.
|
||||||
|
* @type number | undefined
|
||||||
|
* @default 1024 * 1024 * 2 // 2MB
|
||||||
|
* @example maxSize={1024 * 1024 * 2} // 2MB
|
||||||
|
*/
|
||||||
|
maxSize?: DropzoneProps['maxSize'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of files for the uploader.
|
||||||
|
* @type number | undefined
|
||||||
|
* @default 1
|
||||||
|
* @example maxFileCount={4}
|
||||||
|
*/
|
||||||
|
maxFileCount?: DropzoneProps['maxFiles'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the uploader should accept multiple files.
|
||||||
|
* @type boolean
|
||||||
|
* @default false
|
||||||
|
* @example multiple
|
||||||
|
*/
|
||||||
|
multiple?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the uploader is disabled.
|
||||||
|
* @type boolean
|
||||||
|
* @default false
|
||||||
|
* @example disabled
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploader(props: FileUploaderProps) {
|
||||||
|
const {
|
||||||
|
value: valueProp,
|
||||||
|
onValueChange,
|
||||||
|
onUpload,
|
||||||
|
progresses,
|
||||||
|
accept = {
|
||||||
|
'image/*': [],
|
||||||
|
},
|
||||||
|
maxSize = 1024 * 1024 * 2,
|
||||||
|
maxFileCount = 1,
|
||||||
|
multiple = false,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
...dropzoneProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [files, setFiles] = useControllableState({
|
||||||
|
prop: valueProp,
|
||||||
|
onChange: onValueChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDrop = React.useCallback(
|
||||||
|
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||||
|
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
|
||||||
|
toast.error('Cannot upload more than 1 file at a time');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
|
||||||
|
toast.error(`Cannot upload more than ${maxFileCount} files`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = acceptedFiles.map((file) =>
|
||||||
|
Object.assign(file, {
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
|
||||||
|
|
||||||
|
setFiles(updatedFiles);
|
||||||
|
|
||||||
|
if (rejectedFiles.length > 0) {
|
||||||
|
rejectedFiles.forEach(({ file }) => {
|
||||||
|
toast.error(`File ${file.name} was rejected`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
onUpload &&
|
||||||
|
updatedFiles.length > 0 &&
|
||||||
|
updatedFiles.length <= maxFileCount
|
||||||
|
) {
|
||||||
|
const target =
|
||||||
|
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
|
||||||
|
|
||||||
|
toast.promise(onUpload(updatedFiles), {
|
||||||
|
loading: `Uploading ${target}...`,
|
||||||
|
success: () => {
|
||||||
|
setFiles([]);
|
||||||
|
return `${target} uploaded`;
|
||||||
|
},
|
||||||
|
error: `Failed to upload ${target}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
[files, maxFileCount, multiple, onUpload, setFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
function onRemove(index: number) {
|
||||||
|
if (!files) return;
|
||||||
|
const newFiles = files.filter((_, i) => i !== index);
|
||||||
|
setFiles(newFiles);
|
||||||
|
onValueChange?.(newFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke preview url when component unmounts
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!files) return;
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (isFileWithPreview(file)) {
|
||||||
|
URL.revokeObjectURL(file.preview);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-6 overflow-hidden">
|
||||||
|
<Dropzone
|
||||||
|
onDrop={onDrop}
|
||||||
|
accept={accept}
|
||||||
|
maxSize={maxSize}
|
||||||
|
maxFiles={maxFileCount}
|
||||||
|
multiple={maxFileCount > 1 || multiple}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
'group relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-2.5 text-center transition hover:bg-muted/25',
|
||||||
|
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
isDragActive && 'border-muted-foreground/50',
|
||||||
|
isDisabled && 'pointer-events-none opacity-60',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...dropzoneProps}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
{isDragActive ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
|
||||||
|
<div className="rounded-full border border-dashed p-3">
|
||||||
|
<Upload
|
||||||
|
className="size-7 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-muted-foreground">
|
||||||
|
Drop the files here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
|
||||||
|
<div className="rounded-full border border-dashed p-3">
|
||||||
|
<Upload
|
||||||
|
className="size-7 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
<p className="font-medium text-muted-foreground">
|
||||||
|
Drag {`'n'`} drop files here, or click to select files
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70">
|
||||||
|
You can upload
|
||||||
|
{maxFileCount > 1
|
||||||
|
? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
|
||||||
|
files (up to ${formatBytes(maxSize)} each)`
|
||||||
|
: ` a file with ${formatBytes(maxSize)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
{files?.length ? (
|
||||||
|
<ScrollArea className="h-fit w-full px-3">
|
||||||
|
<div className="flex max-h-48 flex-col gap-4">
|
||||||
|
{files?.map((file, index) => (
|
||||||
|
<FileCard
|
||||||
|
key={index}
|
||||||
|
file={file}
|
||||||
|
onRemove={() => onRemove(index)}
|
||||||
|
progress={progresses?.[file.name]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@ -11,7 +10,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
export function DialogDemo() {
|
export function RenameDialog() {
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@ -20,9 +19,6 @@ export function DialogDemo() {
|
|||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit profile</DialogTitle>
|
<DialogTitle>Edit profile</DialogTitle>
|
||||||
<DialogDescription>
|
|
||||||
Make changes to your profile here. Click save when you're done.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
28
web/src/components/ui/progress.tsx
Normal file
28
web/src/components/ui/progress.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
));
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Progress };
|
48
web/src/components/ui/scroll-area.tsx
Normal file
48
web/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'flex touch-none select-none transition-colors',
|
||||||
|
orientation === 'vertical' &&
|
||||||
|
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||||
|
orientation === 'horizontal' &&
|
||||||
|
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative overflow-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
'inline-flex h-10 items-center justify-center rounded-md bg-colors-background-inverse-standard p-1 text-colors-text-neutral-standard',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-colors-background-inverse-strong data-[state=active]:text-colors-text-inverse-strong data-[state=active]:shadow-sm',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
27
web/src/hooks/use-callback-ref.ts
Normal file
27
web/src/hooks/use-callback-ref.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
|
||||||
|
* prop or avoid re-executing effects when passed as a dependency
|
||||||
|
*/
|
||||||
|
function useCallbackRef<T extends (...args: never[]) => unknown>(
|
||||||
|
callback: T | undefined,
|
||||||
|
): T {
|
||||||
|
const callbackRef = React.useRef(callback);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://github.com/facebook/react/issues/19240
|
||||||
|
return React.useMemo(
|
||||||
|
() => ((...args) => callbackRef.current?.(...args)) as T,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useCallbackRef };
|
67
web/src/hooks/use-controllable-state.ts
Normal file
67
web/src/hooks/use-controllable-state.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { useCallbackRef } from '@/hooks/use-callback-ref';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
type UseControllableStateParams<T> = {
|
||||||
|
prop?: T | undefined;
|
||||||
|
defaultProp?: T | undefined;
|
||||||
|
onChange?: (state: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetStateFn<T> = (prevState?: T) => T;
|
||||||
|
|
||||||
|
function useUncontrolledState<T>({
|
||||||
|
defaultProp,
|
||||||
|
onChange,
|
||||||
|
}: Omit<UseControllableStateParams<T>, 'prop'>) {
|
||||||
|
const uncontrolledState = React.useState<T | undefined>(defaultProp);
|
||||||
|
const [value] = uncontrolledState;
|
||||||
|
const prevValueRef = React.useRef(value);
|
||||||
|
const handleChange = useCallbackRef(onChange);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (prevValueRef.current !== value) {
|
||||||
|
handleChange(value as T);
|
||||||
|
prevValueRef.current = value;
|
||||||
|
}
|
||||||
|
}, [value, prevValueRef, handleChange]);
|
||||||
|
|
||||||
|
return uncontrolledState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useControllableState<T>({
|
||||||
|
prop,
|
||||||
|
defaultProp,
|
||||||
|
onChange = () => {},
|
||||||
|
}: UseControllableStateParams<T>) {
|
||||||
|
const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
|
||||||
|
defaultProp,
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
const isControlled = prop !== undefined;
|
||||||
|
const value = isControlled ? prop : uncontrolledProp;
|
||||||
|
const handleChange = useCallbackRef(onChange);
|
||||||
|
|
||||||
|
const setValue: React.Dispatch<React.SetStateAction<T | undefined>> =
|
||||||
|
React.useCallback(
|
||||||
|
(nextValue) => {
|
||||||
|
if (isControlled) {
|
||||||
|
const setter = nextValue as SetStateFn<T>;
|
||||||
|
const value =
|
||||||
|
typeof nextValue === 'function' ? setter(prop) : nextValue;
|
||||||
|
if (value !== prop) handleChange(value as T);
|
||||||
|
} else {
|
||||||
|
setUncontrolledProp(nextValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isControlled, prop, setUncontrolledProp, handleChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useControllableState };
|
@ -4,3 +4,21 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBytes(
|
||||||
|
bytes: number,
|
||||||
|
opts: {
|
||||||
|
decimals?: number;
|
||||||
|
sizeType?: 'accurate' | 'normal';
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const { decimals = 0, sizeType = 'normal' } = opts;
|
||||||
|
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||||
|
if (bytes === 0) return '0 Byte';
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
|
||||||
|
sizeType === 'accurate' ? accurateSizes[i] ?? 'Bytes' : sizes[i] ?? 'Bytes'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
230
web/src/pages/dataset/dataset/hooks.ts
Normal file
230
web/src/pages/dataset/dataset/hooks.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { useSetModalState } from '@/hooks/common-hooks';
|
||||||
|
import {
|
||||||
|
useCreateNextDocument,
|
||||||
|
useNextWebCrawl,
|
||||||
|
useRunNextDocument,
|
||||||
|
useSaveNextDocumentName,
|
||||||
|
useSetNextDocumentParser,
|
||||||
|
useUploadNextDocument,
|
||||||
|
} from '@/hooks/document-hooks';
|
||||||
|
import { useGetKnowledgeSearchParams } from '@/hooks/route-hook';
|
||||||
|
import { IChangeParserConfigRequestBody } from '@/interfaces/request/document';
|
||||||
|
import { getUnSupportedFilesCount } from '@/utils/document-util';
|
||||||
|
import { UploadFile } from 'antd';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useNavigate } from 'umi';
|
||||||
|
|
||||||
|
export const useNavigateToOtherPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { knowledgeId } = useGetKnowledgeSearchParams();
|
||||||
|
|
||||||
|
const linkToUploadPage = useCallback(() => {
|
||||||
|
navigate(`/knowledge/dataset/upload?id=${knowledgeId}`);
|
||||||
|
}, [navigate, knowledgeId]);
|
||||||
|
|
||||||
|
const toChunk = useCallback((id: string) => {}, []);
|
||||||
|
|
||||||
|
return { linkToUploadPage, toChunk };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRenameDocument = (documentId: string) => {
|
||||||
|
const { saveName, loading } = useSaveNextDocumentName();
|
||||||
|
|
||||||
|
const {
|
||||||
|
visible: renameVisible,
|
||||||
|
hideModal: hideRenameModal,
|
||||||
|
showModal: showRenameModal,
|
||||||
|
} = useSetModalState();
|
||||||
|
|
||||||
|
const onRenameOk = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
const ret = await saveName({ documentId, name });
|
||||||
|
if (ret === 0) {
|
||||||
|
hideRenameModal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hideRenameModal, saveName, documentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
renameLoading: loading,
|
||||||
|
onRenameOk,
|
||||||
|
renameVisible,
|
||||||
|
hideRenameModal,
|
||||||
|
showRenameModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateEmptyDocument = () => {
|
||||||
|
const { createDocument, loading } = useCreateNextDocument();
|
||||||
|
|
||||||
|
const {
|
||||||
|
visible: createVisible,
|
||||||
|
hideModal: hideCreateModal,
|
||||||
|
showModal: showCreateModal,
|
||||||
|
} = useSetModalState();
|
||||||
|
|
||||||
|
const onCreateOk = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
const ret = await createDocument(name);
|
||||||
|
if (ret === 0) {
|
||||||
|
hideCreateModal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hideCreateModal, createDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createLoading: loading,
|
||||||
|
onCreateOk,
|
||||||
|
createVisible,
|
||||||
|
hideCreateModal,
|
||||||
|
showCreateModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useChangeDocumentParser = (documentId: string) => {
|
||||||
|
const { setDocumentParser, loading } = useSetNextDocumentParser();
|
||||||
|
|
||||||
|
const {
|
||||||
|
visible: changeParserVisible,
|
||||||
|
hideModal: hideChangeParserModal,
|
||||||
|
showModal: showChangeParserModal,
|
||||||
|
} = useSetModalState();
|
||||||
|
|
||||||
|
const onChangeParserOk = useCallback(
|
||||||
|
async (parserId: string, parserConfig: IChangeParserConfigRequestBody) => {
|
||||||
|
const ret = await setDocumentParser({
|
||||||
|
parserId,
|
||||||
|
documentId,
|
||||||
|
parserConfig,
|
||||||
|
});
|
||||||
|
if (ret === 0) {
|
||||||
|
hideChangeParserModal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hideChangeParserModal, setDocumentParser, documentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeParserLoading: loading,
|
||||||
|
onChangeParserOk,
|
||||||
|
changeParserVisible,
|
||||||
|
hideChangeParserModal,
|
||||||
|
showChangeParserModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetRowSelection = () => {
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
|
||||||
|
const rowSelection = {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (newSelectedRowKeys: React.Key[]) => {
|
||||||
|
setSelectedRowKeys(newSelectedRowKeys);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return rowSelection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHandleUploadDocument = () => {
|
||||||
|
const {
|
||||||
|
visible: documentUploadVisible,
|
||||||
|
hideModal: hideDocumentUploadModal,
|
||||||
|
showModal: showDocumentUploadModal,
|
||||||
|
} = useSetModalState();
|
||||||
|
const { uploadDocument, loading } = useUploadNextDocument();
|
||||||
|
|
||||||
|
const onDocumentUploadOk = useCallback(
|
||||||
|
async (fileList: UploadFile[]): Promise<number | undefined> => {
|
||||||
|
if (fileList.length > 0) {
|
||||||
|
const ret: any = await uploadDocument(fileList);
|
||||||
|
if (typeof ret?.message !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = getUnSupportedFilesCount(ret?.message);
|
||||||
|
/// 500 error code indicates that some file types are not supported
|
||||||
|
let code = ret?.code;
|
||||||
|
if (
|
||||||
|
ret?.code === 0 ||
|
||||||
|
(ret?.code === 500 && count !== fileList.length) // Some files were not uploaded successfully, but some were uploaded successfully.
|
||||||
|
) {
|
||||||
|
code = 0;
|
||||||
|
hideDocumentUploadModal();
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[uploadDocument, hideDocumentUploadModal],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentUploadLoading: loading,
|
||||||
|
onDocumentUploadOk,
|
||||||
|
documentUploadVisible,
|
||||||
|
hideDocumentUploadModal,
|
||||||
|
showDocumentUploadModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHandleWebCrawl = () => {
|
||||||
|
const {
|
||||||
|
visible: webCrawlUploadVisible,
|
||||||
|
hideModal: hideWebCrawlUploadModal,
|
||||||
|
showModal: showWebCrawlUploadModal,
|
||||||
|
} = useSetModalState();
|
||||||
|
const { webCrawl, loading } = useNextWebCrawl();
|
||||||
|
|
||||||
|
const onWebCrawlUploadOk = useCallback(
|
||||||
|
async (name: string, url: string) => {
|
||||||
|
const ret = await webCrawl({ name, url });
|
||||||
|
if (ret === 0) {
|
||||||
|
hideWebCrawlUploadModal();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
},
|
||||||
|
[webCrawl, hideWebCrawlUploadModal],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
webCrawlUploadLoading: loading,
|
||||||
|
onWebCrawlUploadOk,
|
||||||
|
webCrawlUploadVisible,
|
||||||
|
hideWebCrawlUploadModal,
|
||||||
|
showWebCrawlUploadModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHandleRunDocumentByIds = (id: string) => {
|
||||||
|
const { runDocumentByIds, loading } = useRunNextDocument();
|
||||||
|
const [currentId, setCurrentId] = useState<string>('');
|
||||||
|
const isLoading = loading && currentId !== '' && currentId === id;
|
||||||
|
|
||||||
|
const handleRunDocumentByIds = async (
|
||||||
|
documentId: string,
|
||||||
|
isRunning: boolean,
|
||||||
|
shouldDelete: boolean = false,
|
||||||
|
) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentId(documentId);
|
||||||
|
try {
|
||||||
|
await runDocumentByIds({
|
||||||
|
documentIds: [documentId],
|
||||||
|
run: isRunning ? 2 : 1,
|
||||||
|
shouldDelete,
|
||||||
|
});
|
||||||
|
setCurrentId('');
|
||||||
|
} catch (error) {
|
||||||
|
setCurrentId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleRunDocumentByIds,
|
||||||
|
loading: isLoading,
|
||||||
|
};
|
||||||
|
};
|
@ -1,15 +1,32 @@
|
|||||||
|
import { FileUploadDialog } from '@/components/file-upload-dialog';
|
||||||
import ListFilterBar from '@/components/list-filter-bar';
|
import ListFilterBar from '@/components/list-filter-bar';
|
||||||
import { Upload } from 'lucide-react';
|
import { Upload } from 'lucide-react';
|
||||||
import { DatasetTable } from './dataset-table';
|
import { DatasetTable } from './dataset-table';
|
||||||
|
import { useHandleUploadDocument } from './hooks';
|
||||||
|
|
||||||
export default function Dataset() {
|
export default function Dataset() {
|
||||||
|
const {
|
||||||
|
documentUploadVisible,
|
||||||
|
hideDocumentUploadModal,
|
||||||
|
showDocumentUploadModal,
|
||||||
|
onDocumentUploadOk,
|
||||||
|
documentUploadLoading,
|
||||||
|
} = useHandleUploadDocument();
|
||||||
return (
|
return (
|
||||||
<section className="p-8 text-foreground">
|
<section className="p-8 text-foreground">
|
||||||
<ListFilterBar title="Files">
|
<ListFilterBar title="Files" showDialog={showDocumentUploadModal}>
|
||||||
<Upload />
|
<Upload />
|
||||||
Upload file
|
Upload file
|
||||||
</ListFilterBar>
|
</ListFilterBar>
|
||||||
<DatasetTable></DatasetTable>
|
<DatasetTable></DatasetTable>
|
||||||
|
|
||||||
|
{documentUploadVisible && (
|
||||||
|
<FileUploadDialog
|
||||||
|
hideModal={hideDocumentUploadModal}
|
||||||
|
onOk={onDocumentUploadOk}
|
||||||
|
loading={documentUploadLoading}
|
||||||
|
></FileUploadDialog>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import ListFilterBar from '@/components/list-filter-bar';
|
import ListFilterBar from '@/components/list-filter-bar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
|
||||||
import { ChevronRight, MoreHorizontal, Plus } from 'lucide-react';
|
import { ChevronRight, MoreHorizontal, Plus } from 'lucide-react';
|
||||||
import { DatasetCreatingDialog } from './dataset-creating-dialog';
|
import { DatasetCreatingDialog } from './dataset-creating-dialog';
|
||||||
import { useSaveKnowledge } from './hooks';
|
import { useSaveKnowledge } from './hooks';
|
||||||
@ -88,6 +89,8 @@ export default function Datasets() {
|
|||||||
onCreateOk,
|
onCreateOk,
|
||||||
loading: creatingLoading,
|
loading: creatingLoading,
|
||||||
} = useSaveKnowledge();
|
} = useSaveKnowledge();
|
||||||
|
const { navigateToDataset } = useNavigatePage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="p-8 text-foreground">
|
<section className="p-8 text-foreground">
|
||||||
<ListFilterBar title="Datasets" showDialog={showModal}>
|
<ListFilterBar title="Datasets" showDialog={showModal}>
|
||||||
@ -122,7 +125,11 @@ export default function Datasets() {
|
|||||||
Created {dataset.created}
|
Created {dataset.created}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="icon">
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={navigateToDataset}
|
||||||
|
>
|
||||||
<ChevronRight className="h-6 w-6" />
|
<ChevronRight className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user