From ab28ebb7a5e9fe8cd7469f5e6ae56a6785401cb3 Mon Sep 17 00:00:00 2001 From: zwight Date: Mon, 11 Aug 2025 15:52:23 +0800 Subject: [PATCH 1/6] feat: support chart group --- package.json | 1 + pnpm-lock.yaml | 32 ++-- src/chat/content/index.tsx | 5 +- src/chat/demos/basic.tsx | 2 +- src/chat/demos/global-state/group.tsx | 161 +++++++++++++++++++ src/chat/demos/global-state/index.tsx | 2 +- src/chat/demos/group.tsx | 67 ++++++++ src/chat/entity.ts | 3 + src/chat/group/Item/index.scss | 31 ++++ src/chat/group/Item/index.tsx | 105 +++++++++++++ src/chat/group/List/index.tsx | 37 +++++ src/chat/group/index.scss | 110 +++++++++++++ src/chat/group/index.tsx | 216 ++++++++++++++++++++++++++ src/chat/icon/index.tsx | 52 +++++++ src/chat/index.$tab-group.md | 46 ++++++ src/chat/index.md | 2 + src/chat/index.tsx | 2 + src/chat/input/index.scss | 3 +- src/chat/input/index.tsx | 7 +- src/chat/markdown/index.scss | 27 ++++ src/chat/markdown/index.tsx | 14 +- src/chat/message/index.scss | 3 + src/chat/useChat.ts | 25 +-- 23 files changed, 909 insertions(+), 44 deletions(-) create mode 100644 src/chat/demos/global-state/group.tsx create mode 100644 src/chat/demos/group.tsx create mode 100644 src/chat/group/Item/index.scss create mode 100644 src/chat/group/Item/index.tsx create mode 100644 src/chat/group/List/index.tsx create mode 100644 src/chat/group/index.scss create mode 100644 src/chat/group/index.tsx create mode 100644 src/chat/index.$tab-group.md diff --git a/package.json b/package.json index 890d0dfb3..50a692504 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "highlight.js": "^10.5.0", "immer": "~10.1.1", "lodash-es": "^4.17.21", + "moment": "^2.30.1", "rc-drawer": "~5.1.0", "rc-virtual-list": "^3.4.13", "react-draggable": "~4.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57da62ed5..246b87363 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,7 @@ specifiers: ko-lint-config: 2.2.21 lint-staged: ^13.0.3 lodash-es: ^4.17.21 + moment: ^2.30.1 prettier: ^2.7.1 rc-drawer: ~5.1.0 rc-motion: 2.6.2 @@ -70,6 +71,7 @@ dependencies: highlight.js: registry.npmmirror.com/highlight.js/10.7.3 immer: 10.1.1 lodash-es: 4.17.21 + moment: 2.30.1 rc-drawer: registry.npmmirror.com/rc-drawer/5.1.0_react-dom@18.2.0+react@18.2.0 rc-virtual-list: registry.npmmirror.com/rc-virtual-list/3.11.2_react-dom@18.2.0+react@18.2.0 react-draggable: 4.4.6_react-dom@18.2.0+react@18.2.0 @@ -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 @@ -12217,7 +12221,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 +15768,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 +16744,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: @@ -18519,7 +18511,7 @@ packages: 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 + 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/content/index.tsx b/src/chat/content/index.tsx index 685b5a1c3..75033a842 100644 --- a/src/chat/content/index.tsx +++ b/src/chat/content/index.tsx @@ -51,10 +51,7 @@ const Content = forwardRef(function ( }; const dataValid = !!(Array.isArray(data) && data.length); - const lastPrompt = data[data.length - 1]; - const lastMessage = dataValid - ? lastPrompt.messages?.[lastPrompt.messages.length - 1] - : undefined; + const lastMessage = dataValid ? data.at(-1)?.messages?.at(-1) : undefined; useLayoutEffect(() => { const handleScroll = () => { diff --git a/src/chat/demos/basic.tsx b/src/chat/demos/basic.tsx index b0e501ed0..8c1fca2c5 100644 --- a/src/chat/demos/basic.tsx +++ b/src/chat/demos/basic.tsx @@ -38,7 +38,7 @@ export default function () { }, []); return ( -
+
(''); + + const [convert, setConvert] = useState(false); + + const [data, setData] = React.useState([]); + + const [selectId, setSelectId] = React.useState('1'); + const [expand, setIsExpand] = React.useState(true); + + const handleSelectChat = (conversation: ConversationProperties) => { + setSelectId(conversation.id); + }; + + const handleRename = (_conversation: ConversationProperties, _value: string) => { + return Promise.resolve(true); + }; + + const handleClearChat = (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 handleNewChat = () => { + 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..289006715 100644 --- a/src/chat/demos/global-state/index.tsx +++ b/src/chat/demos/global-state/index.tsx @@ -127,7 +127,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 handleRename = (_conversation: ConversationProperties, _value: string) => { + 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(), + }, + ]; + }); + }; + + return ( +
+ + + {props.children} + +
+ ); +} 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..e22802943 --- /dev/null +++ b/src/chat/group/Item/index.scss @@ -0,0 +1,31 @@ +.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; + .anticon-more { + display: block; + } + } + .anticon-more { + display: none; + } + &.dtc-aigc-dialog-list-item__selected { + background-color: #E8F1FF; + color: #1D78FF; + } + &-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..4f4b28e1e --- /dev/null +++ b/src/chat/group/Item/index.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { MoreOutlined } from '@ant-design/icons'; +import { Dropdown, Input, Menu, message } from 'antd'; +import classNames from 'classnames'; + +import EllipsisText from '../../../ellipsisText'; +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 [edit, setEdit] = useState(false); + const handleRename = async (value: string) => { + if (!value) { + setEdit(false); + message.error('请输入对话名称'); + 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 }) => { + // 参考:https://dtstack.yuque.com/rd-center/tqk74v/wydclfzpf96ib8cp + if (keyCode === 13) { + handleRename((target as HTMLInputElement).value); + } + }} + /> + ) : ( + <> + + e.domEvent.stopPropagation()}> + setEdit(true)}> + 重命名 + + + 删除 + + + } + 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..3a881210e --- /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 IListProps { + 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: IListProps) { + 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..5c3acb0e8 --- /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..00f6a3e23 --- /dev/null +++ b/src/chat/group/index.tsx @@ -0,0 +1,216 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ButtonProps, Spin } from 'antd'; +import classNames from 'classnames'; +import moment from 'moment'; +import shortid from 'shortid'; + +import Empty from '../../empty'; +import useMeasure from '../../useMeasure'; +import Button from '../button'; +import { ConversationProperties } from '../entity'; +import { AddDialogIcon } from '../icon'; +import Item from './Item'; +import List, { IListProps } 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 IDialogButtonProps = Omit; + +interface IGroupProps { + loading?: boolean; + children?: React.ReactNode; + fullscreen?: boolean; + expand?: boolean; + className?: string; + // 对话数据 + data?: ConversationProperties[]; + // 是否开启点击其他地方关闭 + maskClose?: boolean; + // 添加对话按钮 + dialogButton?: React.ReactNode; + // 添加对话按钮属性 + dialogButtonProps?: React.HTMLAttributes & IDialogButtonProps; + // 列表属性 + listProps?: IListProps; + openFloat?: boolean; + onExpandChange?: (expand: boolean) => void; +} +function classifyDate(date?: string | Date | number | moment.Moment) { + if (!date) return ''; + const input = moment(date).startOf('day'); + const now = moment().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 = [], + dialogButton, + dialogButtonProps, + expand, + maskClose = true, + listProps, + loading, + openFloat = true, + onExpandChange, + } = props; + 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) && + (dialogButton ?? ( + + ))} +
+ {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..b6c2a5425 --- /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` | - | +| dialogButton | 添加对话按钮 | `React.ReactNode` | - | +| dialogButtonProps | 添加对话按钮属性 | `React.HTMLAttributes & IDialogButtonProps` | - | +| listProps | 列表属性 | [IListProps](?tab=group#list) | - | +| onExpandChange | | `(expand: boolean) => void` | - | + +## List + +| 参数 | 说明 | 类型 | 默认值 | +| ------------- | -------- | ----------------------------------------------------------------------- | ------ | +| 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/useChat.ts b/src/chat/useChat.ts index ee0d8fc5c..e6558e621 100644 --- a/src/chat/useChat.ts +++ b/src/chat/useChat.ts @@ -10,7 +10,6 @@ import { MessageProperties, MessageStatus, Prompt, - PromptProperties, } from './entity'; class BaseConversation extends Conversation {} @@ -117,10 +116,15 @@ export default function useChat< } function _updatePrompt(promptId: Id, predicate: (prompt: Prompt) => 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) => { @@ -208,19 +212,18 @@ export default function useChat< // ================================== Global ================================== function _isProcessing() { - const lastPrompt = state.current?.prompts?.[state.current?.prompts.length - 1]; - const last = lastPrompt?.messages?.[lastPrompt.messages.length - 1]; + const last = state.current?.prompts.at(-1)?.messages?.at(-1); if (!last) return false; return last.status === MessageStatus.PENDING || last.status === MessageStatus.GENERATING; } async function _saveViewState() { - const lastPrompt = state.current?.prompts?.[state.current?.prompts.length - 1]; - const message = lastPrompt?.messages?.[lastPrompt.messages.length - 1]; + const prompt = state.current?.prompts.at(-1); + const message = prompt?.messages?.at(-1); if (message?.status === MessageStatus.GENERATING) { await typing.close(true); if (closing.current) { - _updateMessage(lastPrompt!.id, message.id, { status: MessageStatus.DONE }); + _updateMessage(prompt!.id, message.id, { status: MessageStatus.DONE }); } } else { typing.stop(); @@ -233,10 +236,8 @@ export default function useChat< closing.current = false; if (_isProcessing()) { const conversation = _getConversation(); - const prompt = conversation?.prompts?.[conversation?.prompts.length - 1]; - const message = prompt?.messages?.[prompt.messages.length - 1]; - // 理论上这里不会出现没有 prompt 或 message 的情况 - /* istanbul ignore next */ + const prompt = conversation?.prompts.at(-1); + const message = prompt?.messages.at(-1); if (!prompt || !message) return state.current; typing.start(message.content); typingIds.current = { promptId: prompt.id, messageId: message.id }; From 4ccf565ac5ea58bf7884bb02f9754606db89afb4 Mon Sep 17 00:00:00 2001 From: zwight Date: Mon, 11 Aug 2025 19:01:36 +0800 Subject: [PATCH 2/6] feat: add test for chat group --- .../__snapshots__/group.test.tsx.snap | 479 ++++++++++++++++++ src/chat/__tests__/group.test.tsx | 190 +++++++ src/chat/demos/global-state/group.tsx | 4 +- src/chat/group/Item/index.tsx | 3 +- 4 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 src/chat/__tests__/__snapshots__/group.test.tsx.snap create mode 100644 src/chat/__tests__/group.test.tsx 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..9c972ee67 --- /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..aca3aa042 --- /dev/null +++ b/src/chat/__tests__/group.test.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import moment from 'moment'; +import '@testing-library/jest-dom/extend-expect'; + +import Group from '../group'; + +function generateConversation() { + const conversation = [ + { + id: 'conversation_1', + createdAt: 1736479532239, + updatedAt: moment().subtract(1, 'day').toDate().getTime(), + title: 'this is conversation 1', + assistantId: 'assistant_1', + }, + { + id: 'conversation_2', + createdAt: 1736479532239, + updatedAt: moment().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/global-state/group.tsx b/src/chat/demos/global-state/group.tsx index d42da7af8..315bac4b3 100644 --- a/src/chat/demos/global-state/group.tsx +++ b/src/chat/demos/global-state/group.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { LikeOutlined } from '@ant-design/icons'; +import { ThumbsUpOutlined } from '@dtinsight/react-icons'; import { Button } from 'antd'; import { Chat, Flex } from 'dt-react-component'; import { cloneDeep } from 'lodash-es'; @@ -93,7 +93,7 @@ export default function () { codeBlock={{ convert, }} - messageIcons={() => } + messageIcons={() => } components={{ a: ({ children }) => (
@@ -152,16 +152,16 @@ exports[`Test Chat Group Match snapshot: addNew 1`] = ` exports[`Test Chat Group Match snapshot: expand 1`] = `
@@ -188,16 +188,16 @@ exports[`Test Chat Group Match snapshot: expand 1`] = ` exports[`Test Chat Group Match snapshot: fullscreen 1`] = `
@@ -224,27 +224,27 @@ exports[`Test Chat Group Match snapshot: fullscreen 1`] = ` exports[`Test Chat Group Match snapshot: normal 1`] = `
昨天
今天
@@ -296,17 +296,17 @@ exports[`Test Chat Group Match snapshot: normal 1`] = ` exports[`Test Chat Group Match snapshot: openFloat false 1`] = `
@@ -409,27 +409,27 @@ exports[`Test Chat Group Match snapshot: openFloat false 1`] = ` exports[`Test Chat Group Match snapshot: select 1`] = `
昨天
今天
diff --git a/src/chat/__tests__/group.test.tsx b/src/chat/__tests__/group.test.tsx index aca3aa042..1d5c1b876 100644 --- a/src/chat/__tests__/group.test.tsx +++ b/src/chat/__tests__/group.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { act, cleanup, fireEvent, render } from '@testing-library/react'; -import moment from 'moment'; +import dayjs from 'dayjs'; import '@testing-library/jest-dom/extend-expect'; import Group from '../group'; @@ -10,14 +10,14 @@ function generateConversation() { { id: 'conversation_1', createdAt: 1736479532239, - updatedAt: moment().subtract(1, 'day').toDate().getTime(), + updatedAt: dayjs().subtract(1, 'day').toDate().getTime(), title: 'this is conversation 1', assistantId: 'assistant_1', }, { id: 'conversation_2', createdAt: 1736479532239, - updatedAt: moment().toDate().getTime(), + updatedAt: dayjs().toDate().getTime(), title: 'this is conversation 2', assistantId: 'assistant_2', }, @@ -43,7 +43,7 @@ describe('Test Chat Group', () => { ); expect( render( - + ).asFragment() ).toMatchSnapshot('addNew'); expect(render().asFragment()).toMatchSnapshot('normal'); @@ -56,17 +56,17 @@ describe('Test Chat Group', () => { it('Should fullscreen', () => { const { container } = render(); - const ele = container.querySelector('.dtc-aigc-group'); + const ele = container.querySelector('.dtc-aigc__group'); expect(ele).toBeInTheDocument(); - expect(ele?.className).toContain('dtc-aigc-group__fullscreen'); + expect(ele?.className).toContain('dtc-aigc__group--fullscreen'); }); it('Should expand', () => { const { container } = render(); - const ele = container.querySelector('.dtc-aigc-group'); + const ele = container.querySelector('.dtc-aigc__group'); expect(ele).toBeInTheDocument(); - expect(ele?.className).toContain('dtc-aigc-group__expand'); + expect(ele?.className).toContain('dtc-aigc__group--expand'); }); it('Should select item', () => { @@ -75,15 +75,15 @@ describe('Test Chat Group', () => { ); - const ele = container.querySelector('.dtc-aigc-dialog-list-item'); + const ele = container.querySelector('.dtc-aigc__dialog__list__item'); expect(ele).toBeInTheDocument(); - expect(ele?.className).toContain('dtc-aigc-dialog-list-item__selected'); + 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'); + const ele = container.querySelector('.dtc-aigc__group__list__item__title'); expect(ele).toBeInTheDocument(); expect(ele).toHaveTextContent('昨天'); }); @@ -91,7 +91,7 @@ describe('Test Chat Group', () => { it('Should support add new session', () => { const onAdd = jest.fn(); const { container } = render( - + ); const btn = container.querySelector('.dtc__aigc__button'); @@ -114,7 +114,9 @@ describe('Test Chat Group', () => { ); - const nodeList = container.querySelectorAll('.dtc-aigc-dialog-list-item'); + const nodeList = container.querySelectorAll( + '.dtc-aigc__dialog__list__item' + ); const ele = nodeList?.item(nodeList?.length - 1); expect(onItemClick).not.toBeCalled(); @@ -151,7 +153,7 @@ describe('Test Chat Group', () => { fireEvent.click(dropdownMenuItems[0]); - const ele = container.querySelector('.dtc-aigc-dialog-list-item-input'); + const ele = container.querySelector('.dtc-aigc__dialog__list__item__input'); expect(ele).toBeInTheDocument(); expect(ele).toHaveAttribute('value', conversation[0].title); diff --git a/src/chat/demos/basic.tsx b/src/chat/demos/basic.tsx index 8c1fca2c5..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] = React.useState([]); - - const [selectId, setSelectId] = React.useState('1'); - const [expand, setIsExpand] = React.useState(true); + const [data, setData] = useState([]); + const [expand, setIsExpand] = useState(true); const handleSelectChat = (conversation: ConversationProperties) => { - setSelectId(conversation.id); + chat.conversation.remove(); + chat.conversation.create({ ...conversation }); }; - const handleRename = (_conversation: ConversationProperties, _value: string) => { + const handleRenameChat = (_conversation: ConversationProperties, _value: string) => { return Promise.resolve(true); }; - const handleClearChat = (conversation: ConversationProperties) => { + const handleDeleteChat = (conversation: ConversationProperties) => { const list = cloneDeep(data).filter((i) => i.id !== conversation.id); if (conversation.id === chat.conversation.get()?.id) { chat.conversation.remove(); @@ -37,7 +35,7 @@ export default function () { } setData(list); }; - const handleNewChat = () => { + const handleCreateChat = () => { chat.conversation.remove(); chat.conversation.create({ id: new Date().valueOf().toString() }); }; @@ -87,7 +85,7 @@ export default function () { }, []); return ( -
+
diff --git a/src/chat/demos/global-state/index.tsx b/src/chat/demos/global-state/index.tsx index 289006715..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); @@ -15,11 +13,11 @@ export default function (props: { children: React.ReactNode }) { setSelectId(conversation.id); }; - const handleRename = (_conversation: ConversationProperties, _value: string) => { + const handleRenameChat = (_conversation: ConversationProperties, _value: string) => { return Promise.resolve(true); }; - const handleClearChat = (conversation: ConversationProperties) => { + const handleDeleteChat = (conversation: ConversationProperties) => { console.log(conversation); }; const handleNewChat = () => { @@ -51,11 +49,11 @@ export default function (props: { children: React.ReactNode }) { openFloat={false} listProps={{ selectId, - onRename: handleRename, - onDelete: handleClearChat, + onRename: handleRenameChat, + onDelete: handleDeleteChat, onItemClick: handleSelectChat, }} - dialogButtonProps={{ + conversationButtonProps={{ onClick: handleNewChat, }} onExpandChange={setIsExpand} 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/group/Item/index.scss b/src/chat/group/Item/index.scss index e22802943..23f073d67 100644 --- a/src/chat/group/Item/index.scss +++ b/src/chat/group/Item/index.scss @@ -1,5 +1,5 @@ -.dtc-aigc-dialog-list { - &-item { +.dtc-aigc__dialog__list { + &__item { line-height: 32px; height: 32px; padding: 0 16px; @@ -20,11 +20,11 @@ .anticon-more { display: none; } - &.dtc-aigc-dialog-list-item__selected { + &--selected { background-color: #E8F1FF; color: #1D78FF; } - &-input { + &__input { height: 24px; } } diff --git a/src/chat/group/Item/index.tsx b/src/chat/group/Item/index.tsx index 4da4001f0..602902491 100644 --- a/src/chat/group/Item/index.tsx +++ b/src/chat/group/Item/index.tsx @@ -4,6 +4,7 @@ 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'; @@ -22,11 +23,12 @@ export default function Item({ onDelete, onItemClick, }: IItemProps) { + const locale = useLocale('Chat'); const [edit, setEdit] = useState(false); const handleRename = async (value: string) => { if (!value) { setEdit(false); - message.error('请输入对话名称'); + message.error(locale.renameError); return; } const res = await onRename?.(conversation, value); @@ -48,24 +50,21 @@ export default function Item({ return (
handleSelect(conversation)} > {edit ? ( { handleRename(target.value); }} onPressEnter={({ target, keyCode }) => { - // 参考:https://dtstack.yuque.com/rd-center/tqk74v/wydclfzpf96ib8cp if (keyCode === 13) { handleRename((target as HTMLInputElement).value); } @@ -87,10 +86,10 @@ export default function Item({ overlay={ e.domEvent.stopPropagation()}> setEdit(true)}> - 重命名 + {locale.rename} - 删除 + {locale.delete} } diff --git a/src/chat/group/List/index.tsx b/src/chat/group/List/index.tsx index 3a881210e..30e473ee6 100644 --- a/src/chat/group/List/index.tsx +++ b/src/chat/group/List/index.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import { ConversationProperties } from '../../entity'; import Item from '../Item'; -export interface IListProps { +export interface IChatGroupListProps { conversations?: ConversationProperties[]; className?: string; selectId?: string; @@ -13,12 +13,12 @@ export interface IListProps { onRename?: (conversation: ConversationProperties, value: string) => Promise; onDelete?: (conversation: ConversationProperties) => void; } -export default function List(props: IListProps) { +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 index 5c3acb0e8..794b3ed42 100644 --- a/src/chat/group/index.scss +++ b/src/chat/group/index.scss @@ -1,30 +1,30 @@ -.dtc-aigc-group { +.dtc-aigc__group { display: flex; width: 100%; height: 100%; position: relative; - &__fullscreen { - .dtc-aigc-group-list { + &--fullscreen { + .dtc-aigc__group__list { background-color: #F9F9FA; } - &.dtc-aigc-group__expand .dtc-aigc-group-list { + &.dtc-aigc__group--expand .dtc-aigc__group__list { width: 240px; padding: 8px 0; } } - &__expand { - .dtc-aigc-group-list { + &--expand { + .dtc-aigc__group__list { transform: translateX(0); padding: 8px 0; width: 200px; - &__float { + &--float { height: fit-content; transform: translateY(0); opacity: 1; } } } - &-list { + &__list { width: 0; background-color: transparent; border-right: 1px solid #EBECF0; @@ -35,13 +35,13 @@ position: relative; display: flex; flex-direction: column; - &__hide { + &--hide { display: none; } - &__empty { + &--empty { margin-top: 100%; } - &__float { + &--float { position: absolute; top: 2px; left: 8px; @@ -55,25 +55,25 @@ height: 0; width: 150px; opacity: 0; - .dtc-aigc-group-list__empty { + .dtc-aigc__group__list--empty { margin-top: 0; } - .dtc-aigc-dialog-list-item:hover { + .dtc-aigc__dialog__list__item:hover { .anticon-more { display: none; } } - .dtc-aigc-group-list-item { + .dtc-aigc__group__list__item { padding: 0 8px; } } - &-wrapper { + &__wrapper { flex: 1; overflow-y: auto; } - &-item { + &__item { padding: 0 16px; - &-title { + &__title { color: #B1B4C5; line-height: 20px; font-size: 12px; @@ -90,7 +90,7 @@ justify-content: center; } } - &-content { + &__content { flex: 1; width: 100%; height: 100%; @@ -99,8 +99,8 @@ align-items: center; overflow: hidden; } - .dtc-spin { - &-wrapper { + .dtc__spin { + &__wrapper { width: 100%; display: flex; justify-content: center; diff --git a/src/chat/group/index.tsx b/src/chat/group/index.tsx index 00f6a3e23..cb63115ae 100644 --- a/src/chat/group/index.tsx +++ b/src/chat/group/index.tsx @@ -1,16 +1,17 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ButtonProps, Spin } from 'antd'; import classNames from 'classnames'; -import moment from 'moment'; +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, { IListProps } from './List'; +import List, { IChatGroupListProps } from './List'; import './index.scss'; const GROUP_FLOAT_WIDTH = 640; @@ -21,7 +22,7 @@ export type GroupProperties = { title?: string; }; -type IDialogButtonProps = Omit; +type IConversationButtonProps = Omit; interface IGroupProps { loading?: boolean; @@ -34,18 +35,18 @@ interface IGroupProps { // 是否开启点击其他地方关闭 maskClose?: boolean; // 添加对话按钮 - dialogButton?: React.ReactNode; + conversationButton?: React.ReactNode; // 添加对话按钮属性 - dialogButtonProps?: React.HTMLAttributes & IDialogButtonProps; + conversationButtonProps?: React.HTMLAttributes & IConversationButtonProps; // 列表属性 - listProps?: IListProps; + listProps?: IChatGroupListProps; openFloat?: boolean; onExpandChange?: (expand: boolean) => void; } -function classifyDate(date?: string | Date | number | moment.Moment) { +function classifyDate(date?: string | Date | number) { if (!date) return ''; - const input = moment(date).startOf('day'); - const now = moment().startOf('day'); + const input = dayjs(date).startOf('day'); + const now = dayjs().startOf('day'); const diffDays = now.diff(input, 'days'); @@ -88,8 +89,8 @@ export default function Group(props: IGroupProps) { fullscreen, className, data = [], - dialogButton, - dialogButtonProps, + conversationButton, + conversationButtonProps, expand, maskClose = true, listProps, @@ -97,6 +98,7 @@ export default function Group(props: IGroupProps) { openFloat = true, onExpandChange, } = props; + const locale = useLocale('Chat'); const [ref, { width }] = useMeasure(); const listRef = useRef(null); @@ -152,13 +154,19 @@ export default function Group(props: IGroupProps) { const renderGroups = (groups: GroupProperties[]) => { return !groups.length ? ( - + ) : ( groups?.map((group) => { if (!group.conversations?.length) return null; return ( -
-
{group.title || ''}
+
+
+ {group.title || ''} +
); @@ -170,43 +178,43 @@ export default function Group(props: IGroupProps) {
{ if (!expand) setIsHide(true); }} > {!(width < GROUP_FLOAT_WIDTH && openFloat) && - (dialogButton ?? ( + (conversationButton ?? ( ))} -
- {loading ? : renderGroups(groups)} +
+ {loading ? : renderGroups(groups)}
-
{children}
+
{children}
); } diff --git a/src/chat/index.$tab-group.md b/src/chat/index.$tab-group.md index b6c2a5425..b6e2663cf 100644 --- a/src/chat/index.$tab-group.md +++ b/src/chat/index.$tab-group.md @@ -18,22 +18,22 @@ Group 组件用于展示对话列表 ## API -| 参数 | 说明 | 类型 | 默认值 | -| ----------------- | ------------------------ | ----------------------------------------------------------- | ------ | -| children | | `React.ReactNode` | - | -| loading | | `boolean` | - | -| fullscreen | | `boolean` | - | -| expand | | `boolean` | - | -| openFloat | 是否开启浮动对话列表 | `boolean` | - | -| className | | `string` | - | -| data | 对话数据 | `ConversationProperties[]` | - | -| maskClose | 是否开启点击其他地方关闭 | `boolean` | - | -| dialogButton | 添加对话按钮 | `React.ReactNode` | - | -| dialogButtonProps | 添加对话按钮属性 | `React.HTMLAttributes & IDialogButtonProps` | - | -| listProps | 列表属性 | [IListProps](?tab=group#list) | - | -| onExpandChange | | `(expand: boolean) => void` | - | - -## List +| 参数 | 说明 | 类型 | 默认值 | +| ----------------------- | ------------------------ | ----------------------------------------------------------------- | ------ | +| 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 | 参数 | 说明 | 类型 | 默认值 | | ------------- | -------- | ----------------------------------------------------------------------- | ------ | 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} > Date: Wed, 13 Aug 2025 09:57:56 +0800 Subject: [PATCH 5/6] fix: group demo add rename func --- src/chat/demos/global-state/group.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/chat/demos/global-state/group.tsx b/src/chat/demos/global-state/group.tsx index 46b403d28..79d969920 100644 --- a/src/chat/demos/global-state/group.tsx +++ b/src/chat/demos/global-state/group.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { ThumbsUpOutlined } from '@dtinsight/react-icons'; import { Button } from 'antd'; import { Chat, Flex } from 'dt-react-component'; +import { produce } from 'immer'; import { cloneDeep } from 'lodash-es'; import { ConversationProperties } from '../../entity'; @@ -21,6 +22,13 @@ export default function () { }; 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); }; From 2898e5bd26dd97325f7c8bea39edd446dc146ea3 Mon Sep 17 00:00:00 2001 From: zwight Date: Wed, 13 Aug 2025 10:04:53 +0800 Subject: [PATCH 6/6] fix: update conversation item style --- src/chat/group/Item/index.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/chat/group/Item/index.scss b/src/chat/group/Item/index.scss index 23f073d67..40b4918f0 100644 --- a/src/chat/group/Item/index.scss +++ b/src/chat/group/Item/index.scss @@ -13,16 +13,19 @@ &:hover { cursor: pointer; background-color: #EBECF0; - .anticon-more { + .dtstack-icon { display: block; } } - .anticon-more { + .dtstack-icon { display: none; } &--selected { background-color: #E8F1FF; color: #1D78FF; + &:hover { + background-color: #E8F1FF; + } } &__input { height: 24px;