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`] = `
+
+
+
+ -
+
+
+
+
+
+ this is conversation 1
+
+
+
+
+
+ -
+
+
+
+
+
+ this is conversation 2
+
+
+
+
+
+
+
+
+`;
+
+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: (
+
+ ),
+ });
+ 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}
+
+ )}
+
+
+ );
+ }
+ 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: (
+
+ ),
+ });
+
+ 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: (
+
+ ),
+ });
+
+ 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 ;
+ },
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: '复制成功',