index.tsx 16 KB

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