From 4a9708889eedc7abafd23be026f0942bd139f7c1 Mon Sep 17 00:00:00 2001 From: balibabu Date: Mon, 7 Jul 2025 12:18:18 +0800 Subject: [PATCH] Feat: Support uploading files when running agent #3221 (#8697) ### What problem does this PR solve? Feat: Support uploading files when running agent #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality) --- web/src/components/file-upload.tsx | 1434 +++++++++++++++++ web/src/hooks/use-agent-request.ts | 32 + web/src/pages/agent/chat/box.tsx | 4 +- web/src/pages/agent/debug-content/index.tsx | 27 +- .../pages/agent/debug-content/uploader.tsx | 116 ++ web/src/pages/agent/utils.ts | 21 +- web/src/pages/agent/utils/chat.ts | 21 + web/src/services/flow-service.ts | 5 + web/src/utils/api.ts | 1 + 9 files changed, 1634 insertions(+), 27 deletions(-) create mode 100644 web/src/components/file-upload.tsx create mode 100644 web/src/pages/agent/debug-content/uploader.tsx create mode 100644 web/src/pages/agent/utils/chat.ts diff --git a/web/src/components/file-upload.tsx b/web/src/components/file-upload.tsx new file mode 100644 index 000000000..d880e4342 --- /dev/null +++ b/web/src/components/file-upload.tsx @@ -0,0 +1,1434 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { Slot } from '@radix-ui/react-slot'; +import { + FileArchiveIcon, + FileAudioIcon, + FileCodeIcon, + FileCogIcon, + FileIcon, + FileTextIcon, + FileVideoIcon, +} from 'lucide-react'; +import * as React from 'react'; + +const ROOT_NAME = 'FileUpload'; +const DROPZONE_NAME = 'FileUploadDropzone'; +const TRIGGER_NAME = 'FileUploadTrigger'; +const LIST_NAME = 'FileUploadList'; +const ITEM_NAME = 'FileUploadItem'; +const ITEM_PREVIEW_NAME = 'FileUploadItemPreview'; +const ITEM_METADATA_NAME = 'FileUploadItemMetadata'; +const ITEM_PROGRESS_NAME = 'FileUploadItemProgress'; +const ITEM_DELETE_NAME = 'FileUploadItemDelete'; +const CLEAR_NAME = 'FileUploadClear'; + +function useLazyRef(fn: () => T) { + const ref = React.useRef(null); + + if (ref.current === null) { + ref.current = fn(); + } + + return ref as React.RefObject; +} + +type Direction = 'ltr' | 'rtl'; + +const DirectionContext = React.createContext(undefined); + +function useDirection(dirProp?: Direction): Direction { + const contextDir = React.useContext(DirectionContext); + return dirProp ?? contextDir ?? 'ltr'; +} + +interface FileState { + file: File; + progress: number; + error?: string; + status: 'idle' | 'uploading' | 'error' | 'success'; +} + +interface StoreState { + files: Map; + dragOver: boolean; + invalid: boolean; +} + +type StoreAction = + | { type: 'ADD_FILES'; files: File[] } + | { type: 'SET_FILES'; files: File[] } + | { type: 'SET_PROGRESS'; file: File; progress: number } + | { type: 'SET_SUCCESS'; file: File } + | { type: 'SET_ERROR'; file: File; error: string } + | { type: 'REMOVE_FILE'; file: File } + | { type: 'SET_DRAG_OVER'; dragOver: boolean } + | { type: 'SET_INVALID'; invalid: boolean } + | { type: 'CLEAR' }; + +function createStore( + listeners: Set<() => void>, + files: Map, + urlCache: WeakMap, + invalid: boolean, + onValueChange?: (files: File[]) => void, +) { + let state: StoreState = { + files, + dragOver: false, + invalid: invalid, + }; + + function reducer(state: StoreState, action: StoreAction): StoreState { + switch (action.type) { + case 'ADD_FILES': { + for (const file of action.files) { + files.set(file, { + file, + progress: 0, + status: 'idle', + }); + } + + if (onValueChange) { + const fileList = Array.from(files.values()).map( + (fileState) => fileState.file, + ); + onValueChange(fileList); + } + return { ...state, files }; + } + + case 'SET_FILES': { + const newFileSet = new Set(action.files); + for (const existingFile of files.keys()) { + if (!newFileSet.has(existingFile)) { + files.delete(existingFile); + } + } + + for (const file of action.files) { + const existingState = files.get(file); + if (!existingState) { + files.set(file, { + file, + progress: 0, + status: 'idle', + }); + } + } + return { ...state, files }; + } + + case 'SET_PROGRESS': { + const fileState = files.get(action.file); + if (fileState) { + files.set(action.file, { + ...fileState, + progress: action.progress, + status: 'uploading', + }); + } + return { ...state, files }; + } + + case 'SET_SUCCESS': { + const fileState = files.get(action.file); + if (fileState) { + files.set(action.file, { + ...fileState, + progress: 100, + status: 'success', + }); + } + return { ...state, files }; + } + + case 'SET_ERROR': { + const fileState = files.get(action.file); + if (fileState) { + files.set(action.file, { + ...fileState, + error: action.error, + status: 'error', + }); + } + return { ...state, files }; + } + + case 'REMOVE_FILE': { + if (urlCache) { + const cachedUrl = urlCache.get(action.file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + urlCache.delete(action.file); + } + } + + files.delete(action.file); + + if (onValueChange) { + const fileList = Array.from(files.values()).map( + (fileState) => fileState.file, + ); + onValueChange(fileList); + } + return { ...state, files }; + } + + case 'SET_DRAG_OVER': { + return { ...state, dragOver: action.dragOver }; + } + + case 'SET_INVALID': { + return { ...state, invalid: action.invalid }; + } + + case 'CLEAR': { + if (urlCache) { + for (const file of files.keys()) { + const cachedUrl = urlCache.get(file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + urlCache.delete(file); + } + } + } + + files.clear(); + if (onValueChange) { + onValueChange([]); + } + return { ...state, files, invalid: false }; + } + + default: + return state; + } + } + + function getState() { + return state; + } + + function dispatch(action: StoreAction) { + state = reducer(state, action); + for (const listener of listeners) { + listener(); + } + } + + function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + return { getState, dispatch, subscribe }; +} + +const StoreContext = React.createContext | null>( + null, +); + +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +function useStore(selector: (state: StoreState) => T): T { + const store = useStoreContext(ROOT_NAME); + + const lastValueRef = useLazyRef<{ value: T; state: StoreState } | null>( + () => null, + ); + + const getSnapshot = React.useCallback(() => { + const state = store.getState(); + const prevValue = lastValueRef.current; + + if (prevValue && prevValue.state === state) { + return prevValue.value; + } + + const nextValue = selector(state); + lastValueRef.current = { value: nextValue, state }; + return nextValue; + }, [store, selector, lastValueRef]); + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +interface FileUploadContextValue { + inputId: string; + dropzoneId: string; + listId: string; + labelId: string; + disabled: boolean; + dir: Direction; + inputRef: React.RefObject; + urlCache: WeakMap; +} + +const FileUploadContext = React.createContext( + null, +); + +function useFileUploadContext(consumerName: string) { + const context = React.useContext(FileUploadContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface FileUploadRootProps + extends Omit< + React.ComponentPropsWithoutRef<'div'>, + 'defaultValue' | 'onChange' + > { + value?: File[]; + defaultValue?: File[]; + onValueChange?: (files: File[]) => void; + onAccept?: (files: File[]) => void; + onFileAccept?: (file: File) => void; + onFileReject?: (file: File, message: string) => void; + onFileValidate?: (file: File) => string | null | undefined; + onUpload?: ( + files: File[], + options: { + onProgress: (file: File, progress: number) => void; + onSuccess: (file: File) => void; + onError: (file: File, error: Error) => void; + }, + ) => Promise | void; + accept?: string; + maxFiles?: number; + maxSize?: number; + dir?: Direction; + label?: string; + name?: string; + asChild?: boolean; + disabled?: boolean; + invalid?: boolean; + multiple?: boolean; + required?: boolean; +} + +function FileUploadRoot(props: FileUploadRootProps) { + const { + value, + defaultValue, + onValueChange, + onAccept, + onFileAccept, + onFileReject, + onFileValidate, + onUpload, + accept, + maxFiles, + maxSize, + dir: dirProp, + label, + name, + asChild, + disabled = false, + invalid = false, + multiple = false, + required = false, + children, + className, + ...rootProps + } = props; + + const inputId = React.useId(); + const dropzoneId = React.useId(); + const listId = React.useId(); + const labelId = React.useId(); + + const dir = useDirection(dirProp); + const listeners = useLazyRef(() => new Set<() => void>()).current; + const files = useLazyRef>(() => new Map()).current; + const urlCache = useLazyRef(() => new WeakMap()).current; + const inputRef = React.useRef(null); + const isControlled = value !== undefined; + + const store = React.useMemo( + () => createStore(listeners, files, urlCache, invalid, onValueChange), + [listeners, files, invalid, onValueChange, urlCache], + ); + + const acceptTypes = React.useMemo( + () => accept?.split(',').map((t) => t.trim()) ?? null, + [accept], + ); + + const onProgress = useLazyRef(() => { + let frame = 0; + return (file: File, progress: number) => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + store.dispatch({ + type: 'SET_PROGRESS', + file, + progress: Math.min(Math.max(0, progress), 100), + }); + }); + }; + }).current; + + React.useEffect(() => { + if (isControlled) { + store.dispatch({ type: 'SET_FILES', files: value }); + } else if ( + defaultValue && + defaultValue.length > 0 && + !store.getState().files.size + ) { + store.dispatch({ type: 'SET_FILES', files: defaultValue }); + } + }, [value, defaultValue, isControlled, store]); + + React.useEffect(() => { + return () => { + for (const file of files.keys()) { + const cachedUrl = urlCache.get(file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + } + } + }; + }, [files, urlCache]); + + const onFilesChange = React.useCallback( + (originalFiles: File[]) => { + if (disabled) return; + + let filesToProcess = [...originalFiles]; + let invalid = false; + + if (maxFiles) { + const currentCount = store.getState().files.size; + const remainingSlotCount = Math.max(0, maxFiles - currentCount); + + if (remainingSlotCount < filesToProcess.length) { + const rejectedFiles = filesToProcess.slice(remainingSlotCount); + invalid = true; + + filesToProcess = filesToProcess.slice(0, remainingSlotCount); + + for (const file of rejectedFiles) { + let rejectionMessage = `Maximum ${maxFiles} files allowed`; + + if (onFileValidate) { + const validationMessage = onFileValidate(file); + if (validationMessage) { + rejectionMessage = validationMessage; + } + } + + onFileReject?.(file, rejectionMessage); + } + } + } + + const acceptedFiles: File[] = []; + const rejectedFiles: { file: File; message: string }[] = []; + + for (const file of filesToProcess) { + let rejected = false; + let rejectionMessage = ''; + + if (onFileValidate) { + const validationMessage = onFileValidate(file); + if (validationMessage) { + rejectionMessage = validationMessage; + onFileReject?.(file, rejectionMessage); + rejected = true; + invalid = true; + continue; + } + } + + if (acceptTypes) { + const fileType = file.type; + const fileExtension = `.${file.name.split('.').pop()}`; + + if ( + !acceptTypes.some( + (type) => + type === fileType || + type === fileExtension || + (type.includes('/*') && + fileType.startsWith(type.replace('/*', '/'))), + ) + ) { + rejectionMessage = 'File type not accepted'; + onFileReject?.(file, rejectionMessage); + rejected = true; + invalid = true; + } + } + + if (maxSize && file.size > maxSize) { + rejectionMessage = 'File too large'; + onFileReject?.(file, rejectionMessage); + rejected = true; + invalid = true; + } + + if (!rejected) { + acceptedFiles.push(file); + } else { + rejectedFiles.push({ file, message: rejectionMessage }); + } + } + + if (invalid) { + store.dispatch({ type: 'SET_INVALID', invalid }); + setTimeout(() => { + store.dispatch({ type: 'SET_INVALID', invalid: false }); + }, 2000); + } + + if (acceptedFiles.length > 0) { + store.dispatch({ type: 'ADD_FILES', files: acceptedFiles }); + + if (isControlled && onValueChange) { + const currentFiles = Array.from(store.getState().files.values()).map( + (f) => f.file, + ); + onValueChange([...currentFiles]); + } + + if (onAccept) { + onAccept(acceptedFiles); + } + + for (const file of acceptedFiles) { + onFileAccept?.(file); + } + + if (onUpload) { + requestAnimationFrame(() => { + onFilesUpload(acceptedFiles); + }); + } + } + }, + [ + store, + isControlled, + onValueChange, + onAccept, + onFileAccept, + onUpload, + maxFiles, + onFileValidate, + onFileReject, + acceptTypes, + maxSize, + disabled, + ], + ); + + const onFilesUpload = React.useCallback( + async (files: File[]) => { + try { + for (const file of files) { + store.dispatch({ type: 'SET_PROGRESS', file, progress: 0 }); + } + + if (onUpload) { + await onUpload(files, { + onProgress, + onSuccess: (file) => { + store.dispatch({ type: 'SET_SUCCESS', file }); + }, + onError: (file, error) => { + store.dispatch({ + type: 'SET_ERROR', + file, + error: error.message ?? 'Upload failed', + }); + }, + }); + } else { + for (const file of files) { + store.dispatch({ type: 'SET_SUCCESS', file }); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Upload failed'; + for (const file of files) { + store.dispatch({ + type: 'SET_ERROR', + file, + error: errorMessage, + }); + } + } + }, + [store, onUpload, onProgress], + ); + + const onInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + onFilesChange(files); + event.target.value = ''; + }, + [onFilesChange], + ); + + const contextValue = React.useMemo( + () => ({ + dropzoneId, + inputId, + listId, + labelId, + dir, + disabled, + inputRef, + urlCache, + }), + [dropzoneId, inputId, listId, labelId, dir, disabled, urlCache], + ); + + const RootPrimitive = asChild ? Slot : 'div'; + + return ( + + + + {children} + + + {label ?? 'File upload'} + + + + + ); +} + +interface FileUploadDropzoneProps + extends React.ComponentPropsWithoutRef<'div'> { + asChild?: boolean; +} + +function FileUploadDropzone(props: FileUploadDropzoneProps) { + const { + asChild, + className, + onClick: onClickProp, + onDragOver: onDragOverProp, + onDragEnter: onDragEnterProp, + onDragLeave: onDragLeaveProp, + onDrop: onDropProp, + onPaste: onPasteProp, + onKeyDown: onKeyDownProp, + ...dropzoneProps + } = props; + + const context = useFileUploadContext(DROPZONE_NAME); + const store = useStoreContext(DROPZONE_NAME); + const dragOver = useStore((state) => state.dragOver); + const invalid = useStore((state) => state.invalid); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + + if (event.defaultPrevented) return; + + const target = event.target; + + const isFromTrigger = + target instanceof HTMLElement && + target.closest('[data-slot="file-upload-trigger"]'); + + if (!isFromTrigger) { + context.inputRef.current?.click(); + } + }, + [context.inputRef, onClickProp], + ); + + const onDragOver = React.useCallback( + (event: React.DragEvent) => { + onDragOverProp?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: true }); + }, + [store, onDragOverProp], + ); + + const onDragEnter = React.useCallback( + (event: React.DragEvent) => { + onDragEnterProp?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: true }); + }, + [store, onDragEnterProp], + ); + + const onDragLeave = React.useCallback( + (event: React.DragEvent) => { + onDragLeaveProp?.(event); + + if (event.defaultPrevented) return; + + const relatedTarget = event.relatedTarget; + if ( + relatedTarget && + relatedTarget instanceof Node && + event.currentTarget.contains(relatedTarget) + ) { + return; + } + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); + }, + [store, onDragLeaveProp], + ); + + const onDrop = React.useCallback( + (event: React.DragEvent) => { + onDropProp?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); + + const files = Array.from(event.dataTransfer.files); + const inputElement = context.inputRef.current; + if (!inputElement) return; + + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + + inputElement.files = dataTransfer.files; + inputElement.dispatchEvent(new Event('change', { bubbles: true })); + }, + [store, context.inputRef, onDropProp], + ); + + const onPaste = React.useCallback( + (event: React.ClipboardEvent) => { + onPasteProp?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); + + const items = event.clipboardData?.items; + if (!items) return; + + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item?.kind === 'file') { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length === 0) return; + + const inputElement = context.inputRef.current; + if (!inputElement) return; + + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + + inputElement.files = dataTransfer.files; + inputElement.dispatchEvent(new Event('change', { bubbles: true })); + }, + [store, context.inputRef, onPasteProp], + ); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + onKeyDownProp?.(event); + + if ( + !event.defaultPrevented && + (event.key === 'Enter' || event.key === ' ') + ) { + event.preventDefault(); + context.inputRef.current?.click(); + } + }, + [context.inputRef, onKeyDownProp], + ); + + const DropzonePrimitive = asChild ? Slot : 'div'; + + return ( + + ); +} + +interface FileUploadTriggerProps + extends React.ComponentPropsWithoutRef<'button'> { + asChild?: boolean; +} + +function FileUploadTrigger(props: FileUploadTriggerProps) { + const { asChild, onClick: onClickProp, ...triggerProps } = props; + const context = useFileUploadContext(TRIGGER_NAME); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + + if (event.defaultPrevented) return; + + context.inputRef.current?.click(); + }, + [context.inputRef, onClickProp], + ); + + const TriggerPrimitive = asChild ? Slot : 'button'; + + return ( + + ); +} + +interface FileUploadListProps extends React.ComponentPropsWithoutRef<'div'> { + orientation?: 'horizontal' | 'vertical'; + asChild?: boolean; + forceMount?: boolean; +} + +function FileUploadList(props: FileUploadListProps) { + const { + className, + orientation = 'vertical', + asChild, + forceMount, + ...listProps + } = props; + + const context = useFileUploadContext(LIST_NAME); + const fileCount = useStore((state) => state.files.size); + const shouldRender = forceMount || fileCount > 0; + + if (!shouldRender) return null; + + const ListPrimitive = asChild ? Slot : 'div'; + + return ( + + ); +} + +interface FileUploadItemContextValue { + id: string; + fileState: FileState | undefined; + nameId: string; + sizeId: string; + statusId: string; + messageId: string; +} + +const FileUploadItemContext = + React.createContext(null); + +function useFileUploadItemContext(consumerName: string) { + const context = React.useContext(FileUploadItemContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); + } + return context; +} + +interface FileUploadItemProps extends React.ComponentPropsWithoutRef<'div'> { + value: File; + asChild?: boolean; +} + +function FileUploadItem(props: FileUploadItemProps) { + const { value, asChild, className, ...itemProps } = props; + + const id = React.useId(); + const statusId = `${id}-status`; + const nameId = `${id}-name`; + const sizeId = `${id}-size`; + const messageId = `${id}-message`; + + const context = useFileUploadContext(ITEM_NAME); + const fileState = useStore((state) => state.files.get(value)); + const fileCount = useStore((state) => state.files.size); + const fileIndex = useStore((state) => { + const files = Array.from(state.files.keys()); + return files.indexOf(value) + 1; + }); + + const itemContext = React.useMemo( + () => ({ + id, + fileState, + nameId, + sizeId, + statusId, + messageId, + }), + [id, fileState, statusId, nameId, sizeId, messageId], + ); + + if (!fileState) return null; + + const statusText = fileState.error + ? `Error: ${fileState.error}` + : fileState.status === 'uploading' + ? `Uploading: ${fileState.progress}% complete` + : fileState.status === 'success' + ? 'Upload complete' + : 'Ready to upload'; + + const ItemPrimitive = asChild ? Slot : 'div'; + + return ( + + + {props.children} + + {statusText} + + + + ); +} + +function formatBytes(bytes: number) { + if (bytes === 0) return '0 B'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / 1024 ** i).toFixed(i ? 1 : 0)} ${sizes[i]}`; +} + +function getFileIcon(file: File) { + const type = file.type; + const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; + + if (type.startsWith('video/')) { + return ; + } + + if (type.startsWith('audio/')) { + return ; + } + + if ( + type.startsWith('text/') || + ['txt', 'md', 'rtf', 'pdf'].includes(extension) + ) { + return ; + } + + if ( + [ + 'html', + 'css', + 'js', + 'jsx', + 'ts', + 'tsx', + 'json', + 'xml', + 'php', + 'py', + 'rb', + 'java', + 'c', + 'cpp', + 'cs', + ].includes(extension) + ) { + return ; + } + + if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) { + return ; + } + + if ( + ['exe', 'msi', 'app', 'apk', 'deb', 'rpm'].includes(extension) || + type.startsWith('application/') + ) { + return ; + } + + return ; +} + +interface FileUploadItemPreviewProps + extends React.ComponentPropsWithoutRef<'div'> { + render?: (file: File) => React.ReactNode; + asChild?: boolean; +} + +function FileUploadItemPreview(props: FileUploadItemPreviewProps) { + const { render, asChild, children, className, ...previewProps } = props; + + const itemContext = useFileUploadItemContext(ITEM_PREVIEW_NAME); + const context = useFileUploadContext(ITEM_PREVIEW_NAME); + + const onPreviewRender = React.useCallback( + (file: File) => { + if (render) return render(file); + + if (itemContext.fileState?.file.type.startsWith('image/')) { + let url = context.urlCache.get(file); + if (!url) { + url = URL.createObjectURL(file); + context.urlCache.set(file, url); + } + + return ( + {file.name} + ); + } + + return getFileIcon(file); + }, + [render, itemContext.fileState?.file.type, context.urlCache], + ); + + if (!itemContext.fileState) return null; + + const ItemPreviewPrimitive = asChild ? Slot : 'div'; + + return ( + svg]:size-10', + className, + )} + > + {onPreviewRender(itemContext.fileState.file)} + {children} + + ); +} + +interface FileUploadItemMetadataProps + extends React.ComponentPropsWithoutRef<'div'> { + asChild?: boolean; + size?: 'default' | 'sm'; +} + +function FileUploadItemMetadata(props: FileUploadItemMetadataProps) { + const { + asChild, + size = 'default', + children, + className, + ...metadataProps + } = props; + + const context = useFileUploadContext(ITEM_METADATA_NAME); + const itemContext = useFileUploadItemContext(ITEM_METADATA_NAME); + + if (!itemContext.fileState) return null; + + const ItemMetadataPrimitive = asChild ? Slot : 'div'; + + return ( + + {children ?? ( + <> + + {itemContext.fileState.file.name} + + + {formatBytes(itemContext.fileState.file.size)} + + {itemContext.fileState.error && ( + + {itemContext.fileState.error} + + )} + + )} + + ); +} +interface FileUploadItemProgressProps + extends React.ComponentPropsWithoutRef<'div'> { + variant?: 'linear' | 'circular' | 'fill'; + size?: number; + asChild?: boolean; + forceMount?: boolean; +} + +function FileUploadItemProgress(props: FileUploadItemProgressProps) { + const { + variant = 'linear', + size = 40, + asChild, + forceMount, + className, + ...progressProps + } = props; + + const itemContext = useFileUploadItemContext(ITEM_PROGRESS_NAME); + + if (!itemContext.fileState) return null; + + const shouldRender = forceMount || itemContext.fileState.progress !== 100; + + if (!shouldRender) return null; + + const ItemProgressPrimitive = asChild ? Slot : 'div'; + + switch (variant) { + case 'circular': { + const circumference = 2 * Math.PI * ((size - 4) / 2); + const strokeDashoffset = + circumference - (itemContext.fileState.progress / 100) * circumference; + + return ( + + + + + + + ); + } + + case 'fill': { + const progressPercentage = itemContext.fileState.progress; + const topInset = 100 - progressPercentage; + + return ( + + ); + } + + default: + return ( + +
+ + ); + } +} + +interface FileUploadItemDeleteProps + extends React.ComponentPropsWithoutRef<'button'> { + asChild?: boolean; +} + +function FileUploadItemDelete(props: FileUploadItemDeleteProps) { + const { asChild, onClick: onClickProp, ...deleteProps } = props; + + const store = useStoreContext(ITEM_DELETE_NAME); + const itemContext = useFileUploadItemContext(ITEM_DELETE_NAME); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + + if (!itemContext.fileState || event.defaultPrevented) return; + + store.dispatch({ + type: 'REMOVE_FILE', + file: itemContext.fileState.file, + }); + }, + [store, itemContext.fileState, onClickProp], + ); + + if (!itemContext.fileState) return null; + + const ItemDeletePrimitive = asChild ? Slot : 'button'; + + return ( + + ); +} + +interface FileUploadClearProps + extends React.ComponentPropsWithoutRef<'button'> { + forceMount?: boolean; + asChild?: boolean; +} + +function FileUploadClear(props: FileUploadClearProps) { + const { + asChild, + forceMount, + disabled, + onClick: onClickProp, + ...clearProps + } = props; + + const context = useFileUploadContext(CLEAR_NAME); + const store = useStoreContext(CLEAR_NAME); + const fileCount = useStore((state) => state.files.size); + + const isDisabled = disabled || context.disabled; + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + + if (event.defaultPrevented) return; + + store.dispatch({ type: 'CLEAR' }); + }, + [store, onClickProp], + ); + + const shouldRender = forceMount || fileCount > 0; + + if (!shouldRender) return null; + + const ClearPrimitive = asChild ? Slot : 'button'; + + return ( + + ); +} + +export { + FileUploadClear as Clear, + FileUploadDropzone as Dropzone, + FileUploadRoot as FileUpload, + FileUploadClear, + FileUploadDropzone, + FileUploadItem, + FileUploadItemDelete, + FileUploadItemMetadata, + FileUploadItemPreview, + FileUploadItemProgress, + FileUploadList, + FileUploadTrigger, + FileUploadItem as Item, + FileUploadItemDelete as ItemDelete, + FileUploadItemMetadata as ItemMetadata, + FileUploadItemPreview as ItemPreview, + FileUploadItemProgress as ItemProgress, + FileUploadList as List, + // + FileUploadRoot as Root, + FileUploadTrigger as Trigger, + // + useStore as useFileUpload, + // + type FileUploadRootProps as FileUploadProps, +}; diff --git a/web/src/hooks/use-agent-request.ts b/web/src/hooks/use-agent-request.ts index 016718af8..cf0996537 100644 --- a/web/src/hooks/use-agent-request.ts +++ b/web/src/hooks/use-agent-request.ts @@ -26,6 +26,7 @@ export const enum AgentApiAction { ResetAgent = 'resetAgent', SetAgent = 'setAgent', FetchAgentTemplates = 'fetchAgentTemplates', + UploadCanvasFile = 'uploadCanvasFile', } export const EmptyDsl = { @@ -268,3 +269,34 @@ export const useSetAgent = () => { return { data, loading, setAgent: mutateAsync }; }; + +export const useUploadCanvasFile = () => { + const { + data, + isPending: loading, + mutateAsync, + } = useMutation({ + mutationKey: [AgentApiAction.UploadCanvasFile], + mutationFn: async (body: any) => { + let nextBody = body; + try { + if (Array.isArray(body)) { + nextBody = new FormData(); + body.forEach((file: File) => { + nextBody.append('file', file as any); + }); + } + + const { data } = await flowService.uploadCanvasFile(nextBody); + if (data?.code === 0) { + message.success(i18n.t('message.uploaded')); + } + return data; + } catch (error) { + message.error('error'); + } + }, + }); + + return { data, loading, uploadCanvasFile: mutateAsync }; +}; diff --git a/web/src/pages/agent/chat/box.tsx b/web/src/pages/agent/chat/box.tsx index 34d886ef7..bae4c50bb 100644 --- a/web/src/pages/agent/chat/box.tsx +++ b/web/src/pages/agent/chat/box.tsx @@ -1,6 +1,5 @@ import { MessageType } from '@/constants/chat'; import { useGetFileIcon } from '@/pages/chat/hooks'; -import { buildMessageItemReference } from '@/pages/chat/utils'; import { Spin } from 'antd'; import { useSendNextMessage } from './hooks'; @@ -19,6 +18,7 @@ import { useParams } from 'umi'; import DebugContent from '../debug-content'; import { BeginQuery } from '../interface'; import { buildBeginQueryWithObject } from '../utils'; +import { buildAgentMessageItemReference } from '../utils/chat'; const AgentChatBox = () => { const { @@ -88,7 +88,7 @@ const AgentChatBox = () => { avatar={userInfo.avatar} avatarDialog={canvasInfo.avatar} item={message} - reference={buildMessageItemReference( + reference={buildAgentMessageItemReference( { message: derivedMessages, reference }, message, )} diff --git a/web/src/pages/agent/debug-content/index.tsx b/web/src/pages/agent/debug-content/index.tsx index 9228bed85..ae0ab4120 100644 --- a/web/src/pages/agent/debug-content/index.tsx +++ b/web/src/pages/agent/debug-content/index.tsx @@ -1,4 +1,3 @@ -import { FileUploader } from '@/components/file-uploader'; import { ButtonLoading } from '@/components/ui/button'; import { Form, @@ -19,6 +18,7 @@ import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { BeginQueryType } from '../constant'; import { BeginQuery } from '../interface'; +import { FileUploadDirectUpload } from './uploader'; export const BeginQueryComponentMap = { [BeginQueryType.Line]: 'string', @@ -71,7 +71,7 @@ const DebugContent = ({ } else if (type === BeginQueryType.Integer) { fieldSchema = z.coerce.number(); } else { - fieldSchema = z.instanceof(File); + fieldSchema = z.record(z.any()); } if (cur.optional) { @@ -165,18 +165,16 @@ const DebugContent = ({ (
{t('assistantAvatar')} - + onChange={field.onChange} + > @@ -232,18 +230,7 @@ const DebugContent = ({ (values: z.infer) => { const nextValues = Object.entries(values).map(([key, value]) => { const item = parameters[Number(key)]; - let nextValue = value; - if (Array.isArray(value)) { - nextValue = ``; - - value.forEach((x) => { - nextValue += - x?.originFileObj instanceof File - ? `${x.name}\n${x.response?.data}\n----\n` - : `${x.url}\n${x.result}\n----\n`; - }); - } - return { ...item, value: nextValue }; + return { ...item, value }; }); ok(nextValues); diff --git a/web/src/pages/agent/debug-content/uploader.tsx b/web/src/pages/agent/debug-content/uploader.tsx new file mode 100644 index 000000000..569f7ad3a --- /dev/null +++ b/web/src/pages/agent/debug-content/uploader.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { + FileUpload, + FileUploadDropzone, + FileUploadItem, + FileUploadItemDelete, + FileUploadItemMetadata, + FileUploadItemPreview, + FileUploadItemProgress, + FileUploadList, + FileUploadTrigger, + type FileUploadProps, +} from '@/components/file-upload'; +import { Button } from '@/components/ui/button'; +import { useUploadCanvasFile } from '@/hooks/use-agent-request'; +import { Upload, X } from 'lucide-react'; +import * as React from 'react'; +import { toast } from 'sonner'; + +type FileUploadDirectUploadProps = { + value: Record; + onChange(value: Record): void; +}; + +export function FileUploadDirectUpload({ + onChange, +}: FileUploadDirectUploadProps) { + const [files, setFiles] = React.useState([]); + + const { uploadCanvasFile } = useUploadCanvasFile(); + + const onUpload: NonNullable = React.useCallback( + async (files, { onSuccess, onError }) => { + try { + const uploadPromises = files.map(async (file) => { + const handleError = (error?: any) => { + onError( + file, + error instanceof Error ? error : new Error('Upload failed'), + ); + }; + try { + const ret = await uploadCanvasFile([file]); + if (ret.code === 0) { + onSuccess(file); + onChange(ret.data); + } else { + handleError(); + } + } catch (error) { + handleError(error); + } + }); + + // Wait for all uploads to complete + await Promise.all(uploadPromises); + } catch (error) { + // This handles any error that might occur outside the individual upload processes + console.error('Unexpected error during upload:', error); + } + }, + [onChange, uploadCanvasFile], + ); + + const onFileReject = React.useCallback((file: File, message: string) => { + toast(message, { + description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`, + }); + }, []); + + return ( + + +
+
+ +
+

Drag & drop files here

+

+ Or click to browse (max 2 files) +

+
+ + + +
+ + {files.map((file, index) => ( + +
+ + + + + +
+ +
+ ))} +
+
+ ); +} diff --git a/web/src/pages/agent/utils.ts b/web/src/pages/agent/utils.ts index f1167b791..e2e064487 100644 --- a/web/src/pages/agent/utils.ts +++ b/web/src/pages/agent/utils.ts @@ -150,13 +150,22 @@ function buildAgentTools(edges: Edge[], nodes: Node[], nodeId: string) { (params as IAgentForm).tools = (params as IAgentForm).tools.concat( bottomSubAgentEdges.map((x) => { - const formData = buildAgentTools(edges, nodes, x.target); + const { + params: formData, + id, + name, + } = buildAgentTools(edges, nodes, x.target); - return { component_name: Operator.Agent, params: { ...formData } }; + return { + component_name: Operator.Agent, + id, + name, + params: { ...formData }, + }; }), ); } - return params; + return { params, name: node?.data.name, id: node?.id }; } function filterTargetsBySourceHandleId(edges: Edge[], handleId: string) { @@ -221,9 +230,11 @@ export const buildDslComponentsByGraph = ( let params = x?.data.form ?? {}; switch (operatorName) { - case Operator.Agent: - params = buildAgentTools(edges, nodes, id); + case Operator.Agent: { + const { params: formData } = buildAgentTools(edges, nodes, id); + params = formData; break; + } case Operator.Categorize: params = buildCategorizeTos(edges, nodes, id); break; diff --git a/web/src/pages/agent/utils/chat.ts b/web/src/pages/agent/utils/chat.ts new file mode 100644 index 000000000..15c8ff850 --- /dev/null +++ b/web/src/pages/agent/utils/chat.ts @@ -0,0 +1,21 @@ +import { MessageType } from '@/constants/chat'; +import { IReference } from '@/interfaces/database/chat'; +import { IMessage } from '@/pages/chat/interface'; +import { isEmpty } from 'lodash'; + +export const buildAgentMessageItemReference = ( + conversation: { message: IMessage[]; reference: IReference[] }, + message: IMessage, +) => { + const assistantMessages = conversation.message?.filter( + (x) => x.role === MessageType.Assistant, + ); + const referenceIndex = assistantMessages.findIndex( + (x) => x.id === message.id, + ); + const reference = !isEmpty(message?.reference) + ? message?.reference + : (conversation?.reference ?? [])[referenceIndex]; + + return reference ?? { doc_aggs: [], chunks: [], total: 0 }; +}; diff --git a/web/src/services/flow-service.ts b/web/src/services/flow-service.ts index 7da121ea4..846d8ec74 100644 --- a/web/src/services/flow-service.ts +++ b/web/src/services/flow-service.ts @@ -18,6 +18,7 @@ const { debug, listCanvasTeam, settingCanvas, + uploadCanvasFile, } = api; const methods = { @@ -81,6 +82,10 @@ const methods = { url: settingCanvas, method: 'post', }, + uploadCanvasFile: { + url: uploadCanvasFile, + method: 'post', + }, } as const; const flowService = registerServer(methods, request); diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index d0369d1e8..817cf46ae 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -143,6 +143,7 @@ export default { testDbConnect: `${api_host}/canvas/test_db_connect`, getInputElements: `${api_host}/canvas/input_elements`, debug: `${api_host}/canvas/debug`, + uploadCanvasFile: `${api_host}/canvas/upload`, // mcp server getMcpServerList: `${api_host}/mcp_server/list`,