123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413 |
- import * as React from 'react';
- import BaseComponent from '../_base/baseComponent';
- import cls from "classnames";
- import PropTypes from 'prop-types';
- import type { ChatProps, ChatState, Message } from './interface';
- import InputBox from './inputBox';
- import "@douyinfe/semi-foundation/chat/chat.scss";
- import Hint from './hint';
- import { IconChevronDown, IconDisc } from '@douyinfe/semi-icons';
- import ChatContent from './chatContent';
- import { getDefaultPropsFromGlobalConfig } from '../_utils';
- import { cssClasses, strings } from '@douyinfe/semi-foundation/chat/constants';
- import ChatFoundation, { ChatAdapter } from '@douyinfe/semi-foundation/chat/foundation';
- import type { FileItem } from '../upload';
- import LocaleConsumer from "../locale/localeConsumer";
- import { Locale } from "../locale/interface";
- import { Button, Upload } from '../index';
- const prefixCls = cssClasses.PREFIX;
- const { CHAT_ALIGN, MODE, SEND_HOT_KEY, MESSAGE_STATUS } = strings;
- class Chat extends BaseComponent<ChatProps, ChatState> {
- static __SemiComponentName__ = "Chat";
- // dragStatus: Whether the component contains the dragged object
- dragStatus = false;
- containerRef: React.RefObject<HTMLDivElement>;
- animation: any;
- wheelEventHandler: any;
- foundation: ChatFoundation;
- uploadRef: React.RefObject<Upload>;
- dropAreaRef: React.RefObject<HTMLDivElement>;
- scrollTargetRef: React.RefObject<HTMLElement>;
- static propTypes = {
- className: PropTypes.string,
- style: PropTypes.object,
- roleConfig: PropTypes.object,
- chats: PropTypes.array,
- hints: PropTypes.array,
- renderHintBox: PropTypes.func,
- onChatsChange: PropTypes.func,
- align: PropTypes.string,
- chatBoxRenderConfig: PropTypes.object,
- customMarkDownComponents: PropTypes.object,
- onClear: PropTypes.func,
- onMessageDelete: PropTypes.func,
- onMessageReset: PropTypes.func,
- onMessageCopy: PropTypes.func,
- onMessageGoodFeedback: PropTypes.func,
- onMessageBadFeedback: PropTypes.func,
- inputContentConvert: PropTypes.func,
- onMessageSend: PropTypes.func,
- InputBoxStyle: PropTypes.object,
- inputBoxCls: PropTypes.string,
- renderFullInputBox: PropTypes.func,
- placeholder: PropTypes.string,
- topSlot: PropTypes.node || PropTypes.array,
- bottomSlot: PropTypes.node || PropTypes.array,
- showStopGenerate: PropTypes.bool,
- showClearContext: PropTypes.bool,
- hintStyle: PropTypes.object,
- hintCls: PropTypes.string,
- uploadProps: PropTypes.object,
- uploadTipProps: PropTypes.object,
- mode: PropTypes.string,
- markdownRenderProps: PropTypes.object,
- };
- static defaultProps = getDefaultPropsFromGlobalConfig(Chat.__SemiComponentName__, {
- align: CHAT_ALIGN.LEFT_RIGHT,
- showStopGenerate: false,
- mode: MODE.BUBBLE,
- showClearContext: false,
- sendHotKey: SEND_HOT_KEY.ENTER,
- })
- constructor(props: ChatProps) {
- super(props);
- this.containerRef = React.createRef();
- this.uploadRef = React.createRef();
- this.dropAreaRef = React.createRef();
- this.wheelEventHandler = null;
- this.foundation = new ChatFoundation(this.adapter);
- this.scrollTargetRef = React.createRef();
- this.state = {
- backBottomVisible: false,
- chats: [],
- cacheHints: [],
- wheelScroll: false,
- uploadAreaVisible: false,
- };
- }
- get adapter(): ChatAdapter {
- return {
- ...super.adapter,
- getContainerRef: () => this.containerRef?.current,
- setWheelScroll: (flag: boolean) => {
- this.setState({
- wheelScroll: flag,
- });
- },
- notifyChatsChange: (chats: Message[]) => {
- const { onChatsChange } = this.props;
- onChatsChange && onChatsChange(chats);
- },
- notifyLikeMessage: (message: Message) => {
- const { onMessageGoodFeedback } = this.props;
- onMessageGoodFeedback && onMessageGoodFeedback(message);
- },
- notifyDislikeMessage: (message: Message) => {
- const { onMessageBadFeedback } = this.props;
- onMessageBadFeedback && onMessageBadFeedback(message);
- },
- notifyCopyMessage: (message: Message) => {
- const { onMessageCopy } = this.props;
- onMessageCopy && onMessageCopy(message);
- },
- notifyClearContext: () => {
- const { onClear } = this.props;
- onClear && onClear();
- },
- notifyMessageSend: (content: string, attachment: any[]) => {
- const { onMessageSend } = this.props;
- onMessageSend && onMessageSend(content, attachment);
- },
- notifyInputChange: (props: { inputValue: string; attachment: any[]}) => {
- const { onInputChange } = this.props;
- onInputChange && onInputChange(props);
- },
- setBackBottomVisible: (visible: boolean) => {
- this.setState((state) => {
- if (state.backBottomVisible !== visible) {
- return {
- backBottomVisible: visible,
- };
- }
- return null;
- });
- },
- registerWheelEvent: () => {
- this.adapter.unRegisterWheelEvent();
- const containerElement = this.containerRef.current;
- if (!containerElement) {
- return ;
- }
- this.wheelEventHandler = (e: any) => {
- /**
- * Why use this.scrollTargetRef.current and wheel's currentTarget target comparison?
- * Both scroll and wheel events are on the container
- * his.scrollTargetRef.current is the object where scrolling actually occurs
- * wheel's currentTarget is the container,
- * Only when the wheel event occurs and there is scroll, the following logic(show scroll bar) needs to be executed
- */
- if (this.scrollTargetRef?.current !== e.currentTarget) {
- return;
- }
- this.adapter.setWheelScroll(true);
- this.adapter.unRegisterWheelEvent();
- };
- containerElement.addEventListener('wheel', this.wheelEventHandler);
- },
- unRegisterWheelEvent: () => {
- if (this.wheelEventHandler) {
- const containerElement = this.containerRef.current;
- if (!containerElement) {
- return ;
- } else {
- containerElement.removeEventListener('wheel', this.wheelEventHandler);
- }
- this.wheelEventHandler = null;
- }
- },
- notifyStopGenerate: (e: MouseEvent) => {
- const { onStopGenerator } = this.props;
- onStopGenerator && onStopGenerator(e);
- },
- notifyHintClick: (hint: string) => {
- const { onHintClick } = this.props;
- onHintClick && onHintClick(hint);
- },
- setUploadAreaVisible: (visible: boolean) => {
- this.setState({ uploadAreaVisible: visible });
- },
- manualUpload: (file: File[]) => {
- const uploadComponent = this.uploadRef.current;
- if (uploadComponent) {
- uploadComponent.insert(file);
- }
- },
- getDropAreaElement: () => {
- return this.dropAreaRef?.current;
- },
- getDragStatus: () => this.dragStatus,
- setDragStatus: (status: boolean) => { this.dragStatus = status; },
- };
- }
- static getDerivedStateFromProps(nextProps: ChatProps, prevState: ChatState) {
- const { chats, hints } = nextProps;
- const newState = {} as any;
- if (chats !== prevState.chats) {
- newState.chats = chats ?? [];
- }
- if (hints !== prevState.cacheHints) {
- newState.cacheHints = hints;
- }
- if (Object.keys(newState).length) {
- return newState;
- }
- return null;
- }
- componentDidMount(): void {
- this.foundation.init();
- }
- componentDidUpdate(prevProps: Readonly<ChatProps>, prevState: Readonly<ChatState>, snapshot?: any): void {
- const { chats: newChats, hints: newHints } = this.props;
- const { chats: oldChats, cacheHints } = prevState;
- const { wheelScroll } = this.state;
- let shouldScroll = false;
- if (newChats !== oldChats) {
- if (Array.isArray(newChats) && Array.isArray(oldChats)) {
- const newLastChat = newChats[newChats.length - 1];
- const oldLastChat = oldChats[oldChats.length - 1];
- if (newChats.length > oldChats.length) {
- if (oldChats.length === 0 || newLastChat.id !== oldLastChat.id) {
- shouldScroll = true;
- }
- } else if (newChats.length === oldChats.length && newChats.length &&
- (newLastChat.status !== 'complete' || newLastChat.status !== oldLastChat.status)
- ) {
- shouldScroll = true;
- }
- }
- }
- if (newHints !== cacheHints) {
- if (newHints.length > cacheHints.length) {
- shouldScroll = true;
- }
- }
- if (!wheelScroll && shouldScroll) {
- this.foundation.scrollToBottomImmediately();
- }
- }
- componentWillUnmount(): void {
- this.foundation.destroy();
- }
- resetMessage = () => {
- this.foundation.resetMessage(null);
- }
- clearContext = () => {
- this.foundation.clearContext(null);
- }
- scrollToBottom = (animation: boolean) => {
- if (animation) {
- this.foundation.scrollToBottomWithAnimation();
- } else {
- this.foundation.scrollToBottomImmediately();
- }
- }
- sendMessage = (content: string, attachment: FileItem[]) => {
- this.foundation.onMessageSend(content, attachment);
- }
- containerScroll = (e: React.UIEvent<HTMLDivElement>) => {
- (this.scrollTargetRef as any).current = e.target as HTMLElement;
- if (e.target !== e.currentTarget) {
- return;
- }
- this.foundation.containerScroll(e);
- }
- render() {
- const { topSlot, bottomSlot, roleConfig, hints,
- onChatsChange, onMessageCopy, renderInputArea,
- chatBoxRenderConfig, align, renderHintBox,
- style, className, showStopGenerate,
- customMarkDownComponents, mode, showClearContext,
- placeholder, inputBoxCls, inputBoxStyle,
- hintStyle, hintCls, uploadProps, uploadTipProps,
- sendHotKey, renderDivider, markdownRenderProps, enableUpload
- } = this.props;
- const { backBottomVisible, chats, wheelScroll, uploadAreaVisible } = this.state;
- let showStopGenerateFlag = false;
- const lastChat = chats.length > 0 && chats[chats.length - 1];
- let disableSend = false;
- if (lastChat && showStopGenerate) {
- const lastChatOnGoing = lastChat?.status && [MESSAGE_STATUS.LOADING, MESSAGE_STATUS.INCOMPLETE].includes(lastChat?.status);
- disableSend = lastChatOnGoing;
- showStopGenerate && (showStopGenerateFlag = lastChatOnGoing);
- }
- const { dragUpload, clickUpload, pasteUpload } = this.foundation.getUploadProps(enableUpload);
- const dragEventHandlers = dragUpload ? {
- onDragOver: this.foundation.handleDragOver,
- onDragStart: this.foundation.handleDragStart,
- onDragEnd: this.foundation.handleDragEnd,
- } : {};
- return (
- <div
- className={cls(`${prefixCls}`, className)}
- style={style}
- {...dragEventHandlers}
- >
- {dragUpload && uploadAreaVisible && <div
- ref={this.dropAreaRef}
- className={`${prefixCls}-dropArea`}
- onDragOver={this.foundation.handleContainerDragOver}
- onDrop={this.foundation.handleContainerDrop}
- onDragLeave={this.foundation.handleContainerDragLeave}
- >
- <span className={`${prefixCls}-dropArea-text`}>
- <LocaleConsumer<Locale["Chat"]> componentName="Chat" >
- {(locale: Locale["Chat"]) => locale['dropAreaText']}
- </LocaleConsumer>
- </span>
- </div>}
- <div className={`${prefixCls}-inner`}>
- {/* top slot */}
- {topSlot}
- {/* chat area */}
- <div className={`${prefixCls}-content`}>
- <div
- className={cls(`${prefixCls}-container`, {
- 'semi-chat-container-scroll-hidden': !wheelScroll
- })}
- onScroll={this.containerScroll}
- ref={this.containerRef}
- >
- <ChatContent
- align={align}
- mode={mode}
- chats={chats}
- roleConfig={roleConfig}
- customMarkDownComponents={customMarkDownComponents}
- onMessageDelete={this.foundation.deleteMessage}
- onChatsChange={onChatsChange}
- onMessageBadFeedback={this.foundation.dislikeMessage}
- onMessageGoodFeedback={this.foundation.likeMessage}
- onMessageReset={this.foundation.resetMessage}
- onMessageCopy={onMessageCopy}
- chatBoxRenderConfig={chatBoxRenderConfig}
- renderDivider={renderDivider}
- markdownRenderProps={markdownRenderProps}
- />
- {/* hint area */}
- {!!hints?.length && <Hint
- className={hintCls}
- style={hintStyle}
- value={hints}
- onHintClick={this.foundation.onHintClick}
- renderHintBox={renderHintBox}
- />}
- </div>
- </div>
- {backBottomVisible && !showStopGenerateFlag && (<span className={`${prefixCls}-action`}>
- <Button
- className={`${prefixCls}-action-content ${prefixCls}-action-backBottom`}
- icon={<IconChevronDown size="extra-large"/>}
- type="tertiary"
- onClick={this.foundation.scrollToBottomWithAnimation}
- />
- </span>)}
- {showStopGenerateFlag && (<span className={`${prefixCls}-action`}>
- <Button
- className={`${prefixCls}-action-content ${prefixCls}-action-stop`}
- icon={<IconDisc size="extra-large" />}
- type="tertiary"
- onClick={this.foundation.stopGenerate}
- >
- <LocaleConsumer<Locale["Chat"]> componentName="Chat" >
- {(locale: Locale["Chat"]) => locale['stop']}
- </LocaleConsumer>
- </Button>
- </span>)}
- {/* input area */}
- <InputBox
- showClearContext={showClearContext}
- uploadRef={this.uploadRef}
- manualUpload={this.adapter.manualUpload}
- style={inputBoxStyle}
- className={inputBoxCls}
- placeholder={placeholder}
- disableSend={disableSend}
- onClearContext={this.foundation.clearContext}
- onSend={this.foundation.onMessageSend}
- onInputChange={this.foundation.onInputChange}
- renderInputArea={renderInputArea}
- uploadProps={uploadProps}
- uploadTipProps={uploadTipProps}
- sendHotKey={sendHotKey}
- clickUpload={clickUpload}
- pasteUpload={pasteUpload}
- />
- {bottomSlot}
- </div>
- </div>
- );
- }
- }
- export default Chat;
|