UNPKG

@douyinfe/semi-ui

Version:

A modern, comprehensive, flexible design system and UI library. Connect DesignOps & DevOps. Quickly build beautiful React apps. Maintained by Douyin-fe team.

699 lines 25.5 kB
import _throttle from "lodash/throttle"; import _isEqual from "lodash/isEqual"; import _noop from "lodash/noop"; var __rest = this && this.__rest || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; /* eslint-disable jsx-a11y/no-static-element-interactions */ import React from 'react'; import BaseComponent from '../_base/baseComponent'; import { cssClasses, numbers, strings } from '@douyinfe/semi-foundation/lib/es/aiChatInput/constants'; import { Popover, Tooltip, Upload, Progress } from '../index'; import { IconSendMsgStroked, IconFile, IconCode, IconCrossStroked, IconPaperclip, IconArrowUp, IconStop, IconClose, IconTemplateStroked, IconMusic, IconVideo, IconPdf, IconWord, IconExcel } from '@douyinfe/semi-icons'; import '@douyinfe/semi-foundation/lib/es/aiChatInput/aiChatInput.css'; import HorizontalScroller from './horizontalScroller'; import cls from 'classnames'; import { getAttachmentType, isImageType, getContentType, getCustomSlotAttribute, getSkillSlotString } from '@douyinfe/semi-foundation/lib/es/aiChatInput/utils'; import Configure from './configure'; import RichTextInput from './richTextInput'; import { getUuidShort } from '@douyinfe/semi-foundation/lib/es/utils/uuid'; import AIChatInputFoundation from '@douyinfe/semi-foundation/lib/es/aiChatInput/foundation'; import { NodeSelection, TextSelection } from 'prosemirror-state'; import ConfigContext from '../configProvider/context'; import getConfigureItem from './configure/getConfigureItem'; 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 { constructor(props) { var _a, _b; super(props); this.richTextDIVRef = /*#__PURE__*/React.createRef(); this.suggestionPanelRef = /*#__PURE__*/React.createRef(); // ref method this.setContent = content => { this.adapter.setContent(content); }; // ref method this.focusEditor = pos => { this.adapter.focusEditor(pos); }; // ref method & inner method this.changeTemplateVisible = value => { this.foundation.changeTemplateVisible(value); }; // ref method & inner method this.getEditor = () => this.editor; this.setEditor = editor => { this.editor = editor; }; this.setContentWhileSaveTool = content => { const { skill } = this.state; let realContent = ''; if (!skill) { realContent = `<p>${content}</p>`; } else { realContent = `<p>${getSkillSlotString(skill)}${content}</p>`; } this.setContent(realContent); }; this.handleReferenceDelete = reference => { const { onReferenceDelete } = this.props; onReferenceDelete(reference); }; // ref method this.deleteUploadFile = item => { this.foundation.handleUploadFileDelete(item); }; this.renderLeftFooter = () => { const { renderConfigureArea, round, showTemplateButton } = this.props; const { skill = {} } = this.state; const { hasTemplate } = skill; return /*#__PURE__*/React.createElement(LocaleConsumer, { componentName: "AIChatInput" }, locale => (/*#__PURE__*/React.createElement("div", { className: `${prefixCls}-footer-configure` }, /*#__PURE__*/React.createElement(Configure, { ref: this.configureRef, round: round, onChange: this.foundation.onConfigureChange }, renderConfigureArea === null || renderConfigureArea === void 0 ? void 0 : renderConfigureArea(), (showTemplateButton || hasTemplate) && /*#__PURE__*/React.createElement(Configure.Button, { key: "template", field: "template", onClick: this.changeTemplateVisible, icon: /*#__PURE__*/React.createElement(IconTemplateStroked, null) }, locale.template))))); }; this.renderUploadNode = () => { const { uploadTipProps, uploadProps, renderUploadButton } = this.props; const { attachments } = this.state; const _a = uploadProps !== null && uploadProps !== void 0 ? uploadProps : {}, { children } = _a, rest = __rest(_a, ["children"]); const realUploadProps = Object.assign(Object.assign({}, rest), { onChange: this.foundation.onUploadChange }); const defaultButtonNode = /*#__PURE__*/React.createElement("button", { className: `${prefixCls}-footer-action-button ${prefixCls}-footer-action-upload` }, /*#__PURE__*/React.createElement(IconPaperclip, null)); const openFileDialog = () => { var _a, _b; (_b = (_a = this.uploadRef.current) === null || _a === void 0 ? void 0 : _a.openFileDialog) === null || _b === void 0 ? void 0 : _b.call(_a); }; const renderProps = { defaultNode: children !== null && children !== void 0 ? children : defaultButtonNode, openFileDialog, disabled: Boolean(uploadProps === null || uploadProps === void 0 ? void 0 : uploadProps.disabled), attachments: attachments !== null && attachments !== void 0 ? attachments : [] }; const uploadChild = renderUploadButton ? renderUploadButton(renderProps) : renderProps.defaultNode; const uploadNode = /*#__PURE__*/React.createElement(Upload, Object.assign({ ref: this.uploadRef, fileList: attachments, listType: "none" }, realUploadProps, { key: 'upload' }), uploadChild); return uploadTipProps ? /*#__PURE__*/React.createElement(Tooltip, Object.assign({}, uploadTipProps, { key: 'upload' }), /*#__PURE__*/React.createElement("span", null, uploadNode)) : uploadNode; }; this.renderSendButton = () => { const { generating } = this.props; const canSend = this.foundation.canSend(); return /*#__PURE__*/React.createElement("button", { key: "send", className: cls(`${prefixCls}-footer-action-button`, { [`${prefixCls}-footer-action-send`]: !generating, [`${prefixCls}-footer-action-stop`]: generating, [`${prefixCls}-footer-action-send-disabled`]: !generating && !canSend }), onClick: generating ? this.foundation.handleStopGenerate : this.foundation.handleSend }, generating ? /*#__PURE__*/React.createElement(IconStop, null) : /*#__PURE__*/React.createElement(IconArrowUp, null)); }; this.renderRightFooter = () => { const { renderActionArea, showUploadButton } = this.props; const actionCls = `${prefixCls}-footer-action`; const actionNode = [showUploadButton && this.renderUploadNode(), this.renderSendButton()].filter(Boolean); if (renderActionArea) { return renderActionArea({ menuItem: actionNode, className: actionCls }); } return /*#__PURE__*/React.createElement("div", { className: actionCls }, actionNode); }; this.renderFooter = () => { const round = this.props.round; return /*#__PURE__*/React.createElement("div", { className: cls(`${prefixCls}-footer`, { [`${prefixCls}-footer-round`]: round }) }, this.renderLeftFooter(), this.renderRightFooter()); }; this.editor = null; const defaultAttachment = (_b = (_a = props === null || props === void 0 ? void 0 : props.uploadProps) === null || _a === void 0 ? void 0 : _a.defaultFileList) !== null && _b !== void 0 ? _b : []; this.state = { popupKey: 1, templateVisible: false, skillVisible: false, suggestionVisible: false, attachments: defaultAttachment, content: null, popupWidth: null, skill: undefined, 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 = /*#__PURE__*/React.createRef(); this.popUpOptionListID = getUuidShort(); this.foundation = new AIChatInputFoundation(this.adapter); this.transformedContent = []; this.uploadRef = /*#__PURE__*/React.createRef(); this.configureRef = /*#__PURE__*/React.createRef(); this.richTextDIVRef = /*#__PURE__*/React.createRef(); this.suggestionPanelRef = /*#__PURE__*/React.createRef(); this.clickOutsideHandler = null; } get adapter() { return Object.assign(Object.assign({}, super.adapter), { reposPopover: _throttle(() => { const { templateVisible } = this.state; if (templateVisible) { this.setState({ popupKey: this.state.popupKey + 1 }); } }, 200), setContent: content => { this.editor.commands.setContent(content); }, clearContent: () => { this.setContent(''); }, clearAttachments: () => { this.setState({ attachments: [] }); }, focusEditor: pos => { var _a; (_a = this.editor) === null || _a === void 0 ? void 0 : _a.commands.focus(pos || 'end'); }, getTriggerWidth: () => { const el = this.triggerRef.current; return el && el.getBoundingClientRect().width; }, getEditor: () => this.editor, getPopupID: () => this.popUpOptionListID, notifySkillChange: skill => { var _a, _b; (_b = (_a = this.props).onSkillChange) === null || _b === void 0 ? void 0 : _b.call(_a, skill); }, notifyContentChange: result => { var _a, _b; this.transformedContent = result; (_b = (_a = this.props).onContentChange) === null || _b === void 0 ? void 0 : _b.call(_a, result); }, notifyConfigureChange: (value, changedValue) => { var _a, _b; (_b = (_a = this.props).onConfigureChange) === null || _b === void 0 ? void 0 : _b.call(_a, value, changedValue); }, manualUpload: files => { const uploadComponent = this.uploadRef.current; if (uploadComponent) { uploadComponent.insert(files); } }, notifyMessageSend: props => { var _a, _b; (_b = (_a = this.props).onMessageSend) === null || _b === void 0 ? void 0 : _b.call(_a, props); }, notifyStopGenerate: () => { var _a, _b; (_b = (_a = this.props).onStopGenerate) === null || _b === void 0 ? void 0 : _b.call(_a); }, getRichTextDiv: () => { var _a; return (_a = this.richTextDIVRef) === null || _a === void 0 ? void 0 : _a.current; }, registerClickOutsideHandler: cb => { const clickOutsideHandler = e => { const optionsDom = this.suggestionPanelRef && this.suggestionPanelRef.current; const triggerDom = this.triggerRef && this.triggerRef.current; const target = e.target; 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 => { var _a, _b; (_b = (_a = this.props).onReferenceDelete) === null || _b === void 0 ? void 0 : _b.call(_a, reference); }, handleReferenceClick: reference => { var _a, _b; (_b = (_a = this.props).onReferenceClick) === null || _b === void 0 ? void 0 : _b.call(_a, reference); }, isSelectionText: selection => { return selection instanceof TextSelection; }, createSelection: (node, pos) => { return NodeSelection.create(node, pos); }, notifyFocus: event => { var _a, _b; (_b = (_a = this.props).onFocus) === null || _b === void 0 ? void 0 : _b.call(_a, event); }, notifyBlur: event => { var _a, _b; (_b = (_a = this.props).onBlur) === null || _b === void 0 ? void 0 : _b.call(_a, event); }, getConfigureValue: () => { var _a, _b; return (_b = (_a = this.configureRef) === null || _a === void 0 ? void 0 : _a.current) === null || _b === void 0 ? void 0 : _b.getConfigureValue(); } }); } componentDidUpdate(prevProps) { const { suggestions, keepSkillAfterSend, clearContentOnGenerating } = 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) { if (clearContentOnGenerating !== false) { keepSkillAfterSend ? this.setContentWhileSaveTool('') : this.adapter.clearContent(); this.adapter.clearAttachments(); } } } componentWillUnmount() { this.foundation.destroy(); } // ref method deleteContent(content) { this.foundation.handleDeleteContent(content); } renderTemplate() { const { skill } = this.state; const { renderTemplate, templatesStyle, templatesCls } = this.props; const { popupWidth } = this.state; return /*#__PURE__*/React.createElement("div", { className: cls(`${prefixCls}-template`, { [templatesCls]: templatesCls }), style: Object.assign({ width: popupWidth, maxHeight: 500 }, templatesStyle) }, renderTemplate === null || renderTemplate === void 0 ? void 0 : renderTemplate(skill, this.setContent)); } renderSkill() { const { popupWidth } = this.state; const { skills, renderSkillItem } = this.props; return /*#__PURE__*/React.createElement("div", { id: `${prefixCls}-skill-${this.popUpOptionListID}`, className: `${prefixCls}-skill`, style: { width: popupWidth, maxHeight: numbers.SKILL_MAX_HEIGHT } }, skills === null || skills === void 0 ? void 0 : skills.map((item, index) => (/*#__PURE__*/React.createElement(SkillItem, { index: index, isActive: this.state.activeSkillIndex === index, key: item.key || item.value, skill: item, renderSkillItem: renderSkillItem, onClick: this.foundation.handleSkillSelect, onMouseEnter: this.foundation.setActiveSkillIndex })))); } renderSuggestions() { const { suggestions, renderSuggestionItem } = this.props; const { popupWidth, activeSuggestionIndex } = this.state; return /*#__PURE__*/React.createElement("div", { id: `${prefixCls}-suggestion-${this.popUpOptionListID}`, className: `${prefixCls}-suggestion`, style: { width: popupWidth, maxHeight: numbers.SUGGESTION_MAX_HEIGHT }, ref: this.suggestionPanelRef }, suggestions.map((item, index) => (/*#__PURE__*/React.createElement(SuggestionItem, { index: index, key: typeof item === 'string' ? item : item && 'content' in item ? item.content : index, suggestion: item, isActive: activeSuggestionIndex === index, renderSuggestionItem: renderSuggestionItem, onClick: this.foundation.handleSuggestionSelect, onMouseEnter: this.foundation.setActiveSuggestionIndex })))); } 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; } } getIconByType(type) { let size = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'small'; let iconNode; if (type === 'text') { return null; } switch (type) { case 'file': case 'word': iconNode = /*#__PURE__*/React.createElement(IconWord, { size: size }); break; case 'code': iconNode = /*#__PURE__*/React.createElement(IconCode, { size: size }); break; case 'excel': iconNode = /*#__PURE__*/React.createElement(IconExcel, { size: size }); break; case 'video': iconNode = /*#__PURE__*/React.createElement(IconVideo, { size: size }); break; case 'audio': iconNode = /*#__PURE__*/React.createElement(IconMusic, { size: size }); break; case 'pdf': iconNode = /*#__PURE__*/React.createElement(IconPdf, { size: size }); break; default: iconNode = /*#__PURE__*/React.createElement(IconFile, { size: size }); break; } return iconNode; } getReferenceIconByType(type) { let iconNode = this.getIconByType(type); return /*#__PURE__*/React.createElement("span", { className: `${prefixCls}-ref-icon ${prefixCls}-ref-icon-${type} ${prefixCls}-reference-icon` }, iconNode); } getAttachmentIconByType(type) { let iconNode = this.getIconByType(type, 'large'); return /*#__PURE__*/React.createElement("span", { className: `${prefixCls}-attachment-icon ${prefixCls}-ref-icon ${prefixCls}-ref-icon-${type}` }, iconNode); } renderReference() { const { references = [], renderReference } = this.props; if (references.length === 0) { return null; } return /*#__PURE__*/React.createElement("div", { className: `${prefixCls}-references` }, 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 /*#__PURE__*/React.createElement("div", { key: id, className: `${prefixCls}-reference`, onClick: () => { this.foundation.handleReferenceClick(item); } }, /*#__PURE__*/React.createElement(IconSendMsgStroked, null), /*#__PURE__*/React.createElement("span", { className: `${prefixCls}-reference-content` }, type !== 'text' && (isImage ? /*#__PURE__*/React.createElement("img", { className: `${prefixCls}-reference-img`, src: url, alt: name }) : this.getReferenceIconByType(signIconType)), /*#__PURE__*/React.createElement("span", { className: `${prefixCls}-reference-name` }, type === 'text' ? content : name)), /*#__PURE__*/React.createElement(IconCrossStroked, { size: "small", className: `${prefixCls}-reference-delete`, onClick: e => { this.handleReferenceDelete(item); e.stopPropagation(); } })); })); } renderAttachment() { const { attachments = [] } = this.state; if (attachments.length === 0) { return null; } return /*#__PURE__*/React.createElement(HorizontalScroller, { prefix: `${prefixCls}` }, attachments === null || attachments === void 0 ? void 0 : attachments.map((item, index) => { 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 /*#__PURE__*/React.createElement("div", { className: `${prefixCls}-attachment`, key: uid }, isImage ? /*#__PURE__*/React.createElement("img", { className: `${prefixCls}-attachment-img`, src: url, alt: name }) : this.getAttachmentIconByType(signIconType), /*#__PURE__*/React.createElement("div", { className: `${prefixCls}-attachment-content` }, /*#__PURE__*/React.createElement("div", { className: `${prefixCls}-attachment-content-name` }, name), /*#__PURE__*/React.createElement("div", { className: `${prefixCls}-attachment-content-size` }, `${realType} ${size}`)), showPercent && /*#__PURE__*/React.createElement(Progress, { type: "circle", width: 30, className: `${prefixCls}-attachment-progress`, percent: percent, showInfo: false, "aria-label": "upload progress" }), /*#__PURE__*/React.createElement(IconClose, { className: `${prefixCls}-attachment-delete`, size: "small", onClick: () => { this.foundation.handleUploadFileDelete(item); } })); })); } renderTopArea() { const { references, topSlotPosition, renderTopSlot, showReference, showUploadFile } = this.props; const { attachments } = this.state; const topSlot = renderTopSlot === null || renderTopSlot === void 0 ? void 0 : renderTopSlot({ references, attachments, content: this.transformedContent, handleUploadFileDelete: this.foundation.handleUploadFileDelete, handleReferenceDelete: this.handleReferenceDelete }); return /*#__PURE__*/React.createElement(React.Fragment, null, topSlotPosition === 'top' && topSlot, showReference && this.renderReference(), topSlotPosition === 'middle' && topSlot, showUploadFile && this.renderAttachment(), topSlotPosition === 'bottom' && topSlot); } render() { const { direction } = this.context; const defaultPosition = direction === 'rtl' ? 'bottomRight' : 'bottomLeft'; const { style, className, popoverProps, placeholder, extensions, defaultContent, immediatelyRender, onPaste } = this.props; const { templateVisible, skillVisible, suggestionVisible, popupKey } = this.state; return /*#__PURE__*/React.createElement(Popover, Object.assign({ position: defaultPosition }, popoverProps, { rePosKey: popupKey, className: cls({ [`${prefixCls}-popover-suggestion`]: suggestionVisible, [`${prefixCls}-popover-skill`]: skillVisible, [`${prefixCls}-popover-template`]: templateVisible }), content: this.renderPopoverContent(), visible: templateVisible || skillVisible || suggestionVisible, trigger: "custom", disableArrowKeyDown: true }), /*#__PURE__*/React.createElement("div", { className: cls(prefixCls, { [className]: className }), style: style, ref: this.triggerRef, onClick: this.foundation.handleContainerClick, onMouseDown: this.foundation.handleContainerMouseDown }, this.renderTopArea(), /*#__PURE__*/React.createElement(RichTextInput, { immediatelyRender: immediatelyRender, innerRef: this.richTextDIVRef, defaultContent: defaultContent, placeholder: placeholder, onKeyDown: this.foundation.handleKeyDown, setEditor: this.setEditor, onChange: this.foundation.handleContentChange, extensions: extensions, handleKeyDown: this.foundation.handRichTextArealKeyDown, onPasteEvent: onPaste, onPaste: this.foundation.handlePaste, onFocus: this.foundation.handleFocus, onBlur: this.foundation.handleBlur, handleCreate: this.foundation.handleCreate, showPlaceholderWhenSkillOnly: this.props.showPlaceholderWhenSkillOnly }), this.renderFooter())); } } AIChatInput.__SemiComponentName__ = "AIChatInput"; AIChatInput.Configure = Configure; AIChatInput.contextType = ConfigContext; AIChatInput.getCustomSlotAttribute = getCustomSlotAttribute; AIChatInput.defaultProps = { onContentChange: _noop, onStopGenerate: _noop, showReference: true, showUploadFile: true, generating: false, dropdownMatchTriggerWidth: true, round: true, topSlotPosition: 'top', sendHotKey: strings.SEND_HOTKEY.ENTER, keepSkillAfterSend: false, showUploadButton: true, clearContentOnGenerating: true }; export default AIChatInput;