diff --git a/package.json b/package.json
index 890d0dfb3..00b538c7a 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.13",
"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..b98e4c753 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.13
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.13
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.13
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.13:
+ resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
dev: false
/debug/3.2.7:
@@ -2216,11 +2218,13 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
- /moment/2.29.4:
- resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
- requiresBuild: true
+ /moment/2.20.1:
+ resolution: {integrity: sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==}
+ dev: false
+
+ /moment/2.30.1:
+ resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
dev: false
- optional: true
/mri/1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
@@ -6950,7 +6954,7 @@ packages:
copy-to-clipboard: registry.npmmirror.com/copy-to-clipboard/3.3.3
lodash: registry.npmmirror.com/lodash/4.17.21
memoize-one: registry.npmmirror.com/memoize-one/6.0.0
- moment: registry.npmmirror.com/moment/2.29.4
+ moment: 2.30.1
rc-cascader: registry.npmmirror.com/rc-cascader/3.6.2_react-dom@18.2.0+react@18.2.0
rc-checkbox: registry.npmmirror.com/rc-checkbox/2.3.2_react-dom@18.2.0+react@18.2.0
rc-collapse: registry.npmmirror.com/rc-collapse/3.3.1_react-dom@18.2.0+react@18.2.0
@@ -9338,12 +9342,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
@@ -12217,7 +12215,7 @@ packages:
name: handsontable
version: 6.2.2
dependencies:
- moment: registry.npmmirror.com/moment/2.20.1
+ moment: 2.20.1
numbro: registry.npmmirror.com/numbro/2.4.0
pikaday: registry.npmmirror.com/pikaday/1.5.1
dev: false
@@ -15764,18 +15762,6 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
- registry.npmmirror.com/moment/2.20.1:
- resolution: {integrity: sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/moment/-/moment-2.20.1.tgz}
- name: moment
- version: 2.20.1
- dev: false
-
- registry.npmmirror.com/moment/2.29.4:
- resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/moment/-/moment-2.29.4.tgz}
- name: moment
- version: 2.29.4
- dev: false
-
registry.npmmirror.com/move-concurrently/1.0.1:
resolution: {integrity: sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/move-concurrently/-/move-concurrently-1.0.1.tgz}
name: move-concurrently
@@ -16752,7 +16738,7 @@ packages:
name: pikaday
version: 1.5.1
optionalDependencies:
- moment: 2.29.4
+ moment: 2.30.1
dev: false
registry.npmmirror.com/pinkie-promise/2.0.1:
@@ -18518,8 +18504,8 @@ 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
- moment: registry.npmmirror.com/moment/2.29.4
+ dayjs: 1.11.13
+ moment: 2.30.1
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
react: registry.npmmirror.com/react/18.2.0
diff --git a/src/chat/__tests__/__snapshots__/group.test.tsx.snap b/src/chat/__tests__/__snapshots__/group.test.tsx.snap
new file mode 100644
index 000000000..d4c026278
--- /dev/null
+++ b/src/chat/__tests__/__snapshots__/group.test.tsx.snap
@@ -0,0 +1,479 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Test Chat Group Match snapshot 1`] = `
+
+
+
+
+
+
+

+
+
+ 暂无对话
+
+
+
+
+
+
+
+`;
+
+exports[`Test Chat Group Match snapshot: addNew 1`] = `
+
+
+
+
+
+
+
+

+
+
+ 暂无对话
+
+
+
+
+
+
+
+`;
+
+exports[`Test Chat Group Match snapshot: expand 1`] = `
+
+
+
+
+
+
+

+
+
+ 暂无对话
+
+
+
+
+
+
+
+`;
+
+exports[`Test Chat Group Match snapshot: fullscreen 1`] = `
+
+
+
+
+
+
+

+
+
+ 暂无对话
+
+
+
+
+
+
+
+`;
+
+exports[`Test Chat Group Match snapshot: normal 1`] = `
+
+
+
+
+
+
+ 昨天
+
+
+
+
+ this is conversation 1
+
+
+
+
+
+
+
+ 今天
+
+
+
+
+ this is conversation 2
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Test Chat Group Match snapshot: openFloat false 1`] = `
+
+
+
+
+
+
+
+

+
+
+ 暂无对话
+
+
+
+
+
+
+
+`;
+
+exports[`Test Chat Group Match snapshot: select 1`] = `
+
+
+
+
+
+
+ 昨天
+
+
+
+
+ this is conversation 1
+
+
+
+
+
+
+
+ 今天
+
+
+
+
+ this is conversation 2
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/chat/__tests__/group.test.tsx b/src/chat/__tests__/group.test.tsx
new file mode 100644
index 000000000..1d5c1b876
--- /dev/null
+++ b/src/chat/__tests__/group.test.tsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import { act, cleanup, fireEvent, render } from '@testing-library/react';
+import dayjs from 'dayjs';
+import '@testing-library/jest-dom/extend-expect';
+
+import Group from '../group';
+
+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}
;
+});
+describe('Test Chat Group', () => {
+ beforeEach(() => {
+ cleanup();
+ });
+
+ it('Match snapshot', () => {
+ const conversation = generateConversation();
+ const onAdd = jest.fn();
+ expect(render().asFragment()).toMatchSnapshot();
+ expect(render().asFragment()).toMatchSnapshot('expand');
+ expect(render().asFragment()).toMatchSnapshot('fullscreen');
+ expect(render().asFragment()).toMatchSnapshot(
+ 'openFloat false'
+ );
+ expect(
+ render(
+
+ ).asFragment()
+ ).toMatchSnapshot('addNew');
+ expect(render().asFragment()).toMatchSnapshot('normal');
+ expect(
+ render(
+
+ ).asFragment()
+ ).toMatchSnapshot('select');
+ });
+ it('Should fullscreen', () => {
+ const { container } = render();
+
+ const ele = container.querySelector('.dtc-aigc__group');
+ expect(ele).toBeInTheDocument();
+ expect(ele?.className).toContain('dtc-aigc__group--fullscreen');
+ });
+
+ it('Should expand', () => {
+ const { container } = render();
+
+ const ele = container.querySelector('.dtc-aigc__group');
+ expect(ele).toBeInTheDocument();
+ expect(ele?.className).toContain('dtc-aigc__group--expand');
+ });
+
+ it('Should select item', () => {
+ const conversation = generateConversation();
+ const { container } = render(
+
+ );
+
+ const ele = container.querySelector('.dtc-aigc__dialog__list__item');
+ expect(ele).toBeInTheDocument();
+ expect(ele?.className).toContain('dtc-aigc__dialog__list__item--selected');
+ });
+
+ it('Should group list title', () => {
+ const { container } = render();
+
+ const ele = container.querySelector('.dtc-aigc__group__list__item__title');
+ expect(ele).toBeInTheDocument();
+ expect(ele).toHaveTextContent('昨天');
+ });
+
+ it('Should support add new session', () => {
+ const onAdd = jest.fn();
+ const { container } = render(
+
+ );
+
+ const btn = container.querySelector('.dtc__aigc__button');
+ expect(onAdd).not.toBeCalled();
+ expect(btn).not.toBeNull();
+
+ act(() => {
+ fireEvent.click(btn!);
+ });
+ expect(onAdd).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-aigc__dialog__list__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 rename input when rename menu click and do rename', () => {
+ const conversation = generateConversation();
+ const onRename = jest.fn();
+ 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(2);
+
+ fireEvent.click(dropdownMenuItems[0]);
+
+ const ele = container.querySelector('.dtc-aigc__dialog__list__item__input');
+ expect(ele).toBeInTheDocument();
+ expect(ele).toHaveAttribute('value', conversation[0].title);
+
+ fireEvent.change(ele!, { target: { value: 'new_title' } });
+ fireEvent.keyDown(ele!, { keyCode: 13 });
+ expect(onRename).toBeCalledWith(conversation[0], 'new_title');
+ });
+ test('Should render delete button', () => {
+ const conversation = generateConversation();
+ const onDelete = jest.fn();
+ 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(2);
+
+ fireEvent.click(dropdownMenuItems[1]);
+ expect(onDelete).toBeCalledWith(conversation[0]);
+ });
+});
diff --git a/src/chat/demos/basic.tsx b/src/chat/demos/basic.tsx
index b0e501ed0..7b6072683 100644
--- a/src/chat/demos/basic.tsx
+++ b/src/chat/demos/basic.tsx
@@ -4,6 +4,7 @@ import { Button } from 'antd';
import { Chat, Flex } from 'dt-react-component';
import { mockSSE } from './mockSSE';
+import './index.scss';
export default function () {
const chat = Chat.useChat();
@@ -38,7 +39,7 @@ export default function () {
}, []);
return (
-
+
('');
+ const [convert, setConvert] = useState(false);
+ const [data, setData] = useState([]);
+ const [expand, setIsExpand] = useState(true);
+
+ 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);
+ },
+ });
+ };
+
+ useEffect(() => {
+ chat.conversation.create({ id: new Date().valueOf().toString() });
+ }, []);
+
+ return (
+
+ }
+ components={{
+ a: ({ children }) => (
+
+ ),
+ }}
+ >
+
+
+
+
+
+ handleSubmit('请告诉我一首诗')}>
+ 返回一首诗
+
+ handleSubmit('生成 CodeBlock')}>
+ 生成 CodeBlock
+
+
+
+ }
+ />
+ handleSubmit()}
+ onPressShiftEnter={() => setValue((v) => v + '\n')}
+ button={{
+ disabled: chat.loading() || !value?.trim(),
+ }}
+ placeholder="请输入想咨询的内容…"
+ />
+
+
+
+ );
+}
diff --git a/src/chat/demos/global-state/index.tsx b/src/chat/demos/global-state/index.tsx
index bd2854f09..2d378a820 100644
--- a/src/chat/demos/global-state/index.tsx
+++ b/src/chat/demos/global-state/index.tsx
@@ -5,6 +5,7 @@ import { Conversation, Message, MessageStatus, Prompt } from 'dt-react-component
import { produce } from 'immer';
import { mockSSE } from '../mockSSE';
+import '../index.scss';
export default function () {
const [tabs, setTabs] = useState([{ label: 'Tab 1', children: 'Content of Tab 1', key: '1' }]);
@@ -127,7 +128,7 @@ function AI({ data, onSubmit }: { data?: Conversation; onSubmit?: (str?: string)
const [value, setValue] = useState
('');
return (
-
+
([]);
+ const [selectId, setSelectId] = React.useState('1');
+ const [expand, setIsExpand] = React.useState(true);
+
+ const handleSelectChat = (conversation: ConversationProperties) => {
+ setSelectId(conversation.id);
+ };
+
+ const handleRenameChat = (_conversation: ConversationProperties, _value: string) => {
+ return Promise.resolve(true);
+ };
+
+ const handleDeleteChat = (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(),
+ },
+ ];
+ });
+ };
+
+ return (
+
+
+
+ {props.children}
+
+
+ );
+}
diff --git a/src/chat/demos/index.scss b/src/chat/demos/index.scss
new file mode 100644
index 000000000..255f7712b
--- /dev/null
+++ b/src/chat/demos/index.scss
@@ -0,0 +1,6 @@
+.dtc-aigc__demo {
+ width: 100%;
+ height: 400px;
+ display: flex;
+ flex-direction: column;
+}
diff --git a/src/chat/entity.ts b/src/chat/entity.ts
index 0c55c960c..9245911cb 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/group/Item/index.scss b/src/chat/group/Item/index.scss
new file mode 100644
index 000000000..40b4918f0
--- /dev/null
+++ b/src/chat/group/Item/index.scss
@@ -0,0 +1,34 @@
+.dtc-aigc__dialog__list {
+ &__item {
+ line-height: 32px;
+ height: 32px;
+ padding: 0 16px;
+ border-radius: 4px;
+ margin-top: 4px;
+ color: #3D446E;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ &:hover {
+ cursor: pointer;
+ background-color: #EBECF0;
+ .dtstack-icon {
+ display: block;
+ }
+ }
+ .dtstack-icon {
+ display: none;
+ }
+ &--selected {
+ background-color: #E8F1FF;
+ color: #1D78FF;
+ &:hover {
+ background-color: #E8F1FF;
+ }
+ }
+ &__input {
+ height: 24px;
+ }
+ }
+}
diff --git a/src/chat/group/Item/index.tsx b/src/chat/group/Item/index.tsx
new file mode 100644
index 000000000..602902491
--- /dev/null
+++ b/src/chat/group/Item/index.tsx
@@ -0,0 +1,104 @@
+import React, { useState } from 'react';
+import { MoreOutlined } from '@dtinsight/react-icons';
+import { Dropdown, Input, Menu, message } from 'antd';
+import classNames from 'classnames';
+
+import EllipsisText from '../../../ellipsisText';
+import useLocale from '../../../locale/useLocale';
+import { ConversationProperties } from '../../entity';
+import './index.scss';
+
+type IItemProps = {
+ conversation: ConversationProperties;
+ selectId?: string;
+ onRename?: (conversation: ConversationProperties, value: string) => Promise;
+ onDelete?: (conversation: ConversationProperties) => void;
+ onItemClick?: (conversation: ConversationProperties) => void;
+};
+
+export default function Item({
+ conversation,
+ selectId,
+ onRename,
+ onDelete,
+ onItemClick,
+}: IItemProps) {
+ const locale = useLocale('Chat');
+ const [edit, setEdit] = useState(false);
+ const handleRename = async (value: string) => {
+ if (!value) {
+ setEdit(false);
+ message.error(locale.renameError);
+ return;
+ }
+ const res = await onRename?.(conversation, value);
+ if (res) {
+ setEdit(false);
+ }
+ };
+
+ const handleDelete = () => {
+ onDelete?.(conversation);
+ };
+ const handleSelect = (conversation: ConversationProperties) => {
+ if (conversation.id === selectId || edit) {
+ return;
+ }
+ onItemClick?.(conversation);
+ };
+
+ return (
+ handleSelect(conversation)}
+ >
+ {edit ? (
+ {
+ handleRename(target.value);
+ }}
+ onPressEnter={({ target, keyCode }) => {
+ if (keyCode === 13) {
+ handleRename((target as HTMLInputElement).value);
+ }
+ }}
+ />
+ ) : (
+ <>
+
+ e.domEvent.stopPropagation()}>
+ setEdit(true)}>
+ {locale.rename}
+
+
+ {locale.delete}
+
+
+ }
+ overlayStyle={{ minWidth: '80px' }}
+ >
+ e.stopPropagation()} />
+
+ >
+ )}
+
+ );
+}
diff --git a/src/chat/group/List/index.tsx b/src/chat/group/List/index.tsx
new file mode 100644
index 000000000..30e473ee6
--- /dev/null
+++ b/src/chat/group/List/index.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import { ConversationProperties } from '../../entity';
+import Item from '../Item';
+
+export interface IChatGroupListProps {
+ conversations?: ConversationProperties[];
+ className?: string;
+ selectId?: string;
+ renderItem?: (conversation: ConversationProperties) => React.ReactNode;
+ onItemClick?: (conversation: ConversationProperties) => void;
+ onRename?: (conversation: ConversationProperties, value: string) => Promise;
+ onDelete?: (conversation: ConversationProperties) => void;
+}
+export default function List(props: IChatGroupListProps) {
+ const { conversations, className, selectId, renderItem, onItemClick, onRename, onDelete } =
+ props;
+
+ return (
+
+ {conversations?.map((conversation) => {
+ if (renderItem) return renderItem(conversation);
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/chat/group/index.scss b/src/chat/group/index.scss
new file mode 100644
index 000000000..794b3ed42
--- /dev/null
+++ b/src/chat/group/index.scss
@@ -0,0 +1,110 @@
+.dtc-aigc__group {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ position: relative;
+ &--fullscreen {
+ .dtc-aigc__group__list {
+ background-color: #F9F9FA;
+ }
+ &.dtc-aigc__group--expand .dtc-aigc__group__list {
+ width: 240px;
+ padding: 8px 0;
+ }
+ }
+ &--expand {
+ .dtc-aigc__group__list {
+ transform: translateX(0);
+ padding: 8px 0;
+ width: 200px;
+ &--float {
+ height: fit-content;
+ transform: translateY(0);
+ opacity: 1;
+ }
+ }
+ }
+ &__list {
+ width: 0;
+ background-color: transparent;
+ border-right: 1px solid #EBECF0;
+ padding: 0;
+ transform: translateX(-210px);
+ transform-origin: 0 0;
+ transition: all 0.3s ease;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ &--hide {
+ display: none;
+ }
+ &--empty {
+ margin-top: 100%;
+ }
+ &--float {
+ position: absolute;
+ top: 2px;
+ left: 8px;
+ z-index: 10;
+ background-color: #FFF;
+ border-radius: 4px;
+ border: 1px solid #EBECF0;
+ box-shadow: 0 12px 20px 0 rgba(29, 120, 255, 0.1);
+ max-height: 400px;
+ transform: translateY(-10px);
+ height: 0;
+ width: 150px;
+ opacity: 0;
+ .dtc-aigc__group__list--empty {
+ margin-top: 0;
+ }
+ .dtc-aigc__dialog__list__item:hover {
+ .anticon-more {
+ display: none;
+ }
+ }
+ .dtc-aigc__group__list__item {
+ padding: 0 8px;
+ }
+ }
+ &__wrapper {
+ flex: 1;
+ overflow-y: auto;
+ }
+ &__item {
+ padding: 0 16px;
+ &__title {
+ color: #B1B4C5;
+ line-height: 20px;
+ font-size: 12px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+ .ant-btn.dtc__aigc__button {
+ margin: 16px;
+ gap: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+ &__content {
+ flex: 1;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ overflow: hidden;
+ }
+ .dtc__spin {
+ &__wrapper {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ margin: 40px 0;
+ }
+ }
+}
diff --git a/src/chat/group/index.tsx b/src/chat/group/index.tsx
new file mode 100644
index 000000000..cb63115ae
--- /dev/null
+++ b/src/chat/group/index.tsx
@@ -0,0 +1,224 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { ButtonProps, Spin } from 'antd';
+import classNames from 'classnames';
+import dayjs from 'dayjs';
+import shortid from 'shortid';
+
+import Empty from '../../empty';
+import useLocale from '../../locale/useLocale';
+import useMeasure from '../../useMeasure';
+import Button from '../button';
+import { ConversationProperties } from '../entity';
+import { AddDialogIcon } from '../icon';
+import Item from './Item';
+import List, { IChatGroupListProps } from './List';
+import './index.scss';
+
+const GROUP_FLOAT_WIDTH = 640;
+const EXPAND_KEY = 'data-expand';
+export type GroupProperties = {
+ id: string;
+ conversations?: ConversationProperties[];
+ title?: string;
+};
+
+type IConversationButtonProps = Omit;
+
+interface IGroupProps {
+ loading?: boolean;
+ children?: React.ReactNode;
+ fullscreen?: boolean;
+ expand?: boolean;
+ className?: string;
+ // 对话数据
+ data?: ConversationProperties[];
+ // 是否开启点击其他地方关闭
+ maskClose?: boolean;
+ // 添加对话按钮
+ conversationButton?: React.ReactNode;
+ // 添加对话按钮属性
+ conversationButtonProps?: React.HTMLAttributes & IConversationButtonProps;
+ // 列表属性
+ listProps?: IChatGroupListProps;
+ openFloat?: boolean;
+ onExpandChange?: (expand: boolean) => void;
+}
+function classifyDate(date?: string | Date | number) {
+ if (!date) return '';
+ const input = dayjs(date).startOf('day');
+ const now = dayjs().startOf('day');
+
+ const diffDays = now.diff(input, 'days');
+
+ if (diffDays < 1) {
+ return '今天';
+ } else if (diffDays < 2) {
+ return '昨天';
+ } else if (diffDays < 7) {
+ return '近7天';
+ } else if (diffDays < 15) {
+ return '近15天';
+ } else if (diffDays < 30) {
+ return '近30天';
+ } else {
+ return '其他';
+ }
+}
+function transform(data: ConversationProperties[]) {
+ return data.reduce((prev, current) => {
+ const group = prev.find((item) => item.title === classifyDate(current.updatedAt));
+
+ if (group) {
+ if (!group.conversations) {
+ group.conversations = [];
+ }
+ group.conversations.push(current);
+ } else {
+ prev.push({
+ id: `group_${shortid()}`,
+ title: classifyDate(current.updatedAt),
+ conversations: [current],
+ });
+ }
+ return prev;
+ }, [] as GroupProperties[]);
+}
+export default function Group(props: IGroupProps) {
+ const {
+ children,
+ fullscreen,
+ className,
+ data = [],
+ conversationButton,
+ conversationButtonProps,
+ expand,
+ maskClose = true,
+ listProps,
+ loading,
+ openFloat = true,
+ onExpandChange,
+ } = props;
+ const locale = useLocale('Chat');
+ const [ref, { width }] = useMeasure();
+
+ const listRef = useRef(null);
+ const isGroupFloat = useRef(false);
+ const isExpand = useRef(expand);
+ const isMaskClose = useRef(maskClose);
+ const [isHide, setIsHide] = useState(true);
+
+ // 浮动对话列表点击其他区域关闭
+ const listenerClick = useCallback((event: MouseEvent | TouchEvent) => {
+ if (!isMaskClose.current) return;
+ // 展开和关闭按钮
+ const dataExpand = (event.target as HTMLElement).closest(`[${EXPAND_KEY}]`);
+
+ if (
+ !listRef.current ||
+ listRef.current.contains(event.target as Node) ||
+ !isGroupFloat.current ||
+ !isExpand.current ||
+ dataExpand
+ ) {
+ return;
+ }
+ onExpandChange?.(false);
+ }, []);
+ useEffect(() => {
+ document.addEventListener('mousedown', listenerClick);
+ document.addEventListener('touchstart', listenerClick);
+ return () => {
+ document.removeEventListener('mousedown', listenerClick);
+ document.removeEventListener('touchstart', listenerClick);
+ };
+ }, []);
+
+ useEffect(() => {
+ isExpand.current = expand;
+ if (expand) {
+ setIsHide(false);
+ }
+ }, [expand]);
+
+ useEffect(() => {
+ isMaskClose.current = maskClose;
+ }, [maskClose]);
+
+ useEffect(() => {
+ isGroupFloat.current = width < GROUP_FLOAT_WIDTH && openFloat;
+ }, [width]);
+
+ const groups = useMemo(() => {
+ return transform(data);
+ }, [data]);
+
+ const renderGroups = (groups: GroupProperties[]) => {
+ return !groups.length ? (
+
+ ) : (
+ groups?.map((group) => {
+ if (!group.conversations?.length) return null;
+ return (
+
+
+ {group.title || ''}
+
+
+
+ );
+ })
+ );
+ };
+
+ return (
+
+
{
+ if (!expand) setIsHide(true);
+ }}
+ >
+ {!(width < GROUP_FLOAT_WIDTH && openFloat) &&
+ (conversationButton ?? (
+
+ }
+ {...conversationButtonProps}
+ >
+ {locale.createConversation}
+
+ ))}
+
+ {loading ? : renderGroups(groups)}
+
+
+
{children}
+
+ );
+}
+
+Group.List = List;
+Group.Item = Item;
+Group.ExpandKey = EXPAND_KEY;
diff --git a/src/chat/icon/index.tsx b/src/chat/icon/index.tsx
index 09e0b132b..70460219b 100644
--- a/src/chat/icon/index.tsx
+++ b/src/chat/icon/index.tsx
@@ -99,3 +99,55 @@ export const GradientDotIcon = ({ className, ...rest }: IconProps) => {
);
};
+export const AddDialogIcon = ({ className, ...rest }: IconProps) => {
+ return (
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/chat/index.$tab-group.md b/src/chat/index.$tab-group.md
new file mode 100644
index 000000000..b6e2663cf
--- /dev/null
+++ b/src/chat/index.$tab-group.md
@@ -0,0 +1,46 @@
+---
+title: Group
+group: 组件
+toc: content
+demo:
+ cols: 2
+---
+
+# Group
+
+## 何时使用
+
+Group 组件用于展示对话列表
+
+## 示例
+
+
+
+## API
+
+| 参数 | 说明 | 类型 | 默认值 |
+| ----------------------- | ------------------------ | ----------------------------------------------------------------- | ------ |
+| children | | `React.ReactNode` | - |
+| loading | | `boolean` | - |
+| fullscreen | | `boolean` | - |
+| expand | | `boolean` | - |
+| openFloat | 是否开启浮动对话列表 | `boolean` | - |
+| className | | `string` | - |
+| data | 对话数据 | `ConversationProperties[]` | - |
+| maskClose | 是否开启点击其他地方关闭 | `boolean` | - |
+| conversationButton | 添加对话按钮 | `React.ReactNode` | - |
+| conversationButtonProps | 添加对话按钮属性 | `React.HTMLAttributes & IConversationButtonProps` | - |
+| listProps | 列表属性 | [IChatGroupListProps](?tab=group#list) | - |
+| onExpandChange | | `(expand: boolean) => void` | - |
+
+## IChatGroupListProps
+
+| 参数 | 说明 | 类型 | 默认值 |
+| ------------- | -------- | ----------------------------------------------------------------------- | ------ |
+| conversations | 对话数据 | `ConversationProperties[]` | - |
+| className | | `string` | - |
+| selectId | | `string` | - |
+| renderItem | | `React.ReactNode` | - |
+| onItemClick | | `(conversation: ConversationProperties) => void` | - |
+| onRename | | `(conversation: ConversationProperties, value: string) => Promise` | - |
+| onDelete | | `(conversation: ConversationProperties) => void` | - |
diff --git a/src/chat/index.md b/src/chat/index.md
index 10b913ae5..bd5b669a8 100644
--- a/src/chat/index.md
+++ b/src/chat/index.md
@@ -21,6 +21,7 @@ Chat 规范由多个组件复合使用实现落地场景,其中:
- `Message` 组件是符合 AI 规范的回答框
- `Prompt` 组件是符合 AI 规范的提问框
- `Content` 组件是符合 AI 规范的正文内容
+- `Group` 组件是符合 AI 规范的对话列表组件
## 何时使用
@@ -30,5 +31,6 @@ Chat 规范由多个组件复合使用实现落地场景,其中:
+
## API
diff --git a/src/chat/index.tsx b/src/chat/index.tsx
index 2a57861f3..6d9a43943 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 Group from './group';
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.Group = Group;
export { type IContentRef } from './content';
export default Chat;
diff --git a/src/chat/input/index.scss b/src/chat/input/index.scss
index a1934271e..e71478ec6 100644
--- a/src/chat/input/index.scss
+++ b/src/chat/input/index.scss
@@ -14,7 +14,8 @@
font-style: normal;
font-weight: 400;
line-height: 20px;
- padding: 8px 36px 8px 16px;
+ padding: 8px 36px 8px 8px;
+ min-height: 100px;
&:focus {
box-shadow: none;
}
diff --git a/src/chat/input/index.tsx b/src/chat/input/index.tsx
index 4666adae8..9cc0978c8 100644
--- a/src/chat/input/index.tsx
+++ b/src/chat/input/index.tsx
@@ -34,6 +34,9 @@ export default function Input({
{
@@ -46,10 +49,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..7af88f7b7 100644
--- a/src/chat/markdown/index.tsx
+++ b/src/chat/markdown/index.tsx
@@ -49,6 +49,9 @@ export default memo(
hr() {
return
;
},
+ table({ children }) {
+ return ;
+ },
p: (data) => {
// avoid validateDOMNesting error for div as a descendant of p
if (data.node.children.every((child) => child.type === 'text')) {
@@ -71,7 +74,7 @@ export default memo(
includeElementIndex
{...rest}
>
- {children}
+ {ensureTag(children)}
);
},
@@ -85,3 +88,12 @@ export default memo(
return true;
}
);
+
+/**
+ * 确保 HTML 标签的前后都有两个换行符
+ */
+function ensureTag(children: string) {
+ if (typeof children !== 'string') return children;
+ const next = children.replace(/<\/?[a-z].[^<]*>/g, (str) => '\n\n' + str + '\n\n');
+ return next;
+}
diff --git a/src/chat/message/index.scss b/src/chat/message/index.scss
index 8e74318c4..e7db33695 100644
--- a/src/chat/message/index.scss
+++ b/src/chat/message/index.scss
@@ -3,6 +3,9 @@
display: flex;
gap: 4px;
position: relative;
+ &:has(+ .dtc__message__container):has(.dtc__message__iconGroup:not(:empty)) {
+ margin-bottom: 8px;
+ }
&:hover {
.dtc__message__iconGroup {
opacity: 1;
diff --git a/src/chat/message/index.tsx b/src/chat/message/index.tsx
index 740f76444..a0137b65c 100644
--- a/src/chat/message/index.tsx
+++ b/src/chat/message/index.tsx
@@ -10,6 +10,7 @@ import { Button, Space, Tooltip } from 'antd';
import classNames from 'classnames';
import Copy from '../../copy';
+import useLocale from '../../locale/useLocale';
import useIntersectionObserver from '../../useIntersectionObserver';
import { Message as MessageEntity, MessageStatus, Prompt as PromptEntity } from '../entity';
import Loading from '../loading';
@@ -43,6 +44,7 @@ export default function Message({
onStop,
onLazyRendered,
}: IMessageProps) {
+ const locale = useLocale('Chat');
const divRef = useIntersectionObserver(handleObserverCb);
const { components = {}, messageIcons, codeBlock, rehypePlugins, remarkPlugins } = useContext();
@@ -160,7 +162,7 @@ export default function Message({
onChange={(cur) => setCurrent(cur)}
/>
)}
- {stopped && 回答已停止}
+ {stopped && {locale.stoped}}
{(typing || loading) && (
@@ -171,7 +173,7 @@ export default function Message({
onClick={() => onStop?.(record)}
icon={
}
>
- 停止回答
+ {locale.stop}
)}
@@ -184,7 +186,7 @@ export default function Message({
: messageIcons}
{regenerate && (
node.parentNode as HTMLElement}
>
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 d5e90024b..f586df760 100644
--- a/src/locale/en-US.ts
+++ b/src/locale/en-US.ts
@@ -11,9 +11,20 @@ const localeValues: Locale = {
inputPlaceholder: `Please enter`,
},
Chat: {
- stopped: 'Answer Stopped',
- stop: 'Stop Answering',
+ stoped: 'Answer stopped',
+ stop: 'Stop answering',
regenerate: 'Regenerate',
+ conversationEmpty: 'No conversation',
+ createConversation: 'Start new conversation',
+ rename: 'Rename',
+ delete: 'Delete',
+ renameError: 'Please enter conversation name',
+ 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 e4681d6e5..7f9a45277 100644
--- a/src/locale/useLocale.tsx
+++ b/src/locale/useLocale.tsx
@@ -7,9 +7,20 @@ export interface Locale {
BlockHeader: { expand: string; collapse: string };
Catalogue: { searchPlaceholder: string; inputPlaceholder: string };
Chat: {
- stopped: string;
+ stoped: string;
stop: string;
regenerate: string;
+ conversationEmpty: string;
+ createConversation: string;
+ rename: string;
+ delete: string;
+ renameError: 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 418ac6331..ac7971d3d 100644
--- a/src/locale/zh-CN.ts
+++ b/src/locale/zh-CN.ts
@@ -11,9 +11,20 @@ const localeValues: Locale = {
inputPlaceholder: `请输入`,
},
Chat: {
- stopped: '回答已停止',
+ stoped: '回答已停止',
stop: '停止回答',
regenerate: '重新生成',
+ conversationEmpty: '暂无对话',
+ createConversation: '开启新对话',
+ rename: '重命名',
+ delete: '删除',
+ renameError: '请输入对话名称',
+ today: '今天',
+ yesterday: '昨天',
+ recent7Days: '近7天',
+ recent15Days: '近15天',
+ recent30Days: '近30天',
+ other: '其他',
},
Copy: {
copied: '复制成功',