소스 검색

Feat: Upload files in the chat box on the agent page #3221 (#9035)

### 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
balibabu 3 달 전
부모
커밋
88910449e4
No account linked to committer's email address

+ 2
- 0
web/src/components/file-upload.tsx 파일 보기

@@ -1,3 +1,5 @@
// https://www.diceui.com/docs/components/file-upload

'use client';

import { cn } from '@/lib/utils';

+ 171
- 0
web/src/components/message-input/next.tsx 파일 보기

@@ -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>
);
}

+ 76
- 1
web/src/hooks/use-agent-request.ts 파일 보기

@@ -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('');

+ 24
- 7
web/src/pages/agent/chat/box.tsx 파일 보기

@@ -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

+ 2
- 1
web/src/pages/agent/chat/chat-sheet.tsx 파일 보기

@@ -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>

+ 53
- 3
web/src/pages/agent/chat/use-send-agent-message.ts 파일 보기

@@ -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,
};
};

+ 2
- 1
web/src/pages/agent/form/agent-form/index.tsx 파일 보기

@@ -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}

+ 25
- 7
web/src/pages/next-chats/share/index.tsx 파일 보기

@@ -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

+ 1
- 0
web/src/utils/api.ts 파일 보기

@@ -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`,

Loading…
취소
저장