diff --git a/.gitignore b/.gitignore index b09d984b21..029e0ef8fc 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,11 @@ package-lock.json yarn.lock pnpm-lock.yaml +## mock server +server/* +packages/pro-components/chat/chatbot/docs/* +packages/pro-components/chat/chatbot/core/* - +bundle-analysis +.zip \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index dd091a7fc4..574be515b9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,7 +36,8 @@ "Cascader", "Popconfirm", "Swiper", - "tdesign" + "tdesign", + "aigc" ], "explorer.fileNesting.enabled": true, "explorer.fileNesting.expand": false, diff --git a/globals.d.ts b/globals.d.ts new file mode 100644 index 0000000000..3f57dea91f --- /dev/null +++ b/globals.d.ts @@ -0,0 +1,3 @@ +declare module '*.md'; +declare module '*.md?import'; +declare module '*.md?raw'; diff --git a/package.json b/package.json index decbbcce85..a11a5a3f16 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "dev": "pnpm -C packages/tdesign-react/site dev", "site": "pnpm run build:jsx-demo && pnpm -C packages/tdesign-react/site build", "site:preview": "pnpm run build:jsx-demo && pnpm -C packages/tdesign-react/site preview", + "dev:aigc": "pnpm -C packages/tdesign-react-aigc/site dev", + "site:aigc": "pnpm -C packages/tdesign-react-aigc/site build", + "site:aigc-intranet": "pnpm -C packages/tdesign-react-aigc/site intranet", + "site:aigc-preview": "pnpm -C packages/tdesign-react-aigc/site preview", "lint": "pnpm run lint:tsc && eslint --ext .ts,.tsx ./ --max-warnings 0", "lint:fix": "eslint --ext .ts,.tsx ./packages/components --ignore-pattern packages/components/__tests__ --max-warnings 0 --fix", "lint:tsc": "tsc -p ./tsconfig.dev.json ", @@ -24,6 +28,7 @@ "test:coverage": "vitest run --coverage", "prebuild": "rimraf packages/tdesign-react/es/* packages/tdesign-react/lib/* packages/tdesign-react/dist/* packages/tdesign-react/esm/* packages/tdesign-react/cjs/*", "build": "cross-env NODE_ENV=production rollup -c script/rollup.config.js && node script/utils/bundle-override.js && pnpm run build:tsc", + "build:aigc": "cross-env NODE_ENV=production rollup -c script/rollup.aigc.config.js && tsc -p ./tsconfig.aigc.build.json --outDir packages/tdesign-react-aigc/es/", "build:tsc": "run-p build:tsc-*", "build:tsc-es": "tsc -p ./tsconfig.build.json --outDir packages/tdesign-react/es/", "build:tsc-esm": "tsc -p ./tsconfig.build.json --outDir packages/tdesign-react/esm/", @@ -135,11 +140,16 @@ "vitest": "^2.1.1" }, "dependencies": { + "@babel/runtime": "~7.26.7", + "@popperjs/core": "~2.11.2", + "tdesign-react": "workspace:^", + "@tdesign-react/chat": "workspace:^", "@tdesign/common": "workspace:^", "@tdesign/common-docs": "workspace:^", "@tdesign/common-js": "workspace:^", "@tdesign/common-style": "workspace:^", "@tdesign/components": "workspace:^", + "@tdesign/pro-components-chat": "workspace:^", "@tdesign/react-site": "workspace:^" } } diff --git a/packages/components/table/interface.ts b/packages/components/table/interface.ts index e7ae194c9f..d90a52f902 100644 --- a/packages/components/table/interface.ts +++ b/packages/components/table/interface.ts @@ -35,6 +35,7 @@ export interface BaseTableProps extends T export type SimpleTableProps = BaseTableProps; export interface PrimaryTableProps extends TdPrimaryTableProps, StyledProps {} + export interface EnhancedTableProps extends TdEnhancedTableProps, StyledProps {} diff --git a/packages/pro-components/chat/_util/reactify.tsx b/packages/pro-components/chat/_util/reactify.tsx new file mode 100644 index 0000000000..fb65659e7b --- /dev/null +++ b/packages/pro-components/chat/_util/reactify.tsx @@ -0,0 +1,451 @@ +import React, { Component, createRef, createElement, forwardRef } from 'react'; +import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; + +// 检测 React 版本 +const isReact18Plus = () => typeof createRoot !== 'undefined'; +const isReact19Plus = (): boolean => { + const majorVersion = parseInt(React.version.split('.')[0]); + return majorVersion >= 19; +}; + +// 增强版本的缓存管理 +const rootCache = new WeakMap< + HTMLElement, + { + root: ReturnType; + lastElement?: React.ReactElement; + } +>(); + +const createRenderer = (container: HTMLElement) => { + if (isReact18Plus()) { + let cached = rootCache.get(container); + if (!cached) { + cached = { root: createRoot(container) }; + rootCache.set(container, cached); + } + + return { + render: (element: React.ReactElement) => { + // 可选:避免相同元素的重复渲染 + if (cached.lastElement !== element) { + cached.root.render(element); + cached.lastElement = element; + } + }, + unmount: () => { + cached.root.unmount(); + rootCache.delete(container); + }, + }; + } + + // React 17的实现 + return { + render: (element: React.ReactElement) => { + ReactDOM.render(element, container); + }, + unmount: () => { + ReactDOM.unmountComponentAtNode(container); + }, + }; +}; + +// 检查是否是React元素 +const isReactElement = (obj: any): obj is React.ReactElement => + obj && typeof obj === 'object' && obj.$$typeof && obj.$$typeof.toString().includes('react'); + +// 检查是否是有效的React节点 +const isValidReactNode = (node: any): node is React.ReactNode => + node !== null && + node !== undefined && + (typeof node === 'string' || + typeof node === 'number' || + typeof node === 'boolean' || + isReactElement(node) || + Array.isArray(node)); + +type AnyProps = { + [key: string]: any; +}; + +const hyphenateRE = /\B([A-Z])/g; + +export function hyphenate(str: string): string { + return str.replace(hyphenateRE, '-$1').toLowerCase(); +} + +const styleObjectToString = (style: any) => { + if (!style || typeof style !== 'object') return ''; + + const unitlessKeys = new Set([ + 'animationIterationCount', + 'boxFlex', + 'boxFlexGroup', + 'boxOrdinalGroup', + 'columnCount', + 'fillOpacity', + 'flex', + 'flexGrow', + 'flexShrink', + 'fontWeight', + 'lineClamp', + 'lineHeight', + 'opacity', + 'order', + 'orphans', + 'tabSize', + 'widows', + 'zIndex', + 'zoom', + ]); + + return Object.entries(style) + .filter(([, value]) => value != null && value !== '') // 过滤无效值 + .map(([key, value]) => { + // 转换驼峰式为连字符格式 + const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + + // 处理数值类型值 + let cssValue = value; + if (typeof value === 'number' && value !== 0 && !unitlessKeys.has(key)) { + cssValue = `${value}px`; + } + + return `${cssKey}:${cssValue};`; + }) + .join(' '); +}; + +const reactify = ( + WC: string, +): React.ForwardRefExoticComponent & React.RefAttributes> => { + class Reactify extends Component { + eventHandlers: [string, EventListener][]; + + slotRenderers: Map void>; + + ref: React.RefObject; + + constructor(props: AnyProps) { + super(props); + this.eventHandlers = []; + this.slotRenderers = new Map(); + const { innerRef } = props; + this.ref = innerRef || createRef(); + } + + setEvent(event: string, val: EventListener) { + this.eventHandlers.push([event, val]); + this.ref.current?.addEventListener(event, val); + } + + // 防止重复处理的标记 + private processingSlots = new Set(); + + // 处理slot相关的prop + handleSlotProp(prop: string, val: any) { + const webComponent = this.ref.current as any; + if (!webComponent) return; + + // 防止重复处理同一个slot + if (this.processingSlots.has(prop)) { + return; + } + + // 检查是否需要更新(避免相同内容的重复渲染) + const currentRenderer = this.slotRenderers.get(prop); + if (currentRenderer && this.isSameReactElement(prop, val)) { + return; // 相同内容,跳过更新 + } + + // 标记正在处理 + this.processingSlots.add(prop); + + // 立即缓存新元素,防止重复调用 + if (isValidReactNode(val)) { + this.lastRenderedElements.set(prop, val); + } + + // 清理旧的渲染器 + if (currentRenderer) { + this.cleanupSlotRenderer(prop); + } + + // 如果val是函数,为WebComponent提供一个函数,该函数返回渲染后的DOM + if (typeof val === 'function') { + const renderSlot = (params?: any) => { + const reactNode = val(params); + return this.renderReactNodeToSlot(reactNode, prop); + }; + webComponent[prop] = renderSlot; + // 函数类型处理完成后立即移除标记 + this.processingSlots.delete(prop); + } + // 如果val是ReactNode,直接渲染到slot + else if (isValidReactNode(val)) { + // 先设置属性,让组件知道这个prop有值 + webComponent[prop] = true; + + // 使用微任务延迟渲染,确保在当前渲染周期完成后执行 + Promise.resolve().then(() => { + if (webComponent.update) { + webComponent.update(); + } + this.renderReactNodeToSlot(val, prop); + // 渲染完成后移除处理标记 + this.processingSlots.delete(prop); + }); + } + } + + // 清理slot渲染器的统一方法 + private cleanupSlotRenderer(slotName: string) { + const renderer = this.slotRenderers.get(slotName); + if (!renderer) return; + + // 立即清理DOM容器 + this.clearSlotContainers(slotName); + + // 总是异步清理React渲染器,避免竞态条件 + Promise.resolve().then(() => { + this.safeCleanupRenderer(renderer); + }); + + this.slotRenderers.delete(slotName); + } + + // 安全清理渲染器 + private safeCleanupRenderer(cleanup: () => void) { + try { + cleanup(); + } catch (error) { + console.warn('Error cleaning up React renderer:', error); + } + } + + // 立即清理指定slot的所有容器 + private clearSlotContainers(slotName: string) { + const webComponent = this.ref.current; + if (!webComponent) return; + + // 查找并移除所有匹配的slot容器 + const containers = webComponent.querySelectorAll(`[slot="${slotName}"]`); + containers.forEach((container: Element) => { + if (container.parentNode) { + container.parentNode.removeChild(container); + } + }); + } + + // 缓存最后渲染的React元素,用于比较 + private lastRenderedElements = new Map(); + + // 检查是否是相同的React元素 + private isSameReactElement(prop: string, val: any): boolean { + const lastElement = this.lastRenderedElements.get(prop); + + if (!lastElement || !isValidReactNode(val)) { + return false; + } + + // 简单比较:如果是相同的React元素引用,则认为相同 + if (lastElement === val) { + return true; + } + + // 对于React元素,比较type、key和props + if (React.isValidElement(lastElement) && React.isValidElement(val)) { + const typeMatch = lastElement.type === val.type; + const keyMatch = lastElement.key === val.key; + const propsMatch = JSON.stringify(lastElement.props) === JSON.stringify(val.props); + return typeMatch && keyMatch && propsMatch; + } + + return false; + } + + // 将React节点渲染到slot中 + renderReactNodeToSlot(reactNode: React.ReactNode, slotName: string) { + const webComponent = this.ref.current; + if (!webComponent) return; + + // 检查是否已经有相同的slot容器存在,避免重复创建 + const existingContainers = webComponent.querySelectorAll(`[slot="${slotName}"]`); + if (existingContainers.length > 0) { + return; + } + + // 直接创建容器并添加到Web Component中 + const container = document.createElement('div'); + container.style.display = 'contents'; // 不影响布局 + container.setAttribute('slot', slotName); // 设置slot属性,Web Components会自动处理 + + // 将容器添加到Web Component中 + webComponent.appendChild(container); + + // 根据不同类型的reactNode创建不同的清理函数 + let cleanupFn: (() => void) | null = null; + + if (isValidReactNode(reactNode)) { + if (React.isValidElement(reactNode)) { + try { + const renderer = createRenderer(container); + renderer.render(reactNode); + cleanupFn = () => { + try { + renderer.unmount(); + } catch (error) { + console.warn('Error unmounting React renderer:', error); + } + }; + } catch (error) { + console.warn('Error creating React renderer:', error); + } + } else if (typeof reactNode === 'string' || typeof reactNode === 'number') { + container.textContent = String(reactNode); + cleanupFn = () => { + container.textContent = ''; + }; + } else if (Array.isArray(reactNode)) { + try { + const renderer = createRenderer(container); + const wrapper = React.createElement( + 'div', + { style: { display: 'contents' } }, + ...reactNode.filter(isValidReactNode), + ); + renderer.render(wrapper); + cleanupFn = () => { + try { + renderer.unmount(); + } catch (error) { + console.warn('Error unmounting React renderer:', error); + } + }; + } catch (error) { + console.warn('Error creating React renderer for array:', error); + } + } + } + + // 保存cleanup函数 + this.slotRenderers.set(slotName, () => { + // 清理缓存 + this.lastRenderedElements.delete(slotName); + // 异步unmount避免竞态条件 + Promise.resolve().then(() => { + if (cleanupFn) { + cleanupFn(); + } + if (container.parentNode) { + container.parentNode.removeChild(container); + } + }); + }); + } + + update() { + this.clearEventHandlers(); + if (!this.ref.current) return; + + Object.entries(this.props).forEach(([prop, val]) => { + if (['innerRef', 'children'].includes(prop)) return; + + // event handler + if (typeof val === 'function' && prop.match(/^on[A-Za-z]/)) { + const eventName = prop.slice(2); + const omiEventName = eventName[0].toLowerCase() + eventName.slice(1); + this.setEvent(omiEventName, val as EventListener); + return; + } + + // render functions or slot props + if (typeof val === 'function' && prop.match(/^render[A-Za-z]/)) { + this.handleSlotProp(prop, val); + return; + } + + // 检查是否是slot prop(通过组件的slotProps静态属性或Slot后缀) + if (isReactElement(val) && !prop.match(/^on[A-Za-z]/) && !prop.match(/^render[A-Za-z]/)) { + const componentClass = this.ref.current?.constructor as any; + const declaredSlots = componentClass?.slotProps || []; + + if (declaredSlots.includes(prop) || prop.endsWith('Slot')) { + this.handleSlotProp(prop, val); + return; + } + } + + // Complex object处理 + if (typeof val === 'object' && val !== null) { + // style特殊处理 + if (prop === 'style') { + this.ref.current?.setAttribute('style', styleObjectToString(val)); + return; + } + // 其他复杂对象直接设置为属性 + (this.ref.current as any)[prop] = val; + return; + } + + // 函数类型但不是事件处理器也不是render函数的,直接设置为属性 + if (typeof val === 'function') { + (this.ref.current as any)[prop] = val; + return; + } + + // camel case to kebab-case for attributes + if (prop.match(hyphenateRE)) { + this.ref.current?.setAttribute(hyphenate(prop), val); + this.ref.current?.removeAttribute(prop); + return; + } + if (!isReact19Plus()) { + (this.ref.current as any)[prop] = val; + } + }); + } + + componentDidUpdate() { + this.update(); + } + + componentDidMount() { + this.update(); + } + + componentWillUnmount() { + this.clearEventHandlers(); + this.clearSlotRenderers(); + } + + clearEventHandlers() { + this.eventHandlers.forEach(([event, handler]) => { + this.ref.current?.removeEventListener(event, handler); + }); + this.eventHandlers = []; + } + + clearSlotRenderers() { + this.slotRenderers.forEach((cleanup) => { + this.safeCleanupRenderer(cleanup); + }); + this.slotRenderers.clear(); + this.processingSlots.clear(); + } + + render() { + const { children, className, innerRef, ...rest } = this.props; + + return createElement(WC, { class: className, ...rest, ref: this.ref }, children); + } + } + + return forwardRef((props, ref) => + createElement(Reactify, { ...props, innerRef: ref }), + ) as React.ForwardRefExoticComponent & React.RefAttributes>; +}; + +export default reactify; diff --git a/packages/pro-components/chat/_util/useDynamicStyle.ts b/packages/pro-components/chat/_util/useDynamicStyle.ts new file mode 100644 index 0000000000..a2a6ee5c05 --- /dev/null +++ b/packages/pro-components/chat/_util/useDynamicStyle.ts @@ -0,0 +1,41 @@ +import { useRef, useEffect, MutableRefObject } from 'react'; + +type StyleVariables = Record; + +// 用于动态管理组件作用域样式 +export const useDynamicStyle = (elementRef: MutableRefObject, cssVariables: StyleVariables) => { + const styleId = useRef(`dynamic-styles-${Math.random().toString(36).slice(2, 11)}`); + + // 生成带作用域的CSS样式 + const generateScopedStyles = (vars: StyleVariables) => { + const variables = Object.entries(vars) + .map(([key, value]) => `${key}: ${value};`) + .join('\n'); + + return ` + .${styleId.current} { + ${variables} + } + `; + }; + + useEffect(() => { + if (!elementRef?.current) return; + const styleElement = document.createElement('style'); + styleElement.innerHTML = generateScopedStyles(cssVariables); + document.head.appendChild(styleElement); + + // 绑定样式类到目标元素 + const currentElement = elementRef.current; + if (currentElement) { + currentElement.classList.add(styleId.current); + } + + return () => { + document.head.removeChild(styleElement); + if (currentElement) { + currentElement.classList.remove(styleId.current); + } + }; + }, [cssVariables]); +}; diff --git a/packages/pro-components/chat/chat-actionbar/_example/base.tsx b/packages/pro-components/chat/chat-actionbar/_example/base.tsx new file mode 100644 index 0000000000..d58af3abc9 --- /dev/null +++ b/packages/pro-components/chat/chat-actionbar/_example/base.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; +import { ChatActionBar } from '@tdesign-react/chat'; + +const ChatActionBarExample = () => { + const onActions = (name, data) => { + console.log('消息事件触发:', name, data); + }; + + return ( + + + + ); +}; + +export default ChatActionBarExample; diff --git a/packages/pro-components/chat/chat-actionbar/_example/custom.tsx b/packages/pro-components/chat/chat-actionbar/_example/custom.tsx new file mode 100644 index 0000000000..dfee769317 --- /dev/null +++ b/packages/pro-components/chat/chat-actionbar/_example/custom.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; +import { ChatActionBar } from '@tdesign-react/chat'; + +const ChatActionBarExample = () => { + const onActions = (name, data) => { + console.log('消息事件触发:', name, data); + }; + + return ( + + + + ); +}; + +export default ChatActionBarExample; diff --git a/packages/pro-components/chat/chat-actionbar/_example/style.tsx b/packages/pro-components/chat/chat-actionbar/_example/style.tsx new file mode 100644 index 0000000000..415c56a4e0 --- /dev/null +++ b/packages/pro-components/chat/chat-actionbar/_example/style.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; +import { ChatActionBar } from '@tdesign-react/chat'; +import { useDynamicStyle } from '../../_util/useDynamicStyle'; + +const ChatActionBarExample = () => { + const barRef = React.useRef(null); + + // 这里是为了演示样式修改不影响其他Demo,实际项目中直接设置css变量到:root即可 + useDynamicStyle(barRef, { + '--td-chat-item-actions-list-border': 'none', + '--td-chat-item-actions-list-bg': 'none', + '--td-chat-item-actions-item-hover-bg': '#f3f3f3', + }); + + const onActions = (name, data) => { + console.log('消息事件触发:', name, data); + }; + + return ( + + + + ); +}; + +export default ChatActionBarExample; diff --git a/packages/pro-components/chat/chat-actionbar/chat-actionbar.en-US.md b/packages/pro-components/chat/chat-actionbar/chat-actionbar.en-US.md new file mode 100644 index 0000000000..3e6425a2ad --- /dev/null +++ b/packages/pro-components/chat/chat-actionbar/chat-actionbar.en-US.md @@ -0,0 +1,11 @@ +:: BASE_DOC :: + +## API +### ChatActionBar Props + +name | type | default | description | required +-- | -- | -- | -- | -- +actionBar | Array / Boolean | true | 操作按钮配置项,可配置操作按钮选项和顺序。数组可选项:replay/copy/good/bad/goodActived/badActived/share | N +onActions | Function | - | 操作按钮回调函数。TS类型:`Record void>` | N +message | Object | - | 对话数据信息 | N +tooltipProps | TooltipProps | - | [类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/tooltip/type.ts) | N diff --git a/packages/pro-components/chat/chat-actionbar/chat-actionbar.md b/packages/pro-components/chat/chat-actionbar/chat-actionbar.md new file mode 100644 index 0000000000..c2f481e215 --- /dev/null +++ b/packages/pro-components/chat/chat-actionbar/chat-actionbar.md @@ -0,0 +1,36 @@ +--- +title: ChatActionBar 对话操作栏 +description: ChatActionbar 包含重新生成,点赞,点踩,复制按钮。 内置 Clipboard 可以复制聊天内容,提供按钮的交互样式,监听 actions 相关事件由业务层实现具体逻辑 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +## 基础用法 + +{{ base }} + +## 样式调整 +支持通过css变量修改样式, +支持通过`tooltipProps`属性设置提示浮层的样式 + +{{ style }} + +## 自定义 + +目前仅支持有限的自定义,包括调整顺序,展示指定项 + +{{ custom }} + + + +## API +### ChatActionBar Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +actionBar | TdChatActionsName[] \| boolean | true | 操作按钮配置项,可配置操作按钮选项和顺序。数组可选项:replay/copy/good/bad/share | N +handleAction | Function | - | 操作回调函数。TS类型:`(name: TdChatActionsName, data: any) => void` | N +comment | ChatComment | - | 用户反馈状态,可选项:'good'/'bad' | N +copyText | string | - | 复制按钮的复制文本 | N +tooltipProps | TooltipProps | - | tooltip的属性 [类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/tooltip/type.ts) | N diff --git a/packages/pro-components/chat/chat-actionbar/index.tsx b/packages/pro-components/chat/chat-actionbar/index.tsx new file mode 100644 index 0000000000..1a21b91b1a --- /dev/null +++ b/packages/pro-components/chat/chat-actionbar/index.tsx @@ -0,0 +1,52 @@ +import { TdChatActionProps } from 'tdesign-web-components'; +import 'tdesign-web-components/lib/chat-action'; +import reactify from '../_util/reactify'; + +export const ChatActionBar: React.ForwardRefExoticComponent< + Omit & + React.RefAttributes & { + [key: string]: any; + } +> = reactify('t-chat-action'); + +export default ChatActionBar; +export type { TdChatActionProps, TdChatActionsName } from 'tdesign-web-components'; + +// 方案1 +// import { reactifyLazy } from './_util/reactifyLazy'; +// const ChatActionBar = reactifyLazy<{ +// size: 'small' | 'medium' | 'large', +// variant: 'primary' | 'secondary' | 'outline' +// }>( +// 't-chat-action', +// 'tdesign-web-components/esm/chat-action' +// ); + +// import ChatAction from 'tdesign-web-components/esm/chat-action'; +// import React, { forwardRef, useEffect } from 'react'; + +// // 注册Web Components组件 +// const registerChatAction = () => { +// if (!customElements.get('t-chat-action')) { +// customElements.define('t-chat-action', ChatAction); +// } +// }; + +// // 在组件挂载时注册 +// const useRegisterWebComponent = () => { +// useEffect(() => { +// registerChatAction(); +// }, []); +// }; + +// // 使用reactify创建React组件 +// const BaseChatActionBar = reactify('t-chat-action'); + +// // 包装组件,确保Web Components已注册 +// export const ChatActionBar2 = forwardRef< +// HTMLElement | undefined, +// Omit & { [key: string]: any } +// >((props, ref) => { +// useRegisterWebComponent(); +// return ; +// }); diff --git a/packages/pro-components/chat/chat-attachments/_example/base.tsx b/packages/pro-components/chat/chat-attachments/_example/base.tsx new file mode 100644 index 0000000000..a1cb34a571 --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/_example/base.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { Attachments, TdAttachmentItem } from '@tdesign-react/chat'; +import { Space } from 'tdesign-react'; + +const filesList: TdAttachmentItem[] = [ + { + name: 'excel-file.xlsx', + size: 111111, + }, + { + name: 'word-file.docx', + size: 222222, + }, + { + name: 'image-file.png', + size: 333333, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + { + name: 'pdf-file.pdf', + size: 444444, + }, + { + name: 'ppt-file.pptx', + size: 555555, + }, + { + name: 'video-file.mp4', + size: 666666, + }, + { + name: 'audio-file.mp3', + size: 777777, + }, + { + name: 'zip-file.zip', + size: 888888, + }, + { + name: 'markdown-file.md', + size: 999999, + description: 'Custom description', + }, + { + name: 'word-markdown-file.doc', + size: 99899, + status: 'progress', + percent: 50, + }, +]; + +const ChatAttachmentExample = () => { + const [list, setlist] = useState(filesList); + + const onRemove = (item) => { + console.log('remove', item); + setlist(list.filter((a) => a.name !== item.detail.name)); + }; + + return ( + + + + ); +}; + +export default ChatAttachmentExample; diff --git a/packages/pro-components/chat/chat-attachments/_example/scroll-x.tsx b/packages/pro-components/chat/chat-attachments/_example/scroll-x.tsx new file mode 100644 index 0000000000..f020273147 --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/_example/scroll-x.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { Attachments, TdAttachmentItem } from '@tdesign-react/chat'; +import { Space } from 'tdesign-react'; + +const filesList: TdAttachmentItem[] = [ + { + name: 'excel-file.xlsx', + size: 111111, + }, + { + name: 'word-file.docx', + size: 222222, + }, + { + name: 'image-file.png', + size: 333333, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + { + name: 'pdf-file.pdf', + size: 444444, + }, + { + name: 'ppt-file.pptx', + size: 555555, + }, + { + name: 'video-file.mp4', + size: 666666, + }, + { + name: 'audio-file.mp3', + size: 777777, + }, + { + name: 'zip-file.zip', + size: 888888, + }, + { + name: 'markdown-file.md', + size: 999999, + description: 'Custom description', + }, + { + name: 'word-markdown-file.doc', + size: 99899, + status: 'progress', + percent: 50, + }, +]; + +const ChatAttachmentExample = () => { + const [list, setlist] = useState(filesList); + + const onRemove = (item) => { + console.log('remove', item); + setlist(list.filter((a) => a.name !== item.detail.name)); + }; + + return ( + + + + ); +}; + +export default ChatAttachmentExample; diff --git a/packages/pro-components/chat/chat-attachments/_example/scroll-y.tsx b/packages/pro-components/chat/chat-attachments/_example/scroll-y.tsx new file mode 100644 index 0000000000..3831c51789 --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/_example/scroll-y.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { Attachments, TdAttachmentItem } from '@tdesign-react/chat'; +import { Space } from 'tdesign-react'; + +const filesList: TdAttachmentItem[] = [ + { + name: 'excel-file.xlsx', + size: 111111, + }, + { + name: 'word-file.docx', + size: 222222, + }, + { + name: 'image-file.png', + size: 333333, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + { + name: 'pdf-file.pdf', + size: 444444, + }, + { + name: 'ppt-file.pptx', + size: 555555, + }, + { + name: 'video-file.mp4', + size: 666666, + }, + { + name: 'audio-file.mp3', + size: 777777, + }, + { + name: 'zip-file.zip', + size: 888888, + }, + { + name: 'markdown-file.md', + size: 999999, + description: 'Custom description', + }, + { + name: 'word-markdown-file.doc', + size: 99899, + status: 'progress', + percent: 50, + }, +]; + +const ChatAttachmentExample = () => { + const [list, setlist] = useState(filesList); + + const onRemove = (item) => { + console.log('remove', item); + setlist(list.filter((a) => a.name !== item.detail.name)); + }; + + return ( + + + + ); +}; + +export default ChatAttachmentExample; diff --git a/packages/pro-components/chat/chat-attachments/_usage/index.jsx b/packages/pro-components/chat/chat-attachments/_usage/index.jsx new file mode 100644 index 0000000000..f47ce1e03e --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/_usage/index.jsx @@ -0,0 +1,95 @@ +/** + * 该脚本为自动生成,如有需要请在 /script/generate-usage.js 中调整 + */ + +// @ts-nocheck +import React, { useState, useEffect, useMemo } from 'react'; +import BaseUsage, { useConfigChange, usePanelChange } from '@tdesign/react-site/src/components/BaseUsage'; +import jsxToString from 'react-element-to-jsx-string'; + +import { Attachments } from '@tdesign-react/chat'; +import configProps from './props.json'; + +const filesList = [ + { + name: 'excel-file.xlsx', + size: 111111, + }, + { + name: 'word-file.docx', + size: 222222, + }, + { + name: 'image-file.png', + size: 333333, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + { + name: 'pdf-file.pdf', + size: 444444, + }, + { + name: 'ppt-file.pptx', + size: 555555, + }, + { + name: 'video-file.mp4', + size: 666666, + }, + { + name: 'audio-file.mp3', + size: 777777, + }, + { + name: 'zip-file.zip', + size: 888888, + }, + { + name: 'markdown-file.md', + size: 999999, + description: 'Custom description', + }, + { + name: 'word-markdown-file.doc', + size: 99899, + status: 'progress', + percent: 50, + }, +]; + +export default function Usage() { + const [configList, setConfigList] = useState(configProps); + + const { changedProps, onConfigChange } = useConfigChange(configList); + + const panelList = [{ label: 'Attachments', value: 'Attachments' }]; + + const { panel, onPanelChange } = usePanelChange(panelList); + + const [renderComp, setRenderComp] = useState(); + + useEffect(() => { + setRenderComp( +
+ +
, + ); + }, [changedProps]); + + const jsxStr = useMemo(() => { + if (!renderComp) return ''; + return jsxToString(renderComp); + }, [renderComp]); + + return ( + + {renderComp} + + ); +} diff --git a/packages/pro-components/chat/chat-attachments/_usage/props.json b/packages/pro-components/chat/chat-attachments/_usage/props.json new file mode 100644 index 0000000000..c944cd989e --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/_usage/props.json @@ -0,0 +1,33 @@ +[ + { + "name": "removable", + "type": "Boolean", + "defaultValue": true, + "options": [] + }, + { + "name": "imageViewer", + "type": "Boolean", + "defaultValue": true, + "options": [] + }, + { + "name": "overflow", + "type": "enum", + "defaultValue": "wrap", + "options": [ + { + "label": "wrap", + "value": "wrap" + }, + { + "label": "scrollX", + "value": "scrollX" + }, + { + "label": "scrollY", + "value": "scrollY" + } + ] + } +] \ No newline at end of file diff --git a/packages/pro-components/chat/chat-attachments/chat-attachments.en-US.md b/packages/pro-components/chat/chat-attachments/chat-attachments.en-US.md new file mode 100644 index 0000000000..607a518b85 --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/chat-attachments.en-US.md @@ -0,0 +1,19 @@ +:: BASE_DOC :: + +## API +### Attachments Props + +name | type | default | description | required +-- | -- | -- | -- | -- +items | Array | - | 附件列表。TS类型:TdAttachmentItem[]。[类型定义](?tab=api#tdattachmentitem-类型说明) | Y +onRemove | Function | - | 附件移除时的回调函数。 TS类型:`(item: TdAttachmentItem) => void \| undefined` | N +removable | Boolean | true | 是否显示删除按钮 | N +overflow | String | wrap | 文件列表超出时样式。可选项:wrap/scrollX/scrollY | N +imageViewer | Boolean | true | 图片预览开关 | N + +## TdAttachmentItem 类型说明 +name | type | default | description | required +-- | -- | -- | -- | -- +description | String | - | 文件描述信息 | N +extension | String | - | 文件扩展名 | N +(继承属性) | UploadFile | - | 包含 `name`, `size`, `status` 等基础文件属性 | N \ No newline at end of file diff --git a/packages/pro-components/chat/chat-attachments/chat-attachments.md b/packages/pro-components/chat/chat-attachments/chat-attachments.md new file mode 100644 index 0000000000..09a6d4b149 --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/chat-attachments.md @@ -0,0 +1,30 @@ +--- +title: Attachments 文件附件 +description: 文件附件 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +### 基础用法 + +{{ base }} + +### 滚动 ScrollX + +{{ scroll-x }} + +### 滚动 ScrollY + +{{ scroll-y }} + +## API +### Attachments Props +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +items | Array | - | 附件列表。TS类型:`TdAttachmentItem[]` | Y +removable | Boolean | true | 是否显示删除按钮 | N +overflow | String | wrap | 文件列表超出时样式。可选项:wrap/scrollX/scrollY | N +imageViewer | Boolean | true | 图片预览开关 | N +onFileClick | Function | - | 点击文件卡片时的回调,TS类型:`(event: CustomEvent) => void;` | N +onRemove | Function | - | 附件移除时的回调函数。 TS类型:`(event: CustomEvent) => void` | N diff --git a/packages/pro-components/chat/chat-attachments/index.ts b/packages/pro-components/chat/chat-attachments/index.ts new file mode 100644 index 0000000000..1591037fb4 --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/index.ts @@ -0,0 +1,11 @@ +import { TdAttachmentsProps } from 'tdesign-web-components'; +import 'tdesign-web-components/lib/attachments'; +import reactify from '../_util/reactify'; + +export const Attachments: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-attachments'); + +export default Attachments; + +export type { TdAttachmentsProps, TdAttachmentItem } from 'tdesign-web-components'; diff --git a/packages/pro-components/chat/chat-engine/_example/agui-basic.tsx b/packages/pro-components/chat/chat-engine/_example/agui-basic.tsx new file mode 100644 index 0000000000..5d5cd43d9f --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-basic.tsx @@ -0,0 +1,102 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + ChatList, + ChatSender, + ChatMessage, + type TdChatSenderParams, + type ChatRequestParams, +} from '@tdesign-react/chat'; +import { useChat } from '@tdesign-react/chat'; +import { MessagePlugin } from 'tdesign-react'; +import { AGUIAdapter } from '@tdesign-react/chat'; + +/** + * AG-UI 协议基础示例 + * + * 学习目标: + * - 开启 AG-UI 协议支持(protocol: 'agui') + * - 理解 AG-UI 协议的自动解析机制 + * - 处理文本消息事件(TEXT_MESSAGE_*) + * - 初始化加载历史消息方法 AGUIAdapter.convertHistoryMessages + */ +export default function AguiBasicExample() { + const [inputValue, setInputValue] = useState('AG-UI协议的作用是什么'); + const listRef = useRef(null); + + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/agui-simple', + // 开启 AG-UI 协议解析支持 + protocol: 'agui', + stream: true, + // 自定义请求参数 + onRequest: (params: ChatRequestParams) => ({ + body: JSON.stringify({ + uid: 'agui-demo', + prompt: params.prompt, + }), + }), + // 生命周期回调 + onStart: (chunk) => { + console.log('AG-UI 流式传输开始:', chunk); + }, + onComplete: (aborted, params, event) => { + console.log('AG-UI 流式传输完成:', { aborted, event }); + }, + onError: (err) => { + console.error('AG-UI 错误:', err); + }, + }, + }); + + // 初始化加载历史消息 + useEffect(() => { + const loadHistoryMessages = async () => { + try { + const response = await fetch(`https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/api/conversation/history?type=simple`); + const result = await response.json(); + if (result.success && result.data) { + const messages = AGUIAdapter.convertHistoryMessages(result.data); + chatEngine.setMessages(messages); + listRef.current?.scrollList({ to: 'bottom' }); + } + } catch (error) { + console.error('加载历史消息出错:', error); + MessagePlugin.error('加载历史消息出错'); + } + }; + + loadHistoryMessages(); + }, []); + + const handleSend = async (e: CustomEvent) => { + const { value } = e.detail; + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + return ( +
+ + {messages.map((message) => ( + + ))} + + + setInputValue(e.detail)} + onSend={handleSend} + onStop={() => chatEngine.abortChat()} + /> +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/agui-comprehensive.tsx b/packages/pro-components/chat/chat-engine/_example/agui-comprehensive.tsx new file mode 100644 index 0000000000..4f513c9508 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-comprehensive.tsx @@ -0,0 +1,451 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Button, Card, Progress, Tag, Space, Input, Select } from 'tdesign-react'; +import { + ChatList, + ChatSender, + ChatMessage, + TdChatListApi, + TdChatSenderApi, + ToolCallRenderer, + useAgentToolcall, + useChat, + useAgentState, +} from '@tdesign-react/chat'; +import { CheckCircleFilledIcon, TimeFilledIcon, ErrorCircleFilledIcon, LoadingIcon } from 'tdesign-icons-react'; +import type { + TdChatMessageConfig, + TdChatSenderParams, + ChatMessagesData, + ChatRequestParams, + ToolCall, + ToolcallComponentProps, +} from '@tdesign-react/chat'; + +// ==================== 类型定义 ==================== +interface WeatherArgs { + city: string; +} + +interface WeatherResult { + temperature: string; + condition: string; + humidity: string; +} + +interface PlanningArgs { + destination: string; + days: number; + taskId: string; +} + +interface UserPreferencesArgs { + destination: string; +} + +interface UserPreferencesResponse { + budget: number; + interests: string[]; + accommodation: string; +} + +// ==================== 工具组件 ==================== + +// 1. 天气查询组件(展示 TOOL_CALL 基础用法) +const WeatherCard: React.FC> = ({ + status, + args, + result, + error, +}) => { + if (error) { + return ( + +
查询天气失败: {error.message}
+
+ ); + } + + return ( + +
+ {args?.city} 天气信息 +
+ {status === 'executing' &&
正在查询天气...
} + {status === 'complete' && result && ( + +
🌡️ 温度: {result.temperature}
+
☁️ 天气: {result.condition}
+
💧 湿度: {result.humidity}
+
+ )} +
+ ); +}; + +// 2. 规划步骤组件(展示 STATE 订阅 + agentState 注入) +const PlanningSteps: React.FC> = ({ + status, + args, + respond, + agentState, +}) => { + // 因为配置了 subscribeKey,agentState 已经是 taskId 对应的状态对象 + const planningState = agentState || {}; + + const isComplete = status === 'complete'; + + React.useEffect(() => { + if (isComplete) { + respond?.({ success: true }); + } + }, [isComplete, respond]); + + return ( + +
+ 正在为您规划 {args?.destination} {args?.days}日游 +
+ + {/* 只保留进度条 */} + {planningState?.progress !== undefined && ( +
+ +
+ {planningState.message || '规划中...'} +
+
+ )} +
+ ); +}; + +// 3. 用户偏好设置组件(展示 Human-in-the-Loop 交互) +const UserPreferencesForm: React.FC> = ({ + status, + respond, + result, +}) => { + const [budget, setBudget] = useState(5000); + const [interests, setInterests] = useState(['美食', '文化']); + const [accommodation, setAccommodation] = useState('经济型'); + + const handleSubmit = () => { + respond?.({ + budget, + interests, + accommodation, + }); + }; + + if (status === 'complete' && result) { + return ( + +
+ ✓ 已收到您的偏好设置 +
+ +
+ 预算:¥{result.budget} +
+
+ 兴趣:{result.interests.join('、')} +
+
+ 住宿:{result.accommodation} +
+
+
+ ); + } + + return ( + +
+ 请设置您的旅游偏好 +
+ +
+
预算(元)
+ setBudget(Number(value))} + placeholder="请输入预算" + /> +
+
+
兴趣爱好
+ setAccommodation(value as string)} + options={[ + { label: '经济型', value: '经济型' }, + { label: '舒适型', value: '舒适型' }, + { label: '豪华型', value: '豪华型' }, + ]} + /> +
+ +
+
+ ); +}; + +// ==================== 外部进度面板组件 ==================== + +/** + * 右侧进度面板组件 + * 演示如何在对话组件外部使用 useAgentState 获取状态 + * + * 💡 使用场景:展示规划行程的详细子步骤(从后端 STATE_DELTA 事件推送) + * + * 实现方式: + * 1. 使用 useAgentState 订阅状态更新 + * 2. 从 stateMap 中获取规划步骤的详细进度 + */ +const ProgressPanel: React.FC = () => { + // 使用 useAgentState 订阅状态更新 + const { stateMap, currentStateKey } = useAgentState(); + + // 获取规划状态 + const planningState = useMemo(() => { + if (!currentStateKey || !stateMap[currentStateKey]) { + return null; + } + return stateMap[currentStateKey]; + }, [stateMap, currentStateKey]); + + // 如果没有规划状态,不显示面板 + if (!planningState || !planningState.items || planningState.items.length === 0) { + return null; + } + + const items = planningState.items || []; + const completedCount = items.filter((item: any) => item.status === 'completed').length; + const totalCount = items.length; + + // 如果所有步骤都完成了,隐藏面板 + if (completedCount === totalCount && totalCount > 0) { + return null; + } + + const getStatusIcon = (itemStatus: string) => { + switch (itemStatus) { + case 'completed': + return ; + case 'running': + return ; + case 'failed': + return ; + default: + return ; + } + }; + + return ( +
+
+
+ 规划进度 +
+ + {completedCount}/{totalCount} + +
+ + {/* 步骤列表 */} + + {items.map((item: any, index: number) => ( +
+ {getStatusIcon(item.status)} + + {item.label} + +
+ ))} +
+
+ ); +}; + +// ==================== 主组件 ==================== +const TravelPlannerContent: React.FC = () => { + const listRef = useRef(null); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState('请为我规划一个北京3日游行程'); + + // 注册工具配置 + useAgentToolcall([ + { + name: 'collect_user_preferences', + description: '收集用户偏好', + parameters: [{ name: 'destination', type: 'string', required: true }], + component: UserPreferencesForm as any, + }, + { + name: 'query_weather', + description: '查询目的地天气', + parameters: [{ name: 'city', type: 'string', required: true }], + component: WeatherCard, + }, + { + name: 'show_planning_steps', + description: '展示规划步骤', + parameters: [ + { name: 'destination', type: 'string', required: true }, + { name: 'days', type: 'number', required: true }, + { name: 'taskId', type: 'string', required: true }, + ], + component: PlanningSteps as any, + // 配置 subscribeKey,让组件订阅对应 taskId 的状态 + subscribeKey: (props) => props.args?.taskId, + }, + ]); + + // 聊天配置 + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/travel-planner', + protocol: 'agui', + stream: true, + onRequest: (params: ChatRequestParams) => ({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: params.prompt, + toolCallMessage: params.toolCallMessage, + }), + }), + }, + }); + + const senderLoading = useMemo(() => status === 'pending' || status === 'streaming', [status]); + + // 消息配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + }, + assistant: { + placement: 'left', + }, + }; + + // 处理工具调用响应 + const handleToolCallRespond = useCallback( + async (toolcall: ToolCall, response: any) => { + // 判断如果是手机用户偏好的响应,则使用 toolcall 中的信息来构建新的请求 + if (toolcall.toolCallName === 'collect_user_preferences') { + await chatEngine.sendAIMessage({ + params: { + toolCallMessage: { + toolCallId: toolcall.toolCallId, + toolCallName: toolcall.toolCallName, + result: JSON.stringify(response), + }, + }, + sendRequest: true, + }); + listRef.current?.scrollList({ to: 'bottom' }); + } + }, + [chatEngine], + ); + + // 渲染消息内容 + const renderMessageContent = useCallback( + (item: any, index: number) => { + if (item.type === 'toolcall') { + return ( +
+ +
+ ); + } + return null; + }, + [handleToolCallRespond], + ); + + const renderMsgContents = (message: ChatMessagesData) => ( + <> + {message.content?.map((item: any, index: number) => renderMessageContent(item, index))} + + ); + + const sendHandler = async (e: CustomEvent) => { + const { value } = e.detail; + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + return ( +
+ {/* 右侧进度面板:使用 useAgentState 订阅状态 */} + + +
+ + {messages.map((message) => ( + + {renderMsgContents(message)} + + ))} + + setInputValue(e.detail)} + onSend={sendHandler} + onStop={() => chatEngine.abortChat()} + /> +
+
+ ); +}; + +// 导出主组件(不需要 Provider,因为 useAgentState 内部已处理) +export default TravelPlannerContent; diff --git a/packages/pro-components/chat/chat-engine/_example/agui-toolcall.tsx b/packages/pro-components/chat/chat-engine/_example/agui-toolcall.tsx new file mode 100644 index 0000000000..97afaa82e1 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-toolcall.tsx @@ -0,0 +1,321 @@ +import React, { ReactNode, useRef, useState, useMemo } from 'react'; +import { Card, Progress, Space, Image } from 'tdesign-react'; +import { CheckCircleFilledIcon, CloseCircleFilledIcon, LoadingIcon } from 'tdesign-icons-react'; +import { + ChatList, + ChatSender, + ChatMessage, + ChatActionBar, + isAIMessage, + TdChatSenderParams, + ChatLoading, + TdChatActionsName, + ToolCallRenderer, + useChat, + useAgentToolcall, +} from '@tdesign-react/chat'; +import type { TdChatMessageConfig, ChatMessagesData, ChatRequestParams, AIMessageContent, ToolCall, AgentToolcallConfig, ToolcallComponentProps } from '@tdesign-react/chat'; + +/** + * 图片生成进度状态接口 + */ +interface ImageGenState { + status: 'preparing' | 'generating' | 'completed' | 'failed'; + progress: number; + message: string; + imageUrl?: string; + error?: string; +} + +// 图片生成工具调用类型定义 +interface GenerateImageArgs { + taskId: string; + prompt: string; +} + +/** + * 图片生成进度组件 + * 演示如何通过 agentState 注入获取 AG-UI 状态 + * + * 💡 最佳实践:在工具组件内部,优先使用注入的 agentState + * + * 注意:当配置了 subscribeKey 时,agentState 直接就是订阅的状态对象, + * 而不是整个 stateMap。例如:subscribeKey 返回 taskId,则 agentState 就是 stateMap[taskId] + */ +const ImageGenProgress: React.FC> = ({ + args, + agentState, // 使用注入的 agentState(已经是 taskId 对应的状态对象) + status: toolStatus, + error: toolError, +}) => { + // agentState 已经是 taskId 对应的状态对象,直接使用 + const genState = useMemo(() => { + if (!agentState) { + return null; + } + return agentState as ImageGenState; + }, [agentState]); + + // 工具调用错误 + if (toolStatus === 'error') { + return ( + +
解析参数失败: {toolError?.message}
+
+ ); + } + + // 等待状态数据 + if (!genState) { + return ( + +
等待任务开始...
+
+ ); + } + + const { status, progress, message, imageUrl, error } = genState; + + // 渲染不同状态的 UI + const renderContent = () => { + switch (status) { + case 'preparing': + return ( + +
+ + 准备生成图片... +
+ +
{message}
+
+ ); + + case 'generating': + return ( + +
+ + 正在生成图片... +
+ +
{message}
+
+ ); + + case 'completed': + return ( + +
+ + 图片生成完成 +
+ {imageUrl && ( + + )} +
+ ); + + case 'failed': + return ( + +
+ + 图片生成失败 +
+
{error || '未知错误'}
+
+ ); + + default: + return null; + } + }; + + return ( + + {renderContent()} + + ); +}; + +// 图片生成工具调用配置 +const imageGenActions: AgentToolcallConfig[] = [ + { + name: 'generate_image', + description: '生成图片', + parameters: [ + { name: 'taskId', type: 'string', required: true }, + { name: 'prompt', type: 'string', required: true }, + ], + // 不需要订阅状态,只是声明工具 + component: ({ args }) => ( + +
+ 🎨 开始生成图片 +
+
+ 提示词: {args?.prompt} +
+
+ ), + }, + { + name: 'show_progress', + description: '展示图片生成进度', + parameters: [ + { name: 'taskId', type: 'string', required: true }, + ], + // 配置 subscribeKey,告诉 ToolCallRenderer 订阅哪个状态 key + subscribeKey: (props) => props.args?.taskId, + // 组件会自动接收注入的 agentState + component: ImageGenProgress, + }, +]; + +/** + * 图片生成 Agent 聊天组件 + * 演示如何使用 useAgentToolcall 和 useAgentState 实现工具调用和状态订阅 + */ +export default function ImageGenAgentChat() { + const listRef = useRef(null); + const [inputValue, setInputValue] = useState('请帮我生成一张赛博朋克风格的城市夜景图片'); + + // 注册图片生成工具 + useAgentToolcall(imageGenActions); + + // 创建聊天服务配置 + const createChatServiceConfig = () => ({ + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/image-gen`, + protocol: 'agui' as const, + stream: true, + onError: (err: Error | Response) => { + console.error('图片生成服务错误:', err); + }, + onRequest: (innerParams: ChatRequestParams) => { + const { prompt, toolCallMessage } = innerParams; + return { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + uid: 'image_gen_uid', + prompt, + toolCallMessage, + }), + }; + }, + }); + + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: createChatServiceConfig(), + }); + + const senderLoading = useMemo(() => status === 'pending' || status === 'streaming', [status]); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { variant: 'base', placement: 'right' }, + assistant: { + placement: 'left', + handleActions: { + suggestion: (data) => { + setInputValue(data.content.prompt); + }, + } + }, + }; + + // 操作栏配置 + const getActionBar = (isLast: boolean): TdChatActionsName[] => { + const actions: TdChatActionsName[] = ['good', 'bad']; + if (isLast) actions.unshift('replay'); + return actions; + }; + + // 操作处理 + const handleAction = (name: string) => { + if (name === 'replay') { + chatEngine.regenerateAIMessage(); + } + }; + + // 处理工具调用响应(如果需要交互式工具) + const handleToolCallRespond = async (toolcall: ToolCall, response: T) => { + const tools = chatEngine.getToolcallByName(toolcall.toolCallName) || {}; + await chatEngine.sendAIMessage({ + params: { + prompt: inputValue, + toolCallMessage: { ...tools, result: JSON.stringify(response) }, + }, + sendRequest: true, + }); + }; + + // 渲染消息内容 + const renderMessageContent = (item: AIMessageContent, index: number, isLast: boolean): ReactNode => { + if (item.type === 'suggestion' && !isLast) { + return
; + } + if (item.type === 'toolcall' && item.data) { + return ( +
+ +
+ ); + } + return null; + }; + + // 渲染消息体 + const renderMsgContents = (message: ChatMessagesData, isLast: boolean): ReactNode => ( + <> + {message.content?.map((item, index) => renderMessageContent(item, index, isLast))} + {isAIMessage(message) && message.status === 'complete' ? ( + + ) : ( + isLast && message.status !== 'stop' && ( +
+ +
+ ) + )} + + ); + + // 发送消息 + const handleSend = async (e: CustomEvent) => { + await chatEngine.sendUserMessage({ prompt: e.detail.value }); + setInputValue(''); + }; + + return ( +
+ + {messages.map((message, idx) => ( + + {renderMsgContents(message, idx === messages.length - 1)} + + ))} + + setInputValue(e.detail)} + onSend={handleSend as any} + onStop={() => chatEngine.abortChat()} + /> +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/agui-videoclip.tsx b/packages/pro-components/chat/chat-engine/_example/agui-videoclip.tsx new file mode 100644 index 0000000000..ce845f468d --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-videoclip.tsx @@ -0,0 +1,645 @@ +import React, { ReactNode, useRef, useState, useEffect, useCallback, useMemo } from 'react'; +import { + type TdChatMessageConfig, + ChatList, + ChatSender, + ChatMessage, + ChatActionBar, + isAIMessage, + getMessageContentForCopy, + TdChatSenderParams, + ChatLoading, + TdChatActionsName, + ToolCallRenderer, + useAgentState, + useChat, + useAgentToolcall, + isUserMessage, +} from '@tdesign-react/chat'; +import { Steps, Card, Tag } from 'tdesign-react'; +import { + PlayCircleIcon, + VideoIcon, + CheckCircleFilledIcon, + CloseCircleFilledIcon, + TimeFilledIcon, + LoadingIcon, + ChevronRightIcon, +} from 'tdesign-icons-react'; +import type { ChatMessagesData, ChatRequestParams, AIMessageContent, ToolCall } from '@tdesign-react/chat'; +import type { AgentToolcallConfig, ToolcallComponentProps } from '../components/toolcall/types'; +import './videoclipAgent.css'; + +const { StepItem } = Steps; + +// 状态映射 +const statusMap: Record = { + pending: { theme: 'default', status: 'default', icon: }, + running: { theme: 'primary', status: 'process', icon: }, + completed: { theme: 'success', status: 'finish', icon: }, + failed: { theme: 'danger', status: 'error', icon: }, +}; + +// 自定义Hook:状态跟踪 +function useStepsStatusTracker(stepsData: any[]) { + const [prevStepsStatus, setPrevStepsStatus] = useState([]); + + // 获取当前步骤状态 + const currentStepsStatus = useMemo(() => stepsData.map((item) => item?.status || 'unknown'), [stepsData]); + + // 检查状态是否有变化 + const hasStatusChanged = useMemo( + () => JSON.stringify(currentStepsStatus) !== JSON.stringify(prevStepsStatus), + [currentStepsStatus, prevStepsStatus], + ); + + // 更新状态记录 + useEffect(() => { + if (hasStatusChanged) { + setPrevStepsStatus(currentStepsStatus); + } + }, [hasStatusChanged, currentStepsStatus]); + + return { hasStatusChanged, currentStepsStatus, prevStepsStatus }; +} + +// 步骤选择逻辑 +function findTargetStepIndex(stepsData: any[]): number { + // 优先查找状态为running的步骤 + let targetStepIndex = stepsData.findIndex((item) => item && item.status === 'running'); + + // 如果没有running的步骤,查找最后一个completed的步骤 + if (targetStepIndex === -1) { + for (let i = stepsData.length - 1; i >= 0; i--) { + if (stepsData[i] && stepsData[i].status === 'completed') { + targetStepIndex = i; + break; + } + } + } + + return targetStepIndex; +} + +// 进度状态计算 +function calculateProgressStatus(stepsData: any[]) { + if (!stepsData || stepsData.length === 0) { + return { + timeRemain: '', + progressStatus: '视频剪辑准备中', + hasRunningSteps: false, + }; + } + + // 估算剩余时间 + const runningItems = stepsData.filter((item) => item && item.status === 'running'); + let timeRemainText = ''; + if (runningItems.length > 0) { + const timeMatch = runningItems[0].content?.match(/预估全部完成还需要(\d+)分钟/); + if (timeMatch && timeMatch[1]) { + timeRemainText = `预计剩余时间: ${timeMatch[1]}分钟`; + } + } + + // 获取当前进度状态 + const completedCount = stepsData.filter((item) => item && item.status === 'completed').length; + const totalCount = stepsData.length; + const runningCount = runningItems.length; + + let progressStatusText = '视频剪辑准备中'; + if (completedCount === totalCount) { + progressStatusText = '视频剪辑已完成'; + } else if (runningCount > 0) { + progressStatusText = `视频剪辑进行中 (${completedCount}/${totalCount})`; + } + + return { + timeRemain: timeRemainText, + progressStatus: progressStatusText, + hasRunningSteps: runningCount > 0, + }; +} + +// 消息头部组件 +interface MessageHeaderProps { + loading: boolean; + content: string; + timeRemain: string; +} + +const MessageHeader: React.FC = ({ loading, content, timeRemain }) => ( +
+
{loading ? : null}
+
{content}
+
{timeRemain}
+
+); + +// 子任务卡片组件 +interface SubTaskCardProps { + item: any; + idx: number; +} + +const SubTaskCard: React.FC = ({ item, idx }) => { + const itemStatus = statusMap[item.status] || statusMap.pending; + + const getTheme = () => { + switch (itemStatus.status) { + case 'finish': + return 'success'; + case 'process': + return 'primary'; + case 'error': + return 'danger'; + default: + return 'default'; + } + }; + + return ( + + {item.label} + + {item.status} + +
+ } + bordered + hoverShadow + > +
+
{item.content}
+ {item.status === 'completed' && ( + + )} +
+ + ); +}; + +const CustomUserMessage = ({ message }) => ( + <> + {message.content.map((content, index) => ( +
+ {content.data} +
+ ))} + +); + +// 视频剪辑Agent工具调用类型定义 +interface ShowStepsArgs { + stepId: string; +} + +interface VideoClipStepsProps { + /** + * 绑定到特定的状态key,如果指定则只显示该状态key的状态 + * 这样可以确保多轮对话时,每个消息的步骤显示都是独立的 + * 对于videoclip业务,这个stateKey通常就是runId + */ + boundStateKey?: string; + /** + * 状态订阅模式 + * latest: 订阅最新状态,适用于状态覆盖场景 + * bound: 订阅特定stateKey,适用于状态隔离场景 + */ + mode?: 'latest' | 'bound'; +} + +/** + * 使用状态订阅机制的视频剪辑步骤组件 + * 演示如何通过useAgentState订阅AG-UI状态事件 + */ +export const VideoClipSteps: React.FC = ({ boundStateKey }) => { + // 订阅AG-UI状态事件 + const { stateMap, currentStateKey } = useAgentState({ + subscribeKey: boundStateKey, + }); + + // 本地UI状态 + const [currentStep, setCurrentStep] = useState(0); + const [currentStepContent, setCurrentStepContent] = useState<{ + mainContent: string; + items: any[]; + }>({ mainContent: '', items: [] }); + const [isManualSelection, setIsManualSelection] = useState(false); + + // 可点击的状态 + const canClickState = ['completed', 'running']; + + // 提取当前组件关心的状态数据 + const stepsData = useMemo(() => { + const targetStateKey = boundStateKey || currentStateKey; + if (!stateMap || !targetStateKey || !stateMap[targetStateKey]) { + return []; + } + return stateMap[targetStateKey].items || []; + }, [stateMap, boundStateKey, currentStateKey]); + + // 使用状态跟踪Hook + const { hasStatusChanged } = useStepsStatusTracker(stepsData); + + // 处理步骤点击 + const handleStepChange = useCallback( + (stepIndex: number) => { + try { + if (!stepsData[stepIndex] || stepsData[stepIndex] === null) { + console.warn(`handleStepChange: 步骤${stepIndex}不存在或为null`); + return; + } + + const stepStatus = stepsData[stepIndex].status; + if (!canClickState.includes(stepStatus)) { + return; + } + + setIsManualSelection(true); + setCurrentStep(stepIndex); + const targetStep = stepsData[stepIndex]; + setCurrentStepContent({ + mainContent: targetStep.content || '', + items: targetStep.items || [], + }); + } catch (error) { + console.error('handleStepChange出错:', error); + } + }, + [canClickState, stepsData], + ); + + // 自动选择当前步骤 + useEffect(() => { + if (stepsData.length === 0) { + setCurrentStep(0); + setCurrentStepContent({ mainContent: '', items: [] }); + setIsManualSelection(false); + return; + } + + // 如果用户手动选择了步骤,不执行自动选择逻辑 + if (isManualSelection) { + return; + } + + // 如果有新的running步骤,重置手动选择标记 + const hasRunningStep = stepsData.some((item) => item && item.status === 'running'); + if (hasRunningStep && isManualSelection) { + setIsManualSelection(false); + return; // 让下次useEffect执行自动选择 + } + + try { + const targetStepIndex = findTargetStepIndex(stepsData); + + // 只有在目标步骤不同或状态有变化时才更新 + if ((targetStepIndex !== -1 && targetStepIndex !== currentStep) || hasStatusChanged) { + if (targetStepIndex !== -1) { + setCurrentStep(targetStepIndex); + const targetStep = stepsData[targetStepIndex]; + setCurrentStepContent({ + mainContent: targetStep.content || '', + items: targetStep.items || [], + }); + } else { + const firstValidIndex = stepsData.findIndex((item) => item !== null); + if (firstValidIndex !== -1) { + setCurrentStep(firstValidIndex); + const targetStep = stepsData[firstValidIndex]; + setCurrentStepContent({ + mainContent: targetStep.content || '', + items: targetStep.items || [], + }); + } else { + console.warn('useEffect: 未找到任何有效步骤'); + setCurrentStep(0); + setCurrentStepContent({ mainContent: '', items: [] }); + } + } + } + } catch (error) { + console.error('useEffect步骤选择出错:', error); + } + }, [stepsData, currentStep, hasStatusChanged, isManualSelection]); + + // 计算进度状态和其他UI信息 + const { timeRemain, progressStatus, hasRunningSteps } = useMemo( + () => calculateProgressStatus(stepsData), + [stepsData], + ); + + console.log('render: ', boundStateKey); + + return ( + } + bordered + hoverShadow + > +
+
+ + {stepsData.map((step, index) => { + const { status: stepStatus, label } = step; + const stepStatusConfig = statusMap[stepStatus] || statusMap.pending; + const canClick = canClickState.includes(stepStatus); + + return ( + + {label || `步骤${index + 1}`} + {canClick && } +
+ } + status={stepStatusConfig.status} + icon={stepStatusConfig.icon} + /> + ); + })} + +
+ +
+
{currentStepContent.mainContent}
+ + {currentStepContent.items && currentStepContent.items.length > 0 && ( +
+

子任务进度

+ {currentStepContent.items.map((item, idx) => ( + + ))} +
+ )} +
+ +
+ ); +}; + +// 视频剪辑Agent工具调用配置 +const videoclipActions: AgentToolcallConfig[] = [ + { + name: 'show_steps', + description: '显示视频剪辑步骤', + parameters: [{ name: 'stepId', type: 'string', required: true }], + component: ({ status, args, error }: ToolcallComponentProps) => { + if (status === 'error') { + return
解析参数失败: {error?.message}
; + } + // 使用绑定stateKey的VideoClipSteps组件,这样每个消息的步骤显示都是独立的 + // 对于videoclip业务,stepId实际上就是runId,我们将其作为stateKey使用 + const stateKey = args?.stepId; + return ; + }, + }, +]; + +interface MessageRendererProps { + item: AIMessageContent; + index: number; + message: ChatMessagesData; + isLast: boolean; +} + +/** + * 使用状态订阅机制的视频剪辑Agent聊天组件 + * 演示如何结合状态订阅和工具调用功能 + */ +export default function VideoClipAgentChatWithSubscription() { + const listRef = useRef(null); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState('请帮我剪辑一段李雪琴大笑的视频片段'); + + // 注册视频剪辑相关的 Agent Toolcalls + useAgentToolcall(videoclipActions); + + // 创建聊天服务配置 + const createChatServiceConfig = () => ({ + // 对话服务地址 - 使用 POST 请求 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/videoclip`, + protocol: 'agui' as const, + stream: true, + defaultMessages: [], + // 流式对话结束 + onComplete: (isAborted: boolean, params?: RequestInit, parsed?: any) => { + if (parsed?.result?.status === 'user_aborted') { + return { + status: 'stop', + }; + } + }, + // 流式对话过程中出错 + onError: (err: Error | Response) => { + console.error('视频剪辑服务错误:', err); + }, + // 流式对话过程中用户主动结束对话 + onAbort: async () => { + console.log('用户取消视频剪辑'); + }, + // 自定义请求参数 - 使用 POST 请求 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt, toolCallMessage } = innerParams; + const requestBody: any = { + uid: 'videoclip_agent_uid', + prompt, + agentType: 'videoclip-agent', + }; + + // 如果有用户输入数据,添加到请求中 + if (toolCallMessage) { + requestBody.toolCallMessage = toolCallMessage; + } + + return { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }; + }, + }); + + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: createChatServiceConfig(), + }); + + const senderLoading = React.useMemo(() => status === 'pending' || status === 'streaming', [status]); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + }, + assistant: { + placement: 'left', + chatContentProps: { + thinking: { + maxHeight: 120, + }, + }, + }, + }; + + const getChatActionBar = (isLast: boolean) => { + let filterToolcalls = ['replay', 'good', 'bad', 'copy']; + if (!isLast) { + filterToolcalls = filterToolcalls.filter((item) => item !== 'replay'); + } + return filterToolcalls; + }; + + const actionHandler = (name: string, data?: any) => { + switch (name) { + case 'replay': { + console.log('重新开始视频剪辑'); + chatEngine.regenerateAIMessage(); + return; + } + default: + console.log('触发操作', name, 'data', data); + } + }; + + // 处理工具调用响应 + const handleToolCallRespond = async (toolcall: ToolCall, response: T) => { + try { + // 构造新的请求参数 + const tools = chatEngine.getToolcallByName(toolcall.toolCallName) || {}; + const newRequestParams: ChatRequestParams = { + prompt: inputValue, + toolCallMessage: { + ...tools, + result: JSON.stringify(response), + }, + }; + + // 继续对话 + await chatEngine.sendAIMessage({ + params: newRequestParams, + sendRequest: true, + }); + listRef.current?.scrollList({ to: 'bottom' }); + } catch (error) { + console.error('提交工具调用响应失败:', error); + } + }; + + const renderMessageContent = ({ item, index, isLast }: MessageRendererProps): React.ReactNode => { + const { data, type } = item; + if (item.type === 'suggestion' && !isLast) { + // 只有最后一条消息才需要展示suggestion,其他消息将slot内容置空 + return
; + } + if (item.type === 'toolcall') { + // 使用统一的 ToolCallRenderer 处理所有工具调用 + return ( +
+ +
+ ); + } + + return null; + }; + + /** 渲染消息内容体 */ + const renderMsgContents = (message: ChatMessagesData, isLast: boolean): ReactNode => ( + <> + {message.content?.map((item, index) => renderMessageContent({ item, index, message, isLast }))} + + {isAIMessage(message) && message.status === 'complete' ? ( + + ) : ( + isLast && + message.status !== 'stop' && ( +
+ +
+ ) + )} + + ); + + const sendUserMessage = async (requestParams: ChatRequestParams) => { + await chatEngine.sendUserMessage(requestParams); + }; + + const inputChangeHandler = (e: CustomEvent) => { + setInputValue(e.detail); + }; + + const sendHandler = async (e: CustomEvent) => { + const { value } = e.detail; + const params = { + prompt: value, + }; + await sendUserMessage(params); + setInputValue(''); + }; + + const stopHandler = () => { + console.log('停止视频剪辑'); + chatEngine.abortChat(); + }; + + return ( +
+
+ {/* 聊天区域 */} + + {messages.map((message, idx) => ( + + {isUserMessage(message) && ( +
+ +
+ )} + {renderMsgContents(message, idx === messages.length - 1)} +
+ ))} +
+ +
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/agui.tsx b/packages/pro-components/chat/chat-engine/_example/agui.tsx new file mode 100644 index 0000000000..5d14acc57b --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui.tsx @@ -0,0 +1,237 @@ +import React, { ReactNode, useMemo, useRef, useState } from 'react'; +import { + type TdChatMessageConfig, + type ChatRequestParams, + type ChatMessagesData, + type TdChatActionsName, + type TdChatSenderParams, + ChatList, + ChatSender, + ChatMessage, + TdChatListApi, + TdChatSenderApi, + ChatActionBar, + isAIMessage, + getMessageContentForCopy, + AGUIAdapter, +} from '@tdesign-react/chat'; +import { Button, Space, MessagePlugin } from 'tdesign-react'; +import { useChat } from '../index'; +import CustomToolCallRenderer from './components/Toolcall'; + +export default function ComponentsBuild() { + const listRef = useRef(null); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState('AG-UI协议的作用是什么'); + const [loadingHistory, setLoadingHistory] = useState(false); + + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + // 聊天服务配置 + chatServiceConfig: { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/agui-simple`, + // 开启agui协议解析支持 + protocol: 'agui', + stream: true, + onStart: (chunk) => { + console.log('onStart', chunk); + }, + // 流式对话结束(aborted为true时,表示用户主动结束对话,params为请求参数) + onComplete: (aborted: boolean, params: RequestInit, event) => { + console.log('onComplete', aborted, params, event); + }, + // 流式对话过程中出错业务自定义行为 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + // 流式对话过程中用户主动结束对话业务自定义行为 + onAbort: async () => {}, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'agent_uid', + prompt, + }), + }; + }, + }, + }); + + const senderLoading = useMemo(() => { + if (status === 'pending' || status === 'streaming') { + return true; + } + return false; + }, [status]); + + // 加载历史消息 + const loadHistoryMessages = async () => { + setLoadingHistory(true); + try { + const response = await fetch(`http://127.0.0.1:3000/api/conversation/history?type=simple`); + const result = await response.json(); + if (result.success && result.data) { + const messages = AGUIAdapter.convertHistoryMessages(result.data); + chatEngine.setMessages(messages); + listRef.current?.scrollList({ to: 'bottom' }); + } + } catch (error) { + console.error('加载历史消息出错:', error); + MessagePlugin.error('加载历史消息出错'); + } finally { + setLoadingHistory(false); + } + }; + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + }, + assistant: { + placement: 'left', + // 内置的消息渲染配置 + chatContentProps: { + thinking: { + maxHeight: 300, + }, + reasoning: { + maxHeight: 300, + defaultCollapsed: false, + }, + }, + }, + }; + + const getChatActionBar = (isLast: boolean) => { + let filterActions = ['replay', 'good', 'bad', 'copy']; + if (!isLast) { + // 只有最后一条AI消息才能重新生成 + filterActions = filterActions.filter((item) => item !== 'replay'); + } + return filterActions; + }; + + const actionHandler = (name: string, data?: any) => { + switch (name) { + case 'replay': { + chatEngine.regenerateAIMessage(); + return; + } + default: + console.log('触发action', name, 'data', data); + } + }; + + /** 渲染消息内容体 */ + const renderMsgContents = (message: ChatMessagesData, isLast: boolean): ReactNode => { + const contentElements = message.content?.map((item, index) => { + const { data, type } = item; + + if (type === 'reasoning') { + // reasoning 类型包含一个 data 数组,需要遍历渲染每个子项 + return data.map((subItem: any, subIndex: number) => { + if (subItem.type === 'toolcall') { + return ( +
+ +
+ ); + } + return null; + }); + } + + return null; + }); + + return ( + <> + {contentElements} + {isAIMessage(message) && message.status === 'complete' ? ( + + ) : null} + + ); + }; + + const sendUserMessage = async (requestParams: ChatRequestParams) => { + await chatEngine.sendUserMessage(requestParams); + listRef.current?.scrollList({ to: 'bottom' }); + }; + + const inputChangeHandler = (e: CustomEvent) => { + setInputValue(e.detail); + }; + + const sendHandler = async (e: CustomEvent) => { + const { value } = e.detail; + const params = { + prompt: value, + }; + await sendUserMessage(params); + setInputValue(''); + }; + + const stopHandler = () => { + console.log('stopHandler'); + chatEngine.abortChat(); + }; + + return ( +
+ {/* 历史消息加载控制栏 */} +
+ + + + +
+ + + {messages.map((message, idx) => ( + + {renderMsgContents(message, idx === messages.length - 1)} + + ))} + + +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/basic.tsx b/packages/pro-components/chat/chat-engine/_example/basic.tsx new file mode 100644 index 0000000000..7416d17c9c --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/basic.tsx @@ -0,0 +1,77 @@ +import React, { useRef, useState } from 'react'; +import { + ChatList, + ChatSender, + ChatMessage, + type SSEChunkData, + type AIMessageContent, + type TdChatSenderParams, +} from '@tdesign-react/chat'; +import { useChat } from '@tdesign-react/chat'; + +/** + * 快速开始示例 + * + * 学习目标: + * - 使用 useChat Hook 创建聊天引擎 + * - 组合 ChatList、ChatMessage、ChatSender 组件 + * - 理解 chatEngine、messages、status 的作用 + */ +export default function BasicExample() { + const [inputValue, setInputValue] = useState(''); + + // 使用 useChat Hook 创建聊天引擎 + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal', + stream: true, + // 数据转换 + onMessage: (chunk: SSEChunkData): AIMessageContent => { + const { type, ...rest } = chunk.data; + return { + type: 'markdown', + data: rest?.msg || '', + }; + }, + }, + }); + + // 发送消息 + const handleSend = async (e: CustomEvent) => { + const { value } = e.detail; + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + // 停止生成 + const handleStop = () => { + chatEngine.abortChat(); + }; + + return ( +
+ {/* 消息列表 */} + + {messages.map((message) => ( + + ))} + + + {/* 输入框 */} + setInputValue(e.detail)} + onSend={handleSend} + onStop={handleStop} + /> +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/components/HotelCard.tsx b/packages/pro-components/chat/chat-engine/_example/components/HotelCard.tsx new file mode 100644 index 0000000000..5c135f9121 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/HotelCard.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Card, Tag } from 'tdesign-react'; +import { HomeIcon } from 'tdesign-icons-react'; + +interface HotelCardProps { + hotels: any[]; +} + +export const HotelCard: React.FC = ({ hotels }) => ( + +
+ + 酒店推荐 +
+
+ {hotels.map((hotel, index) => ( +
+
+ {hotel.name} +
+ + 评分 {hotel.rating} + + ¥{hotel.price}/晚 +
+
+
+ ))} +
+
+); diff --git a/packages/pro-components/chat/chat-engine/_example/components/HumanInputForm.tsx b/packages/pro-components/chat/chat-engine/_example/components/HumanInputForm.tsx new file mode 100644 index 0000000000..449a51a1eb --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/HumanInputForm.tsx @@ -0,0 +1,188 @@ +import React, { useState } from 'react'; +import { Card, Input, Select, Checkbox, Button, Space } from 'tdesign-react'; +import { UserIcon } from 'tdesign-icons-react'; + +export interface FormField { + name: string; + label: string; + type: 'number' | 'select' | 'multiselect'; + required: boolean; + placeholder?: string; + options?: Array<{ value: string; label: string }>; + min?: number; + max?: number; +} + +export interface FormConfig { + title: string; + description: string; + fields: FormField[]; +} + +interface HumanInputFormProps { + formConfig: FormConfig; + onSubmit: (data: any) => void; + onCancel: () => void; + disabled?: boolean; +} + +export const HumanInputForm: React.FC = ({ formConfig, onSubmit, onCancel, disabled = false }) => { + const [formData, setFormData] = useState>({}); + const [errors, setErrors] = useState>({}); + + const handleInputChange = (fieldName: string, value: any) => { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + + // 清除错误 + if (errors[fieldName]) { + setErrors((prev) => ({ + ...prev, + [fieldName]: '', + })); + } + }; + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + formConfig.fields.forEach((field) => { + if (field.required) { + const value = formData[field.name]; + if (!value || (Array.isArray(value) && value.length === 0)) { + newErrors[field.name] = `${field.label}是必填项`; + } + } + + // 数字类型验证 + if (field.type === 'number' && formData[field.name]) { + const numValue = Number(formData[field.name]); + if (isNaN(numValue)) { + newErrors[field.name] = '请输入有效的数字'; + } else if (field.min !== undefined && numValue < field.min) { + newErrors[field.name] = `最小值不能小于${field.min}`; + } else if (field.max !== undefined && numValue > field.max) { + newErrors[field.name] = `最大值不能大于${field.max}`; + } + } + }); + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + if (validateForm()) { + onSubmit(formData); + } + }; + + const handleCancel = () => { + setFormData({}); + setErrors({}); + onCancel(); + }; + + const renderField = (field: FormField) => { + const value = formData[field.name]; + const error = errors[field.name]; + + switch (field.type) { + case 'number': + return ( +
+ handleInputChange(field.name, val)} + status={error ? 'error' : undefined} + tips={error} + /> +
+ ); + + case 'select': + return ( +
+ +
+ ); + + case 'multiselect': + return ( +
+
+ {field.options?.map((option) => ( + { + const currentValues = Array.isArray(value) ? value : []; + const newValues = checked + ? [...currentValues, option.value] + : currentValues.filter((v) => v !== option.value); + handleInputChange(field.name, newValues); + }} + > + {option.label} + + ))} +
+ {error &&
{error}
} +
+ ); + + default: + return null; + } + }; + + return ( + +
+ + {formConfig.title} +
+ +
{formConfig.description}
+ +
+ {formConfig.fields.map((field) => ( +
+ + {renderField(field)} +
+ ))} +
+ +
+ + + + +
+
+ ); +}; diff --git a/packages/pro-components/chat/chat-engine/_example/components/HumanInputResult.tsx b/packages/pro-components/chat/chat-engine/_example/components/HumanInputResult.tsx new file mode 100644 index 0000000000..08ba351ca1 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/HumanInputResult.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Card } from 'tdesign-react'; +import { UserIcon } from 'tdesign-icons-react'; + +interface HumanInputResultProps { + userInput: any; +} + +export const HumanInputResult: React.FC = ({ userInput }) => ( + +
+ + 出行偏好信息 +
+
您已提供的出行信息:
+
+ {userInput.travelers_count && ( +
+ 出行人数: + {userInput.travelers_count}人 +
+ )} + {userInput.budget_range && ( +
+ 预算范围: + {userInput.budget_range} +
+ )} + {userInput.preferred_activities && userInput.preferred_activities.length > 0 && ( +
+ 偏好活动: + {userInput.preferred_activities.join('、')} +
+ )} +
+
+); diff --git a/packages/pro-components/chat/chat-engine/_example/components/ItineraryCard.tsx b/packages/pro-components/chat/chat-engine/_example/components/ItineraryCard.tsx new file mode 100644 index 0000000000..eb2144c48d --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/ItineraryCard.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Card, Timeline, Tag } from 'tdesign-react'; +import { CalendarIcon, CheckCircleFilledIcon } from 'tdesign-icons-react'; + +interface ItineraryCardProps { + plan: any[]; +} + +export const ItineraryCard: React.FC = ({ plan }) => ( + +
+ + 行程安排 +
+ + {plan.map((dayPlan, index) => ( + } + > +
+ {dayPlan.activities.map((activity: string, actIndex: number) => ( + + {activity} + + ))} +
+
+ ))} +
+
+); diff --git a/packages/pro-components/chat/chat-engine/_example/components/PlanningStatePanel.tsx b/packages/pro-components/chat/chat-engine/_example/components/PlanningStatePanel.tsx new file mode 100644 index 0000000000..d2ee385725 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/PlanningStatePanel.tsx @@ -0,0 +1,136 @@ +import React, { useMemo } from 'react'; +import { Card, Timeline, Tag, Divider } from 'tdesign-react'; +import { CheckCircleFilledIcon, LocationIcon, LoadingIcon, TimeIcon, InfoCircleIcon } from 'tdesign-icons-react'; +import { useAgentState } from '../../hooks/useAgentState'; + +interface PlanningStatePanelProps { + className: string; + currentStep?: string; +} + +export const PlanningStatePanel: React.FC = ({ className, currentStep }) => { + // 规划状态管理 - 用于右侧面板展示 + // 使用 useAgentState Hook 管理状态 + const { stateMap: planningState, currentStateKey: stateKey } = useAgentState(); + + const state = useMemo(() => { + if (!planningState || !stateKey || !planningState[stateKey]) { + return []; + } + return planningState[stateKey]; + }, [planningState, stateKey]); + + if (!state?.status) return null; + + const { itinerary, status } = state; // 定义步骤顺序和状态 + const allSteps = [ + { name: '查询天气', key: 'weather', completed: !!itinerary?.weather }, + { name: '行程规划', key: 'plan', completed: !!itinerary?.plan }, + { name: '酒店推荐', key: 'hotels', completed: !!itinerary?.hotels }, + ]; + + // 获取步骤状态 + const getStepStatus = (step: any) => { + // currentStep 查询天气 init {name: '天气查询', key: 'weather', completed: false} + if (step.completed) return 'completed'; + if (currentStep === step.name) { + return 'running'; + } + return 'pending'; + }; + + // 获取步骤图标 + const getStepIcon = (step: any) => { + const stepStatus = getStepStatus(step); + + switch (stepStatus) { + case 'completed': + return ; + case 'running': + return ; + default: + return ; + } + }; + + // 获取步骤标签 + const getStepTag = (step: any) => { + const stepStatus = getStepStatus(step); + + switch (stepStatus) { + case 'completed': + return ( + + 已完成 + + ); + case 'running': + return ( + + 进行中 + + ); + default: + return ( + + 等待中 + + ); + } + }; + + const getStatusText = () => { + if (status === 'finished') return '已完成'; + if (status === 'planning') return '规划中'; + return '准备中'; + }; + + const getStatusTheme = () => { + if (status === 'finished') return 'success'; + if (status === 'planning') return 'primary'; + return 'default'; + }; + + return ( +
+ +
+ + 规划进度 + + {getStatusText()} + +
+ +
+ + {allSteps.map((step) => ( + +
+
{step.name}
+ {getStepTag(step)} +
+
+ ))} +
+
+ + {/* 显示最终结果摘要 */} + {status === 'finished' && itinerary && ( +
+ +
+ + 规划摘要 +
+
+ {itinerary.weather &&
• 天气信息: {itinerary.weather.length}天预报
} + {itinerary.plan &&
• 行程安排: {itinerary.plan.length}天计划
} + {itinerary.hotels &&
• 酒店推荐: {itinerary.hotels.length}个选择
} +
+
+ )} +
+
+ ); +}; diff --git a/packages/pro-components/chat/chat-engine/_example/components/Toolcall.tsx b/packages/pro-components/chat/chat-engine/_example/components/Toolcall.tsx new file mode 100644 index 0000000000..42eea268b5 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/Toolcall.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { Collapse, Tag } from 'tdesign-react'; + +const { Panel } = Collapse; + +// 状态渲染函数 +const renderStatusTag = (status: 'pending' | 'streaming' | 'complete') => { + const statusConfig = { + pending: { color: 'warning', text: '处理中' }, + streaming: { color: 'processing', text: '执行中' }, + complete: { color: 'success', text: '已完成' }, + }; + + const config = statusConfig[status] || statusConfig.complete; + + return ( + + {config.text} + + ); +}; + +export default function CustomToolCallRenderer({ + toolCall, + status = 'complete', +}: { + toolCall: any; + status: 'pending' | 'streaming' | 'complete'; +}) { + const { toolCallName, args, result } = toolCall; + + if (toolCallName === 'search') { + // 搜索工具的特殊处理 + let searchResult: any = null; + try { + searchResult = typeof result === 'string' ? JSON.parse(result) : result; + } catch (e) { + searchResult = { title: '解析错误', references: [] }; + } + + return ( + + + {searchResult && ( +
+
{searchResult.title}
+ {searchResult.references && searchResult.references.length > 0 && ( +
+ {searchResult.references.map((ref: any, idx: number) => ( + + ))} +
+ )} +
+ )} +
+
+ ); + } + + // 默认工具调用渲染 + return ( + + + {args && ( +
+ 参数: {typeof args === 'string' ? args : JSON.stringify(args)} +
+ )} + {result && ( +
+ 结果: {typeof result === 'string' ? result : JSON.stringify(result)} +
+ )} +
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/components/WeatherCard.tsx b/packages/pro-components/chat/chat-engine/_example/components/WeatherCard.tsx new file mode 100644 index 0000000000..6428a66aa2 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/WeatherCard.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Card } from 'tdesign-react'; +import { CloudIcon } from 'tdesign-icons-react'; + +interface WeatherCardProps { + weather: any[]; +} + +export const WeatherCard: React.FC = ({ weather }) => ( + +
+ + 未来5天天气预报 +
+
+ {weather.map((day, index) => ( +
+ 第{day.day}天 + {day.condition} + + {day.high}°/{day.low}° + +
+ ))} +
+
+); diff --git a/packages/pro-components/chat/chat-engine/_example/components/index.ts b/packages/pro-components/chat/chat-engine/_example/components/index.ts new file mode 100644 index 0000000000..be2a49d666 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/index.ts @@ -0,0 +1,6 @@ +export { WeatherCard } from './WeatherCard'; +export { ItineraryCard } from './ItineraryCard'; +export { HotelCard } from './HotelCard'; +export { PlanningStatePanel } from './PlanningStatePanel'; +export { HumanInputResult } from './HumanInputResult'; +export { HumanInputForm } from './HumanInputForm'; diff --git a/packages/pro-components/chat/chat-engine/_example/components/login.tsx b/packages/pro-components/chat/chat-engine/_example/components/login.tsx new file mode 100644 index 0000000000..077041df05 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/login.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Form, Input, Button, MessagePlugin } from 'tdesign-react'; +import type { FormProps } from 'tdesign-react'; + +import { DesktopIcon, LockOnIcon } from 'tdesign-icons-react'; + +const { FormItem } = Form; + +export default function BaseForm() { + const onSubmit: FormProps['onSubmit'] = (e) => { + if (e.validateResult === true) { + MessagePlugin.info('提交成功'); + } + }; + + const onReset: FormProps['onReset'] = (e) => { + MessagePlugin.info('重置成功'); + }; + + return ( +
+
+ + } placeholder="请输入账户名" /> + + + } clearable={true} placeholder="请输入密码" /> + + + + +
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/comprehensive.tsx b/packages/pro-components/chat/chat-engine/_example/comprehensive.tsx new file mode 100644 index 0000000000..9f4644d14b --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/comprehensive.tsx @@ -0,0 +1,287 @@ +import React, { ReactNode, useEffect, useRef, useState } from 'react'; +import { InternetIcon } from 'tdesign-icons-react'; +import { + ChatList, + ChatSender, + ChatMessage, + ChatActionBar, + ChatLoading, + useChat, + isAIMessage, + getMessageContentForCopy, + type SSEChunkData, + type AIMessageContent, + type ChatMessagesData, + type ChatRequestParams, + type TdChatSenderParams, + type TdChatActionsName, +} from '@tdesign-react/chat'; +import { Avatar, Button, Space } from 'tdesign-react'; + +/** + * 综合示例 + * + * 本示例展示如何综合使用多个功能: + * - 初始消息和建议问题 + * - 消息配置(样式、操作按钮) + * - 数据转换(思考过程、搜索结果、文本) + * - 请求配置(自定义参数) + * - 实例方法(重新生成、填充提示语) + * - 自定义插槽(输入框底部区域) + */ +export default function Comprehensive() { + const [inputValue, setInputValue] = useState(''); + const [activeR1, setR1Active] = useState(true); + const [activeSearch, setSearchActive] = useState(true); + const reqParamsRef = useRef<{ think: boolean; search: boolean }>({ think: false, search: false }); + + // 默认初始化消息 + const defaultMessages: ChatMessagesData[] = [ + { + id: '123', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '欢迎使用 TDesign Chatbot 智能助手,你可以这样问我:', + }, + { + type: 'suggestion', + status: 'complete', + data: [ + { + title: '南极的自动提款机叫什么名字', + prompt: '南极的自动提款机叫什么名字?', + }, + { + title: '南极自动提款机在哪里', + prompt: '南极自动提款机在哪里', + }, + ], + }, + ], + }, + ]; + + const { chatEngine, messages, status } = useChat({ + defaultMessages, + chatServiceConfig: { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal`, + stream: true, + + // 流式对话结束 + onComplete: (aborted: boolean, params: RequestInit) => { + console.log('onComplete', aborted, params); + }, + + // 流式对话过程中出错 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + + // 用户主动结束对话 + onAbort: async () => {}, + + // 自定义流式数据结构解析 + onMessage: (chunk: SSEChunkData): AIMessageContent | null => { + const { type, ...rest } = chunk.data; + switch (type) { + case 'search': + // 搜索 + return { + type: 'search', + data: { + title: rest.title || `搜索到${rest?.docs?.length || 0}条内容`, + references: rest?.content, + }, + }; + // 思考 + case 'think': + return { + type: 'thinking', + status: /耗时/.test(rest?.title) ? 'complete' : 'streaming', + data: { + title: rest.title || '深度思考中', + text: rest.content || '', + }, + }; + // 正文 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + }; + } + return null; + }, + + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'tdesign-chat', + prompt, + ...reqParamsRef.current, + }), + }; + }, + }, + }); + + // 更新请求参数 + useEffect(() => { + reqParamsRef.current = { + think: activeR1, + search: activeSearch, + }; + }, [activeR1, activeSearch]); + + // 操作按钮配置 + const getActionBar = (message: ChatMessagesData, isLast: boolean): TdChatActionsName[] => { + const actions: TdChatActionsName[] = ['copy', 'good', 'bad']; + if (isLast) { + actions.push('replay'); + } + return actions; + }; + + // 消息内容操作回调(用于 ChatMessage) + const handleMsgActions = { + suggestion: (data?: any) => { + console.log('点击建议问题', data); + // 点建议问题自动填入输入框 + setInputValue(data?.content?.prompt || ''); + // 也可以点建议问题直接发送消息 + // chatEngine.sendUserMessage({ prompt: data.content.prompt }); + }, + }; + + // 底部操作栏处理(用于 ChatActionBar) + const handleAction = (name: string, data?: any) => { + console.log('触发操作栏action', name, 'data', data); + switch (name) { + case 'copy': + console.log('复制'); + break; + case 'good': + console.log('点赞', data); + break; + case 'bad': + console.log('点踩', data); + break; + case 'replay': + console.log('重新生成'); + chatEngine.regenerateAIMessage(); + break; + default: + console.log('其他操作', name, data); + } + }; + + // 渲染消息内容 + const renderMessageContent = (message: ChatMessagesData, isLast: boolean): ReactNode => { + if (isAIMessage(message) && message.status === 'complete') { + return ( + + ); + } + return ; + }; + + // 发送消息 + const handleSend = async (e: CustomEvent) => { + const { value } = e.detail; + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + return ( +
+ + {messages.map((message, idx) => { + const isLast = idx === messages.length - 1; + // 假设只有单条thinking + const thinking = message.content.find((item) => item.type === 'thinking'); + + // 根据角色配置消息样式 + if (message.role === 'user') { + return ( + } + /> + ); + } + + // AI 消息配置 + return ( + + {renderMessageContent(message, isLast)} + + ); + })} + + + setInputValue(e.detail)} + onSend={handleSend} + onStop={() => chatEngine.abortChat()} + > + {/* 自定义输入框底部区域slot,可以增加模型选项 */} +
+ + + + +
+
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/custom-content.tsx b/packages/pro-components/chat/chat-engine/_example/custom-content.tsx new file mode 100644 index 0000000000..c76468d172 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/custom-content.tsx @@ -0,0 +1,437 @@ +import React, { useEffect, useRef, useState, ReactNode } from 'react'; +import { + BrowseIcon, + Filter3Icon, + ImageAddIcon, + Transform1Icon, + CopyIcon, + EditIcon, + SoundIcon, +} from 'tdesign-icons-react'; +import type { + SSEChunkData, + AIMessageContent, + ChatRequestParams, + ChatMessagesData, + ChatServiceConfig, + TdAttachmentItem, + TdChatSenderParams, + UploadFile, + ChatBaseContent, +} from '@tdesign-react/chat'; +import { ImageViewer, Skeleton, ImageViewerProps, Button, Dropdown, Space, Image, MessagePlugin } from 'tdesign-react'; +import { useChat, ChatList, ChatMessage, ChatSender, isAIMessage } from '@tdesign-react/chat'; + +/** + * 自定义内容渲染示例 - AI 生图助手 + * + * 本示例展示如何使用 ChatEngine 的插槽机制实现自定义渲染,包括: + * 1. 自定义内容渲染:扩展自定义内容类型(如图片预览) + * 2. 自定义操作栏:为消息添加自定义操作按钮 + * 3. 自定义输入框:添加参考图上传、比例选择、风格选择等功能 + * + * 插槽类型: + * - 内容插槽:`${content.type}-${index}` - 用于渲染自定义内容 + * - 操作栏插槽:`actionbar` - 用于渲染自定义操作栏 + * - 输入框插槽:`footer-prefix` - 用于自定义输入框底部区域 + * + * 实现步骤: + * 1. 扩展类型:通过 TypeScript 模块扩展声明自定义内容类型 + * 2. 解析数据:在 onMessage 中返回自定义类型的数据结构 + * 3. 监听变化:通过 useChat Hook 获取 messages 数据 + * 4. 植入插槽:使用 slot 属性渲染自定义组件 + * + * 学习目标: + * - 掌握插槽机制的使用方法 + * - 理解插槽命名规则和渲染时机 + * - 学会扩展自定义内容类型和操作栏 + * - 掌握 ChatSender 的自定义能力 + */ + +const RatioOptions = [ + { content: '1:1 头像', value: 1 }, + { content: '2:3 自拍', value: 2 / 3 }, + { content: '4:3 插画', value: 4 / 3 }, + { content: '9:16 人像', value: 9 / 16 }, + { content: '16:9 风景', value: 16 / 9 }, +]; + +const StyleOptions = [ + { content: '人像摄影', value: 'portrait' }, + { content: '卡通动漫', value: 'cartoon' }, + { content: '风景', value: 'landscape' }, + { content: '像素风', value: 'pixel' }, +]; + +// 默认初始化消息 +const mockData: ChatMessagesData[] = [ + { + id: '123', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '欢迎使用 TDesign 智能生图助手,请先写下你的创意,可以试试上传参考图哦~', + }, + ], + }, +]; + +// 1. 扩展自定义消息体类型 +declare global { + interface AIContentTypeOverrides { + imageview: ChatBaseContent< + 'imageview', + Array<{ + id?: number; + url: string; + }> + >; + } +} + +// 2. 自定义生图消息内容 +const BasicImageViewer = ({ images }) => { + if (images?.length === 0 || images?.every((img) => img === undefined)) { + return ; + } + + return ( + + {images.map((imgSrc, index) => { + const trigger: ImageViewerProps['trigger'] = ({ open }) => { + const mask = ( +
+ + 预览 + +
+ ); + + return ( + {'test'} + ); + }; + return ; + })} +
+ ); +}; + +// 3. 自定义操作栏组件 +const CustomActionBar = ({ textContent }: { textContent: string }) => { + const handlePlayAudio = () => { + MessagePlugin.info('播放语音'); + }; + + const handleEdit = () => { + MessagePlugin.info('编辑消息'); + }; + + const handleCopy = (content: string) => { + navigator.clipboard.writeText(content); + MessagePlugin.success('已复制到剪贴板'); + }; + + return ( + + + + + + ); +}; + +// 4. 自定义输入框底部控制栏组件 +const SenderFooterControls = ({ + ratio, + style, + onAttachClick, + onRatioChange, + onStyleChange, +}: { + ratio: number; + style: string; + onAttachClick: () => void; + onRatioChange: (data: any) => void; + onStyleChange: (data: any) => void; +}) => ( + + + + + + + + + +); + +export default function CustomContent() { + const senderRef = useRef(null); + const [ratio, setRatio] = useState(0); + const [style, setStyle] = useState(''); + const reqParamsRef = useRef<{ ratio: number; style: string; file?: string }>({ ratio: 0, style: '' }); + const [files, setFiles] = useState([]); + const [inputValue, setInputValue] = useState('请为 TDesign 设计三张品牌宣传图'); + + // 聊天服务配置 + const chatServiceConfig: ChatServiceConfig = { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal`, + stream: true, + // 流式对话结束(aborted为true时,表示用户主动结束对话,params为请求参数) + onComplete: (aborted: boolean, params: RequestInit) => { + console.log('onComplete', aborted, params); + }, + // 流式对话过程中出错业务自定义行为 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + // 流式对话过程中用户主动结束对话业务自定义行为 + onAbort: async () => {}, + // 自定义流式数据结构解析 + onMessage: (chunk: SSEChunkData): AIMessageContent => { + const { type, ...rest } = chunk.data; + switch (type) { + // 图片列表预览(自定义渲染) + case 'image': + return { + type: 'imageview', + status: 'complete', + data: JSON.parse(rest.content), + }; + // 正文 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + }; + } + }, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'tdesign-chat', + prompt, + image: true, + ...reqParamsRef.current, + }), + }; + }, + }; + + // 使用 useChat Hook 创建聊天引擎 + const { chatEngine, messages, status } = useChat({ + defaultMessages: mockData, + chatServiceConfig, + }); + + // 选中文件 + const onAttachClick = () => { + senderRef.current?.selectFile(); + }; + + // 文件上传 + const onFileSelect = (e: CustomEvent) => { + // 添加新文件并模拟上传进度 + const newFile = { + ...e.detail[0], + name: e.detail[0].name, + status: 'progress' as UploadFile['status'], + description: '上传中', + }; + + setFiles((prev) => [newFile, ...prev]); + + setTimeout(() => { + setFiles((prevState) => + prevState.map((file) => + file.name === newFile.name + ? { + ...file, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', // mock返回的图片地址 + status: 'success', + description: '上传成功', + } + : file, + ), + ); + }, 1000); + }; + + // 移除文件回调 + const onFileRemove = (e: CustomEvent) => { + setFiles(e.detail); + }; + + // 发送用户消息回调,这里可以自定义修改返回的prompt + const onSend = async (e: CustomEvent) => { + const { value, attachments } = e.detail; + setFiles([]); // 清除掉附件区域 + const enhancedPrompt = `${value},要求比例:${ + ratio === 0 ? '默认比例' : RatioOptions.filter((item) => item.value === ratio)[0].content + }, 风格:${style ? StyleOptions.filter((item) => item.value === style)[0].content : '默认风格'}`; + + await chatEngine.sendUserMessage({ + attachments, + prompt: enhancedPrompt, + }); + setInputValue(''); + }; + + // 停止生成 + const onStop = () => { + chatEngine.abortChat(); + }; + + const switchRatio = (data) => { + setRatio(data.value); + }; + + const switchStyle = (data) => { + setStyle(data.value); + }; + + useEffect(() => { + reqParamsRef.current = { + ratio, + style, + file: 'https://tdesign.gtimg.com/site/avatar.jpg', + }; + }, [ratio, style]); + + // 渲染自定义内容 + const renderMessageContent = (msg: ChatMessagesData, item: AIMessageContent, index: number): ReactNode => { + if (item.type === 'imageview') { + // 内容插槽命名规则:`${content.type}-${index}` + return ( +
+ img?.url)} /> +
+ ); + } + return null; + }; + + // 渲染自定义操作栏 + const renderActionBar = (message: ChatMessagesData): ReactNode => { + if (isAIMessage(message) && message.status === 'complete') { + // 提取消息文本内容用于复制 + const textContent = + message.content + ?.filter((item) => item.type === 'text' || item.type === 'markdown') + .map((item) => item.data) + .join('\n') || ''; + + // 操作栏插槽命名规则:actionbar + return ( +
+ +
+ ); + } + return null; + }; + + // 渲染消息内容体 + const renderMsgContents = (message: ChatMessagesData): ReactNode => ( + <> + {message.content?.map((item, index) => renderMessageContent(message, item, index))} + {renderActionBar(message)} + + ); + + return ( +
+
+ + {messages.map((message) => ( + + {renderMsgContents(message)} + + ))} + +
+ + setInputValue(e.detail)} + onSend={onSend} + onStop={onStop} + onFileSelect={onFileSelect} + onFileRemove={onFileRemove} + > + {/* 自定义输入框底部区域slot */} +
+ +
+
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/hookComponent.tsx b/packages/pro-components/chat/chat-engine/_example/hookComponent.tsx new file mode 100644 index 0000000000..0dd65abdde --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/hookComponent.tsx @@ -0,0 +1,202 @@ +import React, { ReactNode, useMemo, useRef, useState } from 'react'; +import { Avatar } from 'tdesign-react'; +import { + type SSEChunkData, + type TdChatMessageConfig, + type AIMessageContent, + type ChatRequestParams, + type ChatMessagesData, + ChatList, + ChatSender, + ChatMessage, + TdChatListApi, + TdChatSenderApi, + ChatActionBar, + isAIMessage, + getMessageContentForCopy, + TdChatSenderParams, + ChatLoading, + TdChatActionsName, +} from '@tdesign-react/chat'; +import { useChat } from '../index'; + +export default function ComponentsBuild() { + const listRef = useRef(null); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState('南极的自动提款机叫什么名字'); + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + // 聊天服务配置 + chatServiceConfig: { + // 对话服务地址f + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal`, + stream: true, + // 流式对话结束(aborted为true时,表示用户主动结束对话,params为请求参数) + onComplete: (aborted: boolean, params: RequestInit) => { + console.log('onComplete', aborted, params); + }, + // 流式对话过程中出错业务自定义行为 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + // 流式对话过程中用户主动结束对话业务自定义行为 + onAbort: async () => { + console.log('中断'); + }, + onMessage: (chunk: SSEChunkData): AIMessageContent => { + const { type, ...rest } = chunk.data; + switch (type) { + case 'search': + // 搜索 + return { + type: 'search', + data: { + title: rest.title || `搜索到${rest?.docs.length}条内容`, + references: rest?.content, + }, + }; + // 思考 + case 'think': + return { + type: 'thinking', + status: (status) => (/耗时/.test(rest?.title) ? 'complete' : status), + data: { + title: rest.title || '深度思考中', + text: rest.content || '', + }, + }; + // 正文 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + }; + } + }, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => ({ + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'abcd', + think: true, + search: true, + ...innerParams, + }), + }), + }, + }); + + const senderLoading = useMemo(() => { + if (status === 'pending' || status === 'streaming') { + return true; + } + return false; + }, [status]); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + avatar: , + }, + assistant: { + placement: 'left', + // 内置的消息渲染配置 + chatContentProps: { + thinking: { + maxHeight: 100, + }, + }, + }, + }; + + const getChatActionBar = (isLast: boolean) => { + let filterActions = ['replay', 'good', 'bad', 'copy']; + if (!isLast) { + // 只有最后一条AI消息才能重新生成 + filterActions = filterActions.filter((item) => item !== 'replay'); + } + return filterActions; + }; + + const actionHandler = (name: string, data?: any) => { + switch (name) { + case 'replay': { + console.log('自定义重新回复'); + chatEngine.regenerateAIMessage(); + return; + } + default: + console.log('触发action', name, 'data', data); + } + }; + + /** 渲染消息内容体 */ + const renderMsgContents = (message: ChatMessagesData, isLast: boolean): ReactNode => ( + <> + {isAIMessage(message) && message.status === 'complete' ? ( + + ) : ( + + )} + + ); + + const sendUserMessage = async (requestParams: ChatRequestParams) => { + await chatEngine.sendUserMessage(requestParams); + listRef.current?.scrollList({ to: 'bottom' }); + }; + + const inputChangeHandler = (e: CustomEvent) => { + setInputValue(e.detail); + }; + + const sendHandler = async (e: CustomEvent) => { + const { value } = e.detail; + const params = { + prompt: value, + abc: 1, + }; + await sendUserMessage(params); + setInputValue(''); + }; + + const stopHandler = () => { + chatEngine.abortChat(); + }; + + const onScrollHandler = (e) => { + // console.log('===scroll', e, e.detail); + }; + + return ( +
+ + {messages.map((message, idx) => ( + + {renderMsgContents(message, idx === messages.length - 1)} + + ))} + + +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/initial-messages.tsx b/packages/pro-components/chat/chat-engine/_example/initial-messages.tsx new file mode 100644 index 0000000000..c68ec84a44 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/initial-messages.tsx @@ -0,0 +1,194 @@ +import React, { useState } from 'react'; +import { + ChatList, + ChatSender, + ChatMessage, + useChat, + type SSEChunkData, + type AIMessageContent, + type ChatMessagesData, + type TdChatSenderParams, +} from '@tdesign-react/chat'; +import { Button, Space } from 'tdesign-react'; + +/** + * 初始化消息示例 + * + * 学习目标: + * - 使用 defaultMessages 设置欢迎语和建议问题 + * - 通过 chatEngine.setMessages 动态加载历史消息 + * - 实现点击建议问题填充输入框 + */ +export default function InitialMessages() { + const [inputValue, setInputValue] = useState(''); + const [hasHistory, setHasHistory] = useState(false); + + // 初始化消息 + const defaultMessages: ChatMessagesData[] = [ + { + id: 'welcome', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '你好!我是 TDesign 智能助手,有什么可以帮助你的吗?', + }, + { + type: 'suggestion', + status: 'complete', + data: [ + { + title: 'TDesign 是什么?', + prompt: '请介绍一下 TDesign 设计体系', + }, + { + title: '如何快速上手?', + prompt: 'TDesign React 如何快速开始使用?', + }, + { + title: '有哪些组件?', + prompt: 'TDesign 提供了哪些常用组件?', + }, + ], + }, + ], + }, + ]; + + // 使用 useChat Hook + const { chatEngine, messages, status } = useChat({ + defaultMessages, + chatServiceConfig: { + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal', + stream: true, + onMessage: (chunk: SSEChunkData): AIMessageContent => { + const { type, ...rest } = chunk.data; + return { + type: 'markdown', + data: rest?.msg || '', + }; + }, + }, + }); + + // 模拟历史消息数据(通常从后端接口获取) + const historyMessages: ChatMessagesData[] = [ + { + id: 'history-1', + role: 'user', + datetime: '2024-01-01 10:00:00', + content: [ + { + type: 'text', + data: 'TDesign 支持哪些框架?', + }, + ], + }, + { + id: 'history-2', + role: 'assistant', + datetime: '2024-01-01 10:00:05', + status: 'complete', + content: [ + { + type: 'markdown', + data: 'TDesign 目前支持以下框架:\n\n- **React**\n- **Vue 2/3**\n- **Flutter**\n- **小程序**', + }, + ], + }, + { + id: 'history-3', + role: 'user', + datetime: '2024-01-01 10:01:00', + content: [ + { + type: 'text', + data: '如何安装 TDesign React?', + }, + ], + }, + { + id: 'history-4', + role: 'assistant', + datetime: '2024-01-01 10:01:03', + status: 'complete', + content: [ + { + type: 'markdown', + data: '安装 TDesign React 非常简单:\n\n```bash\nnpm install tdesign-react\n```', + }, + ], + }, + ]; + + // 加载历史消息 + const loadHistory = () => { + chatEngine.setMessages(historyMessages, 'replace'); + setHasHistory(true); + }; + + // 清空消息 + const clearMessages = () => { + chatEngine.setMessages([], 'replace'); + setHasHistory(false); + }; + + // 发送消息 + const handleSend = async (e: CustomEvent) => { + const { value } = e.detail; + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + // 点击建议问题 + const handleSuggestionClick = (prompt: string) => { + setInputValue(prompt); + }; + + return ( +
+ {/* 操作按钮 */} +
+
快捷指令:
+ + + + +
+ + {/* 聊天界面 */} +
+ + {messages.map((message) => ( + { + handleSuggestionClick(content.prompt); + }, + }} + /> + ))} + + + setInputValue(e.detail)} + onSend={handleSend} + onStop={() => chatEngine.abortChat()} + /> +
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/instance-methods.tsx b/packages/pro-components/chat/chat-engine/_example/instance-methods.tsx new file mode 100644 index 0000000000..96e4cacc95 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/instance-methods.tsx @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; +import { + ChatList, + ChatSender, + ChatMessage, + useChat, + type SSEChunkData, + type AIMessageContent, + type TdChatSenderParams, +} from '@tdesign-react/chat'; +import { Button, Space, MessagePlugin } from 'tdesign-react'; + +/** + * 实例方法示例 + * + * 学习目标: + * - 通过 chatEngine 调用实例方法 + * - 了解各种实例方法的使用场景 + * + * 方法分类: + * 1. 消息设置:sendUserMessage、sendSystemMessage、setMessages + * 2. 发送控制: regenerateAIMessage、abortChat + * 3. 获取状态 + */ +export default function InstanceMethods() { + const [inputValue, setInputValue] = useState(''); + + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal', + stream: true, + onMessage: (chunk: SSEChunkData): AIMessageContent => { + const { type, ...rest } = chunk.data; + return { + type: 'markdown', + data: rest?.msg || '', + }; + }, + }, + }); + + // 1. 发送用户消息 + const handleSendUserMessage = () => { + chatEngine.sendUserMessage({ + prompt: '这是通过实例方法发送的用户消息', + }); + }; + + const handleSendAIMessage = () => { + chatEngine.sendAIMessage({ + params: { + prompt: '这是通过实例方法发送的用户消息', + }, + content: [{ + type: 'text', + data: '这是通过实例方法发送的AI回答', + }], + sendRequest: false, + }); + }; + + // 2. 发送系统消息 + const handleSendSystemMessage = () => { + chatEngine.sendSystemMessage('这是一条系统通知消息'); + }; + + // 3. 填充提示语到输入框 + const handleAddPrompt = () => { + setInputValue('请介绍一下 TDesign'); + }; + + // 4. 批量设置消息 + const handleSetMessages = () => { + chatEngine.setMessages( + [ + { + id: `msg-${Date.now()}`, + role: 'assistant', + content: [{ type: 'text', data: '这是通过 setMessages 设置的消息' }], + status: 'complete', + }, + ], + 'replace', + ); + }; + + // 5. 清空消息 + const handleClearMessages = () => { + chatEngine.setMessages([], 'replace'); + }; + + // 6. 重新生成最后一条消息 + const handleRegenerate = () => { + chatEngine.regenerateAIMessage(); + }; + + // 7. 中止当前请求 + const handleAbort = () => { + chatEngine.abortChat(); + MessagePlugin.info('已中止当前请求'); + }; + + // 8. 获取当前状态 + const handleGetStatus = () => { + const statusInfo = { + chatStatus: status, + messagesCount: messages.length, + }; + console.log('当前状态:', statusInfo); + MessagePlugin.info(`状态: ${statusInfo.chatStatus}, 消息数: ${statusInfo.messagesCount}`); + }; + + // 发送消息 + const handleSend = async (e: CustomEvent) => { + const { value } = e.detail; + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + return ( +
+ {/* 操作按钮区域 */} +
+
快捷指令:
+ + + + + + + + + + + +
+ + {/* 聊天界面 */} +
+ + {messages.map((message) => ( + + ))} + + setInputValue(e.detail)} + onSend={handleSend} + onStop={() => chatEngine.abortChat()} + /> +
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/travel-actions.tsx b/packages/pro-components/chat/chat-engine/_example/travel-actions.tsx new file mode 100644 index 0000000000..6ba3e5c8a2 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/travel-actions.tsx @@ -0,0 +1,689 @@ +import React from 'react'; +import { Button, Select, Input, Checkbox, Card, Tag, Space, Divider, Typography, Alert, Loading } from 'tdesign-react'; +import { CloseIcon, InfoCircleIcon } from 'tdesign-icons-react'; +import type { AgentToolcallConfig, ToolcallComponentProps } from '@tdesign-react/chat'; + +// ==================== 类型定义 ==================== +// 天气显示 +interface WeatherArgs { + location: string; + date?: string; +} + +interface WeatherResult { + location: string; + temperature: string; + condition: string; + humidity: string; + windSpeed: string; +} + +// 行程规划 +interface PlanItineraryArgs { + destination: string; + days: number; + budget?: number; + interests?: string[]; +} + +interface PlanItineraryResult { + destination: string; + totalDays: number; + dailyPlans: DailyPlan[]; + totalBudget: number; + recommendations: string[]; + // handler 增强的字段 + optimized?: boolean; + localTips?: string[]; + processTime?: number; +} + +interface DailyPlan { + day: number; + activities: Activity[]; + estimatedCost: number; +} + +interface Activity { + time: string; + name: string; + description: string; + cost: number; + location: string; +} + +// 用户偏好设置 +interface TravelPreferencesArgs { + destination: string; + purpose: string; +} + +interface TravelPreferencesResult { + budget: number; + interests: string[]; + accommodation: string; + transportation: string; + confirmed: boolean; +} + +interface TravelPreferencesResponse { + budget: number; + interests: string[]; + accommodation: string; + transportation: string; +} + +// 酒店信息 +interface HotelArgs { + location: string; + checkIn: string; + checkOut: string; +} + +interface HotelResult { + hotels: Array<{ + name: string; + rating: number; + price: number; + location: string; + amenities: string[]; + }>; +} + +// ==================== 组件实现 ==================== + +// 天气显示组件(后端完全受控,无 handler) +const WeatherDisplay: React.FC> = ({ + status, + args, + result, + error, +}) => { + if (status === 'error') { + return ( + } title="获取天气信息失败"> + {error?.message} + + ); + } + + if (status === 'complete' && result) { + const weather = typeof result === 'string' ? JSON.parse(result) : result; + return ( + + {weather.location} 天气 + + } + bordered + hoverShadow + style={{ maxWidth: 400 }} + > + +
+ + + {weather.temperature} + +
+
+ + + {weather.condition} + +
+
+ + +
+
+ + +
+
+
+ ); + } + + if (status === 'inProgress') { + return ( + + + + + + + ); + } + + return ( + + + + + + + ); +}; + +// 行程规划组件(有 handler 进行数据后处理) +const PlanItinerary: React.FC> = ({ + status, + args, + result, + error, +}) => { + // 处理 result 可能是 Promise 的情况 + const [resolvedResult, setResolvedResult] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + + React.useEffect(() => { + if (result && typeof result === 'object' && 'then' in result && typeof (result as any).then === 'function') { + // result 是一个 Promise + setIsLoading(true); + (result as any) + .then((resolved: PlanItineraryResult) => { + setResolvedResult(resolved); + setIsLoading(false); + }) + .catch((err: any) => { + console.error('Failed to resolve result:', err); + setIsLoading(false); + }); + } else { + // result 是直接的对象 + const planResult = typeof result === 'string' ? JSON.parse(result) : result; + setResolvedResult(planResult as PlanItineraryResult); + } + }, [result]); + + if (status === 'error') { + return ( + } title="行程规划失败"> + {error?.message} + + ); + } + + if (status === 'complete' && resolvedResult) { + return ( + + {resolvedResult.destination} {resolvedResult.totalDays}日游行程 + + } + bordered + hoverShadow + style={{ maxWidth: 600 }} + > + +
+ + + ¥{resolvedResult.totalBudget} + +
+ + + + 每日行程 + {resolvedResult.dailyPlans.map((day, index) => ( + + +
+ + 第 {day.day} 天 + + + 预计花费: ¥{day.estimatedCost} + +
+ {day.activities.map((activity, actIndex) => ( +
+ + + {activity.time} + + + + +
+ ))} +
+
+ ))} + + {resolvedResult.recommendations && resolvedResult.recommendations.length > 0 && ( + <> + + 💡 推荐 + + {resolvedResult.recommendations.map((rec, index) => ( + + ))} + + + )} + + {resolvedResult.localTips && resolvedResult.localTips.length > 0 && ( + <> + + 🏠 本地贴士 + + {resolvedResult.localTips.map((tip, index) => ( + + ))} + + + )} +
+
+ ); + } + + if (status === 'inProgress' || isLoading) { + return ( + + + + + + + {args.budget && } + {args.interests && args.interests.length > 0 && } + + + ); + } + + return ( + + + + + + + ); +}; + +// 酒店推荐组件 +const HotelRecommend: React.FC> = ({ status, args, result, error }) => { + if (status === 'error') { + return ( + } title="获取酒店信息失败"> + {error?.message} + + ); + } + if (status === 'complete' && result) { + const hotels = typeof result === 'string' ? JSON.parse(result) : result; + return ( + + {args.location} 酒店推荐 + + } + bordered + hoverShadow + style={{ maxWidth: 500 }} + > + + {hotels.map((hotel: any, index: number) => ( + + +
+ + {hotel.name} + + + ¥{hotel.price}/晚 + +
+
+ + + {hotel.rating}分 + +
+
+ +
+
+
+ ))} +
+
+ ); + } + + if (status === 'inProgress') { + return ( + + + + + + + ); + } + + return ( + + + + + + + ); +}; + +// 旅行偏好设置组件(交互式,使用 props.respond) +const TravelPreferences: React.FC< + ToolcallComponentProps +> = ({ status, args, result, error, respond }) => { + const [budget, setBudget] = React.useState(5000); + const [interests, setInterests] = React.useState(['美食', '景点']); + const [accommodation, setAccommodation] = React.useState('酒店'); + const [transportation, setTransportation] = React.useState('高铁'); + + const interestOptions = ['美食', '景点', '购物', '文化', '自然', '历史', '娱乐', '运动']; + const accommodationOptions = ['酒店', '民宿', '青旅', '度假村']; + const transportationOptions = ['飞机', '高铁', '汽车', '自驾']; + + if (status === 'error') { + return ( + } title="设置偏好失败"> + {error?.message} + + ); + } + + if (status === 'complete' && result) { + return ( + + 偏好设置完成 + + } + bordered + hoverShadow + style={{ maxWidth: 500 }} + > + +
+ + + {args.destination} + +
+
+ + +
+
+ + + ¥{result.budget} + +
+
+ + + {result.interests.map((interest, index) => ( + + {interest} + + ))} + +
+
+ + + {result.accommodation} + +
+
+ + + {result.transportation} + +
+
+
+ ); + } + + if (status === 'inProgress') { + return ( + + + + + + + ); + } + + if (status === 'executing') { + return ( + + 设置您的 {args.destination} 旅行偏好 + + } + bordered + hoverShadow + style={{ maxWidth: 500 }} + > + +
+ + setBudget(Number(value))} + style={{ width: '100%', marginTop: 8 }} + /> +
+ +
+ +
+ + + {interestOptions.map((option) => ( + + ))} + + +
+
+ +
+ + +
+ +
+ + +
+ + + + + + + +
+
+ ); + } + + return ( + + + + + + + ); +}; + +// ==================== 智能体动作配置 ==================== + +// 天气预报工具配置 - 非交互式(完全依赖后端数据) +export const weatherForecastAction: AgentToolcallConfig = { + name: 'get_weather_forecast', + description: '获取天气预报信息', + parameters: [ + { name: 'location', type: 'string', required: true }, + { name: 'date', type: 'string', required: false }, + ], + // 没有 handler,完全依赖后端返回的 result + component: WeatherDisplay, +}; + +// 行程规划工具配置 - 有 handler 进行数据后处理 +export const itineraryPlanAction: AgentToolcallConfig = { + name: 'plan_itinerary', + description: '规划旅游行程', + parameters: [ + { name: 'destination', type: 'string', required: true }, + { name: 'days', type: 'number', required: true }, + { name: 'budget', type: 'number', required: false }, + { name: 'interests', type: 'array', required: false }, + ], + component: PlanItinerary, + // handler 作为数据后处理器,增强后端返回的数据 + handler: async (args: PlanItineraryArgs, backendResult?: any): Promise => { + const startTime = Date.now(); + + // 如果后端提供了完整数据,进行增强处理 + if (backendResult && backendResult.dailyPlans) { + // 添加本地化贴士 + const localTips = [ + `${args.destination}的最佳游览时间是上午9-11点和下午3-5点`, + '建议提前预订热门景点门票', + '随身携带充电宝和雨具', + ]; + + // 优化行程安排 + const optimizedPlans = backendResult.dailyPlans.map((day: DailyPlan) => ({ + ...day, + activities: day.activities.sort((a, b) => a.time.localeCompare(b.time)), + })); + + return { + ...backendResult, + dailyPlans: optimizedPlans, + localTips, + optimized: true, + processTime: Date.now() - startTime, + }; + } + + // 否则返回默认结果 + const fallbackResult: PlanItineraryResult = { + dailyPlans: [], + totalDays: args.days, + totalBudget: args.budget || 180 * args.days, + localTips: ['暂时无法提供旅行方案,请稍后再试'], + optimized: false, + destination: args.destination, + recommendations: [], + processTime: Date.now() - startTime, + }; + return fallbackResult; + }, +}; + +// 酒店推荐工具配置 - 非交互式(完全依赖后端数据) +export const hotelRecommendAction: AgentToolcallConfig = { + name: 'get_hotel_details', + description: '获取酒店推荐信息', + parameters: [ + { name: 'location', type: 'string', required: true }, + { name: 'checkIn', type: 'string', required: true }, + { name: 'checkOut', type: 'string', required: true }, + ], + // 没有 handler,完全依赖后端返回的 result + component: HotelRecommend, +}; + +// 用户偏好收集工具配置 - 交互式(需要用户输入) +export const travelPreferencesAction: AgentToolcallConfig = { + name: 'get_travel_preferences', + description: '收集用户旅游偏好信息', + parameters: [ + { name: 'destination', type: 'string', required: true }, + { name: 'purpose', type: 'string', required: true }, + ], + // 没有 handler,使用交互式模式 + component: TravelPreferences, +}; + +// 用户偏好结果展示工具配置 - 用于历史消息展示 +// export const travelPreferencesResultAction: AgentToolcallConfig = { +// name: 'get_travel_preferences_result', +// description: '展示用户已输入的旅游偏好', +// parameters: [{ name: 'userInput', type: 'object', required: true }], +// // 没有 handler,纯展示组件 +// component: TravelPreferencesResult, +// }; + +// 导出所有 action 配置 +export const travelActions = [ + weatherForecastAction, + itineraryPlanAction, + hotelRecommendAction, + travelPreferencesAction, +]; diff --git a/packages/pro-components/chat/chat-engine/_example/travel.css b/packages/pro-components/chat/chat-engine/_example/travel.css new file mode 100644 index 0000000000..e1bba70b6c --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/travel.css @@ -0,0 +1,381 @@ +/* 旅游规划器容器 */ +.travel-planner-container { + display: flex; + flex-direction: column; + height: 100vh; + position: relative; +} + +.chat-content { + display: flex; + flex-direction: column; + flex: 1; + background: white; + border-radius: 8px; + overflow: hidden; + margin-top: 20px; +} + +/* 右下角固定规划状态面板 */ +.planning-panel-fixed { + position: fixed; + bottom: 20px; + right: 20px; + width: 220px; + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid #e7e7e7; + overflow: hidden; + transition: all 0.3s ease; +} + +.planning-panel-fixed:hover { + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .planning-panel-fixed { + position: fixed; + bottom: 10px; + right: 10px; + left: 10px; + width: auto; + max-height: 300px; + } + + .chat-content { + margin-bottom: 320px; /* 为固定面板留出空间 */ + } +} + +/* 内容卡片通用样式 */ +.content-card { + margin: 8px 0; +} + +/* 天气卡片样式 */ +.weather-card { + border: 1px solid #e7e7e7; +} + +.weather-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.weather-title { + font-weight: 600; + color: #333; +} + +.weather-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #f8f9fa; + border-radius: 6px; +} + +.weather-item .day { + font-weight: 500; + color: #333; +} + +.weather-item .condition { + color: #666; +} + +.weather-item .temp { + font-weight: 600; + color: #0052d9; +} + +/* 行程规划卡片样式 */ +.itinerary-card { + border: 1px solid #e7e7e7; +} + +.itinerary-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.itinerary-title { + font-weight: 600; + color: #333; +} + +.day-activities { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.activity-tag { + font-size: 12px; + padding: 4px 8px; +} + +/* 酒店推荐卡片样式 */ +.hotel-card { + border: 1px solid #e7e7e7; +} + +.hotel-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.hotel-title { + font-weight: 600; + color: #333; +} + +.hotel-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hotel-item { + padding: 12px; + border: 1px solid #e7e7e7; + border-radius: 6px; + background: #f8f9fa; +} + +.hotel-info { + display: flex; + justify-content: space-between; + align-items: center; +} + +.hotel-name { + font-weight: 500; + color: #333; +} + +.hotel-details { + display: flex; + align-items: center; + gap: 8px; +} + +.hotel-price { + font-weight: 600; + color: #e34d59; +} + +/* 规划状态面板样式 */ +.planning-state-panel { + border: 1px solid #e7e7e7; +} + +.panel-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.panel-title { + font-weight: 600; + color: #333; + flex: 1; +} + +.progress-steps { + margin: 16px 0; +} + +.step-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.step-title { + font-weight: 500; + color: #333; +} + +.summary-header { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.summary-content { + display: flex; + flex-direction: column; + gap: 4px; + color: #666; + font-size: 14px; +} + +/* Human-in-the-Loop 表单样式 */ +/* 动态表单组件样式 */ +.human-input-form { + border: 2px solid #0052d9; + border-radius: 8px; + padding: 20px; + background: #f8f9ff; + margin: 16px 0; + max-width: 500px; +} + +.form-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.form-title { + font-weight: 600; + color: #0052d9; + font-size: 16px; +} + +.form-description { + color: #666; + margin-bottom: 16px; + line-height: 1.5; +} + +.form-fields { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.field-label { + font-weight: 500; + color: #333; + font-size: 14px; +} + +.required { + color: #e34d59; + margin-left: 4px; +} + +.field-wrapper { + width: 100%; +} + +.checkbox-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.error-message { + color: #e34d59; + font-size: 12px; + margin-top: 4px; +} + +.form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid #e7e7e7; +} + +/* 加载动画 */ +.loading-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .human-input-form { + max-width: 100%; + padding: 16px; + } + + .form-actions { + flex-direction: column; + } +} + +/* 用户输入结果展示样式 */ +.human-input-result { + border: 1px solid #e7e7e7; + border-radius: 8px; + padding: 16px; + background: #f8f9fa; + max-width: 500px; +} + +.user-input-summary { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; +} + +.summary-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: white; + border-radius: 6px; + border: 1px solid #e7e7e7; +} + +.summary-item .label { + font-weight: 500; + color: #666; + min-width: 80px; +} + +.summary-item .value { + color: #333; + font-weight: 600; +} + +h5.t-typography { + margin: 0; +} \ No newline at end of file diff --git a/packages/pro-components/chat/chat-engine/_example/travelToolcall.tsx b/packages/pro-components/chat/chat-engine/_example/travelToolcall.tsx new file mode 100644 index 0000000000..d90730c757 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/travelToolcall.tsx @@ -0,0 +1,361 @@ +import React, { ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { Button } from 'tdesign-react'; +import { + ChatList, + ChatSender, + ChatMessage, + TdChatListApi, + TdChatSenderApi, + ChatActionBar, + isAIMessage, +} from '@tdesign-react/chat'; +import { LoadingIcon, HistoryIcon } from 'tdesign-icons-react'; +import type { + TdChatMessageConfig, + TdChatActionsName, + TdChatSenderParams, + ChatMessagesData, + ChatRequestParams, + ChatBaseContent, + AIMessageContent, + ToolCall, + AGUIHistoryMessage, +} from '@tdesign-react/chat'; +import { getMessageContentForCopy, AGUIAdapter } from '@tdesign-react/chat'; +import { ToolCallRenderer, useAgentToolcall, useChat } from '../index'; +import './travel.css'; +import { travelActions } from './travel-actions'; + +// 扩展自定义消息体类型 +declare module '@tdesign-react/chat' { + interface AIContentTypeOverrides { + weather: ChatBaseContent<'weather', { weather: any[] }>; + itinerary: ChatBaseContent<'itinerary', { plan: any[] }>; + hotel: ChatBaseContent<'hotel', { hotels: any[] }>; + planningState: ChatBaseContent<'planningState', { state: any }>; + } +} + +interface MessageRendererProps { + item: AIMessageContent; + index: number; + message: ChatMessagesData; +} + +// 加载历史消息的函数 +const loadHistoryMessages = async (): Promise => { + try { + const response = await fetch( + 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/api/conversation/history?type=default', + ); + if (response.ok) { + const result = await response.json(); + const historyMessages: AGUIHistoryMessage[] = result.data; + + // 使用AGUIAdapter的静态方法进行转换 + return AGUIAdapter.convertHistoryMessages(historyMessages); + } + } catch (error) { + console.error('加载历史消息失败:', error); + } + return []; +}; + +export default function TravelPlannerChat() { + const listRef = useRef(null); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState('请为我规划一个北京5日游行程'); + + // 注册旅游相关的 Agent Toolcalls + useAgentToolcall(travelActions); + + const [currentStep, setCurrentStep] = useState(''); + + // 加载历史消息 + const [defaultMessages, setDefaultMessages] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + const [hasLoadedHistory, setHasLoadedHistory] = useState(false); + + // 创建聊天服务配置 + const createChatServiceConfig = () => ({ + // 对话服务地址 - 使用 POST 请求 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/agui`, + protocol: 'agui' as const, + stream: true, + // 流式对话结束 + onComplete: (isAborted: boolean, params?: RequestInit, parsed?: any) => { + // 检查是否是等待用户输入的状态 + if (parsed?.result?.status === 'waiting_for_user_input') { + console.log('检测到等待用户输入状态,保持消息为 streaming'); + // 返回一个空的更新来保持消息状态为 streaming + return { + status: 'streaming', + }; + } + if (parsed?.result?.status === 'user_aborted') { + return { + status: 'stop', + }; + } + }, + // 流式对话过程中出错 + onError: (err: Error | Response) => { + console.error('旅游规划服务错误:', err); + }, + // 流式对话过程中用户主动结束对话 + onAbort: async () => { + console.log('用户取消旅游规划'); + }, + // AG-UI协议消息处理 - 优先级高于内置处理 + onMessage: (chunk): AIMessageContent | undefined => { + const { type, ...rest } = chunk.data; + + switch (type) { + // ========== 步骤开始/结束事件处理 ========== + case 'STEP_STARTED': + setCurrentStep(rest.stepName); + break; + + case 'STEP_FINISHED': + setCurrentStep(''); + break; + } + + return undefined; + }, + // 自定义请求参数 - 使用 POST 请求 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt, toolCallMessage } = innerParams; + const requestBody: any = { + uid: 'travel_planner_uid', + prompt, + agentType: 'travel-planner', + }; + + // 如果有用户输入数据,添加到请求中 + if (toolCallMessage) { + requestBody.toolCallMessage = toolCallMessage; + } + + return { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }; + }, + }); + + // 加载历史消息的函数 + const handleLoadHistory = async () => { + if (hasLoadedHistory) return; + + setIsLoadingHistory(true); + try { + const messages = await loadHistoryMessages(); + setDefaultMessages(messages); + setHasLoadedHistory(true); + } catch (error) { + console.error('加载历史消息失败:', error); + } finally { + setIsLoadingHistory(false); + } + }; + + const { chatEngine, messages, status } = useChat({ + defaultMessages, + chatServiceConfig: createChatServiceConfig(), + }); + + const senderLoading = useMemo(() => status === 'pending' || status === 'streaming', [status]); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + }, + assistant: { + placement: 'left', + chatContentProps: { + thinking: { + maxHeight: 120, + }, + }, + }, + }; + + const getChatActionBar = (isLast: boolean) => { + let filterToolcalls = ['replay', 'good', 'bad', 'copy']; + if (!isLast) { + filterToolcalls = filterToolcalls.filter((item) => item !== 'replay'); + } + return filterToolcalls; + }; + + const actionHandler = (name: string, data?: any) => { + switch (name) { + case 'replay': { + console.log('重新规划旅游行程'); + chatEngine.regenerateAIMessage(); + return; + } + case 'good': + console.log('用户满意此次规划'); + break; + case 'bad': + console.log('用户不满意此次规划'); + break; + default: + console.log('触发操作', name, 'data', data); + } + }; + + // 处理工具调用响应 + const handleToolCallRespond = useCallback( + async (toolcall: ToolCall, response: any) => { + try { + // 构造新的请求参数 + const tools = chatEngine.getToolcallByName(toolcall.toolCallName) || {}; + const newRequestParams: ChatRequestParams = { + toolCallMessage: { + ...tools, + result: JSON.stringify(response), + }, + }; + + // 继续对话 + await chatEngine.sendAIMessage({ + params: newRequestParams, + sendRequest: true, + }); + listRef.current?.scrollList({ to: 'bottom' }); + } catch (error) { + console.error('提交工具调用响应失败:', error); + } + }, + [chatEngine, listRef], + ); + + const renderMessageContent = useCallback( + ({ item, index }: MessageRendererProps): React.ReactNode => { + if (item.type === 'toolcall') { + const { data, type } = item; + + // 使用统一的 ToolCallRenderer 处理所有工具调用 + return ( +
+ +
+ ); + } + + return null; + }, + [handleToolCallRespond], + ); + + /** 渲染消息内容体 */ + const renderMsgContents = (message: ChatMessagesData, isLast: boolean): ReactNode => ( + <> + {message.content?.map((item, index) => renderMessageContent({ item, index, message }))} + + {isAIMessage(message) && message.status === 'complete' ? ( + + ) : null} + + ); + + const sendUserMessage = async (requestParams: ChatRequestParams) => { + // 重置规划状态 + await chatEngine.sendUserMessage(requestParams); + }; + + const inputChangeHandler = (e: CustomEvent) => { + setInputValue(e.detail); + }; + + const sendHandler = async (e: CustomEvent) => { + const { value } = e.detail; + const params = { + prompt: value, + }; + await sendUserMessage(params); + setInputValue(''); + }; + + const stopHandler = () => { + console.log('停止旅游规划'); + chatEngine.abortChat(); + }; + + if (isLoadingHistory) { + return ( +
+
+ + 加载历史消息中... +
+
+ ); + } + + return ( +
+ {/* 顶部工具栏 */} +
+

旅游规划助手

+ +
+ +
+ + {messages.map((message, idx) => ( + + {renderMsgContents(message, idx === messages.length - 1)} + + ))} + + +
+ + {/* 右下角固定规划状态面板 */} + {/* */} +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/videoclipAgent.css b/packages/pro-components/chat/chat-engine/_example/videoclipAgent.css new file mode 100644 index 0000000000..c5a7b2efd7 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/videoclipAgent.css @@ -0,0 +1,194 @@ +.videoclip-agent-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.chat-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.content-card { + margin: 8px 0; + width: 100%; +} + +.videoclip-header { + padding: 12px 16px; + border-bottom: 1px solid #e7e7e7; + background-color: #f9f9f9; +} + +.videoclip-transfer-view { + width: 100%; + margin-bottom: 16px; +} + +/* 状态内容布局 */ +.state-content { + display: flex; + gap: 24px; +} + +.main-steps { + flex: 0 0 200px; + border-right: 1px solid #eaeaea; + padding-right: 16px; +} + +.step-detail { + flex: 1; + padding-left: 8px; +} + +/* 步骤样式 */ +.steps-vertical { + height: 100%; +} + +.step-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.step-arrow { + color: #0052d9; + font-size: 14px; +} + +.main-content { + font-size: 14px; + color: #333; + line-height: 1.5; + white-space: pre-wrap; + margin-bottom: 16px; + padding: 8px; + background-color: #f9f9f9; + border-radius: 4px; +} + +/* 子步骤样式 */ +.sub-steps-container { + margin-top: 16px; +} + +.sub-steps-title { + font-size: 15px; + font-weight: 500; + margin-bottom: 12px; + color: #333; +} + +.sub-step-card { + margin-bottom: 12px; +} + +.sub-step-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sub-step-content { + padding: 8px 0; +} + +.sub-step-content pre { + font-size: 13px; + color: #666; + line-height: 1.4; + white-space: pre-wrap; + margin-bottom: 8px; +} + +.item-actions { + display: flex; + margin-top: 8px; + gap: 16px; +} + +.action-link { + display: flex; + align-items: center; + gap: 4px; + color: #0052d9; + font-size: 13px; + text-decoration: none; +} + +.action-link:hover { + text-decoration: underline; +} + +/* 状态图标样式 */ +.status-icon { + font-size: 16px; +} + +.status-icon.pending { + color: #999; +} + +.status-icon.running { + color: #0052d9; +} + +.status-icon.success { + color: #00a870; +} + +.status-icon.failed { + color: #e34d59; +} + +/* 消息头部样式 */ +.message-header { + display: flex; + align-items: center; + gap: 8px; +} + +.header-loading { + color: #0052d9; + animation: spin 1s linear infinite; +} + +.header-content { + font-weight: 500; + color: #333; +} + +.header-time { + margin-left: auto; + font-size: 13px; + color: #999; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* 工具调用样式 */ +.videoclip-toolcall { + padding: 12px; + border: 1px solid #eaeaea; + border-radius: 4px; + background-color: #f9f9f9; +} + +.videoclip-toolcall.error { + border-color: #ffccc7; + background-color: #fff2f0; + color: #e34d59; +} \ No newline at end of file diff --git a/packages/pro-components/chat/chat-engine/chat-engine.en-US.md b/packages/pro-components/chat/chat-engine/chat-engine.en-US.md new file mode 100644 index 0000000000..6419da72a7 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/chat-engine.en-US.md @@ -0,0 +1,310 @@ +--- +title: ChatEngine +description: A low-level conversational engine for AI agents, providing flexible Hook APIs for deep customization. +isComponent: true +spline: navigation +--- + +## Reading Guide + +ChatEngine is a low-level conversational engine that provides flexible Hook APIs for deep customization. It supports custom UI structures, message processing, and the AG-UI protocol, making it suitable for building complex AI agent applications such as tool calling, multi-step task planning, and state streaming. Compared to the Chatbot component, it offers greater flexibility and is ideal for scenarios requiring **deep customization of UI structure and message processing flow**. The Chatbot component itself is built on top of ChatEngine. + +We recommend following this progressive reading path: + +1. **Quick Start** - Learn the basic usage of the useChat Hook and how to compose components to build a chat interface +2. **Basic Usage** - Master key features including data processing, message management, UI customization, lifecycle, and custom rendering +3. **AG-UI Protocol** - Learn how to use the AG-UI protocol and its advanced features (tool calling, state subscription, etc.) + +> 💡 **Example Notes**: All examples are based on Mock SSE services. You can open the browser developer tools (F12), switch to the Network tab, and view the request and response data to understand the data format. + +## Quick Start + +The simplest example: use the `useChat` Hook to create a conversational engine, and compose `ChatList`, `ChatMessage`, and `ChatSender` components to build a chat interface. + +{{ basic }} + +## Basic Usage + +### Initial Messages + +Use `defaultMessages` to set static initial messages, or dynamically load message history via `chatEngine.setMessages`. + +{{ initial-messages }} + +### Data Processing + +`chatServiceConfig` is the core configuration of ChatEngine, controlling communication with the backend and data processing. It serves as the bridge between frontend components and backend services. Its roles include: + +- **Request Configuration** (endpoint, onRequest for setting headers and parameters) +- **Data Transformation** (onMessage: converting backend data to the format required by components) +- **Lifecycle Callbacks** (onStart, onComplete, onError, onAbort) + +Depending on the backend service protocol, there are two configuration approaches: + +- **Custom Protocol**: When the backend uses a custom data format that doesn't match the frontend component's requirements, you need to use `onMessage` for data transformation. +- **AG-UI Protocol**: When the backend service conforms to the [AG-UI Protocol](/react-chat/agui), you only need to set `protocol: 'agui'` without writing `onMessage` for data transformation, greatly simplifying the integration process. See the [AG-UI Protocol](#ag-ui-protocol) section below for details. + +The configuration usage in this section is consistent with Chatbot. For examples, refer to the [Chatbot Data Processing](/react-chat/components/chatbot#data-processing) section. + +### Instance Methods + +Control component behavior (message setting, send management, etc.) by calling [various methods](#chatengine-instance-methods) through `chatEngine`. + +{{ instance-methods }} + +### Custom Rendering + +Use the **dynamic slot mechanism** to implement custom rendering, including custom `content rendering`, custom `action bar`, and custom `input area`. + +- **Custom Content Rendering**: If you need to customize how message content is rendered, follow these steps: + + - 1. Extend Types: Declare custom content types via TypeScript + - 2. Parse Data: Return custom type data structures in `onMessage` + - 3. Listen to Changes: Monitor message changes via `onMessageChange` and sync to local state + - 4. Insert Slots: Loop through the `messages` array and use the `slot = ${content.type}-${index}` attribute to render custom components + +- **Custom Action Bar**: If the built-in [`ChatActionbar`](/react-chat/components/chat-actionbar) doesn't meet your needs, you can use the `slot='actionbar'` attribute to render a custom component. + +- **Custom Input Area**: If you need to customize the ChatSender input area, see available slots in [ChatSender Slots](/react-chat/components/chat-sender?tab=api#slots) + +{{ custom-content }} + +### Comprehensive Example + +After understanding the usage of the above basic properties, here's a complete example showing how to comprehensively use multiple features in production: initial messages, message configuration, data transformation, request configuration, instance methods, and custom slots. + +{{ comprehensive }} + +## AG-UI Protocol + +[AG-UI (Agent-User Interface)](https://docs.ag-ui.com/introduction) is a lightweight protocol designed specifically for AI Agent and frontend application interaction, focusing on real-time interaction, state streaming, and human-machine collaboration. ChatEngine has built-in support for the AG-UI protocol, enabling **seamless integration with backend services that conform to AG-UI standards**. + +### Basic Usage + +Enable AG-UI protocol support (`protocol: 'agui'`), and the component will automatically parse standard event types (such as `TEXT_MESSAGE_*`, `THINKING_*`, `TOOL_CALL_*`, `STATE_*`, etc.). Use the `AGUIAdapter.convertHistoryMessages` method to backfill message history that conforms to the [`AGUIHistoryMessage`](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chat-engine/adapters/agui/types.ts) data structure. + +{{ agui-basic }} + +### Tool Calling + +The AG-UI protocol supports AI Agents calling frontend tool components through `TOOL_CALL_*` events to enable human-machine collaboration. + +> **Protocol Compatibility Note**: `useAgentToolcall` and `ToolCallRenderer` are protocol-agnostic; they only depend on the [ToolCall data structure](#toolcall-object-structure) and don't care about the data source. The advantage of the AG-UI protocol is automation (backend directly outputs standard `TOOL_CALL_*` events), while regular protocols require manually converting backend data to the `ToolCall` structure in `onMessage`. Adapters can reduce the complexity of manual conversion. + +#### Core Hooks and Components + +ChatEngine provides several core Hooks around tool calling, each with its own responsibilities working together: + +- **`useAgentToolcall` Hook**: Registers tool configurations (metadata, parameters, UI components). Compared to traditional custom rendering approaches, it provides highly cohesive configuration, unified API interface, complete type safety, and better portability. See [FAQ](/react-chat/components/chat-engine?tab=demo#faq) below for details +- **`ToolCallRenderer` Component**: A unified renderer for tool calls, responsible for finding the corresponding configuration based on the tool name, parsing parameters, managing state, and rendering the registered UI component. Simply pass in the `toolCall` object to automatically complete rendering +- **`useAgentState` Hook**: Subscribes to AG-UI protocol's `STATE_SNAPSHOT` and `STATE_DELTA` events to get real-time task execution status. + +#### Usage Flow + +1. Use `useAgentToolcall` to register tool configurations (metadata, parameters, UI components) +2. Use the `ToolCallRenderer` component to render tool calls when rendering messages +3. `ToolCallRenderer` automatically finds configuration, parses parameters, manages state, and renders UI + +#### Basic Example + +A simulated image generation assistant Agent demonstrating core usage of tool calling and state subscription: + +- **Tool Registration**: Use `useAgentToolcall` to register the `generate_image` tool +- **State Subscription**: Use the injected `agentState` parameter to subscribe to image generation progress (preparing → generating → completed/failed) +- **Progress Display**: Real-time display of progress bar and status information +- **Result Presentation**: Display the image after generation is complete +- **Suggested Questions**: By returning `toolcallName: 'suggestion'`, you can seamlessly integrate with the built-in suggested questions component + +{{ agui-toolcall }} + +### Tool State Subscription + +In the AG-UI protocol, besides displaying state inside tool components, sometimes we also need to subscribe to and display tool execution status in **UI outside the conversation component** (such as a progress bar at the top of the page, a task list in the sidebar, etc.). The Agent service implements streaming of state changes and snapshots by adding `STATE_SNAPSHOT` and `STATE_DELTA` events during tool calling. + +To facilitate state subscription for external UI components, you can use `useAgentState` to get state data and render task execution progress and status information in real-time. For example, to display the current task's execution progress at the top of the page without showing it in the conversation flow, you can implement it like this: + +```javascript +// External progress panel component +const GlobalProgressBar: React.FC = () => { + // Subscribe to state using useAgentState + const { stateMap, currentStateKey } = useAgentState(); + + /* Backend pushes state data through STATE_SNAPSHOT and STATE_DELTA events, sample data as follows: + // + // STATE_SNAPSHOT (initial snapshot): + // data: {"type":"STATE_SNAPSHOT","snapshot":{"task_xxx":{"progress":0,"message":"Preparing to start planning...","items":[]}}} + // + // STATE_DELTA (incremental update, using JSON Patch format): + // data: {"type":"STATE_DELTA","delta":[ + // {"op":"replace","path":"/task_xxx/progress","value":20}, + // {"op":"replace","path":"/task_xxx/message","value":"Analyzing destination information"}, + // {"op":"replace","path":"/task_xxx/items","value":[{"label":"Analyzing destination information","status":"running"}]} + // ]} + */ + + // useAgentState internally handles these events automatically, merging snapshot and delta into stateMap + + // Get current task state + const currentState = currentStateKey ? stateMap[currentStateKey] : null; + + // items array contains information about each step of the task + // Each item contains: label (step name), status (state: running/completed/failed) + const items = currentState?.items || []; + const completedCount = items.filter((item: any) => item.status === 'completed').length; + + return ( +
+
+ Progress: {completedCount}/{items.length} +
+ {items.map((item: any, index: number) => ( +
+ {item.label} - {item.status} +
+ ))} +
+ ); +}; +``` + +When multiple external components need to access the same state, use the Provider pattern. Share state by using `AgentStateProvider` + `useAgentStateContext`. + +For a complete example, please refer to the [Comprehensive Example](#comprehensive-example) demonstration below. + +### Comprehensive Example + +Simulates a complete **travel planning Agent scenario**, demonstrating how to use the AG-UI protocol to build a complex **multi-step task planning** application. First collect user preferences (Human-in-the-Loop), then execute based on the submitted preferences: query weather, display planning steps through tool calls, and finally summarize to generate the final plan. + +**Core Features:** + +- **16 Standardized Event Types**: Complete demonstration of the AG-UI protocol event system +- **Multi-step Flow**: Support for executing complex tasks step by step (such as travel planning) +- **State Streaming**: Real-time application state updates, supporting state snapshots and incremental updates +- **Human-in-the-Loop**: Support for human-machine collaboration, inserting user input steps in the flow +- **Tool Calling**: Integration of external tool calls, such as weather queries, itinerary planning, etc. +- **External State Subscription**: Demonstrates how to subscribe to and display tool execution status outside the conversation component + +**Example Highlights:** + +1. **Three Typical Tool Calling Patterns** + + - Weather Query: Demonstrates basic `TOOL_CALL_*` event handling + - Planning Steps: Demonstrates `STATE_*` event subscription + automatic `agentState` injection + - User Preferences: Demonstrates Human-in-the-Loop interactive tools + +2. **State Usage Inside Tool Components** + + - Tool components automatically get state through the `agentState` parameter, no additional Hook needed + - Configure `subscribeKey` to tell the Renderer which state key to subscribe to + +3. **External UI State Subscription** + - Use `useAgentState` to subscribe to state outside the conversation component + - Real-time display of task execution progress and status information + +{{ agui-comprehensive }} + +## API + +### useChat + +A core Hook for managing chat state and lifecycle, initializing the chat engine, synchronizing message data, subscribing to state changes, and automatically handling resource cleanup when the component unmounts. + +#### Parameters + +| Parameter | Type | Description | Required | +| ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | -------- | +| defaultMessages | ChatMessagesData[] | Initial message list | N | +| chatServiceConfig | ChatServiceConfig | Chat service configuration, see [Chatbot Documentation](/react-chat/components/chatbot?tab=api#chatserviceconfig-configuration) | Y | + +#### Return Value + +| Return Value | Type | Description | +| ------------ | ------------------ | ------------------------------------------------------------------------------------------- | +| chatEngine | ChatEngine | Chat engine instance, see [ChatEngine Instance Methods](#chatengine-instance-methods) below | +| messages | ChatMessagesData[] | Current chat message list | +| status | ChatStatus | Current chat status (idle/pending/streaming/complete/stop/error) | + +### ChatEngine Instance Methods + +ChatEngine instance methods are completely consistent with Chatbot component instance methods. See [Chatbot Instance Methods Documentation](/react-chat/components/chatbot?tab=api#chatbot-instance-methods-and-properties). + +### useAgentToolcall + +A Hook for registering tool call configurations, supporting both automatic and manual registration modes. + +#### Parameters + +| Parameter | Type | Description | Required | +| --------- | ---------------------- | ------------------------ | -------- | --------- | -------------------------------------------------------------------------------------------------------- | --- | +| config | AgentToolcallConfig \\ | AgentToolcallConfig[] \\ | null \\ | undefined | Tool call configuration object or array, auto-registers when passed, manual registration when not passed | N | + +#### Return Value + +| Return Value | Type | Description | +| ------------- | ------------------------------- | ------------------------------ | ------------------------------------ | +| register | (config: AgentToolcallConfig \\ | AgentToolcallConfig[]) => void | Manually register tool configuration | +| unregister | (names: string \\ | string[]) => void | Unregister tool configuration | +| isRegistered | (name: string) => boolean | Check if tool is registered | +| getRegistered | () => string[] | Get all registered tool names | + +#### AgentToolcallConfig Configuration + +| Property | Type | Description | Required | +| ------------ | ------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------- | --- | +| name | string | Tool call name, must match the backend-defined tool name | Y | +| description | string | Tool call description | Y | +| parameters | ParameterDefinition[] | Parameter definition array | Y | +| component | React.ComponentType | Custom rendering component | Y | +| handler | (args, result?) => Promise | Handler function for non-interactive tools (optional) | N | +| subscribeKey | (props) => string \\ | undefined | State subscription key extraction function (optional) | N | + +#### ToolcallComponentProps Component Properties + +| Property | Type | Description | +| ---------- | ----------------------------- | -------------------------------------------------------------------- | -------------- | ------------- | ------- | ---------------- | +| status | 'idle' \\ | 'inProgress' \\ | 'executing' \\ | 'complete' \\ | 'error' | Tool call status | +| args | TArgs | Parsed tool call parameters | +| result | TResult | Tool call result | +| error | Error | Error information (when status is 'error') | +| respond | (response: TResponse) => void | Response callback function (for interactive tools) | +| agentState | Record | Subscribed state data (auto-injected after configuring subscribeKey) | + +### ToolCallRenderer + +A unified rendering component for tool calls, responsible for automatically finding configuration based on the tool name, parsing parameters, managing state, and rendering the corresponding UI component. + +#### Props + +| Property | Type | Description | Required | +| --------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------- | +| toolCall | ToolCall [Object Structure](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chat-engine/type.ts#L97) | Tool call object, containing toolCallName, args, result, etc. | Y | +| onRespond | (toolCall: ToolCall, response: any) => void | Response callback for interactive tools, used to return user input to backend | N | + +### useAgentState + +A Hook for subscribing to AG-UI protocol state events, providing a flexible state subscription mechanism. + +> 💡 **Usage Recommendation**: For detailed usage instructions and scenario examples, please refer to the [Tool State Subscription](#tool-state-subscription) section above. + +#### Parameters + +| Parameter | Type | Description | Required | +| --------- | ------------------ | ---------------------------------------- | -------- | +| options | StateActionOptions | State subscription configuration options | N | + +#### StateActionOptions Configuration + +| Property | Type | Description | Required | +| ------------ | ------------------- | ------------------------------------------------------------------------------------ | -------- | +| subscribeKey | string | Specify the stateKey to subscribe to, subscribes to the latest state when not passed | N | +| initialState | Record | Initial state value | N | + +#### Return Value + +| Return Value | Type | Description | +| --------------- | --------------------------------- | ---------------------------------------------- | ------------------------------------ | +| stateMap | Record | State map, format is { [stateKey]: stateData } | +| currentStateKey | string \\ | null | Currently active stateKey | +| setStateMap | (stateMap: Record \\ | Function) => void | Method to manually set the state map | +| getCurrentState | () => Record | Method to get the current complete state | +| getStateByKey | (key: string) => any | Method to get state for a specific key | diff --git a/packages/pro-components/chat/chat-engine/chat-engine.md b/packages/pro-components/chat/chat-engine/chat-engine.md new file mode 100644 index 0000000000..404a5ddbc7 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/chat-engine.md @@ -0,0 +1,314 @@ +--- +title: ChatEngine 对话引擎 +description: 智能体对话底层逻辑引擎,提供灵活的 Hook API 用于深度定制。 +isComponent: true +spline: navigation +--- + +## 阅读指引 + +ChatEngine 是一个底层对话引擎,提供灵活的 Hook API 用于深度定制。支持自定义 UI 结构、消息处理和 AG-UI 协议,适合构建复杂智能体应用,如工具调用、多步骤任务规划、状态流式传输等场景,相比 Chatbot 组件提供了更高的灵活性,适合需要**深度定制 UI 结构和消息处理流程**的场景。Chatbot组件本身也是基于 ChatEngine 构建的。 + +建议按以下路径循序渐进阅读: + +1. **快速开始** - 了解 useChat Hook 的基本用法,组合组件构建对话界面的方法 +2. **基础用法** - 掌握数据处理、消息管理、UI 定制、生命周期、自定义渲染等主要功能 +3. **AG-UI 协议** - 学习 AG-UI 协议的使用和高级特性(工具调用、状态订阅等) + +> 💡 **示例说明**:所有示例都基于 Mock SSE 服务,可以打开浏览器开发者工具(F12),切换到 Network(网络)标签,查看接口的请求和响应数据,了解数据格式。 + + +## 快速开始 + +最简单的示例,使用 `useChat` Hook 创建对话引擎,组合 `ChatList`、`ChatMessage`、`ChatSender` 组件构建对话界面。 + +{{ basic }} + +## 基础用法 + +### 初始化消息 + +使用 `defaultMessages` 设置静态初始化消息,或通过 `chatEngine.setMessages` 动态加载历史消息。 + +{{ initial-messages }} + +### 数据处理 + +`chatServiceConfig` 是 ChatEngine 的核心配置,控制着与后端的通信和数据处理,是连接前端组件和后端服务的桥梁。作用包括 +- **请求配置** (endpoint、onRequest设置请求头、请求参数) +- **数据转换** (onMessage:将后端数据转换为组件所需格式) +- **生命周期回调** (onStart、onComplete、onError、onAbort)。 + +根据后端服务协议的不同,又有两种配置方式: + +- **自定义协议**:当后端使用自定义数据格式时,往往不能按照前端组件的要求来输出,这时需要通过 `onMessage` 进行数据转换。 +- **AG-UI 协议**:当后端服务符合 [AG-UI 协议](/react-chat/agui) 时,只需设置 `protocol: 'agui'`,无需编写 `onMessage` 进行数据转换,大大简化了接入流程。详见下方 [AG-UI 协议](#ag-ui-协议) 章节。 + +这部分的配置用法与Chatbot中一致,示例可以参考 [Chatbot 数据处理](/react-chat/components/chatbot#数据处理) 章节。 + +### 实例方法 + +通过 `chatEngine` 调用[各种方法](#chatengine-实例方法)控制组件行为(消息设置、发送管理等)。 + +{{ instance-methods }} + +### 自定义渲染 + +使用**动态插槽机制**实现自定义渲染,包括自定义`内容渲染`、自定义`操作栏`、自定义`输入区域`。 + + +- **自定义内容渲染**:如果需要自定义消息内容的渲染方式,可以按照以下步骤实现: + - 1. 扩展类型:通过 TypeScript 声明自定义内容类型 + - 2. 解析数据:在 `onMessage` 中返回自定义类型的数据结构 + - 3. 监听变化:通过 `onMessageChange` 监听消息变化并同步到本地状态 + - 4. 植入插槽:循环 `messages` 数组,使用 `slot = ${content.type}-${index}` 属性来渲染自定义组件 + + +- **自定义操作栏**:如果组件库内置的 [`ChatActionbar`](/react-chat/components/chat-actionbar) 不能满足需求,可以通过 `slot='actionbar'` 属性来渲染自定义组件。 + +- **自定义输入区域**:如果需要自定义ChatSender输入区,可用插槽详见[ChatSender插槽](/react-chat/components/chat-sender?tab=api#插槽) + + +{{ custom-content }} + +### 综合示例 + +在了解了以上各个基础属性的用法后,这里给出一个完整的示例,展示如何在生产实践中综合使用多个功能:初始消息、消息配置、数据转换、请求配置、实例方法和自定义插槽。 + +{{ comprehensive }} + + +## AG-UI 协议 + +[AG-UI(Agent-User Interface)](https://docs.ag-ui.com/introduction) 是一个专为 AI Agent 与前端应用交互设计的轻量级协议,专注于实时交互、状态流式传输和人机协作。ChatEngine 内置了对 AG-UI 协议的支持,可以**无缝集成符合 AG-UI 标准的后端服务**。 + +### 基础用法 + +开启 AG-UI 协议支持(`protocol: 'agui'`),组件会自动解析标准事件类型(如 `TEXT_MESSAGE_*`、`THINKING_*`、`TOOL_CALL_*`、`STATE_*` 等)。使用`AGUIAdapter.convertHistoryMessages`方法即可实现符合[`AGUIHistoryMessage`](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chat-engine/adapters/agui/types.ts)数据结构的历史消息回填。 + +{{ agui-basic }} + + +### 工具调用 + +AG-UI 协议支持通过 `TOOL_CALL_*` 事件让 AI Agent 调用前端工具组件,实现人机协作。 + +> **协议兼容性说明**:`useAgentToolcall` 和 `ToolCallRenderer` 本身是协议无关的,它们只依赖 [ToolCall 数据结构](#toolcall-对象结构),不关心数据来源。AG-UI 协议的优势在于自动化(后端直接输出标准 `TOOL_CALL_*` 事件),普通协议需要在 `onMessage` 中手动将后端数据转换为 `ToolCall` 结构。通过适配器可以降低手动转换的复杂度。 + +#### 核心 Hook 与组件 + +ChatEngine 围绕工具调用提供了几个核心 Hook,它们各司其职,协同工作: + +- **`useAgentToolcall` Hook**:注册工具配置(元数据、参数、UI 组件),相比传统的自定义渲染方式,提供了高度内聚的配置、统一的 API 接口、完整的类型安全和更好的可移植性。详见下方[常见问题](/react-chat/components/chat-engine?tab=demo#常见问题) +- **`ToolCallRenderer` 组件**:工具调用的统一渲染器,负责根据工具名称查找对应的配置,解析参数,管理状态并渲染注册的 UI 组件。使用时只需传入 `toolCall` 对象即可自动完成渲染 + +#### 使用流程 + +1. 使用 `useAgentToolcall` 注册工具配置(元数据、参数、UI 组件) +2. 在消息渲染时使用 `ToolCallRenderer` 组件渲染工具调用 +3. `ToolCallRenderer` 自动查找配置、解析参数、管理状态、渲染 UI + + +#### 基础示例 + +一个模拟图片生成助手的Agent,展示工具调用和状态订阅的核心用法: + +- **工具注册**:使用 `useAgentToolcall` 注册 `generate_image` 工具 +- **状态订阅**:使用注入的 `agentState` 参数来订阅图片生成进度(preparing → generating → completed/failed) +- **进度展示**:实时显示生成进度条和状态信息 +- **结果呈现**:生成完成后展示图片 +- **推荐问题**:通过返回`toolcallName: 'suggestion'`,可以无缝对接内置的推荐问题组件 + +{{ agui-toolcall }} + + +### 工具状态订阅 + +在 AG-UI 协议中,除了工具组件内部需要展示状态,有时我们还需要在**对话组件外部的 UI**(如页面顶部的进度条、侧边栏的任务列表等)中订阅和展示工具执行状态。Agent服务是通在工具调用过程中增加`STATE_SNAPSHOT` 和 `STATE_DELTA` 事件来实现状态变更、快照的流式传输。 + +为了方便旁路UI组件订阅状态,可以使用 `useAgentState` 来获取状态数据,实时渲染任务执行进度和状态信息。比如要在页面顶部显示当前任务的执行进度,不在对话流中展示, 可以这样实现。 + +```javascript +// 外部进度面板组件 +const GlobalProgressBar: React.FC = () => { + // 使用 useAgentState 订阅状态 + const { stateMap, currentStateKey } = useAgentState(); + + /* 后端通过 STATE_SNAPSHOT 和 STATE_DELTA 事件推送状态数据,模拟数据如下: + // + // STATE_SNAPSHOT(初始快照): + // data: {"type":"STATE_SNAPSHOT","snapshot":{"task_xxx":{"progress":0,"message":"准备开始规划...","items":[]}}} + // + // STATE_DELTA(增量更新,使用 JSON Patch 格式): + // data: {"type":"STATE_DELTA","delta":[ + // {"op":"replace","path":"/task_xxx/progress","value":20}, + // {"op":"replace","path":"/task_xxx/message","value":"分析目的地信息"}, + // {"op":"replace","path":"/task_xxx/items","value":[{"label":"分析目的地信息","status":"running"}]} + // ]} + */ + + // useAgentState 内部会自动处理这些事件,将 snapshot 和 delta 合并到 stateMap 中 + + // 获取当前任务状态 + const currentState = currentStateKey ? stateMap[currentStateKey] : null; + + // items 数组包含任务的各个步骤信息 + // 每个 item 包含:label(步骤名称)、status(状态:running/completed/failed) + const items = currentState?.items || []; + const completedCount = items.filter((item: any) => item.status === 'completed').length; + + return ( +
+
进度:{completedCount}/{items.length}
+ {items.map((item: any, index: number) => ( +
+ {item.label} - {item.status} +
+ ))} +
+ ); +}; +``` + +当多个外部组件需要访问同一份状态时,使用 Provider 模式。通过使用 `AgentStateProvider` + `useAgentStateContext` 来共享状态 + +完整示例请参考下方 [综合示例](#综合示例) 演示。 + + +### 综合示例 + +模拟一个完整的**旅游规划 Agent 场景**,演示了如何使用 AG-UI 协议构建复杂的**多步骤任务规划**应用。先收集用户偏好(Human-in-the-Loop),然后根据用户提交的偏好依次执行:查询天气、展示规划步骤的工具调用,最后总结生成最终计划 + +**核心特性:** +- **16 种标准化事件类型**:完整展示 AG-UI 协议的事件体系 +- **多步骤流程**:支持分步骤执行复杂任务(如旅游规划) +- **状态流式传输**:实时更新应用状态,支持状态快照和增量更新 +- **Human-in-the-Loop**:支持人机协作,在流程中插入用户输入环节 +- **工具调用**:集成外部工具调用,如天气查询、行程规划等 +- **外部状态订阅**:演示如何在对话组件外部订阅和展示工具执行状态 + +**示例要点:** + +1. **三种典型工具调用模式** + - 天气查询:展示基础的 `TOOL_CALL_*` 事件处理 + - 规划步骤:展示 `STATE_*` 事件订阅 + `agentState` 自动注入 + - 用户偏好:展示 Human-in-the-Loop 交互式工具 + +2. **工具组件内状态使用** + - 工具组件通过 `agentState` 参数自动获取状态,无需额外 Hook + - 配置 `subscribeKey` 告诉 Renderer 订阅哪个状态 key + +3. **外部 UI 状态订阅** + - 使用 `useAgentState` 在对话组件外部订阅状态 + - 实时展示任务执行进度和状态信息 + +{{ agui-comprehensive }} + + +## API + +### useChat + +用于管理对话状态与生命周期的核心 Hook,初始化对话引擎、同步消息数据、订阅状态变更,并自动处理组件卸载时的资源清理。 + +#### 参数 + +| 参数名 | 类型 | 说明 | 必传 | +| ----------------- | ------------------ | ---------------------------------------------------------------------------------------- | ---- | +| defaultMessages | ChatMessagesData[] | 初始化消息列,[详细类型定义](/react-chat/components/chat-message?tab=api) 表 | N | +| chatServiceConfig | ChatServiceConfig | 对话服务配置,[详细类型定义](/react-chat/components/chatbot?tab=api#chatserviceconfig-类型说明) | Y | + +#### 返回值 + +| 返回值 | 类型 | 说明 | +| ---------- | ------------------ | -------------------------------------------------------------------- | +| chatEngine | ChatEngine | 对话引擎实例,详见下方 [ChatEngine 实例方法](#chatengine-实例方法) | +| messages | ChatMessagesData[] | 当前对话消息列表 | +| status | ChatStatus | 当前对话状态(idle/pending/streaming/complete/stop/error) | + +### ChatEngine 实例方法 + +ChatEngine 实例方法与 Chatbot 组件实例方法完全一致,详见 [Chatbot 实例方法文档](/react-chat/components/chatbot?tab=api#chatbot-实例方法和属性)。 + +### useAgentToolcall + +用于注册工具调用配置的 Hook,支持自动注册和手动注册两种模式。 + +#### 参数 + +| 参数名 | 类型 | 说明 | 必传 | +| ------ | ----------------------------------------------------------------- | -------------------------------------------------------- | ---- | +| config | AgentToolcallConfig \\| AgentToolcallConfig[] \\| null \\| undefined | 工具调用配置对象或数组,传入时自动注册,不传入时手动注册 | N | + +#### 返回值 + +| 返回值 | 类型 | 说明 | +| ------------- | -------------------------------------------------------------- | ------------------------ | +| register | (config: AgentToolcallConfig \\| AgentToolcallConfig[]) => void | 手动注册工具配置 | +| unregister | (names: string \\| string[]) => void | 取消注册工具配置 | +| isRegistered | (name: string) => boolean | 检查工具是否已注册 | +| getRegistered | () => string[] | 获取所有已注册的工具名称 | + +#### AgentToolcallConfig 配置 + +| 属性名 | 类型 | 说明 | 必传 | +| ------------ | ------------------------------------------- | ------------------------------------------ | ---- | +| name | string | 工具调用名称,需要与后端定义的工具名称一致 | Y | +| description | string | 工具调用描述 | N | +| parameters | Array<{ name: string; type: string; required?: boolean }> | 参数定义数组 | N | +| component | React.ComponentType | 自定义渲染组件 | Y | +| handler | (args: TArgs, backendResult?: any) => Promise | 非交互式工具的处理函数(可选) | N | +| subscribeKey | (props: ToolcallComponentProps) => string | undefined | 状态订阅 key 提取函数(可选), 返回值用于订阅对应的状态数据,不配置或不返回则订阅所有的状态变化 | N | + +#### ToolcallComponentProps 组件属性 + +| 属性名 | 类型 | 说明 | +| ---------- | ---------------------------------------------------- | ----------------------------------- | +| status | 'idle' \\| 'executing' \\| 'complete' \\| 'error' | 工具调用状态 | +| args | TArgs | 解析后的工具调用参数 | +| result | TResult | 工具调用结果 | +| error | Error | 错误信息(当 status 为 'error' 时) | +| respond | (response: TResponse) => void | 响应回调函数(用于交互式工具) | +| agentState | Record | 订阅的状态数据,返回依赖subscribeKey这里的配置 | + + +### ToolCallRenderer + +工具调用的统一渲染组件,负责根据工具名称自动查找配置、解析参数、管理状态并渲染对应的 UI 组件。 + +#### Props + +| 属性名 | 类型 | 说明 | 必传 | +| --------- | ------------------------------------------- | ---------------------------------------------- | ---- | +| toolCall | ToolCall [对象结构](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chat-engine/type.ts#L97) | 工具调用对象,包含 toolCallName、args、result 等信息 | Y | +| onRespond | (toolCall: ToolCall, response: any) => void | 交互式工具的响应回调,用于将用户输入返回给后端 | N | + + +### useAgentState + +用于订阅 AG-UI 协议状态事件的 Hook,提供灵活的状态订阅机制。 + +> 💡 **使用建议**:详细的使用说明和场景示例请参考上方 [工具状态订阅](#工具状态订阅) 章节。 + +#### 参数 + +| 参数名 | 类型 | 说明 | 必传 | +| ------- | ------------------ | ---------------- | ---- | +| options | StateActionOptions | 状态订阅配置选项 | N | + +#### StateActionOptions 配置 + +| 属性名 | 类型 | 说明 | 必传 | +| ------------ | ------------------- | -------------------------------------------------------------------- | ---- | +| subscribeKey | string | 指定要订阅的 stateKey,不传入时订阅最新状态 | N | +| initialState | Record | 初始状态值 | N | + +#### 返回值 + +| 返回值 | 类型 | 说明 | +| --------------- | --------------------------------------------------- | ---------------------------------------- | +| stateMap | Record | 状态映射表,格式为 { [stateKey]: stateData } | +| currentStateKey | string \\| null | 当前活跃的 stateKey | +| setStateMap | (stateMap: Record \\| Function) => void | 手动设置状态映射表的方法 | +| getCurrentState | () => Record | 获取当前完整状态的方法 | +| getStateByKey | (key: string) => any | 获取特定 key 状态的方法 | diff --git a/packages/pro-components/chat/chat-engine/components/provider/agent-state.tsx b/packages/pro-components/chat/chat-engine/components/provider/agent-state.tsx new file mode 100644 index 0000000000..9fe21ce3a4 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/provider/agent-state.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { AgentStateContext, type StateActionOptions, useAgentState } from '../../hooks/useAgentState'; + +// 导出 Provider 组件 +export const AgentStateProvider = ({ children, initialState = {}, subscribeKey }: StateActionOptions & { + children: React.ReactNode; +}) => { + const agentStateResult = useAgentState({ + initialState, + subscribeKey, + }); + + return ( + + {children} + + ); +}; diff --git a/packages/pro-components/chat/chat-engine/components/toolcall/index.ts b/packages/pro-components/chat/chat-engine/components/toolcall/index.ts new file mode 100644 index 0000000000..784efd23b3 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/toolcall/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './registry'; +export * from './render'; + diff --git a/packages/pro-components/chat/chat-engine/components/toolcall/registry.ts b/packages/pro-components/chat/chat-engine/components/toolcall/registry.ts new file mode 100644 index 0000000000..b690e35a9c --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/toolcall/registry.ts @@ -0,0 +1,89 @@ +import React from 'react'; +import type { AgentToolcallConfig, AgentToolcallRegistry, ToolcallComponentProps } from './types'; + +/** + * 全局 Agent Toolcall 注册表 + */ +class AgentToolcallRegistryManager { + private registry: AgentToolcallRegistry = {}; + + // 添加组件渲染函数缓存(类似CopilotKit的chatComponentsCache.current.actions) + private renderFunctionCache = new Map< + string, + React.MemoExoticComponent> + >(); + + /** + * 注册一个 Agent Toolcall + */ + register( + config: AgentToolcallConfig, + ): void { + const existingConfig = this.registry[config.name]; + + // 如果组件发生变化,清除旧的缓存 + if (existingConfig && existingConfig.component !== config.component) { + this.renderFunctionCache.delete(config.name); + } + this.registry[config.name] = config; + window.dispatchEvent( + new CustomEvent('toolcall-registered', { + detail: { name: config.name }, + }), + ); + } + + /** + * 获取指定名称的 Agent Toolcall 配置 + */ + get(name: string): AgentToolcallConfig | undefined { + return this.registry[name]; + } + + /** + * 获取或创建缓存的组件渲染函数 + */ + getRenderFunction(name: string): React.MemoExoticComponent> | null { + const config = this.registry[name]; + if (!config) return null; + + // 检查缓存 + let memoizedComponent = this.renderFunctionCache.get(name); + + if (!memoizedComponent) { + // 创建memo化的组件 + memoizedComponent = React.memo((props: ToolcallComponentProps) => React.createElement(config.component, props)); + + // 缓存组件 + this.renderFunctionCache.set(name, memoizedComponent); + } + + return memoizedComponent; + } + + /** + * 获取所有已注册的 Agent Toolcall + */ + getAll(): AgentToolcallRegistry { + return { ...this.registry }; + } + + /** + * 取消注册指定的 Agent Toolcall + */ + unregister(name: string): void { + delete this.registry[name]; + this.renderFunctionCache.delete(name); + } + + /** + * 清空所有注册的 Agent Toolcall + */ + clear(): void { + this.registry = {}; + this.renderFunctionCache.clear(); + } +} + +// 导出单例实例 +export const agentToolcallRegistry = new AgentToolcallRegistryManager(); diff --git a/packages/pro-components/chat/chat-engine/components/toolcall/render.tsx b/packages/pro-components/chat/chat-engine/components/toolcall/render.tsx new file mode 100644 index 0000000000..dcf00a9c39 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/toolcall/render.tsx @@ -0,0 +1,250 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import type { ToolCall } from 'tdesign-web-components/lib/chat-engine'; +import { isNonInteractiveConfig, type ToolcallComponentProps } from './types'; +import { agentToolcallRegistry } from './registry'; +import { AgentStateContext, useAgentStateDataByKey } from '../../hooks/useAgentState'; + +interface ToolCallRendererProps { + toolCall: ToolCall; + onRespond?: (toolCall: ToolCall, response: any) => void; +} + +export const ToolCallRenderer = React.memo( + ({ toolCall, onRespond }) => { + const [actionState, setActionState] = useState<{ + status: ToolcallComponentProps['status']; + result?: any; + error?: Error; + }>({ + status: 'idle', + }); + + // 缓存配置获取 + const config = useMemo(() => { + const cfg = agentToolcallRegistry.get(toolCall.toolCallName); + return cfg; + }, [toolCall.toolCallName]); + + // 添加注册状态监听 + const [isRegistered, setIsRegistered] = useState( + () => !!agentToolcallRegistry.getRenderFunction(toolCall.toolCallName), + ); + + // 缓存参数解析 + const args = useMemo(() => { + try { + return toolCall.args ? JSON.parse(toolCall.args) : {}; + } catch (error) { + console.error('解析工具调用参数失败:', error); + return {}; + } + }, [toolCall.args]); + + const handleRespond = useCallback( + (response: any) => { + if (onRespond) { + onRespond(toolCall, response); + setActionState((prev) => ({ + ...prev, + status: 'complete', + result: response, + })); + } + }, + [toolCall.toolCallId, onRespond], + ); + + // 执行 handler(如果存在)- 必须在条件判断之前调用 + useEffect(() => { + if (!config) return; + + if (isNonInteractiveConfig(config)) { + // 非交互式:执行 handler + const executeHandler = async () => { + try { + setActionState({ status: 'executing' }); + + // 解析后端返回的结果作为 handler 的第二个参数 + let backendResult; + if (toolCall.result) { + try { + backendResult = JSON.parse(toolCall.result); + } catch (error) { + console.warn('解析后端结果失败,使用原始字符串:', error); + backendResult = toolCall.result; + } + } + + // 调用 handler,传入 args 和 backendResult + const result = await config.handler(args, backendResult); + setActionState({ + status: 'complete', + result, + }); + } catch (error) { + setActionState({ + status: 'error', + error: error as Error, + }); + } + }; + + executeHandler(); + } else if (toolCall.result) { + // 交互式:已有结果,显示完成状态 + try { + const result = JSON.parse(toolCall.result); + setActionState({ + status: 'complete', + result, + }); + } catch (error) { + setActionState({ + status: 'error', + error: error as Error, + }); + } + } else { + // 等待用户交互 + setActionState({ status: 'executing' }); + } + }, [config, args, toolCall.result]); + + // 从配置中获取 subscribeKey 提取函数 + const subscribeKeyExtractor = useMemo(() => config?.subscribeKey, [config]); + + // 使用配置的提取函数来获取 targetStateKey + const targetStateKey = useMemo(() => { + if (!subscribeKeyExtractor) return undefined; + + // 构造完整的 props 对象传给提取函数 + const fullProps = { + status: actionState.status, + args, + result: actionState.result, + error: actionState.error, + respond: handleRespond, + }; + + return subscribeKeyExtractor(fullProps); + }, [subscribeKeyExtractor, args, actionState]); + + // 监听组件注册事件, 无论何时注册,都能正确触发重新渲染 + useEffect(() => { + if (!isRegistered) { + const handleRegistered = (event: CustomEvent) => { + if (event.detail?.name === toolCall.toolCallName) { + setIsRegistered(true); + } + }; + + // 添加事件监听 + window.addEventListener('toolcall-registered', handleRegistered as EventListener); + + return () => { + window.removeEventListener('toolcall-registered', handleRegistered as EventListener); + }; + } + }, [toolCall.toolCallName, isRegistered]); + + // 使用精确订阅 + const agentState = useAgentStateDataByKey(targetStateKey); + + // 缓存组件 props + const componentProps = useMemo( + () => ({ + status: actionState.status, + args, + result: actionState.result, + error: actionState.error, + respond: handleRespond, + agentState, + }), + [actionState.status, args, actionState.result, actionState.error, handleRespond, agentState], + ); + + // 使用registry的缓存渲染函数 + const MemoizedComponent = useMemo( + () => agentToolcallRegistry.getRenderFunction(toolCall.toolCallName), + [toolCall.toolCallName, isRegistered], + ); + + if (!MemoizedComponent) { + return null; + } + + return ; + }, + (prevProps, nextProps) => + prevProps.toolCall.toolCallId === nextProps.toolCall.toolCallId && + prevProps.toolCall.toolCallName === nextProps.toolCall.toolCallName && + prevProps.toolCall.args === nextProps.toolCall.args && + prevProps.toolCall.result === nextProps.toolCall.result && + prevProps.onRespond === nextProps.onRespond, +); +// 用于调试,可以在控制台查看每次渲染的参数 +// (prevProps, nextProps) => { +// const toolCallIdSame = prevProps.toolCall.toolCallId === nextProps.toolCall.toolCallId; +// const toolCallNameSame = prevProps.toolCall.toolCallName === nextProps.toolCall.toolCallName; +// const argsSame = prevProps.toolCall.args === nextProps.toolCall.args; +// const resultSame = prevProps.toolCall.result === nextProps.toolCall.result; +// const onRespondSame = prevProps.onRespond === nextProps.onRespond; + +// console.log(`ToolCallRenderer memo 详细检查 [${prevProps.toolCall.toolCallName}]:`, { +// toolCallIdSame, +// toolCallNameSame, +// argsSame, +// resultSame, +// onRespondSame, +// prevToolCallId: prevProps.toolCall.toolCallId, +// nextToolCallId: nextProps.toolCall.toolCallId, +// prevOnRespond: prevProps.onRespond, +// nextOnRespond: nextProps.onRespond, +// }); + +// const shouldSkip = toolCallIdSame && toolCallNameSame && argsSame && resultSame && onRespondSame; + +// console.log(`ToolCallRenderer memo 检查 [${prevProps.toolCall.toolCallName}]:`, shouldSkip ? '跳过渲染' : '需要重新渲染'); +// return shouldSkip +// }, +// ); + +// 定义增强后的 Props 类型 +type WithAgentStateProps

= P & { agentState?: Record }; + +export const withAgentStateToolcall1 =

( + Component: React.ComponentType>, +): React.ComponentType

=> { + const WrappedComponent: React.FC

= (props: P) => ( + + {(context) => { + if (!context) { + console.warn('AgentStateContext not found, component will render without state'); + return ; + } + + return ; + }} + + ); + + WrappedComponent.displayName = `withAgentState(${Component.displayName || Component.name || 'Component'})`; + return React.memo(WrappedComponent); +}; + +export const withAgentStateToolcall =

( + Component: React.ComponentType>, + subscribeKeyExtractor?: (props: P) => string | undefined, +): React.ComponentType

=> { + const WrappedComponent: React.FC

= (props: P) => { + // 计算需要订阅的 stateKey + const targetStateKey = useMemo(() => (subscribeKeyExtractor ? subscribeKeyExtractor(props) : undefined), [props]); + + const agentState = useAgentStateDataByKey(targetStateKey); + + return ; + }; + + WrappedComponent.displayName = `withAgentState(${Component.displayName || Component.name || 'Component'})`; + return React.memo(WrappedComponent); +}; diff --git a/packages/pro-components/chat/chat-engine/components/toolcall/types.ts b/packages/pro-components/chat/chat-engine/components/toolcall/types.ts new file mode 100644 index 0000000000..4be3264b1d --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/toolcall/types.ts @@ -0,0 +1,76 @@ +import React from 'react'; + +/** + * 智能体可交互组件的标准 Props 接口 + */ +export interface ToolcallComponentProps { + /** 组件的当前渲染状态 */ + status: 'idle' | 'executing' | 'complete' | 'error'; + /** Agent 调用时传入的初始参数 */ + args: TArgs; + /** 当 status 为 'complete' 时,包含 Toolcall 的最终执行结果 */ + result?: TResult; + /** 当 status 为 'error' 时,包含错误信息 */ + error?: Error; + /** + * 【交互核心】一个回调函数,用于将用户的交互结果返回给宿主环境。 + * 仅在"交互式"场景下由宿主提供。 + */ + respond?: (response: TResponse) => void; + agentState?: Record; +} + +// 场景一:非交互式 Toolcall 的配置 (有 handler) +interface NonInteractiveToolcallConfig { + name: string; + description?: string; + parameters?: Array<{ name: string; type: string; required?: boolean }>; + /** 业务逻辑执行器,支持可选的后端结果作为第二个参数 */ + handler: (args: TArgs, backendResult?: any) => Promise; + /** 状态显示组件 */ + component: React.FC>; + /** 订阅statekey提取函数 */ + subscribeKey?: (props: ToolcallComponentProps) => string | undefined; +} + +// 场景二:交互式 Toolcall 的配置 (无 handler) +interface InteractiveToolcallConfig { + name: string; + description: string; + parameters?: Array<{ name: string; type: string; required?: boolean }>; + /** 交互式UI组件 */ + component: React.FC>; + /** handler 属性不存在,以此作为区分标志 */ + handler?: never; + /** 订阅statekey提取函数 */ + subscribeKey?: (props: ToolcallComponentProps) => string | undefined; +} + +// 最终的配置类型 +export type AgentToolcallConfig = + | NonInteractiveToolcallConfig + | InteractiveToolcallConfig; + +// 类型守卫:判断是否为非交互式配置 +export function isNonInteractive( + config: AgentToolcallConfig, +): config is NonInteractiveToolcallConfig { + return typeof (config as any).handler === 'function'; +} + +// Agent Toolcall 注册表 +export interface AgentToolcallRegistry { + [ToolcallName: string]: AgentToolcallConfig; +} + +// 内部状态管理 +export interface AgentToolcallState { + status: ToolcallComponentProps['status']; + args?: TArgs; + result?: TResult; + error?: Error; +} + +// 类型守卫函数 +export const isNonInteractiveConfig = (cfg: AgentToolcallConfig): cfg is AgentToolcallConfig & { handler: Function } => + typeof (cfg as any).handler === 'function'; diff --git a/packages/pro-components/chat/chat-engine/hooks/useAgentState.ts b/packages/pro-components/chat/chat-engine/hooks/useAgentState.ts new file mode 100644 index 0000000000..d41e4719a0 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/hooks/useAgentState.ts @@ -0,0 +1,118 @@ +import { useState, useEffect, useRef, createContext, useContext, useMemo } from 'react'; +import { stateManager } from 'tdesign-web-components/lib/chat-engine'; + +/** + * 状态订阅相关类型定义 + */ + +export interface StateActionOptions { + /** + * 初始状态 + */ + initialState?: Record; + /** + * 只订阅特定key的变化 + */ + subscribeKey?: string; +} + +export interface UseStateActionReturn { + /** + * 全量状态Map - 包含所有stateKey的状态 + * 格式: { [stateKey]: stateData } + */ + stateMap: Record; + /** + * 当前最新的状态key + */ + currentStateKey: string | null; + /** + * 设置状态Map,用于加载历史对话消息中的state数据 + */ + setStateMap: (stateMap: Record | ((prev: Record) => Record)) => void; + /** + * 获取当前完整状态的方法 + */ + getCurrentState: () => Record; + /** + * 获取特定 key 状态的方法 + */ + getStateByKey: (key: string) => any; +} + +export const useAgentState = (options: StateActionOptions = {}): UseStateActionReturn => { + const { initialState, subscribeKey } = options; + const [stateMap, setStateMap] = useState>(initialState || {}); + const [currentStateKey, setCurrentStateKey] = useState(null); + + // 使用 ref 来避免不必要的重新渲染 + const stateMapRef = useRef(stateMap); + stateMapRef.current = stateMap; + + useEffect( + () => + stateManager.subscribeToLatest((newState: T, newStateKey: string) => { + // 如果指定了 subscribeKey,只有匹配时才更新状态 + if (subscribeKey && newStateKey !== subscribeKey) { + // 仍然更新内部状态,但不触发重新渲染 + stateMapRef.current = { + ...stateMapRef.current, + [newStateKey]: newState, + }; + return; + } + + setStateMap((prev) => ({ + ...prev, + [newStateKey]: newState, + })); + setCurrentStateKey(newStateKey); + }), + [subscribeKey], + ); + + return { + stateMap: stateMapRef.current, + currentStateKey, + setStateMap, + getCurrentState: () => stateMapRef.current, + getStateByKey: (key: string) => stateMapRef.current[key], + }; +}; + +// 创建 AgentState Context +export const AgentStateContext = createContext(null); + +// 简化的状态选择器 +export const useAgentStateDataByKey = (stateKey?: string) => { + const contextState = useContext(AgentStateContext); + const independentState = useAgentState({ subscribeKey: stateKey }); + + return useMemo(() => { + if (contextState) { + // 有 Provider,使用 Context 状态 + const { stateMap } = contextState; + return stateKey ? stateMap[stateKey] : stateMap; + } + + // 没有 Provider,使用独立状态 + const { stateMap } = independentState; + return stateKey ? stateMap[stateKey] : stateMap; + }, [ + stateKey, + // 关键:添加和 useAgentStateByKey 相同的深度依赖逻辑 + contextState && (stateKey ? contextState.stateMap[stateKey] : JSON.stringify(contextState.stateMap)), + independentState && (stateKey ? independentState.stateMap[stateKey] : JSON.stringify(independentState.stateMap)), + ]); +}; + +// 导出 Context Hook +export const useAgentStateContext = (): UseStateActionReturn => { + const context = useContext(AgentStateContext); + + if (!context) { + throw new Error('useAgentState must be used within AgentStateProvider'); + } + + return context; +}; diff --git a/packages/pro-components/chat/chat-engine/hooks/useAgentToolcall.ts b/packages/pro-components/chat/chat-engine/hooks/useAgentToolcall.ts new file mode 100644 index 0000000000..357090475d --- /dev/null +++ b/packages/pro-components/chat/chat-engine/hooks/useAgentToolcall.ts @@ -0,0 +1,119 @@ +import { useCallback, useRef, useEffect } from 'react'; +import type { AgentToolcallConfig, ToolcallComponentProps } from '../components/toolcall/types'; +import { agentToolcallRegistry } from '../components/toolcall/registry'; + +export interface UseAgentToolcallReturn { + register: (config: AgentToolcallConfig | AgentToolcallConfig[]) => void; + unregister: (names: string | string[]) => void; + isRegistered: (name: string) => boolean; + getRegistered: () => string[]; + config: any; +} + +/** + * 统一的、智能的 Agent Toolcall 适配器 Hook, + * 注册管理:负责工具配置的注册、取消注册、状态跟踪;生命周期管理:自动清理、防止内存泄漏 + * 支持两种使用模式: + * 1. 自动注册模式:传入配置,自动注册和清理 + * 2. 手动注册模式:不传配置或传入null,返回注册方法由业务控制 + */ +export function useAgentToolcall( + config?: + | AgentToolcallConfig + | AgentToolcallConfig[] + | null + | undefined, +): UseAgentToolcallReturn { + const registeredNamesRef = useRef>(new Set()); + const autoRegisteredNamesRef = useRef>(new Set()); + const configRef = useRef(config); + + // 手动注册方法 + const register = useCallback((newConfig: AgentToolcallConfig | AgentToolcallConfig[]) => { + if (!newConfig) { + console.warn('[useAgentToolcall] 配置为空,跳过注册'); + return; + } + + const configs = Array.isArray(newConfig) ? newConfig : [newConfig]; + + configs.forEach((cfg) => { + if (agentToolcallRegistry.get(cfg.name)) { + console.warn(`[useAgentToolcall] 配置名称 "${cfg.name}" 已存在于注册表中,将被覆盖`); + } + + agentToolcallRegistry.register(cfg); + registeredNamesRef.current.add(cfg.name); + }); + }, []); + + // 手动取消注册方法 + const unregister = useCallback((names: string | string[]) => { + const nameArray = Array.isArray(names) ? names : [names]; + + nameArray.forEach((name) => { + agentToolcallRegistry.unregister(name); + registeredNamesRef.current.delete(name); + autoRegisteredNamesRef.current.delete(name); + }); + }, []); + + // 检查是否已注册 + const isRegistered = useCallback( + (name: string) => registeredNamesRef.current.has(name) || autoRegisteredNamesRef.current.has(name), + [], + ); + + // 获取所有已注册的配置名称 + const getRegistered = useCallback( + () => Array.from(new Set([...registeredNamesRef.current, ...autoRegisteredNamesRef.current])), + [], + ); + + // 自动注册逻辑(当传入配置时) + useEffect(() => { + if (!config) { + return; + } + + const configs = Array.isArray(config) ? config : [config]; + configs.forEach((cfg) => { + if (agentToolcallRegistry.get(cfg.name)) { + console.warn(`[useAgentToolcall] 配置名称 "${cfg.name}" 已存在于注册表中,将被覆盖`); + } + + agentToolcallRegistry.register(cfg); + autoRegisteredNamesRef.current.add(cfg.name); + }); + + // 清理函数:取消注册自动注册的配置 + return () => { + configs.forEach((cfg) => { + agentToolcallRegistry.unregister(cfg.name); + autoRegisteredNamesRef.current.delete(cfg.name); + }); + }; + }, [config]); + + // 更新配置引用 + useEffect(() => { + configRef.current = config; + }, [config]); + + return { + register, + unregister, + isRegistered, + getRegistered, + config: configRef.current, + }; +} + +// 创建带状态感知的工具配置(带状态变化事件),状态注入,自动为组件注入 agentState +export interface ToolConfigWithStateOptions { + name: string; + description: string; + parameters: Array<{ name: string; type: string }>; + subscribeKey?: (props: ToolcallComponentProps) => string | undefined; + component: React.ComponentType & { agentState?: Record }>; +} diff --git a/packages/pro-components/chat/chat-engine/hooks/useChat.ts b/packages/pro-components/chat/chat-engine/hooks/useChat.ts new file mode 100644 index 0000000000..5d8c44c298 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/hooks/useChat.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; +import ChatEngine from 'tdesign-web-components/lib/chat-engine'; +import type { ChatMessagesData, ChatServiceConfig, ChatStatus } from 'tdesign-web-components/lib/chat-engine'; + +export type IUseChat = { + defaultMessages?: ChatMessagesData[]; + chatServiceConfig: ChatServiceConfig; +}; + +export const useChat = ({ defaultMessages: initialMessages, chatServiceConfig }: IUseChat) => { + const [messages, setMessage] = useState([]); + const [status, setStatus] = useState('idle'); + const chatEngineRef = useRef(new ChatEngine()); + const msgSubscribeRef = useRef void)>(null); + const prevInitialMessagesRef = useRef([]); + + const chatEngine = chatEngineRef.current; + + const syncState = (state: ChatMessagesData[]) => { + setMessage(state); + setStatus(state.at(-1)?.status || 'idle'); + }; + + const subscribeToChat = () => { + // 清理之前的订阅 + msgSubscribeRef.current?.(); + + msgSubscribeRef.current = chatEngine.messageStore.subscribe((state) => { + syncState(state.messages); + }); + }; + + const initChat = () => { + // @ts-ignore + chatEngine.init(chatServiceConfig, initialMessages); + // @ts-ignore + syncState(initialMessages); + subscribeToChat(); + }; + + // 初始化聊天引擎 + useEffect(() => { + initChat(); + return () => msgSubscribeRef.current?.(); + }, []); + + // 监听 defaultMessages 变化 + useEffect(() => { + // 检查 initialMessages 是否真的发生了变化 + const hasChanged = JSON.stringify(prevInitialMessagesRef.current) !== JSON.stringify(initialMessages); + + if (hasChanged && initialMessages && initialMessages.length > 0) { + // 更新引用 + prevInitialMessagesRef.current = initialMessages; + + // 重新初始化聊天引擎或更新消息 + chatEngine.setMessages(initialMessages, 'replace'); + + // 同步状态 + syncState(initialMessages); + } + }, [initialMessages, chatEngine]); + + return { + chatEngine, + messages, + status, + }; +}; diff --git a/packages/pro-components/chat/chat-engine/index.ts b/packages/pro-components/chat/chat-engine/index.ts new file mode 100644 index 0000000000..119d96fe4a --- /dev/null +++ b/packages/pro-components/chat/chat-engine/index.ts @@ -0,0 +1,6 @@ +export * from './hooks/useChat'; +export * from './hooks/useAgentToolcall'; +export * from './hooks/useAgentState'; +export * from './components/toolcall'; +export * from './components/provider/agent-state'; +export * from 'tdesign-web-components/lib/chat-engine'; \ No newline at end of file diff --git a/packages/pro-components/chat/chat-filecard/_example/base.tsx b/packages/pro-components/chat/chat-filecard/_example/base.tsx new file mode 100644 index 0000000000..ca7fe3effa --- /dev/null +++ b/packages/pro-components/chat/chat-filecard/_example/base.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; +import { Filecard, type TdAttachmentItem } from '@tdesign-react/chat'; + +const filesList: TdAttachmentItem[] = [ + { + name: 'excel-file.xlsx', + size: 111111, + }, + { + name: 'word-file.docx', + size: 222222, + }, + { + name: 'image-file.png', + size: 333333, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + { + name: 'pdf-file.pdf', + size: 444444, + }, + { + name: 'pdf-file.pdf', + size: 444444, + extension: '.docx', + description: '自定义文件扩展类型', + }, + { + name: 'ppt-file.pptx', + size: 555555, + }, + { + name: 'video-file.mp4', + size: 666666, + }, + { + name: 'audio-file.mp3', + size: 777777, + }, + { + name: 'zip-file.zip', + size: 888888, + }, + { + name: 'markdown-file.md', + size: 999999, + description: 'Custom description', + }, + { + name: 'word-markdown-file.doc', + size: 99899, + status: 'progress', + percent: 50, + }, +]; + +export default function Cards() { + return ( + + {filesList.map((file, index) => ( + console.log('remove', e.detail)} + removable={index % 2 === 0} + > + ))} + + ); +} diff --git a/packages/pro-components/chat/chat-filecard/chat-filecard.en-US.md b/packages/pro-components/chat/chat-filecard/chat-filecard.en-US.md new file mode 100644 index 0000000000..f84d00d9f1 --- /dev/null +++ b/packages/pro-components/chat/chat-filecard/chat-filecard.en-US.md @@ -0,0 +1,13 @@ +:: BASE_DOC :: + +## API +### Filecard Props + +name | type | default | description | required +-- | -- | -- | -- | -- +item | Object | - | TS类型:TdAttachmentItem。[类型定义](./chat-attachments?tab=api#tdattachmentitem-类型说明) | Y +removable | Boolean | true | 是否显示删除按钮 | N +onRemove | Function | - | 附件移除时的回调函数。TS类型:`(item: TdAttachmentItem) => void` | N +disabled | Boolean | false | 禁用状态 | N +imageViewer | Boolean | true | 图片预览开关 | N +cardType | String | file | 卡片类型。可选项:file/image | N diff --git a/packages/pro-components/chat/chat-filecard/chat-filecard.md b/packages/pro-components/chat/chat-filecard/chat-filecard.md new file mode 100644 index 0000000000..5dc78e9167 --- /dev/null +++ b/packages/pro-components/chat/chat-filecard/chat-filecard.md @@ -0,0 +1,33 @@ +--- +title: FileCard 文件缩略卡片 +description: 文件缩略卡片 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +## 基础用法 + +{{ base }} + + +## API +### Filecard Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +item | Object | - | TS类型:TdAttachmentItem。[类型定义](?tab=api#tdattachmentitem-类型说明) | Y +removable | Boolean | true | 是否显示删除按钮 | N +disabled | Boolean | false | 禁用状态 | N +imageViewer | Boolean | true | 图片预览开关 | N +cardType | String | file | 卡片类型。可选项:file/image | N +onRemove | Function | - | 卡片移除时的回调函数。TS类型:`(event: CustomEvent) => void` | N +onFileClick | Function | - | 卡片点击时的回调函数。 TS类型:`(event: CustomEvent) => void` | N + +## TdAttachmentItem 类型说明 +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +fileType | String | - | 文件类型,可选值:'image'/'video'/'audio'/'pdf'/'doc'/'ppt'/'txt' | N +description | String | - | 文件描述信息 | N +extension | String | - | 文件扩展名 | N +(继承属性) | UploadFile | - | 包含 `name`, `size`, `status` 等基础文件属性 | N diff --git a/packages/pro-components/chat/chat-filecard/index.ts b/packages/pro-components/chat/chat-filecard/index.ts new file mode 100644 index 0000000000..7dcf2d03cd --- /dev/null +++ b/packages/pro-components/chat/chat-filecard/index.ts @@ -0,0 +1,10 @@ +import { TdFileCardProps } from 'tdesign-web-components'; +import 'tdesign-web-components/lib/filecard'; +import reactify from '../_util/reactify'; + +export const Filecard: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-filecard'); + +export default Filecard; +export type { TdFileCardProps } from 'tdesign-web-components'; diff --git a/packages/pro-components/chat/chat-loading/_example/base.tsx b/packages/pro-components/chat/chat-loading/_example/base.tsx new file mode 100644 index 0000000000..492140b610 --- /dev/null +++ b/packages/pro-components/chat/chat-loading/_example/base.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; +import { ChatLoading } from '@tdesign-react/chat'; + +const ChatLoadingExample = () => ( + + + + + + +); + +export default ChatLoadingExample; diff --git a/packages/pro-components/chat/chat-loading/_example/text.tsx b/packages/pro-components/chat/chat-loading/_example/text.tsx new file mode 100644 index 0000000000..3c3619532e --- /dev/null +++ b/packages/pro-components/chat/chat-loading/_example/text.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; +import { ChatLoading } from '@tdesign-react/chat'; + +const ChatLoadingTextExample = () => ( + + + +); + +export default ChatLoadingTextExample; diff --git a/packages/pro-components/chat/chat-loading/chat-loading.en-US.md b/packages/pro-components/chat/chat-loading/chat-loading.en-US.md new file mode 100644 index 0000000000..e50c31d383 --- /dev/null +++ b/packages/pro-components/chat/chat-loading/chat-loading.en-US.md @@ -0,0 +1,9 @@ +:: BASE_DOC :: + +## API +### ChatLoading Props + +name | type | default | description | required +-- | -- | -- | -- | -- +animation | String | moving | 动画效果。可选项:skeleton/moving/gradient/dots/circle | N +text | TNode | - | 加载提示文案。TS类型:`string / TNode` | N diff --git a/packages/pro-components/chat/chat-loading/chat-loading.md b/packages/pro-components/chat/chat-loading/chat-loading.md new file mode 100644 index 0000000000..53e0a18634 --- /dev/null +++ b/packages/pro-components/chat/chat-loading/chat-loading.md @@ -0,0 +1,23 @@ +--- +title: ChatLoading 对话加载 +description: 适用于 Chat 对话场景下的加载组件 +isComponent: true +usage: { title: '', description: '' } +spline: navigation +--- + +### 加载组件 + +{{ base }} + +### 带文案描述的加载组件 + +{{ text }} + +## API +### ChatLoading Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +animation | String | moving | 动画效果。可选项:skeleton/moving/gradient/dots/circle | N +text | TNode | - | 加载提示文案。TS类型:`string / TNode` | N diff --git a/packages/pro-components/chat/chat-loading/index.ts b/packages/pro-components/chat/chat-loading/index.ts new file mode 100644 index 0000000000..8db790dfb5 --- /dev/null +++ b/packages/pro-components/chat/chat-loading/index.ts @@ -0,0 +1,10 @@ +import { TdChatLoadingProps } from 'tdesign-web-components'; +import 'tdesign-web-components/lib/chat-loading'; +import reactify from '../_util/reactify'; + +export const ChatLoading: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-chat-loading'); + +export default ChatLoading; +export type { TdChatLoadingProps, ChatLoadingAnimationType } from 'tdesign-web-components'; diff --git a/packages/pro-components/chat/chat-markdown/_example/base.tsx b/packages/pro-components/chat/chat-markdown/_example/base.tsx new file mode 100644 index 0000000000..df7898df3c --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/base.tsx @@ -0,0 +1,136 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Space } from 'tdesign-react'; +import { ChatMarkdown, findTargetElement } from '@tdesign-react/chat'; + +const doc = ` +# This is TDesign + +## This is TDesign + +### This is TDesign + +#### This is TDesign + +The point of reference-style links is not that they’re easier to write. The point is that with reference-style links, your document source is vastly more readable. Compare the above examples: using reference-style links, the paragraph itself is only 81 characters long; with inline-style links, it’s 176 characters; and as raw \`HTML\`, it’s 234 characters. In the raw \`HTML\`, there’s more markup than there is text. + +> This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet. + +an example | *an example* | **an example** + +1. Bird +1. McHale +1. Parish + 1. Bird + 1. McHale + 1. Parish + +- Red +- Green +- Blue + - Red + - Green + - Blue + +This is [an example](http://example.com/ "Title") inline link. + + +# TDesign(腾讯设计体系)核心特性与技术架构 + +以下是关于 TDesign(腾讯设计体系)的核心特性与技术架构的表格化总结: + +| 分类 | 核心内容 | 关键技术/特性 | +|------|----------|---------------| +| **设计理念** | • 设计价值观:用户为本、科技向善、突破创新... | • 设计原子单元 | +| **核心组件库** | • 基础组件:Button/Input/Table/Modal... | • 组件覆盖率 | +| **技术特性** | • 多框架支持:Vue/React/Angular... | • 按需加载 | +\`\`\`bash +$ npm i tdesign-vue-next +\`\`\` + +--- + +\`\`\`javascript +import { createApp } from 'vue'; +import App from './app.vue'; + +const app = createApp(App); +app.use(TDesignChat); +\`\`\` +\`\`\`mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; + +\`\`\` + `; + +export default function ThinkContentDemo() { + const [displayText, setDisplayText] = useState(doc); + const [isTyping, setIsTyping] = useState(false); + const timerRef = useRef>(null); + const currentIndex = useRef(doc.length); + const startTimeRef = useRef(Date.now()); + + // 自定义链接的点击 + useEffect(() => { + // 处理链接点击 + const handleResourceClick = (event: MouseEvent) => { + event.preventDefault(); + // 查找符合条件的目标元素 + const targetResource = findTargetElement(event, ['a[part=md_a]']); + if (targetResource) { + // 获取链接地址并触发回调 + const href = targetResource.getAttribute('href'); + if (href) { + console.log('跳转链接href', href); + } + } + }; + // 注册全局点击事件监听 + document.addEventListener('click', handleResourceClick); + + // 清理函数 + return () => { + document.removeEventListener('click', handleResourceClick); + }; + }, []); + + useEffect(() => { + // 模拟打字效果 + const typeEffect = () => { + if (!isTyping) return; + + if (currentIndex.current < doc.length) { + const char = doc[currentIndex.current]; + currentIndex.current += 1; + setDisplayText((prev) => prev + char); + timerRef.current = setTimeout(typeEffect, 10); + } else { + // 输入完成时自动停止 + setIsTyping(false); + } + }; + + if (isTyping) { + // 如果已经完成输入,点击开始则重置 + if (currentIndex.current >= doc.length) { + currentIndex.current = 0; + setDisplayText(''); + } + startTimeRef.current = Date.now(); + timerRef.current = setTimeout(typeEffect, 500); + } + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [isTyping]); + + return ( + + + + ); +} diff --git a/packages/pro-components/chat/chat-markdown/_example/custom.tsx b/packages/pro-components/chat/chat-markdown/_example/custom.tsx new file mode 100644 index 0000000000..1a62a04227 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/custom.tsx @@ -0,0 +1,70 @@ +import React, { useEffect } from 'react'; +import { ChatMarkdown, MarkdownEngine } from '@tdesign-react/chat'; + +const classStyles = ` + +`; + +/** + * markdown自定义插件,请参考cherry-markdown定义插件的方法,事件触发需考虑shadowDOM隔离情况 + * https://github.com/Tencent/cherry-markdown/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89%E8%AF%AD%E6%B3%95 + */ +const colorText = MarkdownEngine.createSyntaxHook('important', MarkdownEngine.constants.HOOKS_TYPE_LIST.SEN, { + makeHtml(str) { + return str.replace( + this.RULE.reg, + (_whole, _m1, m2) => + `${m2}`, + ); + }, + rule() { + // 匹配 !!...!! 语法 + // eslint-disable-next-line no-useless-escape + return { reg: /(\!\!)([^\!]+)\1/g }; + }, +}); + +const clickTextHandler = (e) => { + console.log('点击:', e.detail.content); +}; + +const MarkdownExample = () => { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + useEffect(() => { + document.addEventListener('color-text-click', clickTextHandler); + + return () => { + document.removeEventListener('color-text-click', clickTextHandler); + }; + }, []); + + return ( + + ); +}; + +export default MarkdownExample; diff --git a/packages/pro-components/chat/chat-markdown/_example/event.tsx b/packages/pro-components/chat/chat-markdown/_example/event.tsx new file mode 100644 index 0000000000..97a43393f9 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/event.tsx @@ -0,0 +1,72 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { ChatMarkdown, findTargetElement } from '@tdesign-react/chat'; + +const doc = ` +这是一个markdown[链接地址](http://example.com), 点击后**不会**自动跳转. +`; + +export default function ThinkContentDemo() { + const [displayText, setDisplayText] = useState(doc); + const [isTyping, setIsTyping] = useState(false); + const timerRef = useRef>(null); + const currentIndex = useRef(doc.length); + const startTimeRef = useRef(Date.now()); + + // 自定义链接的点击 + useEffect(() => { + // 处理链接点击 + const handleResourceClick = (event: MouseEvent) => { + event.preventDefault(); + // 查找符合条件的目标元素 + const targetResource = findTargetElement(event, ['a[part=md_a]']); + if (targetResource) { + // 获取链接地址并触发回调 + const href = targetResource.getAttribute('href'); + if (href) { + console.log('跳转链接href', href); + } + } + }; + // 注册全局点击事件监听 + document.addEventListener('click', handleResourceClick); + + // 清理函数 + return () => { + document.removeEventListener('click', handleResourceClick); + }; + }, []); + + useEffect(() => { + // 模拟打字效果 + const typeEffect = () => { + if (!isTyping) return; + + if (currentIndex.current < doc.length) { + const char = doc[currentIndex.current]; + currentIndex.current += 1; + setDisplayText((prev) => prev + char); + timerRef.current = setTimeout(typeEffect, 10); + } else { + // 输入完成时自动停止 + setIsTyping(false); + } + }; + + if (isTyping) { + // 如果已经完成输入,点击开始则重置 + if (currentIndex.current >= doc.length) { + currentIndex.current = 0; + setDisplayText(''); + } + startTimeRef.current = Date.now(); + timerRef.current = setTimeout(typeEffect, 500); + } + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [isTyping]); + + + return ; +} diff --git a/packages/pro-components/chat/chat-markdown/_example/footnote.tsx b/packages/pro-components/chat/chat-markdown/_example/footnote.tsx new file mode 100644 index 0000000000..17982df0c4 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/footnote.tsx @@ -0,0 +1,200 @@ +import React, { useEffect } from 'react'; +import { ChatMarkdown, MarkdownEngine } from '@tdesign-react/chat'; + +const hoverStyles = ` + +`; + +/** + * 自定义悬停提示语法插件 + * 语法格式:[ref:1|标题|摘要|链接] + */ +const hoverRefHook = MarkdownEngine.createSyntaxHook('hoverRef', MarkdownEngine.constants.HOOKS_TYPE_LIST.SEN, { + makeHtml(str) { + return str.replace(this.RULE.reg, (_whole, id, title, summary, link) => { + const tooltipId = `tooltip-${id}-${Math.random().toString(36).substr(2, 9)}`; + return `${id}`; + }); + }, + rule() { + // 匹配 [ref:1|标题|摘要|链接] 语法 + return { reg: /\[ref:([^|\]]+)\|([^|\]]+)\|([^|\]]+)\|([^\]]+)\]/g }; + }, +}); + +const tooltipTimeouts = new Map(); + +const handleHoverEnter = (e) => { + const { id, title, summary, link, target } = e.detail; + + // 清除该 tooltip 的隐藏定时器 + if (tooltipTimeouts.has(id)) { + clearTimeout(tooltipTimeouts.get(id)); + tooltipTimeouts.delete(id); + } + + // 移除其他所有浮层(确保同时只显示一个) + document.querySelectorAll('.hover-tooltip').forEach((tooltip) => { + if (tooltip.id !== id) { + tooltip.remove(); + } + }); + + // 移除已存在的同 ID 浮层 + const existingTooltip = document.getElementById(id); + if (existingTooltip) { + existingTooltip.remove(); + } + + // 创建新的浮层 + const tooltip = document.createElement('div'); + tooltip.id = id; + tooltip.className = 'hover-tooltip'; + + // 创建可点击的标题 + const titleElement = link + ? `${title}` + : `

${title}
`; + + tooltip.innerHTML = ` + ${titleElement} +
${summary}
+ `; + + document.body.appendChild(tooltip); + + // 定位浮层 + const rect = target.getBoundingClientRect(); + tooltip.style.display = 'block'; + tooltip.style.left = `${rect.left}px`; + tooltip.style.top = `${rect.bottom + 5}px`; + + // 添加 tooltip 的鼠标事件 + tooltip.addEventListener('mouseenter', () => { + if (tooltipTimeouts.has(id)) { + clearTimeout(tooltipTimeouts.get(id)); + tooltipTimeouts.delete(id); + } + }); + + tooltip.addEventListener('mouseleave', () => { + const timeout = setTimeout(() => { + tooltip.remove(); + tooltipTimeouts.delete(id); + }, 100); + tooltipTimeouts.set(id, timeout); + }); +}; + +const handleHoverLeave = (e) => { + const { id } = e.detail; + + // 延迟隐藏,给用户时间移动到 tooltip 上 + const timeout = setTimeout(() => { + const tooltip = document.getElementById(id); + if (tooltip) { + tooltip.remove(); + } + tooltipTimeouts.delete(id); + }, 100); + + tooltipTimeouts.set(id, timeout); +}; + +const FootnoteDemo = () => { + useEffect(() => { + // 添加样式 + document.head.insertAdjacentHTML('beforeend', hoverStyles); + }, []); + + useEffect(() => { + // 添加事件监听器 + document.addEventListener('hover-enter', handleHoverEnter); + document.addEventListener('hover-leave', handleHoverLeave); + + return () => { + document.removeEventListener('hover-enter', handleHoverEnter); + document.removeEventListener('hover-leave', handleHoverLeave); + }; + }, []); + + const markdownContent = `人工智能的发展经历了不同的阶段和研究方法,包括​​符号处理、神经网络、机器学习、深度学习等[ref:1|人工智能的发展历程|探讨AI从诞生到现在的重要里程碑和技术突破|https://tdesign.tencent.com][ref:2|机器学习算法详解|深入分析各种机器学习算法的原理和应用场景|https://tdesign.tencent.com]。`; + + return ( + + ); +}; + +export default FootnoteDemo; diff --git a/packages/pro-components/chat/chat-markdown/_example/mock.md b/packages/pro-components/chat/chat-markdown/_example/mock.md new file mode 100644 index 0000000000..305e06b605 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/mock.md @@ -0,0 +1,88 @@ +# Markdown功能测试 (H1标题) + +## 基础语法测试 (H2标题) + +### 文字样式 (H3标题) + +#### 文字样式 (H4标题) + +##### 文字样式 (H5标题) + +###### 文字样式 (H6标题) + +**加粗文字** +_斜体文字_ +~~删除线~~ +**_加粗且斜体_** +行内代码: `console.log('Hello')` + +### 代码块测试 + +```javascript +// JavaScript 代码块 +const express = require('express') +const app = express() +const port = 3000 + +app.get('/', (req, res) => { + res.send('Hello World') +}) + +app.listen(port, () => { + console.log(`http://localhost:${port}`) +}) + +function greet(name) { + console.log(`Hello, ${name}!`); +} +greet('Markdown'); +``` + +```python +# Python 代码块 +def hello(): + print("Markdown 示例") +``` + +### 列表测试 + +- 无序列表项1 +- 无序列表项2 + - 嵌套列表项 + - 嵌套列表项 + +1. 有序列表项1 +2. 有序列表项2 + +### 表格测试 + +| 左对齐 | 居中对齐 | 右对齐 | +| :--------- | :------: | -----: | +| 单元格 | 单元格 | 单元格 | +| 长文本示例 | 中等长度 | $100 | + +![示例](https://tdesign.gtimg.com/demo/demo-image-1.png "示例") + +### 其他元素 + +> 引用文本块 +> 多行引用内容 + +--- + +分割线测试(上方) + +脚注测试[^1] + +[^1]: 这里是脚注内容 + +这是一个链接 [Markdown语法](https://markdown.com.cn)。 + +✅ 任务列表: + +- [ ] 未完成任务 +- [x] 已完成任务 + +HTML混合测试: +
(需要开启html选项) +辅助文字 diff --git a/packages/pro-components/chat/chat-markdown/_example/mock2.md b/packages/pro-components/chat/chat-markdown/_example/mock2.md new file mode 100644 index 0000000000..eb1eaa0244 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/mock2.md @@ -0,0 +1,59 @@ +# Markdown功能测试 + +## 块级公式 + +$$ +E=mc^2 +$$ + +## 行内公式 +这是一个行内公式 $\\sqrt{3x-1}+(1+x)^2$ + +## Mermaid 图表 + +- 脑图 + +```mermaid +mindmap + root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + Pen and paper + Mermaid +``` + +- 统计图 + +```mermaid + xychart-beta + title "Sales Revenue" + x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec] + y-axis "Revenue (in $)" 4000 --> 11000 + bar [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000] + line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000] +``` + +- 计划 + +```mermaid +journey + title My working day + section Go to work + Make tea: 5: Me + Go upstairs: 3: Me + Do work: 1: Me, Cat + section Go home + Go downstairs: 5: Me + Sit down: 3: Me +``` diff --git a/packages/pro-components/chat/chat-markdown/_example/plugin.tsx b/packages/pro-components/chat/chat-markdown/_example/plugin.tsx new file mode 100644 index 0000000000..db7eb7b311 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/plugin.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { Space, Switch } from 'tdesign-react'; +import { ChatMarkdown } from '@tdesign-react/chat'; +// 公式能力引入,可参考cherryMarkdown示例 +import 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js'; + +const mdContent = ` +--- + +## 块级公式 + +$$ +E=mc^2 +$$ + +## 行内公式 +这是一个行内公式 $\\sqrt{3x-1}+(1+x)^2$ +`; + +const MarkdownExample = () => { + const [hasKatex, setHasKatex] = useState(false); + const [rerenderKey, setRerenderKey] = useState(1); + + // 切换公式插件 + const handleKatexChange = (checked: boolean) => { + setHasKatex(checked); + setRerenderKey((prev) => prev + 1); + }; + + return ( + + + 动态加载插件: + + 公式 + + + + {/* 通过key强制重新挂载组件 */} + + + ); +}; + +export default MarkdownExample; diff --git a/packages/pro-components/chat/chat-markdown/_example/theme.tsx b/packages/pro-components/chat/chat-markdown/_example/theme.tsx new file mode 100644 index 0000000000..1ba0de8fbd --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/theme.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { Space, Switch } from 'tdesign-react'; +import { ChatMarkdown } from '@tdesign-react/chat'; +// 公式能力引入,可参考cherryMarkdown示例 +import 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js'; + +const mdContent = ` +--- + +## 代码块主题设置演示 + +\`\`\`javascript +function fibonacci(n) { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +console.log(fibonacci(10)); // 输出: 55 +\`\`\` + +\`\`\`python +def quick_sort(arr): + if len(arr) <= 1: + return arr + pivot = arr[len(arr) // 2] + left = [x for x in arr if x < pivot] + middle = [x for x in arr if x == pivot] + right = [x for x in arr if x > pivot] + return quick_sort(left) + middle + quick_sort(right) +\`\`\` +`; + +const MarkdownExample = () => { + const [codeBlockTheme, setCodeBlockTheme] = useState<'light' | 'dark'>('light'); + const [rerenderKey, setRerenderKey] = useState(1); + + // 切换代码块主题 + const handleCodeThemeChange = (checked: boolean) => { + setCodeBlockTheme(checked ? 'dark' : 'light'); + setRerenderKey((prev) => prev + 1); + }; + + return ( + + + + 代码块主题切换: + + + + {/* 通过key强制重新挂载组件 */} + + + ); +}; + +export default MarkdownExample; diff --git "a/packages/pro-components/chat/chat-markdown/_example/\350\207\252\345\256\232\344\271\211\350\257\255\346\263\225.md" "b/packages/pro-components/chat/chat-markdown/_example/\350\207\252\345\256\232\344\271\211\350\257\255\346\263\225.md" new file mode 100644 index 0000000000..b093353700 --- /dev/null +++ "b/packages/pro-components/chat/chat-markdown/_example/\350\207\252\345\256\232\344\271\211\350\257\255\346\263\225.md" @@ -0,0 +1,416 @@ +## 简单例子 + +通过一个例子来了解cherry的自定义语法机制,如下: + +**定义一个自定义语法** +```javascript +/** + * 自定义一个语法,识别形如 ***ABC*** 的内容,并将其替换成 ABC + */ +var CustomHookA = Cherry.createSyntaxHook('important', Cherry.constants.HOOKS_TYPE_LIST.SEN, { + makeHtml(str) { + return str.replace(this.RULE.reg, function(whole, m1, m2) { + return `${m2}`; + }); + }, + rule(str) { + return { reg: /(\*\*\*)([^\*]+)\1/g }; + }, +}); + +... +/** + * @param {string} hookName 语法名 + * @param {string} type 语法类型,行内语法为Cherry.constants.HOOKS_TYPE_LIST.SEN,段落语法为Cherry.constants.HOOKS_TYPE_LIST.PAR + * @param {object} options 自定义语法的主体逻辑 + */ +Cherry.createSyntaxHook(hookName, type, options) +``` + +**将这个语法配置到cherry配置中**: +```javascript +new Cherry({ + id: 'markdown-container', + value: '## hello world', + fileUpload: myFileUpload, + engine: { + customSyntax: { + importHook: { + syntaxClass: CustomHookA, // 将自定义语法对象挂载到 importHook.syntaxClass上 + force: false, // true: 当cherry自带的语法中也有一个“importHook”时,用自定义的语法覆盖默认语法; false:不覆盖 + before: 'fontEmphasis', // 定义该自定义语法的执行顺序,当前例子表明在加粗/斜体语法前执行该自定义语法 + }, + }, + }, + toolbars: { + ... + }, +}); +``` + +**效果如下图:** +![image](https://github.com/Tencent/cherry-markdown/assets/998441/66a99621-ee1c-4a46-99d2-35f25c1e132f) + +----- + +## 详细解释 + +**原理** + +一言以蔽之,cherry的语法解析引擎就是将一堆**正则**按一定**顺序**依次执行,将markdown字符串替换为html字符串的工具。 + + +**语法分两类** +1. 行内语法,即类似加粗、斜体、上下标等主要对文字样式进行控制的语法,最大的特点是可以相互嵌套 +2. 段落语法,即类似表格、代码块、列表等主要对整段文本样式进行控制的语法,有两个特点: + 1. 可以在内部执行行内语法 + 2. 可以声明与其他段落语法互斥 + + +**语法的组成** +1. 语法名,唯一的作用就是用来定义语法的执行顺序的时候按语法名排序 +2. beforeMakeHtml(),engine会最先按**语法正序**依次调用beforeMakeHtml() +3. makeHtml(),engine会在调用完所有语法的beforeMakeHtml()后,再按**语法正序**依次调用makeHtml() +4. afterMakeHtml(),engine会在调用完所有语法的makeHtml()后,再按**语法逆序**依次调用afterMakeHtml() +5. rule(),用来定义语法的正则 +6. needCache,用来声明是否需要“缓存”,只有段落语法支持这个变量,true:段落语法可以在beforeMakeHtml()、makeHtml()的时候利用`this.pushCache()`和`this.popCache()`实现排它的能力 +> 这些东西都是干啥用的?继续往下看,我们会用一个实际的例子介绍上述各功能属性的作用 + + +**自带的语法** +- 行内Hook
+引擎会按当前顺序执行makeHtml方法 + - emoji 表情 + - image 图片 + - link 超链接 + - autoLink 自动超链接(自动将符合超链接格式的字符串转换成标签) + - fontEmphasis 加粗和斜体 + - bgColor 字体背景色 + - fontColor 字体颜色 + - fontSize 字体大小 + - sub 下标 + - sup 上标 + - ruby 一种表明上下排列的排版方式,典型应用就是文字上面加拼音 + - strikethrough 删除线 + - underline 下划线 + - highLight 高亮(就是在文字外层包一个标签) +- 段落级 Hook
+引擎会按当前排序顺序执行beforeMake、makeHtml方法
+引擎会按当前排序逆序执行afterMake方法 + - codeBlock 代码块 + - inlineCode 行内代码(因要实现排它特性,所以归类为段落语法) + - mathBlock 块级公式 + - inlineMath 行内公式(理由同行内代码) + - htmlBlock html标签,主要作用为过滤白名单外的html标签 + - footnote 脚注 + - commentReference 超链接引用 + - br 换行 + - table 表格 + - blockquote 引用 + - toc 目录 + - header 标题 + - hr 分割线 + - list 有序列表、无序列表、checklist + - detail 手风琴 + - panel 信息面板 + - normalParagraph 普通段落 + +**具体介绍** +- 如果要实现一个**行内语法**,只需要了解以下三个概念 + 1. 定义正则 rule() + 2. 定义具体的正则替换逻辑 makeHtml() + 3. 确定自定义语法名,并确定执行顺序 +- 如果要实现一个**段落语法**,则需要在了解行内语法相关概念后再了解以下概念: + 1. 排它机制 + 2. 局部渲染机制 + 3. 编辑区和预览区同步滚动机制 + +由于上面已有自定义行内语法的实现例子,接下来我们将通过实现一个**自定义段落语法**的例子来了解各个机制 + + +-------- +## 一例胜千言 + +**最简单段落语法** +```javascript +/** + * 把 \n++\n XXX \n++\n 渲染成
XXX
+ */ +var myBlockHook = Cherry.createSyntaxHook('myBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { + makeHtml(str) { + return str.replace(this.RULE.reg, function(whole, m1) { + return `
${m1}
`; + }); + }, + rule(str) { + return { reg: /\n\+\+(\n[\s\S]+?\n)\+\+\n/g }; + }, +}); +... +new Cherry({ + ... + customSyntax: { + myBlock: { + syntaxClass: myBlockHook, + before: 'blockquote', + }, + }, + ... +}); +``` + + +**效果如下** + +![image](https://github.com/Tencent/cherry-markdown/assets/998441/48b57bb2-4a18-434a-a7b9-3ec1a46d264b) + + +**遇到问题** + +当我们尝试进行段逻语法嵌套时,就会发现这样的问题: +1. **问题1**:代码块没有放进新语法里 +2. **问题2**:产生了一个多余的P标签 +![image](https://github.com/Tencent/cherry-markdown/assets/998441/61db32f1-cced-46f3-a286-491f4c049c54) + + +### 理解排它机制 + +为什么会有这样的问题,则需要先理解cherry的排他机制 +一言以蔽之,排他就是某个语法利用自己的“先发优势(如`beforeMakeHtml`、`makeHtml`)”把符合自己语法规则的内容先替换成**占位符**,再利用自己的“后发优势(`afterMakeHtml`)”将占位符**替换回**html内容 + +**分析原因** + +接下来解释上面出现的“bug”的原因: +1. 新语法(`myBlockHook`)并没有实现排他操作 +2. 在1)的前提下,引擎先执行`codeBlock.makeHtml()`,再执行`myBlockHook.makeHtml()`,最后执行`normalParagraph.makeHtml()`(当然还执行了其他语法hook) + 1. 在执行`codeBlock.makeHtml()`后,源md内容变成了
![image](https://github.com/Tencent/cherry-markdown/assets/998441/091cb59e-e3f1-45e8-9278-cd2aa4e7f644) + 2. 在执行`myBlockHook.makeHtml()`后,原内容变成了
![image](https://github.com/Tencent/cherry-markdown/assets/998441/59e3f0ae-92b8-43f0-bc74-6e24405a92a8) + 3. 在执行`normalParagraph.makeHtml()`时,必须要先讲一下`normalParagraph.makeHtml()`的逻辑了,逻辑如下: + - normalParagraph认为任意两个同层级排他段落语法之间是**无关的**,所以其会按**段落语法占位符**分割文档,把文档分成若干段落,在本例子中,其把文章内容分成了 `src`、`~CodeBlock的占位符~`、``三块内容,至于为什么这么做,则涉及到了**局部渲染机制**,后续会介绍 + - normalParagraph在渲染各块内容时会利用[dompurify](https://github.com/cure53/DOMPurify)对内容进行html标签合法性处理,比如会检测到第一段内容div标签没有闭合,会将第一段内容变成`src`这就出现了**问题1**,然后会判定第三段内容非法,直接把``删掉,这就出现了**问题2** + + +**解决问题** + +如何解决上述“bug”呢,很简单,只要给myBlockHook实现排他就好了,实现代码如下: +```javascript +/** + * 把 \n++\n XXX \n++\n 渲染成
XXX
+ */ +var myBlockHook = Cherry.createSyntaxHook('myBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { + needCache: true, // 表明要使用缓存,也是实现排他的必要操作 + makeHtml(str) { + const that = this; + return str.replace(this.RULE.reg, function(whole, m1) { + const result = `\n
${m1}
\n`; + return that.pushCache(result); // 将结果转成占位符 + }); + }, + rule(str) { + return { reg: /\n\+\+(\n[\s\S]+?\n)\+\+\n/g }; + }, +}); +``` + +**效果如下** + +![image](https://github.com/Tencent/cherry-markdown/assets/998441/98149a7f-7462-4621-a15c-3179c6fd323c) + +**遇到问题** + +自定义语法没有被渲染出来 + +### 理解局部渲染机制 + +为什么自定义语法没有渲染出来,则需要了解cherry的局部渲染机制,首先看以下例子: +![image](https://github.com/Tencent/cherry-markdown/assets/998441/8fd27a4d-aa69-47b1-8c1a-1aa573a3eb4f) +> 局部渲染的目的就是为了在**文本量很大**时**提高性能**,做的无非两件事:减少每个语法的**执行次数**(局部解析),减少预览区域dom的**变更范围**(局部渲染)。 + +局部解析的机制与当前问题无关先按下不表,接下来解释局部渲染的实现机制: +1. 段落语法根据md内容生成对应html内容时,会提取md内容的特征(md5),并将特征值放进段落标签的`data-sign`属性中 +2. 预览区会将已有段落的`data-sign`和引擎生成的新段落`data-sign`进行对比 + - 如果`data-sign`值没有变化,则认为当前段落内容没有变化 + - 如果段落内容有变化,则用新段落替换旧段落 + + +**分析原因** + +接下来解释上面出现的“bug”的原因: +1. 新语法(`myBlockHook`)输出的html标签里并没有`data-sign`属性 +2. 预览区在拿到新的html内容时,会获取有`data-sign`属性的段落,并将其更新到预览区 +3. 由于`myBlockHook`没有`data-sign`属性,但`codeBlock`有`data-sign`属性,所以只有代码块被渲染到了预览区 + + +**解决问题** + +如何解决上述“bug”呢,很简单,只要给myBlockHook增加`data-sign`属性就好了,实现代码如下: +```javascript +/** + * 把 \n++\n XXX \n++\n 渲染成
XXX
+ */ +var myBlockHook = Cherry.createSyntaxHook('myBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { + needCache: true, // 表明要使用缓存,也是实现排他的必要操作 + makeHtml(str) { + const that = this; + return str.replace(this.RULE.reg, function(whole, m1) { + const sign = that.$engine.md5(whole); // 定义sign,用来实现局部渲染 + const lines = that.getLineCount(whole); // 定义行号,用来实现联动滚动 + const result = `\n
${m1}
\n`; + return that.pushCache(result, sign, lines); // 将结果转成占位符 + }); + }, + rule(str) { + return { reg: /\n\+\+(\n[\s\S]+?\n)\+\+\n/g }; + }, +}); +``` + +> 注:`data-lines`属性是用来实现编辑区和预览区联动滚动的 + + +**效果如下** + +![image](https://github.com/Tencent/cherry-markdown/assets/998441/b2f9d90a-91c4-4bb2-a79d-24b903ef5ace) + + +**遇到问题** + +新段落语法里的行内语法没有被渲染出来 + +**解决问题** + +段落语法的`makeHtml()`会传入**两个**参数(行内语法的只会传入**一个**参数),第二个参数是`sentenceMakeFunc`(行内语法渲染器) +```javascript +/** + * 把 \n++\n XXX \n++\n 渲染成
XXX
+ */ +var myBlockHook = Cherry.createSyntaxHook('myBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { + needCache: true, // 表明要使用缓存,也是实现排他的必要操作 + makeHtml(str, sentenceMakeFunc) { + const that = this; + return str.replace(this.RULE.reg, function(whole, m1) { + // const sign = that.$engine.md5(whole); // 定义sign,用来实现局部渲染 + const lines = that.getLineCount(whole); // 定义行号,用来实现联动滚动 + const { sign, html } = sentenceMakeFunc(m1); // 解析行内语法 + const result = `\n
${html}
\n`; + return that.pushCache(result, sign, lines); // 将结果转成占位符 + }); + }, + rule(str) { + return { reg: /\n\+\+(\n[\s\S]+?\n)\+\+\n/g }; + }, +}); +``` + +----- + +### 总结 + +- 如果要实现一个**行内语法**,只需要实现以下三个功能 + 1. 定义正则 rule() + 2. 定义具体的正则替换逻辑 makeHtml() + 3. 确定自定义语法名,并确定执行顺序 +- 如果要实现一个**段落语法**,则需要在实现上面三个功能后,同时实现以下三个功能 + 1. 排它机制 `needCache: true` + 2. 局部渲染机制 `data-sign` + 3. 编辑区和预览区同步滚动机制 `data-lines` + + +**完整例子**: + +```javascript +/** + * 自定义一个语法,识别形如 ***ABC*** 的内容,并将其替换成 ABC + */ +var CustomHookA = Cherry.createSyntaxHook('important', Cherry.constants.HOOKS_TYPE_LIST.SEN, { + makeHtml(str) { + return str.replace(this.RULE.reg, function(whole, m1, m2) { + return `${m2}`; + }); + }, + rule(str) { + return { reg: /(\*\*\*)([^\*]+)\1/g }; + }, +}); + +/** + * 把 \n++\n XXX \n++\n 渲染成
XXX
+ */ +var myBlockHook = Cherry.createSyntaxHook('myBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { + needCache: true, // 表明要使用缓存,也是实现排他的必要操作 + makeHtml(str, sentenceMakeFunc) { + const that = this; + return str.replace(this.RULE.reg, function(whole, m1) { + // const sign = that.$engine.md5(whole); // 定义sign,用来实现局部渲染 + const lines = that.getLineCount(whole); // 定义行号,用来实现联动滚动 + const { sign, html } = sentenceMakeFunc(m1); // 解析行内语法 + const result = `\n
${html}
\n`; + return that.pushCache(result, sign, lines); // 将结果转成占位符 + }); + }, + rule(str) { + return { reg: /\n\+\+(\n[\s\S]+?\n)\+\+\n/g }; + }, +}); + +new Cherry({ + id: 'markdown-container', + value: '## hello world', + fileUpload: myFileUpload, + engine: { + customSyntax: { + importHook: { + syntaxClass: CustomHookA, // 将自定义语法对象挂载到 importHook.syntaxClass上 + force: false, // true: 当cherry自带的语法中也有一个“importHook”时,用自定义的语法覆盖默认语法; false:不覆盖 + before: 'fontEmphasis', // 定义该自定义语法的执行顺序,当前例子表明在加粗/斜体语法前执行该自定义语法 + }, + myBlock: { + syntaxClass: myBlockHook, + force: true, + before: 'blockquote', + }, + }, + }, + toolbars: { + ... + }, +}); +``` + + +**效果如下** + +![image](https://github.com/Tencent/cherry-markdown/assets/998441/fe3feb0b-2791-460f-9ab7-e04f076ce388) + +## 特殊情况 + +因为要实现**排他性**,所以需要声明自定义语法为**段落语法**,但实际这个语法是**行内语法**,所以需要**特殊处理**。 + +主要操作就是在调用`pushCache`的时候,第二个参数前面加上`!`前缀,这样cherry就会按照行内语法进行渲染 + +```js +/** + * 把 ++\n XXX \n++ 渲染成 XXX,并且排他 + */ +var myBlockHook = Cherry.createSyntaxHook('myBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { + needCache: true, // 表明要使用缓存,也是实现排他的必要操作 + makeHtml(str, sentenceMakeFunc) { + const that = this; + return str.replace(this.RULE.reg, function(whole, m1) { + // const sign = that.$engine.md5(whole); // 定义sign,用来实现局部渲染 + const lines = that.getLineCount(whole); // 定义行号,用来实现联动滚动 + const { sign, html } = sentenceMakeFunc(m1); // 解析行内语法 + const result = `${html}`; + // pushCache的第二个参数是sign,因为是行内语法,所以需要加一个前缀! + return that.pushCache(result, `!${sign}`, lines); // 将结果转成占位符 + }); + }, + rule(str) { + return { reg: /\+\+(\n[\s\S]+?\n)\+\+/g }; + }, +}); +``` + +**效果如下**: + +![image](https://github.com/user-attachments/assets/fad1ae9a-0b3b-4f03-af95-617b50aa65d7) diff --git a/packages/pro-components/chat/chat-markdown/chat-markdown.en-US.md b/packages/pro-components/chat/chat-markdown/chat-markdown.en-US.md new file mode 100644 index 0000000000..e0dd3a022e --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/chat-markdown.en-US.md @@ -0,0 +1,30 @@ +--- +title: ChatMarkdown 消息内容 +description: Markdown格式的消息内容 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +## 基础用法 + +{{ base }} + +## 配置项及加载插件 +组件内置了`markdown-it`作为markdown解析引擎,可以通过配置项`options`来定制解析规则。同时为了减小打包体积,我们只默认加载了部分必要插件,如果需要加载更多插件,可以通过`pluginConfig`属性来选择性开启,目前支持动态加载`code代码块`和`katex公式`插件。 + +{{ plugin }} + + + +## API +### ChatMarkdown Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +content | String | - | 需要渲染的 Markdown 内容 | N +role | String | - | 发送者角色,影响样式渲染 | N +options | MarkdownIt.Options | - | Markdown 解析器基础配置。TS类型:`{ html: true, breaks: true, typographer: true }` | N +pluginConfig | Array | - | 插件配置数组。TS类型:`[ { preset: 'code', enabled: false }, { preset: 'katex', enabled: false } ]` | N diff --git a/packages/pro-components/chat/chat-markdown/chat-markdown.md b/packages/pro-components/chat/chat-markdown/chat-markdown.md new file mode 100644 index 0000000000..2ca6702656 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/chat-markdown.md @@ -0,0 +1,39 @@ +--- +title: ChatMarkdown 消息内容 +description: Markdown格式的消息内容 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +## 基础用法 + +{{ base }} + + +## 主题配置 +目前仅支持对`代码块`的主题设置 + +{{ theme }} + +## 配置项及加载插件 +组件内置了`cherry-markdown`作为markdown解析引擎,可以通过配置项`options`来定制解析规则,其中通过themeSetting可以来设置。同时为了减小打包体积,我们只默认加载了部分必要插件,如果需要加载更多插件,可以通过查看[cherry-markdown文档](https://github.com/Tencent/cherry-markdown/blob/dev/README.CN.md)配置开启,以下给出动态引入`katex公式`插件的示例。 + +{{ plugin }} + + +## 自定义事件响应 +{{ event }} + +## 自定义语法渲染 +以下展示了如何基于`cherry createSyntaxHook`机制来实现自定义脚注,语法格式:**[ref:1|标题|摘要|链接]**, 更多更丰富的自定义语法功能和示例,可以参考[cherry-markdown自定义语法](https://github.com/Tencent/cherry-markdown/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89%E8%AF%AD%E6%B3%95) + +{{ footnote }} + +## API +### ChatMarkdown Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +content | String | - | 需要渲染的 Markdown 内容 | N +options | Object | - | Markdown 解析器基础配置。TS类型:`TdChatContentMDOptions` | N diff --git a/packages/pro-components/chat/chat-markdown/index.ts b/packages/pro-components/chat/chat-markdown/index.ts new file mode 100644 index 0000000000..719a9fbec0 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/index.ts @@ -0,0 +1,13 @@ +import { TdChatMarkdownContentProps, TdMarkdownEngine } from 'tdesign-web-components'; +import reactify from '../_util/reactify'; + +export const MarkdownEngine: typeof TdMarkdownEngine = TdMarkdownEngine; +export const ChatMarkdown: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-chat-md-content'); + +// eslint-disable-next-line import/first +import 'tdesign-web-components/lib/chat-message/content/markdown-content'; + +export default ChatMarkdown; +export type { TdChatMarkdownContentProps } from 'tdesign-web-components'; diff --git a/packages/pro-components/chat/chat-message/_example/action.tsx b/packages/pro-components/chat/chat-message/_example/action.tsx new file mode 100644 index 0000000000..5163dd7c10 --- /dev/null +++ b/packages/pro-components/chat/chat-message/_example/action.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; +import { ChatActionBar, ChatMessage, AIMessage, getMessageContentForCopy } from '@tdesign-react/chat'; + +const message: AIMessage = { + id: '123123', + role: 'assistant', + content: [ + { + type: 'text', + data: '牛顿第一定律并不适用于所有参考系,它只适用于惯性参考系。在质点不受外力作用时,能够判断出质点静止或作匀速直线运动的参考系一定是惯性参考系,因此只有在惯性参考系中牛顿第一定律才适用。', + }, + ], +}; +export default function ChatMessageExample() { + return ( + + + {/* 植入插槽用来追加消息底部操作栏 */} + + + + ); +} diff --git a/packages/pro-components/chat/chat-message/_example/base.tsx b/packages/pro-components/chat/chat-message/_example/base.tsx new file mode 100644 index 0000000000..86074fc8c8 --- /dev/null +++ b/packages/pro-components/chat/chat-message/_example/base.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; +import { UserMessage, ChatMessage } from '@tdesign-react/chat'; + +const message: UserMessage = { + id: '1', + role: 'user', + content: [ + { + type: 'text', + data: '牛顿第一定律是否适用于所有参考系?', + }, + ], +}; + +export default function ChatMessageExample() { + return ( + + + + + + ); +} diff --git a/packages/pro-components/chat/chat-message/_example/configure.tsx b/packages/pro-components/chat/chat-message/_example/configure.tsx new file mode 100644 index 0000000000..0dea6de900 --- /dev/null +++ b/packages/pro-components/chat/chat-message/_example/configure.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Divider, Space } from 'tdesign-react'; +import { AIMessage, ChatMessage, SystemMessage, UserMessage } from '@tdesign-react/chat'; + +const messages = { + ai: { + id: '11111', + role: 'assistant', + content: [ + { + type: 'text', + data: '牛顿第一定律并不适用于所有参考系,它只适用于惯性参考系。在质点不受外力作用时,能够判断出质点静止或作匀速直线运动的参考系一定是惯性参考系,因此只有在惯性参考系中牛顿第一定律才适用。', + }, + ], + } as AIMessage, + user: { + id: '22222', + role: 'user', + content: [ + { + type: 'text', + data: '牛顿第一定律是否适用于所有参考系?', + }, + ], + } as UserMessage, + system: { + id: '33333', + role: 'system', + content: [ + { + type: 'text', + data: '模型由 hunyuan 变为 GPT4', + }, + ], + } as SystemMessage, + error: { + id: '4444', + role: 'assistant', + status: 'error', + content: [ + { + type: 'text', + data: '数据解析失败', + }, + ], + } as AIMessage, +}; + +export default function ChatMessageExample() { + return ( + + 发送消息 + + + 可配置位置 + + + 角色为system的系统消息 + + + ); +} diff --git a/packages/pro-components/chat/chat-message/_example/content.tsx b/packages/pro-components/chat/chat-message/_example/content.tsx new file mode 100644 index 0000000000..c164114e36 --- /dev/null +++ b/packages/pro-components/chat/chat-message/_example/content.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Space, Divider } from 'tdesign-react'; +import { ChatMessage } from '@tdesign-react/chat'; + +export default function ChatMessageExample() { + return ( + + 文本格式 + + Markdown格式 + + 思考过程 + + 搜索结果 + + 建议问题 + + 附件内容 + + + ); +} diff --git a/packages/pro-components/chat/chat-message/_example/custom.tsx b/packages/pro-components/chat/chat-message/_example/custom.tsx new file mode 100644 index 0000000000..96b74bee67 --- /dev/null +++ b/packages/pro-components/chat/chat-message/_example/custom.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import TvisionTcharts from 'tvision-charts-react'; +import { Avatar, Space } from 'tdesign-react'; + +import { ChatBaseContent, ChatMessage } from '@tdesign-react/chat'; + +// 扩展自定义消息体类型 +declare module 'tdesign-react' { + interface AIContentTypeOverrides { + chart: ChatBaseContent< + 'chart', + { + chartType: string; + options: any; + theme: string; + } + >; + } +} + +const aiMessage: any = { + id: '123123', + role: 'assistant', + content: [ + { + type: 'text', + data: '昨日上午北京道路车辆通行状况,9:00的峰值(1330)可能显示早高峰拥堵最严重时段,10:00后缓慢回落,可以得出如下折线图:', + }, + { + type: 'chart', + data: { + id: '13123', + chartType: 'line', + options: { + xAxis: { + type: 'category', + data: [ + '0:00', + '1:00', + '2:00', + '3:00', + '4:00', + '5:00', + '6:00', + '7:00', + '8:00', + '9:00', + '10:00', + '11:00', + '12:00', + ], + }, + yAxis: { + axisLabel: { inside: false }, + }, + series: [ + { + data: [820, 932, 901, 934, 600, 500, 700, 900, 1330, 1320, 1200, 1300, 1100], + type: 'line', + }, + ], + }, + }, + }, + ], +}; + +const userMessage: any = { + id: '456456', + role: 'user', + content: [ + { + type: 'text', + data: '请帮我分析一下昨天北京的交通状况', + }, + ], +}; + +const ChartDemo = ({ data }) => ( +
+ +
+); + +// 自定义用户消息组件 +const CustomUserMessage = ({ message }) => ( +
+ {message.content.map((content, index) => ( +
+ {content.data} +
+ ))} + {/* 气泡尾巴 */} +
+
+); + +export default function ChatMessageExample() { + return ( + + {/* 用户消息 - 使用自定义渲染 */} + +
+ +
+
+ + {/* AI消息 - 使用自定义图表渲染 */} + } + name="TDesignAI" + role={aiMessage.role} + content={aiMessage.content} + > + {aiMessage.content.map(({ type, data }, index) => { + switch (type) { + /* 自定义渲染chart类型的消息内容--植入插槽 */ + case 'chart': + return ( +
+ +
+ ); + } + return null; + })} +
+
+ ); +} diff --git a/packages/pro-components/chat/chat-message/_example/handle-actions.tsx b/packages/pro-components/chat/chat-message/_example/handle-actions.tsx new file mode 100644 index 0000000000..2fd929fcef --- /dev/null +++ b/packages/pro-components/chat/chat-message/_example/handle-actions.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import { Space, MessagePlugin } from 'tdesign-react'; +import { ChatMessage, AIMessage } from '@tdesign-react/chat'; + +const message: AIMessage = { + id: '123123', + role: 'assistant', + status: 'complete', + content: [ + { + type: 'markdown', + data: '牛顿第一定律并不适用于所有参考系,它只适用于惯性参考系。在质点不受外力作用时,能够判断出质点静止或作匀速直线运动的参考系一定是惯性参考系,因此只有在惯性参考系中牛顿第一定律才适用。', + }, + { + type: 'search', + data: { + title: '搜索到 3 条相关内容', + references: [ + { + title: '惯性参考系 - 百度百科', + url: 'https://baike.baidu.com/item/惯性参考系', + content: '惯性参考系是指牛顿运动定律在其中成立的参考系...', + site: '百度百科', + }, + { + title: '牛顿第一定律的适用范围', + url: 'https://example.com/newton-first-law', + content: '牛顿第一定律只在惯性参考系中成立...', + site: '物理学习网', + }, + ], + }, + }, + { + type: 'suggestion', + data: [ + { + title: '什么是惯性参考系', + prompt: '什么是惯性参考系?', + }, + { + title: '牛顿第二定律是什么', + prompt: '牛顿第二定律是什么?', + }, + { + title: '非惯性参考系的例子', + prompt: '非惯性参考系有哪些例子?', + }, + ], + }, + ], +}; + +/** + * handleActions 使用示例 + * + * handleActions 用于处理消息内容中的交互操作,采用对象方式配置。 + * 支持的操作:suggestion(建议问题点击)、searchItem(搜索结果点击) + */ +export default function HandleActionsExample() { + + // 配置消息内容操作回调 + const handleActions = { + // 点击建议问题时触发 + suggestion: (data?: any) => { + console.log('点击建议问题', data); + const { title } = data?.content || {}; + MessagePlugin.info(`选择了问题:${title}`); + }, + // 点击搜索结果条目时触发 + searchItem: (data?: any) => { + console.log('点击搜索结果', data); + const { title } = data?.content || {}; + MessagePlugin.info(`点击了搜索结果:${title}`); + // 可以在这里打开链接或执行其他操作 + // window.open(url, '_blank'); + }, + }; + + return ( + + {/* 消息展示 */} + + + ); +} diff --git a/packages/pro-components/chat/chat-message/_example/status.tsx b/packages/pro-components/chat/chat-message/_example/status.tsx new file mode 100644 index 0000000000..d4f9abf954 --- /dev/null +++ b/packages/pro-components/chat/chat-message/_example/status.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Divider, Space, Select } from 'tdesign-react'; +import { AIMessage, ChatMessage, TdChatLoadingProps } from '@tdesign-react/chat'; + +const messages: Record = { + loading: { + id: '11111', + role: 'assistant', + status: 'pending', + datetime: '今天16:38', + }, + error: { + id: '22222', + role: 'assistant', + status: 'error', + content: [ + { + type: 'text', + data: '自定义错误文案', + }, + ], + }, +}; + +export default function ChatMessageExample() { + return ( + + 加载状态下的消息 + + + + + 出错状态下的消息 + + + + ); +} diff --git a/packages/pro-components/chat/chat-message/_example/think.tsx b/packages/pro-components/chat/chat-message/_example/think.tsx new file mode 100644 index 0000000000..cbba3d0c33 --- /dev/null +++ b/packages/pro-components/chat/chat-message/_example/think.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Space } from 'tdesign-react'; + +import { AIMessage, ChatMessage } from '@tdesign-react/chat'; + +const aiMessages: AIMessage = { + id: '33333', + role: 'assistant', + status: 'complete', + content: [ + { + type: 'thinking', + status: 'complete', + data: { + title: '已完成思考(耗时3秒)', + text: '好的,我现在需要回答用户关于对比近3年当代偶像爱情剧并总结创作经验的问题\n查询网络信息中...\n根据网络搜索结果,成功案例包括《春色寄情人》《要久久爱》《你也有今天》等,但缺乏具体播放数据,需要结合行业报告总结共同特征。2022-2024年偶像爱情剧的创作经验主要集中在题材创新、现实元素融入、快节奏叙事等方面。结合行业报告和成功案例,总结出以下创作经验。', + }, + }, + { + type: 'markdown', + data: '**数据支撑:** 据《传媒内参2024报告》,2024年偶像爱情剧完播率`提升12%`,其中“职业创新”类`占比达65%`,豆瓣评分7+作品数量同比`增加40%`。', + }, + { + type: 'suggestion', + data: [ + { + title: '近3年偶像爱情剧的市场反馈如何', + prompt: '近3年偶像爱情剧的市场反馈如何', + }, + { + title: '偶像爱情剧的观众群体分析', + prompt: '偶像爱情剧的观众群体分析', + }, + { + title: '偶像爱情剧的创作趋势是什么', + prompt: '偶像爱情剧的创作趋势是什么', + }, + ], + }, + ], +}; + +export default function ChatMessageExample() { + const onActions = { + suggestion: ({ content }) => { + console.log('suggestionItem', content); + }, + searchItem: ({ content, event }) => { + event.preventDefault(); + event.stopPropagation(); + console.log('searchItem', content); + }, + }; + return ( + + + + ); +} diff --git a/packages/pro-components/chat/chat-message/_usage/index.jsx b/packages/pro-components/chat/chat-message/_usage/index.jsx new file mode 100644 index 0000000000..6ca6a2660f --- /dev/null +++ b/packages/pro-components/chat/chat-message/_usage/index.jsx @@ -0,0 +1,78 @@ +/** + * 该脚本为自动生成,如有需要请在 /script/generate-usage.js 中调整 + */ + +// @ts-nocheck +import React, { useState, useEffect, useMemo } from 'react'; +import BaseUsage, { useConfigChange, usePanelChange } from '@tdesign/react-site/src/components/BaseUsage'; +import jsxToString from 'react-element-to-jsx-string'; + +import { ChatMessage } from '@tdesign-react/chat'; + +import configProps from './props.json'; + +const message = { + content: [ + { + type: 'text', + data: '牛顿第一定律是否适用于所有参考系?', + }, + { + id: '11111', + role: 'assistant', + status: 'pending', + }, + ], +}; + +export default function Usage() { + const [configList, setConfigList] = useState(configProps); + + const { changedProps, onConfigChange } = useConfigChange(configList); + + const panelList = [{ label: 'ChatMessage', value: 'ChatMessage' }]; + + const { panel, onPanelChange } = usePanelChange(panelList); + + const [renderComp, setRenderComp] = useState(); + + useEffect(() => { + setRenderComp( +
+ + +
, + ); + }, [changedProps]); + + const jsxStr = useMemo(() => { + if (!renderComp) return ''; + return jsxToString(renderComp); + }, [renderComp]); + + return ( + + {renderComp} + + ); +} diff --git a/packages/pro-components/chat/chat-message/_usage/props.json b/packages/pro-components/chat/chat-message/_usage/props.json new file mode 100644 index 0000000000..1a9574d094 --- /dev/null +++ b/packages/pro-components/chat/chat-message/_usage/props.json @@ -0,0 +1,63 @@ +[ + { + "name": "variant", + "type": "enum", + "defaultValue": "base", + "options": [ + { + "label": "base", + "value": "base" + }, + { + "label": "outline", + "value": "outline" + }, + { + "label": "text", + "value": "text" + } + ] + }, + { + "name": "animation", + "type": "enum", + "defaultValue": "skeleton", + "options": [ + { + "label": "skeleton", + "value": "skeleton" + }, + { + "label": "moving", + "value": "moving" + }, + { + "label": "gradient", + "value": "gradient" + }, + { + "label": "circle", + "value": "circle" + }, + { + "label": "dot", + "value": "dot" + } + ] + }, + { + "name": "placement", + "type": "enum", + "defaultValue": "left", + "options": [ + { + "label": "left", + "value": "left" + }, + { + "label": "right", + "value": "right" + } + ] + } +] \ No newline at end of file diff --git a/packages/pro-components/chat/chat-message/chat-message.en-US.md b/packages/pro-components/chat/chat-message/chat-message.en-US.md new file mode 100644 index 0000000000..83cb46684b --- /dev/null +++ b/packages/pro-components/chat/chat-message/chat-message.en-US.md @@ -0,0 +1,60 @@ +:: BASE_DOC :: + +## API +### ChatMessage Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +actions | Array/Function/Boolean | - | 操作按钮配置项,可配置操作按钮选项和顺序。数组可选项:replay/copy/good/bad/goodActived/badActived/share | N +name | String | - | 发送者名称 | N +avatar | String/JSX.Element | - | 发送者头像 | N +datetime | String | - | 消息发送时间 | N +message | Object | - | 消息内容对象。类型定义见 `Message` | Y +placement | String | left | 消息位置。可选项:left/right | N +role | String | - | 发送者角色 | N +variant | String | text | 消息变体样式。可选项:base/outline/text | N +chatContentProps | Object | - | 消息内容属性配置。类型支持见 `chatContentProps` | N +handleActions | Object | - | 操作按钮处理函数 | N +animation | String | skeleton | 加载动画类型。可选项:skeleton/moving/gradient/circle | N + +### ChatMessagesData 消息对象结构 + +字段 | 类型 | 必传 | 说明 +--|--|--|-- +id | string | Y | 消息唯一标识 +role | `"user" \| "assistant" \| "system"` | Y | 消息角色类型 +status | `"pending" \| "streaming" \| "complete" \| "stop" \| "error"` | N | 消息状态 +content | `UserMessageContent[] \| AIMessageContent[] \| TextContent[]` | N | 消息内容 +ext | any | N | 扩展字段 + +#### UserMessageContent 内容类型支持 +- 文本消息 (`TextContent`) +- 附件消息 (`AttachmentContent`) + +#### AIMessageContent 内容类型支持 +- 文本消息 (`TextContent`) +- Markdown 消息 (`MarkdownContent`) +- 搜索消息 (`SearchContent`) +- 建议消息 (`SuggestionContent`) +- 思考状态 (`ThinkingContent`) +- 图片消息 (`ImageContent`) +- 附件消息 (`AttachmentContent`) +- 自定义消息 (`AIContentTypeOverrides`) + +几种类型都继承自`ChatBaseContent`,包含通用字段: +字段 | 类型 | 必传 | 默认值 | 说明 +--|--|--|--|-- +type | `ChatContentType` | Y | - | 内容类型标识(text/markdown/search等) +data | 泛型TData | Y | - | 具体内容数据,类型由type决定 +status | `ChatMessageStatus \| ((currentStatus?: ChatMessageStatus) => ChatMessageStatus)` | N | - | 内容状态或状态计算函数 +id | string | N | - | 内容块唯一标识 + +每种类型的data字段有不同的结构,具体可参考下方表格,[详细类型定义](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chatbot/core/type.ts#L17) + + +### 插槽 + +| 插槽名 | 说明 | +|--------|------| +| actionbar | 自定义操作栏 | +| `${type}-${index}` | 消息内容动态插槽,默认命名规则为`消息类型-内容索引`,如`chart-1`等 | \ No newline at end of file diff --git a/packages/pro-components/chat/chat-message/chat-message.md b/packages/pro-components/chat/chat-message/chat-message.md new file mode 100644 index 0000000000..343ead8cfe --- /dev/null +++ b/packages/pro-components/chat/chat-message/chat-message.md @@ -0,0 +1,144 @@ +--- +title: ChatMessage 对话消息体 +description: 对话消息体组件,用于展示单条对话消息,支持用户消息和 AI 消息的多种内容类型渲染,包括文本、Markdown、思考过程、搜索结果、建议问题、图片、附件等,提供丰富的样式配置和交互能力。 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +## 基础样式 + +### 气泡样式 +对话消息气泡样式,分为基础、线框、文字,默认为文字 + +{{ base }} + +### 可配置角色,头像,昵称,位置 + +{{ configure }} + +### 消息状态 +{{ status }} + +## 消息内容渲染 +### 内置支持的几种消息内容 +通过配置 `message type`属性,可以渲染内置的几种消息内容:**文本格式内容**,**Markdown格式内容**、**思考过程**、**搜索结果**、**建议问题**、**附件列表**、**图片**, 通过`chatContentProps`属性来配置对应类型的属性 +{{ content }} + +### 消息内容操作回调 + +通过 `handleActions` 属性配置消息内容的操作回调,支持建议问题点击、搜索结果点击等交互。 + +{{ handle-actions }} + +### 消息内容自定义 +如果需要自定义消息内容,可以通过`植入自定义渲染插槽`的方式实现,以下示例实现了如何自定义用户消息,同时也通过引入了`tvision`自定义渲染`图表`组件演示如何自定义渲染AI消息内容: +{{ custom }} + + +### 消息底部操作栏 +消息底部操作栏,通过`植入插槽actionbar`的方式实现,可以直接使用[`ChatActionBar`组件](/react-chat/components/chat-actionbar),也可以完全自定义实现 +{{ action }} + +## API +### ChatMessage Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +placement | String | left | 消息显示位置。可选项:left/right | N +variant | String | text | 消息气泡样式变体。可选项:base/outline/text | N +animation | String | skeleton | 加载动画类型。可选项:skeleton/moving/gradient/circle | N +name | String/TNode | - | 发送者名称,支持字符串或自定义渲染 | N +avatar | String/TNode | - | 发送者头像,支持 URL 字符串或自定义渲染 | N +datetime | String/TNode | - | 消息发送时间 | N +role | String | - | 消息角色类型。可选项:user/assistant/system | Y +status | String | - | 消息状态。可选项:pending/streaming/complete/stop/error | N +content | AIMessageContent[] / UserMessageContent[] | - | 消息内容数组,根据 role 不同支持不同的内容类型,详见下方 `content 内容类型` 说明 | N +chatContentProps | Object | - | 消息内容属性配置,用于配置各类型内容的展示行为,[详见 `TdChatContentProps` 说明](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chat-message/type.ts) | N +actions | Array/Boolean | ['copy', 'good', 'bad', 'replay'] | 操作按钮配置。传入数组可自定义按钮顺序,可选项:copy/good/bad/replay/share;传入 false 隐藏操作栏 | N +handleActions | Object | - | 操作按钮处理函数对象,key 为操作名称(searchResult/searchItem/suggestion),value 为回调函数 `(data?: any) => void` | N +message | Object | - | 消息体对象(兼容旧版本),优先级低于直接传入的 role/content/status | N +id | String | - | 消息唯一标识 | N + +### content 内容类型 + +#### UserMessageContent(用户消息) +用户消息支持以下内容类型: + +类型 | data 结构 | 说明 +--|--|-- +text | `string` | 纯文本内容 +attachment | `AttachmentItem[]` | 附件列表,AttachmentItem 包含:fileType(文件类型:image/video/audio/pdf/doc/ppt/txt)、name(文件名)、url(文件地址)、size(文件大小)、width/height(尺寸)、extension(扩展名)、isReference(是否为引用)、metadata(元数据) + +#### AIMessageContent(AI 消息) +AI 消息支持更丰富的内容类型: + +类型 | data 结构 | 说明 +--|--|-- +text | `string` | 纯文本内容,支持 strategy 字段(merge: 合并文本流,append: 追加独立内容块) +markdown | `string` | Markdown 格式内容,支持复杂排版渲染,支持 strategy 字段 +thinking |
{
text?: string
title?: string
}
| 思考过程展示,text 为思考内容,title 为思考标题 +reasoning | `AIMessageContent[]` | 推理过程展示,data 为嵌套的 AI 消息内容数组,支持递归渲染 +image |
{
name?: string
url?: string
width?: number
height?: number
}
| 图片消息,支持尺寸配置 +search |
{
title?: string
references?: ReferenceItem[]
}
| 搜索结果展示,ReferenceItem 包含:title(标题)、icon(图标)、type(类型)、url(链接)、content(内容)、site(来源站点)、date(日期) +suggestion | `SuggestionItem[]` | 建议问题列表,用于快速交互,SuggestionItem 包含:title(显示文本)、prompt(实际发送内容),状态固定为 complete +attachment | `AttachmentItem[]` | 附件列表,结构同 UserMessageContent 的 attachment +自定义类型 | - | 支持通过 AIContentTypeOverrides 扩展自定义内容类型 + +**通用字段说明**:所有内容类型都继承自 `ChatBaseContent`,包含以下通用字段: +- `type`:内容类型标识(必传) +- `data`:具体内容数据(必传),类型由 type 决定 +- `id`:内容块唯一标识(可选) +- `status`:内容状态(可选),可选项:pending/streaming/complete/stop/error +- `strategy`:内容合并策略(可选,仅部分类型支持),可选项:merge(合并)/append(追加) +- `ext`:扩展字段(可选),用于存储自定义数据 + +### chatContentProps 配置 + +用于配置各类型内容的展示行为和交互逻辑: + +#### markdown 配置 + +名称 | 类型 | 默认值 | 说明 +-- | -- | -- | -- +options | Object | - | Cherry Markdown 配置项,支持 CherryOptions 的大部分配置(不包括 id/el/toolbars) +options.themeSettings | Object | - | 主题配置 +options.themeSettings.codeBlockTheme | String | - | 代码块主题。可选项:light/dark + +#### search 配置 + +名称 | 类型 | 默认值 | 说明 +-- | -- | -- | -- +useCollapse | Boolean | - | 是否使用折叠面板展示搜索结果 +collapsed | Boolean | - | 是否默认折叠 + +#### thinking 配置 + +名称 | 类型 | 默认值 | 说明 +-- | -- | -- | -- +maxHeight | Number | - | 思考内容最大高度(px) +animation | String | - | 加载动画类型。可选项:skeleton/moving/gradient/circle +collapsed | Boolean | - | 是否默认折叠 +layout | String | - | 布局样式。可选项:block/border + +#### reasoning 配置 + +名称 | 类型 | 默认值 | 说明 +-- | -- | -- | -- +maxHeight | Number | - | 推理内容最大高度(px) +animation | String | - | 加载动画类型。可选项:skeleton/moving/gradient/circle +collapsed | Boolean | - | 是否默认折叠 +layout | String | - | 布局样式。可选项:block/border + +#### suggestion 配置 + +名称 | 类型 | 默认值 | 说明 +-- | -- | -- | -- +directSend | Boolean | - | 点击建议问题是否直接发送(不填充到输入框) + +### 插槽 + +名称 | 说明 +-- | -- +actionbar | 自定义操作栏,可完全替换默认操作栏 +`${type}-${index}` | 消息内容动态插槽,命名规则为 `消息类型-内容索引`,如 `chart-0`、`custom-1` 等,用于自定义渲染特定位置的内容块 \ No newline at end of file diff --git a/packages/pro-components/chat/chat-message/index.ts b/packages/pro-components/chat/chat-message/index.ts new file mode 100644 index 0000000000..e20963760e --- /dev/null +++ b/packages/pro-components/chat/chat-message/index.ts @@ -0,0 +1,11 @@ +import { type TdChatMessageProps } from 'tdesign-web-components'; +import 'tdesign-web-components/lib/chat-message'; +import reactify from '../_util/reactify'; + +export const ChatMessage: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-chat-item'); + +export default ChatMessage; + +export type { TdChatMessageProps } from 'tdesign-web-components'; diff --git a/packages/pro-components/chat/chat-sender/_example/attachment.tsx b/packages/pro-components/chat/chat-sender/_example/attachment.tsx new file mode 100644 index 0000000000..9e6827870f --- /dev/null +++ b/packages/pro-components/chat/chat-sender/_example/attachment.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import type { UploadFile } from 'tdesign-react'; +import { ChatSender, TdAttachmentItem } from '@tdesign-react/chat'; + +const ChatSenderExample = () => { + const [inputValue, setInputValue] = useState(''); + const [loading, setLoading] = useState(false); + const [files, setFiles] = useState([ + { + name: 'excel-file.xlsx', + size: 111111, + }, + { + name: 'word-file.docx', + size: 222222, + }, + { + name: 'image-file.png', + size: 333333, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + { + name: 'pdf-file.pdf', + size: 4444, + }, + ]); + + // 输入变化处理 + const handleChange = (e) => { + console.log('onChange', e.detail); + setInputValue(e.detail); + }; + + // 发送处理 + const handleSend = () => { + console.log('提交', { value: inputValue }); + setInputValue(''); + setLoading(true); + setFiles([]); + }; + + // 停止处理 + const handleStop = () => { + console.log('停止'); + setLoading(false); + }; + + const onAttachmentsRemove = (e: CustomEvent) => { + console.log('onAttachmentsRemove', e); + setFiles(e.detail); + }; + + const onAttachmentsSelect = (e: CustomEvent) => { + // 添加新文件并模拟上传进度 + const newFile = { + ...e.detail[0], + size: e.detail[0].size, + name: e.detail[0].name, + status: 'progress' as UploadFile['status'], + description: '上传中', + }; + + setFiles((prev) => [newFile, ...prev]); + setTimeout(() => { + setFiles((prevState) => + prevState.map((file) => + file.name === newFile.name + ? { + ...file, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + status: 'success', + description: `${Math.floor((newFile?.size || 0) / 1024)}KB`, + } + : file, + ), + ); + }, 1000); + }; + + return ( + + ); +}; + +export default ChatSenderExample; diff --git a/packages/pro-components/chat/chat-sender/_example/base.tsx b/packages/pro-components/chat/chat-sender/_example/base.tsx new file mode 100644 index 0000000000..ad5e48b3aa --- /dev/null +++ b/packages/pro-components/chat/chat-sender/_example/base.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { ChatSender } from '@tdesign-react/chat'; + +const ChatSenderExample = () => { + const [inputValue, setInputValue] = useState(''); + const [loading, setLoading] = useState(false); + + // 输入变化处理 + const handleChange = (e) => { + console.log('onChange', e.detail); + setInputValue(e.detail); + }; + + // 发送处理 + const handleSend = () => { + console.log('提交', { value: inputValue }); + setInputValue(''); + setLoading(true); + }; + + // 停止处理 + const handleStop = () => { + console.log('停止'); + setLoading(false); + }; + + return ( + + ); +}; + +export default ChatSenderExample; diff --git a/packages/pro-components/chat/chat-sender/_example/custom.tsx b/packages/pro-components/chat/chat-sender/_example/custom.tsx new file mode 100644 index 0000000000..8c093cd6fe --- /dev/null +++ b/packages/pro-components/chat/chat-sender/_example/custom.tsx @@ -0,0 +1,201 @@ +import { TdAttachmentItem } from 'tdesign-web-components'; +import React, { useRef, useState, useEffect } from 'react'; +import { EnterIcon, InternetIcon, AttachIcon, CloseIcon, ArrowUpIcon, StopIcon } from 'tdesign-icons-react'; +import { ChatSender } from '@tdesign-react/chat'; +import { Space, Button, Tag, Dropdown, Tooltip, UploadFile } from 'tdesign-react'; +import { useDynamicStyle } from '../../_util/useDynamicStyle'; + +const options = [ + { + content: '帮我写作', + value: 1, + placeholder: '输入你要撰写的主题', + }, + { + content: '图像生成', + value: 2, + placeholder: '说说你的创作灵感', + }, + { + content: '网页摘要', + value: 3, + placeholder: '输入你要解读的网页地址', + }, +]; + +const ChatSenderExample = () => { + const [inputValue, setInputValue] = useState(''); + const [loading, setLoading] = useState(false); + const senderRef = useRef(null); + const [files, setFiles] = useState([]); + const [scene, setScene] = useState(1); + const [showRef, setShowRef] = useState(true); + const [activeR1, setR1Active] = useState(false); + const [activeSearch, setSearchActive] = useState(false); + + // 这里是为了演示样式修改不影响其他Demo,实际项目中直接设置css变量到:root即可 + useDynamicStyle(senderRef, { + '--td-text-color-placeholder': '#DFE2E7', + '--td-chat-input-radius': '6px', + }); + + // 输入变化处理 + const handleChange = (e) => { + console.log('onChange', e.detail); + setInputValue(e.detail); + }; + + // 发送处理 + const handleSend = () => { + console.log('提交', { value: inputValue }); + setInputValue(''); + setLoading(true); + setFiles([]); + }; + + // 停止处理 + const handleStop = () => { + console.log('停止'); + setLoading(false); + }; + + const onAttachClick = () => { + // senderRef.current?.focus(); + senderRef.current?.selectFile(); + }; + + const onFileSelect = (e: CustomEvent) => { + // 添加新文件并模拟上传进度 + const newFile = { + ...e.detail[0], + name: e.detail[0].name, + status: 'progress' as UploadFile['status'], + description: '上传中', + }; + + setFiles((prev) => [newFile, ...prev]); + + setTimeout(() => { + setFiles((prevState) => + prevState.map((file) => + file.name === newFile.name + ? { + ...file, + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + status: 'success', + } + : file, + ), + ); + }, 1000); + }; + + const switchScene = (data) => { + setScene(data.value); + }; + + const onRemoveRef = () => { + setShowRef(false); + }; + + const onAttachmentsRemove = (e: CustomEvent) => { + setFiles(e.detail); + }; + + return ( + item.value === scene)[0].placeholder} + loading={loading} + autosize={{ minRows: 2 }} + onChange={handleChange} + onSend={handleSend} + onStop={handleStop} + onFileSelect={onFileSelect} + onFileRemove={onAttachmentsRemove} + uploadProps={{ + accept: 'image/*', + }} + attachmentsProps={{ + items: files, + }} + > + {/* 自定义输入框上方区域,可用来引用内容或提示场景 */} + {showRef && ( +
+ + + + 引用一段文字 + +
+ +
+
+
+ )} + {/* 自定义输入框底部区域slot,可以增加模型选项 */} +
+ + + + + +
+ {/* 自定义输入框左侧区域slot,可以用来触发工具场景切换 */} +
+ + + {options.filter((item) => item.value === scene)[0].content} + + +
+ {/* 自定义提交区域slot */} +
+ {!loading ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default ChatSenderExample; diff --git a/packages/pro-components/chat/chat-sender/_example/style.css b/packages/pro-components/chat/chat-sender/_example/style.css new file mode 100644 index 0000000000..bb8d51eaed --- /dev/null +++ b/packages/pro-components/chat/chat-sender/_example/style.css @@ -0,0 +1,3 @@ +:root { + --td-text-color-placeholder: #DFE2E7; +} \ No newline at end of file diff --git a/packages/pro-components/chat/chat-sender/chat-sender.en-US.md b/packages/pro-components/chat/chat-sender/chat-sender.en-US.md new file mode 100644 index 0000000000..cf3544c7d1 --- /dev/null +++ b/packages/pro-components/chat/chat-sender/chat-sender.en-US.md @@ -0,0 +1,61 @@ +--- +title: ChatSender 对话输入 +description: 用于构建智能对话场景下的输入框组件 +isComponent: true +usage: { title: '', description: '' } +spline: navigation +--- + +## 基础用法 + +受控进行输入/发送等状态管理 +{{ base }} + + +## 附件输入 +支持选择附件及展示附件列表,受控进行文件数据管理,示例中模拟了文件上传流程 +{{ attachment }} + + +## 自定义 +通过植入具名插槽来实现输入框的自定义,内置支持的扩展位置包括: + +输入框上方区域`header`,输入框内头部区域`inner-header`,可输入区域前置部分`prefix`,输入框底部左侧区域`footer-prefix`,输入框底部操作区域`actions` + +同时示例中演示了通过`CSS变量覆盖`实现样式定制 + +{{ custom }} + +## API +### ChatSender Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +placeholder | String | - | 输入框占位文本 | N +disabled | Boolean | false | 是否禁用组件 | N +value | String | - | 输入框内容(受控) | N +defaultValue | String | - | 输入框默认内容(非受控) | N +loading | Boolean | false | 是否显示加载状态 | N +autosize | Object | `{ minRows: 2 }` | 输入框自适应高度配置 | N +actions | Array/Boolean | - | 操作按钮配置,TS 类型:`<'attachment' \| 'send'>[]` | N +attachmentsProps | Object | `{ items: [], overflow: 'scrollX' }` | 附件配置透传`ChatAttachment`,详见[ChatAttachment](https://tdesign.gtimg.com/chatbot/doc/react/api/chat-attachment?tab=api) | N +textareaProps | Object | - | 输入框额外属性,部分透传`Textarea`,TS 类型:`Partial>`,详见[TdTextareaProps](https://tdesign.tencent.com/react/components/textarea?tab=api) | N +uploadProps | Object | - | 文件上传属性,TS 类型:`{ accept: string; multiple: boolean; }` | N +onSend | Function | - | 发送消息事件。TS 类型:`(e: CustomEvent) => ChatRequestParams | void` | N +onStop | Function | - | 停止发送事件,TS 类型:`(e: CustomEvent) => void` | N +onChange | Function | - | 输入内容变化事件,TS 类型:`(e: CustomEvent) => void` | N +onFocus | Function | - | 输入框聚焦事件,TS 类型:`(e: CustomEvent) => void` | N +onBlur | Function | - | 输入框失焦事件,TS 类型:`(e: CustomEvent) => void` | N +onFileSelect | Function | - | 文件选择事件,TS 类型:`(e: CustomEvent) => void` | N +onFileRemove | Function | - | 文件移除事件,TS 类型:`(e: CustomEvent) => void` | N + + +### 插槽 + +| 插槽名 | 说明 | +|--------|------| +| header | 顶部自定义内容 | +| inner-header | 输入区域顶部内容 | +| input-prefix | 输入框前方区域 | +| footer-prefix | 底部左侧区域 | +| actions | 操作按钮区域 | \ No newline at end of file diff --git a/packages/pro-components/chat/chat-sender/chat-sender.md b/packages/pro-components/chat/chat-sender/chat-sender.md new file mode 100644 index 0000000000..1eb16f0338 --- /dev/null +++ b/packages/pro-components/chat/chat-sender/chat-sender.md @@ -0,0 +1,62 @@ +--- +title: ChatSender 对话输入 +description: 用于构建智能对话场景下的输入框组件 +isComponent: true +usage: { title: '', description: '' } +spline: navigation +--- + +### 基础用法 + +受控进行输入/发送等状态管理 +{{ base }} + + +### 附件输入 +支持选择附件及展示附件列表,受控进行文件数据管理,示例中模拟了文件上传流程 +{{ attachment }} + + +### 自定义 +通过植入具名插槽来实现输入框的自定义,内置支持的扩展位置包括: + +输入框上方区域`header`,输入框内头部区域`inner-header`,可输入区域前置部分`input-prefix`,输入框底部左侧区域`footer-prefix`,输入框底部操作区域`actions` + +同时示例中演示了通过`CSS变量覆盖`实现样式定制 + +{{ custom }} + +## API +### ChatSender Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +placeholder | String | - | 输入框占位文本 | N +disabled | Boolean | false | 是否禁用组件 | N +value | String | - | 输入框内容(受控) | N +defaultValue | String | - | 输入框默认内容(非受控) | N +loading | Boolean | false | 是否显示加载状态 | N +autosize | Object | `{ minRows: 2 }` | 输入框自适应高度配置 | N +actions | Array/Boolean | - | 操作按钮配置,TS 类型:`<'attachment' \| 'send'>[]` | N +attachmentsProps | Object | `{ items: [], overflow: 'scrollX' }` | 附件配置透传`ChatAttachment`,详见[ChatAttachment](https://tdesign.gtimg.com/chatbot/doc/react/api/chat-attachment?tab=api) | N +textareaProps | Object | - | 输入框额外属性,部分透传`Textarea`,TS 类型:`Partial>`,详见[TdTextareaProps](https://tdesign.tencent.com/react/components/textarea?tab=api) | N +uploadProps | Object | - | 文件上传属性,TS 类型:`{ accept: string; multiple: boolean; }` | N +onSend | Function | - | 发送消息事件。TS 类型:`(e: CustomEvent) => ChatRequestParams | void` | N +onStop | Function | - | 停止发送事件,TS 类型:`(e: CustomEvent) => void` | N +onChange | Function | - | 输入内容变化事件,TS 类型:`(e: CustomEvent) => void` | N +onFocus | Function | - | 输入框聚焦事件,TS 类型:`(e: CustomEvent) => void` | N +onBlur | Function | - | 输入框失焦事件,TS 类型:`(e: CustomEvent) => void` | N +onFileSelect | Function | - | 文件选择事件,TS 类型:`(e: CustomEvent) => void` | N +onFileRemove | Function | - | 文件移除事件,TS 类型:`(e: CustomEvent) => void` | N + + +### 插槽 + +| 插槽名 | 说明 | +|--------|------| +| header | 顶部自定义内容 | +| inner-header | 输入区域顶部内容 | +| input-prefix | 输入框前方区域 | +| textarea | 输入框替换 | +| footer-prefix | 底部左侧区域 | +| actions | 操作按钮区域 | diff --git a/packages/pro-components/chat/chat-sender/index.ts b/packages/pro-components/chat/chat-sender/index.ts new file mode 100644 index 0000000000..2097f5c24a --- /dev/null +++ b/packages/pro-components/chat/chat-sender/index.ts @@ -0,0 +1,10 @@ +import { TdChatSenderProps } from 'tdesign-web-components'; +import 'tdesign-web-components/lib/chat-sender'; +import reactify from '../_util/reactify'; + +export const ChatSender: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-chat-sender'); + +export default ChatSender; +export type * from 'tdesign-web-components/lib/chat-sender/type'; diff --git a/packages/pro-components/chat/chat-thinking/_example/base.tsx b/packages/pro-components/chat/chat-thinking/_example/base.tsx new file mode 100644 index 0000000000..5577369a36 --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/_example/base.tsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { ChatThinking, ChatMessageStatus } from '@tdesign-react/chat'; + +const fullText = + '嗯,用户问牛顿第一定律是不是适用于所有参考系。首先,我得先回忆一下牛顿第一定律的内容。牛顿第一定律,也就是惯性定律,说物体在没有外力作用时会保持静止或匀速直线运动。也就是说,保持原来的运动状态。那问题来了,这个定律是否适用于所有参考系呢?记得以前学过的参考系分惯性系和非惯性系。惯性系里,牛顿定律成立;非惯性系里,可能需要引入惯性力之类的修正。所以牛顿第一定律应该只在惯性参考系中成立,而在非惯性系中不适用,比如加速的电梯或者旋转的参考系,这时候物体会有看似无外力下的加速度,所以必须引入假想的力来解释。'; + +export default function ThinkContentDemo() { + const [displayText, setDisplayText] = useState(''); + const [status, setStatus] = useState('pending'); + const [title, setTitle] = useState('正在思考中...'); + const [collapsed, setCollapsed] = useState(false); + const timerRef = useRef>(null); + const currentIndex = useRef(0); + const startTimeRef = useRef(Date.now()); + + useEffect(() => { + // 模拟打字效果 + const typeEffect = () => { + if (currentIndex.current < fullText.length) { + const char = fullText[currentIndex.current]; + currentIndex.current += 1; + setDisplayText((prev) => prev + char); + timerRef.current = setTimeout(typeEffect, 50); + setStatus('streaming'); + } else { + // 计算耗时并更新状态 + const costTime = parseInt(((Date.now() - startTimeRef.current) / 1000).toString(), 10); + setTitle(`已完成思考(耗时${costTime}秒)`); + setStatus('complete'); + } + }; + + startTimeRef.current = Date.now(); + timerRef.current = setTimeout(typeEffect, 500); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const collapsedChangeHandle = (e: CustomEvent) => { + setCollapsed(e.detail); + }; + + useEffect(() => { + if (status === 'complete') { + setCollapsed(true); // 内容结束输出后收起面板 + } + }, [status]); + + return ( + + ); +} diff --git a/packages/pro-components/chat/chat-thinking/_example/style.tsx b/packages/pro-components/chat/chat-thinking/_example/style.tsx new file mode 100644 index 0000000000..d24f4d1964 --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/_example/style.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Radio, Space } from 'tdesign-react'; +import { ChatThinking } from '@tdesign-react/chat'; + +import type { TdChatThinkContentProps, ChatMessageStatus } from 'tdesign-web-components'; + +const fullText = + '嗯,用户问牛顿第一定律是不是适用于所有参考系。首先,我得先回忆一下牛顿第一定律的内容。牛顿第一定律,也就是惯性定律,说物体在没有外力作用时会保持静止或匀速直线运动。也就是说,保持原来的运动状态。那问题来了,这个定律是否适用于所有参考系呢?记得以前学过的参考系分惯性系和非惯性系。惯性系里,牛顿定律成立;非惯性系里,可能需要引入惯性力之类的修正。所以牛顿第一定律应该只在惯性参考系中成立,而在非惯性系中不适用,比如加速的电梯或者旋转的参考系,这时候物体会有看似无外力下的加速度,所以必须引入假想的力来解释。'; + +export default function ThinkContentDemo() { + const [displayText, setDisplayText] = useState(''); + const [status, setStatus] = useState('pending'); + const [title, setTitle] = useState('正在思考中...'); + const [layout, setLayout] = useState('block'); + const [animation, setAnimation] = useState('circle'); + const timerRef = useRef>(null); + const currentIndex = useRef(0); + const startTimeRef = useRef(Date.now()); + + useEffect(() => { + // 每次layout变化时重置状态 + resetTypingEffect(); + // 模拟打字效果 + const typeEffect = () => { + if (currentIndex.current < fullText.length) { + const char = fullText[currentIndex.current]; + currentIndex.current += 1; + setDisplayText((prev) => prev + char); + timerRef.current = setTimeout(typeEffect, 50); + setStatus('streaming'); + } else { + // 计算耗时并更新状态 + const costTime = parseInt(((Date.now() - startTimeRef.current) / 1000).toString(), 10); + setTitle(`已完成思考(耗时${costTime}秒)`); + setStatus('complete'); + } + }; + + startTimeRef.current = Date.now(); + timerRef.current = setTimeout(typeEffect, 500); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [layout, animation]); + + // 重置打字效果相关状态 + const resetTypingEffect = () => { + setDisplayText(''); + setStatus('pending'); + setTitle('正在思考中...'); + currentIndex.current = 0; + if (timerRef.current) clearTimeout(timerRef.current); + }; + + return ( + + + + ); +} diff --git a/packages/pro-components/chat/chat-thinking/_usage/index.jsx b/packages/pro-components/chat/chat-thinking/_usage/index.jsx new file mode 100644 index 0000000000..51196fa6d6 --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/_usage/index.jsx @@ -0,0 +1,59 @@ +/** + * 该脚本为自动生成,如有需要请在 /script/generate-usage.js 中调整 + */ + +// @ts-nocheck +import React, { useState, useEffect, useMemo } from 'react'; +import BaseUsage, { useConfigChange, usePanelChange } from '@tdesign/react-site/src/components/BaseUsage'; +import jsxToString from 'react-element-to-jsx-string'; + +import { ChatThinking } from '@tdesign-react/chat'; + +import configProps from './props.json'; + +const fullText = + '嗯,用户问牛顿第一定律是不是适用于所有参考系。首先,我得先回忆一下牛顿第一定律的内容。牛顿第一定律,也就是惯性定律,说物体在没有外力作用时会保持静止或匀速直线运动。也就是说,保持原来的运动状态。那问题来了,这个定律是否适用于所有参考系呢?记得以前学过的参考系分惯性系和非惯性系。惯性系里,牛顿定律成立;非惯性系里,可能需要引入惯性力之类的修正。所以牛顿第一定律应该只在惯性参考系中成立,而在非惯性系中不适用,比如加速的电梯或者旋转的参考系,这时候物体会有看似无外力下的加速度,所以必须引入假想的力来解释。'; + +export default function Usage() { + const [configList, setConfigList] = useState(configProps); + + const { changedProps, onConfigChange } = useConfigChange(configList); + + const panelList = [{ label: 'ChatThinking', value: 'ChatThinking' }]; + + const { panel, onPanelChange } = usePanelChange(panelList); + + const [renderComp, setRenderComp] = useState(); + + useEffect(() => { + setRenderComp( +
+ +
, + ); + }, [changedProps]); + + const jsxStr = useMemo(() => { + if (!renderComp) return ''; + return jsxToString(renderComp); + }, [renderComp]); + + return ( + + {renderComp} + + ); +} diff --git a/packages/pro-components/chat/chat-thinking/_usage/props.json b/packages/pro-components/chat/chat-thinking/_usage/props.json new file mode 100644 index 0000000000..37cc59ab6e --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/_usage/props.json @@ -0,0 +1,61 @@ +[ + { + "name": "collapsed", + "type": "Boolean", + "defaultValue": false, + "options": [] + }, + { + "name": "layout", + "type": "enum", + "defaultValue": "block", + "options": [ + { + "label": "border", + "value": "border" + }, + { + "label": "block", + "value": "block" + } + ] + }, + { + "name": "animation", + "type": "enum", + "defaultValue": "moving", + "options": [ + { + "label": "moving", + "value": "moving" + }, + { + "label": "gradient", + "value": "gradient" + }, + { + "label": "dots", + "value": "dots" + }, + { + "label": "circle", + "value": "circle" + } + ] + }, + { + "name": "status", + "type": "enum", + "defaultValue": "pending", + "options": [ + { + "label": "pending", + "value": "pending" + }, + { + "label": "complete", + "value": "complete" + } + ] + } +] diff --git a/packages/pro-components/chat/chat-thinking/chat-thinking.en-US.md b/packages/pro-components/chat/chat-thinking/chat-thinking.en-US.md new file mode 100644 index 0000000000..decddbe153 --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/chat-thinking.en-US.md @@ -0,0 +1,35 @@ +--- +title: ChatThinking 思考过程 +description: 思考过程 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +## 基础用法 +支持通过`maxHeight`来设置展示内容的最大高度,超出会自动滚动; + +支持通过`collapsed`来控制面板是否折叠,示例中展示了当内容输出结束时自动收起的效果 + +{{ base }} + + +## 样式设置 +支持通过`layout`来设置思考过程的布局方式 + +支持通过`animation`来设置思考内容加载过程的动画效果 + +{{ style }} + + +## API +### ChatThinking Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +content | Object | - | 思考内容对象。TS类型:`{ text?: string; title?: string }` | N +layout | String | block | 布局方式。可选项: block/border | N +status | ChatMessageStatus/Function | - | 思考状态。可选项:complete/stop/error/pending | N +maxHeight | Number | - | 内容区域最大高度,超出会自动滚动 | N +animation | String | circle | 加载动画类型。可选项: circle/moving/gradient | N +collapsed | Boolean | false | 是否折叠(受控) | N \ No newline at end of file diff --git a/packages/pro-components/chat/chat-thinking/chat-thinking.md b/packages/pro-components/chat/chat-thinking/chat-thinking.md new file mode 100644 index 0000000000..1aab149499 --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/chat-thinking.md @@ -0,0 +1,35 @@ +--- +title: ChatThinking 思考过程 +description: 思考过程 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +## 基础用法 +支持通过`maxHeight`来设置展示内容的最大高度,超出会自动滚动; + +支持通过`collapsed`来控制面板是否折叠,示例中展示了当内容输出结束时自动收起的效果 + +{{ base }} + + +## 样式设置 +支持通过`layout`来设置思考过程的布局方式 + +支持通过`animation`来设置思考内容加载过程的动画效果 + +{{ style }} + + +## API +### ChatThinking Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +content | Object | - | 思考内容对象。TS类型:`{ text?: string; title?: string }` | N +layout | String | block | 布局方式。可选项: block/border | N +status | ChatMessageStatus/Function | - | 思考状态。可选项:complete/stop/error/pending | N +maxHeight | Number | - | 内容区域最大高度,超出会自动滚动 | N +animation | String | circle | 加载动画类型。可选项: circle/moving/gradient | N +collapsed | Boolean | false | 是否折叠(受控) | N diff --git a/packages/pro-components/chat/chat-thinking/index.ts b/packages/pro-components/chat/chat-thinking/index.ts new file mode 100644 index 0000000000..58fac59d62 --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/index.ts @@ -0,0 +1,13 @@ +import { TdChatThinkContentProps } from 'tdesign-web-components'; +import 'tdesign-web-components/lib/chat-message/content/thinking-content'; +import reactify from '../_util/reactify'; + +const ChatThinkContent: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-chat-thinking-content'); + +export const ChatThinking = ChatThinkContent; + +export default ChatThinking; + +export type { TdChatThinkContentProps } from 'tdesign-web-components'; diff --git a/packages/pro-components/chat/chatbot/_example/agent.tsx b/packages/pro-components/chat/chatbot/_example/agent.tsx new file mode 100644 index 0000000000..f51f0cb445 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/agent.tsx @@ -0,0 +1,233 @@ +import React, { useEffect, useRef } from 'react'; +import type { + TdChatMessageConfig, + AIMessageContent, + ChatRequestParams, + ChatServiceConfig, + ChatBaseContent, + ChatMessagesData, + SSEChunkData, +} from '@tdesign-react/chat'; +import { Timeline } from 'tdesign-react'; + +import { CheckCircleFilledIcon } from 'tdesign-icons-react'; + +import { ChatBot } from '@tdesign-react/chat'; + +import './index.css'; + +const AgentTimeline = ({ steps }) => ( +
+ + {steps.map((step) => ( + } + > +
+
{step.step}
+ {step?.tasks?.map((task, taskIndex) => ( +
+
{task.text}
+
+ ))} +
+
+ ))} +
+
+); + +// 扩展自定义消息体类型 +type AgentContent = ChatBaseContent< + 'agent', + { + id: string; + state: 'pending' | 'command' | 'result' | 'finish'; + content: { + steps?: { + step: string; + agent_id: string; + status: string; + tasks?: { + type: 'command' | 'result'; + text: string; + }[]; + }[]; + text?: string; + }; + } +>; + +// 默认初始化消息 +const mockData: ChatMessagesData[] = [ + { + id: '123', + role: 'assistant', + status: 'complete', + content: [ + { + type: 'text', + data: '欢迎使用 TDesign Agent 家庭活动策划助手,请给我布置任务吧~', + }, + ], + }, +]; + +export default function ChatBotReact() { + const chatRef = useRef(null); + const [mockMessage, setMockMessage] = React.useState(mockData); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + }, + assistant: { + placement: 'left', + }, + }; + + // 聊天服务配置 + const chatServiceConfig: ChatServiceConfig = { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/agent`, + stream: true, + // 流式对话结束(aborted为true时,表示用户主动结束对话,params为请求参数) + onComplete: (aborted: boolean, params: RequestInit) => { + console.log('onComplete', aborted, params); + }, + // 流式对话过程中出错业务自定义行为 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + // 流式对话过程中用户主动结束对话业务自定义行为 + onAbort: async () => {}, + onMessage: (chunk: SSEChunkData): AIMessageContent => { + const { type, ...rest } = chunk.data; + switch (type) { + // 正文 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + }; + case 'agent': + return { + type: 'agent', + ...rest, + }; + default: + return { + ...chunk.data, + data: { ...chunk.data.content }, + }; + } + }, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'agent_uid', + prompt, + }), + }; + }, + }; + + useEffect(() => { + if (!chatRef.current) { + return; + } + // 此处增加自定义消息内容合并策略逻辑 + // 该示例agent类型结构比较复杂,根据任务步骤的state有不同的策略,组件内onMessage这里提供了的strategy无法满足,可以通过注册合并策略自行实现 + chatRef.current.registerMergeStrategy('agent', (newChunk, existing) => { + console.log('newChunk, existing', newChunk, existing); + // 创建新对象避免直接修改原状态 + const updated = { + ...existing, + content: { + ...existing.content, + steps: [...existing.content.steps], + }, + }; + + const stepIndex = updated.content.steps.findIndex((step) => step.agent_id === newChunk.content.agent_id); + + if (stepIndex === -1) return updated; + + // 更新步骤信息 + const step = { + ...updated.content.steps[stepIndex], + tasks: [...(updated.content.steps[stepIndex].tasks || [])], + status: newChunk.state === 'finish' ? 'finish' : 'pending', + }; + + // 处理不同类型的新数据 + if (newChunk.state === 'command') { + // 新增每个步骤执行的命令 + step.tasks.push({ + type: 'command', + text: newChunk.content.text, + }); + } else if (newChunk.state === 'result') { + // 新增每个步骤执行的结论是流式输出,需要分情况处理 + const resultTaskIndex = step.tasks.findIndex((task) => task.type === 'result'); + if (resultTaskIndex >= 0) { + // 合并到已有结果 + step.tasks = step.tasks.map((task, index) => + index === resultTaskIndex ? { ...task, text: task.text + newChunk.content.text } : task, + ); + } else { + // 添加新结果 + step.tasks.push({ + type: 'result', + text: newChunk.content.text, + }); + } + } + + updated.content.steps[stepIndex] = step; + return updated; + }); + }, []); + + return ( +
+ { + setMockMessage(e.detail); + }} + > + {mockMessage + ?.map((msg) => + msg?.content?.map((item, index) => { + if (item.type === 'agent') { + return ( +
+ +
+ ); + } + return null; + }), + ) + .flat()} +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/agui.tsx b/packages/pro-components/chat/chatbot/_example/agui.tsx new file mode 100644 index 0000000000..524b10c41a --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/agui.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { ChatBot, ChatServiceConfig } from '@tdesign-react/chat'; + +/** + * AG-UI 协议示例 + * + * 本示例展示如何使用 AG-UI 协议快速接入聊天服务。 + * AG-UI 是一种标准化的 AI 对话协议,当后端服务符合该协议时, + * 前端无需编写 onMessage 进行数据转换,大大简化了接入流程。 + * 可以通过查看网络输出的数据流来了解协议格式。 + * + * 对比说明: + * - 自定义协议:需要配置 onMessage 进行数据转换(参考 service-config 示例) + * - AG-UI 协议:只需设置 protocol: 'agui',无需 onMessage + */ +export default function AguiProtocol() { + // AG-UI 协议配置(最简化) + const chatServiceConfig: ChatServiceConfig = { + // 对话服务地址 + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/agui-simple', + // 开启流式传输 + stream: true, + // 使用 AG-UI 协议(无需 onMessage) + protocol: 'agui', + }; + + return ( +
+ +
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/backup/travel-planner.css b/packages/pro-components/chat/chatbot/_example/backup/travel-planner.css new file mode 100644 index 0000000000..e8f3ca8f56 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/backup/travel-planner.css @@ -0,0 +1,552 @@ +/* 旅游规划器容器 */ +.travel-planner-container { + display: flex; + flex-direction: column; + height: 100vh; + position: relative; +} + +.chat-content { + display: flex; + flex-direction: column; + flex: 1; + background: white; + border-radius: 8px; + overflow: hidden; + margin-top: 20px; +} + +/* 右下角固定规划状态面板 */ +.planning-panel-fixed { + position: fixed; + bottom: 20px; + right: 20px; + width: 220px; + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid #e7e7e7; + overflow: hidden; + transition: all 0.3s ease; +} + +.planning-panel-fixed:hover { + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .planning-panel-fixed { + position: fixed; + bottom: 10px; + right: 10px; + left: 10px; + width: auto; + max-height: 300px; + } + + .chat-content { + margin-bottom: 320px; /* 为固定面板留出空间 */ + } +} + +/* 内容卡片通用样式 */ +.content-card { + margin: 8px 0; +} + +/* TDesign 组件样式增强 */ +.t-travel-card { + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.t-travel-card:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); +} + +/* 卡片头部简化样式 */ +.t-card__header { + background: #fafafa; + border-bottom: 1px solid #e7e7e7; + padding: 16px 20px; +} + +.t-card__body { + padding: 20px; +} + +/* 标签样式增强 */ +.t-tag { + border-radius: 6px; + font-weight: 500; +} + +/* 按钮样式增强 */ +.t-button { + border-radius: 8px; + font-weight: 500; + transition: all 0.3s ease; +} + +.t-button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +/* 输入框样式增强 */ +.t-input { + border-radius: 8px; + transition: all 0.3s ease; +} + +.t-input:focus { + box-shadow: 0 0 0 2px rgba(0, 82, 217, 0.2); +} + +/* 选择框样式增强 */ +.t-select { + border-radius: 8px; +} + +/* 复选框样式增强 */ +.t-checkbox { + border-radius: 4px; +} + +/* 分割线样式 */ +.t-divider { + margin: 16px 0; +} + +/* 空间组件样式 */ +.t-space { + width: 100%; +} + +/* 加载状态样式 */ +.loading-container { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +/* 错误状态样式 */ +.error-container { + padding: 16px; + border-radius: 8px; + background: #fff2f0; + border: 1px solid #ffccc7; +} + +/* 成功状态样式 */ +.success-container { + padding: 16px; + border-radius: 8px; + background: #f6ffed; + border: 1px solid #b7eb8f; +} + +/* 警告状态样式 */ +.warning-container { + padding: 16px; + border-radius: 8px; + background: #fffbe6; + border: 1px solid #ffe58f; +} + +/* 信息状态样式 */ +.info-container { + padding: 16px; + border-radius: 8px; + background: #e6f7ff; + border: 1px solid #91d5ff; +} + +/* 动画效果 */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.travel-card-animation { + animation: fadeIn 0.3s ease-out; +} + +/* 响应式设计增强 */ +@media (max-width: 768px) { + .t-card { + margin: 8px; + border-radius: 8px; + } + + .t-card__header { + padding: 12px 16px; + } + + .t-card__body { + padding: 16px; + } + + .t-space { + gap: 8px !important; + } + + .t-button { + padding: 8px 16px; + font-size: 14px; + } +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + .t-card { + background: #1f1f1f; + border-color: #333; + } + + .t-card__header { + background: #2a2a2a; + border-bottom-color: #444; + } + + .t-input { + background: #2a2a2a; + border-color: #444; + } + + .t-select { + background: #2a2a2a; + border-color: #444; + } +} + +/* 天气卡片样式 */ +.weather-card { + border: 1px solid #e7e7e7; +} + +.weather-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.weather-title { + font-weight: 600; + color: #333; +} + +.weather-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #f8f9fa; + border-radius: 6px; +} + +.weather-item .day { + font-weight: 500; + color: #333; +} + +.weather-item .condition { + color: #666; +} + +.weather-item .temp { + font-weight: 600; + color: #0052d9; +} + +/* 行程规划卡片样式 */ +.itinerary-card { + border: 1px solid #e7e7e7; +} + +.itinerary-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.itinerary-title { + font-weight: 600; + color: #333; +} + +.day-activities { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.activity-tag { + font-size: 12px; + padding: 4px 8px; +} + +/* 酒店推荐卡片样式 */ +.hotel-card { + border: 1px solid #e7e7e7; +} + +.hotel-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.hotel-title { + font-weight: 600; + color: #333; +} + +.hotel-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hotel-item { + padding: 12px; + border: 1px solid #e7e7e7; + border-radius: 6px; + background: #f8f9fa; +} + +.hotel-info { + display: flex; + justify-content: space-between; + align-items: center; +} + +.hotel-name { + font-weight: 500; + color: #333; +} + +.hotel-details { + display: flex; + align-items: center; + gap: 8px; +} + +.hotel-price { + font-weight: 600; + color: #e34d59; +} + +/* 规划状态面板样式 */ +.planning-state-panel { + border: 1px solid #e7e7e7; +} + +.panel-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.panel-title { + font-weight: 600; + color: #333; + flex: 1; +} + +.progress-steps { + margin: 16px 0; +} + +.step-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.step-title { + font-weight: 500; + color: #333; +} + +.summary-header { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.summary-content { + display: flex; + flex-direction: column; + gap: 4px; + color: #666; + font-size: 14px; +} + +/* Human-in-the-Loop 表单样式 */ +/* 动态表单组件样式 */ +.human-input-form { + border: 2px solid #0052d9; + border-radius: 8px; + padding: 20px; + background: #f8f9ff; + margin: 16px 0; + max-width: 500px; +} + +.form-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.form-title { + font-weight: 600; + color: #0052d9; + font-size: 16px; +} + +.form-description { + color: #666; + margin-bottom: 16px; + line-height: 1.5; +} + +.form-fields { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.field-label { + font-weight: 500; + color: #333; + font-size: 14px; +} + +.required { + color: #e34d59; + margin-left: 4px; +} + +.field-wrapper { + width: 100%; +} + +.checkbox-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.error-message { + color: #e34d59; + font-size: 12px; + margin-top: 4px; +} + +.form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid #e7e7e7; +} + +/* 加载动画 */ +.loading-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .human-input-form { + max-width: 100%; + padding: 16px; + } + + .form-actions { + flex-direction: column; + } +} + +/* 用户输入结果展示样式 */ +.human-input-result { + border: 1px solid #e7e7e7; + border-radius: 8px; + padding: 16px; + background: #f8f9fa; + max-width: 500px; +} + +.user-input-summary { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; +} + +.summary-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: white; + border-radius: 6px; + border: 1px solid #e7e7e7; +} + +.summary-item .label { + font-weight: 500; + color: #666; + min-width: 80px; +} + +.summary-item .value { + color: #333; + font-weight: 600; +} diff --git a/packages/pro-components/chat/chatbot/_example/backup/travel.tsx b/packages/pro-components/chat/chatbot/_example/backup/travel.tsx new file mode 100644 index 0000000000..37aad00061 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/backup/travel.tsx @@ -0,0 +1,465 @@ +import React, { ReactNode, useMemo, useRef, useState } from 'react'; +import { Button } from 'tdesign-react'; +import { + ChatList, + ChatSender, + ChatMessage, + TdChatListApi, + TdChatSenderApi, + ChatActionBar, + AGUIAdapter, + isAIMessage, + applyJsonPatch, + getMessageContentForCopy, +} from '@tdesign-react/chat'; +import type { + TdChatMessageConfig, + TdChatActionsName, + TdChatSenderParams, + ChatMessagesData, + ChatRequestParams, + ChatBaseContent, + AIMessageContent, + AGUIHistoryMessage, +} from '@tdesign-react/chat'; +import { LoadingIcon, HistoryIcon } from 'tdesign-icons-react'; +import { useChat } from '../../hooks/useChat'; +import { + PlanningStatePanel, + WeatherCard, + ItineraryCard, + HotelCard, + HumanInputResult, + HumanInputForm, +} from '../components'; +import type { FormConfig } from '../components/HumanInputForm'; +import './travel-planner.css'; + +// 扩展自定义消息体类型 +declare module '@tdesign-react/chat' { + interface AIContentTypeOverrides { + weather: ChatBaseContent<'weather', { weather: any[] }>; + itinerary: ChatBaseContent<'itinerary', { plan: any[] }>; + hotel: ChatBaseContent<'hotel', { hotels: any[] }>; + planningState: ChatBaseContent<'planningState', { state: any }>; + } +} + +interface MessageRendererProps { + item: AIMessageContent; + index: number; + message: ChatMessagesData; +} + +// 加载历史消息的函数 +const loadHistoryMessages = async (): Promise => { + try { + const response = await fetch('https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/api/conversation/history'); + if (response.ok) { + const result = await response.json(); + const historyMessages: AGUIHistoryMessage[] = result.data; + + // 使用AGUIAdapter的静态方法进行转换 + return AGUIAdapter.convertHistoryMessages(historyMessages); + } + } catch (error) { + console.error('加载历史消息失败:', error); + } + return []; +}; + +export default function TravelPlannerChat() { + const listRef = useRef(null); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState('请为我规划一个北京5日游行程'); + + // 规划状态管理 - 用于右侧面板展示 + const [planningState, setPlanningState] = useState(null); + const [currentStep, setCurrentStep] = useState(''); + + // Human-in-the-Loop 状态管理 + const [userInputFormConfig, setUserInputFormConfig] = useState(null); + + // 加载历史消息 + const [defaultMessages, setDefaultMessages] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + const [hasLoadedHistory, setHasLoadedHistory] = useState(false); + + // 创建聊天服务配置 + const createChatServiceConfig = () => ({ + // 对话服务地址 - 使用 POST 请求 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/agui`, + protocol: 'agui' as const, + stream: true, + // 流式对话结束 + onComplete: (isAborted: boolean, params?: RequestInit, parsed?: any) => { + // 检查是否是等待用户输入的状态 + if (parsed?.result?.status === 'waiting_for_user_input') { + console.log('检测到等待用户输入状态,保持消息为 streaming'); + // 返回一个空的更新来保持消息状态为 streaming + return { + status: 'streaming', + }; + } + if (parsed?.result?.status === 'user_aborted') { + return { + status: 'stop', + }; + } + }, + // 流式对话过程中出错 + onError: (err: Error | Response) => { + console.error('旅游规划服务错误:', err); + }, + // 流式对话过程中用户主动结束对话 + onAbort: async () => { + console.log('用户取消旅游规划'); + }, + // AG-UI协议消息处理 - 优先级高于内置处理 + onMessage: (chunk, message, parsedResult): AIMessageContent | undefined => { + const { type, ...rest } = chunk.data; + + switch (type) { + // ========== 步骤开始/结束事件处理 ========== + case 'STEP_STARTED': + setCurrentStep(rest.stepName); + break; + + case 'STEP_FINISHED': + setCurrentStep(''); + break; + // ========== 工具调用事件处理 ========== + case 'TOOL_CALL_ARGS': + // 使用解析后的 ToolCall 数据 + if (parsedResult?.data?.toolCallName === 'get_travel_preferences') { + const toolCall = parsedResult.data as any; + if (toolCall.args) { + try { + const formConfig = JSON.parse(toolCall.args); + setUserInputFormConfig(formConfig); + console.log('成功解析表单配置:', formConfig); + } catch (error) { + console.log('JSON 不完整,继续等待...', toolCall.args); + } + } + } + break; + // ========== 状态管理事件处理 ========== + case 'STATE_SNAPSHOT': + setPlanningState(rest.snapshot); + return { + type: 'planningState', + data: { state: rest.snapshot }, + } as any; + + case 'STATE_DELTA': + // 应用状态变更到当前状态 + setPlanningState((prevState: any) => { + if (!prevState) return prevState; + return applyJsonPatch(prevState, rest.delta); + }); + + // 返回更新后的状态组件 + return { + type: 'planningState', + data: { state: planningState }, + } as any; + } + }, + // 自定义请求参数 - 使用 POST 请求 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt, toolCallMessage } = innerParams; + const requestBody: any = { + uid: 'travel_planner_uid', + prompt, + agentType: 'travel-planner', + }; + + // 如果有用户输入数据,添加到请求中 + if (toolCallMessage) { + requestBody.toolCallMessage = toolCallMessage; + } + + return { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }; + }, + }); + + // 加载历史消息的函数 + const handleLoadHistory = async () => { + if (hasLoadedHistory) return; + + setIsLoadingHistory(true); + try { + const messages = await loadHistoryMessages(); + setDefaultMessages(messages); + setHasLoadedHistory(true); + } catch (error) { + console.error('加载历史消息失败:', error); + } finally { + setIsLoadingHistory(false); + } + }; + + const { chatEngine, messages, status } = useChat({ + defaultMessages, + chatServiceConfig: createChatServiceConfig(), + }); + + const senderLoading = useMemo(() => status === 'pending' || status === 'streaming', [status]); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + }, + assistant: { + placement: 'left', + chatContentProps: { + thinking: { + maxHeight: 120, + }, + }, + }, + }; + + const getChatActionBar = (isLast: boolean) => { + let filterActions = ['replay', 'good', 'bad', 'copy']; + if (!isLast) { + filterActions = filterActions.filter((item) => item !== 'replay'); + } + return filterActions; + }; + + const actionHandler = (name: string, data?: any) => { + switch (name) { + case 'replay': { + console.log('重新规划旅游行程'); + chatEngine.regenerateAIMessage(); + return; + } + case 'good': + console.log('用户满意此次规划'); + break; + case 'bad': + console.log('用户不满意此次规划'); + break; + default: + console.log('触发操作', name, 'data', data); + } + }; + + // 处理用户输入提交 + const handleUserInputSubmit = async (userData: any) => { + try { + // 1. 更新状态 + setUserInputFormConfig(null); + + // 2. 构造新的请求参数 + const tools = chatEngine.getToolcallByName('get_travel_preferences') || {}; + const newRequestParams: ChatRequestParams = { + prompt: inputValue, + toolCallMessage: { + ...tools, + result: JSON.stringify(userData), + }, + }; + + // 3. 直接调用 chatEngine.continueChat(params) 继续请求 + await chatEngine.continueChat(newRequestParams); + listRef.current?.scrollList({ to: 'bottom' }); + } catch (error) { + console.error('提交用户输入失败:', error); + // 可以显示错误提示 + } + }; + + // 处理用户输入取消 + const handleUserInputCancel = async () => { + await chatEngine.continueChat({ + prompt: inputValue, + toolCallMessage: { + ...chatEngine.getToolcallByName('get_travel_preferences'), + result: 'user_cancel', + }, + }); + await chatEngine.abortChat(); + }; + + const renderMessageContent = ({ item, index }: MessageRendererProps): React.ReactNode => { + if (item.type === 'toolcall') { + const { data, type } = item; + // Human-in-the-Loop 输入请求 + if (data.toolCallName === 'get_travel_preferences') { + // 区分历史消息和实时交互 + if (data.result) { + // 历史消息:静态展示用户已输入的数据 + try { + const userInput = JSON.parse(data.result); + return ( +
+ +
+ ); + } catch (e) { + console.error('解析用户输入数据失败:', e); + } + } else if (userInputFormConfig) { + // 实时交互:使用状态中的表单配置 + return ( +
+ +
+ ); + } + } + + // 天气卡片 + if (data.toolCallName === 'get_weather_forecast' && data?.result) { + return ( +
+ +
+ ); + } + + // 行程规划卡片 + if (data.toolCallName === 'plan_itinerary' && data.result) { + return ( +
+ +
+ ); + } + + // 酒店推荐卡片 + if (data.toolCallName === 'get_hotel_details' && data.result) { + return ( +
+ +
+ ); + } + } + + return null; + }; + + /** 渲染消息内容体 */ + const renderMsgContents = (message: ChatMessagesData, isLast: boolean): ReactNode => ( + <> + {message.content?.map((item, index) => renderMessageContent({ item, index, message }))} + + {isAIMessage(message) && message.status === 'complete' ? ( + + ) : null} + + ); + + const sendUserMessage = async (requestParams: ChatRequestParams) => { + // 重置规划状态 + setPlanningState(null); + await chatEngine.sendUserMessage(requestParams); + }; + + const inputChangeHandler = (e: CustomEvent) => { + setInputValue(e.detail); + }; + + const sendHandler = async (e: CustomEvent) => { + const { value } = e.detail; + const params = { + prompt: value, + }; + await sendUserMessage(params); + setInputValue(''); + }; + + const stopHandler = () => { + console.log('停止旅游规划'); + chatEngine.abortChat(); + }; + + if (isLoadingHistory) { + return ( +
+
+ + 加载历史消息中... +
+
+ ); + } + + return ( +
+ {/* 顶部工具栏 */} +
+

旅游规划助手

+ +
+ +
+ + {messages.map((message, idx) => ( + + {renderMsgContents(message, idx === messages.length - 1)} + + ))} + + +
+ + {/* 右下角固定规划状态面板 */} + {planningState && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/basic.tsx b/packages/pro-components/chat/chatbot/_example/basic.tsx new file mode 100644 index 0000000000..9d5c93bbaf --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/basic.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { InternetIcon } from 'tdesign-icons-react'; +import { + SSEChunkData, + AIMessageContent, + TdChatMessageConfigItem, + ChatRequestParams, + ChatMessagesData, + ChatServiceConfig, + ChatBot, + type TdChatbotApi, +} from '@tdesign-react/chat'; +import { Button, Space } from 'tdesign-react'; + +// 默认初始化消息 +const mockData: ChatMessagesData[] = [ + { + id: '123', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '欢迎使用TDesign Chatbot智能助手,你可以这样问我:', + }, + { + type: 'suggestion', + status: 'complete', + data: [ + { + title: '南极的自动提款机叫什么名字', + prompt: '南极的自动提款机叫什么名字?', + }, + { + title: '南极自动提款机在哪里', + prompt: '南极自动提款机在哪里', + }, + ], + }, + ], + }, +]; + +export default function chatSample() { + const chatRef = useRef(null); + const [activeR1, setR1Active] = useState(false); + const [activeSearch, setSearchActive] = useState(false); + const [ready, setReady] = useState(false); + const reqParamsRef = useRef<{ think: boolean; search: boolean }>({ think: false, search: false }); + + // 消息属性配置 + const messageProps = (msg: ChatMessagesData): TdChatMessageConfigItem => { + const { role, content } = msg; + // 假设只有单条thinking + const thinking = content.find((item) => item.type === 'thinking'); + if (role === 'user') { + return { + variant: 'base', + placement: 'right', + avatar: 'https://tdesign.gtimg.com/site/avatar.jpg', + }; + } + if (role === 'assistant') { + return { + placement: 'left', + actions: ['replay', 'copy', 'good', 'bad'], + handleActions: { + // 处理消息操作回调 + good: async ({ message, active }) => { + // 点赞 + console.log('点赞', message, active); + }, + bad: async ({ message, active }) => { + // 点踩 + console.log('点踩', message, active); + }, + replay: ({ message, active }) => { + console.log('自定义重新回复', message, active); + chatRef?.current?.regenerate(); + }, + searchItem: ({ content, event }) => { + event.preventDefault(); + console.log('点击搜索条目', content); + }, + suggestion: ({ content }) => { + console.log('点击建议问题', content); + // 点建议问题自动填入输入框 + chatRef?.current?.addPrompt(content.prompt); + // 也可以点建议问题直接发送消息 + // chatRef?.current?.sendUserMessage({ prompt: content.prompt }); + }, + }, + // 内置的消息渲染配置 + chatContentProps: { + thinking: { + maxHeight: 100, // 思考框最大高度,超过会自动滚动 + layout: 'block', // 思考内容样式,border|block + collapsed: thinking?.status === 'complete', // 是否折叠,这里设置内容输出完成后折叠 + }, + }, + }; + } + }; + + // 聊天服务配置 + const chatServiceConfig: ChatServiceConfig = { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal`, + stream: true, + // 流式对话结束(aborted为true时,表示用户主动结束对话,params为请求参数) + onComplete: (aborted: boolean, params: RequestInit) => { + console.log('onComplete', aborted, params); + }, + // 流式对话过程中出错业务自定义行为 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + // 流式对话过程中用户主动结束对话业务自定义行为 + onAbort: async () => {}, + // 自定义流式数据结构解析 + onMessage: (chunk: SSEChunkData): AIMessageContent => { + console.log('====chunk', chunk); + const { type, ...rest } = chunk.data; + switch (type) { + case 'search': + // 搜索 + return { + type: 'search', + data: { + title: rest.title || `搜索到${rest?.docs.length}条内容`, + references: rest?.content, + }, + }; + // 思考 + case 'think': + return { + type: 'thinking', + status: /耗时/.test(rest?.title) ? 'complete' : 'streaming', + data: { + title: rest.title || '深度思考中', + text: rest.content || '', + }, + }; + // 正文 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + }; + } + }, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'tdesign-chat', + prompt, + ...reqParamsRef.current, + }), + }; + }, + }; + + useEffect(() => { + reqParamsRef.current = { + think: activeR1, + search: activeSearch, + }; + }, [activeR1, activeSearch]); + + useEffect(() => { + if (ready) { + // 设置消息内容 + chatRef.current?.setMessages(mockData, 'replace'); + } + }, [ready]); + + return ( +
+ { + setReady(true); + }} + > + {/* 自定义输入框底部区域slot,可以增加模型选项 */} +
+ + + + +
+
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/code.tsx b/packages/pro-components/chat/chatbot/_example/code.tsx new file mode 100644 index 0000000000..778462c3fc --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/code.tsx @@ -0,0 +1,224 @@ +import React, { useRef } from 'react'; +import { DialogPlugin, Card, Space } from 'tdesign-react'; +import { + ChatBot, + ChatMessagesData, + SSEChunkData, + TdChatMessageConfig, + AIMessageContent, + ChatRequestParams, + ChatServiceConfig, + TdChatbotApi, +} from '@tdesign-react/chat'; +import Login from './components/login'; + +// 默认初始化消息 +const mockData: ChatMessagesData[] = [ + { + id: '123', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '欢迎使用 TDesign Chatbot 智能代码助手,请输入你的问题', + }, + ], + }, +]; + +const PreviewCard = ({ header, desc, loading, code }) => { + // 预览效果弹窗 + const previewHandler = () => { + const myDialog = DialogPlugin({ + header: '代码生成预览', + body: , + onConfirm: () => { + myDialog.hide(); + }, + onClose: () => { + myDialog.hide(); + }, + }); + }; + + // 复制生成的代码 + const copyHandler = async () => { + try { + const codeBlocks = Array.from(code.matchAll(/```(?:jsx|javascript)?\n([\s\S]*?)```/g)).map((match) => + match[1].trim(), + ); + // 拼接多个代码块(如有) + const combinedCode = codeBlocks.join('\n\n// 分割代码块\n\n'); + + // 使用剪贴板 + await navigator.clipboard.writeText(combinedCode); + console.log('代码已复制到剪贴板'); + } catch (error) { + console.error('复制失败:', error); + } + }; + + return ( + <> + +
+ 复制代码 + + + 预览 + + + ) + } + > + + ); +}; + +export default function chatSample() { + const chatRef = useRef(null); + const [mockMessage, setMockMessage] = React.useState(mockData); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + avatar: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + assistant: { + actions: ['replay', 'good', 'bad'], + handleActions: { + // 处理消息操作回调 + replay: ({ message, active }) => { + console.log('自定义重新回复', message, active); + chatRef?.current?.regenerate(); + }, + }, + // 内置的消息渲染配置 + chatContentProps: { + markdown: { + options: { + html: true, + breaks: true, + typographer: true, + }, + pluginConfig: [ + // 按需加载,开启插件 + { + preset: 'code', // 代码块 + enabled: true, + }, + ], + }, + }, + }, + }; + + // 聊天服务配置 + const chatServiceConfig: ChatServiceConfig = { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal`, + stream: true, + // 流式对话结束(aborted为true时,表示用户主动结束对话,params为请求参数) + onComplete: (aborted: boolean, params: RequestInit) => { + console.log('onComplete', aborted, params); + }, + // 流式对话过程中出错业务自定义行为 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + // 流式对话过程中用户主动结束对话业务自定义行为 + onAbort: async () => {}, + // 自定义流式数据结构解析 + onMessage: (chunk: SSEChunkData): AIMessageContent => { + const { type, ...rest } = chunk.data; + switch (type) { + // 正文 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + // 根据后端返回的paragraph字段来决定是否需要另起一段展示markdown + strategy: rest?.paragraph === 'next' ? 'append' : 'merge', + }; + // 自定义:代码运行结果预览 + case 'preview': + return { + type: 'preview', + status: () => (/完成/.test(rest?.content?.cnName) ? 'complete' : 'streaming'), + data: rest?.content, + }; + } + }, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'tdesign-chat', + prompt, + code: true, + }), + }; + }, + }; + + return ( +
+ { + setMockMessage(e.detail); + }} + senderProps={{ + defaultValue: '使用 TDesign 组件库实现一个登录表单的例子', + placeholder: '有问题,尽管问~ Enter 发送,Shift+Enter 换行', + }} + chatServiceConfig={chatServiceConfig} + > + {/* 自定义消息体渲染-植入插槽 */} + {mockMessage + ?.map((msg) => + msg.content.map((item, index) => { + switch (item.type) { + // 示例:代码运行结果预览 + case 'preview': + return ( +
+ +
+ ); + } + return null; + }), + ) + .flat()} +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/components/ItineraryCard.tsx b/packages/pro-components/chat/chatbot/_example/components/ItineraryCard.tsx new file mode 100644 index 0000000000..eb2144c48d --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/components/ItineraryCard.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Card, Timeline, Tag } from 'tdesign-react'; +import { CalendarIcon, CheckCircleFilledIcon } from 'tdesign-icons-react'; + +interface ItineraryCardProps { + plan: any[]; +} + +export const ItineraryCard: React.FC = ({ plan }) => ( + +
+ + 行程安排 +
+ + {plan.map((dayPlan, index) => ( + } + > +
+ {dayPlan.activities.map((activity: string, actIndex: number) => ( + + {activity} + + ))} +
+
+ ))} +
+
+); diff --git a/packages/pro-components/chat/chatbot/_example/components/index.ts b/packages/pro-components/chat/chatbot/_example/components/index.ts new file mode 100644 index 0000000000..be2a49d666 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/components/index.ts @@ -0,0 +1,6 @@ +export { WeatherCard } from './WeatherCard'; +export { ItineraryCard } from './ItineraryCard'; +export { HotelCard } from './HotelCard'; +export { PlanningStatePanel } from './PlanningStatePanel'; +export { HumanInputResult } from './HumanInputResult'; +export { HumanInputForm } from './HumanInputForm'; diff --git a/packages/pro-components/chat/chatbot/_example/components/login.tsx b/packages/pro-components/chat/chatbot/_example/components/login.tsx new file mode 100644 index 0000000000..077041df05 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/components/login.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Form, Input, Button, MessagePlugin } from 'tdesign-react'; +import type { FormProps } from 'tdesign-react'; + +import { DesktopIcon, LockOnIcon } from 'tdesign-icons-react'; + +const { FormItem } = Form; + +export default function BaseForm() { + const onSubmit: FormProps['onSubmit'] = (e) => { + if (e.validateResult === true) { + MessagePlugin.info('提交成功'); + } + }; + + const onReset: FormProps['onReset'] = (e) => { + MessagePlugin.info('重置成功'); + }; + + return ( +
+
+ + } placeholder="请输入账户名" /> + + + } clearable={true} placeholder="请输入密码" /> + + + + +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/comprehensive.tsx b/packages/pro-components/chat/chatbot/_example/comprehensive.tsx new file mode 100644 index 0000000000..76df7a2c37 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/comprehensive.tsx @@ -0,0 +1,234 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + SSEChunkData, + TdChatMessageConfigItem, + ChatRequestParams, + ChatMessagesData, + ChatServiceConfig, + ChatBot, + type TdChatbotApi, +} from '@tdesign-react/chat'; +import { Button, Space, Select } from 'tdesign-react'; +import { SystemSumIcon } from 'tdesign-icons-react'; + +// 默认初始化消息 +const mockData: ChatMessagesData[] = [ + { + id: '123', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '欢迎使用 TDesign Chatbot 智能助手,你可以这样问我:', + }, + { + type: 'suggestion', + status: 'complete', + data: [ + { + title: '南极的自动提款机叫什么名字', + prompt: '南极的自动提款机叫什么名字?', + }, + { + title: '南极自动提款机在哪里', + prompt: '南极自动提款机在哪里', + }, + ], + }, + ], + }, +]; + +const selectOptions = [ + { + label: '默认模型', + value: 'default', + }, + { + label: 'Deepseek', + value: 'deepseek-r1', + }, + { + label: '混元', + value: 'hunyuan', + }, +]; + +export default function chatSample() { + const chatRef = useRef(null); + const [activeSearch, setSearchActive] = useState(false); + const [ready, setReady] = useState(false); + const reqParamsRef = useRef<{ think: boolean; search: boolean }>({ think: false, search: false }); + + // 消息属性配置 + const messageProps = (msg: ChatMessagesData): TdChatMessageConfigItem => { + const { role, content } = msg; + // 假设只有单条thinking + const thinking = content.find((item) => item.type === 'thinking'); + if (role === 'user') { + return { + variant: 'base', + placement: 'right', + avatar: 'https://tdesign.gtimg.com/site/avatar.jpg', + }; + } + if (role === 'assistant') { + return { + placement: 'left', + actions: ['replay', 'copy', 'good', 'bad'], + handleActions: { + replay: ({ message, active }) => { + console.log('自定义重新回复', message, active); + chatRef?.current?.regenerate(); + }, + suggestion: ({ content }) => { + // 点建议问题自动填入输入框 + chatRef?.current?.addPrompt(content.prompt); + // 也可以点建议问题直接发送消息 + // chatRef?.current?.sendUserMessage({ prompt: content.prompt }); + }, + }, + // 内置的消息渲染配置 + chatContentProps: { + thinking: { + maxHeight: 100, // 思考框最大高度,超过会自动滚动 + layout: 'block', // 思考内容样式,border|block + collapsed: thinking?.status === 'complete', // 是否折叠,这里设置内容输出完成后折叠 + }, + }, + }; + } + }; + + // 聊天服务配置 + const chatServiceConfig: ChatServiceConfig = { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal`, + stream: true, + // 流式对话结束(aborted为true时,表示用户主动结束对话,params为请求参数) + onComplete: (aborted: boolean, params: RequestInit) => { + console.log('onComplete', aborted, params); + }, + // 流式对话过程中出错业务自定义行为 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + // 流式对话过程中用户主动结束对话业务自定义行为 + onAbort: async () => {}, + // 自定义流式数据结构解析 + onMessage: (chunk: SSEChunkData) => { + const { type, ...rest } = chunk.data; + switch (type) { + case 'search': + // 搜索 + return { + type: 'search', + data: { + title: rest.title || `搜索到${rest?.docs.length}条内容`, + references: rest?.content, + }, + }; + // 思考 + case 'think': + return { + type: 'thinking', + status: /耗时/.test(rest?.title) ? 'complete' : 'streaming', + data: { + title: rest.title || '深度思考中', + text: rest.content || '', + }, + }; + // 正文 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + }; + } + return null; + }, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'tdesign-chat', + prompt, + ...reqParamsRef.current, + }), + }; + }, + }; + + useEffect(() => { + reqParamsRef.current = { + search: activeSearch, + }; + }, [activeSearch]); + + useEffect(() => { + if (ready) { + // 设置消息内容 + chatRef.current?.setMessages(mockData, 'replace'); + } + }, [ready]); + + return ( +
+ { + setReady(true); + }} + > + {/* 自定义输入框底部区域slot,可以增加模型选项 */} +
+ + + + + ) : ( + + )} + + + + + + + + +
+ + + +
+ + + ); +} diff --git a/packages/tdesign-react-aigc/site/src/main.jsx b/packages/tdesign-react-aigc/site/src/main.jsx new file mode 100644 index 0000000000..6e9d13a1e4 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/main.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { registerLocaleChange } from 'tdesign-site-components'; +import App from './App'; + +// import tdesign style; +import '@tdesign/pro-components-chat/style/index.js'; +import '@tdesign/common-style/web/docs.less'; + +import 'tdesign-site-components/lib/styles/style.css'; +import 'tdesign-site-components/lib/styles/prism-theme.less'; +import 'tdesign-site-components/lib/styles/prism-theme-dark.less'; + +import 'tdesign-theme-generator'; + +const rootElement = document.getElementById('app'); +const root = createRoot(rootElement); + +registerLocaleChange(); + +root.render( + + + , +); diff --git a/packages/tdesign-react-aigc/site/src/styles/Codesandbox.less b/packages/tdesign-react-aigc/site/src/styles/Codesandbox.less new file mode 100644 index 0000000000..c2b54f6fa3 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/styles/Codesandbox.less @@ -0,0 +1,24 @@ +div[slot='action'] { + display: inline-flex; + column-gap: 8px; +} + +.action-online { + width: 32px; + height: 32px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px; + transition: all 0.2s linear; + cursor: pointer; + border-radius: 3px; + color: var(--text-secondary); + + &:hover { + color: var(--text-primary); + background-color: var(--bg-color-demo-hover, rgb(243, 243, 243)); + } +} + diff --git a/packages/tdesign-react-aigc/site/src/utils.js b/packages/tdesign-react-aigc/site/src/utils.js new file mode 100644 index 0000000000..586b03360f --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/utils.js @@ -0,0 +1,24 @@ +export function getRoute(list, docRoutes) { + list.forEach((item) => { + if (item.children) { + return getRoute(item.children, docRoutes); + } + return docRoutes.push(item); + }); + return docRoutes; +} + +// 过滤小版本号 +export function filterVersions(versions = []) { + const versionMap = new Map(); + + versions.forEach((v) => { + if (v.includes('-')) return false; + const nums = v.split('.'); + versionMap.set(`${nums[0]}.${nums[1]}`, v); + }); + + return [...versionMap.values()].sort((a, b) => { + return Number(a.split('.').slice(0, 2).join('.')) - Number(b.split('.').slice(0, 2).join('.')); + }); +} diff --git a/packages/tdesign-react-aigc/site/test-coverage.js b/packages/tdesign-react-aigc/site/test-coverage.js new file mode 100644 index 0000000000..e1e1b27f39 --- /dev/null +++ b/packages/tdesign-react-aigc/site/test-coverage.js @@ -0,0 +1,446 @@ +module.exports = { + "Util": { + "statements": "58.47%", + "branches": "47.92%", + "functions": "63.49%", + "lines": "60.29%" + }, + "affix": { + "statements": "84.84%", + "branches": "61.29%", + "functions": "87.5%", + "lines": "85.93%" + }, + "alert": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "anchor": { + "statements": "93.85%", + "branches": "69.56%", + "functions": "88%", + "lines": "98.05%" + }, + "autoComplete": { + "statements": "96.17%", + "branches": "90.9%", + "functions": "97.05%", + "lines": "97.95%" + }, + "avatar": { + "statements": "92.64%", + "branches": "86.48%", + "functions": "75%", + "lines": "92.64%" + }, + "backTop": { + "statements": "78.68%", + "branches": "53.84%", + "functions": "83.33%", + "lines": "83.92%" + }, + "badge": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "breadcrumb": { + "statements": "84.31%", + "branches": "53.12%", + "functions": "85.71%", + "lines": "89.58%" + }, + "button": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "calendar": { + "statements": "78.07%", + "branches": "53.24%", + "functions": "72%", + "lines": "80.86%" + }, + "card": { + "statements": "100%", + "branches": "84.61%", + "functions": "100%", + "lines": "100%" + }, + "cascader": { + "statements": "93.12%", + "branches": "75.8%", + "functions": "90.62%", + "lines": "94.21%" + }, + "checkbox": { + "statements": "90.27%", + "branches": "83.01%", + "functions": "100%", + "lines": "91.3%" + }, + "collapse": { + "statements": "96.15%", + "branches": "78.94%", + "functions": "94.11%", + "lines": "96.1%" + }, + "colorPicker": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "comment": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "common": { + "statements": "94.33%", + "branches": "84.61%", + "functions": "100%", + "lines": "97.95%" + }, + "configProvider": { + "statements": "70.58%", + "branches": "66.66%", + "functions": "25%", + "lines": "68.75%" + }, + "datePicker": { + "statements": "59.16%", + "branches": "41.42%", + "functions": "58.9%", + "lines": "62.07%" + }, + "descriptions": { + "statements": "98.82%", + "branches": "100%", + "functions": "95.45%", + "lines": "100%" + }, + "dialog": { + "statements": "83.53%", + "branches": "71.92%", + "functions": "79.06%", + "lines": "85.62%" + }, + "divider": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "drawer": { + "statements": "86.44%", + "branches": "84.48%", + "functions": "61.53%", + "lines": "89.09%" + }, + "dropdown": { + "statements": "89.28%", + "branches": "58.69%", + "functions": "80%", + "lines": "92.59%" + }, + "empty": { + "statements": "84.37%", + "branches": "63.63%", + "functions": "100%", + "lines": "84.37%" + }, + "form": { + "statements": "83.5%", + "branches": "70.73%", + "functions": "81.51%", + "lines": "87.17%" + }, + "grid": { + "statements": "84.21%", + "branches": "74.24%", + "functions": "90%", + "lines": "84.21%" + }, + "guide": { + "statements": "99.32%", + "branches": "92.85%", + "functions": "100%", + "lines": "99.31%" + }, + "hooks": { + "statements": "61.17%", + "branches": "49.41%", + "functions": "68.86%", + "lines": "62.4%" + }, + "image": { + "statements": "88.88%", + "branches": "82.53%", + "functions": "80%", + "lines": "91.86%" + }, + "imageViewer": { + "statements": "65.28%", + "branches": "76.54%", + "functions": "65.11%", + "lines": "65.59%" + }, + "input": { + "statements": "93.9%", + "branches": "92.3%", + "functions": "89.47%", + "lines": "94.19%" + }, + "inputAdornment": { + "statements": "86.95%", + "branches": "54.54%", + "functions": "100%", + "lines": "90.47%" + }, + "inputNumber": { + "statements": "76.74%", + "branches": "59.74%", + "functions": "78.94%", + "lines": "80.16%" + }, + "layout": { + "statements": "91.48%", + "branches": "41.66%", + "functions": "85.71%", + "lines": "91.48%" + }, + "link": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "list": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "loading": { + "statements": "86.07%", + "branches": "65%", + "functions": "78.57%", + "lines": "89.33%" + }, + "locale": { + "statements": "73.07%", + "branches": "72.22%", + "functions": "83.33%", + "lines": "73.91%" + }, + "menu": { + "statements": "85.44%", + "branches": "69.23%", + "functions": "81.48%", + "lines": "90.51%" + }, + "message": { + "statements": "88.96%", + "branches": "86.66%", + "functions": "70.45%", + "lines": "94.28%" + }, + "notification": { + "statements": "89.24%", + "branches": "75%", + "functions": "86.95%", + "lines": "92.59%" + }, + "pagination": { + "statements": "93.82%", + "branches": "76.08%", + "functions": "93.75%", + "lines": "94.87%" + }, + "popconfirm": { + "statements": "76.92%", + "branches": "60%", + "functions": "81.81%", + "lines": "76.92%" + }, + "popup": { + "statements": "48.38%", + "branches": "44.77%", + "functions": "45.23%", + "lines": "46.52%" + }, + "progress": { + "statements": "89.23%", + "branches": "65.71%", + "functions": "100%", + "lines": "89.23%" + }, + "radio": { + "statements": "83.58%", + "branches": "47.36%", + "functions": "92.85%", + "lines": "83.58%" + }, + "rangeInput": { + "statements": "75.32%", + "branches": "62.79%", + "functions": "51.85%", + "lines": "75%" + }, + "rate": { + "statements": "96.36%", + "branches": "80.76%", + "functions": "100%", + "lines": "96.36%" + }, + "select": { + "statements": "100%", + "branches": "100%", + "functions": "100%", + "lines": "100%" + }, + "selectInput": { + "statements": "99%", + "branches": "94.11%", + "functions": "100%", + "lines": "100%" + }, + "skeleton": { + "statements": "77.35%", + "branches": "43.47%", + "functions": "83.33%", + "lines": "78.84%" + }, + "slider": { + "statements": "89.47%", + "branches": "68.85%", + "functions": "92.85%", + "lines": "91.2%" + }, + "space": { + "statements": "87.75%", + "branches": "84.37%", + "functions": "100%", + "lines": "87.75%" + }, + "statistic": { + "statements": "84.44%", + "branches": "85.71%", + "functions": "72.72%", + "lines": "85.71%" + }, + "steps": { + "statements": "87.8%", + "branches": "66.07%", + "functions": "100%", + "lines": "87.8%" + }, + "swiper": { + "statements": "71.93%", + "branches": "43.28%", + "functions": "85.71%", + "lines": "71.35%" + }, + "switch": { + "statements": "96.55%", + "branches": "92%", + "functions": "100%", + "lines": "96.55%" + }, + "table": { + "statements": "48.36%", + "branches": "33.74%", + "functions": "45.91%", + "lines": "49.56%" + }, + "tabs": { + "statements": "89.79%", + "branches": "77.27%", + "functions": "88%", + "lines": "90.86%" + }, + "tag": { + "statements": "56.25%", + "branches": "48.21%", + "functions": "47.05%", + "lines": "55.55%" + }, + "tagInput": { + "statements": "85.11%", + "branches": "82.89%", + "functions": "84.21%", + "lines": "87.26%" + }, + "textarea": { + "statements": "82.89%", + "branches": "62.22%", + "functions": "80.95%", + "lines": "86.76%" + }, + "timePicker": { + "statements": "82.88%", + "branches": "73.68%", + "functions": "86.36%", + "lines": "83.01%" + }, + "timeline": { + "statements": "96.87%", + "branches": "88.13%", + "functions": "90.9%", + "lines": "96.77%" + }, + "tooltip": { + "statements": "90.74%", + "branches": "64.7%", + "functions": "75%", + "lines": "90.56%" + }, + "transfer": { + "statements": "86.27%", + "branches": "67.61%", + "functions": "84.28%", + "lines": "87.97%" + }, + "tree": { + "statements": "86.22%", + "branches": "70.64%", + "functions": "84.9%", + "lines": "88.33%" + }, + "treeSelect": { + "statements": "95.45%", + "branches": "82.35%", + "functions": "97.56%", + "lines": "97.2%" + }, + "typography": { + "statements": "95.52%", + "branches": "76.31%", + "functions": "81.81%", + "lines": "98.43%" + }, + "upload": { + "statements": "96.77%", + "branches": "95.65%", + "functions": "88.88%", + "lines": "100%" + }, + "watermark": { + "statements": "95.77%", + "branches": "79.41%", + "functions": "100%", + "lines": "98.5%" + }, + "utils": { + "statements": "75.43%", + "branches": "73.68%", + "functions": "83.33%", + "lines": "74.54%" + } +}; diff --git a/packages/tdesign-react-aigc/site/vite.config.js b/packages/tdesign-react-aigc/site/vite.config.js new file mode 100644 index 0000000000..65b6e88424 --- /dev/null +++ b/packages/tdesign-react-aigc/site/vite.config.js @@ -0,0 +1,56 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tdocPlugin from './plugin-tdoc'; + +const publicPathMap = { + preview: '/', + intranet: '/react-chat/', + production: 'https://static.tdesign.tencent.com/react-chat/', +}; + +const disableTreeShakingPlugin = (paths) => ({ + name: 'disable-treeshake', + transform(code, id) { + for (const path of paths) { + if (id.includes(path)) { + return { code, map: null, moduleSideEffects: 'no-treeshake' }; + } + } + }, +}); + +export default ({ mode }) => + defineConfig({ + base: publicPathMap[mode], + resolve: { + alias: { + '@tdesign-react/chat': path.resolve(__dirname, '../../pro-components/chat'), + '@tdesign/react-aigc-site': path.resolve(__dirname, './'), + 'tdesign-react/es': path.resolve(__dirname, '../../components'), + 'tdesign-react': path.resolve(__dirname, '../../components'), + }, + }, + build: { + rollupOptions: { + input: { + index: 'index.html', + playground: 'playground.html', + }, + }, + }, + jsx: 'react', + server: { + host: '0.0.0.0', + port: 15001, + open: '/', + https: false, + fs: { + strict: false, + }, + }, + test: { + environment: 'jsdom', + }, + plugins: [react(), tdocPlugin(), disableTreeShakingPlugin(['style/'])], + }); diff --git a/packages/tdesign-react/site/plugins/plugin-tdoc/index.js b/packages/tdesign-react/site/plugins/plugin-tdoc/index.js index 597b403a21..d18e6fbca3 100644 --- a/packages/tdesign-react/site/plugins/plugin-tdoc/index.js +++ b/packages/tdesign-react/site/plugins/plugin-tdoc/index.js @@ -3,38 +3,39 @@ import vitePluginTdoc from 'vite-plugin-tdoc'; import renderDemo from './demo'; import transforms from './transforms'; -export default () => vitePluginTdoc({ - transforms, // 解析 markdown 数据 - markdown: { - anchor: { - tabIndex: false, - config: (anchor) => ({ - permalink: anchor.permalink.linkInsideHeader({ symbol: '' }), - }), +export default () => + vitePluginTdoc({ + transforms, // 解析 markdown 数据 + markdown: { + anchor: { + tabIndex: false, + config: (anchor) => ({ + permalink: anchor.permalink.linkInsideHeader({ symbol: '' }), + }), + }, + toc: { + listClass: 'tdesign-toc_list', + itemClass: 'tdesign-toc_list_item', + linkClass: 'tdesign-toc_list_item_a', + containerClass: 'tdesign-toc_container', + format: parseHeader, + }, + container(md, container) { + renderDemo(md, container); + }, + config(md) { + // 禁用 markdown-it-attrs 对内联代码的处理,并转义花括号 + // eslint-disable-next-line no-param-reassign + md.renderer.rules.code_inline = (tokens, idx) => { + const token = tokens[idx]; + token.attrs = null; + let content = md.utils.escapeHtml(token.content); + content = content.replace(/\{/g, '{').replace(/\}/g, '}'); + return `${content}`; + }; + }, }, - toc: { - listClass: 'tdesign-toc_list', - itemClass: 'tdesign-toc_list_item', - linkClass: 'tdesign-toc_list_item_a', - containerClass: 'tdesign-toc_container', - format: parseHeader, - }, - container(md, container) { - renderDemo(md, container); - }, - config(md) { - // 禁用 markdown-it-attrs 对内联代码的处理,并转义花括号 - // eslint-disable-next-line no-param-reassign - md.renderer.rules.code_inline = (tokens, idx) => { - const token = tokens[idx]; - token.attrs = null; - let content = md.utils.escapeHtml(token.content); - content = content.replace(/\{/g, '{').replace(/\}/g, '}'); - return `${content}`; - }; - }, - }, -}); + }); function parseHeader(header) { // 转义 HTML 标签 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 08f4a78ef1..a96a77d986 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - 'packages/**' - - 'site' - - 'test' \ No newline at end of file + - 'internal/**' + - 'test' diff --git a/script/analyze-aigc-bundle.js b/script/analyze-aigc-bundle.js new file mode 100755 index 0000000000..a928549bf6 --- /dev/null +++ b/script/analyze-aigc-bundle.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const analysisDir = path.join(__dirname, '..', 'packages', 'tdesign-react-aigc', 'bundle-analysis'); +const esDir = path.join(__dirname, '..', 'packages', 'tdesign-react-aigc', 'es'); + +console.log('🔍 TDesign React AIGC 包体积分析报告'); +console.log('='.repeat(50)); + +// 检查构建产物 +if (!fs.existsSync(esDir)) { + console.log('❌ 未找到构建产物,请先运行: npm run build:aigc'); + process.exit(1); +} + +// 检查分析报告 +const analysisFiles = ['stats-es.html', 'stats-es-sunburst.html', 'stats-es-network.html']; +const existingFiles = analysisFiles.filter(file => fs.existsSync(path.join(analysisDir, file))); + +if (existingFiles.length === 0) { + console.log('❌ 未找到分析报告,请运行: ANALYZE=true npm run build:aigc'); + process.exit(1); +} + +console.log('📊 可用的分析报告:'); +existingFiles.forEach((file, index) => { + const filePath = path.join(analysisDir, file); + const size = (fs.statSync(filePath).size / 1024).toFixed(2); + console.log(`${index + 1}. ${file} (${size} KB)`); +}); + +console.log('\n🚀 快速操作:'); +console.log('open packages/tdesign-react-aigc/bundle-analysis/stats-es.html'); +console.log('\n🔄 重新分析:'); +console.log('ANALYZE=true npm run build:aigc && node script/analyze-aigc-bundle.js'); \ No newline at end of file diff --git a/script/rollup.aigc.config.js b/script/rollup.aigc.config.js new file mode 100644 index 0000000000..f21349863b --- /dev/null +++ b/script/rollup.aigc.config.js @@ -0,0 +1,146 @@ +import url from '@rollup/plugin-url'; +import json from '@rollup/plugin-json'; +import babel from '@rollup/plugin-babel'; +import styles from 'rollup-plugin-styles'; +import esbuild from 'rollup-plugin-esbuild'; +import replace from '@rollup/plugin-replace'; +import { terser } from 'rollup-plugin-terser'; +import commonjs from '@rollup/plugin-commonjs'; +import { DEFAULT_EXTENSIONS } from '@babel/core'; +import multiInput from 'rollup-plugin-multi-input'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import analyzer from 'rollup-plugin-analyzer'; +// import { visualizer } from 'rollup-plugin-visualizer'; +import { resolve } from 'path'; + +import pkg from '../packages/tdesign-react-aigc/package.json'; + +const name = 'tdesign'; +const externalDeps = Object.keys(pkg.dependencies || {}); +const externalPeerDeps = Object.keys(pkg.peerDependencies || {}); + +// 分析模式配置 +const isAnalyze = process.env.ANALYZE === 'true'; + +const banner = `/** + * ${name} v${pkg.version} + * (c) ${new Date().getFullYear()} ${pkg.author} + * @license ${pkg.license} + */ +`; + +// 获取分析插件 +const getAnalyzePlugins = (buildType = 'aigc') => { + if (!isAnalyze) return []; + + const plugins = []; + + // 基础分析器 - 控制台输出 + plugins.push( + analyzer({ + limit: 10, + summaryOnly: false, + hideDeps: false, + showExports: true, + }) + ); + + return plugins; +}; +const inputList = [ + 'packages/pro-components/chat/**/*.ts', + 'packages/pro-components/chat/**/*.tsx', + '!packages/pro-components/chat/**/_example', + '!packages/pro-components/chat/**/*.d.ts', + '!packages/pro-components/chat/**/__tests__', + '!packages/pro-components/chat/**/_usage', +]; + +const getPlugins = ({ env, isProd = false } = {}) => { + const plugins = [ + nodeResolve({ + extensions: ['.mjs', '.js', '.json', '.node', '.ts', '.tsx'], + }), + commonjs(), + esbuild({ + include: /\.[jt]sx?$/, + target: 'esnext', + minify: false, + jsx: 'transform', + jsxFactory: 'React.createElement', + jsxFragment: 'React.Fragment', + tsconfig: resolve(__dirname, '../tsconfig.build.json'), + }), + babel({ + babelHelpers: 'runtime', + extensions: [...DEFAULT_EXTENSIONS, '.ts', '.tsx'], + }), + json(), + url(), + replace({ + preventAssignment: true, + values: { + __VERSION__: JSON.stringify(pkg.version), + }, + }), + ]; + + if (env) { + plugins.push( + replace({ + preventAssignment: true, + values: { + 'process.env.NODE_ENV': JSON.stringify(env), + }, + }), + ); + } + + if (isProd) { + plugins.push( + terser({ + output: { + /* eslint-disable */ + ascii_only: true, + /* eslint-enable */ + }, + }), + ); + } + + return plugins; +}; + +const cssConfig = { + input: ['packages/pro-components/chat/style/index.js'], + plugins: [multiInput({ relative: 'packages/pro-components/chat' }), styles({ mode: 'extract' })], + output: { + banner, + dir: 'packages/tdesign-react-aigc/es/', + sourcemap: true, + assetFileNames: '[name].css', + }, +}; + +// 按需加载组件 带 css 样式 +const esConfig = { + input: inputList, + // 为了保留 style/css.js + treeshake: false, + external: (id) => + // 处理子路径模式的外部依赖 + externalDeps.some((dep) => id === dep || id.startsWith(`${dep}/`)) || + externalPeerDeps.some((dep) => id === dep || id.startsWith(`${dep}/`)), + plugins: [multiInput({ relative: 'packages/pro-components/chat' })] + .concat(getPlugins({ extractMultiCss: true })) + .concat(getAnalyzePlugins('es')), + output: { + banner, + dir: 'packages/tdesign-react-aigc/es/', + format: 'esm', + sourcemap: true, + chunkFileNames: '_chunks/dep-[hash].js', + }, +}; + +export default [esConfig, cssConfig]; diff --git a/tsconfig.aigc.build.json b/tsconfig.aigc.build.json new file mode 100644 index 0000000000..c92203ddff --- /dev/null +++ b/tsconfig.aigc.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig", + "include": ["packages/pro-components/chat"], + "exclude": ["**/**/__tests__/*", "**/**/_example/*", "**/**/_usage/*", "es", "node_modules"], + "compilerOptions": { + "jsx": "react-jsx", + "emitDeclarationOnly": true, + "rootDir": "packages/pro-components/chat", + "skipLibCheck": true + } +} diff --git a/tsconfig.json b/tsconfig.json index a6eda09fd3..f2f54ab634 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,12 @@ "tdesign-react/*": [ "packages/components/*" ], + "@tdesign-react/chat": [ + "packages/pro-components/chat" + ], + "@tdesign-react/chat/*": [ + "packages/pro-components/chat/*" + ], "@test/utils": [ "test/utils" ], diff --git a/yarn-error.log b/yarn-error.log new file mode 100644 index 0000000000..463fb481e5 --- /dev/null +++ b/yarn-error.log @@ -0,0 +1,207 @@ +Arguments: + /Users/caolin/.nvm/versions/node/v18.17.1/bin/node /Users/caolin/.nvm/versions/node/v18.17.1/bin/yarn install + +PATH: + /Users/caolin/.codebuddy/bin:/Users/caolin/.codebuddy/bin:/Users/caolin/.nvm/versions/node/v18.17.1/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Library/Apple/usr/bin:/Users/caolin/.rvm/bin:/Users/caolin/.rvm/bin:/Users/caolin/.fef/bin + +Yarn version: + 1.22.19 + +Node version: + 18.17.1 + +Platform: + darwin arm64 + +Trace: + Error: https://mirrors.tencent.com/npm/@tdesign%2fcommon: no such package available + at params.callback [as _callback] (/Users/caolin/.nvm/versions/node/v18.17.1/lib/node_modules/yarn/lib/cli.js:66145:18) + at self.callback (/Users/caolin/.nvm/versions/node/v18.17.1/lib/node_modules/yarn/lib/cli.js:140890:22) + at Request.emit (node:events:514:28) + at Request. (/Users/caolin/.nvm/versions/node/v18.17.1/lib/node_modules/yarn/lib/cli.js:141862:10) + at Request.emit (node:events:514:28) + at IncomingMessage. (/Users/caolin/.nvm/versions/node/v18.17.1/lib/node_modules/yarn/lib/cli.js:141784:12) + at Object.onceWrapper (node:events:628:28) + at IncomingMessage.emit (node:events:526:35) + at endReadableNT (node:internal/streams/readable:1359:12) + at process.processTicksAndRejections (node:internal/process/task_queues:82:21) + +npm manifest: + { + "name": "tdesign-react-mono", + "packageManager": "pnpm@9.15.9", + "private": true, + "scripts": { + "pnpm:devPreinstall": "node script/pnpm-dev-preinstall.js", + "init": "git submodule init && git submodule update", + "start": "pnpm run dev", + "dev": "pnpm -C packages/tdesign-react/site dev", + "site": "pnpm run build:jsx-demo && pnpm -C packages/tdesign-react/site build", + "site:preview": "pnpm run build:jsx-demo && pnpm -C packages/tdesign-react/site preview", + "dev:aigc": "pnpm -C packages/tdesign-react-aigc/site dev", + "site:aigc": "pnpm -C packages/tdesign-react-aigc/site build", + "site:aigc-intranet": "pnpm -C packages/tdesign-react-aigc/site intranet", + "site:aigc-preview": "pnpm -C packages/tdesign-react-aigc/site preview", + "lint": "pnpm run lint:tsc && eslint --ext .ts,.tsx ./ --max-warnings 0", + "lint:fix": "eslint --ext .ts,.tsx ./packages/components --ignore-pattern packages/components/__tests__ --max-warnings 0 --fix", + "lint:tsc": "tsc -p ./tsconfig.dev.json ", + "generate:usage": "node script/generate-usage/index.js", + "generate:coverage-badge": "pnpm run test:coverage && node script/generate-coverage.js", + "generate:jsx-demo": "npx babel packages/components/**/_example --extensions '.tsx' --config-file ./babel.config.demo.js --relative --out-dir ../_example-js --out-file-extension=.jsx", + "format:jsx-demo": "npx eslint packages/components/**/_example-js/*.jsx --fix && npx prettier --write packages/components/**/_example-js/*.jsx", + "test": "vitest run && pnpm run test:snap", + "test:ui": "vitest --ui", + "test:snap": "cross-env NODE_ENV=test-snap vitest run", + "test:snap-update": "cross-env NODE_ENV=test-snap vitest run -u", + "test:update": "vitest run -u && pnpm run test:snap-update", + "test:coverage": "vitest run --coverage", + "prebuild": "rimraf packages/tdesign-react/es/* packages/tdesign-react/lib/* packages/tdesign-react/dist/* packages/tdesign-react/esm/* packages/tdesign-react/cjs/*", + "build": "cross-env NODE_ENV=production rollup -c script/rollup.config.js && node script/utils/bundle-override.js && pnpm run build:tsc", + "build:aigc": "cross-env NODE_ENV=production rollup -c script/rollup.aigc.config.js && tsc -p ./tsconfig.aigc.build.json --outDir packages/tdesign-react-aigc/es/", + "build:tsc": "run-p build:tsc-*", + "build:tsc-es": "tsc -p ./tsconfig.build.json --outDir packages/tdesign-react/es/", + "build:tsc-esm": "tsc -p ./tsconfig.build.json --outDir packages/tdesign-react/esm/", + "build:tsc-cjs": "tsc -p ./tsconfig.build.json --outDir packages/tdesign-react/cjs/", + "build:tsc-lib": "tsc -p ./tsconfig.build.json --outDir packages/tdesign-react/lib/", + "build:jsx-demo": "pnpm run generate:jsx-demo && pnpm run format:jsx-demo", + "init:component": "node script/init-component", + "robot": "publish-cli robot-msg", + "prepare": "husky install" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, + "lint-staged": { + "src/**/*.{ts,tsx}": [ + "prettier --write", + "pnpm run lint:fix" + ] + }, + "keywords": [ + "tdesign", + "react" + ], + "author": "tdesign", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13.1", + "react-dom": ">=16.13.1" + }, + "devDependencies": { + "@babel/cli": "^7.24.7", + "@babel/core": "^7.16.5", + "@babel/plugin-transform-runtime": "^7.21.4", + "@babel/plugin-transform-typescript": "^7.18.10", + "@babel/preset-env": "^7.16.5", + "@babel/preset-react": "^7.16.5", + "@babel/preset-typescript": "^7.16.5", + "@commitlint/cli": "^16.1.0", + "@commitlint/config-conventional": "^17.1.0", + "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@rollup/plugin-babel": "^5.3.0", + "@rollup/plugin-commonjs": "^21.0.2", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.3.0", + "@rollup/plugin-replace": "^3.0.0", + "@rollup/plugin-url": "^7.0.0", + "@testing-library/jest-dom": "^5.16.1", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.4.3", + "@types/node": "^22.7.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/rimraf": "^4.0.5", + "@types/testing-library__jest-dom": "5.14.2", + "@typescript-eslint/eslint-plugin": "^5.13.0", + "@typescript-eslint/parser": "^5.13.0", + "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-istanbul": "^2.1.1", + "@vitest/coverage-v8": "^2.1.1", + "@vitest/ui": "^3.1.1", + "autoprefixer": "^10.4.0", + "babel-polyfill": "^6.26.0", + "camelcase": "^6.2.1", + "cross-env": "^5.2.1", + "cz-conventional-changelog": "^3.3.0", + "dom-parser": "^0.1.6", + "esbuild": "^0.14.9", + "eslint": "^7.32.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-react": "~7.28.0", + "eslint-plugin-react-hooks": "^4.0.0", + "fs-extra": "^11.3.0", + "glob": "^9.0.3", + "happy-dom": "^15.11.0", + "husky": "^7.0.4", + "jest-canvas-mock": "^2.4.0", + "jsdom": "^20.0.1", + "less": "^4.1.2", + "lint-staged": "^13.2.2", + "mockdate": "^3.0.5", + "msw": "^1.0.0", + "npm-run-all2": "^8.0.4", + "postcss": "^8.3.11", + "prettier": "^2.3.2", + "prismjs": "^1.28.0", + "prop-types": "^15.7.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-element-to-jsx-string": "^17.0.0", + "react-router-dom": "^6.2.2", + "resize-observer-polyfill": "^1.5.1", + "rimraf": "^6.0.1", + "rollup": "^2.74.1", + "rollup-plugin-analyzer": "^4.0.0", + "rollup-plugin-copy": "^3.4.0", + "rollup-plugin-esbuild": "^4.9.1", + "rollup-plugin-ignore-import": "^1.3.2", + "rollup-plugin-multi-input": "^1.3.1", + "rollup-plugin-postcss": "^4.0.2", + "rollup-plugin-static-import": "^0.1.1", + "rollup-plugin-styles": "^4.0.0", + "rollup-plugin-terser": "^7.0.2", + "rollup-plugin-typescript2": "^0.31.2", + "typescript": "5.6.2", + "vitest": "^2.1.1" + }, + "dependencies": { + "@babel/runtime": "~7.26.7", + "@popperjs/core": "~2.11.2", + "@tdesign-react/chat": "workspace:^", + "@tdesign/common": "workspace:^", + "@tdesign/common-docs": "workspace:^", + "@tdesign/common-js": "workspace:^", + "@tdesign/common-style": "workspace:^", + "@tdesign/components": "workspace:^", + "@tdesign/pro-components-chat": "workspace:^", + "@tdesign/react-site": "workspace:^", + "@types/sortablejs": "^1.10.7", + "@types/tinycolor2": "^1.4.3", + "@types/validator": "^13.1.3", + "classnames": "~2.5.1", + "dayjs": "1.11.10", + "hoist-non-react-statics": "~3.3.2", + "lodash-es": "^4.17.21", + "mitt": "^3.0.0", + "raf": "~3.4.1", + "react-fast-compare": "^3.2.2", + "react-is": "^18.2.0", + "react-transition-group": "~4.4.1", + "sortablejs": "^1.15.0", + "tdesign-icons-react": "0.5.0", + "tdesign-react": "workspace:^", + "tinycolor2": "^1.4.2", + "tslib": "~2.3.1", + "validator": "~13.7.0" + } + } + +yarn manifest: + No manifest + +Lockfile: + No lockfile