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 ?? ( + + ))} +
+ {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 {children}
; + }, 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: '复制成功',