index.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import * as React from 'react';
  2. import BaseComponent from '../_base/baseComponent';
  3. import cls from "classnames";
  4. import PropTypes from 'prop-types';
  5. import type { ChatProps, ChatState, Message } from './interface';
  6. import InputBox from './inputBox';
  7. import "@douyinfe/semi-foundation/chat/chat.scss";
  8. import Hint from './hint';
  9. import { IconChevronDown, IconDisc } from '@douyinfe/semi-icons';
  10. import ChatContent from './chatContent';
  11. import { getDefaultPropsFromGlobalConfig } from '../_utils';
  12. import { cssClasses, strings } from '@douyinfe/semi-foundation/chat/constants';
  13. import ChatFoundation, { ChatAdapter } from '@douyinfe/semi-foundation/chat/foundation';
  14. import type { FileItem } from '../upload';
  15. import LocaleConsumer from "../locale/localeConsumer";
  16. import { Locale } from "../locale/interface";
  17. import { Button, Upload } from '../index';
  18. const prefixCls = cssClasses.PREFIX;
  19. const { CHAT_ALIGN, MODE, SEND_HOT_KEY, MESSAGE_STATUS } = strings;
  20. class Chat extends BaseComponent<ChatProps, ChatState> {
  21. static __SemiComponentName__ = "Chat";
  22. // dragStatus: Whether the component contains the dragged object
  23. dragStatus = false;
  24. containerRef: React.RefObject<HTMLDivElement>;
  25. animation: any;
  26. wheelEventHandler: any;
  27. foundation: ChatFoundation;
  28. uploadRef: React.RefObject<Upload>;
  29. dropAreaRef: React.RefObject<HTMLDivElement>;
  30. scrollTargetRef: React.RefObject<HTMLElement>;
  31. static propTypes = {
  32. className: PropTypes.string,
  33. style: PropTypes.object,
  34. roleConfig: PropTypes.object,
  35. chats: PropTypes.array,
  36. hints: PropTypes.array,
  37. renderHintBox: PropTypes.func,
  38. onChatsChange: PropTypes.func,
  39. align: PropTypes.string,
  40. chatBoxRenderConfig: PropTypes.object,
  41. customMarkDownComponents: PropTypes.object,
  42. onClear: PropTypes.func,
  43. onMessageDelete: PropTypes.func,
  44. onMessageReset: PropTypes.func,
  45. onMessageCopy: PropTypes.func,
  46. onMessageGoodFeedback: PropTypes.func,
  47. onMessageBadFeedback: PropTypes.func,
  48. inputContentConvert: PropTypes.func,
  49. onMessageSend: PropTypes.func,
  50. InputBoxStyle: PropTypes.object,
  51. inputBoxCls: PropTypes.string,
  52. renderFullInputBox: PropTypes.func,
  53. placeholder: PropTypes.string,
  54. topSlot: PropTypes.node || PropTypes.array,
  55. bottomSlot: PropTypes.node || PropTypes.array,
  56. showStopGenerate: PropTypes.bool,
  57. showClearContext: PropTypes.bool,
  58. hintStyle: PropTypes.object,
  59. hintCls: PropTypes.string,
  60. uploadProps: PropTypes.object,
  61. uploadTipProps: PropTypes.object,
  62. mode: PropTypes.string,
  63. markdownRenderProps: PropTypes.object,
  64. };
  65. static defaultProps = getDefaultPropsFromGlobalConfig(Chat.__SemiComponentName__, {
  66. align: CHAT_ALIGN.LEFT_RIGHT,
  67. showStopGenerate: false,
  68. mode: MODE.BUBBLE,
  69. showClearContext: false,
  70. sendHotKey: SEND_HOT_KEY.ENTER,
  71. })
  72. constructor(props: ChatProps) {
  73. super(props);
  74. this.containerRef = React.createRef();
  75. this.uploadRef = React.createRef();
  76. this.dropAreaRef = React.createRef();
  77. this.wheelEventHandler = null;
  78. this.foundation = new ChatFoundation(this.adapter);
  79. this.scrollTargetRef = React.createRef();
  80. this.state = {
  81. backBottomVisible: false,
  82. chats: [],
  83. cacheHints: [],
  84. wheelScroll: false,
  85. uploadAreaVisible: false,
  86. };
  87. }
  88. get adapter(): ChatAdapter {
  89. return {
  90. ...super.adapter,
  91. getContainerRef: () => this.containerRef?.current,
  92. setWheelScroll: (flag: boolean) => {
  93. this.setState({
  94. wheelScroll: flag,
  95. });
  96. },
  97. notifyChatsChange: (chats: Message[]) => {
  98. const { onChatsChange } = this.props;
  99. onChatsChange && onChatsChange(chats);
  100. },
  101. notifyLikeMessage: (message: Message) => {
  102. const { onMessageGoodFeedback } = this.props;
  103. onMessageGoodFeedback && onMessageGoodFeedback(message);
  104. },
  105. notifyDislikeMessage: (message: Message) => {
  106. const { onMessageBadFeedback } = this.props;
  107. onMessageBadFeedback && onMessageBadFeedback(message);
  108. },
  109. notifyCopyMessage: (message: Message) => {
  110. const { onMessageCopy } = this.props;
  111. onMessageCopy && onMessageCopy(message);
  112. },
  113. notifyClearContext: () => {
  114. const { onClear } = this.props;
  115. onClear && onClear();
  116. },
  117. notifyMessageSend: (content: string, attachment: any[]) => {
  118. const { onMessageSend } = this.props;
  119. onMessageSend && onMessageSend(content, attachment);
  120. },
  121. notifyInputChange: (props: { inputValue: string; attachment: any[]}) => {
  122. const { onInputChange } = this.props;
  123. onInputChange && onInputChange(props);
  124. },
  125. setBackBottomVisible: (visible: boolean) => {
  126. this.setState((state) => {
  127. if (state.backBottomVisible !== visible) {
  128. return {
  129. backBottomVisible: visible,
  130. };
  131. }
  132. return null;
  133. });
  134. },
  135. registerWheelEvent: () => {
  136. this.adapter.unRegisterWheelEvent();
  137. const containerElement = this.containerRef.current;
  138. if (!containerElement) {
  139. return ;
  140. }
  141. this.wheelEventHandler = (e: any) => {
  142. /**
  143. * Why use this.scrollTargetRef.current and wheel's currentTarget target comparison?
  144. * Both scroll and wheel events are on the container
  145. * his.scrollTargetRef.current is the object where scrolling actually occurs
  146. * wheel's currentTarget is the container,
  147. * Only when the wheel event occurs and there is scroll, the following logic(show scroll bar) needs to be executed
  148. */
  149. if (this.scrollTargetRef?.current !== e.currentTarget) {
  150. return;
  151. }
  152. this.adapter.setWheelScroll(true);
  153. this.adapter.unRegisterWheelEvent();
  154. };
  155. containerElement.addEventListener('wheel', this.wheelEventHandler);
  156. },
  157. unRegisterWheelEvent: () => {
  158. if (this.wheelEventHandler) {
  159. const containerElement = this.containerRef.current;
  160. if (!containerElement) {
  161. return ;
  162. } else {
  163. containerElement.removeEventListener('wheel', this.wheelEventHandler);
  164. }
  165. this.wheelEventHandler = null;
  166. }
  167. },
  168. notifyStopGenerate: (e: MouseEvent) => {
  169. const { onStopGenerator } = this.props;
  170. onStopGenerator && onStopGenerator(e);
  171. },
  172. notifyHintClick: (hint: string) => {
  173. const { onHintClick } = this.props;
  174. onHintClick && onHintClick(hint);
  175. },
  176. setUploadAreaVisible: (visible: boolean) => {
  177. this.setState({ uploadAreaVisible: visible });
  178. },
  179. manualUpload: (file: File[]) => {
  180. const uploadComponent = this.uploadRef.current;
  181. if (uploadComponent) {
  182. uploadComponent.insert(file);
  183. }
  184. },
  185. getDropAreaElement: () => {
  186. return this.dropAreaRef?.current;
  187. },
  188. getDragStatus: () => this.dragStatus,
  189. setDragStatus: (status: boolean) => { this.dragStatus = status; },
  190. };
  191. }
  192. static getDerivedStateFromProps(nextProps: ChatProps, prevState: ChatState) {
  193. const { chats, hints } = nextProps;
  194. const newState = {} as any;
  195. if (chats !== prevState.chats) {
  196. newState.chats = chats ?? [];
  197. }
  198. if (hints !== prevState.cacheHints) {
  199. newState.cacheHints = hints;
  200. }
  201. if (Object.keys(newState).length) {
  202. return newState;
  203. }
  204. return null;
  205. }
  206. componentDidMount(): void {
  207. this.foundation.init();
  208. }
  209. componentDidUpdate(prevProps: Readonly<ChatProps>, prevState: Readonly<ChatState>, snapshot?: any): void {
  210. const { chats: newChats, hints: newHints } = this.props;
  211. const { chats: oldChats, cacheHints } = prevState;
  212. const { wheelScroll } = this.state;
  213. let shouldScroll = false;
  214. if (newChats !== oldChats) {
  215. if (Array.isArray(newChats) && Array.isArray(oldChats)) {
  216. const newLastChat = newChats[newChats.length - 1];
  217. const oldLastChat = oldChats[oldChats.length - 1];
  218. if (newChats.length > oldChats.length) {
  219. if (oldChats.length === 0 || newLastChat.id !== oldLastChat.id) {
  220. shouldScroll = true;
  221. }
  222. } else if (newChats.length === oldChats.length && newChats.length &&
  223. (newLastChat.status !== 'complete' || newLastChat.status !== oldLastChat.status)
  224. ) {
  225. shouldScroll = true;
  226. }
  227. }
  228. }
  229. if (newHints !== cacheHints) {
  230. if (newHints.length > cacheHints.length) {
  231. shouldScroll = true;
  232. }
  233. }
  234. if (!wheelScroll && shouldScroll) {
  235. this.foundation.scrollToBottomImmediately();
  236. }
  237. }
  238. componentWillUnmount(): void {
  239. this.foundation.destroy();
  240. }
  241. resetMessage = () => {
  242. this.foundation.resetMessage(null);
  243. }
  244. clearContext = () => {
  245. this.foundation.clearContext(null);
  246. }
  247. scrollToBottom = (animation: boolean) => {
  248. if (animation) {
  249. this.foundation.scrollToBottomWithAnimation();
  250. } else {
  251. this.foundation.scrollToBottomImmediately();
  252. }
  253. }
  254. sendMessage = (content: string, attachment: FileItem[]) => {
  255. this.foundation.onMessageSend(content, attachment);
  256. }
  257. containerScroll = (e: React.UIEvent<HTMLDivElement>) => {
  258. (this.scrollTargetRef as any).current = e.target as HTMLElement;
  259. if (e.target !== e.currentTarget) {
  260. return;
  261. }
  262. this.foundation.containerScroll(e);
  263. }
  264. render() {
  265. const { topSlot, bottomSlot, roleConfig, hints,
  266. onChatsChange, onMessageCopy, renderInputArea,
  267. chatBoxRenderConfig, align, renderHintBox,
  268. style, className, showStopGenerate,
  269. customMarkDownComponents, mode, showClearContext,
  270. placeholder, inputBoxCls, inputBoxStyle,
  271. hintStyle, hintCls, uploadProps, uploadTipProps,
  272. sendHotKey, renderDivider, markdownRenderProps, enableUpload
  273. } = this.props;
  274. const { backBottomVisible, chats, wheelScroll, uploadAreaVisible } = this.state;
  275. let showStopGenerateFlag = false;
  276. const lastChat = chats.length > 0 && chats[chats.length - 1];
  277. let disableSend = false;
  278. if (lastChat && showStopGenerate) {
  279. const lastChatOnGoing = lastChat?.status && [MESSAGE_STATUS.LOADING, MESSAGE_STATUS.INCOMPLETE].includes(lastChat?.status);
  280. disableSend = lastChatOnGoing;
  281. showStopGenerate && (showStopGenerateFlag = lastChatOnGoing);
  282. }
  283. const { dragUpload, clickUpload, pasteUpload } = this.foundation.getUploadProps(enableUpload);
  284. const dragEventHandlers = dragUpload ? {
  285. onDragOver: this.foundation.handleDragOver,
  286. onDragStart: this.foundation.handleDragStart,
  287. onDragEnd: this.foundation.handleDragEnd,
  288. } : {};
  289. return (
  290. <div
  291. className={cls(`${prefixCls}`, className)}
  292. style={style}
  293. {...dragEventHandlers}
  294. >
  295. {dragUpload && uploadAreaVisible && <div
  296. ref={this.dropAreaRef}
  297. className={`${prefixCls}-dropArea`}
  298. onDragOver={this.foundation.handleContainerDragOver}
  299. onDrop={this.foundation.handleContainerDrop}
  300. onDragLeave={this.foundation.handleContainerDragLeave}
  301. >
  302. <span className={`${prefixCls}-dropArea-text`}>
  303. <LocaleConsumer<Locale["Chat"]> componentName="Chat" >
  304. {(locale: Locale["Chat"]) => locale['dropAreaText']}
  305. </LocaleConsumer>
  306. </span>
  307. </div>}
  308. <div className={`${prefixCls}-inner`}>
  309. {/* top slot */}
  310. {topSlot}
  311. {/* chat area */}
  312. <div className={`${prefixCls}-content`}>
  313. <div
  314. className={cls(`${prefixCls}-container`, {
  315. 'semi-chat-container-scroll-hidden': !wheelScroll
  316. })}
  317. onScroll={this.containerScroll}
  318. ref={this.containerRef}
  319. >
  320. <ChatContent
  321. align={align}
  322. mode={mode}
  323. chats={chats}
  324. roleConfig={roleConfig}
  325. customMarkDownComponents={customMarkDownComponents}
  326. onMessageDelete={this.foundation.deleteMessage}
  327. onChatsChange={onChatsChange}
  328. onMessageBadFeedback={this.foundation.dislikeMessage}
  329. onMessageGoodFeedback={this.foundation.likeMessage}
  330. onMessageReset={this.foundation.resetMessage}
  331. onMessageCopy={onMessageCopy}
  332. chatBoxRenderConfig={chatBoxRenderConfig}
  333. renderDivider={renderDivider}
  334. markdownRenderProps={markdownRenderProps}
  335. />
  336. {/* hint area */}
  337. {!!hints?.length && <Hint
  338. className={hintCls}
  339. style={hintStyle}
  340. value={hints}
  341. onHintClick={this.foundation.onHintClick}
  342. renderHintBox={renderHintBox}
  343. />}
  344. </div>
  345. </div>
  346. {backBottomVisible && !showStopGenerateFlag && (<span className={`${prefixCls}-action`}>
  347. <Button
  348. className={`${prefixCls}-action-content ${prefixCls}-action-backBottom`}
  349. icon={<IconChevronDown size="extra-large"/>}
  350. type="tertiary"
  351. onClick={this.foundation.scrollToBottomWithAnimation}
  352. />
  353. </span>)}
  354. {showStopGenerateFlag && (<span className={`${prefixCls}-action`}>
  355. <Button
  356. className={`${prefixCls}-action-content ${prefixCls}-action-stop`}
  357. icon={<IconDisc size="extra-large" />}
  358. type="tertiary"
  359. onClick={this.foundation.stopGenerate}
  360. >
  361. <LocaleConsumer<Locale["Chat"]> componentName="Chat" >
  362. {(locale: Locale["Chat"]) => locale['stop']}
  363. </LocaleConsumer>
  364. </Button>
  365. </span>)}
  366. {/* input area */}
  367. <InputBox
  368. showClearContext={showClearContext}
  369. uploadRef={this.uploadRef}
  370. manualUpload={this.adapter.manualUpload}
  371. style={inputBoxStyle}
  372. className={inputBoxCls}
  373. placeholder={placeholder}
  374. disableSend={disableSend}
  375. onClearContext={this.foundation.clearContext}
  376. onSend={this.foundation.onMessageSend}
  377. onInputChange={this.foundation.onInputChange}
  378. renderInputArea={renderInputArea}
  379. uploadProps={uploadProps}
  380. uploadTipProps={uploadTipProps}
  381. sendHotKey={sendHotKey}
  382. clickUpload={clickUpload}
  383. pasteUpload={pasteUpload}
  384. />
  385. {bottomSlot}
  386. </div>
  387. </div>
  388. );
  389. }
  390. }
  391. export default Chat;