| 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;
 |