Browse Source

Feat: Upload files in the chat box #3221 (#9483)

### 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
balibabu 2 months ago
parent
commit
562349eb02
No account linked to committer's email address

+ 60
- 6
web/src/hooks/logic-hooks.ts View File

import { FormInstance } from 'antd/lib'; import { FormInstance } from 'antd/lib';
import axios from 'axios'; import axios from 'axios';
import { EventSourceParserStream } from 'eventsource-parser/stream'; import { EventSourceParserStream } from 'eventsource-parser/stream';
import { omit } from 'lodash';
import { has, isEmpty, omit } from 'lodash';
import { import {
ChangeEventHandler, ChangeEventHandler,
useCallback, useCallback,
return appConf; 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 = ( export const useSendMessageWithSse = (
url: string = api.completeConversation, url: string = api.completeConversation,
) => { ) => {
const [answer, setAnswer] = useState<IAnswer>({} as IAnswer); const [answer, setAnswer] = useState<IAnswer>({} as IAnswer);
const [done, setDone] = useState(true); const [done, setDone] = useState(true);
const { doneRecord, clearDoneRecord, setDoneRecordById, allDone } =
useSetDoneRecord();
const timer = useRef<any>(); const timer = useRef<any>();
const sseRef = useRef<AbortController>(); const sseRef = useRef<AbortController>();


}, 1000); }, 1000);
}, []); }, []);


const setDoneValue = useCallback(
(body: any, value: boolean) => {
if (has(body, 'chatBoxId')) {
setDoneRecordById(body.chatBoxId, value);
} else {
setDone(value);
}
},
[setDoneRecordById],
);

const send = useCallback( const send = useCallback(
async ( async (
body: any, body: any,
): Promise<{ response: Response; data: ResponseType } | undefined> => { ): Promise<{ response: Response; data: ResponseType } | undefined> => {
initializeSseRef(); initializeSseRef();
try { try {
setDone(false);
setDoneValue(body, false);
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
} }
} }
} }
setDone(true);
setDoneValue(body, true);
resetAnswer(); resetAnswer();
return { data: await res, response }; return { data: await res, response };
} catch (e) { } catch (e) {
setDone(true);
setDoneValue(body, true);

resetAnswer(); resetAnswer();
// Swallow fetch errors silently // Swallow fetch errors silently
} }
}, },
[initializeSseRef, url, resetAnswer],
[initializeSseRef, setDoneValue, url, resetAnswer],
); );


const stopOutputMessage = useCallback(() => { const stopOutputMessage = useCallback(() => {
sseRef.current?.abort(); 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) => { export const useSpeechWithSse = (url: string = api.tts) => {

+ 33
- 0
web/src/hooks/use-chat-request.ts View File

DeleteMessage = 'deleteMessage', DeleteMessage = 'deleteMessage',
FetchMindMap = 'fetchMindMap', FetchMindMap = 'fetchMindMap',
FetchRelatedQuestions = 'fetchRelatedQuestions', FetchRelatedQuestions = 'fetchRelatedQuestions',
UploadAndParse = 'upload_and_parse',
} }


export const useGetChatSearchParams = () => { export const useGetChatSearchParams = () => {
queryKey: [ChatApiAction.FetchDialogList], queryKey: [ChatApiAction.FetchDialogList],
}); });


queryClient.invalidateQueries({
queryKey: [ChatApiAction.FetchDialog],
});

message.success( message.success(
t(`message.${params.dialog_id ? 'modified' : 'created'}`), t(`message.${params.dialog_id ? 'modified' : 'created'}`),
); );
return { data, loading, deleteMessage: mutateAsync }; 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 //#endregion


//#region search page //#region search page

+ 1
- 0
web/src/pages/agent/constant.tsx View File

exception_default_value: '', exception_default_value: '',
tools: [], tools: [],
mcp: [], mcp: [],
cite: true,
outputs: { outputs: {
// structured_output: { // structured_output: {
// topic: { // topic: {

+ 19
- 0
web/src/pages/agent/form/agent-form/index.tsx View File

FormLabel, FormLabel,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input, NumberInput } from '@/components/ui/input'; import { Input, NumberInput } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { LlmModelType } from '@/constants/knowledge'; import { LlmModelType } from '@/constants/knowledge';
import { useFindLlmByUuid } from '@/hooks/use-llm-request'; import { useFindLlmByUuid } from '@/hooks/use-llm-request';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
exception_goto: z.array(z.string()).optional(), exception_goto: z.array(z.string()).optional(),
exception_default_value: z.string().optional(), exception_default_value: z.string().optional(),
...LargeModelFilterFormSchema, ...LargeModelFilterFormSchema,
cite: z.boolean().optional(),
}); });


const outputList = buildOutputList(initialAgentValues.outputs); const outputList = buildOutputList(initialAgentValues.outputs);
<Collapse title={<div>Advanced Settings</div>}> <Collapse title={<div>Advanced Settings</div>}>
<FormContainer> <FormContainer>
<MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField> <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 <FormField
control={form.control} control={form.control}
name={`max_retries`} name={`max_retries`}

+ 30
- 4
web/src/pages/next-chats/chat/chat-box/multiple-chat-box.tsx View File

useFetchConversation, useFetchConversation,
useFetchDialog, useFetchDialog,
useGetChatSearchParams, useGetChatSearchParams,
useSetDialog,
} from '@/hooks/use-chat-request'; } from '@/hooks/use-chat-request';
import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; import { useFetchUserInfo } from '@/hooks/user-setting-hooks';
import { buildMessageUuidWithRole } from '@/utils/chat'; import { buildMessageUuidWithRole } from '@/utils/chat';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { isEmpty, omit } from 'lodash';
import { ListCheck, Plus, Trash2 } from 'lucide-react'; import { ListCheck, Plus, Trash2 } from 'lucide-react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from '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 { z } from 'zod';
import { import {
useGetSendButtonDisabled, useGetSendButtonDisabled,
id: string; id: string;
idx: number; idx: number;
derivedMessages: IMessage[]; derivedMessages: IMessage[];
sendLoading: boolean;
} & Pick< } & Pick<
MultipleChatBoxProps, MultipleChatBoxProps,
'controller' | 'removeChatBox' | 'addChatBox' | 'chatBoxIds' 'controller' | 'removeChatBox' | 'addChatBox' | 'chatBoxIds'
addChatBox, addChatBox,
chatBoxIds, chatBoxIds,
derivedMessages, derivedMessages,
sendLoading,
}: ChatCardProps, }: ChatCardProps,
ref, ref,
) { ) {
const { sendLoading, regenerateMessage, removeMessageById } =
useSendMessage(controller);
const { id: dialogId } = useParams();
const { setDialog } = useSetDialog();

const { regenerateMessage, removeMessageById } = useSendMessage(controller);


const messageContainerRef = useRef<HTMLDivElement>(null); const messageContainerRef = useRef<HTMLDivElement>(null);


}, },
}); });


const llmId = useWatch({ control: form.control, name: 'llm_id' });

const { data: userInfo } = useFetchUserInfo(); const { data: userInfo } = useFetchUserInfo();
const { data: currentDialog } = useFetchDialog(); const { data: currentDialog } = useFetchDialog();
const { data: conversation } = useFetchConversation(); const { data: conversation } = useFetchConversation();
removeChatBox(id); removeChatBox(id);
}, [id, removeChatBox]); }, [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, () => ({ useImperativeHandle(ref, () => ({
getFormData: () => form.getValues(), getFormData: () => form.getValues(),
})); }));
<div className="space-x-2"> <div className="space-x-2">
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Button variant={'ghost'}>
<Button
variant={'ghost'}
disabled={isEmpty(llmId)}
onClick={handleApplyConfig}
>
<ListCheck /> <ListCheck />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
handlePressEnter, handlePressEnter,
stopOutputMessage, stopOutputMessage,
setFormRef, setFormRef,
handleUploadFile,
} = useSendMultipleChatMessage(controller, chatBoxIds); } = useSendMultipleChatMessage(controller, chatBoxIds);


const { createConversationBeforeUploadDocument } = const { createConversationBeforeUploadDocument } =
addChatBox={addChatBox} addChatBox={addChatBox}
derivedMessages={messageRecord[id]} derivedMessages={messageRecord[id]}
ref={setFormRef(id)} ref={setFormRef(id)}
sendLoading={sendLoading}
></ChatCard> ></ChatCard>
))} ))}
</div> </div>
createConversationBeforeUploadDocument createConversationBeforeUploadDocument
} }
stopOutputMessage={stopOutputMessage} stopOutputMessage={stopOutputMessage}
onUpload={handleUploadFile}
/> />
</div> </div>
</section> </section>

+ 2
- 0
web/src/pages/next-chats/chat/chat-box/single-chat-box.tsx View File

regenerateMessage, regenerateMessage,
removeMessageById, removeMessageById,
stopOutputMessage, stopOutputMessage,
handleUploadFile,
} = useSendMessage(controller); } = useSendMessage(controller);
const { data: userInfo } = useFetchUserInfo(); const { data: userInfo } = useFetchUserInfo();
const { data: currentDialog } = useFetchDialog(); const { data: currentDialog } = useFetchDialog();
createConversationBeforeUploadDocument createConversationBeforeUploadDocument
} }
stopOutputMessage={stopOutputMessage} stopOutputMessage={stopOutputMessage}
onUpload={handleUploadFile}
/> />
</section> </section>
); );

+ 14
- 3
web/src/pages/next-chats/chat/index.tsx View File

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useSetModalState } from '@/hooks/common-hooks'; import { useSetModalState } from '@/hooks/common-hooks';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-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 { cn } from '@/lib/utils';
import { isEmpty } from 'lodash';
import { ArrowUpRight, LogOut } from 'lucide-react'; import { ArrowUpRight, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHandleClickConversationCard } from '../hooks/use-click-card'; import { useHandleClickConversationCard } from '../hooks/use-click-card';
hasThreeChatBox, hasThreeChatBox,
} = useAddChatBox(); } = useAddChatBox();


const { conversationId, isNew } = useGetChatSearchParams();

const { isDebugMode, switchDebugMode } = useSwitchDebugMode(); const { isDebugMode, switchDebugMode } = useSwitchDebugMode();


if (isDebugMode) { if (isDebugMode) {
<Button <Button
variant={'ghost'} variant={'ghost'}
onClick={switchDebugMode} onClick={switchDebugMode}
disabled={hasThreeChatBox}
disabled={
hasThreeChatBox ||
isEmpty(conversationId) ||
isNew === 'true'
}
> >
<ArrowUpRight /> Multiple Models <ArrowUpRight /> Multiple Models
</Button> </Button>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 p-0">
<CardContent className="flex-1 p-0 min-h-0">
<SingleChatBox controller={controller}></SingleChatBox> <SingleChatBox controller={controller}></SingleChatBox>
</CardContent> </CardContent>
</Card> </Card>

+ 29
- 19
web/src/pages/next-chats/hooks/use-send-chat-message.ts View File

import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { IMessage } from '../chat/interface'; import { IMessage } from '../chat/interface';
import { useFindPrologueFromDialogList } from './use-select-conversation-list'; import { useFindPrologueFromDialogList } from './use-select-conversation-list';
import { useUploadFile } from './use-upload-file';


export const useSetChatRouteParams = () => { export const useSetChatRouteParams = () => {
const [currentQueryParameters, setSearchParams] = useSearchParams(); const [currentQueryParameters, setSearchParams] = useSearchParams();
const { conversationId, isNew } = useGetChatSearchParams(); const { conversationId, isNew } = useGetChatSearchParams();
const { handleInputChange, value, setValue } = useHandleMessageInputChange(); const { handleInputChange, value, setValue } = useHandleMessageInputChange();


const { handleUploadFile, fileIds, clearFileIds } = useUploadFile();

const { send, answer, done } = useSendMessageWithSse( const { send, answer, done } = useSendMessageWithSse(
api.completeConversation, api.completeConversation,
); );
} }
}, [answer, addNewestAnswer, conversationId, isNew]); }, [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, id,
content: value.trim(),
role: MessageType.User, 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 { return {
handlePressEnter, handlePressEnter,
derivedMessages, derivedMessages,
removeMessageById, removeMessageById,
stopOutputMessage, stopOutputMessage,
handleUploadFile,
}; };
}; };

+ 13
- 5
web/src/pages/next-chats/hooks/use-send-multiple-message.ts View File

import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { IMessage } from '../chat/interface'; import { IMessage } from '../chat/interface';
import { useBuildFormRefs } from './use-build-form-refs'; import { useBuildFormRefs } from './use-build-form-refs';
import { useUploadFile } from './use-upload-file';


export function useSendMultipleChatMessage( export function useSendMultipleChatMessage(
controller: AbortController, controller: AbortController,
const { conversationId } = useGetChatSearchParams(); const { conversationId } = useGetChatSearchParams();


const { handleInputChange, value, setValue } = useHandleMessageInputChange(); const { handleInputChange, value, setValue } = useHandleMessageInputChange();
const { send, answer, done } = useSendMessageWithSse(
const { send, answer, allDone } = useSendMessageWithSse(
api.completeConversation, api.completeConversation,
); );


const { handleUploadFile, fileIds, clearFileIds } = useUploadFile();

const { setFormRef, getLLMConfigById, isLLMConfigEmpty } = const { setFormRef, getLLMConfigById, isLLMConfigEmpty } =
useBuildFormRefs(chatBoxIds); useBuildFormRefs(chatBoxIds);


id, id,
role: MessageType.User, role: MessageType.User,
chatBoxId, chatBoxId,
doc_ids: fileIds,
}); });
} }
}); });


if (done) {
// TODO:
if (allDone) {
setValue(''); setValue('');
chatBoxIds.forEach((chatBoxId) => { chatBoxIds.forEach((chatBoxId) => {
if (!isLLMConfigEmpty(chatBoxId)) { if (!isLLMConfigEmpty(chatBoxId)) {
id, id,
content: value.trim(), content: value.trim(),
role: MessageType.User, role: MessageType.User,
doc_ids: fileIds,
}, },
chatBoxId, chatBoxId,
}); });
} }
}); });
} }
clearFileIds();
}, [ }, [
value, value,
chatBoxIds, chatBoxIds,
done,
allDone,
clearFileIds,
isLLMConfigEmpty, isLLMConfigEmpty,
addNewestQuestion, addNewestQuestion,
fileIds,
setValue, setValue,
sendMessage, sendMessage,
]); ]);
handleInputChange, handleInputChange,
handlePressEnter, handlePressEnter,
stopOutputMessage, stopOutputMessage,
sendLoading: false,
sendLoading: !allDone,
setFormRef, setFormRef,
handleUploadFile,
}; };
} }

+ 27
- 0
web/src/pages/next-chats/hooks/use-upload-file.ts View File

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

+ 5
- 0
web/src/services/next-chat-service.ts View File

mindmap, mindmap,
getRelatedQuestions, getRelatedQuestions,
listNextDialog, listNextDialog,
upload_and_parse,
} = api; } = api;


const methods = { const methods = {
url: getRelatedQuestions, url: getRelatedQuestions,
method: 'post', method: 'post',
}, },
uploadAndParse: {
method: 'post',
url: upload_and_parse,
},
} as const; } as const;


const chatService = registerNextServer<keyof typeof methods>(methods); const chatService = registerNextServer<keyof typeof methods>(methods);

Loading…
Cancel
Save