diff --git a/package.json b/package.json index 890d0dfb3..6800fefee 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "@handsontable/react": "2.1.0", "antd": "4.22.5", "classnames": "^2.2.6", + "dayjs": "^1.11.19", "handsontable": "6.2.2", "highlight.js": "^10.5.0", "immer": "~10.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57da62ed5..66459f6a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,7 @@ specifiers: babel-plugin-import: ^1.13.8 classnames: ^2.2.6 cz-conventional-changelog: ^3.3.0 + dayjs: ^1.11.19 dumi: ^2.2.12 eslint: ^8.23.0 father: ~4.1.0 @@ -66,6 +67,7 @@ dependencies: '@handsontable/react': registry.npmmirror.com/@handsontable/react/2.1.0_handsontable@6.2.2 antd: registry.npmmirror.com/antd/4.22.5_react-dom@18.2.0+react@18.2.0 classnames: registry.npmmirror.com/classnames/2.3.2 + dayjs: 1.11.19 handsontable: registry.npmmirror.com/handsontable/6.2.2 highlight.js: registry.npmmirror.com/highlight.js/10.7.3 immer: 10.1.1 @@ -204,7 +206,7 @@ packages: /@dtinsight/dt-utils/1.3.1: resolution: {integrity: sha512-bV3xfCUthEtPkBpsCV/798J/Fz9xhxq9QybAaXhOtfGlZRuqPrb4Irdp2ySj7UaFB4VmmDg0wTIyxv0HMyGctQ==} dependencies: - dayjs: 1.11.10 + dayjs: 1.11.19 lodash: 4.17.21 standard-version: 9.5.0 dev: false @@ -1240,8 +1242,8 @@ packages: resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} dev: false - /dayjs/1.11.10: - resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} + /dayjs/1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} dev: false /debug/3.2.7: @@ -9338,12 +9340,6 @@ packages: version: 3.0.3 dev: true - registry.npmmirror.com/dayjs/1.11.10: - resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/dayjs/-/dayjs-1.11.10.tgz} - name: dayjs - version: 1.11.10 - dev: false - registry.npmmirror.com/debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz} name: debug @@ -18518,7 +18514,7 @@ packages: '@babel/runtime': registry.npmmirror.com/@babel/runtime/7.23.1 classnames: registry.npmmirror.com/classnames/2.3.2 date-fns: registry.npmmirror.com/date-fns/2.30.0 - dayjs: registry.npmmirror.com/dayjs/1.11.10 + dayjs: 1.11.19 moment: registry.npmmirror.com/moment/2.29.4 rc-trigger: registry.npmmirror.com/rc-trigger/5.3.4_react-dom@18.2.0+react@18.2.0 rc-util: registry.npmmirror.com/rc-util/5.37.0_react-dom@18.2.0+react@18.2.0 diff --git a/src/chat/__tests__/__snapshots__/button.test.tsx.snap b/src/chat/__tests__/__snapshots__/button.test.tsx.snap index 653bc87eb..8f4ea5bd7 100644 --- a/src/chat/__tests__/__snapshots__/button.test.tsx.snap +++ b/src/chat/__tests__/__snapshots__/button.test.tsx.snap @@ -45,4 +45,4 @@ exports[`Test Chat Button Match the snapshot: secondary button 1`] = ` `; -exports[`Test Chat Button expect ONLY one global gradient div: global gradient 1`] = `""`; +exports[`Test Chat Button expect ONLY one global gradient div: global gradient 1`] = `""`; diff --git a/src/chat/__tests__/__snapshots__/conversations.test.tsx.snap b/src/chat/__tests__/__snapshots__/conversations.test.tsx.snap new file mode 100644 index 000000000..8041d0c07 --- /dev/null +++ b/src/chat/__tests__/__snapshots__/conversations.test.tsx.snap @@ -0,0 +1,202 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Test Chat Conversations Match snapshot 1`] = ` + +
+
+
+`; + +exports[`Test Chat Conversations Match snapshot: collapsed 1`] = ` + +
+
+
+`; + +exports[`Test Chat Conversations Match snapshot: groupable 1`] = ` + +
+ +
+
+`; + +exports[`Test Chat Conversations Match snapshot: handleCreateChat 1`] = ` + +
+ +
+
+`; + +exports[`Test Chat Conversations Match snapshot: loading 1`] = ` + +
+
+ + + + + + +
+
+
+`; + +exports[`Test Chat Conversations Match snapshot: normal 1`] = ` + +
+
    +
    +
    +
    + this is conversation 1 +
    +
    +
    +
    +
    +
    + this is conversation 2 +
    +
    +
    +
+
+
+`; + +exports[`Test Chat Conversations Match snapshot: select 1`] = ` + +
+
    +
+
+`; diff --git a/src/chat/__tests__/conversations.test.tsx b/src/chat/__tests__/conversations.test.tsx new file mode 100644 index 000000000..8214b36dd --- /dev/null +++ b/src/chat/__tests__/conversations.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { NewChatOutlined } from '@dtinsight/react-icons'; +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { Menu } from 'antd'; +import dayjs from 'dayjs'; +import '@testing-library/jest-dom/extend-expect'; + +import Conversations, { ConversationInfo } from '../conversations'; +import Chat from '..'; + +function generateConversation() { + const conversation = [ + { + id: 'conversation_1', + createdAt: 1736479532239, + updatedAt: dayjs().subtract(1, 'day').toDate().getTime(), + title: 'this is conversation 1', + assistantId: 'assistant_1', + }, + { + id: 'conversation_2', + createdAt: 1736479532239, + updatedAt: dayjs().toDate().getTime(), + title: 'this is conversation 2', + assistantId: 'assistant_2', + }, + ]; + return conversation; +} +jest.mock('../../ellipsisText', () => { + return (props: any) =>
{props.value}
; +}); +jest.mock('remark-gfm', () => () => ({})); + +describe('Test Chat Conversations', () => { + beforeEach(() => { + cleanup(); + }); + + it('Match snapshot', () => { + const conversation = generateConversation(); + const handleCreateChat = jest.fn(); + expect(render().asFragment()).toMatchSnapshot(); + expect(render().asFragment()).toMatchSnapshot( + 'collapsed' + ); + expect( + render().asFragment() + ).toMatchSnapshot('select'); + expect(render().asFragment()).toMatchSnapshot( + 'loading' + ); + expect( + render( + } + onClick={handleCreateChat} + className="prompt-float-chat-add" + style={{ + margin: '16px', + gap: 4, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + 开启新对话 + + } + /> + ).asFragment() + ).toMatchSnapshot('handleCreateChat'); + expect(render().asFragment()).toMatchSnapshot( + 'normal' + ); + expect( + render().asFragment() + ).toMatchSnapshot('groupable'); + }); + it('Should loading', () => { + const { container } = render(); + + const ele = container.querySelector('.dtc__conversations__wrapper')?.children[0]; + expect(ele).toBeInTheDocument(); + expect(ele?.className).toContain('dtc__conversations__spin__wrapper'); + }); + + it('Should collapsed', () => { + const { container } = render(); + + const ele = container.querySelector('.dtc__conversations__wrapper'); + expect(ele).toBeInTheDocument(); + expect(ele?.className).toContain('dtc__conversations--collapsed'); + }); + + it('Should select item', () => { + const conversation = generateConversation(); + const { container } = render( + + ); + + const ele = container.querySelector('.dtc__conversations__item'); + expect(ele).toBeInTheDocument(); + expect(ele?.className).toContain('dtc__conversations__item--active'); + }); + + it('Should group list title', () => { + const { container } = render( + + ); + + const ele = container.querySelectorAll('.dtc__conversations__title'); + expect(ele).toHaveLength(2); + }); + + it('Should support add new session', () => { + const handleCreateChat = jest.fn(); + const { container } = render( + } + onClick={handleCreateChat} + className="prompt-float-chat-add" + style={{ + margin: '16px', + gap: 4, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + 开启新对话 + + } + /> + ); + + const btn = container.querySelector('.prompt-float-chat-add'); + expect(handleCreateChat).not.toBeCalled(); + expect(btn).not.toBeNull(); + + act(() => { + fireEvent.click(btn!); + }); + expect(handleCreateChat).toBeCalledWith( + expect.objectContaining({ + type: 'click', + }) + ); + }); + it('Should support select item', () => { + const conversation = generateConversation(); + const onItemClick = jest.fn(); + const { container } = render( + + ); + + const nodeList = container.querySelectorAll('.dtc__conversations__item'); + const ele = nodeList?.item(nodeList?.length - 1); + + expect(onItemClick).not.toBeCalled(); + expect(ele).not.toBeNull(); + + fireEvent.click(ele!); + expect(onItemClick).toBeCalledWith(conversation[conversation.length - 1]); + }); + + test('Should render delete button', () => { + const conversation = generateConversation(); + const onDelete = jest.fn(); + const renderMenu = (info: ConversationInfo) => ({ + overlay: ( + e.domEvent.stopPropagation()}> + onDelete?.(info)}> + 删除 + + + ), + }); + const { container } = render( + + ); + + const icon = container.querySelectorAll('.ant-dropdown-trigger')[0]; + expect(icon).toBeInTheDocument(); + + act(() => { + fireEvent.click(icon); + }); + + const dropdownMenuItems = document.querySelectorAll( + '.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item' + ); + expect(dropdownMenuItems).toHaveLength(1); + + fireEvent.click(dropdownMenuItems[0]); + expect(onDelete).toBeCalledWith(conversation[0]); + }); +}); diff --git a/src/chat/button/index.tsx b/src/chat/button/index.tsx index f6eb0243f..87697a49e 100644 --- a/src/chat/button/index.tsx +++ b/src/chat/button/index.tsx @@ -25,11 +25,10 @@ export default function Button({ type = 'default', className, children, ...rest @@ -37,11 +36,10 @@ export default function Button({ type = 'default', className, children, ...rest diff --git a/src/chat/conversations/groupTitle/index.scss b/src/chat/conversations/groupTitle/index.scss new file mode 100644 index 000000000..e48c3d57c --- /dev/null +++ b/src/chat/conversations/groupTitle/index.scss @@ -0,0 +1,6 @@ +.dtc__conversations__title { + color: #B1B4C5; + line-height: 20px; + font-size: 12px; + margin-bottom: 4px; +} diff --git a/src/chat/conversations/groupTitle/index.tsx b/src/chat/conversations/groupTitle/index.tsx new file mode 100644 index 000000000..71b89822a --- /dev/null +++ b/src/chat/conversations/groupTitle/index.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import EllipsisText from '../../../ellipsisText'; +import { GroupTitleProps } from '../interface'; +import './index.scss'; + +const GroupTitle: React.FC = (props) => { + const { prefixCls = 'dtc__conversations' } = props; + return ( +
+ {props.children && ( + + )} +
+ ); +}; + +export default GroupTitle; diff --git a/src/chat/conversations/hooks/useGroupable.ts b/src/chat/conversations/hooks/useGroupable.ts new file mode 100644 index 000000000..d3ea0b73e --- /dev/null +++ b/src/chat/conversations/hooks/useGroupable.ts @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import dayjs from 'dayjs'; +import shortid from 'shortid'; + +import useLocale, { Locale } from '../../../locale/useLocale'; +import { ConversationInfo, ConversationsProps, Groupable, GroupInfo } from '../interface'; + +const DEFAULT_GROUP_KEY = 'updatedAt'; +type GroupMap = Record; +const useGroupable = ( + groupable: ConversationsProps['groupable'], + conversations: ConversationInfo[] +): [list: GroupInfo[], enable: boolean] => { + const locale = useLocale('Chat'); + const [enable, sort, title] = useMemo(() => { + if (!groupable) return [false, undefined, undefined]; + + let baseConfig: Groupable = { + sort: undefined, + title: undefined, + }; + + if (typeof groupable === 'object') { + baseConfig = { ...baseConfig, ...groupable }; + } + + return [true, baseConfig.sort, baseConfig.title]; + }, [groupable]); + + return useMemo(() => { + if (!enable) { + const groupList: GroupInfo[] = [ + { + id: `group_${shortid()}`, + conversations, + title: undefined, + }, + ]; + return [groupList, enable]; + } + const groupMap = conversations.reduce((prev, current) => { + const group = current.group || classifyDate(locale, current[DEFAULT_GROUP_KEY]); + if (!prev[group]) { + prev[group] = []; + } + prev[group].push(current); + return prev; + }, {}); + + const groupEntries = sort ? Object.entries(groupMap).sort(sort) : Object.entries(groupMap); + + const groupList: GroupInfo[] = groupEntries.map(([key, value]) => { + return { + id: `group_${shortid()}`, + title, + conversations: value, + name: key, + }; + }); + return [groupList, enable]; + }, [conversations, enable]); +}; + +export function classifyDate(locale: Locale['Chat'], date?: string | Date | number) { + const input = dayjs(date).startOf('day'); + const now = dayjs().startOf('day'); + + const diffDays = now.diff(input, 'days'); + + if (diffDays < 1) { + return locale.today; + } else if (diffDays < 2) { + return locale.yesterday; + } else if (diffDays < 7) { + return locale.recent7Days; + } else if (diffDays < 15) { + return locale.recent15Days; + } else if (diffDays < 30) { + return locale.recent30Days; + } else { + return locale.other; + } +} + +export default useGroupable; diff --git a/src/chat/conversations/index.scss b/src/chat/conversations/index.scss new file mode 100644 index 000000000..bd04b1bd3 --- /dev/null +++ b/src/chat/conversations/index.scss @@ -0,0 +1,41 @@ +.dtc__conversations { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0 16px; + list-style: none; + overflow-y: auto; + flex: 1; + &--empty { + margin-top: 50%; + } + &__list { + display: flex; + flex-direction: column; + gap: 4px; + } + &__spin__wrapper { + width: 100%; + display: flex; + justify-content: center; + margin: 40px 0; + } + &__wrapper { + display: flex; + flex-direction: column; + width: 0; + height: 100%; + transition: width 0.3s ease; + overflow: hidden; + } + &--collapsed { + width: 240px; + } + &--hide { + opacity: 0; + pointer-events: none; + } +} diff --git a/src/chat/conversations/index.tsx b/src/chat/conversations/index.tsx new file mode 100644 index 000000000..44403ba21 --- /dev/null +++ b/src/chat/conversations/index.tsx @@ -0,0 +1,132 @@ +import React, { useEffect } from 'react'; +import { Spin } from 'antd'; +import classNames from 'classnames'; + +import Empty from '../../empty'; +import useLocale from '../../locale/useLocale'; +import useGroupable from './hooks/useGroupable'; +import GroupTitle from './groupTitle'; +import { ConversationInfo, ConversationsProps } from './interface'; +import Item from './item'; +import './index.scss'; + +const prefixCls = 'dtc__conversations'; +const Conversations = (props: ConversationsProps) => { + const { + conversations, + activeKey, + defaultActiveKey, + dropdown, + groupable, + className, + style, + + loading, + header, + collapsed = true, + onItemClick, + renderItem, + ...restProps + } = props; + const [value, setValue] = React.useState(activeKey || defaultActiveKey); + + const [groupList, enable] = useGroupable(groupable, conversations); + const [isHide, setIsHide] = React.useState(false); + const locale = useLocale('Chat'); + + const handleItemClick = (info: ConversationInfo) => { + setValue(info.id); + onItemClick?.(info); + }; + + useEffect(() => { + if (activeKey !== value) { + setValue(activeKey); + } + }, [activeKey]); + + const renderConversations = () => { + if (loading) { + return ; + } + if (!groupList?.length) { + return ( + + ); + } + return ( +
    + {groupList.map((group) => { + const items = group.conversations.map((conversation) => { + const dropdownVal = + typeof dropdown === 'function' ? dropdown(conversation) : dropdown; + if (renderItem) { + return renderItem({ + info: conversation, + active: conversation.id === value, + onClick: handleItemClick, + dropdown: dropdownVal, + }); + } + return ( + + ); + }); + if (enable) { + return ( +
  • + {group.title?.(group, { components: { GroupTitle } }) || ( + + {group.name} + + )} +
      {items}
    +
  • + ); + } + return items; + })} +
+ ); + }; + useEffect(() => { + if (collapsed) { + setIsHide(false); + } + }, [collapsed]); + + return ( +
{ + if (!collapsed) setIsHide(true); + }} + > + {header} + {renderConversations()} +
+ ); +}; + +export type { ConversationInfo }; + +Conversations.Item = Item; +Conversations.Title = GroupTitle; + +export default Conversations; diff --git a/src/chat/conversations/interface.ts b/src/chat/conversations/interface.ts new file mode 100644 index 000000000..d67401202 --- /dev/null +++ b/src/chat/conversations/interface.ts @@ -0,0 +1,130 @@ +import { HTMLAttributes } from 'react'; +import { DropdownProps } from 'antd'; + +import { ConversationProperties } from '../entity'; + +/** + * 单条会话信息结构 + * 用于描述侧边栏会话列表中的一项 + */ +export interface ConversationInfo extends ConversationProperties { + /** 会话所属分组(用于分组展示,可选,默认以updateAt分组) */ + group?: string; + /** 会话项自定义图标 */ + icon?: React.ReactNode; + /** 是否禁用此会话(禁用点击与交互) */ + disabled?: boolean; +} +/** + * Conversations 会话组件入参 + * 用于渲染会话列表与相关交互 + */ +export interface ConversationsProps extends React.HTMLAttributes { + /** 会话列表数据源 */ + conversations: ConversationInfo[]; + /** 当前激活会话的 id(受控模式) */ + activeKey?: ConversationInfo['id']; + /** 默认激活会话 id(非受控模式) */ + defaultActiveKey?: ConversationInfo['id']; + /** + * 自定义每一项的下拉菜单 + * - 传入对象时:所有项共享同一配置 + * - 传入方法时:可根据不同会话动态生成 + */ + dropdown?: + | ConversationsItemProps['dropdown'] + | ((info: ConversationInfo) => ConversationsItemProps['dropdown']); + /** 是否启用按 group 分组展示(true 时使用默认配置,也可传入自定义 Groupable 配置) */ + groupable?: boolean | Groupable; + className?: string; + style?: React.CSSProperties; + loading?: boolean; + /** 列表头部区域内容(false 表示不展示) */ + header?: React.ReactNode | boolean; + /** 是否为折叠状态(折叠时仅展示图标) */ + collapsed?: boolean; + /** 点击某条会话时触发 */ + onItemClick?: (info: ConversationInfo) => void; + /** 自定义渲染每一项的内容 */ + renderItem?: (props: ConversationsItemProps) => React.ReactNode; +} +/** + * Conversations.Item 单个会话项组件的入参 + * 用于渲染侧边栏中的一条会话 + */ +export interface ConversationsItemProps extends Omit, 'onClick'> { + /** 当前会话项的数据对象 */ + info: ConversationInfo; + /** 是否为激活状态 */ + active?: boolean; + /** + * 下拉菜单配置(用于操作会话项) + * - 可传入 DropdownProps + * - 支持通过 triggerDom 自定义触发节点 + */ + dropdown?: DropdownProps & { + triggerDom?: + | React.ReactNode + | (( + conversation: ConversationInfo, + info: { originNode: React.ReactNode } + ) => React.ReactNode); + }; + onClick?: (info: ConversationInfo) => void; +} + +/** + * 分组组件入参 + */ +export interface GroupTitleProps { + /** 分组标题内容 */ + children?: React.ReactNode; + prefixCls?: string; +} + +/** + * 处理之后的分组数据 + */ +export type GroupInfo = { + /** 分组内的会话列表 */ + conversations: ConversationInfo[]; + /** 分组唯一标识(可选) */ + id?: string; + /** 自定义渲染后的标题(Groupable.title 的结果) */ + title?: Groupable['title']; + name?: string; +}; + +/** 分组排序函数类型,来自 Array.sort 的入参类型 */ +export type GroupSorter = Parameters<[string, ConversationInfo[]][]['sort']>[0]; + +/** 自定义分组标题渲染时可访问的内置组件 */ +export type GroupTitleRenderComponents = { + components: { + GroupTitle: React.ComponentType; + }; +}; +/** + * 分组标题渲染函数 + * 用于完全自定义分组标题渲染逻辑 + */ +export type GroupTitleRender = + | ((groupInfo: GroupInfo, info: GroupTitleRenderComponents) => React.ReactNode) + | undefined; + +/** + * 分组功能配置 + * 控制会话列表是否按 group 分组显示 + */ +export interface Groupable { + /** + * @desc 分组排序函数 + * @descEN Group sorter + */ + sort?: GroupSorter; + /** + * @desc 自定义分组标签渲染 + * @descEN Semantic custom rendering + */ + title?: GroupTitleRender; +} diff --git a/src/chat/conversations/item/index.scss b/src/chat/conversations/item/index.scss new file mode 100644 index 000000000..6a7a1dadb --- /dev/null +++ b/src/chat/conversations/item/index.scss @@ -0,0 +1,32 @@ +.dtc__conversations__item { + display: flex; + align-items: center; + justify-content: center; + height: 32px; + gap: 4px; + padding: 0 16px; + border-radius: 4px; + cursor: pointer; + color: #3D446E; + &__title { + flex: 1; + overflow: hidden; + } + &:hover { + background-color: #EBECF0; + .dtc__conversations__menu__icon { + display: block; + } + } + .dtc__conversations__menu__icon { + display: none; + } + &--active, &--active:hover { + color: #1D78FF; + background-color: #E8F1FF; + } + &--disabled, &--disabled:hover { + cursor: not-allowed; + opacity: 0.5; + } +} diff --git a/src/chat/conversations/item/index.tsx b/src/chat/conversations/item/index.tsx new file mode 100644 index 000000000..c2dea873d --- /dev/null +++ b/src/chat/conversations/item/index.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { MoreOutlined } from '@dtinsight/react-icons'; +import { Dropdown } from 'antd'; +import classNames from 'classnames'; + +import EllipsisText from '../../../ellipsisText'; +import { ConversationInfo, ConversationsItemProps } from '../interface'; +import './index.scss'; + +const prefixCls = 'dtc__conversations'; +const Item: React.FC = (props) => { + const { info, active, dropdown, onClick } = props; + + const { disabled } = info; + const { triggerDom } = dropdown || {}; + + const handleClick = () => { + if (disabled || active) return; + onClick?.(info); + }; + const stopPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + const renderMenuTrigger = (conversation: ConversationInfo) => { + const originTriggerNode = ( + + ); + if (triggerDom) { + return typeof triggerDom === 'function' + ? triggerDom(conversation, { originNode: originTriggerNode }) + : triggerDom; + } + return originTriggerNode; + }; + return ( +
+ {info.icon &&
{info.icon}
} +
+ +
+ {!disabled && dropdown?.overlay && ( + + {renderMenuTrigger(info)} + + )} +
+ ); +}; + +export default Item; diff --git a/src/chat/demos/components/customConversationItem.tsx b/src/chat/demos/components/customConversationItem.tsx new file mode 100644 index 000000000..f526c1d05 --- /dev/null +++ b/src/chat/demos/components/customConversationItem.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Input, message } from 'antd'; +import classNames from 'classnames'; +import { Chat } from 'dt-react-component'; +import { ConversationsItemProps } from 'dt-react-component/chat/conversations/interface'; + +interface IProps extends ConversationsItemProps { + edit?: ConversationsItemProps['info']; + onRename?: (info: ConversationsItemProps['info'], value: string) => Promise; + setEdit: (edit?: ConversationsItemProps['info']) => void; +} + +export default function CustomConversionItem(props: IProps) { + const { info, prefixCls, active, edit, onRename, setEdit } = props; + const { disabled, title } = info || {}; + + const isEdit = edit?.id === info?.id; + + const handleRename = async (value: string) => { + if (!value) { + setEdit(undefined); + message.error('请输入对话名称'); + return; + } + const res = await onRename?.(info, value); + if (res) { + setEdit(undefined); + } + }; + return isEdit ? ( +
+ { + handleRename(target.value); + }} + onPressEnter={({ target, key }) => { + if (key === 'Enter') { + handleRename((target as HTMLInputElement).value); + } + }} + /> +
+ ) : ( + + ); +} diff --git a/src/chat/demos/conversations.tsx b/src/chat/demos/conversations.tsx new file mode 100644 index 000000000..b4f2609d5 --- /dev/null +++ b/src/chat/demos/conversations.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { NewChatOutlined } from '@dtinsight/react-icons'; +import { Menu } from 'antd'; +import { Button, Chat } from 'dt-react-component'; +import { ConversationInfo } from 'dt-react-component/chat/conversations'; +import { ConversationProperties } from 'dt-react-component/chat/entity'; + +import CustomConversionItem from './components/customConversationItem'; +import './index.scss'; + +export default function ({ conversations = [] }: { conversations: ConversationProperties[] }) { + const [data, setData] = React.useState(conversations); + const [selectId, setSelectId] = React.useState('1'); + const [collapsed, setCollapsed] = React.useState(true); + const [edit, setEdit] = React.useState(); + + const handleSelectChat = (conversation: ConversationProperties) => { + setSelectId(conversation.id); + }; + + const handleRenameChat = (_conversation: ConversationProperties, _value: string) => { + data.forEach((item) => { + if (item.id === _conversation.id) { + item.title = _value; + } + }); + setData(data); + return Promise.resolve(true); + }; + + const handleClearChat = (conversation: ConversationProperties) => { + console.log(conversation); + }; + const handleNewChat = () => { + setData((prev) => { + const idx = prev.length + 1; + return [ + ...prev, + { + id: idx.toString(), + title: `对话${idx}`, + assistantId: idx.toString(), + createdAt: new Date().valueOf(), + updatedAt: new Date().valueOf(), + }, + ]; + }); + }; + const renderMenu = (info: ConversationInfo) => ({ + overlay: ( + e.domEvent.stopPropagation()}> + { + setEdit(info); + }} + > + 重命名 + + handleClearChat?.(info)}> + 删除 + + + ), + }); + + return ( +
+ + ( + + )} + header={ + } + onClick={handleNewChat} + className="prompt-float-chat-add" + > + 开启新对话 + + } + /> +
+ ); +} diff --git a/src/chat/demos/global-state/conversations.tsx b/src/chat/demos/global-state/conversations.tsx new file mode 100644 index 000000000..d8745d182 --- /dev/null +++ b/src/chat/demos/global-state/conversations.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useState } from 'react'; +import { NewChatOutlined, ThumbsUpOutlined } from '@dtinsight/react-icons'; +import { Button, Menu } from 'antd'; +import { Chat, Flex } from 'dt-react-component'; +import { ConversationInfo } from 'dt-react-component/chat/conversations'; +import { ConversationProperties } from 'dt-react-component/chat/entity'; +import { produce } from 'immer'; +import { cloneDeep } from 'lodash-es'; + +import CustomConversionItem from '../components/customConversationItem'; +import { mockSSE } from '../mockSSE'; +import '../index.scss'; + +export default function () { + const chat = Chat.useChat(); + const [value, setValue] = useState(''); + const [convert, setConvert] = useState(false); + const [data, setData] = useState([]); + const [edit, setEdit] = React.useState(); + + const handleSelectChat = (conversation: ConversationProperties) => { + chat.conversation.remove(); + chat.conversation.create({ ...conversation }); + }; + + const handleRenameChat = (_conversation: ConversationProperties, _value: string) => { + setData((prev) => { + const idx = prev.findIndex((i) => i.id === _conversation.id); + if (idx === -1) return prev; + return produce(prev, (draft) => { + draft[idx].title = _value; + }); + }); + return Promise.resolve(true); + }; + + const handleDeleteChat = (conversation: ConversationProperties) => { + const list = cloneDeep(data).filter((i) => i.id !== conversation.id); + if (conversation.id === chat.conversation.get()?.id) { + chat.conversation.remove(); + if (list.length) { + handleSelectChat(list[0]); + chat.conversation.create({ ...list[0] }); + } + } + setData(list); + }; + const handleCreateChat = () => { + chat.conversation.remove(); + chat.conversation.create({ id: new Date().valueOf().toString() }); + }; + + const addData = (str: string) => { + setData((prev) => { + const idx = prev.length + 1; + return [ + ...prev, + { + id: chat.conversation.get()!.id, + title: str, + assistantId: idx.toString(), + createdAt: new Date().valueOf(), + updatedAt: new Date().valueOf(), + }, + ]; + }); + handleSelectChat(chat.conversation.get()!); + }; + + const handleSubmit = (raw = value) => { + const val = raw?.trim(); + if (chat.loading() || !val) return; + setValue(''); + const promptId = new Date().valueOf().toString(); + const messageId = (new Date().valueOf() + 1).toString(); + chat.prompt.create({ id: promptId, title: val }); + chat.message.create(promptId, { id: messageId, content: '' }); + mockSSE({ + message: val, + onopen() { + chat.start(promptId, messageId); + addData(val); + }, + onmessage(str) { + chat.push(promptId, messageId, str); + }, + onstop() { + chat.close(promptId, messageId); + }, + }); + }; + const renderMenu = (info: ConversationInfo) => ({ + overlay: ( + e.domEvent.stopPropagation()}> + { + setEdit(info); + }} + > + 重命名 + + handleDeleteChat?.(info)}> + 删除 + + + ), + }); + + useEffect(() => { + chat.conversation.create({ id: new Date().valueOf().toString() }); + }, []); + + return ( +
+ } + components={{ + a: ({ children }) => ( + + ), + }} + > + + ( + + )} + header={ + } + onClick={handleCreateChat} + className="prompt-float-chat-add" + style={{ + margin: '16px', + gap: 4, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + 开启新对话 + + } + style={{ + backgroundColor: '#F9F9FA', + borderRight: '1px solid #E5E7EB', + }} + /> + + + +
+ + handleSubmit('请告诉我一首诗')}> + 返回一首诗 + + handleSubmit('生成 CodeBlock')}> + 生成 CodeBlock + + +
+ } + /> + handleSubmit()} + onPressShiftEnter={() => setValue((v) => v + '\n')} + button={{ + disabled: chat.loading() || !value?.trim(), + }} + placeholder="请输入想咨询的内容…" + /> + + + + + ); +} diff --git a/src/chat/demos/index.scss b/src/chat/demos/index.scss new file mode 100644 index 000000000..5dde1b073 --- /dev/null +++ b/src/chat/demos/index.scss @@ -0,0 +1,16 @@ +li,ul { + padding: 0; + margin: 0; +} + +.dtc__conversations__wrapper { + border-right: 1px solid #E5E7EB; + background-color: #F9F9FA; + .ant-btn.dtc__aigc__button { + margin: 16px; + gap: 4px; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/chat/entity.ts b/src/chat/entity.ts index 0c55c960c..ac31659c7 100644 --- a/src/chat/entity.ts +++ b/src/chat/entity.ts @@ -29,6 +29,7 @@ export type ConversationProperties = { id: string; assistantId?: string; createdAt?: Timestamp; + updatedAt?: Timestamp; title?: string; prompts?: Prompt[]; }; @@ -58,6 +59,7 @@ export abstract class Conversation { // 后端 Id assistantId?: string; createdAt: Timestamp; + updatedAt?: Timestamp; title?: string; prompts: Prompt[]; @@ -67,6 +69,7 @@ export abstract class Conversation { this.id = props.id; this.assistantId = props.assistantId; this.createdAt = props.createdAt || new Date().valueOf(); + this.updatedAt = props.updatedAt || new Date().valueOf(); this.title = props.title; this.prompts = props.prompts || []; } diff --git a/src/chat/index.$tab-conversations.md b/src/chat/index.$tab-conversations.md new file mode 100644 index 000000000..03e7fdec1 --- /dev/null +++ b/src/chat/index.$tab-conversations.md @@ -0,0 +1,43 @@ +--- +title: Conversations +group: 组件 +toc: content +demo: + cols: 2 +--- + +# Conversations + +## 何时使用 + +Conversations 组件用于展示会话列表 + +## 示例 + + + +## API + +| 参数 | 说明 | 类型 | 默认值 | +| ---------------- | ----------------- | ---------------------------------------------------------------------------------- | ------- | +| conversations | 会话列表 | `ConversationInfo[]` | - | +| activeKey | 激活的会话 ID | `ConversationInfo['id']` | - | +| defaultActiveKey | 默认激活的会话 ID | `ConversationInfo['id']` | - | +| dropdown | 下拉菜单 | [ConversationsItemProps](?tab=conversations#iconversationsitemprops)`['dropdown']` | `false` | +| groupable | 是否启用分组 | `boolean` | - | +| className | 自定义类名 | `string` | - | +| style | 自定义样式 | `React.CSSProperties` | - | +| loading | 是否加载中 | `boolean` | - | +| header | 会话列表头部 | `React.ReactNode` | - | +| collapsed | 是否折叠 | `boolean` | `true` | +| onItemClick | 点击会话事件 | `(info: ConversationInfo) => void` | - | +| renderItem | 自定义渲染会话项 | `(props: ConversationsItemProps) => React.ReactNode` | - | + +## ConversationsItemProps + +| 参数 | 说明 | 类型 | 默认值 | +| -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| info | 对话数据 | `ConversationInfo[]` | - | +| active | 是否激活 | `boolean` | - | +| dropdown | 下拉菜单 | `DropdownProps & { triggerDom?: React.ReactNode \| ((conversation: ConversationInfo, info: { originNode: React.ReactNode }) => React.ReactNode) }` | - | +| onClick | 点击事件 | `(conversation: ConversationInfo) => void` | - | diff --git a/src/chat/index.md b/src/chat/index.md index 10b913ae5..c1e1ca277 100644 --- a/src/chat/index.md +++ b/src/chat/index.md @@ -21,6 +21,7 @@ Chat 规范由多个组件复合使用实现落地场景,其中: - `Message` 组件是符合 AI 规范的回答框 - `Prompt` 组件是符合 AI 规范的提问框 - `Content` 组件是符合 AI 规范的正文内容 +- `Conversations` 组件是符合 AI 规范的会话列表组件 ## 何时使用 @@ -30,5 +31,6 @@ Chat 规范由多个组件复合使用实现落地场景,其中: + ## API diff --git a/src/chat/index.tsx b/src/chat/index.tsx index 2a57861f3..14f9b41cf 100644 --- a/src/chat/index.tsx +++ b/src/chat/index.tsx @@ -3,6 +3,7 @@ import React, { type PropsWithChildren } from 'react'; import Button from './button'; import CodeBlock from './codeBlock'; import Content from './content'; +import Conversations from './conversations'; import Input from './input'; import Loading from './loading'; import Markdown from './markdown'; @@ -66,6 +67,7 @@ Chat.Prompt = Prompt; Chat.Content = Content; Chat.Tag = Tag; Chat.Welcome = Welcome; +Chat.Conversations = Conversations; export { type IContentRef } from './content'; export default Chat; diff --git a/src/chat/input/index.tsx b/src/chat/input/index.tsx index 4666adae8..897656bb9 100644 --- a/src/chat/input/index.tsx +++ b/src/chat/input/index.tsx @@ -34,6 +34,10 @@ export default function Input({
{ @@ -46,10 +50,6 @@ export default function Input({ onPressEnter?.(e); } }} - autoSize={{ - minRows: 2, - maxRows: 7, - }} /> {button?.disabled ? ( diff --git a/src/chat/markdown/index.scss b/src/chat/markdown/index.scss index d076fe70a..08418932a 100644 --- a/src/chat/markdown/index.scss +++ b/src/chat/markdown/index.scss @@ -73,6 +73,33 @@ font-size: 14px; } } + &__table { + border: 1px solid #EBECF0; + width: 100%; + margin-block-end: 8px; + tr { + border-bottom: 1px solid #EBECF0; + height: 36px; + text-align: left; + font-size: 12px; + line-height: 20px; + color: #3D446E; + th, td { + padding: 8px 16px; + } + } + thead { + tr { + background-color: #F9F9FA; + font-weight: 500; + } + } + tbody { + tr { + background-color: #FFF; + } + } + } &__inlineCode { margin: 0 4px; padding: 2px 8px; diff --git a/src/chat/markdown/index.tsx b/src/chat/markdown/index.tsx index 1f2223b33..c5699f523 100644 --- a/src/chat/markdown/index.tsx +++ b/src/chat/markdown/index.tsx @@ -57,6 +57,9 @@ export default memo( return
{data.children}
; } }, + table({ children }) { + return {children}
; + }, img({ src, ...rest }) { return ( Prompt): void; - function _updatePrompt(promptId: Id, data: Partial>): void; function _updatePrompt( promptId: Id, - dataOrPredicate: Partial> | ((prompt: Prompt) => Prompt) + data: Partial[0], 'id'>> + ): void; + function _updatePrompt( + promptId: Id, + dataOrPredicate: + | Partial[0], 'id'>> + | ((prompt: Prompt) => Prompt) ) { if (!state.current) return; state.current = produce(state.current, (draft) => { diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 6f44cbf28..6141da637 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -14,6 +14,13 @@ const localeValues: Locale = { stopped: 'Answer Stopped', stop: 'Stop Answering', regenerate: 'Regenerate', + conversationEmpty: 'No conversation', + today: 'Today', + yesterday: 'Yesterday', + recent7Days: 'Recent 7 days', + recent15Days: 'Recent 15 days', + recent30Days: 'Recent 30 days', + other: 'Other', }, Copy: { copied: 'Copied', diff --git a/src/locale/useLocale.tsx b/src/locale/useLocale.tsx index 9d57f29e2..fe1454858 100644 --- a/src/locale/useLocale.tsx +++ b/src/locale/useLocale.tsx @@ -10,6 +10,13 @@ export interface Locale { stopped: string; stop: string; regenerate: string; + conversationEmpty: string; + today: string; + yesterday: string; + recent7Days: string; + recent15Days: string; + recent30Days: string; + other: string; }; Copy: { copied: string; copy: string }; Dropdown: { selectAll: string; resetText: string; okText: string }; diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 6e3e55449..c5622323e 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -14,6 +14,13 @@ const localeValues: Locale = { stopped: '回答已停止', stop: '停止回答', regenerate: '重新生成', + conversationEmpty: '暂无对话', + today: '今天', + yesterday: '昨天', + recent7Days: '近7天', + recent15Days: '近15天', + recent30Days: '近30天', + other: '其他', }, Copy: { copied: '复制成功',