### 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)tags/v0.20.0
| @@ -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 }; | |||
| }; | |||
| @@ -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, | |||
| )} | |||
| @@ -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 = ({ | |||
| <React.Fragment key={idx}> | |||
| <FormField | |||
| control={form.control} | |||
| name={'file'} | |||
| name={props.name} | |||
| render={({ field }) => ( | |||
| <div className="space-y-6"> | |||
| <FormItem className="w-full"> | |||
| <FormLabel>{t('assistantAvatar')}</FormLabel> | |||
| <FormControl> | |||
| <FileUploader | |||
| <FileUploadDirectUpload | |||
| value={field.value} | |||
| onValueChange={field.onChange} | |||
| maxFileCount={1} | |||
| maxSize={4 * 1024 * 1024} | |||
| /> | |||
| onChange={field.onChange} | |||
| ></FileUploadDirectUpload> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| @@ -232,18 +230,7 @@ const DebugContent = ({ | |||
| (values: z.infer<typeof formSchemaValues.schema>) => { | |||
| 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); | |||
| @@ -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<string, any>; | |||
| onChange(value: Record<string, any>): void; | |||
| }; | |||
| export function FileUploadDirectUpload({ | |||
| onChange, | |||
| }: FileUploadDirectUploadProps) { | |||
| const [files, setFiles] = React.useState<File[]>([]); | |||
| const { uploadCanvasFile } = useUploadCanvasFile(); | |||
| const onUpload: NonNullable<FileUploadProps['onUpload']> = 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 ( | |||
| <FileUpload | |||
| value={files} | |||
| onValueChange={setFiles} | |||
| onUpload={onUpload} | |||
| onFileReject={onFileReject} | |||
| maxFiles={1} | |||
| className="w-full max-w-md" | |||
| multiple={false} | |||
| > | |||
| <FileUploadDropzone> | |||
| <div className="flex flex-col items-center gap-1 text-center"> | |||
| <div className="flex items-center justify-center rounded-full border p-2.5"> | |||
| <Upload className="size-6 text-muted-foreground" /> | |||
| </div> | |||
| <p className="font-medium text-sm">Drag & drop files here</p> | |||
| <p className="text-muted-foreground text-xs"> | |||
| Or click to browse (max 2 files) | |||
| </p> | |||
| </div> | |||
| <FileUploadTrigger asChild> | |||
| <Button variant="outline" size="sm" className="mt-2 w-fit"> | |||
| Browse files | |||
| </Button> | |||
| </FileUploadTrigger> | |||
| </FileUploadDropzone> | |||
| <FileUploadList> | |||
| {files.map((file, index) => ( | |||
| <FileUploadItem key={index} value={file} className="flex-col"> | |||
| <div className="flex w-full items-center gap-2"> | |||
| <FileUploadItemPreview /> | |||
| <FileUploadItemMetadata /> | |||
| <FileUploadItemDelete asChild> | |||
| <Button variant="ghost" size="icon" className="size-7"> | |||
| <X /> | |||
| </Button> | |||
| </FileUploadItemDelete> | |||
| </div> | |||
| <FileUploadItemProgress /> | |||
| </FileUploadItem> | |||
| ))} | |||
| </FileUploadList> | |||
| </FileUpload> | |||
| ); | |||
| } | |||
| @@ -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); | |||
| return { component_name: Operator.Agent, params: { ...formData } }; | |||
| const { | |||
| params: formData, | |||
| id, | |||
| name, | |||
| } = buildAgentTools(edges, nodes, x.target); | |||
| 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; | |||
| @@ -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 }; | |||
| }; | |||
| @@ -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<keyof typeof methods>(methods, request); | |||
| @@ -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`, | |||