diff --git a/src/ProChat/__test__/__snapshots__/demo.test.tsx.snap b/src/ProChat/__test__/__snapshots__/demo.test.tsx.snap index afe0649d..844af43a 100644 --- a/src/ProChat/__test__/__snapshots__/demo.test.tsx.snap +++ b/src/ProChat/__test__/__snapshots__/demo.test.tsx.snap @@ -11090,6 +11090,450 @@ exports[` > renders doc-mode.tsx correctly 1`] = ` `; +exports[` > renders elegy.tsx correctly 1`] = ` + + + + + + + + + + + + 🤖 + + + + + + + + 2024-02-27 17:20:00 + + + + + + + + + 让我们开始对话吧 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 返回底部 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + exports[` > renders error.tsx correctly 1`] = ` { * @param onClearAllHistory 清除所有历史记录的回调函数 * @returns 渲染的 React 元素 */ - inputAreaRender?: ChatInputAreaProps['inputAreaRender']; + inputAreaRender?: ProInputAreaProps['inputAreaRender']; /** * 输入框的渲染函数 * @param defaultDom 默认的 DOM 元素 * @param onMessageSend 发送消息的回调函数 * @param props 输入框的属性 */ - inputRender: ChatInputAreaProps['inputRender']; + inputRender: ProInputAreaProps['inputRender']; /** * 聊天发送按钮的渲染配置 * @param defaultDom 默认的 DOM 元素 * @param defaultProps 默认的属性 */ - sendButtonRender?: ChatInputAreaProps['sendButtonRender']; + sendButtonRender?: ProInputAreaProps['sendButtonRender']; /** * 滚动时候的监听方法 @@ -101,6 +102,7 @@ const App = memo( const [height, setHeight] = useState('100%' as string | number); const { getPrefixCls } = useContext(ConfigProvider.ConfigContext); const { localeObject } = useProChatLocale(); + const [sendMessage, clearMessage] = useStore((s) => [s.sendMessage, s.clearMessage]); useEffect(() => { // 保证 ref 永远存在 @@ -170,10 +172,17 @@ const App = memo( {renderInputArea !== null && inputAreaRender !== null && ( { - } diff --git a/src/ProChat/container/AppNew.tsx b/src/ProChat/container/AppNew.tsx new file mode 100644 index 00000000..6e334699 --- /dev/null +++ b/src/ProChat/container/AppNew.tsx @@ -0,0 +1,184 @@ +import BackBottom from '@/BackBottom'; +import { createStyles } from 'antd-style'; +import RcResizeObserver from 'rc-resize-observer'; +import { CSSProperties, memo, useContext, useEffect, useRef, useState } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { ChatListItemProps } from '@/ChatList/ChatListItem'; +import { ConfigProvider } from 'antd'; +import ProInputArea, { ProInputAreaProps } from '../../ProInputArea'; +import ChatList from '../components/ChatList'; +import ChatScrollAnchor from '../components/ScrollAnchor'; +import useProChatLocale from '../hooks/useProChatLocale'; +import { useOverrideStyles } from './OverrideStyle'; +import { ProChatChatReference } from './StoreUpdater'; +import { ProChatProps } from './index'; + +const useStyles = createStyles( + ({ css, responsive, stylish }) => css` + overflow: hidden scroll; + height: 100%; + ${responsive.mobile} { + ${stylish.noScrollbar} + width: 100%; + } + `, +); + +/** + * 对话组件的属性接口 + */ +export interface ConversationProps extends ProChatProps { + /** + * 是否显示标题 + */ + showTitle?: boolean; + /** + * 样式对象 + */ + style?: CSSProperties; + /** + * CSS类名 + */ + className?: string; + /** + * 聊天引用 + */ + chatRef?: ProChatChatReference; + /** + * 输入区域的渲染函数 + * @param defaultDom 默认的 DOM 元素 + * @param onMessageSend 发送消息的回调函数 + * @param onClearAllHistory 清除所有历史记录的回调函数 + * @returns 渲染的 React 元素 + */ + inputAreaRender?: ProInputAreaProps['inputAreaRender']; + /** + * 输入框的渲染函数 + * @param defaultDom 默认的 DOM 元素 + * @param onMessageSend 发送消息的回调函数 + * @param props 输入框的属性 + */ + inputRender: ProInputAreaProps['inputRender']; + + /** + * 聊天发送按钮的渲染配置 + * @param defaultDom 默认的 DOM 元素 + * @param defaultProps 默认的属性 + */ + sendButtonRender?: ProInputAreaProps['sendButtonRender']; + + /** + * 滚动时候的监听方法 + */ + onScroll?: (e: Event) => void; + + renderErrorMessages?: ChatListItemProps['renderErrorMessages']; +} + +const App = memo( + ({ + renderInputArea, + inputAreaRender, + className, + style, + showTitle, + chatRef, + itemShouldUpdate, + inputRender, + chatItemRenderConfig, + backToBottomConfig, + renderErrorMessages, + sendButtonRender, + onScroll, + markdownProps, + }) => { + const ref = useRef(null); + const areaHtml = useRef(null); + const { styles, cx } = useStyles(); + const { styles: override } = useOverrideStyles(); + const [isRender, setIsRender] = useState(false); + const [height, setHeight] = useState('100%' as string | number); + const { getPrefixCls } = useContext(ConfigProvider.ConfigContext); + const { localeObject } = useProChatLocale(); + + useEffect(() => { + // 保证 ref 永远存在 + setIsRender(true); + if (chatRef?.current) { + chatRef.current.scrollToBottom = () => { + (ref as any)?.current?.scrollTo({ + behavior: 'smooth', + left: 0, + top: ref.current?.scrollHeight || 99999, + }); + }; + } + }, []); + + const prefixClass = getPrefixCls('pro-chat'); + return ( + { + if (e.height !== height) { + setHeight(e.height); + } + }} + > + + <> + + + {ref?.current && } + + {isRender && ref?.current ? ( + + ) : null} + > + {renderInputArea !== null && inputAreaRender !== null && ( + + { + + } + + )} + + + ); + }, +); + +export default App; diff --git a/src/ProInputArea/components/ControlPanel.tsx b/src/ProInputArea/components/ControlPanel.tsx new file mode 100644 index 00000000..c2dad2bd --- /dev/null +++ b/src/ProInputArea/components/ControlPanel.tsx @@ -0,0 +1,62 @@ +import ActionIcon from '@/ActionIcon'; +import useProChatLocale from '@/ProChat/hooks/useProChatLocale'; +import { ConfigProvider, Popconfirm } from 'antd'; +import { createStyles, cx } from 'antd-style'; +import { Trash2 } from 'lucide-react'; +import { Flexbox } from 'react-layout-kit'; +// import { useStore } from '../ProChat/store'; + +const useStyles = createStyles(({ css, token }) => ({ + extra: css` + color: ${token.colorTextTertiary}; + `, +})); + +interface ControlPanelProps { + className?: string; + clearMessage?: () => void; + actionsRender?: (defaultDoms: React.ReactNode[]) => React.ReactNode; + flexConfig?: Record; +} + +export const ActionBar = ({ + className, + clearMessage, + actionsRender, + flexConfig, +}: ControlPanelProps) => { + const { localeObject } = useProChatLocale(); + + const { styles, theme } = useStyles(); + const defaultDoms = [ + { + clearMessage(); + }} + > + + , + ]; + + return ( + + + {actionsRender?.(defaultDoms) ?? defaultDoms} + + + ); +}; + +export default ActionBar; diff --git a/src/ProInputArea/components/ExtraModel.tsx b/src/ProInputArea/components/ExtraModel.tsx new file mode 100644 index 00000000..cb645250 --- /dev/null +++ b/src/ProInputArea/components/ExtraModel.tsx @@ -0,0 +1,86 @@ +import ActionIcon from '@/ActionIcon'; +import useProChatLocale from '@/ProChat/hooks/useProChatLocale'; +import { ConfigProvider } from 'antd'; +import { createStyles, cx } from 'antd-style'; +import { isObject, isString } from 'lodash-es'; +import { FileVideo, Image } from 'lucide-react'; +import { Flexbox } from 'react-layout-kit'; +import AudioIcon from '../icons/AudioLines'; + +const useStyles = createStyles(({ css, token }) => ({ + extra: css` + color: ${token.colorTextTertiary}; + `, +})); + +export type ExtraType = 'image' | 'audio' | 'video'; +export interface ExtraItem { + type: ExtraType; + onChange?: () => void; + onFinish?: () => void; + render: () => JSX.Element; +} +export type ExtraModelProps = { + className?: string; + extra?: Array; +}; + +export const ExtraModel = (props: ExtraModelProps) => { + const { className, extra } = props; + + const { localeObject } = useProChatLocale(); + + const defaultDoms = [ + { + type: 'video', + render: , + }, + { + type: 'audio', + render: , + }, + { + type: 'image', + render: , + }, + ]; + + const { styles, theme } = useStyles(); + + const renderContent = () => { + if (!extra || extra.length === 0) { + return null; + } + + const getDefaultRender = (type: ExtraType) => { + const defaultComponent = defaultDoms.find((dom) => dom.type === type); + return defaultComponent ? defaultComponent.render : null; + }; + + return extra.reverse().map((item) => { + if (isString(item)) { + return getDefaultRender(item as ExtraType); + } else if (isObject(item) && 'type' in item && 'render' in item) { + return item.render(); + } else { + return null; + } + }); + }; + renderContent(); + return ( + + + {renderContent()} + + + ); +}; + +export default ExtraModel; diff --git a/src/ProInputArea/components/ProTextArea.tsx b/src/ProInputArea/components/ProTextArea.tsx new file mode 100644 index 00000000..7ed412d1 --- /dev/null +++ b/src/ProInputArea/components/ProTextArea.tsx @@ -0,0 +1,26 @@ +import { Input } from 'antd'; +import { TextAreaProps } from 'antd/es/input'; +import { TextAreaRef } from 'antd/es/input/TextArea'; +import React from 'react'; + +export const ProTextArea: React.FC = React.forwardRef( + (props, ref) => { + const { disabled, ...rest } = props; + + return ( + { + props.onFocus?.(e); + }} + onPressEnter={(e) => { + props.onPressEnter?.(e); + }} + {...rest} + /> + ); + }, +); diff --git a/src/ProInputArea/icons/AudioLines.tsx b/src/ProInputArea/icons/AudioLines.tsx new file mode 100644 index 00000000..41ba9332 --- /dev/null +++ b/src/ProInputArea/icons/AudioLines.tsx @@ -0,0 +1,26 @@ +import { memo } from 'react'; + +const AudioIcon = memo(() => { + return ( + + + + + + + + + ); +}); +export default AudioIcon; diff --git a/src/ProInputArea/icons/StopLoading.tsx b/src/ProInputArea/icons/StopLoading.tsx new file mode 100644 index 00000000..450b9820 --- /dev/null +++ b/src/ProInputArea/icons/StopLoading.tsx @@ -0,0 +1,38 @@ +import { useTheme } from 'antd-style'; +import { memo } from 'react'; + +const StopLoadingIcon = memo(() => { + const theme = useTheme(); + return ( + + + + + + + + + + ); +}); +export default StopLoadingIcon; diff --git a/src/ProInputArea/index.tsx b/src/ProInputArea/index.tsx new file mode 100644 index 00000000..5b3e3422 --- /dev/null +++ b/src/ProInputArea/index.tsx @@ -0,0 +1,338 @@ +import useProChatLocale from '@/ProChat/hooks/useProChatLocale'; +import { SendOutlined } from '@ant-design/icons'; +import { Button, ButtonProps, ConfigProvider } from 'antd'; +import { createStyles, cx } from 'antd-style'; +import { TextAreaProps } from 'antd/es/input'; +import { ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Flexbox } from 'react-layout-kit'; +import ControlPanel from './components/ControlPanel'; +import ExtraModel, { ExtraItem, ExtraType } from './components/ExtraModel'; +import { ProTextArea } from './components/ProTextArea'; +import StopLoadingIcon from './icons/StopLoading'; + +const ENTER = 'enter'; +const SHIFT_ENTER = 'shiftEnter'; + +const useStyles = createStyles(({ css, responsive, token }) => ({ + container: css` + position: sticky; + z-index: ${token.zIndexPopupBase}; + bottom: 0; + padding-top: 12px; + padding-bottom: 24px; + background-image: linear-gradient(to top, ${token.colorBgLayout} 88%, transparent 100%); + ${responsive.mobile} { + width: 100%; + } + `, + boxShadow: css` + position: relative; + border-radius: 8px; + box-shadow: ${token.boxShadowSecondary}; + `, + input: css` + width: 100%; + border: none; + outline: none; + border-radius: 8px; + `, + btn: css` + position: absolute; + z-index: 10; + right: 8px; + bottom: 6px; + color: ${token.colorTextTertiary}; + &:hover { + color: ${token.colorTextSecondary}; + } + `, + extra: css` + color: ${token.colorTextTertiary}; + `, +})); + +export type ProInputAreaProps = { + /** + * @description 组件的类名,用于自定义样式 + */ + className?: string; + + /** + * @description 左侧多模态功能区 + * @type Array + */ + extra?: Array; + + /** + * @description 消息发送的快捷键,支持 'enter' 或 'shiftEnter' + * @type 'enter' | 'shiftEnter' + * @default 'enter' + */ + sendShortcutKey?: 'enter' | 'shiftEnter'; + + /** + * @description 发送消息的回调函数,返回布尔值或 Promise 布尔值表示是否发送成功 + * @param message - 需要发送的消息内容 + * @returns {boolean | Promise} + */ + onSend?: (message: string) => boolean | Promise; + + /** + * @description 输入框内容变化的回调函数 + * @param message - 输入框的内容 + * @returns {void} + */ + onChange?: (message: string) => void; + + /** + * @description 清除消息的回调函数 + */ + onClearMessage?: () => void; + + /** + * @description 更改网络状态的回调函数 + */ + onChangeNetwork?: () => void; + + /** + * @description 重新生成消息的回调函数,支持返回 Promise 对象 + * @returns {void | Promise} + */ + onRegenerate?: () => void | Promise; + + /** + * @description 发送消息的函数 + * @param message - 需要发送的消息内容 + * @returns {void | Promise} + */ + sendMessage?: (message: string) => void | Promise; + + /** + * @description 停止生成消息的函数 + */ + stopGenerateMessage?: () => void; + + /** + * @description 清除所有消息的回调函数 + */ + clearMessage?: () => void; + + /** + * @description 消息发送状态,表示是否正在发送中 + * @type boolean + */ + isLoading: boolean; + + /** + * @description 输入框的占位符 + * @type string + */ + placeholder?: string; + + /** + * @description 输入框的属性配置 + * @type TextAreaProps + */ + inputAreaProps?: TextAreaProps; + + /** + * @description 自定义发送按钮的渲染函数 + * @param defaultDom - 默认的发送按钮 DOM + * @param defaultProps - 默认的发送按钮属性 + * @returns {ReactNode} + */ + sendButtonRender?: (defaultDom: ReactNode, defaultProps: ButtonProps) => ReactNode; + + /** + * @description 自定义输入框的渲染函数 + * @param defaultDom - 默认的输入框 DOM + * @param onMessageSend - 消息发送处理函数 + * @param defaultProps - 默认的输入框属性 + * @returns {ReactNode} + */ + inputRender?: ( + defaultDom: ReactNode, + onMessageSend: (message: string) => void | Promise, + defaultProps: TextAreaProps, + ) => ReactNode; + + /** + * @description 自定义输入区域的渲染函数 + * @param defaultDom - 默认的输入区域 DOM + * @param onMessageSend - 消息发送处理函数 + * @param onClearAllHistory - 清除所有历史记录的处理函数 + * @returns {ReactNode} + */ + inputAreaRender?: ( + defaultDom: ReactNode, + onMessageSend: (message: string) => void | Promise, + onClearAllHistory: () => void, + ) => ReactNode; + /** + * @description 其他属性,透传至源组件 + */ + [key: string]: any; +}; + +export const ProInputArea = ({ + className, + sendMessage, + stopGenerateMessage, + isLoading, + placeholder, + inputAreaProps, + sendShortcutKey = 'enter', + extra, + value, + onSend, + onChange, + clearMessage, + sendButtonRender, + inputRender, + inputAreaRender, + ...rest +}: ProInputAreaProps) => { + const { getPrefixCls } = useContext(ConfigProvider.ConfigContext); + const isChineseInput = useRef(false); + const { styles, theme } = useStyles(); + const { localeObject } = useProChatLocale(); + const [message, setMessage] = useState(''); + const [currentShortcutKey] = useState(sendShortcutKey); + + useEffect(() => { + if (!isChineseInput.current && onChange) { + onChange(message); + } + }, [message]); + + useEffect(() => { + if (value) { + setMessage(value); + } + }, [value]); + + const send = async () => { + if (onSend) { + const success = await onSend(message); + if (success) { + sendMessage(message); + setMessage(''); + } + } else { + sendMessage(message); + setMessage(''); + } + }; + + const prefixClass = getPrefixCls('pro-chat-input-area'); + + const defaultProTextAreaProps = { + placeholder: placeholder || localeObject.placeholder, + ...inputAreaProps, + className: cx(styles.input, inputAreaProps?.className, `${prefixClass}-component`), + value: message, + onChange: (e) => { + setMessage(e.target.value); + }, + autoSize: { maxRows: 8 }, + onCompositionStart: () => { + isChineseInput.current = true; + }, + onCompositionEnd: (e) => { + isChineseInput.current = false; + setMessage(e.target.value); + }, + onPressEnter: (e) => { + if (currentShortcutKey === ENTER) { + if (!isLoading && !e.shiftKey && !isChineseInput.current) { + e.preventDefault(); + send(); + } + } else if (currentShortcutKey === SHIFT_ENTER) { + if (!isLoading && e.shiftKey && !isChineseInput.current) { + e.preventDefault(); + send(); + } + } + }, + ...rest, + }; + + const defaultInput = ; + + const inputDom = inputRender + ? inputRender?.( + defaultInput, + (message) => { + sendMessage(message); + }, + defaultProTextAreaProps, + ) + : defaultInput; + + const defaultButtonProps = useMemo(() => { + return isLoading + ? ({ + type: 'text', + className: styles.btn, + onClick: () => stopGenerateMessage(), + icon: , + } as const) + : ({ + type: 'text', + className: styles.btn, + onClick: () => send(), + icon: , + } as const); + }, [isLoading, message]); + + const defaultButtonDom = ; + + const buttonDom = sendButtonRender + ? sendButtonRender(defaultButtonDom, defaultButtonProps) + : defaultButtonDom; + + const defaultInputArea = ( + + + + + + + + {inputDom} + {buttonDom} + + + + ); + + if (inputAreaRender) { + return inputAreaRender( + defaultInputArea, + (message) => { + sendMessage(message); + }, + clearMessage, + ); + } + + return defaultInputArea; +}; + +export default ProInputArea; diff --git a/src/index.ts b/src/index.ts index 1a703aa2..74418877 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export { default as List } from './List'; export { default as MessageInput, type MessageInputProps } from './MessageInput'; export { default as MessageModal, type MessageModalProps } from './MessageModal'; +export {default as ProInputArea,type ProInputAreaProps} from './ProInputArea' export { default as TokenTag, type TokenTagProps } from './TokenTag'; export { useChatListActionsBar } from './hooks/useChatListActionsBar'; export * from './styles'; diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 960d9d62..500e3dac 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -14,4 +14,8 @@ export default { edit: 'Edit', history: 'History', regenerate: 'Regenerate', + connectNetwork: 'Connect to network', + video: 'Video upload', + audio: 'Audio upload', + image: 'Image upload', }; diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 10580e9e..c3844e17 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -13,4 +13,8 @@ export default { edit: '编辑', history: '历史范围', regenerate: '重新生成', + connectNetwork: '是否连接网络', + video: '视频上传', + audio: '音频上传', + image: '图片上传', }; diff --git a/src/locale/zh-HK.ts b/src/locale/zh-HK.ts index e3ba0d1b..b74e88f2 100644 --- a/src/locale/zh-HK.ts +++ b/src/locale/zh-HK.ts @@ -13,4 +13,8 @@ export default { edit: '編輯', history: '歷史', regenerate: '重新生成', + connectNetwork: '是否連接網絡', + video: '視頻上傳', + audio: '音頻上傳', + image: '圖片上傳', }; diff --git a/src/types/locale.ts b/src/types/locale.ts index e3dbf4c9..a9abe562 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -13,4 +13,8 @@ export interface LocaleProps { edit: string; history: string; regenerate: string; + connectNetwork: string; + video: string; + audio: string; + image: string; }
+ 让我们开始对话吧 +