/* eslint-disable jsx-a11y/no-static-element-interactions */ import React from 'react'; import BaseComponent from '../_base/baseComponent'; import { AIChatInputProps, AIChatInputState, Skill, Attachment, Reference, Content, LeftMenuChangeProps } from './interface'; import { noop, isEqual } from 'lodash'; import { cssClasses, numbers } from '@douyinfe/semi-foundation/aiChatInput/constants'; import { Popover, Tooltip, Upload, Progress } from '../index'; import { IconSendMsgStroked, IconFile, IconCode, IconCrossStroked, IconPaperclip, IconArrowUp, IconStop, IconClose, IconTemplateStroked, IconMusic, IconVideo, IconPdf, IconWord, IconExcel, IconSize } from '@douyinfe/semi-icons'; import '@douyinfe/semi-foundation/aiChatInput/aiChatInput.scss'; import HorizontalScroller from './horizontalScroller'; import cls from 'classnames'; import { getAttachmentType, isImageType, getContentType, getCustomSlotAttribute } from '@douyinfe/semi-foundation/aiChatInput/utils'; import Configure from './configure'; import RichTextInput from './richTextInput'; import { Editor, FocusPosition } from '@tiptap/core'; import { getUuidShort } from '@douyinfe/semi-foundation/utils/uuid'; import { throttle } from 'lodash'; import AIChatInputFoundation, { AIChatInputAdapter } from '@douyinfe/semi-foundation/aiChatInput/foundation'; import { NodeSelection, TextSelection } from 'prosemirror-state'; import { Node } from 'prosemirror-model'; import ConfigContext, { ContextValue } from '../configProvider/context'; import getConfigureItem from './configure/getConfigureItem'; import { MessageContent } from '@douyinfe/semi-foundation/aiChatInput/interface'; import { Content as TiptapContent } from "@tiptap/core"; import { Locale } from '../locale/interface'; import LocaleConsumer from '../locale/localeConsumer'; import SkillItem from './skillItem'; import SuggestionItem from './suggestionItem'; export { getConfigureItem }; export * from './interface'; const prefixCls = cssClasses.PREFIX; class AIChatInput extends BaseComponent { static __SemiComponentName__ = "AIChatInput"; static Configure = Configure; static contextType = ConfigContext; static getCustomSlotAttribute = getCustomSlotAttribute; private clickOutsideHandler: (e: Event) => void | null; static defaultProps: Partial = { onContentChange: noop, onStopGenerate: noop, showReference: true, showUploadFile: true, generating: false, dropdownMatchTriggerWidth: true, round: true, topSlotPosition: 'top', } constructor(props: AIChatInputProps) { super(props); this.editor = null; const defaultAttachment = props?.uploadProps?.defaultFileList ?? []; this.state = { popupKey: 1, templateVisible: false, skillVisible: false, suggestionVisible: false, attachments: defaultAttachment, content: null, popupWidth: null, skill: {} as Skill, activeSkillIndex: 0, activeSuggestionIndex: 0, /** * richTextInit 用于标识富文本编辑区是否初始化完成,会影响初始化时发送按钮是否可以点击 * richTextInit is used to identify whether the rich text editing area has been initialized, * which will affect whether the send button can be clicked during initialization. */ richTextInit: false, }; this.triggerRef = React.createRef(); this.popUpOptionListID = getUuidShort(); this.foundation = new AIChatInputFoundation(this.adapter); this.transformedContent = []; this.uploadRef = React.createRef(); this.configureRef = React.createRef(); this.richTextDIVRef = React.createRef(); this.suggestionPanelRef = React.createRef(); this.clickOutsideHandler = null; } editor: Editor; triggerRef: React.RefObject; configureRef: React.RefObject; popUpOptionListID: string; foundation: AIChatInputFoundation; transformedContent: Content[]; context: ContextValue; uploadRef: React.RefObject; richTextDIVRef = React.createRef(); suggestionPanelRef = React.createRef(); get adapter(): AIChatInputAdapter { return { ...super.adapter, reposPopover: throttle(() => { const { templateVisible } = this.state; if (templateVisible) { this.setState({ popupKey: this.state.popupKey + 1, }); } }, 200), setContent: (content: string) => { this.editor.commands.setContent(content); }, clearContent: () => { this.setContent(''); }, clearAttachments: () => { this.setState({ attachments: [], }); }, focusEditor: (pos?: FocusPosition) => { this.editor?.commands.focus(pos || 'end'); }, getTriggerWidth: () => { const el = this.triggerRef.current; return el && el.getBoundingClientRect().width; }, getEditor: () => this.editor, getPopupID: () => this.popUpOptionListID, notifyContentChange: (result: Content[]) => { this.transformedContent = result; this.props.onContentChange?.(result); }, notifyConfigureChange: (value: LeftMenuChangeProps, changedValue: LeftMenuChangeProps) => { this.props.onConfigureChange?.(value, changedValue); }, manualUpload: (files: File[]) => { const uploadComponent = this.uploadRef.current; if (uploadComponent) { uploadComponent.insert(files); } }, notifyMessageSend: (props: MessageContent) => { this.props.onMessageSend?.(props); }, notifyStopGenerate: () => { this.props.onStopGenerate?.(); }, getRichTextDiv: () => this.richTextDIVRef?.current, registerClickOutsideHandler: cb => { const clickOutsideHandler = (e: Event) => { const optionsDom = this.suggestionPanelRef && this.suggestionPanelRef.current; const triggerDom = this.triggerRef && this.triggerRef.current; const target = e.target as Element; const path = e.composedPath && e.composedPath() || [target]; if ( optionsDom && (!optionsDom.contains(target) || !optionsDom.contains(target.parentNode)) && triggerDom && !triggerDom.contains(target) && !(path.includes(triggerDom) || path.includes(optionsDom)) ) { cb(e); } }; this.clickOutsideHandler = clickOutsideHandler; document.addEventListener('mousedown', clickOutsideHandler, false); }, unregisterClickOutsideHandler: () => { if (this.clickOutsideHandler) { document.removeEventListener('mousedown', this.clickOutsideHandler, false); } }, handleReferenceDelete: (reference: Reference) => { this.props.onReferenceDelete?.(reference); }, handleReferenceClick: (reference: Reference) => { this.props.onReferenceClick?.(reference); }, isSelectionText: (selection: Selection) => { return selection instanceof TextSelection; }, createSelection: (node: Node, pos: number) => { return NodeSelection.create(node, pos); }, notifyFocus: (event: any) => { this.props.onFocus?.(event); }, notifyBlur: (event: any) => { this.props.onBlur?.(event); }, getConfigureValue: () => { return this.configureRef?.current?.getConfigureValue(); } }; } componentDidUpdate(prevProps: Readonly): void { const { suggestions } = this.props; if (!isEqual(suggestions, prevProps.suggestions)) { const newVisible = (suggestions && suggestions.length > 0) ? true : false; newVisible ? this.foundation.showSuggestionPanel() : this.foundation.hideSuggestionPanel(); } if (this.props.generating && (this.props.generating !== prevProps.generating)) { this.adapter.clearContent(); this.adapter.clearAttachments(); } } componentWillUnmount(): void { this.foundation.destroy(); } // ref method setContent = (content: TiptapContent) => { this.adapter.setContent(content); }; // ref method focusEditor = (pos: FocusPosition) => { this.adapter.focusEditor(pos); } // ref method & inner method changeTemplateVisible = (value: boolean) => { this.foundation.changeTemplateVisible(value); } // ref method & inner method getEditor = () => this.editor; // ref method deleteContent(content: Content) { this.foundation.handleDeleteContent(content); } setEditor = (editor: Editor) => { this.editor = editor; } setContentWhileSaveTool = (content: string) => { const { skill } = this.state; let realContent = ''; if (!skill) { realContent = `

${content}

`; } else { realContent = `

${content}

`; } this.setContent(realContent); } renderTemplate() { const { skill } = this.state; const { renderTemplate, templatesStyle, templatesCls } = this.props; const { popupWidth } = this.state; return
{renderTemplate?.(skill, this.setContent)}
; } renderSkill() { const { popupWidth } = this.state; const { skills, renderSkillItem } = this.props; return
{ skills?.map((item, index) => ()) }
; } renderSuggestions() { const { suggestions, renderSuggestionItem } = this.props; const { popupWidth, activeSuggestionIndex } = this.state; return (
{ suggestions.map((item, index) => ( )) }
); } renderPopoverContent() { const { templateVisible, skillVisible, suggestionVisible } = this.state; if (templateVisible) { return this.renderTemplate(); } else if (skillVisible) { return this.renderSkill(); } else if (suggestionVisible) { return this.renderSuggestions(); } else { return null; } } handleReferenceDelete = (reference: Reference) => { const { onReferenceDelete } = this.props; onReferenceDelete(reference); } getIconByType(type: string, size: IconSize = 'small') { let iconNode: React.ReactNode; if (type === 'text') { return null; } switch (type) { case 'file': case 'word': iconNode = ; break; case 'code': iconNode = ; break; case 'excel': iconNode = ; break; case 'video': iconNode = ; break; case 'audio': iconNode = ; break; case 'pdf': iconNode = ; break; default: iconNode = ; break; } return iconNode; } getReferenceIconByType(type: string) { let iconNode = this.getIconByType(type); return {iconNode} ; } getAttachmentIconByType(type: string) { let iconNode = this.getIconByType(type, 'large'); return {iconNode} ; } renderReference() { const { references = [], renderReference } = this.props; if (references.length === 0 ) { return null; } return
{references.map(item => { if (renderReference) { return renderReference(item); } const { id, type, content, name, url } = item; const isImage = isImageType(item); const signIconType = getContentType(getAttachmentType(item)); // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
{ this.foundation.handleReferenceClick(item);}} > {type !== 'text' && ( isImage ? {name} : this.getReferenceIconByType(signIconType))} {type === 'text' ? content : name} { this.handleReferenceDelete(item); e.stopPropagation(); }} />
; })}
; } // ref method deleteUploadFile = (item: Attachment) => { this.foundation.handleUploadFileDelete(item); } renderAttachment() { const { attachments = [] } = this.state; if (attachments.length === 0) { return null; } return {attachments?.map((item: Attachment, index: number) => { const isImage = isImageType(item); const realType = getAttachmentType(item); const signIconType = getContentType(realType); const { uid, name, url, size, percent, status } = item; const showPercent = !(percent === 100 || typeof percent === 'undefined') && status === 'uploading'; return
{isImage ? {name} : this.getAttachmentIconByType(signIconType) }
{name}
{`${realType} ${size}`}
{showPercent && } { this.foundation.handleUploadFileDelete(item);}} />
; } )}
; } renderTopArea() { const { references, topSlotPosition, renderTopSlot, showReference, showUploadFile } = this.props; const { attachments } = this.state; const topSlot = renderTopSlot?.({ references, attachments, content: this.transformedContent, handleUploadFileDelete: this.foundation.handleUploadFileDelete, handleReferenceDelete: this.handleReferenceDelete, }); return <> {topSlotPosition === 'top' && topSlot} {showReference && this.renderReference()} {topSlotPosition === 'middle' && topSlot} {showUploadFile && this.renderAttachment()} {topSlotPosition === 'bottom' && topSlot} ; } renderLeftFooter = () => { const { renderConfigureArea, round, showTemplateButton } = this.props; const { skill = {} } = this.state; const { hasTemplate } = skill as Skill; return {(locale: Locale['AIChatInput']) => (
{renderConfigureArea?.()} {(showTemplateButton || hasTemplate) && } >{locale.template}}
)}
; } renderUploadButton = () => { const { uploadTipProps, uploadProps } = this.props; const { attachments } = this.state; const { className, onChange, renderFileItem, children, ...rest } = uploadProps; const realUploadProps = { ...rest, onChange: this.foundation.onUploadChange, }; const uploadNode = ; return uploadTipProps ? {uploadNode} : uploadNode; } renderSendButton = () => { const { generating } = this.props; const canSend = this.foundation.canSend(); return ; } renderRightFooter = () => { const { renderActionArea } = this.props; const actionCls = `${prefixCls}-footer-action`; const actionNode = [ this.renderUploadButton(), this.renderSendButton(), ]; if (renderActionArea) { return renderActionArea({ menuItem: actionNode, className: actionCls }); } return
{actionNode}
; } renderFooter = () => { const round = this.props.round; return
{this.renderLeftFooter()} {this.renderRightFooter()}
; } render() { const { direction } = this.context; const defaultPosition = direction === 'rtl' ? 'bottomRight' : 'bottomLeft'; const { style, className, popoverProps, placeholder, extensions, defaultContent } = this.props; const { templateVisible, skillVisible, suggestionVisible, popupKey } = this.state; return ( {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
{this.renderTopArea()} {this.renderFooter()}
); } } export default AIChatInput;