Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

index.tsx 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { useTranslate } from '@/hooks/common-hooks';
  2. import {
  3. useDeleteDocument,
  4. useFetchDocumentInfosByIds,
  5. useRemoveNextDocument,
  6. useUploadAndParseDocument,
  7. } from '@/hooks/document-hooks';
  8. import { cn } from '@/lib/utils';
  9. import { getExtension } from '@/utils/document-util';
  10. import { formatBytes } from '@/utils/file-util';
  11. import {
  12. CloseCircleOutlined,
  13. InfoCircleOutlined,
  14. LoadingOutlined,
  15. } from '@ant-design/icons';
  16. import type { GetProp, UploadFile } from 'antd';
  17. import {
  18. Button,
  19. Card,
  20. Divider,
  21. Flex,
  22. Input,
  23. List,
  24. Space,
  25. Spin,
  26. Typography,
  27. Upload,
  28. UploadProps,
  29. } from 'antd';
  30. import get from 'lodash/get';
  31. import { CircleStop, Paperclip, SendHorizontal } from 'lucide-react';
  32. import {
  33. ChangeEventHandler,
  34. memo,
  35. useCallback,
  36. useEffect,
  37. useRef,
  38. useState,
  39. } from 'react';
  40. import FileIcon from '../file-icon';
  41. import styles from './index.less';
  42. type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
  43. const { Text } = Typography;
  44. const { TextArea } = Input;
  45. const getFileId = (file: UploadFile) => get(file, 'response.data.0');
  46. const getFileIds = (fileList: UploadFile[]) => {
  47. const ids = fileList.reduce((pre, cur) => {
  48. return pre.concat(get(cur, 'response.data', []));
  49. }, []);
  50. return ids;
  51. };
  52. const isUploadSuccess = (file: UploadFile) => {
  53. const code = get(file, 'response.code');
  54. return typeof code === 'number' && code === 0;
  55. };
  56. interface IProps {
  57. disabled: boolean;
  58. value: string;
  59. sendDisabled: boolean;
  60. sendLoading: boolean;
  61. onPressEnter(documentIds: string[]): void;
  62. onInputChange: ChangeEventHandler<HTMLTextAreaElement>;
  63. conversationId: string;
  64. uploadMethod?: string;
  65. isShared?: boolean;
  66. showUploadIcon?: boolean;
  67. createConversationBeforeUploadDocument?(message: string): Promise<any>;
  68. stopOutputMessage?(): void;
  69. }
  70. const getBase64 = (file: FileType): Promise<string> =>
  71. new Promise((resolve, reject) => {
  72. const reader = new FileReader();
  73. reader.readAsDataURL(file as any);
  74. reader.onload = () => resolve(reader.result as string);
  75. reader.onerror = (error) => reject(error);
  76. });
  77. const MessageInput = ({
  78. isShared = false,
  79. disabled,
  80. value,
  81. onPressEnter,
  82. sendDisabled,
  83. sendLoading,
  84. onInputChange,
  85. conversationId,
  86. showUploadIcon = true,
  87. createConversationBeforeUploadDocument,
  88. uploadMethod = 'upload_and_parse',
  89. stopOutputMessage,
  90. }: IProps) => {
  91. const { t } = useTranslate('chat');
  92. const { removeDocument } = useRemoveNextDocument();
  93. const { deleteDocument } = useDeleteDocument();
  94. const { data: documentInfos, setDocumentIds } = useFetchDocumentInfosByIds();
  95. const { uploadAndParseDocument } = useUploadAndParseDocument(uploadMethod);
  96. const conversationIdRef = useRef(conversationId);
  97. const [fileList, setFileList] = useState<UploadFile[]>([]);
  98. const handlePreview = async (file: UploadFile) => {
  99. if (!file.url && !file.preview) {
  100. file.preview = await getBase64(file.originFileObj as FileType);
  101. }
  102. };
  103. const handleChange: UploadProps['onChange'] = async ({
  104. // fileList: newFileList,
  105. file,
  106. }) => {
  107. let nextConversationId: string = conversationId;
  108. if (createConversationBeforeUploadDocument) {
  109. const creatingRet = await createConversationBeforeUploadDocument(
  110. file.name,
  111. );
  112. if (creatingRet?.code === 0) {
  113. nextConversationId = creatingRet.data.id;
  114. }
  115. }
  116. setFileList((list) => {
  117. list.push({
  118. ...file,
  119. status: 'uploading',
  120. originFileObj: file as any,
  121. });
  122. return [...list];
  123. });
  124. const ret = await uploadAndParseDocument({
  125. conversationId: nextConversationId,
  126. fileList: [file],
  127. });
  128. setFileList((list) => {
  129. const nextList = list.filter((x) => x.uid !== file.uid);
  130. nextList.push({
  131. ...file,
  132. originFileObj: file as any,
  133. response: ret,
  134. percent: 100,
  135. status: ret?.code === 0 ? 'done' : 'error',
  136. });
  137. return nextList;
  138. });
  139. };
  140. const isUploadingFile = fileList.some((x) => x.status === 'uploading');
  141. const handlePressEnter = useCallback(async () => {
  142. if (isUploadingFile) return;
  143. const ids = getFileIds(fileList.filter((x) => isUploadSuccess(x)));
  144. onPressEnter(ids);
  145. setFileList([]);
  146. }, [fileList, onPressEnter, isUploadingFile]);
  147. const handleKeyDown = useCallback(
  148. async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
  149. // check if it was shift + enter
  150. if (event.key === 'Enter' && event.shiftKey) return;
  151. if (event.key !== 'Enter') return;
  152. if (sendDisabled || isUploadingFile || sendLoading) return;
  153. event.preventDefault();
  154. handlePressEnter();
  155. },
  156. [sendDisabled, isUploadingFile, sendLoading, handlePressEnter],
  157. );
  158. const handleRemove = useCallback(
  159. async (file: UploadFile) => {
  160. const ids = get(file, 'response.data', []);
  161. // Upload Successfully
  162. if (Array.isArray(ids) && ids.length) {
  163. if (isShared) {
  164. await deleteDocument(ids);
  165. } else {
  166. await removeDocument(ids[0]);
  167. }
  168. setFileList((preList) => {
  169. return preList.filter((x) => getFileId(x) !== ids[0]);
  170. });
  171. } else {
  172. // Upload failed
  173. setFileList((preList) => {
  174. return preList.filter((x) => x.uid !== file.uid);
  175. });
  176. }
  177. },
  178. [removeDocument, deleteDocument, isShared],
  179. );
  180. const handleStopOutputMessage = useCallback(() => {
  181. stopOutputMessage?.();
  182. }, [stopOutputMessage]);
  183. const getDocumentInfoById = useCallback(
  184. (id: string) => {
  185. return documentInfos.find((x) => x.id === id);
  186. },
  187. [documentInfos],
  188. );
  189. useEffect(() => {
  190. const ids = getFileIds(fileList);
  191. setDocumentIds(ids);
  192. }, [fileList, setDocumentIds]);
  193. useEffect(() => {
  194. if (
  195. conversationIdRef.current &&
  196. conversationId !== conversationIdRef.current
  197. ) {
  198. setFileList([]);
  199. }
  200. conversationIdRef.current = conversationId;
  201. }, [conversationId, setFileList]);
  202. return (
  203. <Flex
  204. gap={1}
  205. vertical
  206. className={cn(styles.messageInputWrapper, 'dark:bg-black')}
  207. >
  208. <TextArea
  209. size="large"
  210. placeholder={t('sendPlaceholder')}
  211. value={value}
  212. allowClear
  213. disabled={disabled}
  214. style={{
  215. border: 'none',
  216. boxShadow: 'none',
  217. padding: '0px 10px',
  218. marginTop: 10,
  219. }}
  220. autoSize={{ minRows: 2, maxRows: 10 }}
  221. onKeyDown={handleKeyDown}
  222. onChange={onInputChange}
  223. />
  224. <Divider style={{ margin: '5px 30px 10px 0px' }} />
  225. <Flex justify="space-between" align="center">
  226. {fileList.length > 0 && (
  227. <List
  228. grid={{
  229. gutter: 16,
  230. xs: 1,
  231. sm: 1,
  232. md: 1,
  233. lg: 1,
  234. xl: 2,
  235. xxl: 4,
  236. }}
  237. dataSource={fileList}
  238. className={styles.listWrapper}
  239. renderItem={(item) => {
  240. const id = getFileId(item);
  241. const documentInfo = getDocumentInfoById(id);
  242. const fileExtension = getExtension(documentInfo?.name ?? '');
  243. const fileName = item.originFileObj?.name ?? '';
  244. return (
  245. <List.Item>
  246. <Card className={styles.documentCard}>
  247. <Flex gap={10} align="center">
  248. {item.status === 'uploading' ? (
  249. <Spin
  250. indicator={
  251. <LoadingOutlined style={{ fontSize: 24 }} spin />
  252. }
  253. />
  254. ) : item.status === 'error' ? (
  255. <InfoCircleOutlined size={30}></InfoCircleOutlined>
  256. ) : (
  257. <FileIcon id={id} name={fileName}></FileIcon>
  258. )}
  259. <Flex vertical style={{ width: '90%' }}>
  260. <Text
  261. ellipsis={{ tooltip: fileName }}
  262. className={styles.nameText}
  263. >
  264. <b> {fileName}</b>
  265. </Text>
  266. {item.status === 'error' ? (
  267. t('uploadFailed')
  268. ) : (
  269. <>
  270. {item.percent !== 100 ? (
  271. t('uploading')
  272. ) : !item.response ? (
  273. t('parsing')
  274. ) : (
  275. <Space>
  276. <span>{fileExtension?.toUpperCase()},</span>
  277. <span>
  278. {formatBytes(
  279. getDocumentInfoById(id)?.size ?? 0,
  280. )}
  281. </span>
  282. </Space>
  283. )}
  284. </>
  285. )}
  286. </Flex>
  287. </Flex>
  288. {item.status !== 'uploading' && (
  289. <span className={styles.deleteIcon}>
  290. <CloseCircleOutlined
  291. onClick={() => handleRemove(item)}
  292. />
  293. </span>
  294. )}
  295. </Card>
  296. </List.Item>
  297. );
  298. }}
  299. />
  300. )}
  301. <Flex
  302. gap={5}
  303. align="center"
  304. justify="flex-end"
  305. style={{
  306. paddingRight: 10,
  307. paddingBottom: 10,
  308. width: fileList.length > 0 ? '50%' : '100%',
  309. }}
  310. >
  311. {showUploadIcon && (
  312. <Upload
  313. onPreview={handlePreview}
  314. onChange={handleChange}
  315. multiple={false}
  316. onRemove={handleRemove}
  317. showUploadList={false}
  318. beforeUpload={() => {
  319. return false;
  320. }}
  321. >
  322. <Button type={'primary'} disabled={disabled}>
  323. <Paperclip className="size-4" />
  324. </Button>
  325. </Upload>
  326. )}
  327. {sendLoading ? (
  328. <Button onClick={handleStopOutputMessage}>
  329. <CircleStop className="size-5" />
  330. </Button>
  331. ) : (
  332. <Button
  333. type="primary"
  334. onClick={handlePressEnter}
  335. loading={sendLoading}
  336. disabled={sendDisabled || isUploadingFile || sendLoading}
  337. >
  338. <SendHorizontal className="size-5" />
  339. </Button>
  340. )}
  341. </Flex>
  342. </Flex>
  343. </Flex>
  344. );
  345. };
  346. export default memo(MessageInput);