### What problem does this PR solve? Feat: Upload files in the chat box on the agent page #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| @@ -1,3 +1,5 @@ | |||
| // https://www.diceui.com/docs/components/file-upload | |||
| 'use client'; | |||
| import { cn } from '@/lib/utils'; | |||
| @@ -0,0 +1,171 @@ | |||
| '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 { Textarea } from '@/components/ui/textarea'; | |||
| import { CircleStop, Paperclip, Send, Upload, X } from 'lucide-react'; | |||
| import * as React from 'react'; | |||
| import { toast } from 'sonner'; | |||
| interface IProps { | |||
| disabled: boolean; | |||
| value: string; | |||
| sendDisabled: boolean; | |||
| sendLoading: boolean; | |||
| conversationId: string; | |||
| uploadMethod?: string; | |||
| isShared?: boolean; | |||
| showUploadIcon?: boolean; | |||
| isUploading?: boolean; | |||
| onPressEnter(...prams: any[]): void; | |||
| onInputChange: React.ChangeEventHandler<HTMLTextAreaElement>; | |||
| createConversationBeforeUploadDocument?(message: string): Promise<any>; | |||
| stopOutputMessage?(): void; | |||
| onUpload?: NonNullable<FileUploadProps['onUpload']>; | |||
| } | |||
| export function NextMessageInput({ | |||
| isUploading = false, | |||
| value, | |||
| sendDisabled, | |||
| sendLoading, | |||
| disabled, | |||
| showUploadIcon = true, | |||
| onUpload, | |||
| onInputChange, | |||
| stopOutputMessage, | |||
| onPressEnter, | |||
| }: IProps) { | |||
| const [files, setFiles] = React.useState<File[]>([]); | |||
| 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`, | |||
| }); | |||
| }, []); | |||
| const submit = React.useCallback(() => { | |||
| if (isUploading) return; | |||
| onPressEnter(); | |||
| setFiles([]); | |||
| }, [isUploading, onPressEnter]); | |||
| const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |||
| if (e.key === 'Enter' && !e.shiftKey) { | |||
| e.preventDefault(); | |||
| submit(); | |||
| } | |||
| }; | |||
| const onSubmit = React.useCallback( | |||
| (event: React.FormEvent<HTMLFormElement>) => { | |||
| event.preventDefault(); | |||
| submit(); | |||
| }, | |||
| [submit], | |||
| ); | |||
| return ( | |||
| <FileUpload | |||
| value={files} | |||
| onValueChange={setFiles} | |||
| onUpload={onUpload} | |||
| onFileReject={onFileReject} | |||
| className="relative w-full items-center " | |||
| disabled={isUploading || disabled} | |||
| > | |||
| <FileUploadDropzone | |||
| tabIndex={-1} | |||
| // Prevents the dropzone from triggering on click | |||
| onClick={(event) => event.preventDefault()} | |||
| className="absolute top-0 left-0 z-0 flex size-full items-center justify-center rounded-none border-none bg-background/50 p-0 opacity-0 backdrop-blur transition-opacity duration-200 ease-out data-[dragging]:z-10 data-[dragging]:opacity-100" | |||
| > | |||
| <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"> | |||
| Upload max 5 files each up to 5MB | |||
| </p> | |||
| </div> | |||
| </FileUploadDropzone> | |||
| <form | |||
| onSubmit={onSubmit} | |||
| className="relative flex w-full max-w-md flex-col gap-2.5 rounded-md border border-input px-3 py-2 outline-none focus-within:ring-1 focus-within:ring-ring/50" | |||
| > | |||
| <FileUploadList | |||
| orientation="horizontal" | |||
| className="overflow-x-auto px-0 py-1" | |||
| > | |||
| {files.map((file, index) => ( | |||
| <FileUploadItem key={index} value={file} className="max-w-52 p-1.5"> | |||
| <FileUploadItemPreview className="size-8 [&>svg]:size-5"> | |||
| <FileUploadItemProgress variant="fill" /> | |||
| </FileUploadItemPreview> | |||
| <FileUploadItemMetadata size="sm" /> | |||
| <FileUploadItemDelete asChild> | |||
| <Button | |||
| variant="secondary" | |||
| size="icon" | |||
| className="-top-1 -right-1 absolute size-4 shrink-0 cursor-pointer rounded-full" | |||
| > | |||
| <X className="size-2.5" /> | |||
| </Button> | |||
| </FileUploadItemDelete> | |||
| </FileUploadItem> | |||
| ))} | |||
| </FileUploadList> | |||
| <Textarea | |||
| value={value} | |||
| onChange={onInputChange} | |||
| placeholder="Type your message here..." | |||
| className="field-sizing-content min-h-10 w-full resize-none border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 dark:bg-transparent" | |||
| disabled={isUploading || disabled} | |||
| onKeyDown={handleKeyDown} | |||
| /> | |||
| <div className="flex items-center justify-between gap-1.5"> | |||
| {showUploadIcon && ( | |||
| <FileUploadTrigger asChild> | |||
| <Button | |||
| type="button" | |||
| size="icon" | |||
| variant="ghost" | |||
| className="size-7 rounded-sm" | |||
| > | |||
| <Paperclip className="size-3.5" /> | |||
| <span className="sr-only">Attach file</span> | |||
| </Button> | |||
| </FileUploadTrigger> | |||
| )} | |||
| {sendLoading ? ( | |||
| <Button onClick={stopOutputMessage} className="size-5 rounded-sm"> | |||
| <CircleStop /> | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| className="size-5 rounded-sm" | |||
| disabled={ | |||
| sendDisabled || isUploading || sendLoading || !value.trim() | |||
| } | |||
| > | |||
| <Send /> | |||
| <span className="sr-only">Send message</span> | |||
| </Button> | |||
| )} | |||
| </div> | |||
| </form> | |||
| </FileUpload> | |||
| ); | |||
| } | |||
| @@ -1,3 +1,4 @@ | |||
| import { FileUploadProps } from '@/components/file-upload'; | |||
| import message from '@/components/ui/message'; | |||
| import { AgentGlobals } from '@/constants/agent'; | |||
| import { ITraceData } from '@/interfaces/database/agent'; | |||
| @@ -7,6 +8,7 @@ import i18n from '@/locales/config'; | |||
| import { BeginId } from '@/pages/agent/constant'; | |||
| import { useGetSharedChatSearchParams } from '@/pages/chat/shared-hooks'; | |||
| import agentService from '@/services/agent-service'; | |||
| import api from '@/utils/api'; | |||
| import { buildMessageListWithUuid } from '@/utils/chat'; | |||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | |||
| import { useDebounce } from 'ahooks'; | |||
| @@ -29,6 +31,7 @@ export const enum AgentApiAction { | |||
| SetAgent = 'setAgent', | |||
| FetchAgentTemplates = 'fetchAgentTemplates', | |||
| UploadCanvasFile = 'uploadCanvasFile', | |||
| UploadCanvasFileWithProgress = 'uploadCanvasFileWithProgress', | |||
| Trace = 'trace', | |||
| TestDbConnect = 'testDbConnect', | |||
| DebugSingle = 'debugSingle', | |||
| @@ -284,7 +287,9 @@ export const useSetAgent = () => { | |||
| return { data, loading, setAgent: mutateAsync }; | |||
| }; | |||
| // Only one file can be uploaded at a time | |||
| export const useUploadCanvasFile = () => { | |||
| const { id } = useParams(); | |||
| const { | |||
| data, | |||
| isPending: loading, | |||
| @@ -301,7 +306,10 @@ export const useUploadCanvasFile = () => { | |||
| }); | |||
| } | |||
| const { data } = await agentService.uploadCanvasFile(nextBody); | |||
| const { data } = await agentService.uploadCanvasFile( | |||
| { url: api.uploadAgentFile(id), data: nextBody }, | |||
| true, | |||
| ); | |||
| if (data?.code === 0) { | |||
| message.success(i18n.t('message.uploaded')); | |||
| } | |||
| @@ -315,6 +323,73 @@ export const useUploadCanvasFile = () => { | |||
| return { data, loading, uploadCanvasFile: mutateAsync }; | |||
| }; | |||
| export const useUploadCanvasFileWithProgress = ( | |||
| identifier?: Nullable<string>, | |||
| ) => { | |||
| const { id } = useParams(); | |||
| type UploadParameters = Parameters<NonNullable<FileUploadProps['onUpload']>>; | |||
| type X = { files: UploadParameters[0]; options: UploadParameters[1] }; | |||
| const { | |||
| data, | |||
| isPending: loading, | |||
| mutateAsync, | |||
| } = useMutation({ | |||
| mutationKey: [AgentApiAction.UploadCanvasFileWithProgress], | |||
| mutationFn: async ({ | |||
| files, | |||
| options: { onError, onSuccess, onProgress }, | |||
| }: X) => { | |||
| const formData = new FormData(); | |||
| try { | |||
| if (Array.isArray(files)) { | |||
| files.forEach((file: File) => { | |||
| formData.append('file', file); | |||
| }); | |||
| } | |||
| const { data } = await agentService.uploadCanvasFile( | |||
| { | |||
| url: api.uploadAgentFile(identifier || id), | |||
| data: formData, | |||
| onUploadProgress: ({ | |||
| loaded, | |||
| total, | |||
| progress, | |||
| bytes, | |||
| estimated, | |||
| rate, | |||
| upload, | |||
| lengthComputable, | |||
| }) => { | |||
| files.forEach((file) => { | |||
| onProgress(file, (progress || 0) * 100); | |||
| }); | |||
| }, | |||
| }, | |||
| true, | |||
| ); | |||
| if (data?.code === 0) { | |||
| files.forEach((file) => { | |||
| onSuccess(file); | |||
| }); | |||
| message.success(i18n.t('message.uploaded')); | |||
| } | |||
| return data; | |||
| } catch (error) { | |||
| files.forEach((file) => { | |||
| onError(file, error as Error); | |||
| }); | |||
| message.error('error', error.message); | |||
| } | |||
| }, | |||
| }); | |||
| return { data, loading, uploadCanvasFile: mutateAsync }; | |||
| }; | |||
| export const useFetchMessageTrace = () => { | |||
| const { id } = useParams(); | |||
| const [messageId, setMessageId] = useState(''); | |||
| @@ -3,11 +3,15 @@ import { useGetFileIcon } from '@/pages/chat/hooks'; | |||
| import { useSendAgentMessage } from './use-send-agent-message'; | |||
| import MessageInput from '@/components/message-input'; | |||
| import { FileUploadProps } from '@/components/file-upload'; | |||
| import { NextMessageInput } from '@/components/message-input/next'; | |||
| import MessageItem from '@/components/next-message-item'; | |||
| import PdfDrawer from '@/components/pdf-drawer'; | |||
| import { useClickDrawer } from '@/components/pdf-drawer/hooks'; | |||
| import { useFetchAgent } from '@/hooks/use-agent-request'; | |||
| import { | |||
| useFetchAgent, | |||
| useUploadCanvasFileWithProgress, | |||
| } from '@/hooks/use-agent-request'; | |||
| import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; | |||
| import { Message } from '@/interfaces/database/chat'; | |||
| import { buildMessageUuidWithRole } from '@/utils/chat'; | |||
| @@ -20,15 +24,16 @@ import { buildBeginQueryWithObject } from '../utils'; | |||
| const AgentChatBox = () => { | |||
| const { | |||
| sendLoading, | |||
| handleInputChange, | |||
| handlePressEnter, | |||
| value, | |||
| ref, | |||
| sendLoading, | |||
| derivedMessages, | |||
| handleInputChange, | |||
| handlePressEnter, | |||
| stopOutputMessage, | |||
| sendFormMessage, | |||
| findReferenceByMessageId, | |||
| appendUploadResponseList, | |||
| } = useSendAgentMessage(); | |||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | |||
| @@ -37,6 +42,7 @@ const AgentChatBox = () => { | |||
| const { data: userInfo } = useFetchUserInfo(); | |||
| const { data: canvasInfo } = useFetchAgent(); | |||
| const { id: canvasId } = useParams(); | |||
| const { uploadCanvasFile, loading } = useUploadCanvasFileWithProgress(); | |||
| const getInputs = useCallback((message: Message) => { | |||
| return get(message, 'data.inputs', {}) as Record<string, BeginQuery>; | |||
| @@ -66,6 +72,15 @@ const AgentChatBox = () => { | |||
| [canvasId, getInputs, sendFormMessage], | |||
| ); | |||
| const handleUploadFile: NonNullable<FileUploadProps['onUpload']> = | |||
| useCallback( | |||
| async (files, options) => { | |||
| const ret = await uploadCanvasFile({ files, options }); | |||
| appendUploadResponseList(ret.data); | |||
| }, | |||
| [appendUploadResponseList, uploadCanvasFile], | |||
| ); | |||
| return ( | |||
| <> | |||
| <section className="flex flex-1 flex-col px-5 h-[90vh]"> | |||
| @@ -104,15 +119,17 @@ const AgentChatBox = () => { | |||
| </div> | |||
| <div ref={ref} /> | |||
| </div> | |||
| <MessageInput | |||
| <NextMessageInput | |||
| value={value} | |||
| sendLoading={sendLoading} | |||
| disabled={false} | |||
| sendDisabled={sendLoading} | |||
| conversationId="" | |||
| isUploading={loading} | |||
| onPressEnter={handlePressEnter} | |||
| onInputChange={handleInputChange} | |||
| stopOutputMessage={stopOutputMessage} | |||
| onUpload={handleUploadFile} | |||
| conversationId="" | |||
| /> | |||
| </section> | |||
| <PdfDrawer | |||
| @@ -1,4 +1,4 @@ | |||
| import { Sheet, SheetContent } from '@/components/ui/sheet'; | |||
| import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| @@ -12,6 +12,7 @@ export function ChatSheet({ hideModal }: IModalProps<any>) { | |||
| className={cn('top-20 p-0')} | |||
| onInteractOutside={(e) => e.preventDefault()} | |||
| > | |||
| <SheetTitle className="hidden"></SheetTitle> | |||
| <div className="pl-5 pt-2">{t('chat.chat')}</div> | |||
| <AgentChatBox></AgentChatBox> | |||
| </SheetContent> | |||
| @@ -137,6 +137,38 @@ export function useFindMessageReference(answerList: IEventList) { | |||
| return { findReferenceByMessageId }; | |||
| } | |||
| interface UploadResponseDataType { | |||
| created_at: number; | |||
| created_by: string; | |||
| extension: string; | |||
| id: string; | |||
| mime_type: string; | |||
| name: string; | |||
| preview_url: null; | |||
| size: number; | |||
| } | |||
| export function useSetUploadResponseData() { | |||
| const [uploadResponseList, setUploadResponseList] = useState< | |||
| UploadResponseDataType[] | |||
| >([]); | |||
| const append = useCallback((data: UploadResponseDataType) => { | |||
| setUploadResponseList((prev) => [...prev, data]); | |||
| }, []); | |||
| const clear = useCallback(() => { | |||
| setUploadResponseList([]); | |||
| }, []); | |||
| return { | |||
| uploadResponseList, | |||
| setUploadResponseList, | |||
| appendUploadResponseList: append, | |||
| clearUploadResponseList: clear, | |||
| }; | |||
| } | |||
| export const useSendAgentMessage = (url?: string) => { | |||
| const { id: agentId } = useParams(); | |||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | |||
| @@ -155,6 +187,11 @@ export const useSendAgentMessage = (url?: string) => { | |||
| addNewestOneAnswer, | |||
| } = useSelectDerivedMessages(); | |||
| const { addEventList } = useContext(AgentChatLogContext); | |||
| const { | |||
| appendUploadResponseList, | |||
| clearUploadResponseList, | |||
| uploadResponseList, | |||
| } = useSetUploadResponseData(); | |||
| const sendMessage = useCallback( | |||
| async ({ message }: { message: Message; messages?: Message[] }) => { | |||
| @@ -171,9 +208,13 @@ export const useSendAgentMessage = (url?: string) => { | |||
| params.query = message.content; | |||
| // params.message_id = message.id; | |||
| params.inputs = transferInputsArrayToObject(query); // begin operator inputs | |||
| params.files = uploadResponseList; | |||
| } | |||
| const res = await send(params); | |||
| clearUploadResponseList(); | |||
| if (receiveMessageError(res)) { | |||
| sonnerMessage.error(res?.data?.message); | |||
| @@ -184,7 +225,15 @@ export const useSendAgentMessage = (url?: string) => { | |||
| // refetch(); // pull the message list after sending the message successfully | |||
| } | |||
| }, | |||
| [agentId, send, inputs, setValue, removeLatestMessage], | |||
| [ | |||
| agentId, | |||
| send, | |||
| inputs, | |||
| uploadResponseList, | |||
| setValue, | |||
| removeLatestMessage, | |||
| clearUploadResponseList, | |||
| ], | |||
| ); | |||
| const sendFormMessage = useCallback( | |||
| @@ -243,16 +292,17 @@ export const useSendAgentMessage = (url?: string) => { | |||
| }, [addEventList, answerList]); | |||
| return { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| value, | |||
| sendLoading: !done, | |||
| derivedMessages, | |||
| ref, | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| removeMessageById, | |||
| stopOutputMessage, | |||
| send, | |||
| sendFormMessage, | |||
| findReferenceByMessageId, | |||
| appendUploadResponseList, | |||
| }; | |||
| }; | |||
| @@ -150,7 +150,8 @@ function AgentForm({ node }: INextOperatorForm) { | |||
| <FormContainer> | |||
| <QueryVariable | |||
| name="visual_files_var" | |||
| label="Visual files var" | |||
| label="Visual Input File" | |||
| type={VariableType.File} | |||
| ></QueryVariable> | |||
| <FormField | |||
| control={form.control} | |||
| @@ -1,15 +1,19 @@ | |||
| import MessageInput from '@/components/message-input'; | |||
| import { FileUploadProps } from '@/components/file-upload'; | |||
| import { NextMessageInput } from '@/components/message-input/next'; | |||
| import MessageItem from '@/components/next-message-item'; | |||
| import PdfDrawer from '@/components/pdf-drawer'; | |||
| import { useClickDrawer } from '@/components/pdf-drawer/hooks'; | |||
| import { MessageType, SharedFrom } from '@/constants/chat'; | |||
| import { useFetchNextConversationSSE } from '@/hooks/chat-hooks'; | |||
| import { useFetchAgentAvatar } from '@/hooks/use-agent-request'; | |||
| import { | |||
| useFetchAgentAvatar, | |||
| useUploadCanvasFileWithProgress, | |||
| } from '@/hooks/use-agent-request'; | |||
| import { cn } from '@/lib/utils'; | |||
| import i18n from '@/locales/config'; | |||
| import { useSendButtonDisabled } from '@/pages/chat/hooks'; | |||
| import { buildMessageUuidWithRole } from '@/utils/chat'; | |||
| import React, { forwardRef, useMemo } from 'react'; | |||
| import React, { forwardRef, useCallback, useMemo } from 'react'; | |||
| import { | |||
| useGetSharedChatSearchParams, | |||
| useSendNextSharedMessage, | |||
| @@ -25,6 +29,9 @@ const ChatContainer = () => { | |||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | |||
| useClickDrawer(); | |||
| const { uploadCanvasFile, loading } = | |||
| useUploadCanvasFileWithProgress(conversationId); | |||
| const { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| @@ -35,6 +42,7 @@ const ChatContainer = () => { | |||
| hasError, | |||
| stopOutputMessage, | |||
| findReferenceByMessageId, | |||
| appendUploadResponseList, | |||
| } = useSendNextSharedMessage(); | |||
| const sendDisabled = useSendButtonDisabled(value); | |||
| @@ -44,6 +52,15 @@ const ChatContainer = () => { | |||
| : useFetchNextConversationSSE; | |||
| }, [from]); | |||
| const handleUploadFile: NonNullable<FileUploadProps['onUpload']> = | |||
| useCallback( | |||
| async (files, options) => { | |||
| const ret = await uploadCanvasFile({ files, options }); | |||
| appendUploadResponseList(ret.data); | |||
| }, | |||
| [appendUploadResponseList, uploadCanvasFile], | |||
| ); | |||
| React.useEffect(() => { | |||
| if (locale && i18n.language !== locale) { | |||
| i18n.changeLanguage(locale); | |||
| @@ -79,6 +96,7 @@ const ChatContainer = () => { | |||
| showLikeButton={false} | |||
| showLoudspeaker={false} | |||
| showLog={false} | |||
| sendLoading={sendLoading} | |||
| ></MessageItem> | |||
| ); | |||
| })} | |||
| @@ -86,7 +104,7 @@ const ChatContainer = () => { | |||
| <div ref={ref} /> | |||
| </div> | |||
| <MessageInput | |||
| <NextMessageInput | |||
| isShared | |||
| value={value} | |||
| disabled={hasError} | |||
| @@ -95,10 +113,10 @@ const ChatContainer = () => { | |||
| onInputChange={handleInputChange} | |||
| onPressEnter={handlePressEnter} | |||
| sendLoading={sendLoading} | |||
| uploadMethod="external_upload_and_parse" | |||
| showUploadIcon={false} | |||
| stopOutputMessage={stopOutputMessage} | |||
| ></MessageInput> | |||
| onUpload={handleUploadFile} | |||
| isUploading={loading} | |||
| ></NextMessageInput> | |||
| </section> | |||
| {visible && ( | |||
| <PdfDrawer | |||
| @@ -152,6 +152,7 @@ export default { | |||
| fetchVersion: (id: string) => `${api_host}/canvas/getversion/${id}`, | |||
| fetchCanvas: (id: string) => `${api_host}/canvas/get/${id}`, | |||
| fetchAgentAvatar: (id: string) => `${api_host}/canvas/getsse/${id}`, | |||
| uploadAgentFile: (id?: string) => `${api_host}/canvas/upload/${id}`, | |||
| // mcp server | |||
| listMcpServer: `${api_host}/mcp_server/list`, | |||