index.tsx 16 KB

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