### What problem does this PR solve? Feat: Upload files in the chat box #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.2
| @@ -12,7 +12,7 @@ import { PaginationProps, message } from 'antd'; | |||
| import { FormInstance } from 'antd/lib'; | |||
| import axios from 'axios'; | |||
| import { EventSourceParserStream } from 'eventsource-parser/stream'; | |||
| import { omit } from 'lodash'; | |||
| import { has, isEmpty, omit } from 'lodash'; | |||
| import { | |||
| ChangeEventHandler, | |||
| useCallback, | |||
| @@ -166,11 +166,43 @@ export const useFetchAppConf = () => { | |||
| return appConf; | |||
| }; | |||
| function useSetDoneRecord() { | |||
| const [doneRecord, setDoneRecord] = useState<Record<string, boolean>>({}); | |||
| const clearDoneRecord = useCallback(() => { | |||
| setDoneRecord({}); | |||
| }, []); | |||
| const setDoneRecordById = useCallback((id: string, val: boolean) => { | |||
| setDoneRecord((prev) => ({ ...prev, [id]: val })); | |||
| }, []); | |||
| const allDone = useMemo(() => { | |||
| return Object.values(doneRecord).every((val) => val); | |||
| }, [doneRecord]); | |||
| useEffect(() => { | |||
| if (!isEmpty(doneRecord) && allDone) { | |||
| clearDoneRecord(); | |||
| } | |||
| }, [allDone, clearDoneRecord, doneRecord]); | |||
| return { | |||
| doneRecord, | |||
| setDoneRecord, | |||
| setDoneRecordById, | |||
| clearDoneRecord, | |||
| allDone, | |||
| }; | |||
| } | |||
| export const useSendMessageWithSse = ( | |||
| url: string = api.completeConversation, | |||
| ) => { | |||
| const [answer, setAnswer] = useState<IAnswer>({} as IAnswer); | |||
| const [done, setDone] = useState(true); | |||
| const { doneRecord, clearDoneRecord, setDoneRecordById, allDone } = | |||
| useSetDoneRecord(); | |||
| const timer = useRef<any>(); | |||
| const sseRef = useRef<AbortController>(); | |||
| @@ -188,6 +220,17 @@ export const useSendMessageWithSse = ( | |||
| }, 1000); | |||
| }, []); | |||
| const setDoneValue = useCallback( | |||
| (body: any, value: boolean) => { | |||
| if (has(body, 'chatBoxId')) { | |||
| setDoneRecordById(body.chatBoxId, value); | |||
| } else { | |||
| setDone(value); | |||
| } | |||
| }, | |||
| [setDoneRecordById], | |||
| ); | |||
| const send = useCallback( | |||
| async ( | |||
| body: any, | |||
| @@ -195,7 +238,7 @@ export const useSendMessageWithSse = ( | |||
| ): Promise<{ response: Response; data: ResponseType } | undefined> => { | |||
| initializeSseRef(); | |||
| try { | |||
| setDone(false); | |||
| setDoneValue(body, false); | |||
| const response = await fetch(url, { | |||
| method: 'POST', | |||
| headers: { | |||
| @@ -236,23 +279,34 @@ export const useSendMessageWithSse = ( | |||
| } | |||
| } | |||
| } | |||
| setDone(true); | |||
| setDoneValue(body, true); | |||
| resetAnswer(); | |||
| return { data: await res, response }; | |||
| } catch (e) { | |||
| setDone(true); | |||
| setDoneValue(body, true); | |||
| resetAnswer(); | |||
| // Swallow fetch errors silently | |||
| } | |||
| }, | |||
| [initializeSseRef, url, resetAnswer], | |||
| [initializeSseRef, setDoneValue, url, resetAnswer], | |||
| ); | |||
| const stopOutputMessage = useCallback(() => { | |||
| sseRef.current?.abort(); | |||
| }, []); | |||
| return { send, answer, done, setDone, resetAnswer, stopOutputMessage }; | |||
| return { | |||
| send, | |||
| answer, | |||
| done, | |||
| doneRecord, | |||
| allDone, | |||
| setDone, | |||
| resetAnswer, | |||
| stopOutputMessage, | |||
| clearDoneRecord, | |||
| }; | |||
| }; | |||
| export const useSpeechWithSse = (url: string = api.tts) => { | |||
| @@ -30,6 +30,7 @@ export const enum ChatApiAction { | |||
| DeleteMessage = 'deleteMessage', | |||
| FetchMindMap = 'fetchMindMap', | |||
| FetchRelatedQuestions = 'fetchRelatedQuestions', | |||
| UploadAndParse = 'upload_and_parse', | |||
| } | |||
| export const useGetChatSearchParams = () => { | |||
| @@ -163,6 +164,10 @@ export const useSetDialog = () => { | |||
| queryKey: [ChatApiAction.FetchDialogList], | |||
| }); | |||
| queryClient.invalidateQueries({ | |||
| queryKey: [ChatApiAction.FetchDialog], | |||
| }); | |||
| message.success( | |||
| t(`message.${params.dialog_id ? 'modified' : 'created'}`), | |||
| ); | |||
| @@ -376,6 +381,34 @@ export const useDeleteMessage = () => { | |||
| return { data, loading, deleteMessage: mutateAsync }; | |||
| }; | |||
| export function useUploadAndParseFile() { | |||
| const { conversationId } = useGetChatSearchParams(); | |||
| const { t } = useTranslation(); | |||
| const { | |||
| data, | |||
| isPending: loading, | |||
| mutateAsync, | |||
| } = useMutation({ | |||
| mutationKey: [ChatApiAction.UploadAndParse], | |||
| mutationFn: async (file: File) => { | |||
| const formData = new FormData(); | |||
| formData.append('file', file); | |||
| formData.append('conversation_id', conversationId); | |||
| const { data } = await chatService.uploadAndParse(formData); | |||
| if (data.code === 0) { | |||
| message.success(t(`message.uploaded`)); | |||
| } | |||
| return data; | |||
| }, | |||
| }); | |||
| return { data, loading, uploadAndParseFile: mutateAsync }; | |||
| } | |||
| //#endregion | |||
| //#region search page | |||
| @@ -651,6 +651,7 @@ export const initialAgentValues = { | |||
| exception_default_value: '', | |||
| tools: [], | |||
| mcp: [], | |||
| cite: true, | |||
| outputs: { | |||
| // structured_output: { | |||
| // topic: { | |||
| @@ -15,6 +15,7 @@ import { | |||
| FormLabel, | |||
| } from '@/components/ui/form'; | |||
| import { Input, NumberInput } from '@/components/ui/input'; | |||
| import { Switch } from '@/components/ui/switch'; | |||
| import { LlmModelType } from '@/constants/knowledge'; | |||
| import { useFindLlmByUuid } from '@/hooks/use-llm-request'; | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| @@ -71,6 +72,7 @@ const FormSchema = z.object({ | |||
| exception_goto: z.array(z.string()).optional(), | |||
| exception_default_value: z.string().optional(), | |||
| ...LargeModelFilterFormSchema, | |||
| cite: z.boolean().optional(), | |||
| }); | |||
| const outputList = buildOutputList(initialAgentValues.outputs); | |||
| @@ -184,6 +186,23 @@ function AgentForm({ node }: INextOperatorForm) { | |||
| <Collapse title={<div>Advanced Settings</div>}> | |||
| <FormContainer> | |||
| <MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField> | |||
| <FormField | |||
| control={form.control} | |||
| name={`cite`} | |||
| render={({ field }) => ( | |||
| <FormItem className="flex-1"> | |||
| <FormLabel tooltip={t('flow.citeTip')}> | |||
| {t('flow.cite')} | |||
| </FormLabel> | |||
| <FormControl> | |||
| <Switch | |||
| checked={field.value} | |||
| onCheckedChange={field.onChange} | |||
| ></Switch> | |||
| </FormControl> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name={`max_retries`} | |||
| @@ -16,13 +16,16 @@ import { | |||
| useFetchConversation, | |||
| useFetchDialog, | |||
| useGetChatSearchParams, | |||
| useSetDialog, | |||
| } from '@/hooks/use-chat-request'; | |||
| import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; | |||
| import { buildMessageUuidWithRole } from '@/utils/chat'; | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| import { isEmpty, omit } from 'lodash'; | |||
| import { ListCheck, Plus, Trash2 } from 'lucide-react'; | |||
| import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; | |||
| import { useForm } from 'react-hook-form'; | |||
| import { useForm, useWatch } from 'react-hook-form'; | |||
| import { useParams } from 'umi'; | |||
| import { z } from 'zod'; | |||
| import { | |||
| useGetSendButtonDisabled, | |||
| @@ -47,6 +50,7 @@ type ChatCardProps = { | |||
| id: string; | |||
| idx: number; | |||
| derivedMessages: IMessage[]; | |||
| sendLoading: boolean; | |||
| } & Pick< | |||
| MultipleChatBoxProps, | |||
| 'controller' | 'removeChatBox' | 'addChatBox' | 'chatBoxIds' | |||
| @@ -61,11 +65,14 @@ const ChatCard = forwardRef(function ChatCard( | |||
| addChatBox, | |||
| chatBoxIds, | |||
| derivedMessages, | |||
| sendLoading, | |||
| }: ChatCardProps, | |||
| ref, | |||
| ) { | |||
| const { sendLoading, regenerateMessage, removeMessageById } = | |||
| useSendMessage(controller); | |||
| const { id: dialogId } = useParams(); | |||
| const { setDialog } = useSetDialog(); | |||
| const { regenerateMessage, removeMessageById } = useSendMessage(controller); | |||
| const messageContainerRef = useRef<HTMLDivElement>(null); | |||
| @@ -80,6 +87,8 @@ const ChatCard = forwardRef(function ChatCard( | |||
| }, | |||
| }); | |||
| const llmId = useWatch({ control: form.control, name: 'llm_id' }); | |||
| const { data: userInfo } = useFetchUserInfo(); | |||
| const { data: currentDialog } = useFetchDialog(); | |||
| const { data: conversation } = useFetchConversation(); | |||
| @@ -90,6 +99,16 @@ const ChatCard = forwardRef(function ChatCard( | |||
| removeChatBox(id); | |||
| }, [id, removeChatBox]); | |||
| const handleApplyConfig = useCallback(() => { | |||
| const values = form.getValues(); | |||
| setDialog({ | |||
| ...currentDialog, | |||
| llm_id: values.llm_id, | |||
| llm_setting: omit(values, 'llm_id'), | |||
| dialog_id: dialogId, | |||
| }); | |||
| }, [currentDialog, dialogId, form, setDialog]); | |||
| useImperativeHandle(ref, () => ({ | |||
| getFormData: () => form.getValues(), | |||
| })); | |||
| @@ -107,7 +126,11 @@ const ChatCard = forwardRef(function ChatCard( | |||
| <div className="space-x-2"> | |||
| <Tooltip> | |||
| <TooltipTrigger> | |||
| <Button variant={'ghost'}> | |||
| <Button | |||
| variant={'ghost'} | |||
| disabled={isEmpty(llmId)} | |||
| onClick={handleApplyConfig} | |||
| > | |||
| <ListCheck /> | |||
| </Button> | |||
| </TooltipTrigger> | |||
| @@ -180,6 +203,7 @@ export function MultipleChatBox({ | |||
| handlePressEnter, | |||
| stopOutputMessage, | |||
| setFormRef, | |||
| handleUploadFile, | |||
| } = useSendMultipleChatMessage(controller, chatBoxIds); | |||
| const { createConversationBeforeUploadDocument } = | |||
| @@ -202,6 +226,7 @@ export function MultipleChatBox({ | |||
| addChatBox={addChatBox} | |||
| derivedMessages={messageRecord[id]} | |||
| ref={setFormRef(id)} | |||
| sendLoading={sendLoading} | |||
| ></ChatCard> | |||
| ))} | |||
| </div> | |||
| @@ -218,6 +243,7 @@ export function MultipleChatBox({ | |||
| createConversationBeforeUploadDocument | |||
| } | |||
| stopOutputMessage={stopOutputMessage} | |||
| onUpload={handleUploadFile} | |||
| /> | |||
| </div> | |||
| </section> | |||
| @@ -32,6 +32,7 @@ export function SingleChatBox({ controller }: IProps) { | |||
| regenerateMessage, | |||
| removeMessageById, | |||
| stopOutputMessage, | |||
| handleUploadFile, | |||
| } = useSendMessage(controller); | |||
| const { data: userInfo } = useFetchUserInfo(); | |||
| const { data: currentDialog } = useFetchDialog(); | |||
| @@ -89,6 +90,7 @@ export function SingleChatBox({ controller }: IProps) { | |||
| createConversationBeforeUploadDocument | |||
| } | |||
| stopOutputMessage={stopOutputMessage} | |||
| onUpload={handleUploadFile} | |||
| /> | |||
| </section> | |||
| ); | |||
| @@ -11,8 +11,13 @@ import { Button } from '@/components/ui/button'; | |||
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |||
| import { useSetModalState } from '@/hooks/common-hooks'; | |||
| import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; | |||
| import { useFetchConversation, useFetchDialog } from '@/hooks/use-chat-request'; | |||
| import { | |||
| useFetchConversation, | |||
| useFetchDialog, | |||
| useGetChatSearchParams, | |||
| } from '@/hooks/use-chat-request'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { isEmpty } from 'lodash'; | |||
| import { ArrowUpRight, LogOut } from 'lucide-react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { useHandleClickConversationCard } from '../hooks/use-click-card'; | |||
| @@ -42,6 +47,8 @@ export default function Chat() { | |||
| hasThreeChatBox, | |||
| } = useAddChatBox(); | |||
| const { conversationId, isNew } = useGetChatSearchParams(); | |||
| const { isDebugMode, switchDebugMode } = useSwitchDebugMode(); | |||
| if (isDebugMode) { | |||
| @@ -104,13 +111,17 @@ export default function Chat() { | |||
| <Button | |||
| variant={'ghost'} | |||
| onClick={switchDebugMode} | |||
| disabled={hasThreeChatBox} | |||
| disabled={ | |||
| hasThreeChatBox || | |||
| isEmpty(conversationId) || | |||
| isNew === 'true' | |||
| } | |||
| > | |||
| <ArrowUpRight /> Multiple Models | |||
| </Button> | |||
| </CardTitle> | |||
| </CardHeader> | |||
| <CardContent className="flex-1 p-0"> | |||
| <CardContent className="flex-1 p-0 min-h-0"> | |||
| <SingleChatBox controller={controller}></SingleChatBox> | |||
| </CardContent> | |||
| </Card> | |||
| @@ -18,6 +18,7 @@ import { useParams, useSearchParams } from 'umi'; | |||
| import { v4 as uuid } from 'uuid'; | |||
| import { IMessage } from '../chat/interface'; | |||
| import { useFindPrologueFromDialogList } from './use-select-conversation-list'; | |||
| import { useUploadFile } from './use-upload-file'; | |||
| export const useSetChatRouteParams = () => { | |||
| const [currentQueryParameters, setSearchParams] = useSearchParams(); | |||
| @@ -137,6 +138,8 @@ export const useSendMessage = (controller: AbortController) => { | |||
| const { conversationId, isNew } = useGetChatSearchParams(); | |||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | |||
| const { handleUploadFile, fileIds, clearFileIds } = useUploadFile(); | |||
| const { send, answer, done } = useSendMessageWithSse( | |||
| api.completeConversation, | |||
| ); | |||
| @@ -238,29 +241,35 @@ export const useSendMessage = (controller: AbortController) => { | |||
| } | |||
| }, [answer, addNewestAnswer, conversationId, isNew]); | |||
| const handlePressEnter = useCallback( | |||
| (documentIds: string[]) => { | |||
| if (trim(value) === '') return; | |||
| const id = uuid(); | |||
| const handlePressEnter = useCallback(() => { | |||
| if (trim(value) === '') return; | |||
| const id = uuid(); | |||
| addNewestQuestion({ | |||
| content: value, | |||
| doc_ids: documentIds, | |||
| addNewestQuestion({ | |||
| content: value, | |||
| doc_ids: fileIds, | |||
| id, | |||
| role: MessageType.User, | |||
| }); | |||
| if (done) { | |||
| setValue(''); | |||
| handleSendMessage({ | |||
| id, | |||
| content: value.trim(), | |||
| role: MessageType.User, | |||
| doc_ids: fileIds, | |||
| }); | |||
| if (done) { | |||
| setValue(''); | |||
| handleSendMessage({ | |||
| id, | |||
| content: value.trim(), | |||
| role: MessageType.User, | |||
| doc_ids: documentIds, | |||
| }); | |||
| } | |||
| }, | |||
| [addNewestQuestion, handleSendMessage, done, setValue, value], | |||
| ); | |||
| } | |||
| clearFileIds(); | |||
| }, [ | |||
| value, | |||
| addNewestQuestion, | |||
| fileIds, | |||
| done, | |||
| clearFileIds, | |||
| setValue, | |||
| handleSendMessage, | |||
| ]); | |||
| return { | |||
| handlePressEnter, | |||
| @@ -275,5 +284,6 @@ export const useSendMessage = (controller: AbortController) => { | |||
| derivedMessages, | |||
| removeMessageById, | |||
| stopOutputMessage, | |||
| handleUploadFile, | |||
| }; | |||
| }; | |||
| @@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from 'react'; | |||
| import { v4 as uuid } from 'uuid'; | |||
| import { IMessage } from '../chat/interface'; | |||
| import { useBuildFormRefs } from './use-build-form-refs'; | |||
| import { useUploadFile } from './use-upload-file'; | |||
| export function useSendMultipleChatMessage( | |||
| controller: AbortController, | |||
| @@ -24,10 +25,12 @@ export function useSendMultipleChatMessage( | |||
| const { conversationId } = useGetChatSearchParams(); | |||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | |||
| const { send, answer, done } = useSendMessageWithSse( | |||
| const { send, answer, allDone } = useSendMessageWithSse( | |||
| api.completeConversation, | |||
| ); | |||
| const { handleUploadFile, fileIds, clearFileIds } = useUploadFile(); | |||
| const { setFormRef, getLLMConfigById, isLLMConfigEmpty } = | |||
| useBuildFormRefs(chatBoxIds); | |||
| @@ -182,12 +185,12 @@ export function useSendMultipleChatMessage( | |||
| id, | |||
| role: MessageType.User, | |||
| chatBoxId, | |||
| doc_ids: fileIds, | |||
| }); | |||
| } | |||
| }); | |||
| if (done) { | |||
| // TODO: | |||
| if (allDone) { | |||
| setValue(''); | |||
| chatBoxIds.forEach((chatBoxId) => { | |||
| if (!isLLMConfigEmpty(chatBoxId)) { | |||
| @@ -196,18 +199,22 @@ export function useSendMultipleChatMessage( | |||
| id, | |||
| content: value.trim(), | |||
| role: MessageType.User, | |||
| doc_ids: fileIds, | |||
| }, | |||
| chatBoxId, | |||
| }); | |||
| } | |||
| }); | |||
| } | |||
| clearFileIds(); | |||
| }, [ | |||
| value, | |||
| chatBoxIds, | |||
| done, | |||
| allDone, | |||
| clearFileIds, | |||
| isLLMConfigEmpty, | |||
| addNewestQuestion, | |||
| fileIds, | |||
| setValue, | |||
| sendMessage, | |||
| ]); | |||
| @@ -229,7 +236,8 @@ export function useSendMultipleChatMessage( | |||
| handleInputChange, | |||
| handlePressEnter, | |||
| stopOutputMessage, | |||
| sendLoading: false, | |||
| sendLoading: !allDone, | |||
| setFormRef, | |||
| handleUploadFile, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| import { FileUploadProps } from '@/components/file-upload'; | |||
| import { useUploadAndParseFile } from '@/hooks/use-chat-request'; | |||
| import { useCallback, useState } from 'react'; | |||
| export function useUploadFile() { | |||
| const { uploadAndParseFile } = useUploadAndParseFile(); | |||
| const [fileIds, setFileIds] = useState<string[]>([]); | |||
| const handleUploadFile: NonNullable<FileUploadProps['onUpload']> = | |||
| useCallback( | |||
| async (files) => { | |||
| if (Array.isArray(files) && files.length) { | |||
| const ret = await uploadAndParseFile(files[0]); | |||
| if (ret.code === 0 && Array.isArray(ret.data)) { | |||
| setFileIds((list) => [...list, ...ret.data]); | |||
| } | |||
| } | |||
| }, | |||
| [uploadAndParseFile], | |||
| ); | |||
| const clearFileIds = useCallback(() => { | |||
| setFileIds([]); | |||
| }, []); | |||
| return { handleUploadFile, clearFileIds, fileIds }; | |||
| } | |||
| @@ -27,6 +27,7 @@ const { | |||
| mindmap, | |||
| getRelatedQuestions, | |||
| listNextDialog, | |||
| upload_and_parse, | |||
| } = api; | |||
| const methods = { | |||
| @@ -126,6 +127,10 @@ const methods = { | |||
| url: getRelatedQuestions, | |||
| method: 'post', | |||
| }, | |||
| uploadAndParse: { | |||
| method: 'post', | |||
| url: upload_and_parse, | |||
| }, | |||
| } as const; | |||
| const chatService = registerNextServer<keyof typeof methods>(methods); | |||