index.tsx 17 KB

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