diff --git a/packages/pro-components/chat/chat-thought-chain/__tests__/__snapshots__/index.test.jsx.snap b/packages/pro-components/chat/chat-thought-chain/__tests__/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..db349b1573 --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/__tests__/__snapshots__/index.test.jsx.snap @@ -0,0 +1,185 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ChatThoughtChain > :props > :items - array 1`] = ` +
+ +
+
+ + + + + + + + + + +
+
+
+ + 分析问题 + + + + + + +
+ +
+
+
+
+ + + + + + +
+
+
+ + 检索资料 + + + + + + +
+ +
+
+
+
+ + + + +
+
+
+ + 输出回答 + + +
+ +
+
+ +
+`; diff --git a/packages/pro-components/chat/chat-thought-chain/__tests__/index.test.jsx b/packages/pro-components/chat/chat-thought-chain/__tests__/index.test.jsx new file mode 100644 index 0000000000..23cb842843 --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/__tests__/index.test.jsx @@ -0,0 +1,78 @@ +import { mount } from '@vue/test-utils'; +import { vi } from 'vitest'; +import ChatThoughtChain from '@tdesign/pro-components-chat/chat-thought-chain/index'; + +const items = [ + { key: 'a', title: '分析问题', content: '分析中的详细内容', status: 'success' }, + { key: 'b', title: '检索资料', content: '检索的详细内容', status: 'processing' }, + { key: 'c', title: '输出回答', status: 'pending' }, +]; + +describe('ChatThoughtChain', () => { + describe(':props', () => { + it(':items - array', () => { + const wrapper = mount(ChatThoughtChain, { + props: { items }, + }); + const nodes = wrapper.findAll('.t-chat__thought-chain-item'); + expect(nodes.length).toBe(3); + expect(nodes[0].classes()).toContain('t-chat__thought-chain-item--success'); + expect(nodes[1].classes()).toContain('t-chat__thought-chain-item--processing'); + expect(nodes[2].classes()).toContain('t-chat__thought-chain-item--pending'); + expect(wrapper.element).toMatchSnapshot(); + }); + + it(':defaultExpandedValue - array', () => { + const wrapper = mount(ChatThoughtChain, { + props: { items, defaultExpandedValue: ['a'] }, + }); + const contents = wrapper.findAll('.t-chat__thought-chain-item__content'); + expect(contents.length).toBe(1); + expect(contents[0].text()).toBe('分析中的详细内容'); + }); + + it(':collapsible - false', async () => { + const wrapper = mount(ChatThoughtChain, { + props: { items, collapsible: false }, + }); + await wrapper.findAll('.t-chat__thought-chain-item__header')[0].trigger('click'); + expect(wrapper.findAll('.t-chat__thought-chain-item__content').length).toBe(0); + expect(wrapper.findAll('.t-chat__thought-chain-item__arrow').length).toBe(0); + }); + + it(':icon - custom slot', () => { + const wrapper = mount(ChatThoughtChain, { + props: { items }, + slots: { + icon: ({ item }) => {item.key}, + }, + }); + expect(wrapper.findAll('.custom-icon').length).toBe(3); + }); + }); + + describe('@event', () => { + it('onExpandChange', async () => { + const onExpandChange = vi.fn(); + const wrapper = mount(ChatThoughtChain, { + props: { items, onExpandChange }, + }); + await wrapper.findAll('.t-chat__thought-chain-item__header')[0].trigger('click'); + expect(onExpandChange).toHaveBeenCalledWith(['a'], expect.objectContaining({ index: 0, expanded: true })); + expect(wrapper.findAll('.t-chat__thought-chain-item__content').length).toBe(1); + + await wrapper.findAll('.t-chat__thought-chain-item__header')[0].trigger('click'); + expect(onExpandChange).toHaveBeenCalledWith([], expect.objectContaining({ index: 0, expanded: false })); + }); + + it('expandedValue - controlled', async () => { + const wrapper = mount(ChatThoughtChain, { + props: { items, expandedValue: ['b'] }, + }); + expect(wrapper.findAll('.t-chat__thought-chain-item__content').length).toBe(1); + // 受控模式下点击不直接改变展开状态 + await wrapper.findAll('.t-chat__thought-chain-item__header')[0].trigger('click'); + expect(wrapper.findAll('.t-chat__thought-chain-item__content').length).toBe(1); + }); + }); +}); diff --git a/packages/pro-components/chat/chat-thought-chain/_example/thought-chain-custom.vue b/packages/pro-components/chat/chat-thought-chain/_example/thought-chain-custom.vue new file mode 100644 index 0000000000..8e3f79b63c --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/_example/thought-chain-custom.vue @@ -0,0 +1,25 @@ + + diff --git a/packages/pro-components/chat/chat-thought-chain/_example/thought-chain-stream.vue b/packages/pro-components/chat/chat-thought-chain/_example/thought-chain-stream.vue new file mode 100644 index 0000000000..6c983eb1e1 --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/_example/thought-chain-stream.vue @@ -0,0 +1,35 @@ + + diff --git a/packages/pro-components/chat/chat-thought-chain/_example/thought-chain.vue b/packages/pro-components/chat/chat-thought-chain/_example/thought-chain.vue new file mode 100644 index 0000000000..9aa99a8e97 --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/_example/thought-chain.vue @@ -0,0 +1,34 @@ + + diff --git a/packages/pro-components/chat/chat-thought-chain/chat-thought-chain-props.ts b/packages/pro-components/chat/chat-thought-chain/chat-thought-chain-props.ts new file mode 100644 index 0000000000..570d986838 --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/chat-thought-chain-props.ts @@ -0,0 +1,52 @@ +/* eslint-disable */ + +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TdChatThoughtChainProps } from '../type'; +import { PropType } from 'vue'; + +export default { + /** 是否支持折叠每个思考节点的内容 */ + collapsible: { + type: Boolean as PropType, + default: true, + }, + /** 思维链节点列表 */ + items: { + type: Array as PropType, + default: (): TdChatThoughtChainProps['items'] => [], + }, + /** 展开的节点。支持语法糖 v-model:expandedValue */ + expandedValue: { + type: Array as PropType, + default: undefined as TdChatThoughtChainProps['expandedValue'], + }, + modelValue: { + type: Array as PropType, + default: undefined as TdChatThoughtChainProps['expandedValue'], + }, + /** 展开的节点。非受控属性 */ + defaultExpandedValue: { + type: Array as PropType, + default: (): TdChatThoughtChainProps['defaultExpandedValue'] => [], + }, + /** 自定义节点内容 */ + content: { + type: Function as PropType, + }, + /** 自定义节点图标 */ + icon: { + type: Function as PropType, + }, + /** 自定义节点标题 */ + title: { + type: Function as PropType, + }, + /** 节点展开/收起时触发 */ + onExpandChange: { + type: Function as PropType, + default: () => {}, + }, +}; diff --git a/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.less b/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.less new file mode 100644 index 0000000000..94eca0fafc --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.less @@ -0,0 +1,133 @@ +// 临时样式:待 UI 样式沉淀到 tdesign-common 子仓库后迁移并删除该文件 +.t-chat__thought-chain { + display: flex; + flex-direction: column; + font-size: 14px; + color: var(--td-text-color-primary); + + &-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid var(--td-component-border); + box-sizing: border-box; + } + + &-item { + display: flex; + gap: 8px; + + &__node { + display: flex; + flex-direction: column; + align-items: center; + width: 20px; + flex-shrink: 0; + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + height: 22px; + font-size: 16px; + color: var(--td-text-color-placeholder); + } + + &__line { + flex: 1; + width: 1px; + background: var(--td-component-stroke); + margin: 2px 0; + } + + &__detail { + flex: 1; + min-width: 0; + padding-bottom: 16px; + } + + &:last-child &__detail { + padding-bottom: 0; + } + + &__header { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + line-height: 22px; + + &--collapsible { + cursor: pointer; + } + } + + &__title { + font-weight: 500; + } + + &__arrow { + font-size: 16px; + color: var(--td-text-color-placeholder); + transition: transform 0.2s; + } + + &--expanded &__arrow { + transform: rotate(180deg); + } + + &__content { + margin-top: 4px; + font-size: 13px; + line-height: 22px; + color: var(--td-text-color-secondary); + } + + &--success &__icon { + color: var(--td-success-color); + } + + &--error &__icon { + color: var(--td-error-color); + } + + &--processing &__icon { + color: var(--td-brand-color); + + .t-icon-loading { + animation: t-chat-thought-chain-spin 1s linear infinite; + } + } + + &--processing &__title { + background: linear-gradient(90deg, var(--td-text-color-primary) 25%, var(--td-brand-color) 50%, var(--td-text-color-primary) 75%); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: t-chat-thought-chain-flow 2s linear infinite; + } + } +} + +@keyframes t-chat-thought-chain-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes t-chat-thought-chain-flow { + from { + background-position: 200% 0; + } + + to { + background-position: 0 0; + } +} diff --git a/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.md b/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.md new file mode 100644 index 0000000000..2c86710ba2 --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.md @@ -0,0 +1,58 @@ +--- +title: ChatThoughtChain 思维链 +description: 以时间线形式结构化展示 AI 的思考过程与执行计划,适用于深度思考、Agent 任务规划等场景。 +isComponent: true +usage: { title: '', description: '' } +spline: ai +--- + +## 组件类型 + +### 基础思维链 + +通过 `items` 配置思考节点,每个节点支持标题、内容与状态(`pending`/`processing`/`success`/`error`),内容可折叠展开。 + +{{ thought-chain }} + +### 流式执行场景 + +配合 Agent 流式执行,逐步追加节点并更新状态,`processing` 状态自带加载动画。 + +{{ thought-chain-stream }} + +### 自定义节点 + +通过 `icon`、`title`、`content` 插槽(携带 `{ item, index }` 参数)自定义任意节点的渲染。 + +{{ thought-chain-custom }} + +## API + +### ChatThoughtChain Props + +名称 | 类型 | 默认值 | 描述 | 必传 +-- | -- | -- | -- | -- +collapsible | Boolean | true | 是否支持折叠每个思考节点的内容 | N +items | Array | [] | 思维链节点列表。TS 类型:`TdThoughtChainItem[]`。[详细类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/pro-components/chat/type.ts) | N +expandedValue | Array | [] | 展开的节点。支持语法糖 v-model:expandedValue。TS 类型:`Array` | N +defaultExpandedValue | Array | [] | 展开的节点。非受控属性。TS 类型:`Array` | N +title | Slot / Function | - | 自定义节点标题。TS 类型:`TNode<{ item: TdThoughtChainItem; index: number }>`。[通用类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/common.ts) | N +content | Slot / Function | - | 自定义节点内容。TS 类型:`TNode<{ item: TdThoughtChainItem; index: number }>`。[通用类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/common.ts) | N +icon | Slot / Function | - | 自定义节点图标,优先级高于节点 status 对应的默认图标。TS 类型:`TNode<{ item: TdThoughtChainItem; index: number }>`。[通用类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/common.ts) | N +onExpandChange | Function | — | TS 类型:`(value: Array, context: { item: TdThoughtChainItem; index: number; expanded: boolean }) => void`
节点展开/收起时触发 | N + +### TdThoughtChainItem + +名称 | 类型 | 默认值 | 描述 | 必传 +-- | -- | -- | -- | -- +key | String / Number | - | 节点唯一标识,缺省时使用节点索引 | N +title | String / Function | - | 节点标题。TS 类型:`string \| TNode` | N +content | String / Function | - | 节点描述内容,为空时该节点不可展开。TS 类型:`string \| TNode` | N +status | String | pending | 节点状态。可选项:pending/processing/success/error | N +icon | Function | - | 自定义节点图标,优先级高于 status 对应的默认图标。TS 类型:`TNode` | N + +### ChatThoughtChain Events + +名称 | 参数 | 描述 +-- | -- | -- +expand-change | `(value: Array, context: { item: TdThoughtChainItem; index: number; expanded: boolean })` | 节点展开/收起时触发 diff --git a/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.tsx b/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.tsx new file mode 100644 index 0000000000..aa95882afb --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/chat-thought-chain.tsx @@ -0,0 +1,93 @@ +import { defineComponent, computed, toRefs } from 'vue'; +import { CheckCircleIcon, ErrorCircleIcon, ChevronDownIcon, LoadingIcon } from 'tdesign-icons-vue-next'; +import { usePrefixClass, useTNodeJSX, useVModel } from '@tdesign/shared-hooks'; +import props from './chat-thought-chain-props'; +import type { TdThoughtChainItem } from '../type'; +import './chat-thought-chain.less'; + +export default defineComponent({ + name: 'TChatThoughtChain', + props, + emits: ['update:expandedValue'], + setup(props) { + const COMPONENT_NAME = usePrefixClass('chat'); + const renderTNodeJSX = useTNodeJSX(); + const baseClass = computed(() => `${COMPONENT_NAME.value}__thought-chain`); + + const { expandedValue, modelValue } = toRefs(props); + const [innerExpanded, setInnerExpanded] = useVModel( + expandedValue, + modelValue, + props.defaultExpandedValue, + props.onExpandChange, + 'expandedValue', + ); + + const getItemKey = (item: TdThoughtChainItem, index: number) => item.key ?? index; + + const isExpanded = (item: TdThoughtChainItem, index: number) => + (innerExpanded.value || []).includes(getItemKey(item, index)); + + const toggleExpand = (item: TdThoughtChainItem, index: number) => { + if (!props.collapsible || !item.content) return; + const key = getItemKey(item, index); + const expanded = !isExpanded(item, index); + const next = expanded + ? [...(innerExpanded.value || []), key] + : (innerExpanded.value || []).filter((k) => k !== key); + setInnerExpanded(next, { item, index, expanded }); + }; + + const renderItemNode = (node: TdThoughtChainItem['title']) => (typeof node === 'function' ? node(null) : node); + + const renderIcon = (item: TdThoughtChainItem, index: number) => { + const custom = renderTNodeJSX('icon', { params: { item, index } }) ?? renderItemNode(item.icon); + if (custom) return custom; + const status = item.status || 'pending'; + if (status === 'success') return ; + if (status === 'error') return ; + if (status === 'processing') return ; + return ; + }; + + return () => ( +
+ {(props.items || []).map((item, index) => { + const status = item.status || 'pending'; + const expanded = isExpanded(item, index); + const expandable = props.collapsible && Boolean(item.content); + const title = renderTNodeJSX('title', { params: { item, index } }) ?? renderItemNode(item.title); + const content = renderTNodeJSX('content', { params: { item, index } }) ?? renderItemNode(item.content); + return ( +
+
+ {renderIcon(item, index)} + {index < (props.items || []).length - 1 && } +
+
+
toggleExpand(item, index)} + > + {title} + {expandable && } +
+ {item.content && expanded &&
{content}
} +
+
+ ); + })} +
+ ); + }, +}); diff --git a/packages/pro-components/chat/chat-thought-chain/index.ts b/packages/pro-components/chat/chat-thought-chain/index.ts new file mode 100644 index 0000000000..064c2bdb86 --- /dev/null +++ b/packages/pro-components/chat/chat-thought-chain/index.ts @@ -0,0 +1,2 @@ +import _ChatThoughtChain from './chat-thought-chain'; +export default _ChatThoughtChain; diff --git a/packages/pro-components/chat/index.ts b/packages/pro-components/chat/index.ts index 7aa47b75c8..84782cf3a3 100644 --- a/packages/pro-components/chat/index.ts +++ b/packages/pro-components/chat/index.ts @@ -5,6 +5,7 @@ import _ChatItem from './chat-item'; import _ChatInput from './chat-input'; import _ChatContent from './chat-content'; import _ChatReasoning from './chat-reasoning'; +import _ChatThoughtChain from './chat-thought-chain'; import _ChatLoading from './chat-loading'; import _ChatActionbar from './chat-actionbar'; @@ -28,6 +29,7 @@ import { TdChatInputProps, TdChatSenderProps, TdChatReasoningProps, + TdChatThoughtChainProps, TdChatLoadingProps, } from './type'; @@ -46,6 +48,7 @@ export type ChatActionProps = TdChatActionProps; export type ChatInputProps = TdChatInputProps; export type ChatSenderProps = TdChatSenderProps; export type ChatReasoningProps = TdChatReasoningProps; +export type ChatThoughtChainProps = TdChatThoughtChainProps; export type ChatLoadingProps = TdChatLoadingProps; export const ChatList = withInstall(_ChatList); @@ -63,6 +66,8 @@ export const ChatSuggestionContent = withInstall(ChatSuggestionContentComponent, export const ChatMarkdown = withInstall(_ChatMarkdown, 't-chat-markdown'); +export const ChatThoughtChain = withInstall(_ChatThoughtChain); + // TODO:待下线的组件 export const Chat = withInstall(_ChatList); // 兼容历史版本,分别导出ChatList,Chat export const ChatAction = withInstall(_ChatActionbar); // 兼容历史版本,分别导出ChatActionbar,ChatAction @@ -93,6 +98,7 @@ export default { app.use(ChatInput, config); app.use(ChatItem, config); app.use(ChatReasoning, config); + app.use(ChatThoughtChain, config); app.component('TChat', Chat); app.component('TChatAction', ChatAction); }, diff --git a/packages/pro-components/chat/type.ts b/packages/pro-components/chat/type.ts index ccd54a84b2..37276ce8c0 100644 --- a/packages/pro-components/chat/type.ts +++ b/packages/pro-components/chat/type.ts @@ -487,6 +487,71 @@ export interface UploadActionConfig { action: (params: { files: File[]; name: UploadActionType; e?: Event }) => void; } +export type ThoughtChainItemStatus = 'pending' | 'processing' | 'success' | 'error'; + +export interface TdThoughtChainItem { + /** + * 节点唯一标识,缺省时使用节点索引 + */ + key?: string | number; + /** + * 节点标题 + */ + title?: string | TNode; + /** + * 节点描述内容,为空时该节点不可展开 + */ + content?: string | TNode; + /** + * 节点状态,可选项:pending/processing/success/error + * @default pending + */ + status?: ThoughtChainItemStatus; + /** + * 自定义节点图标,优先级高于 status 对应的默认图标 + */ + icon?: TNode; +} + +export interface TdChatThoughtChainProps { + /** + * 是否支持折叠每个思考节点的内容 + * @default true + */ + collapsible?: boolean; + /** + * 思维链节点列表 + */ + items?: TdThoughtChainItem[]; + /** + * 展开的节点。支持语法糖 v-model:expandedValue + */ + expandedValue?: Array; + /** + * 展开的节点。非受控属性 + */ + defaultExpandedValue?: Array; + /** + * 自定义节点内容 + */ + content?: TNode<{ item: TdThoughtChainItem; index: number }>; + /** + * 自定义节点图标 + */ + icon?: TNode<{ item: TdThoughtChainItem; index: number }>; + /** + * 自定义节点标题 + */ + title?: TNode<{ item: TdThoughtChainItem; index: number }>; + /** + * 节点展开/收起时触发 + */ + onExpandChange?: ( + value: Array, + context: { item: TdThoughtChainItem; index: number; expanded: boolean }, + ) => void; +} + export type * from 'tdesign-web-components/lib/chat-sender/type'; export type * from 'tdesign-web-components/lib/filecard/type'; export type * from 'tdesign-web-components/lib/chat-message/index'; diff --git a/packages/tdesign-vue-next-chat/site/site.config.mjs b/packages/tdesign-vue-next-chat/site/site.config.mjs index 70ed928406..0f94caab26 100644 --- a/packages/tdesign-vue-next-chat/site/site.config.mjs +++ b/packages/tdesign-vue-next-chat/site/site.config.mjs @@ -48,6 +48,13 @@ const componentDocs = [ path: '/vue-next-chat/components/chat-thinking', component: () => import('@tdesign/pro-components-chat/chat-thinking/chat-thinking.md'), }, + { + title: 'ChatThoughtChain 思维链', + titleEn: 'ChatThoughtChain', + name: 'ChatThoughtChain', + path: '/vue-next-chat/components/chat-thought-chain', + component: () => import('@tdesign/pro-components-chat/chat-thought-chain/chat-thought-chain.md'), + }, { title: 'ChatLoading 对话加载', titleEn: 'ChatLoading',