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..7e9415a20d 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,33 @@ "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/react-site": "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" } } 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..cc47303fea --- /dev/null +++ b/packages/pro-components/chat/chat-actionbar/chat-actionbar.md @@ -0,0 +1,36 @@ +--- +title: ChatActionBar 对话操作栏 +description: 对话消息操作栏 +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..d649ac71ef --- /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/_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..2f0c9d193b --- /dev/null +++ b/packages/pro-components/chat/chat-attachments/chat-attachments.md @@ -0,0 +1,23 @@ +--- +title: Attachments 附件列表 +description: 附件列表 +isComponent: true +usage: { title: '', description: '' } +spline: aigc +--- + +## 基础用法 + +{{ base }} + + +## API +### Attachments Props +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +items | Array | - | 附件列表。TS类型:`TdAttachmentItem[]`。[类型定义](./chat-filecard?tab=api#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..d495d7489b --- /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..8849c92f62 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/custom-content.tsx @@ -0,0 +1,428 @@ +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..dc9c6711f6 --- /dev/null +++ b/packages/pro-components/chat/chat-loading/_example/base.tsx @@ -0,0 +1,22 @@ +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/chat-loading.en-US.md b/packages/pro-components/chat/chat-loading/chat-loading.en-US.md new file mode 100644 index 0000000000..ada7ce7d84 --- /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/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..5036f32527 --- /dev/null +++ b/packages/pro-components/chat/chat-loading/chat-loading.md @@ -0,0 +1,20 @@ +--- +title: ChatLoading 对话加载 +description: 对话加载 +isComponent: true +usage: { title: '', description: '' } +spline: navigation +--- + +## 基础用法 + +{{ base }} + + +## API +### ChatLoading Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +animation | String | moving | 动画效果。可选项:skeleton/moving/gradient/dot/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..64ddbf7114 --- /dev/null +++ b/packages/pro-components/chat/chat-markdown/_example/base.tsx @@ -0,0 +1,139 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Button, 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. + + + +\`\`\`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]); + + const toggleTyping = () => { + if (currentIndex.current >= doc.length) { + currentIndex.current = 0; + setDisplayText(''); + } + setIsTyping(!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..cac2494664 --- /dev/null +++ b/packages/pro-components/chat/chat-sender/_example/attachment.tsx @@ -0,0 +1,95 @@ +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', + }, + ]); + + // 输入变化处理 + 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..323efbe59a --- /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..d224a186e2 --- /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..41a2de6854 --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/_example/style.tsx @@ -0,0 +1,87 @@ +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 ( + + + +
layout:
+ setLayout(val)}> + border + block + +
+ +
animation:
+ setAnimation(val)}> + {/* skeleton */} + moving + gradient + circle + +
+
+ +
+ ); +} 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..39f420fa16 --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/_usage/index.jsx @@ -0,0 +1,57 @@ +/** + * 该脚本为自动生成,如有需要请在 /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: '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-thinking/_usage/props.json b/packages/pro-components/chat/chat-thinking/_usage/props.json new file mode 100644 index 0000000000..563662307d --- /dev/null +++ b/packages/pro-components/chat/chat-thinking/_usage/props.json @@ -0,0 +1,57 @@ +[ + { + "name": "collapsed", + "type": "Boolean", + "defaultValue": false, + "options": [] + }, + { + "name": "layout", + "type": "enum", + "defaultValue": "border", + "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": "circle", + "value": "circle" + } + ] + }, + { + "name": "status", + "type": "enum", + "defaultValue": "pending", + "options": [ + { + "label": "pending", + "value": "pending" + }, + { + "label": "complete", + "value": "complete" + } + ] + } +] \ No newline at end of file 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..be6453d7b7 --- /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..200472d157 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/basic.tsx @@ -0,0 +1,226 @@ +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..05387c27d6 --- /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..f10996f118 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/comprehensive.tsx @@ -0,0 +1,211 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { InternetIcon } from 'tdesign-icons-react'; +import { + SSEChunkData, + 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(true); + const [activeSearch, setSearchActive] = useState(true); + 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 = { + 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/custom-content.tsx b/packages/pro-components/chat/chatbot/_example/custom-content.tsx new file mode 100644 index 0000000000..5f08798494 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/custom-content.tsx @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import { CopyIcon, EditIcon, SoundIcon } from 'tdesign-icons-react'; +import type { + SSEChunkData, + ChatServiceConfig, + ChatBaseContent, + ChatMessagesData, + AIMessageContent, +} from '@tdesign-react/chat'; +import { ChatBot } from '@tdesign-react/chat'; +import { Button, Space, MessagePlugin } from 'tdesign-react'; +import TvisionTcharts from 'tvision-charts-react'; + +/** + * 自定义插槽示例 + * + * 本示例展示如何使用插槽机制实现自定义渲染,包括: + * 1. 自定义内容渲染:扩展自定义内容类型(如图表) + * 2. 自定义操作栏:为消息添加自定义操作按钮 + * + * 插槽类型: + * - 内容插槽:`${msg.id}-${content.type}-${index}` - 用于渲染自定义内容 + * - 操作栏插槽:`${msg.id}-actionbar` - 用于渲染自定义操作栏 + * + * 实现步骤: + * 1. 扩展类型:通过 TypeScript 模块扩展声明自定义内容类型 + * 2. 解析数据:在 onMessage 中返回自定义类型的数据结构 + * 3. 植入插槽:使用 slot 属性渲染自定义组件 + * + * 学习目标: + * - 掌握插槽机制的使用方法 + * - 理解插槽命名规则和渲染时机 + * - 学会扩展自定义内容类型和操作栏 + */ + +// 1. 扩展自定义消息体类型 +declare global { + interface AIContentTypeOverrides { + chart: ChatBaseContent< + 'chart', + { + chartType: string; + options: any; + theme: string; + } + >; + } +} + +// 2. 自定义渲染图表的组件 +const ChartDemo = ({ data }) => ( +
+ +
+); + +export default function CustomContent() { + const [messages, setMessages] = useState([]); + + // 聊天服务配置 + const chatServiceConfig: ChatServiceConfig = { + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal', + stream: true, + + // 3. 在 onMessage 中解析并返回自定义类型数据 + onMessage: (chunk: SSEChunkData): AIMessageContent | null => { + const { type, ...rest } = chunk.data; + + switch (type) { + // 文本内容 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + // 根据后端返回的paragraph字段来决定是否需要另起一段展示markdown + strategy: rest?.paragraph === 'next' ? 'append' : 'merge', + }; + + // 自定义图表内容 + case 'chart': + return { + type: 'chart', + data: { + id: Date.now(), + ...chunk.data.content, + }, + // 图表每次出现都是追加创建新的内容块 + strategy: 'append', + }; + + default: + return null; + } + }, + + // 自定义请求参数(告诉后端需要返回图表) + onRequest: (innerParams) => ({ + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'test', + prompt: innerParams.prompt, + chart: true, // 请求图表数据 + }), + }), + }; + + // 操作按钮回调 + const handlePlayAudio = () => { + MessagePlugin.info('播放语音'); + }; + + const handleEdit = () => { + MessagePlugin.info('编辑消息'); + }; + + const handleCopy = (content: string) => { + navigator.clipboard.writeText(content); + MessagePlugin.success('已复制到剪贴板'); + }; + + return ( +
+ { + setMessages(e.detail); + }} + > + {/* 4. 植入自定义内容渲染插槽 */} + {messages + ?.map((msg) => + msg.content?.map((item, index) => { + if (item.type === 'chart') { + // 内容插槽命名规则:`${msg.id}-${content.type}-${index}` + // slot 名必须保证在 message 队列中的唯一性 + return ( +
+ +
+ ); + } + return null; + }), + ) + .flat()} + + {/* 5. 植入自定义操作栏插槽 */} + {messages?.map((msg) => { + // 只为 AI 消息且已完成的消息添加操作栏 + if (msg.role === 'assistant' && msg.status === 'complete') { + // 提取消息文本内容用于复制 + const textContent = msg.content + ?.filter((item) => item.type === 'text' || item.type === 'markdown') + .map((item) => item.data) + .join('\n') || ''; + + return ( + // 操作栏插槽命名规则:`${msg.id}-actionbar` +
+ + + + + +
+ ); + } + return null; + })} +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/custom-merge.tsx b/packages/pro-components/chat/chatbot/_example/custom-merge.tsx new file mode 100644 index 0000000000..f30f0493aa --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/custom-merge.tsx @@ -0,0 +1,178 @@ +import React, { useRef, useEffect, useState } from 'react'; +import type { + SSEChunkData, + ChatServiceConfig, + ChatBaseContent, + ChatMessagesData, + AIMessageContent, +} from '@tdesign-react/chat'; +import { ChatBot } from '@tdesign-react/chat'; +import { Progress } from 'tdesign-react'; + +/** + * 自定义合并策略示例 + * + * 本示例展示流式数据的两种合并方式: + * + * 1. **strategy 配置**:使用内置的合并策略 + * - 'merge'(默认):查找相同 type 的最后一个内容块并合并 + * - 'append':始终追加为新的独立内容块 + * + * 2. **registerMergeStrategy**:注册自定义合并策略 + * - 适用于复杂的合并逻辑(如状态机、累积计算等) + * - 完全控制数据的合并方式 + * + * 使用场景: + * - 简单场景:使用 strategy 配置即可(文本累积、多段落等) + * - 复杂场景:使用 registerMergeStrategy(进度条、任务步骤、嵌套结构等) + * + * 学习目标: + * - 理解 strategy 的两种取值及其区别 + * - 掌握 registerMergeStrategy 的使用方法 + * - 学会根据场景选择合适的合并方式 + */ + +// 1. 扩展自定义内容类型:进度条 +declare global { + interface AIContentTypeOverrides { + progress: ChatBaseContent< + 'progress', + { + current: number; + total: number; + label: string; + completed?: boolean; // 是否已完成 + } + >; + } +} + +// 自定义进度条组件 +const ProgressDemo = ({ data }) => { + // 计算百分比并保留两位小数 + const percentage = Math.round((data.current / data.total) * 10000) / 100; + const isCompleted = data.completed || percentage === 100; + + return ( +
+ {/* 根据完成状态显示不同的文案 */} + <> +
{data.label}
+ +
+ {data.current} / {data.total} ({percentage}%) +
+ +
+ ); +}; + +export default function CustomMerge() { + const chatRef = useRef(null); + const [messages, setMessages] = useState([]); + const [isProgressCompleted, setIsProgressCompleted] = useState(false); + + useEffect(() => { + if (!chatRef.current?.isChatEngineReady) { + return; + } + + // 注册自定义合并策略 + // 当内置的 strategy 无法满足需求时,可以注册自定义合并策略 + chatRef.current.registerMergeStrategy('progress', (newChunk, existingChunk) => { + if (!existingChunk) { + // 首次接收,直接返回新数据 + return newChunk; + } + + // 自定义合并逻辑:更新进度和状态 + return { + ...existingChunk, + data: { + ...existingChunk.data, + current: newChunk.data.current, // 更新当前进度 + label: newChunk.data.label, // 更新标签 + completed: newChunk.data.completed, // 更新完成状态 + }, + }; + }); + }, []); + + // 聊天服务配置 + const chatServiceConfig: ChatServiceConfig = { + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/progress', + stream: true, + + // 3. 在 onMessage 中返回不同类型的数据 + onMessage: (chunk: SSEChunkData, message): AIMessageContent | AIMessageContent[] | null => { + const { type, ...rest } = chunk.data; + + switch (type) { + // 处理文本消息 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + }; + + // 处理进度条消息,progress的合并逻辑通过 registerMergeStrategy 注册 + case 'progress': + return { + type: 'progress', + data: { + current: rest.current || 0, + total: rest.total || 100, + label: rest.label || '处理中', + completed: rest.completed || false, + }, + + } as any; + + default: + return null; + } + }, + + onRequest: (innerParams) => ({ + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'test', + prompt: innerParams.prompt, + progress: true, + }), + }), + }; + + return ( +
+ { + setMessages(e.detail); + }} + > + {/* 渲染自定义进度条 */} + {messages + ?.map((msg) => + msg.content?.map((item, index) => { + if (item.type === 'progress') { + return ( +
+ +
+ ); + } + return null; + }), + ) + .flat()} +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/custom.css b/packages/pro-components/chat/chatbot/_example/custom.css new file mode 100644 index 0000000000..6a44f4646a --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/custom.css @@ -0,0 +1,20 @@ +:root { + --td-chat-input-background: red; +} + +.my-chat-sender { + --td-chat-input-background: blue; +} + +.my-chat-sender::part(t-chat__input__content) { + border-radius: 2px; +} + +.accessible-chat { + --td-chat-input-font-size: 24px; /* 确保字体大小足够大 */ +} + +.accessible-chat .my-chat-sender::part(t-chat__input__content):hover { + outline: 2px solid yellow; + outline-offset: 2px; +} \ No newline at end of file diff --git a/packages/pro-components/chat/chatbot/_example/custom.tsx b/packages/pro-components/chat/chatbot/_example/custom.tsx new file mode 100644 index 0000000000..1966762841 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/custom.tsx @@ -0,0 +1,228 @@ +import React, { useRef } from 'react'; +import { CopyIcon, EditIcon, SoundIcon } from 'tdesign-icons-react'; +import type { + SSEChunkData, + TdChatMessageConfig, + AIContentChunkUpdate, + ChatRequestParams, + ChatServiceConfig, + ChatBaseContent, + ChatMessagesData, +} from '@tdesign-react/chat'; +import { Button, Space } from 'tdesign-react'; +import { ChatBot } from '@tdesign-react/chat'; +import TvisionTcharts from 'tvision-charts-react'; + +// 1、扩展自定义消息体类型 +declare module '@tdesign-react/chat' { + interface AIContentTypeOverrides { + chart: ChatBaseContent< + 'chart', + { + chartType: string; + options: any; + theme: string; + } + >; + } +} + +// 2、自定义渲染图表的组件 +const ChartDemo = ({ data }) => ( +
+ +
+); + +const initMessage: ChatMessagesData[] = [ + { + id: '7389', + role: 'user', + status: 'complete', + content: [ + { + type: 'attachment', + data: [ + { + fileType: 'image', + // name: '', + // size: 234234, + // extension: '.doc', + url: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + { + fileType: 'image', + // name: 'avatar.jpg', + // size: 234234, + url: 'https://avatars.githubusercontent.com/Jayclelon', + }, + ], + }, + { + type: 'text', + data: '这张图里的帅哥是谁', + }, + ], + }, + { + id: '123', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '欢迎使用TDesign智能图表分析助手,请输入你的问题', + }, + ], + }, +]; + +export default function ChatBotReact() { + const chatRef = useRef(null); + const [mockMessage, setMockMessage] = React.useState(initMessage); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + }, + assistant: { + placement: 'left', + avatar: 'https://tdesign.gtimg.com/site/chat-avatar.png', + // customRenderConfig, + chatContentProps: { + thinking: { + maxHeight: 100, + }, + }, + }, + }; + + // 聊天服务配置 + 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): AIContentChunkUpdate => { + const { type, ...rest } = chunk.data; + switch (type) { + // 正文 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + // 根据后端返回的paragraph字段来决定是否需要另起一段展示markdown + strategy: rest?.paragraph === 'next' ? 'append' : 'merge', + }; + // 3、自定义渲染图表所需的数据结构 + case 'chart': + return { + type: 'chart', + data: { + id: Date.now(), + ...chunk.data.content, + }, + // 图表每次出现都是追加创建新的内容块 + strategy: 'append', + }; + } + }, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'test', + prompt, + chart: true, + }), + }; + }, + }; + + return ( +
+ { + setMockMessage(e.detail); + }} + > + {/* 4、植入自定义消息体渲染插槽 */} + {mockMessage + ?.map((msg) => + msg.content.map((item, index) => { + switch (item.type) { + // 示例:图表消息体 + case 'chart': + return ( + // slot名这里必须保证在message队列中的唯一性,默认插槽命名规则`${msg.id}-${content.type}-${index}`, 也可以在onMessage中自行返回slotName, +
+ +
+ ); + case 'videoAttachment': { + return ( +
+ +
+ ); + } + } + return null; + }), + ) + .flat()} + {/* 自定义消息操作区 */} + {mockMessage?.map((data) => { + // 示例:给ai消息配置操作区 + if (data.role === 'assistant' && data.status === 'complete') { + return ( +
+ + + + + +
+ ); + } + return null; + })} +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/docs.tsx b/packages/pro-components/chat/chatbot/_example/docs.tsx new file mode 100644 index 0000000000..9b45ec6212 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/docs.tsx @@ -0,0 +1,163 @@ +import React, { useRef, useState } from 'react'; +import type { TdAttachmentItem, ChatRequestParams, TdChatMessageConfig, TdChatbotApi } from '@tdesign-react/chat'; +import { ChatBot } from '@tdesign-react/chat'; +import { SSEChunkData, AIMessageContent, ChatMessagesData, ChatServiceConfig } from '../index'; + +// 默认初始化消息 +const mockData: ChatMessagesData[] = [ + { + id: '123', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '欢迎使用TDesign文案写作助手,可以先上传你需要参考的文件,输入你要撰写的主题~', + }, + ], + }, +]; + +export default function chatSample() { + const chatRef = useRef(null); + const [files, setFiles] = useState([]); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + avatar: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + assistant: { + placement: 'left', + actions: ['copy', 'good', 'bad'], + handleActions: { + // 处理消息操作回调 + good: async ({ message, active }) => { + // 点赞 + console.log('点赞', message, active); + }, + bad: async ({ message, active }) => { + // 点踩 + console.log('点踩', message, active); + }, + }, + }, + }; + + // 聊天服务配置 + 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, attachments } = innerParams; + return { + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'tdesign-chat', + prompt, + files: attachments, + docs: true, + }), + }; + }, + }; + + // 文件上传 + const onFileSelect = (e: CustomEvent) => { + // 添加新文件并模拟上传进度 + const newFile = { + ...e.detail[0], + name: e.detail[0].name, + status: 'progress' as any, + description: '上传中', + }; + + setFiles((prev) => [newFile, ...prev]); + + setTimeout(() => { + setFiles((prevState) => + prevState.map((file) => + file.name === newFile.name + ? { + ...file, + url: 'https://tdesign.gtimg.com/site/demo.docx', // mock返回的文件地址 + status: 'success', + description: '上传成功', + } + : file, + ), + ); + }, 1000); + }; + + // 移除文件回调 + const onFileRemove = (e: CustomEvent) => { + setFiles(e.detail); + }; + + const onSend = () => { + setFiles([]); // 清除掉附件区域 + }; + + return ( +
+ +
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/image.tsx b/packages/pro-components/chat/chatbot/_example/image.tsx new file mode 100644 index 0000000000..1984fb2d4d --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/image.tsx @@ -0,0 +1,338 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { BrowseIcon, Filter3Icon, ImageAddIcon, Transform1Icon } from 'tdesign-icons-react'; +import type { + SSEChunkData, + AIMessageContent, + ChatRequestParams, + ChatMessagesData, + ChatServiceConfig, + TdAttachmentItem, + TdChatSenderParams, + UploadFile, + TdChatMessageConfig, + TdChatbotApi, +} from '@tdesign-react/chat'; +import { ImageViewer, Skeleton, ImageViewerProps, Button, Dropdown, Space, Image } from 'tdesign-react'; +import { ChatBot } from '@tdesign-react/chat'; + +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智能生图助手,请先写下你的创意,可以试试上传参考图哦~', + }, + ], + }, +]; + +// 自定义生图消息内容 +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 ; + })} +
+ ); +}; + +export default function chatSample() { + const chatRef = 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 [mockMessage, setMockMessage] = React.useState(mockData); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + avatar: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + assistant: { + placement: 'left', + actions: ['good', 'bad'], + handleActions: { + // 处理消息操作回调 + good: async ({ message, active }) => { + // 点赞 + console.log('点赞', message, active); + }, + bad: async ({ message, active }) => { + // 点踩 + console.log('点踩', message, active); + }, + }, + }, + }; + + // 聊天服务配置 + 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, + }), + }; + }, + }; + + // 选中文件 + const onAttachClick = () => { + chatRef.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 = (e: CustomEvent): ChatRequestParams => { + const { value, attachments } = e.detail; + setFiles([]); // 清除掉附件区域 + return { + attachments, + prompt: `${value},要求比例:${ + ratio === 0 ? '默认比例' : RatioOptions.filter(({ value }) => value === ratio)[0].content + }, 风格:${style ? StyleOptions.filter(({ value }) => value === style)[0].content : '默认风格'}`, + }; + }; + + 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]); + + return ( +
+ { + setMockMessage(e.detail); + }} + > + {mockMessage + ?.map((msg) => + msg.content.map((item, index) => { + switch (item.type) { + // 示例:图片消息体 + case 'imageview': + return ( + // slot名这里必须保证唯一性 +
+ img?.url)} /> +
+ ); + } + return null; + }), + ) + .flat()} + {/* 自定义输入框底部区域slot,可以增加模型选项 */} +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/index.css b/packages/pro-components/chat/chatbot/_example/index.css new file mode 100644 index 0000000000..e9ff3827eb --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/index.css @@ -0,0 +1,23 @@ +.step { + color: #4d4d4d; +} + +.title { + margin-bottom: 8px; +} + +.command, .result { + font-size: 14px; + line-height: 22px; + margin: 6px 0; +} + +.command { + background-color: #efeff0; + border-radius: 12px; + display: inline-block; /* 内联块级元素使宽度自适应内容 */ + padding: 4px 8px; + white-space: nowrap; + width: fit-content; /* 宽度精确匹配内容 */ +} + diff --git a/packages/pro-components/chat/chatbot/_example/initial-messages.tsx b/packages/pro-components/chat/chatbot/_example/initial-messages.tsx new file mode 100644 index 0000000000..d160f82c59 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/initial-messages.tsx @@ -0,0 +1,169 @@ +import React, { useRef, useState } from 'react'; +import { + ChatBot, + SSEChunkData, + AIMessageContent, + ChatServiceConfig, + ChatMessagesData, + type TdChatbotApi, +} from '@tdesign-react/chat'; +import { Button, Space } from 'tdesign-react'; + +/** + * 初始化消息示例 + * + * 学习目标: + * - 使用 defaultMessages 设置欢迎语和建议问题 + * - 通过 setMessages 动态加载历史消息 + * - 实现点击建议问题填充输入框 + */ +export default function InitialMessages() { + const chatRef = useRef(null); + 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 提供了哪些常用组件?', + }, + ], + }, + ], + }, + ]; + + // 模拟历史消息数据(通常从后端接口获取) + 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 = () => { + chatRef.current?.setMessages(historyMessages, 'replace'); + setHasHistory(true); + }; + + // 清空消息 + const clearMessages = () => { + chatRef.current?.clearMessages(); + setHasHistory(false); + }; + + // 聊天服务配置 + const chatServiceConfig: 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 messageProps = { + assistant: { + handleActions: { + // 点击建议问题时,填充到输入框 + suggestion: ({ content }) => { + chatRef.current?.addPrompt(content.prompt); + }, + }, + }, + }; + + return ( +
+ {/* 操作按钮 */} +
+
快捷指令:
+ + + + +
+ {/* 聊天组件 */} +
+ +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/instance-methods.tsx b/packages/pro-components/chat/chatbot/_example/instance-methods.tsx new file mode 100644 index 0000000000..b1860147dc --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/instance-methods.tsx @@ -0,0 +1,175 @@ +import React, { useRef, useState } from 'react'; +import { ChatBot, SSEChunkData, AIMessageContent, ChatServiceConfig, type TdChatbotApi } from '@tdesign-react/chat'; +import { Button, Space, Divider, MessagePlugin } from 'tdesign-react'; + +/** + * 实例方法示例 + * + * 学习目标: + * - 通过 ref 获取组件实例 + * - 调用实例方法控制组件行为 + * - 了解各种实例方法的使用场景 + * + * 方法分类: + * 1. 消息设置:sendUserMessage、sendSystemMessage、setMessages + * 2. 发送控制: addPrompt、regenerate、abortChat、selectFile、scrollList + * 3. 获取状态 + */ +export default function InstanceMethods() { + const chatRef = useRef(null); + const [ready, setReady] = useState(false); + + // 聊天服务配置 + const chatServiceConfig: 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 handleChatReady = () => { + console.log('ChatEngine 已就绪'); + setReady(true); + }; + + // 1. 发送用户消息 + const handleSendUserMessage = () => { + chatRef.current?.sendUserMessage({ + prompt: '这是通过实例方法发送的用户消息', + }); + }; + + const handleSendAIMessage = () => { + chatRef.current?.sendAIMessage({ + content: [ + { + type: 'text', + data: '这是通过实例方法发送的AI回答', + }, + ], + sendRequest: false, + }); + }; + + + + // 2. 发送系统消息 + const handleSendSystemMessage = () => { + chatRef.current?.sendSystemMessage('这是一条系统通知消息'); + }; + + // 3. 添加提示语到输入框 + const handleAddPrompt = () => { + chatRef.current?.addPrompt('请介绍一下 TDesign'); + }; + + // 4. 设置消息 + const handleSetMessages = () => { + chatRef.current?.setMessages( + [ + { + id: `msg-${Date.now()}`, + role: 'assistant', + content: [{ type: 'text', data: '这是通过 setMessages 设置的消息' }], + status: 'complete', + }, + ], + 'replace', + ); + }; + + // 4. 清空消息 + const handleClearMessages = () => { + chatRef.current?.clearMessages(); + }; + + // 5. 重新生成最后一条消息 + const handleRegenerate = () => { + chatRef.current?.regenerate(); + }; + + // 6. 中止当前请求 + const handleAbort = () => { + chatRef.current?.abortChat(); + MessagePlugin.info('已中止当前请求'); + }; + + // 7. 滚动列表 + const handleScrollToBottom = () => { + chatRef.current?.scrollList({ to: 'bottom', behavior: 'smooth' }); + }; + + // 8. 触发文件选择 + const handleSelectFile = () => { + chatRef.current?.selectFile(); + }; + + // 9. 获取当前状态 + const handleGetStatus = () => { + const status = { + isChatEngineReady: chatRef.current?.isChatEngineReady, + chatStatus: chatRef.current?.chatStatus, + senderLoading: chatRef.current?.senderLoading, + messagesCount: chatRef.current?.chatMessageValue?.length || 0, + }; + console.log('当前状态:', status); + MessagePlugin.info(`状态: ${status.chatStatus}, 消息数: ${status.messagesCount}`); + }; + + return ( +
+ {/* 操作按钮区域 */} +
+
+
快捷指令:
+ + + + + + + + + + + + + +
+
+ + {/* 聊天组件 */} +
+ +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/nostream.tsx b/packages/pro-components/chat/chatbot/_example/nostream.tsx new file mode 100644 index 0000000000..f9bdf76bf8 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/nostream.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { InternetIcon } from 'tdesign-icons-react'; +import { + TdChatMessageConfigItem, + ChatRequestParams, + ChatMessagesData, + ChatServiceConfig, + ChatBot, + type TdChatbotApi, +} from '@tdesign-react/chat'; +import { Button, Space } from 'tdesign-react'; + +export default function chatSample() { + const chatRef = useRef(null); + const [activeR1, setR1Active] = useState(false); + const [activeSearch, setSearchActive] = useState(false); + const reqParamsRef = useRef<{ think: boolean; search: boolean }>({ think: false, search: false }); + + // 聊天服务配置 + const chatServiceConfig: ChatServiceConfig = { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/fetch/normal`, + stream: false, + // 流式对话结束(aborted为true时,表示用户主动结束对话,params为请求参数) + onComplete: (isAborted, req, result) => { + console.log('onComplete', isAborted, req, result); + return { + type: 'text', + data: result.data, + }; + }, + // 流式对话过程中出错业务自定义行为 + onError: (err: Error | Response) => { + console.error('Chatservice Error:', err); + }, + // 流式对话过程中用户主动结束对话业务自定义行为 + onAbort: async () => { + chatRef.current?.sendSystemMessage('用户已暂停'); + }, + // 自定义请求参数 + 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]); + + return ( +
+ +
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/quick-start.tsx b/packages/pro-components/chat/chatbot/_example/quick-start.tsx new file mode 100644 index 0000000000..8311594ed0 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/quick-start.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { ChatBot, SSEChunkData, AIMessageContent, ChatServiceConfig } from '@tdesign-react/chat'; + +/** + * 快速开始示例 + * + * 学习目标: + * - 了解 Chatbot 组件的最小配置 + * - 理解 endpoint 和 onMessage 的作用 + * - 实现一个基于SSE流式传输的最简可用的对话界面 + */ +export default function QuickStart() { + // 聊天服务配置 + const chatServiceConfig: 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 || '', + }; + }, + }; + + return ( +
+ +
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/research.tsx b/packages/pro-components/chat/chatbot/_example/research.tsx new file mode 100644 index 0000000000..4bf01c1e58 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/research.tsx @@ -0,0 +1,172 @@ +import React, { useRef, useState } from 'react'; +import type { + SSEChunkData, + AIMessageContent, + ChatMessagesData, + ChatServiceConfig, + TdAttachmentItem, + UploadFile, + ChatRequestParams, + TdChatMessageConfig, + TdChatbotApi, +} from '@tdesign-react/chat'; +import { ChatBot } from '@tdesign-react/chat'; + +// 默认初始化消息 +const mockData: ChatMessagesData[] = [ + { + id: '123', + role: 'assistant', + content: [ + { + type: 'text', + status: 'complete', + data: '欢迎使用TDesign文案写作助手,可以先上传你需要参考的文件,输入你要撰写的主题~', + }, + ], + }, +]; + +export default function chatSample() { + const chatRef = useRef(null); + const [files, setFiles] = useState([]); + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + avatar: 'https://tdesign.gtimg.com/site/avatar.jpg', + }, + assistant: { + placement: 'left', + actions: ['copy', 'good', 'bad'], + handleActions: { + // 处理消息操作回调 + good: async ({ message, active }) => { + // 点赞 + console.log('点赞', message, active); + }, + bad: async ({ message, active }) => { + // 点踩 + console.log('点踩', message, active); + }, + }, + }, + }; + + // 聊天服务配置 + 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, attachments } = innerParams; + return { + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + uid: 'tdesign-chat', + prompt, + files: attachments, + docs: true, + }), + }; + }, + }; + + // 文件上传 + 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/demo.docx', // mock返回的文件地址 + status: 'success', + description: '上传成功', + } + : file, + ), + ); + }, 1000); + }; + + // 移除文件回调 + const onFileRemove = (e: CustomEvent) => { + setFiles(e.detail); + }; + + const onSend = () => { + setFiles([]); // 清除掉附件区域 + }; + + return ( +
+ +
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/role-message-config.tsx b/packages/pro-components/chat/chatbot/_example/role-message-config.tsx new file mode 100644 index 0000000000..fd48805f63 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/role-message-config.tsx @@ -0,0 +1,223 @@ +import React from 'react'; +import { + ChatBot, + SSEChunkData, + AIMessageContent, + ChatServiceConfig, + ChatMessagesData, + TdChatMessageConfigItem, +} from '@tdesign-react/chat'; +import { MessagePlugin } from 'tdesign-react'; + +/** + * 角色消息配置示例 + * + * 本示例展示如何通过 messageProps 配置不同角色的消息展示效果。 + * messageProps 会透传给内部的 ChatMessage 组件,用于控制消息的渲染和交互。 + * + * 配置内容包括: + * - 消息样式配置(气泡样式、位置、头像、昵称等) + * - 消息操作按钮配置(复制、点赞、点踩、重试) + * - 内容类型展示配置(思考过程、搜索结果、Markdown 等) + * - 静态配置 vs 动态配置的使用场景 + * + * 学习目标: + * - 掌握 messageProps 动态配置函数的使用方式 + * - 了解如何根据消息内容、状态动态调整配置 + * - 学会配置消息操作按钮及其回调 + * - 学会使用 chatContentProps 控制内容展示行为 + * + * 相关文档: + * - ChatMessage 组件详细文档:https://tdesign.tencent.com/react-chat/components/chat-message + */ +export default function RoleMessageConfig() { + // 初始化消息:展示各种内置支持的渲染类型 + const defaultMessages: ChatMessagesData[] = [ + { + id: 'demo-1', + role: 'user', + content: [ + { + type: 'text', + data: '请展示一下 TDesign Chatbot 内置支持的各种内容类型', + }, + ], + datetime: '2024-01-01 10:00:00', + }, + { + id: 'demo-2', + role: 'assistant', + status: 'complete', + datetime: '2024-01-01 10:00:05', + content: [ + // 1. 思考过程 + { + type: 'thinking', + data: { + title: '分析问题', + text: '正在分析用户问题的核心需求,准备展示各种内容类型...', + }, + status: 'complete', + }, + // 2. 搜索结果 + { + type: 'search', + data: { + title: '找到 3 条相关内容', + references: [ + { + title: 'TDesign 官网', + url: 'https://tdesign.tencent.com', + content: 'TDesign 是腾讯开源的企业级设计体系', + }, + { + title: 'TDesign React', + url: 'https://tdesign.tencent.com/react', + content: 'TDesign 的 React 技术栈实现', + }, + { + title: 'TDesign AIGC', + url: 'https://tdesign.tencent.com/react-chat', + content: 'TDesign 的 AI 对话组件库', + }, + ], + }, + }, + // 3. Markdown 内容 + { + type: 'markdown', + data: `好的!以下是 TDesign Chatbot 支持的各种内容类型: + +**1. 文本和 Markdown** +- 纯文本内容 +- **粗体**、*斜体*、\`代码\` +- [链接](https://tdesign.tencent.com) + +**2. 代码块** +\`\`\`javascript +const greeting = 'Hello TDesign!'; +console.log(greeting); +\`\`\` + +**3. 列表** +- 思考过程(Thinking) +- 搜索结果(Search) +- 建议问题(Suggestion) +- 图片(Image) +- 附件(Attachment)`, + }, + // 4. 建议问题 + { + type: 'suggestion', + data: [ + { title: '继续了解 TDesign', prompt: '告诉我更多关于 TDesign 的信息' }, + { title: '查看组件列表', prompt: 'TDesign 有哪些组件?' }, + { title: '如何使用', prompt: '如何在项目中使用 TDesign?' }, + ], + }, + ], + }, + ]; + + // 聊天服务配置 + const chatServiceConfig: 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 messageProps = (msg: ChatMessagesData): TdChatMessageConfigItem => { + const { role, content, status } = msg; + + // 用户消息配置 + if (role === 'user') { + return { + variant: 'base', + placement: 'right', + avatar: 'https://tdesign.gtimg.com/site/avatar.jpg', + name: '用户', + }; + } + + // AI 消息配置 + if (role === 'assistant') { + // 检查是否包含思考过程 + const hasThinking = content.some((item) => item.type === 'thinking'); + + return { + variant: 'text', + placement: 'left', + avatar: 'https://tdesign.gtimg.com/site/avatar-boy.jpg', + name: 'TDesign 助手', + // 消息操作按钮 + actions: ['copy', 'replay', 'good', 'bad'], + // 消息操作回调 + handleActions: { + copy: () => { + MessagePlugin.success('已复制到剪贴板'); + }, + good: ({ message, active }) => { + console.log('点赞', message, active); + MessagePlugin.success(active ? '已点赞' : '取消点赞'); + }, + bad: ({ message, active }) => { + console.log('点踩', message, active); + MessagePlugin.success(active ? '已点踩' : '取消点踩'); + }, + replay: ({ message }) => { + console.log('重新生成', message); + MessagePlugin.info('重新生成功能将在实例方法示例中展示'); + }, + searchItem: ({ content }) => { + console.log('点击搜索条目', content); + }, + suggestion: ({ content }) => { + console.log('点击建议问题', content); + }, + }, + // 根据消息状态和内容动态配置 + chatContentProps: { + // 思考过程 + thinking: { + collapsed: false, // 是否折叠 + layout: 'block', // 展示样式:border、block + maxHeight: 200, // 展示最大高度,超出会自动向下滚动 + }, + // 搜索结果 + search: { + useCollapse: true, + collapsed: false, + }, + // markdown文本 + markdown: { // 透传cherryMarkdown引擎配置 + options: { + themeSettings: { + codeBlockTheme: 'light', + }, + }, + }, + }, + }; + } + + return {}; + }; + + return ( +
+ +
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/sender-config.tsx b/packages/pro-components/chat/chatbot/_example/sender-config.tsx new file mode 100644 index 0000000000..8fcca2758a --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/sender-config.tsx @@ -0,0 +1,150 @@ +import React, { useRef, useState } from 'react'; +import { + ChatBot, + SSEChunkData, + AIMessageContent, + ChatServiceConfig, + type TdChatbotApi, + type TdAttachmentItem, + type TdChatSenderActionName, +} from '@tdesign-react/chat'; +import { MessagePlugin, Button, Space } from 'tdesign-react'; +import type { UploadFile } from 'tdesign-react'; + +/** + * 输入配置示例 + * + * 本示例展示如何通过 senderProps 配置输入框的基础行为。 + * senderProps 会透传给内部的 ChatSender 组件,用于控制输入框的功能和交互。 + * + * 配置内容包括: + * - 输入框基础配置(占位符、自动高度等) + * - 附件上传配置(文件类型、附件展示等) + * - 输入事件回调(输入、聚焦、失焦等) + * + * 学习目标: + * - 掌握 senderProps 的常用配置项 + * - 了解如何处理附件上传 + * - 学会处理输入事件 + * + * 相关文档: + * - ChatSender 组件详细文档:https://tdesign.tencent.com/react-chat/components/chat-sender + */ +export default function SenderConfig() { + const chatRef = useRef(null); + const [files, setFiles] = useState([]); + + // 聊天服务配置 + const chatServiceConfig: 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 senderProps = { + // 基础配置 + placeholder: '请输入您的问题...(支持 Shift+Enter 换行)', + // 输入框配置,透传Textarea组件的属性 + textareaProps: { + maxlength: 10, + }, + // 操作按钮 + actions: ['attachment', 'send'] as TdChatSenderActionName[], // 显示附件按钮和发送按钮 + // 附件配置 + attachmentsProps: { + items: files, // 附件列表 + overflow: 'scrollX', // 附件溢出时横向滚动 + }, + // 上传配置 + uploadProps: { + accept: '.pdf,.docx,.txt,.md', // 允许的文件类型 + }, + // 事件回调 + onChange: (e: CustomEvent) => { + console.log('输入内容:', e.detail); + }, + onFocus: (e: CustomEvent) => { + console.log('输入框获得焦点'); + }, + onBlur: (e: CustomEvent) => { + console.log('输入框失去焦点'); + }, + onFileSelect: (e: CustomEvent) => { + console.log('选择文件:', e.detail); + // 添加新文件并模拟上传进度 + const newFile: TdAttachmentItem = { + name: e.detail[0].name, + size: e.detail[0].size, + 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, + ), + ); + MessagePlugin.success(`文件 ${newFile.name} 上传成功`); + }, 1000); + }, + onFileRemove: (e: CustomEvent) => { + console.log('移除文件后的列表:', e.detail); + setFiles(e.detail); + MessagePlugin.info('文件已移除'); + }, + }; + + // 快捷指令列表 + const quickPrompts = [ + '介绍一下 TDesign', + '如何使用 Chatbot 组件?', + '有哪些内容类型?', + '如何自定义样式?', + ]; + + return ( +
+ {/* 快捷指令区域 */} +
+
快捷指令:
+ + {quickPrompts.map((prompt, index) => ( + + ))} + +
+ + {/* 聊天组件 */} +
+ +
+
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/service-config.tsx b/packages/pro-components/chat/chatbot/_example/service-config.tsx new file mode 100644 index 0000000000..c6bdf4d56b --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/service-config.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { ChatBot, SSEChunkData, AIMessageContent, ChatServiceConfig, ChatRequestParams } from '@tdesign-react/chat'; +import { MessagePlugin } from 'tdesign-react'; + +/** + * 自定义协议配置示例 + * + * 本示例展示如何配置自定义协议的聊天服务。 + * 当后端服务使用自定义数据格式时,需要通过 onMessage 进行数据转换。 + * + * 配置内容包括: + * - 请求配置(endpoint、onRequest返回请求头、请求参数) + * - 数据转换(onMessage:将后端数据转换为组件所需格式) + * - 生命周期回调(onStart、onComplete、onError、onAbort) + * + * 学习目标: + * - 掌握 chatServiceConfig 的核心配置项 + * - 理解 onMessage 的数据转换逻辑(自定义协议必需) + * - 学会使用生命周期回调处理不同阶段的业务逻辑 + */ +export default function ServiceConfig() { + // 聊天服务配置(自定义协议) + const chatServiceConfig: ChatServiceConfig = { + // 1. 基础配置 + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal', + stream: true, // 是否使用流式传输 + protocol: 'default', // 使用自定义协议(需要配置 onMessage) + // 2. 请求发送前的配置(添加请求头、修改请求参数等) + onRequest: (params: ChatRequestParams) => { + console.log('发送请求前:', params); + + // 可以修改请求参数、添加请求头等 + return { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer your-token', + 'X-Custom-Header': 'custom-value', + }, + // 添加自定义参数 + body: JSON.stringify({ + ...params, + model: 'gpt-4', + temperature: 0.7, + }), + }; + }, + + // 3. 流式传输开始时的回调 + onStart: (chunk: string) => { + console.log('开始接收流式数据:', chunk); + MessagePlugin.info('AI 开始回复...'); + }, + + // 4. 数据转换:将后端返回的数据转换为组件所需格式 + // 这是最核心的配置,决定了如何解析后端数据 + onMessage: (chunk: SSEChunkData): AIMessageContent | null => { + console.log('收到数据块:', chunk); + + const { type, ...rest } = chunk.data; + + // 根据不同的事件类型,返回不同的内容块 + switch (type) { + // 文本内容 + case 'text': + return { + type: 'markdown', + data: rest?.msg || '', + strategy: 'merge', // 合并到同一个文本块 + }; + + // 思考过程 + case 'think': + return { + type: 'thinking', + data: { + title: rest.title || '思考中', + text: rest.content || '', + }, + status: /完成/.test(rest?.title) ? 'complete' : 'streaming', + }; + + // 搜索结果 + case 'search': + return { + type: 'search', + data: { + title: rest.title || '搜索结果', + references: rest?.content || [], + }, + }; + + // 忽略其他类型的事件(返回 null 表示不处理) + default: + console.log('忽略事件类型:', type); + return null; + } + }, + + // 5. 请求完成时的回调 + onComplete: (isAborted: boolean, params?: ChatRequestParams) => { + console.log('请求完成:', { isAborted, params }); + + if (isAborted) { + MessagePlugin.warning('已停止生成'); + } else { + MessagePlugin.success('回复完成'); + } + }, + + // 6. 用户主动中止时的回调 + onAbort: async () => { + console.log('用户中止对话'); + // 可以执行清理操作 + await new Promise((resolve) => setTimeout(resolve, 100)); + }, + + // 7. 错误处理 + onError: (err: Error | Response) => { + console.error('请求错误:', err); + if (err instanceof Response) { + MessagePlugin.error(`请求失败: ${err.status} ${err.statusText}`); + } else { + MessagePlugin.error(`发生错误: ${err.message}`); + } + }, + }; + + return ( +
+ +
+ ); +} diff --git a/packages/pro-components/chat/chatbot/_example/simple.tsx b/packages/pro-components/chat/chatbot/_example/simple.tsx new file mode 100644 index 0000000000..502bd42b09 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/simple.tsx @@ -0,0 +1,74 @@ + +// import type { SSEChunkData, AIMessageContent, ChatServiceConfig } from '@tdesign-react/chat'; +// import { ChatBot } from '@tdesign-react/chat'; + +// export default function chatSample() { +// // 聊天服务配置 +// const chatServiceConfig: 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 || '', +// }; +// }, +// }; + +// return ( +//
+// +//
+// ); +// } + +import React, { useState } from 'react'; +import { Space } from 'tdesign-react'; +import { useChat, ChatList, ChatMessage, ChatSender } from '@tdesign-react/chat'; + +export default function CompositeChat() { + 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) => { + const { type, ...rest } = chunk.data; + return { + type: 'markdown', + data: rest?.msg || '', + }; + }, + }, + }); + + const sendMessage = async (params) => { + setInputValue(''); + await chatEngine.sendUserMessage(params); + }; + + const stopHandler = () => { + chatEngine.abortChat(); + }; + + return ( + + + {messages.map((message) => ( + + ))} + + sendMessage({ prompt: e.detail.value })} + onStop={stopHandler} + /> + + ); +} \ No newline at end of file diff --git a/packages/pro-components/chat/chatbot/_example/utils/chatServiceConfig.ts b/packages/pro-components/chat/chatbot/_example/utils/chatServiceConfig.ts new file mode 100644 index 0000000000..84f7fbccc4 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/utils/chatServiceConfig.ts @@ -0,0 +1,100 @@ +import type { ChatRequestParams, AIMessageContent } from '@tdesign-react/chat'; + +interface ChatServiceConfigProps { + setPlanningState: (state: any) => void; + setCurrentStep: (step: string) => void; + planningState: any; +} + +export const createChatServiceConfig = ({ + setPlanningState, + setCurrentStep, + planningState, +}: ChatServiceConfigProps) => ({ + // 对话服务地址 - 使用现有的服务 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/agui`, + protocol: 'agui', + stream: true, + // 流式对话结束 + onComplete: (aborted: boolean, params?: RequestInit) => { + console.log('旅游规划完成', aborted, params); + return null; + }, + // 流式对话过程中出错 + 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': + console.log('步骤开始:', rest.stepName); + setCurrentStep(rest.stepName); + break; + + case 'STEP_FINISHED': + console.log('步骤完成:', rest.stepName); + setCurrentStep(''); + 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; + + const newState = { ...prevState }; + rest.delta.forEach((change: any) => { + const { op, path, value } = change; + if (op === 'replace') { + // 简单的路径替换逻辑 + if (path === '/status') { + newState.status = value; + } + } else if (op === 'add') { + // 简单的路径添加逻辑 + if (path.startsWith('/itinerary/')) { + if (!newState.itinerary) newState.itinerary = {}; + const key = path.split('/').pop(); + newState.itinerary[key] = value; + } + } + }); + return newState; + }); + + // 返回更新后的状态组件 + return { + type: 'planningState', + data: { state: planningState }, + } as any; + } + }, + // 自定义请求参数 + onRequest: (innerParams: ChatRequestParams) => { + const { prompt } = innerParams; + return { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + uid: 'travel_planner_uid', + prompt, + agentType: 'travel-planner', + }), + }; + }, +}); diff --git a/packages/pro-components/chat/chatbot/_example/utils/historyLoader.ts b/packages/pro-components/chat/chatbot/_example/utils/historyLoader.ts new file mode 100644 index 0000000000..0d5c42ddee --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/utils/historyLoader.ts @@ -0,0 +1,19 @@ +import type { ChatMessagesData, AGUIHistoryMessage } from '@tdesign-react/chat'; +import { AGUIAdapter } from '@tdesign-react/chat'; + +// 加载历史消息的函数 +export 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 []; +}; diff --git a/packages/pro-components/chat/chatbot/_example/utils/messageRenderer.tsx b/packages/pro-components/chat/chatbot/_example/utils/messageRenderer.tsx new file mode 100644 index 0000000000..c89ba48e2f --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/utils/messageRenderer.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import type { AIMessageContent, ChatMessagesData } from '@tdesign-react/chat'; +import { WeatherCard, ItineraryCard, HotelCard, HumanInputResult } from '../components'; + +interface MessageRendererProps { + item: AIMessageContent; + index: number; + message: ChatMessagesData; +} + +export const renderMessageContent = ({ item, index, message }: MessageRendererProps): React.ReactNode => { + if (item.type === 'toolcall') { + const { data, type } = item; + + // Human-in-the-Loop 输入请求 + if (data.toolCallName === 'get_travel_preferences') { + // 区分历史消息和实时交互 + const isHistoricalMessage = message.status === 'complete'; + + if (isHistoricalMessage && data.result) { + // 历史消息:静态展示用户已输入的数据 + try { + const userInput = JSON.parse(data.result); + return ( +
+ +
+ ); + } catch (e) { + console.error('解析用户输入数据失败:', e); + } + } + } + + // 天气卡片 + 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 ( +
+ +
+ ); + } + } + + // 规划状态面板 - 不在消息中显示,只用于更新右侧面板 + if (item.type === 'planningState') { + return null; + } + + return null; +}; diff --git a/packages/pro-components/chat/chatbot/_example/utils/request.ts b/packages/pro-components/chat/chatbot/_example/utils/request.ts new file mode 100644 index 0000000000..ab736b0109 --- /dev/null +++ b/packages/pro-components/chat/chatbot/_example/utils/request.ts @@ -0,0 +1,4 @@ +export const endpoint = (() => { + const isLocal = ['localhost', '127.0.0.1'].includes(window.location.hostname); + return isLocal ? 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com' : 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com'; +})(); diff --git a/packages/pro-components/chat/chatbot/chatbot-old.md b/packages/pro-components/chat/chatbot/chatbot-old.md new file mode 100644 index 0000000000..33473449d5 --- /dev/null +++ b/packages/pro-components/chat/chatbot/chatbot-old.md @@ -0,0 +1,242 @@ +--- +title: Chatbot 智能对话 +description: 高度封装且功能完备的一体化智能对话组件,专为快速集成标准AI应用而设计。组件内置了完整的状态管理、SSE流式传输、消息渲染和交互逻辑,支持多种业务场景,包括智能客服、问答系统、代码助手、任务规划等。 +isComponent: true +spline: navigation +--- + +## 基本用法 + +组件内置状态管理,SSE 解析,自动处理消息内容渲染与交互逻辑,可开箱即用快速集成实现标准聊天界面。本示例演示了如何快速创建一个具备以下功能的智能对话组件: + +- 初始化预设消息 +- 预设消息内容渲染支持(markdown、搜索、思考、建议等) +- 与服务端的 SSE(Server-Sent Events)通信,支持流式消息响应 +- 自定义流式内容结构解析 +- 自定义请求参数处理 +- 常用消息操作处理及回调(复制、重试、点赞/点踩) +- 支持手动触发填入 prompt, 重新生成,发送消息等 + +{{ basic }} + +## 自定义 + +如果组件内置的消息渲染方案不能满足需求,还可以通过自定义**消息结构解析逻辑**和**消息内容渲染组件**来实现更多渲染需求。以下示例给出了一个自定义实现图表渲染的示例,实现自定义渲染需要完成**四步**,概括起来就是:**扩展类型,准备组件,解析数据,植入插槽**: + +- 1、扩展自定义消息体 type 类型 +- 2、实现自定义渲染的组件,示例中使用了 tvision-charts-react 实现图表渲染 +- 3、流式数据增量更新回调`onMessage`中可以对返回数据进行标准化解构,返回渲染组件所需的数据结构,同时可以通过返回`strategy`来决定**同类新增内容块**的追加策略(merge/append),如果需要更灵活影响到数据整合可以返回完整消息数组`AIMessageContent[]`,或者注册合并策略方法(参考下方‘任务规划’示例) +- 4、在 render 函数中遍历消息内容数组,植入自定义消息体渲染插槽,需保证 slot 名在 list 中的唯一性 + +如果组件内置的几种操作 `TdChatMessageActionName` 不能满足需求,示例中同时给出了**自定义消息操作区**的方法,可以自行实现更多操作。 + +{{ custom }} + +## 场景化示例 + +以下再通过几个常见的业务场景,展示下如何使用 `Chatbot` 组件 + +### 代码助手 + +通过使用 tdesign 开发登录框组件的案例,演示了使用 Chatbot 搭建简单的代码助手场景,该示例你可以了解到如何按需开启**markdown 渲染代码块**,如何**自定义实现代码预览** +{{ code }} + +### 文案助手 + +以下案例演示了使用 Chatbot 搭建简单的文案写作助手应用,通过该示例你可以了解到如何**发送附件**,同时演示了**附件类型的内容渲染** +{{ docs }} + +### 图像生成 + +以下案例演示了使用 Chatbot 搭建简单的图像生成应用,通过该示例你可以了解到如何**自定义输入框操作区域**,同时演示了**自定义生图内容渲染** +{{ image }} + +### 任务规划 + +以下案例模拟了使用 Chatbot 搭建任务规划型智能体应用,分步骤依次执行并输出结果,通过该示例你可以了解到如何**注册自定义消息内容合并策略**,**自定义消息插槽名规则**,同时演示了**自定义任务流程渲染** +{{ agent }} + + +## API + +### Chatbot Props + +| 名称 | 类型 | 默认值 | 说明 | 必传 | +| ----------------- | --------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | +| defaultMessages | Array | - | 初始消息数据列表。TS 类型:`ChatMessagesData[]`。[详细类型定义](/react-chat/components/chat-message?tab=api) | N | +| messageProps | Object/Function | - | 消息项配置。按角色聚合了消息项的配置透传`ChatMessage`组件,可以是静态配置对象或动态配置函数。TS 类型:`TdChatMessageConfig \| ((msg: ChatMessagesData) => Omit)` ,[详细类型定义](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chatbot/type.ts) | N | +| listProps | Object | - | 消息列表配置,见下方详细说明。TS 类型:`TdChatListProps`。 | N | +| senderProps | Object | - | 发送框配置,透传`ChatSender`组件。TS 类型:`TdChatSenderProps`。[类型定义](./chat-sender?tab=api) | N | +| chatServiceConfig | Object | - | 聊天服务配置,包含网络请求和回调配置,见下方详细说明,TS 类型:`ChatServiceConfig` | N | +| onMessageChange | Function | - | 消息列表数据变化回调,TS 类型:`(e: CustomEvent) => void` | N | +| onChatReady | Function | - | 内部对话引擎初始化完成回调,TS 类型:`(e: CustomEvent) => void` | N | +| onChatAfterSend | Function | - | 发送消息后的回调,TS 类型:`(e: CustomEvent) => void` | N | + +### TdChatListProps 消息列表配置 + +| 名称 | 类型 | 默认值 | 说明 | 必传 | +| ---------- | -------- | ------ | -------------------------------- | ---- | +| autoScroll | Boolean | true | 高度变化时列表是否自动滚动到底部 | N | +| onScroll | Function | - | 滚动事件回调 | N | + +### ChatServiceConfig 类型说明 + +聊天服务核心配置类型,主要作用包括基础通信配置,请求流程控制及全生命周期管理(初始化 → 传输 → 完成/中止),流式数据的分块处理策略,状态通知回调等。 + +| 名称 | 类型 | 默认值 | 说明 | 必传 | +| ---------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---- | +| endpoint | String | - | 聊天服务请求地址 url | N | +| protocol | String | 'default' | 聊天服务协议,支持'default'和'agui' | N | +| stream | Boolean | true | 是否使用流式传输 | N | +| onStart | Function | - | 流式传输开始时的回调。TS 类型:`(chunk: string) => void` | N | +| onRequest | Function | - | 请求发送前的配置回调,可修改请求参数、添加 headers 等。TS 类型:`(params: ChatRequestParams) => RequestInit` | N | +| onMessage | Function | - | 处理流式消息的回调,用于解析后端数据并映射为组件所需格式。TS 类型:`(chunk: SSEChunkData) => AIMessageContent / AIMessageContent[] / null` | N | +| onComplete | Function | - | 请求结束时的回调。TS 类型:`(isAborted: boolean, params: RequestInit, result?: any) => AIMessageContent / AIMessageContent[] / null` | N | +| onAbort | Function | - | 中止请求时的回调。TS 类型:`() => Promise` | N | +| onError | Function | - | 错误处理回调。TS 类型:`(err: Error \| Response) => void` | N | + +### Chatbot 实例方法和属性 + +通过 ref 获取组件实例,调用以下方法。 + +| 名称 | 类型 | 描述 | +| --------------------- | --------------------------------------------------------------------------------- | -------------------------------------------- | +| setMessages | (messages: ChatMessagesData[], mode?: 'replace' \| 'prepend' \| 'append') => void | 批量设置消息 | +| sendUserMessage | (params: ChatRequestParams) => Promise | 发送用户消息,处理请求参数并触发消息流 | +| sendAIMessage | (params: ChatRequestParams) => Promise | 发送 AI 消息,处理请求参数并触发消息流 | +| sendSystemMessage | (msg: string) => void | 发送系统级通知消息,用于展示系统提示/警告 | +| abortChat | () => Promise | 中止当前进行中的聊天请求,清理网络连接 | +| addPrompt | (prompt: string) => void | 将预设提示语添加到输入框,辅助用户快速输入 | +| selectFile | () => void | 触发文件选择对话框,用于附件上传功能 | +| regenerate | (keepVersion?: boolean) => Promise | 重新生成最后一条消息,可选保留历史版本 | +| registerMergeStrategy | (type: T['type'], handler: (chunk: T, existing?: T) => T) => void | 注册自定义消息合并策略,用于处理流式数据更新 | +| scrollList | ({ to: 'bottom' \| 'top', behavior: 'auto' \| 'smooth' }) => void | 受控滚动到指定位置 | +| isChatEngineReady | boolean | ChatEngine 是否就绪 | +| chatMessageValue | ChatMessagesData[] | 获取当前消息列表的只读副本 | +| chatStatus | ChatStatus | 获取当前聊天状态(空闲/进行中/错误等) | +| senderLoading | boolean | 当前输入框按钮是否在'输出中' | + +## 常见问题 + +### 如何回填设置消息列表(初始化/加载历史消息)? + +组件支持两种方式回填消息: + +**1. 初始化时回填** + +通过 `defaultMessages` 属性传入静态初始消息列表: + +```javascript + const defaultMessages = [ + { + id: '1', + role: 'user', + content: [{ type: 'text', data: '你好' }], + datetime: '2025-01-01 10:00:00' + }, + { + id: '2', + role: 'assistant', + content: [{ type: 'text', data: '你好!有什么可以帮助你的吗?' }], + datetime: '2025-01-01 10:00:01', + status: 'complete' + } + ]; +``` + + +**2. 动态加载历史消息** + +通过 ref 调用 `setMessages` 方法,支持三种模式: + +```javascript + const chatbotRef = useRef(null); + + // 替换所有消息(初始化回填) + chatbotRef.current.setMessages(historyMessages, 'replace'); + + // 在顶部追加历史消息(适用于上拉加载更多) + chatbotRef.current.setMessages(olderMessages, 'prepend'); + + // 在底部追加消息 + chatbotRef.current.setMessages(newMessages, 'append'); +``` + +**3. 消息数据结构说明** + +每条消息必须包含以下字段: +- `id`:消息唯一标识 +- `role`:消息角色(user/assistant/system) +- `content`:消息内容数组,详见 [ChatMessage 组件文档](/react-chat/components/chat-message?tab=api) +- `datetime`:消息时间(可选) +- `status`:消息状态(可选),AI 消息建议设置为 'complete' + + +### 如何处理后端返回的消息数据转换? + +后端返回的数据格式通常与组件所需的消息结构不一致,需要在 `onMessage` 回调中进行转换。 + +**核心概念**: +- `onMessage` 的返回值决定了如何更新消息内容 +- 返回 `null` 或 `undefined` 表示忽略本次数据块,不更新消息 +- 返回 `AIMessageContent` 表示要添加/更新单个内容块 +- 返回 `AIMessageContent[]` 表示批量更新多个内容块 + +**场景 1:多种消息类型混合** + +```javascript +chatServiceConfig={{ + onMessage: (chunk) => { + // chunk为后端实时返回的数据流 + // { event: "message", data: { type: "think", title: "思考中...", content: "用户" } } + // { event: "message", data: { type: "text", content: "总结下这个问题" } } + const { type, ...rest } = chunk.data; + + // 处理思考过程 + if (type === 'think') { + return { + type: 'thinking', // 返回组件内置的消息类型和data结构 + data: { text: rest.content, title: rest.title } + }; + } + + // 处理文本内容 + if (type === 'text') { + return { + type: 'markdown', + data: rest.content, + strategy: 'merge' + }; + } + + return null; // 忽略未知事件 + } +}} +``` + +**场景 2:批量更新多个内容块** + +```javascript +chatServiceConfig={{ + onMessage: (chunk, message) => { + // message 是当前正在构建的消息对象 + const { event, data } = chunk; + // 也可以根据当前消息内容动态决定 + if (event === 'complete') { + const currentContent = message?.content || []; + // 移除思考过程,只保留最终答案 + return currentContent.filter(c => c.type !== 'thinking'); + } + + return null; + } +}} +``` + +**返回值处理逻辑**: + +| 返回值类型 | 处理逻辑 | 适用场景 | +|-----------|---------|---------| +| `null` / `undefined` | 忽略本次数据块,不更新消息 | 过滤无关事件、跳过中间状态 | +| `AIMessageContent` | 根据 `strategy` 字段决定:
• `merge`(默认):查找相同 type 的最后一个内容块并合并
• `append`:追加为新的独立内容块 | 大多数流式响应场景 | +| `AIMessageContent[]` | 遍历数组,根据 `id` 或 `type` 匹配已存在的内容块:
• 匹配到:更新该内容块
• 未匹配:追加到末尾 | 批量更新多个内容块、动态调整内容结构 | diff --git a/packages/pro-components/chat/chatbot/chatbot.en-US.md b/packages/pro-components/chat/chatbot/chatbot.en-US.md new file mode 100644 index 0000000000..7570ec1354 --- /dev/null +++ b/packages/pro-components/chat/chatbot/chatbot.en-US.md @@ -0,0 +1,128 @@ +--- +title: Chatbot 智能对话 +description: 智能对话聊天组件,适用于需要快速集成智能客服、问答系统等的AI应用 +isComponent: true +spline: navigation +--- + +## 基本用法 + +### 标准化集成 +组件内置状态管理,SSE解析,自动处理消息内容渲染与交互逻辑,可开箱即用快速集成实现标准聊天界面。本示例演示了如何快速创建一个具备以下功能的智能对话组件: + - 初始化预设消息 + - 预设消息内容渲染支持(markdown、搜索、思考、建议等) + - 与服务端的SSE(Server-Sent Events)通信,支持流式消息响应 + - 自定义流式内容结构解析 + - 自定义请求参数处理 + - 常用消息操作处理及回调(复制、重试、点赞/点踩) + - 支持手动触发填入prompt, 重新生成,发送消息等 + +{{ basic }} + + +### 组合式用法 +可以通过 `useChat` Hook提供的对话引擎实例及状态控制方法,同时自行组合拼装`ChatList`,`ChatMessage`, `ChatSender`等组件集成聊天界面,适合需要深度定制组件结构和消息处理流程的场景 +{{ hookComponent }} + +## 自定义 +如果组件内置的消息渲染方案不能满足需求,还可以通过自定义**消息结构解析逻辑**和**消息内容渲染组件**来实现更多渲染需求。以下示例给出了一个自定义实现图表渲染的示例,实现自定义渲染需要完成**四步**,概括起来就是:**扩展类型,准备组件,解析数据,植入插槽**: +- 1、扩展自定义消息体type类型 +- 2、实现自定义渲染的组件,示例中使用了tvision-charts-react实现图表渲染 +- 3、流式数据增量更新回调`onMessage`中可以对返回数据进行标准化解构,返回渲染组件所需的数据结构,同时可以通过返回`strategy`来决定**同类新增内容块**的追加策略(merge/append),如果需要更灵活影响到数据整合可以返回完整消息数组`AIMessageContent[]`,或者注册合并策略方法(参考下方‘任务规划’示例) +- 4、在render函数中遍历消息内容数组,植入自定义消息体渲染插槽,需保证slot名在list中的唯一性 + +如果组件内置的几种操作 `TdChatMessageActionName` 不能满足需求,示例中同时给出了**自定义消息操作区**的方法,可以自行实现更多操作。 + +{{ custom }} + + +## 场景化示例 +以下再通过几个常见的业务场景,展示下如何使用 `Chatbot` 组件 + +### 代码助手 +通过使用tdesign开发登录框组件的案例,演示了使用Chatbot搭建简单的代码助手场景,该示例你可以了解到如何按需开启**markdown渲染代码块**,如何**自定义实现代码预览** +{{ code }} + +### 文案助手 +以下案例演示了使用Chatbot搭建简单的文案写作助手应用,通过该示例你可以了解到如何**发送附件**,同时演示了**附件类型的内容渲染** +{{ docs }} + +### 图像生成 +以下案例演示了使用Chatbot搭建简单的图像生成应用,通过该示例你可以了解到如何**自定义输入框操作区域**,同时演示了**自定义生图内容渲染** +{{ image }} + +### 任务规划 +以下案例模拟了使用Chatbot搭建任务规划型智能体应用,分步骤依次执行并输出结果,通过该示例你可以了解到如何**注册自定义消息内容合并策略**,**自定义消息插槽名规则**,同时演示了**自定义任务流程渲染** +{{ agent }} + + +## API +### Chatbot Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +defaultMessages | Array | - | 初始消息数据列表。TS类型:`ChatMessagesData[]`。[详细类型定义](/react-chat/components/chat-message?tab=api) | N +messageProps | Object/Function | - | 消息项配置。按角色聚合了消息项的配置透传`ChatMessage`组件,TS类型:`TdChatMessageConfig \| ((msg: ChatMessagesData) => Omit)` ,[详细类型定义](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chatbot/type.ts#L151) | N +listProps | Object | - | 消息列表配置。TS类型:`TdChatListProps`。 | N +senderProps | Object | - | 发送框配置,透传`ChatSender`组件。TS类型:`TdChatSenderProps`。[类型定义](./chat-sender?tab=api) | N +chatServiceConfig | Object | - | 聊天服务配置,见下方详细说明,TS类型:`ChatServiceConfig` | N +onMessageChange | Function | - | 消息变化回调,TS类型:`(e: CustomEvent) => void` | N + + +### TdChatListProps 消息列表配置 + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +autoScroll | Boolean | true | 高度变化时列表是否自动滚动到底部 | N +defaultScrollPosition | String | bottom | 默认初始时滚动定位。可选项:top/bottom/bottom | N +onScroll | Function | - | 滚动事件回调 | N + + + +### ChatServiceConfig 类型说明 + +聊天服务核心配置类型,主要作用包括基础通信配置,请求流程控制及全生命周期管理(初始化→传输→完成/中止),流式数据的分块处理策略,状态通知回调等。 + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +endpoint | String | - | 聊天服务请求地址url | N +stream | Boolean | true | 是否使用流式传输 | N +onRequest | Function | - | 请求前的回调,可修改请求参数。TS类型:`(params: ChatRequestParams) => RequestInit` | N +onMessage | Function | - | 处理流式消息的回调。TS类型:`(chunk: SSEChunkData) => AIMessageContent / null` | N +onComplete | Function | - | 请求结束时的回调。TS类型:`(isAborted: boolean, params: RequestInit, result?: any) => void` | N +onAbort | Function | - | 中止请求时的回调。TS类型:`() => Promise` | N +onError | Function | - | 错误处理回调。TS类型:`(err: Error \| Response) => void` | N + +### Chatbot 实例方法 + +名称 | 类型 | 描述 +-- | -- | -- +sendUserMessage | (params: ChatRequestParams) => Promise | 发送用户消息,处理请求参数并触发消息流 +sendSystemMessage | (msg: string) => void | 发送系统级通知消息,用于展示系统提示/警告 +abortChat | () => Promise | 中止当前进行中的聊天请求,清理网络连接 +addPrompt | (prompt: string) => void | 将预设提示语添加到输入框,辅助用户快速输入 +selectFile | () => void | 触发文件选择对话框,用于附件上传功能 +regenerate | (keepVersion?: boolean) => Promise | 重新生成最后一条消息,可选保留历史版本 +registerMergeStrategy | (type: T['type'], handler: (chunk: T, existing?: T) => T) => void | 注册自定义消息合并策略,用于处理流式数据更新 +scrollToBottom | () => void | 将消息列表滚动到底部,适用于有新消息时自动定位 +chatMessageValue | ChatMessagesData[] | 获取当前消息列表的只读副本 +chatStatus | ChatStatus | 获取当前聊天状态(空闲/进行中/错误等) + +### useChat Hook + +useChat 是聊天组件核心逻辑Hook,用于管理聊天状态与生命周期:初始化聊天引擎、同步消息数据、订阅状态变更,并自动处理组件卸载时的资源清理,对外暴露聊天引擎实例/消息列表/状态等核心参数。 + +- **请求参数说明** + +参数名 | 类型 | 说明 +-- | -- | -- +defaultMessages | ChatMessagesData[] | 初始化消息列表,用于设置聊天记录的初始值 +chatServiceConfig | ChatServiceConfigSetter | 聊天服务配置,支持静态配置或动态生成配置的函数,用于设置API端点/重试策略等参数 + +- **返回值说明** + +返回值 | 类型 | 说明 +-- | -- | -- +chatEngine | ChatEngine 实例 | 聊天引擎实例,提供核心操作方法,同上方 `Chatbot 实例方法` +messages | ChatMessagesData[] | 当前聊天消息列表所有数据 +status | ChatStatus | 当前聊天状态 diff --git a/packages/pro-components/chat/chatbot/chatbot.md b/packages/pro-components/chat/chatbot/chatbot.md new file mode 100644 index 0000000000..0f1d8b14b7 --- /dev/null +++ b/packages/pro-components/chat/chatbot/chatbot.md @@ -0,0 +1,194 @@ +--- +title: Chatbot 智能对话 +description: 高度封装且功能完备的一体化智能对话组件,专为快速集成标准AI应用而设计。 +isComponent: true +spline: navigation +--- + +## 阅读指引 + +Chatbot 作为高度封装且功能完备的一体化智能对话组件,专为快速集成标准AI应用而设计。组件内置了完整的状态管理、SSE流式传输、消息渲染和交互逻辑,支持多种业务场景,包括智能客服、问答系统、代码助手、任务规划等。 + +本文档按照**从简单到复杂**的顺序组织,建议按以下路径循序渐进阅读: + +1. **快速开始** - 5分钟上手,了解最基本的配置 +2. **基础用法** - 了解常用功能特性,结合各项示例掌握数据处理、消息配置、自定义渲染等主要功能 +3. **场景示例** - 参考实战案例,快速应用到项目中 + + +> 💡 **示例说明**:所有示例都基于 Mock SSE 服务,您可以打开浏览器开发者工具(F12),切换到 Network(网络)标签,查看接口的请求和响应数据,了解数据格式。 + + +## 快速开始 + +最简单的 Chatbot 配置示例,只需配置 `endpoint` 和 `onMessage` 即可实现一个可用的对话界面。 + +{{ quick-start }} + +## 基础用法 + +### 初始化消息 + +使用 `defaultMessages` 设置静态初始化消息,或通过 `setMessages` 实例方法动态加载历史消息。 + +{{ initial-messages }} + +### 角色消息配置 + +使用 `messageProps` 配置不同角色的消息展示效果,这些配置会透传给内部的 [ChatMessage](/react-chat/components/chat-message) 组件。包括**消息样式**(气泡样式、位置、头像、昵称)、**操作回调**(复制、点赞、点踩、重试)、**内容展示**行为(思考过程、搜索结果、Markdown 等)。支持静态配置对象和动态配置函数两种方式。篇幅有限更多配置项和示例请参考 [ChatMessage 文档](/react-chat/components/chat-message)。 + +{{ role-message-config }} + +### 输入配置 + +使用 `senderProps` 配置输入框的各种行为,这些配置会透传给内部的 [ChatSender](/react-chat/components/chat-sender) 组件。包括基础配置(占位符、自动高度)、附件上传配置(文件类型、附件展示)、输入事件回调等。更多配置项和高级用法请参考 [ChatSender 文档](/react-chat/components/chat-sender)。 + +{{ sender-config }} + +### 数据处理 + +以上完成消息列表初始化并配置好消息气泡的展示形态后,接下来开始处理后端返回的数据格式。`chatServiceConfig` 是 Chatbot 的核心配置,控制着与后端的通信和数据处理,是连接前端组件和后端服务的桥梁。包括 **请求配置** (endpoint、onRequest设置请求头、请求参数)、**数据转换** (onMessage:将后端数据转换为组件所需格式)、**生命周期回调** (onStart、onComplete、onError、onAbort)。 + +根据后端服务协议的不同,有两种配置方式: + +**自定义协议**:当后端使用自定义数据格式时,往往不能按照前端组件的要求来输出,这时需要通过 `onMessage` 进行数据转换。 + +{{ service-config }} + +**AG-UI 协议**:当后端服务符合 [AG-UI 协议](/react-chat/agui) 时,只需设置 `protocol: 'agui'`,无需编写 `onMessage` 进行数据转换,大大简化了接入流程。这里只给出了一个简单的文本对话示例,更多复杂的AG-UI场景可以参考 [ChatEngine集成方式](/react-chat/components/chat-engine)。 + +{{ agui }} + +### 实例方法 + +通过 ref 获取组件实例,调用[各种方法](/react-chat/components/chatbot?tab=api#chatbot-实例方法和属性)控制组件行为(消息设置、发送管理、列表滚动等)。 + +{{ instance-methods }} + +### 自定义渲染 + +使用**动态插槽机制**实现自定义渲染,包括自定义`内容渲染`、自定义`操作栏`、自定义`输入区域`。 + +- **自定义内容渲染**:如果需要自定义消息内容的渲染方式,可以按照以下步骤实现: + - 1. 扩展类型:通过 TypeScript 声明自定义内容类型 + - 2. 解析数据:在 `onMessage` 中返回自定义类型的数据结构 + - 3. 监听变化:通过 `onMessageChange` 监听消息变化并同步到本地状态 + - 4. 植入插槽:循环 `messages` 数组,使用 `slot = ${msg.id}-${content.type}-${index}` 属性来渲染自定义组件 + +- **自定义操作栏**:如果组件库内置的 [`ChatActionbar`](/react-chat/components/chat-actionbar) 不能满足需求,可以通过 `slot = ${msg.id}-actionbar` 属性来渲染自定义组件。 + +- **自定义输入区域**:如果需要自定义ChatSender输入区,可以通过 `slot = sender-${slotName}` 属性,可用插槽slotName详见[ChatSender插槽](/react-chat/components/chat-sender?tab=api#插槽) + +{{ custom-content }} + + + + +## 场景示例 + +在了解了以上各个基础属性的用法后,这里给出一个完整的示例,展示如何在生产实践中综合使用多个功能:初始消息、消息配置、数据转换、请求配置、实例方法和自定义插槽。 + +### 基础问答 + +{{ comprehensive }} + +### 代码助手 + +构建代码助手应用,展示代码高亮、代码预览等功能。 + +{{ code }} + + + +### 图像生成 + +构建图像生成应用,展示自定义输入框操作区和图片渲染。 + +{{ image }} + +### 任务规划 + +构建任务规划智能体,展示复杂的自定义渲染和任务流程管理。 + +{{ agent }} + + +## API + +### Chatbot Props + +| 名称 | 类型 | 默认值 | 说明 | 必传 | +| ----------------- | --------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | +| defaultMessages | Array | - | 初始消息数据列表。TS 类型:`ChatMessagesData[]`。[详细类型定义](/react-chat/components/chat-message?tab=api) | N | +| messageProps | Object/Function | - | 消息项配置,透传给内部 [ChatMessage](/react-chat/components/chat-message) 组件。按角色聚合了消息项的配置,可以是静态配置对象或动态配置函数。TS 类型:`TdChatMessageConfig \| ((msg: ChatMessagesData) => TdChatMessageConfigItem)` ,[详细类型定义](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chatbot/type.ts) | N | +| listProps | Object | - | 消息列表配置,见下方详细说明。TS 类型:`TdChatListProps`。 | N | +| senderProps | Object | - | 发送框配置,透传给内部 [ChatSender](/react-chat/components/chat-sender) 组件。TS 类型:`TdChatSenderProps` [详细类型定义](/react-chat/components/chat-sender?tab=api) | N | +| chatServiceConfig | Object | - | 对话服务配置,包含网络请求和回调配置,见下方详细说明,TS 类型:`ChatServiceConfig`[详细类型定义](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chat-engine/type.ts]) | N | +| onMessageChange | Function | - | 消息列表数据变化回调,TS 类型:`(e: CustomEvent) => void` | N | +| onChatReady | Function | - | 内部对话引擎初始化完成回调,TS 类型:`(e: CustomEvent) => void` | N | +| onChatAfterSend | Function | - | 发送消息后的回调,TS 类型:`(e: CustomEvent) => void` | N | + +### TdChatListProps 消息列表配置 + +| 名称 | 类型 | 默认值 | 说明 | 必传 | +| ---------- | -------- | ------ | -------------------------------- | ---- | +| autoScroll | Boolean | true | 高度变化时列表是否自动滚动到底部 | N | +| onScroll | Function | - | 滚动事件回调 | N | + +### ChatServiceConfig 类型说明 + +对话服务核心配置类型,主要作用包括基础通信配置,请求流程控制及全生命周期管理(初始化 → 传输 → 完成/中止),流式数据的分块处理策略,状态通知回调等。 + +| 名称 | 类型 | 默认值 | 说明 | 必传 | +| ---------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---- | +| endpoint | String | - | 对话服务请求地址 url | N | +| protocol | String | 'default' | 对话服务协议,支持'default'和'agui' | N | +| stream | Boolean | true | 是否使用流式传输 | N | +| onStart | Function | - | 流式传输接收到第一个数据块时的回调。TS 类型:`(chunk: string) => void` | N | +| onRequest | Function | - | 请求发送前的配置回调,可修改请求参数、添加 headers 等。TS 类型:`(params: ChatRequestParams) => RequestInit` | N | +| onMessage | Function | - | 处理流式消息的回调,用于解析后端数据并映射为组件所需格式。TS 类型:`(chunk: SSEChunkData) => AIMessageContent \| AIMessageContent[] \| null` | N | +| onComplete | Function | - | 请求结束时的回调。TS 类型:`(isAborted: boolean, params?: ChatRequestParams) => AIMessageContent \| AIMessageContent[] \| null` | N | +| onAbort | Function | - | 中止请求时的回调。TS 类型:`() => Promise` | N | +| onError | Function | - | 错误处理回调。TS 类型:`(err: Error \| Response) => void` | N | + +### Chatbot 实例方法和属性 + +通过 ref 获取组件实例,调用以下方法。 + +| 名称 | 类型 | 描述 | +| --------------------- | --------------------------------------------------------------------------------- | -------------------------------------------- | +| setMessages | `(messages: ChatMessagesData[], mode?: 'replace' \| 'prepend' \| 'append') => void` | 批量设置消息 | +| sendUserMessage | `(params: ChatRequestParams) => Promise` | 发送用户消息,处理请求参数并触发消息流 | +| sendAIMessage | `{ params?: ChatRequestParams; content?: AIMessageContent[]; sendRequest?: boolean }` | 发送 AI 消息,处理请求参数并触发消息流 | +| sendSystemMessage | `(msg: string) => void` | 发送系统级通知消息,用于展示系统提示/警告 | +| abortChat | `() => Promise` | 中止当前进行中的对话请求,清理网络连接 | +| addPrompt | `(prompt: string) => void ` | 将预设提示语添加到输入框,辅助用户快速输入 | +| selectFile | `() => void` | 触发文件选择对话框,用于附件上传功能 | +| regenerate | `(keepVersion?: boolean) => Promise` | 重新生成最后一条消息,可选保留历史版本 | +| registerMergeStrategy |`(type: T['type'], handler: (chunk: T, existing?: T) => T) => void` | 注册自定义消息合并策略,用于处理流式数据更新 | +| scrollList | `({ to: 'bottom' \| 'top', behavior: 'auto' \| 'smooth' }) => void` | 受控滚动到指定位置 | +| isChatEngineReady | boolean | ChatEngine 是否就绪 | +| chatMessageValue | ChatMessagesData[] | 获取当前消息列表的只读副本 | +| chatStatus | ChatStatus | 获取当前对话状态(空闲/进行中/错误等) | +| senderLoading | boolean | 当前输入框按钮是否在'输出中' | diff --git a/packages/pro-components/chat/chatbot/core.zip b/packages/pro-components/chat/chatbot/core.zip new file mode 100644 index 0000000000..73424eaf1f Binary files /dev/null and b/packages/pro-components/chat/chatbot/core.zip differ diff --git a/packages/pro-components/chat/chatbot/index.ts b/packages/pro-components/chat/chatbot/index.ts new file mode 100644 index 0000000000..eca447e2ab --- /dev/null +++ b/packages/pro-components/chat/chatbot/index.ts @@ -0,0 +1,35 @@ +import 'tdesign-web-components/lib/chatbot'; +import 'tdesign-web-components/lib/chat-message/content/reasoning-content'; +import 'tdesign-web-components/lib/chat-message/content/search-content'; +import 'tdesign-web-components/lib/chat-message/content/suggestion-content'; +import type { + TdChatbotApi, + TdChatListApi, + TdChatListProps, + TdChatProps, + TdChatSearchContentProps, + TdChatSuggestionContentProps, +} from 'tdesign-web-components'; +import reactify from '../_util/reactify'; + +const ChatBot: React.ForwardRefExoticComponent< + Omit, 'ref'> & React.RefAttributes +> = reactify('t-chatbot'); + +const ChatSearchContent: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-chat-search-content'); + +const ChatSuggestionContent: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +> = reactify('t-chat-suggestion-content'); + +const ChatList: React.ForwardRefExoticComponent< + Omit, 'ref'> & React.RefAttributes +> = reactify('t-chat-list'); + +// 导出组件 +export { ChatBot, ChatSearchContent, ChatSuggestionContent, ChatList }; + +// 导出类型和工具 +export type * from 'tdesign-web-components/lib/chatbot/type'; diff --git a/packages/pro-components/chat/index.ts b/packages/pro-components/chat/index.ts new file mode 100644 index 0000000000..e40deea3bc --- /dev/null +++ b/packages/pro-components/chat/index.ts @@ -0,0 +1,12 @@ +export * from './chatbot'; +export * from './chat-engine'; +export * from './chat-actionbar'; +export * from './chat-attachments'; +export * from './chat-filecard'; +export * from './chat-loading'; +export * from './chat-markdown'; +export * from './chat-message'; +export * from './chat-sender'; +export * from './chat-thinking'; +export * from './chat-markdown'; + diff --git a/packages/pro-components/chat/package.json b/packages/pro-components/chat/package.json new file mode 100644 index 0000000000..ae38c1480f --- /dev/null +++ b/packages/pro-components/chat/package.json @@ -0,0 +1,7 @@ +{ + "name": "@tdesign/pro-components-chat", + "private": true, + "main": "index.ts", + "author": "tdesign", + "license": "MIT" +} \ No newline at end of file diff --git a/packages/pro-components/chat/style/index.js b/packages/pro-components/chat/style/index.js new file mode 100644 index 0000000000..3e9cfdf349 --- /dev/null +++ b/packages/pro-components/chat/style/index.js @@ -0,0 +1 @@ +import 'tdesign-web-components/lib/style/index.css'; diff --git a/packages/tdesign-react-aigc/CHANGELOG.md b/packages/tdesign-react-aigc/CHANGELOG.md new file mode 100644 index 0000000000..25155d2822 --- /dev/null +++ b/packages/tdesign-react-aigc/CHANGELOG.md @@ -0,0 +1,10 @@ +--- +title: 更新日志 +docClass: timeline +toc: false +spline: explain +--- + +## 🌈 0.1.0-alpha.1 `2025-05-26` + +- Release 1st version diff --git a/packages/tdesign-react-aigc/LICENSE b/packages/tdesign-react-aigc/LICENSE new file mode 100644 index 0000000000..e289dc9dad --- /dev/null +++ b/packages/tdesign-react-aigc/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025-present TDesign + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/tdesign-react-aigc/README.md b/packages/tdesign-react-aigc/README.md new file mode 100644 index 0000000000..afd71db10a --- /dev/null +++ b/packages/tdesign-react-aigc/README.md @@ -0,0 +1,82 @@ +

+ + TDesign Logo + +

+ +

+ + License + + + codecov + + + Version + + + Downloads + +

+ +TDesign AIGC Components for React Framework + +# 📦 Installation + +```shell +npm i @tdesign-react/chat +``` + +```shell +yarn add @tdesign-react/chat +``` + +```shell +pnpm add @tdesign-react/chat +``` + +# 🔨 Usage + +```tsx +import React from 'react'; +import { ChatBot } from '@tdesign-react/chat'; +import '@tdesign-react/chat/es/style/index.js'; + +function App() { + return ( +
+ +
+ ); +} + +ReactDOM.createRoot(document.getElementById('app')).render(); +``` + +# Browser Support + +| [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Edge >=84 | Firefox >=83 | Chrome >=84 | Safari >=14.1 | + +Read our [browser compatibility](https://github.com/Tencent/tdesign/wiki/Browser-Compatibility) for more details. + + +# Contributing + +Contributing is welcome. Read [guidelines for contributing](https://github.com/Tencent/tdesign-react/blob/develop/CONTRIBUTING.md) before submitting your [Pull Request](https://github.com/Tencent/tdesign-react/pulls). + + +# Feedback + +Create your [Github issues](https://github.com/Tencent/tdesign-react/issues) or scan the QR code below to join our user groups + + + +# License + +The MIT License. Please see [the license file](./LICENSE) for more information. diff --git a/packages/tdesign-react-aigc/package.json b/packages/tdesign-react-aigc/package.json new file mode 100644 index 0000000000..62d2ea08ed --- /dev/null +++ b/packages/tdesign-react-aigc/package.json @@ -0,0 +1,67 @@ +{ + "name": "@tdesign-react/chat", + "version": "1.0.0", + "title": "@tdesign-react/chat", + "description": "TDesign Pro Component for AIGC", + "module": "es/index.js", + "typings": "es/index.d.ts", + "files": [ + "es", + "LICENSE", + "README.md", + "CHANGELOG.md" + ], + "sideEffects": [ + "site/*", + "es/**/style/**" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "start": "pnpm dev", + "dev": "vite", + "prebuild": "rimraf es/*", + "build": "cross-env NODE_ENV=production rollup -c script/rollup.config.js && npm run build:tsc", + "build:tsc": "run-p build:tsc-*", + "build:tsc-es": "tsc --emitDeclarationOnly -d -p ./tsconfig.build.json --outDir es/", + "build:jsx-demo": "npm run generate:jsx-demo && npm run format:jsx-demo" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, + "lint-staged": { + "src/**/*.{ts,tsx}": [ + "prettier --write", + "npm run lint:fix" + ] + }, + "keywords": [ + "tdesign", + "react" + ], + "author": "tdesign", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13.1", + "react-dom": ">=16.13.1" + }, + "dependencies": { + "@babel/runtime": "~7.26.7", + "tdesign-web-components": "1.2.5", + "classnames": "~2.5.1", + "lodash-es": "^4.17.21", + "zod": "^3.24.2", + "cherry-markdown": "^0.10.0" + }, + "devDependencies": { + "cors": "^2.8.5", + "tdesign-icons-react": "0.5.0", + "tdesign-react": "^1.12.1", + "tvision-charts-react": "^3.3.12", + "express": "^4.17.3" + } +} diff --git a/packages/tdesign-react-aigc/site/.gitignore b/packages/tdesign-react-aigc/site/.gitignore new file mode 100644 index 0000000000..082d756e2a --- /dev/null +++ b/packages/tdesign-react-aigc/site/.gitignore @@ -0,0 +1,6 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +package-lock.json \ No newline at end of file diff --git a/packages/tdesign-react-aigc/site/README.md b/packages/tdesign-react-aigc/site/README.md new file mode 100644 index 0000000000..45cb785a4e --- /dev/null +++ b/packages/tdesign-react-aigc/site/README.md @@ -0,0 +1,4 @@ +# tdesign-react + +- 为开发者提供组件文档 +- 支持本地组件开发 diff --git a/packages/tdesign-react-aigc/site/babel.config.demo.js b/packages/tdesign-react-aigc/site/babel.config.demo.js new file mode 100644 index 0000000000..40fab8500e --- /dev/null +++ b/packages/tdesign-react-aigc/site/babel.config.demo.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['@babel/preset-typescript'], +}; diff --git a/packages/tdesign-react-aigc/site/babel.config.js b/packages/tdesign-react-aigc/site/babel.config.js new file mode 100644 index 0000000000..142fdca948 --- /dev/null +++ b/packages/tdesign-react-aigc/site/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], + plugins: ['@babel/plugin-transform-runtime'], +}; diff --git a/packages/tdesign-react-aigc/site/docs/agui.md b/packages/tdesign-react-aigc/site/docs/agui.md new file mode 100644 index 0000000000..2cb24fb78c --- /dev/null +++ b/packages/tdesign-react-aigc/site/docs/agui.md @@ -0,0 +1,148 @@ +--- +title: 与AG-UI协议集成 +order: 4 +group: + title: 快速上手 + order: 3 +--- + +## 什么是 AG-UI + +**AG-UI(Agent User Interaction Protocol)** 是专为 AI Agent 与前端应用交互设计的标准化协议。它建立了一套统一的[通信规范](https://docs.ag-ui.com/introduction),让前端界面能够与各种 AI 服务无缝对话,就像是 AI 应用的"通用语言"。 + +AG-UI 采用**事件驱动模型**,通过标准化的事件流实现前后端解耦: + +- **统一接口标准**:屏蔽不同 AI 供应商的差异,提供一致的交互体验 +- **实时流式通信**:支持 SSE/WebSocket 等方式的流式数据传输 +- **有状态会话管理**:维护完整的对话上下文和共享状态 +- **工具调用机制**:标准化的工具定义、调用和结果处理流程 + +## 为什么选择 AG-UI 协议? + +### 与传统对比 + +| 对比维度 | 传统自定义协议 | AG-UI 标准协议 | +| ---------------- | -------------------------------- | ------------------------------------- | +| **协议标准化** | 各家自定义格式,互不兼容 | 统一标准,跨服务兼容 | +| **事件模型** | 简单请求-响应,缺乏细粒度控制 | 16 种标准事件,支持复杂交互流程 | +| **流式传输** | 需自行设计流式协议和解析逻辑 | 标准化流式事件,开箱即用 | +| **状态管理** | 无标准状态同步机制 | Snapshot + Delta 标准模式 | +| **工具调用** | 各家格式不同,集成复杂 | 统一工具调用生命周期 | +| **迁移适配成本** | 每个 AI 服务需单独开发前端适配层 | 一套前端代码适配所有遵循 AG-UI 的服务 | + +### 核心价值 + +- **统一标准化**:通过统一的事件格式屏蔽底层 AI 服务的差异,一套前端代码可以处理所有遵循 AG-UI 协议的后端服务 +- **组件标准化**:接口协议一致性使得消息渲染、工具调用等核心功能可跨项目复用 +- **可扩展性**:标准化的扩展点,便于添加新功能 + +## 协议要点 + +### 事件机制 + +AG-UI 定义了[16 种标准事件类型](https://docs.ag-ui.com/concepts/events),覆盖 AI 交互的完整生命周期: + +| 事件分类 | 事件名 | 含义 | +| ---------------- | ---------------------------------------- | ----------------------------------------------------------- | +| **生命周期事件** | `RUN_STARTED` | 开始执行,可显示进度指示 | +| | `RUN_FINISHED` | 执行完成 | +| | `RUN_ERROR` | 执行错误,包含错误信息 | +| **思考过程事件** | `THINKING_START/END` | 新的思考过程开始、结束 | +| | `THINKING_TEXT_MESSAGE_START/CONTEN/END` | 思考过程文本内容(段)的过程起止,通过 CONTENT 事件增量传输 | +| **文本消息事件** | `TEXT_MESSAGE_START` | 开始新消息,建立 messageId | +| | `TEXT_MESSAGE_CONTENT` | 流式文本内容,通过 delta 增量传输 | +| | `TEXT_MESSAGE_END` | 消息结束,可触发后续操作 | +| **思考过程事件** | `THINKING_START` | 开始思考阶段 | +| | `THINKING_END` | 思考结束 | +| **工具调用事件** | `TOOL_CALL_START` | 开始调用工具,显示工具名称 | +| | `TOOL_CALL_ARGS` | 工具参数,支持流式传输 JSON 片段 | +| | `TOOL_CALL_END` | 工具调用完成 | +| | `TOOL_CALL_RESULT` | 工具执行结果 | +| **状态管理事件** | `STATE_SNAPSHOT` | 完整状态快照,用于初始化或同步 | +| | `STATE_DELTA` | 增量状态更新,基于 JSON Patch(RFC 6902) | +| | `MESSAGES_SNAPSHOT` | 消息历史快照 | + +以下是一段符合 AG-UI 协议的事件流响应示例: + +```js +data: {"type": "RUN_STARTED", "runId": "run_456"} + +data: {"type": "TEXT_MESSAGE_START", "messageId": "msg_789", "role": "assistant"} +data: {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_789", "delta": "我来帮您查询"} + +// 前端可以根据不同的toolCallName定义不同的工具组件来渲染 +data: {"type": "TOOL_CALL_START", "toolCallId": "tool_001", "toolCallName": "weather_query"} +data: {"type": "TOOL_CALL_ARGS", "toolCallId": "tool_001", "delta": "{\"city\":\"北京\"}"} +data: {"type": "TOOL_CALL_END", "toolCallId": "tool_001"} +data: {"type": "TOOL_CALL_RESULT", "toolCallId": "tool_001", "content": "北京今日晴,22°C"} + +data: {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_789", "delta": "北京的天气"} +data: {"type": "TEXT_MESSAGE_END", "messageId": "msg_789"} + +data: {"type": "RUN_FINISHED", "runId": "run_456"} + +``` + +### 交互流程 + +AG-UI 基于事件驱动架构,实现前后端的实时双向通信: + +#### **基础交互流程** + +1. **前端请求**:用户输入 → 封装为`RunAgentInput结构` → 发送到服务端点 +2. **后端处理**:解析请求 → 启动 AI 代理 → 通过 SSE 发送事件流 +3. **前端响应**:接收事件 → 实时更新界面 → 展示处理过程 +4. **Human-in-Loop 流程**:AG-UI 支持人机协作的交互模式,允许在 AI 处理过程中插入人工干预 + +#### **核心特性** + +- **状态共享**:通过`STATE_SNAPSHOT`和`STATE_DELTA`事件实现前后端状态同步 +- **工具双向调用**:前端定义工具,Agent 通过`TOOL_CALL_*`事件主动调用 +- **实时流式传输**:支持文本、思考过程、工具调用的流式展示 +- **上下文维护**:threadId/runId/stepId 体系维护完整的对话上下文 + +这些能力构成了 AG-UI 成为生产级 Agent 应用的关键基础。 + +## TDesign Chat 集成方式 + +### 基础配置 + +只需要简单配置`protocol: 'agui'`即可开启 TDesign Chat UI 与 AG-UI 协议的无缝对接,内置了对话生命周期`RUN_*`、思考过程事件`THINKING_*`、文本事件`TEXT_MESSAGE_*`和常见`TOOL_CALL_*`事件比如 search 搜索等的渲染支持, 也提供了标准消息结构的转换方法`AGUIAdapter.convertHistoryMessages`用于回填历史消息。 + +```javascript +import { ChatBot, AGUIAdapter } from '@tdesign-react/chat'; + +export default function AguiChat() { + const chatServiceConfig = { + endpoint: '/api/agui/chat', + protocol: 'agui', // 启用AG-UI协议 + stream: true, + // 可选:自定义事件处理 + onMessage: (chunk) => { + // 返回null使用内置AG-UI解析 + // 返回自定义内容覆盖内置解析 + return null; + }, + }; + + return ; +} +``` + +### 高级功能 + +TDesign Chat 为 AG-UI 协议提供了两个专用 Hook: + +- **`useAgentToolcall`**:用于注册和管理工具调用组件,当 Agent 发送`TOOL_CALL_*`事件时自动渲染对应的工具组件 +- **`useAgentState`**:用于订阅 AG-UI 协议的状态事件,支持`STATE_SNAPSHOT`和`STATE_DELTA`事件的自动处理和状态同步 + +详细的使用方法请参考[ChatEngine 工具调用](/react-chat/components/chat-engine#工具调用)。 + +## 总结 + +AG-UI 协议为 AI 应用开发提供了完整的标准化解决方案,通过采用 AG-UI 协议,TDesign Chat 为开发者提供了构建专业级 AI 交互应用的完整工具链,让 AI 功能集成变得简单、高效、可维护。 + +## 相关资源 + +- [AG-UI 官方文档](https://docs.ag-ui.com/) +- [TDesign ChatEngine 组件文档](/react-chat/components/chat-engine) diff --git a/packages/tdesign-react-aigc/site/docs/features.md b/packages/tdesign-react-aigc/site/docs/features.md new file mode 100644 index 0000000000..889db82343 --- /dev/null +++ b/packages/tdesign-react-aigc/site/docs/features.md @@ -0,0 +1,544 @@ +--- +title: 核心功能特性 +order: 2 +group: + title: 概述 + order: 1 +description: TDesign Chat 智能对话组件库核心功能特性详解 +spline: ai +--- + +# TDesign Chat 核心功能特性 + +## 引言 + +TDesign Chat 是专为 AI 应用场景设计的智能对话组件库。此次更新涵盖多个平台:React 版本正式发布,Vue3 版本完成重大升级,微信小程序版本首次推出。组件库支持桌面端和移动端场景,可满足从快速原型开发到复杂企业级 AI 应用的不同需求。 + +## 桌面端:功能完备的 AI 应用开发方案 + +### 核心功能特性升级 + +TDesign Chat Web 端完成了架构层面的全面重构。相比之前仅支持 Vue 框架的原子化 UI 渲染组件,新版本在以下方面实现了显著提升: + +**架构层面:** +- 基于 Web Components 标准重构底层 UI 层,实现对 React、Vue 等主流框架的统一支持 +- 抽象出独立的 ChatEngine 对话引擎,实现业务逻辑与 UI 框架解耦,提升代码复用性 +- 集成完整对话逻辑,适配业界 AG-UI 协议标准 + +**使用模式:** +- 保留原子化组件的灵活性,新增高度封装的 ChatBot 一体化组件 +- 提供标准集成模式(快速接入)和组合式模式(深度定制)两种开发方式 +- 简化接入流程,从手动组装多个原子组件优化为三行代码即可完成基础集成 + +新版本通过以下四个方面的核心特性,为开发者提供从快速集成到深度定制的完整能力支持: + +## 开箱即用的 ChatBot 组件 + +TDesign Chat 提供了高度封装的 `ChatBot` 一体化组件,让您无需关注复杂的实现细节,即可快速构建专业级AI聊天应用。 + +### 零配置快速启动 + +只需三行核心代码,即可拥有功能完整的智能对话界面: + +```jsx +import { ChatBot } from '@tdesign-react/chat'; + +const chatServiceConfig = { + endpoint: 'https://your-ai-service.com/chat', + stream: true, +}; + +export default () => ; +``` + +**开箱即得的完整能力:** + +- ✅ **消息收发管理**:自动处理用户输入、消息发送、AI响应接收的完整流程 +- ✅ **流式打字效果**:内置SSE流式传输支持,实现逐字显示的流畅体验 +- ✅ **智能状态控制**:自动管理加载、流式传输、错误等各种状态 +- ✅ **消息历史管理**:支持消息历史记录、回填、持久化 +- ✅ **自动滚动定位**:智能滚动到最新消息,优化长对话阅读体验 +- ✅ **附件上传支持**:内置文件、图片等附件上传和预览能力 +- ✅ **响应式布局**:自适应各种屏幕尺寸,移动端友好 + +### 丰富的内置消息类型 + +ChatBot 内置了多种常见的AI交互消息类型,无需额外开发即可使用: + +| 消息类型 | 说明 | 适用场景 | +|---------|------|---------| +| **Markdown文本** | 支持富文本、代码高亮、表格等 | 通用文本回复 | +| **思考过程** | 展示AI推理过程 | 增强可解释性 | +| **建议问题** | 推荐相关问题引导对话 | 提升用户体验 | +| **搜索内容** | 展示搜索结果和引用来源 | RAG检索增强 | +| **附件消息** | 文件、图片等附件展示 | 多模态交互 | +| **工具调用** | 展示工具调用过程和结果 | Agent工具链 | + +### 极简集成体验 + +相比传统方案需要自行实现消息管理、UI渲染、状态控制等复杂逻辑,ChatBot 将这些能力全部内置,让您专注于业务逻辑本身: + +```jsx +// 传统方案:需要自行管理状态、消息、UI等 +const [messages, setMessages] = useState([]); +const [loading, setLoading] = useState(false); +const handleSend = async (text) => { + setLoading(true); + // 手动处理消息添加、请求发送、响应解析... +}; +// 还需要实现消息列表、输入框、加载状态等UI组件... + +// TDesign Chat:一行代码搞定 + +``` + +--- + +## 高度可定制化能力 + +在提供开箱即用便利性的同时,TDesign Chat 也为深度定制场景提供了完整的扩展能力。 + +### 丰富的插槽机制 + +ChatBot 预留了多个关键位置的插槽,支持灵活注入自定义内容: + +```jsx +} + // 消息插槽:自定义消息渲染 + messageSlot={(message) => } + // 输入框插槽:扩展输入能力 + senderSlot={} + // 底部插槽:添加免责声明、反馈等 + footerSlot={} +/> +``` + +**核心插槽位置:** + +- **headerSlot**:聊天界面顶部,适合放置标题、清空对话、设置等功能 +- **messageSlot**:消息渲染区域,可完全自定义消息展示样式和交互 +- **senderSlot**:输入发送区域,可扩展语音输入、快捷指令等能力 +- **footerSlot**:界面底部,适合放置免责声明、反馈入口等信息 +- **emptySlot**:空状态展示,可自定义欢迎语、引导信息等 + +### 组合式开发模式(React 框架内) + +当需要在 React 项目中自定义 UI 布局结构时,可以使用组合式开发模式。通过 `useChat` Hook 获取对话引擎实例和实时消息数据,自由组合内置的独立 UI 组件或完全自定义渲染: + +```jsx +import { useChat, ChatList, ChatMessage, ChatSender } from '@tdesign-react/chat'; + +function CustomChatUI() { + // useChat Hook 返回对话引擎实例和响应式消息数据 + const { chatEngine, messages, status } = useChat({ + chatServiceConfig: { endpoint: '/api/chat', stream: true }, + }); + + return ( +
+ {/* 自定义头部 */} + chatEngine.clearMessages()} /> + + {/* 消息列表 - 可使用内置组件或完全自定义 */} + + {messages.map((msg) => ( + + ))} + + + {/* 自定义侧边栏 */} + + + {/* 输入区域 */} + chatEngine.sendUserMessage({ + params: { prompt: e.detail.value } + })} + /> +
+ ); +} +``` + +**适用场景:** +- 在 React 项目中需要自定义整体布局结构 +- 需要在聊天界面中集成其他业务组件(如侧边栏、工具栏等) +- 希望复用内置 UI 组件但调整组合方式 + +### ChatEngine SDK 逻辑层复用(跨框架场景) + +`ChatEngine` 是独立于 UI 框架的纯 JavaScript 对话引擎,封装了完整的对话管理逻辑。它可以脱离 React 组件独立使用,适合跨框架集成或完全自定义 UI 的场景: + +```jsx +import { ChatEngine } from '@tdesign-react/chat'; + +// 创建对话引擎实例 +const chatEngine = new ChatEngine({ + endpoint: '/api/chat', + stream: true, + protocol: 'agui', +}); + +// 监听消息更新 +chatEngine.on('messagesUpdate', (messages) => { + console.log('消息更新:', messages); + // 使用任意UI框架渲染消息 +}); + +// 发送消息 +await chatEngine.sendUserMessage({ + params: { prompt: '你好' }, + content: [{ type: 'text', data: '你好' }], +}); + +// 中止对话 +chatEngine.abortChat(); + +// 清空消息 +chatEngine.clearMessages(); +``` + +**ChatEngine 核心能力:** + +- **消息状态管理**:自动维护消息列表、状态变更 +- **流式数据处理**:内置 SSE/Fetch 流式解析 +- **协议适配转换**:支持自定义协议和 AG-UI 标准协议 +- **事件驱动架构**:通过事件监听实现 UI 与逻辑解耦 +- **请求生命周期管理**:自动处理请求、重试、中止等 + +**适用场景:** +- 在 Vue、Angular 等非 React 框架中复用对话逻辑 +- 使用其他 UI 库(如 Ant Design、Element Plus)实现完全自定义的界面 +- 将对话能力集成到现有复杂业务系统中 +- 在 Node.js 服务端或 Electron 等环境中使用对话能力 + +--- + +### 三种定制模式对比 + +| 定制模式 | 使用方式 | 框架依赖 | 定制程度 | 适用场景 | +|---------|---------|---------|---------|---------| +| **插槽定制** | ChatBot + 插槽 | React | 低 | 在标准界面基础上局部定制 | +| **组合式开发** | useChat + 组件组合 | React | 中 | React 项目中自定义布局结构 | +| **ChatEngine SDK** | 纯 JS 引擎 | 无 | 高 | 跨框架、完全自定义 UI、服务端 | + +--- + +## 基于 Cherry Markdown 渲染引擎 + +TDesign Chat 内置集成了腾讯开源的 [Cherry Markdown](https://github.com/Tencent/cherry-markdown) 作为核心渲染引擎,为AI生成内容提供强大的富文本展示能力。 + +### 为什么选择 Cherry Markdown + +Cherry Markdown 是专为现代Web应用设计的高性能Markdown编辑器和渲染引擎,具有以下优势: + +- **🚀 高性能渲染**:采用增量渲染机制,即使长文本也能流畅展示 +- **🎨 丰富语法支持**:支持GFM标准、数学公式、流程图、代码高亮等 +- **🔧 高度可扩展**:提供完整的插件机制,支持自定义语法 +- **📱 移动端友好**:响应式设计,触摸操作优化 +- **🎯 专为中文优化**:更好的中文排版和字体渲染 + +### 开箱即用的强大能力 + +无需任何配置,即可享受Cherry Markdown带来的丰富渲染能力: + +```jsx +import { ChatMarkdown } from '@tdesign-react/chat'; + +// 自动渲染各种复杂内容 + +``` + +### 灵活的主题定制 + +支持通过配置项自定义渲染样式和主题: + +```jsx + +``` + +### 自定义语法扩展 + +基于 Cherry 的插件机制,可以轻松扩展自定义语法: + +```jsx +import { ChatMarkdown, MarkdownEngine } from '@tdesign-react/chat'; + +// 自定义脚注语法:[ref:1|标题|摘要|链接] +const footnoteHook = MarkdownEngine.createSyntaxHook( + 'footnote', + MarkdownEngine.constants.HOOKS_TYPE_LIST.SEN, + { + makeHtml(str) { + return str.replace( + /\[ref:(\d+)\|([^|]+)\|([^|]+)\|([^\]]+)\]/g, + (match, id, title, summary, link) => ` +
+ [${id}] + ${title} +

${summary}

+
+ ` + ); + }, + } +); + +// 使用自定义语法 + +``` + +### 按需加载插件 + +为了优化打包体积,组件默认只加载核心插件。可以按需引入额外能力: + +```jsx +// 引入数学公式支持 +import 'katex/dist/katex.min.css'; +import 'katex/dist/katex.min.js'; + +// 引入流程图支持 +import 'mermaid/dist/mermaid.min.js'; + + +``` + +--- + +## 适配业界通用 AG-UI 协议 + +TDesign Chat 内置了对 **AG-UI(Agent User Interaction Protocol)** 标准协议的完整支持,让您的AI应用能够无缝对接符合该协议的各类Agent服务。 + +### 什么是 AG-UI 协议 + +AG-UI 是专为AI Agent与前端应用交互设计的标准化协议,定义了统一的事件流规范,覆盖对话生命周期、消息传输、工具调用、状态同步等完整交互流程。 + +**核心优势:** + +- **统一标准**:一套前端代码适配所有遵循AG-UI的后端服务 +- **功能完整**:支持流式传输、工具调用、状态管理等高级能力 +- **易于扩展**:标准化的扩展点,便于添加新功能 +- **生产就绪**:经过大规模实践验证的成熟协议 + +### 一键启用 AG-UI 支持 + +只需简单配置 `protocol: 'agui'`,即可开启AG-UI协议支持: + +```jsx +import { ChatBot } from '@tdesign-react/chat'; + +export default () => ( + +); +``` + +组件会自动处理AG-UI协议定义的16种标准事件类型: + +| 事件类型 | 自动处理能力 | +|---------|-------------| +| **RUN_STARTED/FINISHED/ERROR** | 对话生命周期管理,自动显示加载状态 | +| **TEXT_MESSAGE_START/CONTENT/END** | 流式文本消息解析和渲染 | +| **THINKING_START/END** | 思考过程展示,增强AI可解释性 | +| **TOOL_CALL_START/ARGS/END/RESULT** | 工具调用过程可视化 | +| **STATE_SNAPSHOT/DELTA** | 前后端状态自动同步 | +| **MESSAGES_SNAPSHOT** | 消息历史自动回填 | + +### 标准化的工具调用 + +AG-UI协议定义了完整的工具调用生命周期,TDesign Chat 提供了专用Hook来注册和管理工具组件: + +```jsx +import { ChatBot, useAgentToolcall } from '@tdesign-react/chat'; + +function ChatWithTools() { + // 注册工具渲染组件 + useAgentToolcall('weather_query', ({ toolCall }) => ( +
+

🌤️ 天气查询

+

城市:{toolCall.args.city}

+ {toolCall.result &&

结果:{toolCall.result}

} +
+ )); + + useAgentToolcall('database_search', ({ toolCall }) => ( +
+

🔍 数据库搜索

+

查询:{toolCall.args.query}

+ {toolCall.status === 'running' && } + {toolCall.result && } +
+ )); + + return ; +} +``` + +当后端Agent发送工具调用事件时,组件会自动匹配并渲染对应的工具组件: + +```js +// 后端发送的AG-UI事件流 +data: {"type": "TOOL_CALL_START", "toolCallId": "tool_001", "toolCallName": "weather_query"} +data: {"type": "TOOL_CALL_ARGS", "toolCallId": "tool_001", "delta": "{\"city\":\"北京\"}"} +data: {"type": "TOOL_CALL_END", "toolCallId": "tool_001"} +data: {"type": "TOOL_CALL_RESULT", "toolCallId": "tool_001", "content": "北京今日晴,22°C"} +``` + +### 状态同步机制 + +AG-UI协议支持前后端状态共享,TDesign Chat 提供了 `useAgentState` Hook来订阅和管理状态: + +```jsx +import { ChatBot, useAgentState } from '@tdesign-react/chat'; + +function ChatWithState() { + // 订阅AG-UI状态事件 + const { state, updateState } = useAgentState(); + + return ( +
+ {/* 显示Agent共享的状态 */} +
+ 当前任务进度:{state.progress}% + 处理状态:{state.status} +
+ + +
+ ); +} +``` + +后端通过 `STATE_SNAPSHOT` 和 `STATE_DELTA` 事件推送状态更新: + +```js +// 完整状态快照 +data: {"type": "STATE_SNAPSHOT", "state": {"progress": 0, "status": "started"}} + +// 增量状态更新(基于JSON Patch RFC 6902) +data: {"type": "STATE_DELTA", "delta": [ + {"op": "replace", "path": "/progress", "value": 50} +]} +``` + +### 历史消息回填 + +AG-UI协议支持通过 `MESSAGES_SNAPSHOT` 事件回填历史消息,组件提供了转换工具: + +```jsx +import { ChatBot, AGUIAdapter } from '@tdesign-react/chat'; + +// 从后端获取的AG-UI格式历史消息 +const aguiHistory = [ + { role: 'user', content: [{ type: 'text', text: '你好' }] }, + { role: 'assistant', content: [{ type: 'text', text: '您好!' }] }, +]; + +// 转换为组件所需格式 +const messages = AGUIAdapter.convertHistoryMessages(aguiHistory); + + +``` + +### 协议扩展与自定义 + +即使使用AG-UI协议,仍然可以通过 `onMessage` 回调自定义事件处理: + +```jsx + { + // 自定义处理特定事件 + if (chunk.type === 'CUSTOM_EVENT') { + return { + type: 'custom', + data: processCustomEvent(chunk), + }; + } + // 返回null使用内置AG-UI解析 + return null; + }, + }} +/> +``` + +--- + +## 总结 + +TDesign Chat 通过四大核心特性,为AI应用开发提供了完整的解决方案: + +1. **开箱即用的 ChatBot**:零配置快速启动,内置完整功能,极简集成体验 +2. **高度可定制化**:丰富插槽、组合式开发、ChatEngine SDK逻辑复用,满足各种定制需求 +3. **Cherry Markdown 引擎**:强大的富文本渲染能力,支持代码、公式、图表等复杂内容 +4. **AG-UI 协议支持**:无缝对接标准化Agent服务,内置工具调用、状态管理等高级能力 + +无论您是要快速搭建原型,还是构建复杂的企业级AI应用,TDesign Chat 都能提供合适的解决方案。 + +立即开始您的AI聊天应用开发之旅!查看[快速上手指南](/react-aigc/docs/getting-started)了解更多。 diff --git a/packages/tdesign-react-aigc/site/docs/getting-started.md b/packages/tdesign-react-aigc/site/docs/getting-started.md new file mode 100644 index 0000000000..ec19710b04 --- /dev/null +++ b/packages/tdesign-react-aigc/site/docs/getting-started.md @@ -0,0 +1,225 @@ +--- +title: 快速上手 +order: 2 +description: TDesign Chat 智能对话组件库快速上手指南 +spline: ai +--- + +## 安装 + +### 环境要求 + +- React >= 18.0.0 +- TypeScript >= 4.0 (可选,但推荐) + +### 使用 npm 安装 + +```bash +npm install @tdesign-react/chat +``` + +### 使用 yarn 安装 + +```bash +yarn add @tdesign-react/chat +``` + +### 使用 pnpm 安装 + +```bash +pnpm add @tdesign-react/chat +``` + +## 接入使用 + +TDesign Chat 提供了两种主要的使用方式,适用于不同的业务场景和定制需求。 + +### 选型指南 + +建议根据具体业务场景选择合适的集成方案: + +| 使用方式 | 适用场景 | 定制程度 | 开发复杂度 | +| -------------- | ---------------------- | -------- | ---------- | +| **一体化组件** | 快速集成、标准聊天界面 | 中等 | 低 | +| **组合式开发** | 深度定制、复杂交互逻辑 | 高 | 中等 | + +### 用法一:一体化组件集成 + +直接使用 `ChatBot` 组件,内置完整的 UI 结构和交互逻辑,适合快速集成标准聊天界面的场景,参考[ChatBot 用法](/react-chat/components/chatbot)。 + +#### 最简示例 + +```js +import React from 'react'; +import { ChatBot } from '@tdesign-react/chat'; +import '@tdesign-react/chat/es/style/index.js'; // 少量公共样式 + +export default function () { + // 聊天服务配置 + const chatServiceConfig = { + // 对话服务地址 + endpoint: `https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal`, + // 开启流式对话传输 + stream: true, + // 自定义流式数据结构解析 + onMessage: (chunk) => chunk.data, + }; + + return ; +} +``` + +就是这么简单!三行配置你已经拥有了一个功能完整的聊天界面,包括: + +- 消息发送和接收 +- 标准问答聊天界面 +- 消息加载状态展示 +- 流式消息解析与渲染,内置强大 markdown 语法解析 +- 自动滚动 +- 发送控制 + +### 用法二:组合式开发 + +通过使用 `useChat` Hook 来获取 chatEngine 对话引擎实例和实时消息数据,自由组合独立的 UI 组件(`ChatList`、`ChatMessage`、`ChatSender`),或者可以完全自己实现 UI 部分,适合需要深度定制 UI 结构和交互逻辑的场景,参考[ChatEngine SDK 用法](/react-chat/components/chat-engine)。 + +```js +import React, { useState } from 'react'; +import { Space } from 'tdesign-react'; +import { useChat, ChatList, ChatMessage, ChatSender } from '@tdesign-react/chat'; +import '@tdesign-react/chat/es/style/index.js'; // 少量公共样式 + +export default function CompositeChat() { + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal', + stream: true, + onMessage: (chunk) => chunk.data, + }, + }); + + const sendMessage = async (params) => { + await chatEngine.sendUserMessage(params); + }; + + const stopHandler = () => { + chatEngine.abortChat(); + }; + + return ( + + + {messages.map((message) => ( + + ))} + + sendMessage({ prompt: e.detail.value })} + onStop={stopHandler} + /> + + ); +} +``` + +- 组合式开发的优势 + + - **高度灵活**:完全控制 UI 结构和样式 + - **逻辑分离**:业务逻辑与 UI 渲染分离 + - **渐进增强**:可以逐步添加功能 + - **复用性强**:组件可在不同场景复用 + +## 配置服务 + +TDesign Chat 支持两种后端 AI Agent 服务返回数据协议模式:**自定义协议**和**AG-UI 标准协议**。您可以根据后端服务的实际情况选择合适的协议模式。 + +### 自定义协议模式 + +适用于已有后端服务或需要自定义数据结构的场景,您的后端服务只需要返回标准 SSE 格式即可。 + +```js +// 自定义后端接口(/api/chat)返回案例 +data: {"type": "think", "content": "正在分析您的问题..."} +data: {"type": "text", "content": "我是**腾讯云**助手"} +data: {"type": "text", "content": "很高兴为您服务!"} +``` + +接下来,前端通过配置 `onMessage` 回调来解析流式数据, 将自定义数据映射为组件所需格式。 + +```javascript +const chatServiceConfig = { + endpoint: '/api/chat', + onMessage: (chunk) => { + const { type, content } = chunk.data; + switch (type) { + case 'text': + return { + type: 'markdown', + data: content, + }; + case 'think': + return { + type: 'thinking', + data: { + title: '思考中...', + text: content, + }, + }; + default: + return null; + } + }, +}; +``` + +### AG-UI 标准协议 + +**AG-UI 协议**是专为 AI 代理与前端应用交互设计的标准化轻量级协议,内置支持工具调用、状态管理、多步骤任务等高级功能。AG-UI 协议支持 16 种标准化事件类型,组件会自动解析并渲染,包括对话生命周期`RUN_*`、文本消息`TEXT_MESSAGE_*`、思考过程`THINKING_*`、工具调用`TOOL_CALL_*`、状态更新`STATE_*`等。 + +TDesign Chat 内置支持**AG-UI 协议数据双向转换**,符合该协议的后端 Agent 服务,可以无缝接入使用,只需在配置中开启即可。详细介绍见[与 AG-UI 协议集成](/react-chat/agui) + +```js +// 符合AG-UI协议的后端接口(/api/agui/chat)返回案例 +data: {"type": "RUN_STARTED", "runId": "run_456"} +data: {"type": "TEXT_MESSAGE_CONTENT", "delta": "正在处理您的请求..."} +data: {"type": "TOOL_CALL_START", "toolCallName": "search"} +data: {"type": "TOOL_CALL_RESULT", "content": "查询结果"} +data: {"type": "RUN_FINISHED", "runId": "run_456"} +``` + +```javascript +const chatServiceConfig = { + endpoint: '/api/agui/chat', + protocol: 'agui', // 启用AG-UI协议 + stream: true, +}; +``` + +### 协议选择建议 + +| 场景 | 推荐协议 | 理由 | +| ------------------ | ---------- | --------------------------------- | +| 快速集成到现有服务 | 自定义协议 | 灵活适配现有数据结构 | +| 构建复杂 AI 应用 | AG-UI 协议 | 业界标准、功能完整、扩展性强 | +| 多工具调用场景 | AG-UI 协议 | 内置工具注册、调用及状态管理 Hook | +| 简单问答场景 | 自定义协议 | 配置简单、开发快速 | + +更多详细配置和示例请参考[组件文档](/react-chat/components/chatbot)。 + +## 下一步 + +恭喜!你已经掌握了 TDesign Chat 的基本用法。接下来可以: + +- 查看完整 API 文档了解更多配置选项 +- 探索更多示例和最佳实践 +- 了解高级定制和扩展功能 +- 加入社区讨论获取帮助 + +## 浏览器兼容性 + +| IE / Edge | Firefox | Chrome | Safari | +| --------- | ------------ | ----------- | ------------- | +| Edge >=84 | Firefox >=83 | Chrome >=84 | Safari >=14.1 | + +详情参见[桌面端组件库浏览器兼容性说明](https://github.com/Tencent/tdesign/wiki/%E6%A1%8C%E9%9D%A2%E7%AB%AF%E7%BB%84%E4%BB%B6%E5%BA%93%E6%B5%8F%E8%A7%88%E5%99%A8%E5%85%BC%E5%AE%B9%E6%80%A7%E8%AF%B4%E6%98%8E) diff --git a/packages/tdesign-react-aigc/site/docs/intro.md b/packages/tdesign-react-aigc/site/docs/intro.md new file mode 100644 index 0000000000..8af36a0c83 --- /dev/null +++ b/packages/tdesign-react-aigc/site/docs/intro.md @@ -0,0 +1,59 @@ +--- +title: 概述 +order: 3 +group: + title: 概述 + order: 1 +description: TDesign Chat 智能对话组件库,为AI应用提供专业级聊天界面解决方案 +spline: ai +--- + +## 什么是 TDesign Chat 智能对话 + +TDesign Chat 是腾讯 TDesign 团队推出的专业级智能对话组件库,专为AI应用场景设计。它提供了完整的聊天界面解决方案,让开发者能够快速构建智能客服、问答系统、代码助手、任务规划等AI聊天应用。 + +## 核心优势 + +### 📦 开箱即用,封装度高 + - **零配置启动**:无需复杂配置,只需简单几行代码即可拥有功能完整的专业级聊天界面 + - **核心功能完备**:内置消息发送接收控制、流式展示、自动滚动、消息状态处理等核心功能 + - **组件丰富**:内置支持多种消息渲染组件,包括Markdown文本、思考过程、建议问题、附件等 + - **智能交互体验**:自动处理用户输入、消息排版、界面响应等交互细节 + - **即插即用组件**:提供完整的组件生态,继承TDesign设计风格,支持快速集成到现有项目中 + + +### 🔄 双模式传输支持 + - **SSE流式传输**:支持Server-Sent Events实时消息流,提供打字机效果的流畅体验 + - **传统HTTP模式**:兼容标准HTTP请求响应,适配现有后端架构 + + +### 🔌 **协议灵活适配** + - **自定义协议**:支持服务端自定义数据结构,灵活适配各种后端实现 + - **AG-UI协议**:内置对业界标准 `Agent User Interaction(AG-UI)` 协议的完整支持 + + +### 🛠️ 高度可定制化 + - **数据解析结构**:完全自定义消息数据的解析和处理逻辑 + - **UI渲染组件**:支持自定义消息渲染组件和样式 + - **插槽机制**:预留多个插槽位置,实现灵活的内容渲染扩展 + + +## 接入案例 + +### 知识问答助手 + +构建企业内部知识库问答系统,帮助员工快速获取公司政策、产品信息、技术文档等知识内容。 +(待补充截图) + +### 任务规划工具 + +构建智能任务规划和项目管理助手,帮助用户制定计划、分解任务、跟踪进度。 +(待补充截图) + +更多的实用案例在[组件介绍](/react-aigc/components/chatbot#场景化示例)部分提供。 + +## 开始使用 + +TDesign Chat 让AI聊天应用的开发变得简单高效。无论您是要快速搭建原型,还是构建复杂的企业级应用,都能找到合适的解决方案。 + +立即开始您的AI聊天应用开发之旅! \ No newline at end of file diff --git a/packages/tdesign-react-aigc/site/docs/sse.md b/packages/tdesign-react-aigc/site/docs/sse.md new file mode 100644 index 0000000000..7b8406d7f9 --- /dev/null +++ b/packages/tdesign-react-aigc/site/docs/sse.md @@ -0,0 +1,134 @@ +--- +title: 什么是流式输出 +order: 3 +group: + title: 快速上手 + order: 2 +--- + +## 简述 + +流式输出,也称为流式传输,指的是服务器持续地将数据推送到客户端,而不是一次性发送完毕。这种模式下,连接一旦建立,服务器就能实时地发送更新给客户端。 + +### 使用场景 + +流式输出的典型应用场景包括实时消息推送、股票行情更新、实时通知等,任何需要服务器向客户端实时传输数据的场合都可以使用。 + +### 与普通请求的区别 + +与传统的 HTTP 请求不同,普通请求是基于请求-响应模型,客户端发送请求后,服务器处理完毕即刻响应并关闭连接。流式输出则保持连接开放,允许服务器连续发送多个响应。 + +## 如何创建一个 SSE + +### Python + +在 Python 中,可以使用 fastAPI 框架来实现 Server-Sent Events。以下是一个示例: + +1. 安装 FastAPI 和 Uvicorn + 首先,确保你已经安装了 FastAPI 和 Uvicorn : + +``` +pip install fastapi uvicorn +``` + +2. 创建 FastAPI 应用 + 接下来,创建一个 FastAPI 应用,并定义一个流式接口。我们将使用异步生成器来逐步生成数据,并使用 StreamingResponse 来流式发送数据给客户端。 + +```js +import json +import asyncio +from fastapi import FastAPI +from sse_starlette.sse import EventSourceResponse +import uvicorn +from fastapi.middleware.cors import CORSMiddleware + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=['*'], # 设置允许跨域的域名列表,* 代表所有域名 + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], +) +async def event_generator(): + count = 0 + while True: + await asyncio.sleep(1) + count += 1 + data = {"count": count} + yield json.dumps(data) + +@app.get("/events") +async def get_events(): + return EventSourceResponse(event_generator()) +@app.post("/events") +async def post_events(): + return EventSourceResponse(event_generator()) + +if __name__ == '__main__': + uvicorn.run(app, host='0.0.0.0', port=4000) + +``` + +3. 运行应用 + 保存上述代码到一个文件(例如 main.py),然后运行应用: + +``` +python3 main.py +``` + +4. 测试流式接口 + +- get 接口 + +``` +curl http://0.0.0.0:4000/events +``` + +- post 接口 + +``` +curl -X POST "http://0.0.0.0:4000/events" -H "Content-Type: application/json" +``` + +你应该会看到每秒钟输出一行数据,类似于: + +``` +data: {"count": 1} + +data: {"count": 2} + +data: {"count": 3} + +data: {"count": 4} + +data: {"count": 5} + +... +``` + +## 为什么大模型 LLM 需要使用 SSE ? + +从某种意义上说,现阶段 LLM 模型采用 SSE 是历史遗留原因。 + +Transformer 前后内容是需要推理拼接的,且不说内容很多的时候,推理的时间会很长(还有 Max Token 的限制)。推理上下文的时候也是逐步推理生成的,因此默认就是流式输出进行包裹。如果哪天 AI 的速度可以不受这些内容的限制了,可能一次性返回是一个更好的交互。 + +对于流式请求来说,组件其实只关心一个内容,那就是返回的 String,下面是 hunyuan 的流式返回案例。 + +```js +data: {"id":"7eced65bb3cb122d9f927563fc0e5673","created":1695218378,"choices":[{"delta":{"role":"assistant","content":"我是"}}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11}} + +data: {"id":"7eced65bb3cb122d9f927563fc0e5673","created":1695218378,"choices":[{"delta":{"role":"assistant","content":"由腾"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}} + +data: {"id":"7eced65bb3cb122d9f927563fc0e5673","created":1695218378,"choices":[{"delta":{"role":"assistant","content":"讯公"}}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} + +data: {"id":"7eced65bb3cb122d9f927563fc0e5673","created":1695218378,"choices":[{"delta":{"role":"assistant","content":"司开"}}],"usage":{"prompt_tokens":10,"completion_tokens":7,"total_tokens":17}} + +data: {"id":"7eced65bb3cb122d9f927563fc0e5673","created":1695218378,"choices":[{"delta":{"role":"assistant","content":"发的"}}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}} + +data: {"id":"7eced65bb3cb122d9f927563fc0e5673","created":1695218378,"choices":[{"delta":{"role":"assistant","content":"大型"}}],"usage":{"prompt_tokens":10,"completion_tokens":9,"total_tokens":19}} + +data: {"id":"7eced65bb3cb122d9f927563fc0e5673","created":1695218378,"choices":[{"delta":{"role":"assistant","content":"语言"}}],"usage":{"prompt_tokens":10,"completion_tokens":10,"total_tokens":20}} + +data: {"id":"7eced65bb3cb122d9f927563fc0e5673","created":1695218378,"choices":[{"delta":{"role":"assistant","content":"模型"}}],"usage":{"prompt_tokens":10,"completion_tokens":11,"total_tokens":21}} +``` diff --git a/packages/tdesign-react-aigc/site/docs/style.md b/packages/tdesign-react-aigc/site/docs/style.md new file mode 100644 index 0000000000..7e3b2e5f0e --- /dev/null +++ b/packages/tdesign-react-aigc/site/docs/style.md @@ -0,0 +1,87 @@ +--- +title: 自定义样式 +description: 样式自定义指南 +spline: explain +isGettingStarted: true +--- + +## 概述 + +TDesign AIGC 系列组件底层UI基于 Web Components 技术构建,提供了灵活的样式自定义方案。由于 Web Components 的 Shadow DOM 特性,传统的 CSS 选择器无法直接穿透组件内部样式,因此我们提供了两种主要的样式自定义方式: + +1. **CSS 自定义属性(CSS Variables)**:修改预定义的 CSS 变量来调整组件样式 +2. **CSS Parts**:通过 `::part()` 伪元素选择器直接修改组件内部元素样式[了解更多](https://developer.mozilla.org/zh-CN/docs/Web/CSS/::part) + +## 方式一:CSS 自定义属性(优先推荐) + +组件内部定义了丰富的 CSS 自定义属性,你可以通过重新定义这些变量来自定义样式,以设置对话输入框的背景色为例: + +```css +/* 全局定义 */ +:root { + --td-chat-input-background: red; +} + +/* 针对特定组件实例内部定义,优先级更高 */ +.my-chat-sender { + --td-chat-input-background: blue; +} + +``` + +## 方式二:CSS Parts + +CSS Parts 允许你直接选择和修改 Web Components 内部的特定元素。组件通过 `exportparts` 属性暴露内部元素[了解更多](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/exportparts),你可以使用 `::part()` 伪元素选择器来修改这些元素的样式。 + +以设置对话输入框圆角为例,先在chrome调试器中查看内部元素style,找到计划要设置样式的元素节点对应的part属性`t-chat__input__content`: + +```html + +
+
+
+
+ +``` + +```css +.my-chat-sender::part(t-chat__input__content) { + border-radius: 2px; +} +``` + +## 最佳实践 + +在实际项目中,你可以组合使用上面两种方式来实现更精细的样式控制,建议优先使用 CSS 变量进行基础样式调整,在需要精细控制时再使用 CSS Parts。具体如下: + +### 1. 优先使用 CSS 变量 +- CSS 变量提供了更好的一致性和可维护性 +- 支持动态主题切换 +- 性能更好,避免了复杂的选择器 + +### 2. CSS Parts 用于精细控制 +- 当未找到合适的 CSS 变量或者无法满足需求时使用 CSS Parts +- 可以访问伪元素和伪类 + +比如,如果希望改变对话输入框字体大小和hover态 ,可以这样写: + +```css +.accessible-chat { + --td-chat-input-font-size: 16px; +} +``` + +```css +.accessible-chat chat-sender::part(t-textarea__inner):hover { + outline: 2px solid #0052d9; +} +``` + + +## 常见问题 + +### Q: 为什么我的 CSS 选择器不生效? +A: 组件内部有使用 Shadow DOM,外部 CSS 无法直接穿透。请使用 CSS 变量或 CSS Parts 的方式修改。 + +### Q: 如何知道组件支持哪些 CSS 变量? +A: Chrome Style查找元素样式,或查看[仓库内组件的样式文件](https://github.com/TDesignOteam/tdesign-web-components/blob/develop/src/chatbot/style/_var.less)(_var.less),所有以 `--td-` 开头的都是可自定义的变量。 diff --git a/packages/tdesign-react-aigc/site/index.html b/packages/tdesign-react-aigc/site/index.html new file mode 100644 index 0000000000..74e38448c3 --- /dev/null +++ b/packages/tdesign-react-aigc/site/index.html @@ -0,0 +1,33 @@ + + + + + + TDesign Chat for React + + + + + + + + + + + + + +
+ + + diff --git a/packages/tdesign-react-aigc/site/package.json b/packages/tdesign-react-aigc/site/package.json new file mode 100644 index 0000000000..0a157f7646 --- /dev/null +++ b/packages/tdesign-react-aigc/site/package.json @@ -0,0 +1,42 @@ +{ + "name": "@tdesign/react-aigc-site", + "private": true, + "scripts": { + "start": "pnpm run dev", + "dev": "vite", + "build": "vite build", + "intranet": "vite build --mode intranet", + "preview": "vite build --mode preview && cp dist/index.html dist/404.html" + }, + "author": "tdesign", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.2.2", + "tdesign-icons-react": "^0.4.5", + "tdesign-react": "^1.12.1", + "tdesign-site-components": "^0.15.3", + "tdesign-theme-generator": "^1.1.3" + }, + "devDependencies": { + "@babel/core": "^7.16.5", + "@types/lodash-es": "^4.17.12", + "@types/node": "^22.7.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/rimraf": "^4.0.5", + "@vitejs/plugin-react": "^4.3.1", + "camelcase": "^6.2.1", + "gray-matter": "^4.0.3", + "markdown-it-fence": "^0.1.3", + "semver": "^7.6.3", + "typescript": "5.6.2", + "vite": "^5.4.7", + "vite-plugin-istanbul": "^6.0.2", + "vite-plugin-tdoc": "^2.0.4", + "vitest": "^2.1.1", + "workbox-precaching": "^7.0.0" + } +} diff --git a/packages/tdesign-react-aigc/site/playground.html b/packages/tdesign-react-aigc/site/playground.html new file mode 100644 index 0000000000..24fba7a740 --- /dev/null +++ b/packages/tdesign-react-aigc/site/playground.html @@ -0,0 +1,15 @@ + + + + + + TDesign Web React Playground + + + + + +
+ + + diff --git a/packages/tdesign-react-aigc/site/plugin-tdoc/demo.js b/packages/tdesign-react-aigc/site/plugin-tdoc/demo.js new file mode 100644 index 0000000000..c2a8e3c4e9 --- /dev/null +++ b/packages/tdesign-react-aigc/site/plugin-tdoc/demo.js @@ -0,0 +1,54 @@ +import path from 'path'; +import Markdownitfence from 'markdown-it-fence'; + +function mdInJsx(_md) { + return new Markdownitfence(_md, 'md_in_jsx', { + validate: () => true, + render(tokens, idx) { + const { content, info } = tokens[idx]; + return `
{\`${content.replace(
+        /`/g,
+        '\\`',
+      )}\`}
`; + }, + }); +} + +export default function renderDemo(md, container) { + md.use(mdInJsx).use(container, 'demo', { + validate(params) { + return params.trim().match(/^demo\s+([\\/.\w-]+)(\s+(.+?))?(\s+--dev)?$/); + }, + render(tokens, idx) { + if (tokens[idx].nesting === 1) { + const match = tokens[idx].info.trim().match(/^demo\s+([\\/.\w-]+)(\s+(.+?))?(\s+--dev)?$/); + const [, demoPath, componentName = ''] = match; + const demoPathOnlyLetters = demoPath.replace(/[^a-zA-Z\d]/g, ''); + const demoName = path.basename(demoPath).trim(); + const demoDefName = `Demo${demoPathOnlyLetters}`; + const demoCodeDefName = `Demo${demoPathOnlyLetters}Code`; + const demoJsxCodeDefName = `Demo${demoPathOnlyLetters}JsxCode`; + + const tpl = ` + +
+ + +
+
+
<${demoDefName} />
+
+
+ `; + + // eslint-disable-next-line no-param-reassign + tokens.tttpl = tpl; + + return `
`; + } + if (tokens.tttpl) return `${tokens.tttpl || ''}
`; + + return ''; + }, + }); +} diff --git a/packages/tdesign-react-aigc/site/plugin-tdoc/index.js b/packages/tdesign-react-aigc/site/plugin-tdoc/index.js new file mode 100644 index 0000000000..23740e9eb8 --- /dev/null +++ b/packages/tdesign-react-aigc/site/plugin-tdoc/index.js @@ -0,0 +1,25 @@ +import vitePluginTdoc from 'vite-plugin-tdoc'; + +import transforms from './transforms'; +import renderDemo from './demo'; + +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', + }, + container(md, container) { + renderDemo(md, container); + }, + }, +}); diff --git a/packages/tdesign-react-aigc/site/plugin-tdoc/md-to-react.js b/packages/tdesign-react-aigc/site/plugin-tdoc/md-to-react.js new file mode 100644 index 0000000000..b35522643e --- /dev/null +++ b/packages/tdesign-react-aigc/site/plugin-tdoc/md-to-react.js @@ -0,0 +1,237 @@ +/* eslint-disable */ +import fs from 'fs'; +import path from 'path'; +import matter from 'gray-matter'; +import camelCase from 'camelcase'; + +import { compileUsage, getGitTimestamp } from '../../../../packages/common/docs/compile'; + +import testCoverage from '../test-coverage'; + +import { transformSync } from '@babel/core'; + +export default async function mdToReact(options) { + const mdSegment = await customRender(options); + const { demoDefsStr, demoCodesDefsStr } = options; + + let coverage = {}; + if (mdSegment.isComponent) { + coverage = testCoverage[camelCase(mdSegment.componentName)] || {}; + } + + const reactSource = ` + import React, { useEffect, useRef, useState, useMemo } from 'react';\n + import { useLocation, useNavigate } from 'react-router-dom'; + import Prismjs from 'prismjs'; + import 'prismjs/components/prism-bash.js'; + import Stackblitz from '@tdesign/react-aigc-site/src/components/stackblitz/index.jsx'; + import Codesandbox from '@tdesign/react-aigc-site/src/components/codesandbox/index.jsx'; + ${demoDefsStr} + ${demoCodesDefsStr} + ${mdSegment.usage.importStr} + + function useQuery() { + return new URLSearchParams(useLocation().search); + } + + export default function TdDoc() { + const tdDocHeader = useRef(); + const tdDocTabs = useRef(); + + const isComponent = ${mdSegment.isComponent}; + + const location = useLocation(); + const navigate = useNavigate(); + + const query = useQuery(); + const [tab, setTab] = useState(query.get('tab') || 'demo'); + + const lastUpdated = useMemo(() => { + if (tab === 'design') return ${mdSegment.designDocLastUpdated}; + return ${mdSegment.lastUpdated}; + }, [tab]); + + useEffect(() => { + tdDocHeader.current.docInfo = { + title: \`${mdSegment.title}\`, + desc: \`${mdSegment.description}\` + } + + if (isComponent) { + tdDocTabs.current.tabs = ${JSON.stringify(mdSegment.tdDocTabs)}; + } + + document.title = \`${mdSegment.title} | TDesign\`; + + Prismjs.highlightAll(); + }, []); + + useEffect(() => { + if (!isComponent) return; + + const query = new URLSearchParams(location.search); + const currentTab = query.get('tab') || 'demo'; + setTab(currentTab); + tdDocTabs.current.tab = currentTab; + + tdDocTabs.current.onchange = ({ detail: currentTab }) => { + setTab(currentTab); + const query = new URLSearchParams(location.search); + if (query.get('tab') === currentTab) return; + navigate({ search: '?tab=' + currentTab }); + } + }, [location]) + + function isShow(currentTab) { + return currentTab === tab ? { display: 'block' } : { display: 'none' }; + } + + return ( + <> + ${ + mdSegment.tdDocHeader + ? ` + ` + : '' + } + { + isComponent ? ( + <> + +
+ ${mdSegment.demoMd.replace(/class=/g, 'className=')} + +
+
+
+ + ) :
${mdSegment.docMd.replace( + /class=/g, + 'className=', + )}
+ } +
+ +
+ + ) + } + `; + + const result = transformSync(reactSource, { + babelrc: false, + configFile: false, + sourceMaps: true, + generatorOpts: { + decoratorsBeforeExport: true, + }, + presets: [require('@babel/preset-react')], + plugins: [[require('@babel/plugin-transform-typescript'), { isTSX: true }]], + }); + + return { code: result.code, map: result.map }; +} + +const DEFAULT_TABS = [ + { tab: 'demo', name: '示例' }, + { tab: 'api', name: 'API' }, + { tab: 'design', name: '指南' }, +]; + +const DEFAULT_EN_TABS = [ + { tab: 'demo', name: 'DEMO' }, + { tab: 'api', name: 'API' }, + { tab: 'design', name: 'Guideline' }, +]; + +// 解析 markdown 内容 +async function customRender({ source, file, md }) { + let { content, data } = matter(source); + const lastUpdated = (await getGitTimestamp(file)) || Math.round(fs.statSync(file).mtimeMs); + + const isEn = file.endsWith('en-US.md'); + // md top data + const pageData = { + spline: '', + toc: true, + title: '', + description: '', + isComponent: false, + tdDocHeader: true, + tdDocTabs: !isEn ? DEFAULT_TABS : DEFAULT_EN_TABS, + apiFlag: /#+\s*API/, + docClass: '', + lastUpdated, + designDocLastUpdated: lastUpdated, + ...data, + }; + + // md filename + const reg = file.match(/packages\/pro-components\/chat\/(\w+-?\w+)\/(\w+-?\w+)\.?(\w+-?\w+)?\.md/); + const componentName = reg && reg[1]; + + // split md + let [demoMd = '', apiMd = ''] = content.split(pageData.apiFlag); + + // fix table | render error + demoMd = demoMd.replace(/`([^`\r\n]+)`/g, (str, codeStr) => { + codeStr = codeStr.replace(/"/g, "'"); + return ``; + }); + + apiMd = apiMd.replace(/```([\w-]*)\n([\s\S]*?)\n```/g, (_, codeStr, content) => { + return `
${content}
`; + }); + + const mdSegment = { + ...pageData, + componentName, + usage: { importStr: '' }, + docMd: '', + demoMd: '', + apiMd: '', + designMd: '', + }; + + // 渲染 live demo + if (pageData.usage && pageData.isComponent) { + const usageObj = compileUsage({ + componentName, + usage: pageData.usage, + demoPath: path.posix.resolve(__dirname, `../../../pro-components/chat/${componentName}/_usage/index.jsx`), + }); + if (usageObj) { + mdSegment.usage = usageObj; + demoMd = `${usageObj.markdownStr} ${demoMd}`; + } + } + + if (pageData.isComponent) { + mdSegment.demoMd = md.render.call( + md, + `${pageData.toc ? '[toc]\n' : ''}${demoMd.replace(//g, '')}`, + ).html; + mdSegment.apiMd = md.render.call( + md, + `${pageData.toc ? '[toc]\n' : ''}${apiMd.replace(//g, '')}`, + ).html; + } else { + mdSegment.docMd = md.render.call( + md, + `${pageData.toc ? '[toc]\n' : ''}${content.replace(//g, '')}`, + ).html; + } + + return mdSegment; +} diff --git a/packages/tdesign-react-aigc/site/plugin-tdoc/transforms.js b/packages/tdesign-react-aigc/site/plugin-tdoc/transforms.js new file mode 100644 index 0000000000..e168ca36d7 --- /dev/null +++ b/packages/tdesign-react-aigc/site/plugin-tdoc/transforms.js @@ -0,0 +1,86 @@ +/* eslint-disable indent */ +/* eslint-disable no-param-reassign */ +import path from 'path'; +import fs from 'fs'; + +import mdToReact from './md-to-react'; + +let demoImports = {}; +let demoCodesImports = {}; + +export default { + before({ source, file }) { + const resourceDir = path.dirname(file); + const reg = file.match(/packages\/pro-components\/chat\/([\w-]+)\/(\w+-?\w+)\.?(\w+-?\w+)?\.md/); + + const fileName = reg && reg[0]; + const componentName = reg && reg[1]; + const localeName = reg && reg[3]; + + demoImports = {}; + demoCodesImports = {}; + // 统一换成 common 公共文档内容 + if (fileName && source.includes(':: BASE_DOC ::')) { + const localeDocPath = path.resolve(__dirname, `../../../${fileName}`); + const defaultDocPath = path.resolve( + __dirname, + `../../../common/docs/web/api/${localeName ? `${componentName}.${localeName}` : componentName}.md`, + ); + + let baseDoc = ''; + if (fs.existsSync(localeDocPath)) { + // 优先载入语言版本 + baseDoc = fs.readFileSync(localeDocPath, 'utf-8'); + } else if (fs.existsSync(defaultDocPath)) { + // 回退中文默认版本 + baseDoc = fs.readFileSync(defaultDocPath, 'utf-8'); + } else { + console.error(`未找到 ${defaultDocPath} 文件`); + } + source = source.replace(':: BASE_DOC ::', baseDoc); + } + + // 替换成对应 demo 文件 + source = source.replace(/\{\{\s+(.+)\s+\}\}/g, (demoStr, demoFileName) => { + const tsxDemoPath = path.resolve(resourceDir, `./_example/${demoFileName}.tsx`); + if (!fs.existsSync(tsxDemoPath)) { + console.log('\x1B[36m%s\x1B[0m', `${componentName} 组件需要实现 _example/${demoFileName}.tsx 示例!`); + return '\n

DEMO (🚧建设中)...

'; + } + + return `\n::: demo _example/${demoFileName} ${componentName}\n:::\n`; + }); + + source.replace(/:::\s*demo\s+([\\/.\w-]+)/g, (demoStr, relativeDemoPath) => { + const jsxDemoPath = `_example-js/${relativeDemoPath.split('/')?.[1]}`; + const demoPathOnlyLetters = relativeDemoPath.replace(/[^a-zA-Z\d]/g, ''); + const demoDefName = `Demo${demoPathOnlyLetters}`; + const demoJsxCodeDefName = `Demo${demoPathOnlyLetters}JsxCode`; + const demoCodeDefName = `Demo${demoPathOnlyLetters}Code`; + demoImports[demoDefName] = `import ${demoDefName} from './${relativeDemoPath}';`; + demoCodesImports[demoCodeDefName] = `import ${demoCodeDefName} from './${relativeDemoPath}?raw';`; + if (fs.existsSync(path.resolve(resourceDir, `${jsxDemoPath}.jsx`))) + demoCodesImports[demoJsxCodeDefName] = `import ${demoJsxCodeDefName} from './${jsxDemoPath}?raw'`; + else demoCodesImports[demoJsxCodeDefName] = `import ${demoJsxCodeDefName} from './${relativeDemoPath}?raw'`; + }); + + return source; + }, + render({ source, file, md }) { + const demoDefsStr = Object.keys(demoImports) + .map((key) => demoImports[key]) + .join('\n'); + const demoCodesDefsStr = Object.keys(demoCodesImports) + .map((key) => demoCodesImports[key]) + .join('\n'); + const sfc = mdToReact({ + md, + file, + source, + demoDefsStr, + demoCodesDefsStr, + }); + + return sfc; + }, +}; diff --git a/packages/tdesign-react-aigc/site/public/apple-touch-icon.png b/packages/tdesign-react-aigc/site/public/apple-touch-icon.png new file mode 100644 index 0000000000..bef3ff5255 Binary files /dev/null and b/packages/tdesign-react-aigc/site/public/apple-touch-icon.png differ diff --git a/packages/tdesign-react-aigc/site/public/favicon.ico b/packages/tdesign-react-aigc/site/public/favicon.ico new file mode 100644 index 0000000000..086ac804a6 Binary files /dev/null and b/packages/tdesign-react-aigc/site/public/favicon.ico differ diff --git a/packages/tdesign-react-aigc/site/public/logo.svg b/packages/tdesign-react-aigc/site/public/logo.svg new file mode 100644 index 0000000000..f2c84ac002 --- /dev/null +++ b/packages/tdesign-react-aigc/site/public/logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/tdesign-react-aigc/site/public/pwa-192x192.png b/packages/tdesign-react-aigc/site/public/pwa-192x192.png new file mode 100644 index 0000000000..06562a97a2 Binary files /dev/null and b/packages/tdesign-react-aigc/site/public/pwa-192x192.png differ diff --git a/packages/tdesign-react-aigc/site/public/pwa-512x512.png b/packages/tdesign-react-aigc/site/public/pwa-512x512.png new file mode 100644 index 0000000000..5a8085d057 Binary files /dev/null and b/packages/tdesign-react-aigc/site/public/pwa-512x512.png differ diff --git a/packages/tdesign-react-aigc/site/public/sw.js b/packages/tdesign-react-aigc/site/public/sw.js new file mode 100644 index 0000000000..67150b319c --- /dev/null +++ b/packages/tdesign-react-aigc/site/public/sw.js @@ -0,0 +1,8 @@ +import { precacheAndRoute } from 'workbox-precaching' + +precacheAndRoute(self.__WB_MANIFEST) + +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') + self.skipWaiting() +}); diff --git a/packages/tdesign-react-aigc/site/site.config.mjs b/packages/tdesign-react-aigc/site/site.config.mjs new file mode 100644 index 0000000000..f842a62ecf --- /dev/null +++ b/packages/tdesign-react-aigc/site/site.config.mjs @@ -0,0 +1,169 @@ +export const docs = [ + { + title: '开始', + titleEn: 'Start', + type: 'doc', + children: [ + { + title: '概述', + titleEn: 'Overview', + name: 'overview', + path: '/react-chat/overview', + component: () => import('./docs/intro.md'), + }, + { + title: '快速开始', + titleEn: 'Getting Started', + name: 'getting-started', + path: '/react-chat/getting-started', + component: () => import('./docs/getting-started.md'), + }, + { + title: '什么是流式输出', + titleEn: 'SSE', + name: 'sse', + path: '/react-chat/sse', + component: () => import('./docs/sse.md'), + }, + { + title: '与AG-UI协议集成', + titleEn: 'AG-UI', + name: 'agui', + path: '/react-chat/agui', + component: () => import('./docs/agui.md'), + }, + ], + }, + { + title: '全局配置', + titleEn: 'Global Config', + type: 'doc', + children: [ + { + title: '自定义主题', + titleEn: 'Theme Customization', + name: 'custom-theme', + path: '/react-chat/custom-theme', + component: () => import('@tdesign/common/theme.md'), + componentEn: () => import('@tdesign/common/theme.en-US.md'), + }, + { + title: '深色模式', + titleEn: 'Dark Mode', + name: 'dark-mode', + path: '/react-chat/dark-mode', + component: () => import('@tdesign/common/dark-mode.md'), + componentEn: () => import('@tdesign/common/dark-mode.en-US.md'), + }, + { + title: '自定义样式', + titleEn: 'Style Customization', + name: 'custom-style', + path: '/react-chat/custom-style', + component: () => import('./docs/style.md'), + }, + ], + }, + { + title: '智能对话', + titleEn: 'ChatBot', + type: 'component', + children: [ + { + title: 'Chatbot 智能对话', + titleEn: 'Chatbot', + name: 'chatbot', + path: '/react-chat/components/chatbot', + component: () => import('@tdesign/pro-components-chat/chatbot/chatbot.md'), + componentEn: () => import('@tdesign/pro-components-chat/chatbot/chatbot.en-US.md'), + }, + { + title: 'ChatEngine 对话引擎', + titleEn: 'ChatEngine', + name: 'chat-engine', + path: '/react-chat/components/chat-engine', + component: () => import('@tdesign/pro-components-chat/chat-engine/chat-engine.md'), + componentEn: () => import('@tdesign/pro-components-chat/chat-engine/chat-engine.en-US.md'), + }, + { + title: 'ChatSender 对话输入', + titleEn: 'ChatSender', + name: 'chat-sender', + path: '/react-chat/components/chat-sender', + component: () => import('@tdesign/pro-components-chat/chat-sender/chat-sender.md'), + componentEn: () => import('@tdesign/pro-components-chat/chat-sender/chat-sender.en-US.md'), + }, + { + title: 'ChatMessage 对话消息体', + titleEn: 'ChatMessage', + name: 'chat-message', + path: '/react-chat/components/chat-message', + component: () => import('@tdesign/pro-components-chat/chat-message/chat-message.md'), + componentEn: () => import('@tdesign/pro-components-chat/chat-message/chat-message.en-US.md'), + }, + { + title: 'ChatActionBar 对话操作栏', + titleEn: 'ChatActionBar', + name: 'chat-actionbar', + path: '/react-chat/components/chat-actionbar', + component: () => import('@tdesign/pro-components-chat/chat-actionbar/chat-actionbar.md'), + componentEn: () => import('@tdesign/pro-components-chat/chat-actionbar/chat-actionbar.en-US.md'), + }, + { + title: 'ChatMarkdown 消息内容', + titleEn: 'ChatMarkdown', + name: 'chat-markdown', + path: '/react-chat/components/chat-markdown', + component: () => import('@tdesign/pro-components-chat/chat-markdown/chat-markdown.md'), + componentEn: () => import('@tdesign/pro-components-chat/chat-markdown/chat-markdown.en-US.md'), + }, + { + title: 'ChatThinking 思考过程', + titleEn: 'ChatThinking', + name: 'chat-thinking', + path: '/react-chat/components/chat-thinking', + component: () => import('@tdesign/pro-components-chat/chat-thinking/chat-thinking.md'), + componentEn: () => import('@tdesign/pro-components-chat/chat-thinking/chat-thinking.en-US.md'), + }, + + { + title: 'ChatLoading 对话加载', + titleEn: 'ChatLoading', + name: 'chat-loading', + path: '/react-chat/components/chat-loading', + component: () => import('@tdesign/pro-components-chat/chat-loading/chat-loading.md'), + componentEn: () => import('@tdesign/pro-components-chat/chat-loading/chat-loading.en-US.md'), + }, + // { + // title: 'FileCard 文件缩略卡片', + // titleEn: 'FileCard', + // name: 'filecard', + // path: '/react-chat/components/chat-filecard', + // component: () => import('@tdesign/pro-components-chat/chat-filecard/chat-filecard.md'), + // componentEn: () => import('@tdesign/pro-components-chat/chat-filecard/chat-filecard.en-US.md'), + // }, + { + title: 'Attachments 附件列表', + titleEn: 'Attachments', + name: 'attachment', + path: '/react-chat/components/chat-attachments', + component: () => import('@tdesign/pro-components-chat/chat-attachments/chat-attachments.md'), + componentEn: () => import('@tdesign/pro-components-chat/chat-attachments/chat-attachments.en-US.md'), + }, + ], + }, +]; + +const enDocs = docs.map((doc) => ({ + ...doc, + title: doc.titleEn, + children: doc?.children?.map((child) => ({ + title: child.titleEn, + name: `${child.name}-en`, + path: `${child.path}-en`, + meta: { lang: 'en' }, + component: child.componentEn || child.component, + })), +})); + +export default { docs, enDocs }; diff --git a/packages/tdesign-react-aigc/site/src/App.jsx b/packages/tdesign-react-aigc/site/src/App.jsx new file mode 100644 index 0000000000..8a8dae9f4f --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/App.jsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef, lazy, Suspense } from 'react'; +import { BrowserRouter, Routes, Navigate, Route, useLocation, useNavigate, Outlet } from 'react-router-dom'; +import Loading from '@tdesign/components/loading'; + +import * as siteConfig from '../site.config'; +import { getRoute } from './utils'; + +const LazyDemo = lazy(() => import('./components/Demo')); + +const isDev = import.meta.env.DEV; + +const { docs, enDocs } = JSON.parse(JSON.stringify(siteConfig.default).replace(/component:.+/g, '')); + +const docsMap = { + zh: docs, + en: enDocs, +}; + +const docRoutes = [...getRoute(siteConfig.default.docs, []), ...getRoute(siteConfig.default.enDocs, [])]; +const renderRouter = docRoutes.map((nav, i) => { + const LazyCom = lazy(nav.component); + + return ( + }> + + + } + /> + ); +}); + +function Components() { + const location = useLocation(); + const navigate = useNavigate(); + + const tdHeaderRef = useRef(); + const tdDocAsideRef = useRef(); + const tdDocContentRef = useRef(); + const tdDocSearch = useRef(); + + useEffect(() => { + tdHeaderRef.current.framework = 'react'; + tdDocSearch.current.docsearchInfo = { indexName: 'tdesign_doc_react' }; + const isEn = window.location.pathname.endsWith('en'); + tdDocAsideRef.current.routerList = isEn ? docsMap.en : docsMap.zh; + + tdDocAsideRef.current.onchange = ({ detail }) => { + if (window.location.pathname === detail) return; + tdDocContentRef.current.pageStatus = 'hidden'; + navigate(detail); + requestAnimationFrame(() => { + tdDocContentRef.current.pageStatus = 'show'; + window.scrollTo(0, 0); + }); + }; + + if (isDev) return; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + document.querySelector('td-stats')?.track?.(); + }, [location]); + + return ( + <> + + + + + + + + + + + + + + ); +} + +function App() { + return ( + + + } /> + } /> + }> + + + } + /> + }> + {renderRouter} + + } /> + + + ); +} + +export default App; diff --git a/packages/tdesign-react-aigc/site/src/components/BaseUsage.jsx b/packages/tdesign-react-aigc/site/src/components/BaseUsage.jsx new file mode 100644 index 0000000000..9c422ca7a9 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/components/BaseUsage.jsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState, useRef } from 'react'; + +export function useConfigChange(configList) { + const defaultProps = configList.reduce((prev, curr) => { + if (curr.defaultValue) Object.assign(prev, { [curr.name]: curr.defaultValue }); + return prev; + }, {}); + + const [changedProps, setChangedProps] = useState(defaultProps); + + function onConfigChange(e) { + const { name, value } = e.detail; + + changedProps[name] = value; + setChangedProps({ ...changedProps }); + } + + return { + changedProps, + onConfigChange, + }; +} + +export function usePanelChange(panelList) { + const [panel, setPanel] = useState(panelList[0]?.value); + + function onPanelChange(e) { + const { value } = e.detail; + setPanel(value); + } + + return { + panel, + onPanelChange, + }; +} + +export default function BaseUsage(props) { + const { code, configList, panelList, onConfigChange, onPanelChange, children } = props; + const usageRef = useRef(); + + function handleConfigChange(e) { + onConfigChange?.(e); + } + + function handlePanelChange(e) { + onPanelChange?.(e); + } + + useEffect(() => { + usageRef.current.panelList = panelList; + usageRef.current.configList = configList; + usageRef.current.addEventListener('ConfigChange', handleConfigChange); + usageRef.current.addEventListener('PanelChange', handlePanelChange); + + return () => { + usageRef.current?.removeEventListener('ConfigChange', handleConfigChange); + usageRef.current?.removeEventListener('PanelChange', handlePanelChange); + }; + }, [configList, panelList]); + + useEffect(() => { + usageRef.current.code = code; + }, [code]); + + return ( + + {panelList.map((item) => ( +
+ {children} +
+ ))} +
+ ); +} diff --git a/packages/tdesign-react-aigc/site/src/components/Demo.jsx b/packages/tdesign-react-aigc/site/src/components/Demo.jsx new file mode 100644 index 0000000000..6e0625e3a8 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/components/Demo.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Button } from '@tdesign/components'; +import { Link, useLocation } from 'react-router-dom'; + +export const demoFiles = import.meta.glob('../../../../components/**/_example/*.tsx', { eager: true }); + +const demoObject = {}; +Object.keys(demoFiles).forEach((key) => { + const match = key.match(/([\w-]+)._example.([\w-]+).tsx/); + const [, componentName, demoName] = match; + + demoObject[`${componentName}/${demoName}`] = demoFiles[key].default; + if (demoObject[componentName]) { + demoObject[componentName].push(demoName); + } else { + demoObject[componentName] = [demoName]; + } +}); + +export default function Demo() { + const location = useLocation(); + const match = location.pathname.match(/\/react\/demos\/([\w-]+)\/?([\w-]+)?/); + const [, componentName, demoName] = match; + + const demoList = demoObject[componentName]; + const demoFunc = demoObject[`${componentName}/${demoName}`]; + + return demoFunc ? ( + demoFunc() + ) : ( +
    + {demoList.map((demoName) => ( +
  • + + + +
  • + ))} +
+ ); +} diff --git a/packages/tdesign-react-aigc/site/src/components/Playground.jsx b/packages/tdesign-react-aigc/site/src/components/Playground.jsx new file mode 100644 index 0000000000..6c93d01816 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/components/Playground.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { HashRouter, Routes, Navigate, Route, useLocation, Link } from 'react-router-dom'; +import ReactDOM from 'react-dom'; +import { Button } from '@tdesign/components'; +import '@tdesign/components/style/index.js'; + +const demoFiles = import.meta.glob('../../../src/**/_example/*.jsx', { eager: true }); +const demoObject = {}; +const componentList = new Set(); +Object.keys(demoFiles).forEach((key) => { + const match = key.match(/([\w-]+)._example.([\w-]+).jsx/); + const [, componentName, demoName] = match; + + componentList.add(componentName); + demoObject[`${componentName}/${demoName}`] = demoFiles[key].default; + if (demoObject[componentName]) { + demoObject[componentName].push(demoName); + } else { + demoObject[componentName] = [demoName]; + } +}); + +function Demos() { + const location = useLocation(); + const match = location.pathname.match(/\/demos\/([\w-]+)\/?([\w-]+)?/); + const [, componentName] = match; + const demoList = demoObject[componentName]; + + return ( +
+
    + {[...componentList].map((com) => ( +
  • + + + +
  • + ))} +
+
+ {demoList.map((demoName) => ( +
+

{demoName}

+ {demoObject[`${componentName}/${demoName}`]()} +
+
+ ))} +
+
+ ); +} + +function App() { + return ( + + + } /> + } /> + + + ); +} + +ReactDOM.render( + + + , + document.getElementById('app'), +); diff --git a/packages/tdesign-react-aigc/site/src/components/codesandbox/content.js b/packages/tdesign-react-aigc/site/src/components/codesandbox/content.js new file mode 100644 index 0000000000..4543739a80 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/components/codesandbox/content.js @@ -0,0 +1,111 @@ +import orgPkg from '../../../package.json'; +import tdesignReactPkg from '../../../../package.json'; + +export const htmlContent = '
'; + +export const mainJsContent = ` + import React from 'react'; + import ReactDOM from 'react-dom'; + import Demo from './demo'; + import './index.css'; + import 'tdesign-react/es/style/index.css'; + + const rootElement = document.getElementById('app'); + ReactDOM.render(, rootElement); +`; + +export const styleContent = ` + /* 竖排展示 demo 行间距 16px */ + .tdesign-demo-block-column { + display: flex; + flex-direction: column; + row-gap: 16px; + } + + /* 竖排展示 demo 行间距 32px */ + .tdesign-demo-block-column-large { + display: flex; + flex-direction: column; + row-gap: 32px; + } + + /* 横排排展示 demo 列间距 16px */ + .tdesign-demo-block-row { + display: flex; + column-gap: 16px; + align-items: center; + } + + /* swiper 组件示例展示 */ + .tdesign-demo-block--swiper .demo-item { + display: flex; + height: 280px; + background-color: #4b5b76; + color: #fff; + justify-content: center; + align-items: center; + font-weight: 500; + font-size: 20px; + } +`; + +export const tsconfigContent = `{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +} +`; + +export const pkgContent = JSON.stringify( + { + name: 'tdesign-react-demo', + version: '1.0.0', + description: 'React example starter project', + keywords: ['react', 'starter'], + main: 'src/main.tsx', + dependencies: { + react: orgPkg.dependencies.react, + 'react-dom': orgPkg.dependencies['react-dom'], + 'tdesign-react': orgPkg.dependencies['tdesign-react'], + '@tdesign-react/chat': tdesignReactPkg.version, + 'tdesign-icons-react': orgPkg.dependencies['tdesign-icons-react'], + '@types/react': orgPkg.devDependencies['@types/react'], + '@types/react-dom': orgPkg.devDependencies['@types/react-dom'], + 'lodash-es': orgPkg.dependencies['lodash-es'], + }, + devDependencies: { + typescript: '^4.4.4', + 'react-scripts': '^5.0.0', + }, + scripts: { + start: 'react-scripts start', + build: 'react-scripts build', + test: 'react-scripts test --env=jsdom', + eject: 'react-scripts eject', + }, + browserslist: ['>0.2%', 'not dead', 'not ie <= 11', 'not op_mini all'], + }, + null, + 2, +); diff --git a/packages/tdesign-react-aigc/site/src/components/codesandbox/index.jsx b/packages/tdesign-react-aigc/site/src/components/codesandbox/index.jsx new file mode 100644 index 0000000000..43938e80b6 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/components/codesandbox/index.jsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { Tooltip, Loading } from '@tdesign/components'; + +import { mainJsContent, htmlContent, pkgContent, styleContent, tsconfigContent } from './content'; +import '../../styles/Codesandbox.less'; + +const TypeScriptType = 0; + +export default function Codesandbox(props) { + const [loading, setLoading] = useState(false); + + function onRunOnline() { + const demoDom = document.querySelector(`td-doc-demo[demo-name='${props.demoName}']`); + const code = demoDom?.currentRenderCode; + const isTypeScriptDemo = demoDom?.currentLangIndex === TypeScriptType; + setLoading(true); + if (isTypeScriptDemo) { + fetch('https://codesandbox.io/api/v1/sandboxes/define?json=1', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + files: { + 'package.json': { + content: pkgContent, + }, + 'public/index.html': { + content: htmlContent, + }, + 'src/main.tsx': { + content: mainJsContent, + }, + 'src/index.css': { + content: styleContent, + }, + 'src/demo.tsx': { + content: code, + }, + 'tsconfig.json': { + content: tsconfigContent, + }, + }, + }), + }) + .then((x) => x.json()) + .then(({ sandbox_id: sandboxId }) => { + window.open(`https://codesandbox.io/s/${sandboxId}?file=/src/demo.tsx`); + }) + .finally(() => { + setLoading(false); + }); + return; + } + fetch('https://codesandbox.io/api/v1/sandboxes/define?json=1', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + files: { + 'package.json': { + content: pkgContent, + }, + 'public/index.html': { + content: htmlContent, + }, + 'src/main.jsx': { + content: mainJsContent, + }, + 'src/index.css': { + content: styleContent, + }, + 'src/demo.jsx': { + content: code, + }, + }, + }), + }) + .then((x) => x.json()) + .then(({ sandbox_id: sandboxId }) => { + window.open(`https://codesandbox.io/s/${sandboxId}?file=/src/demo.jsx`); + }) + .finally(() => { + setLoading(false); + }); + } + + return ( + +
+ +
+ + + + + + +
+
+
+
+ ); +} diff --git a/packages/tdesign-react-aigc/site/src/components/stackblitz/content.js b/packages/tdesign-react-aigc/site/src/components/stackblitz/content.js new file mode 100644 index 0000000000..338b094c03 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/components/stackblitz/content.js @@ -0,0 +1,134 @@ +import orgPkg from '../../../package.json'; +import tdesignReactPkg from '../../../../package.json'; + +export const htmlContent = ` +
+ +`; + +export const mainJsContent = ` + import React, { StrictMode } from 'react'; + import { createRoot } from 'react-dom/client'; + + import Demo from './demo'; + import './index.css'; + import 'tdesign-react/dist/tdesign.css'; + + const rootElement = document.getElementById('app'); + const root = createRoot(rootElement); + + root.render( + + + , + ); +`; + +export const styleContent = ` + /* 竖排展示 demo 行间距 16px */ + .tdesign-demo-block-column { + display: flex; + flex-direction: column; + row-gap: 16px; + } + + /* 竖排展示 demo 行间距 32px */ + .tdesign-demo-block-column-large { + display: flex; + flex-direction: column; + row-gap: 32px; + } + + /* 横排排展示 demo 列间距 16px */ + .tdesign-demo-block-row { + display: flex; + column-gap: 16px; + align-items: center; + } + + /* swiper 组件示例展示 */ + .tdesign-demo-block--swiper .demo-item { + display: flex; + height: 280px; + background-color: #4b5b76; + color: #fff; + justify-content: center; + align-items: center; + font-weight: 500; + font-size: 20px; + } +`; + +export const tsconfigContent = `{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +} +`; + +export const stackblitzRc = ` + { + "installDependencies": true, + "startCommand": "npm run dev" + } +`; + +export const viteConfigContent = ` + import { defineConfig } from 'vite'; + import react from '@vitejs/plugin-react'; + + export default defineConfig({ + plugins: [react()], + }); +`; + +export const packageJSONContent = JSON.stringify( + { + name: 'tdesign-react-demo', + version: '0.0.0', + private: true, + scripts: { + dev: 'vite', + build: 'vite build', + serve: 'vite preview', + }, + dependencies: { + react: orgPkg.dependencies.react, + 'react-dom': orgPkg.dependencies['react-dom'], + 'tdesign-react': orgPkg.dependencies['tdesign-react'], + '@tdesign-react/chat': tdesignReactPkg.version, + 'tdesign-icons-react': orgPkg.dependencies['tdesign-icons-react'], + '@types/react': orgPkg.devDependencies['@types/react'], + '@types/react-dom': orgPkg.devDependencies['@types/react-dom'], + 'lodash-es': orgPkg.dependencies['lodash-es'], + }, + devDependencies: { + vite: orgPkg.devDependencies.vite, + '@vitejs/plugin-react': orgPkg.devDependencies['@vitejs/plugin-react'], + typescript: orgPkg.devDependencies.typescript, + }, + }, + null, + 2, +); diff --git a/packages/tdesign-react-aigc/site/src/components/stackblitz/index.jsx b/packages/tdesign-react-aigc/site/src/components/stackblitz/index.jsx new file mode 100644 index 0000000000..8cb9c331a4 --- /dev/null +++ b/packages/tdesign-react-aigc/site/src/components/stackblitz/index.jsx @@ -0,0 +1,68 @@ +import React, { useRef, useState } from 'react'; +import { Tooltip } from '@tdesign/components'; + +import { + htmlContent, + mainJsContent, + styleContent, + tsconfigContent, + viteConfigContent, + packageJSONContent, + stackblitzRc, +} from './content'; + +const TypeScriptType = 0; + +export default function Stackblitz(props) { + const formRef = useRef(null); + const [isTypeScriptDemo, setIsTypeScriptDemo] = useState(false); + const [code, setCurrentCode] = useState(''); + function submit() { + const demoDom = document.querySelector(`td-doc-demo[demo-name='${props.demoName}']`); + const isTypeScriptDemo = demoDom?.currentLangIndex === TypeScriptType; + + setCurrentCode(demoDom?.currentRenderCode); + setIsTypeScriptDemo(isTypeScriptDemo); + + setTimeout(() => { + formRef.current.submit(); + }); + } + + return ( + +
+ {isTypeScriptDemo ? ( + <> + + + + ) : ( + + )} + + + + + + + + +
+ + + +
+
+
+ ); +} 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/package.json b/packages/tdesign-react/package.json index bccb89c3bc..ba803b2d9f 100644 --- a/packages/tdesign-react/package.json +++ b/packages/tdesign-react/package.json @@ -93,6 +93,6 @@ "sortablejs": "^1.15.0", "tdesign-icons-react": "^0.6.1", "tslib": "~2.3.1", - "validator": "~13.15.0" + "validator": "~13.7.0" } } diff --git a/packages/tdesign-react/site/plugins/plugin-tdoc/index.js b/packages/tdesign-react/site/plugins/plugin-tdoc/index.js index 23740e9eb8..20d7c5652a 100644 --- a/packages/tdesign-react/site/plugins/plugin-tdoc/index.js +++ b/packages/tdesign-react/site/plugins/plugin-tdoc/index.js @@ -3,23 +3,24 @@ import vitePluginTdoc from 'vite-plugin-tdoc'; import transforms from './transforms'; import renderDemo from './demo'; -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', + }, + container(md, container) { + renderDemo(md, container); + }, }, - toc: { - listClass: 'tdesign-toc_list', - itemClass: 'tdesign-toc_list_item', - linkClass: 'tdesign-toc_list_item_a', - containerClass: 'tdesign-toc_container', - }, - container(md, container) { - renderDemo(md, container); - }, - }, -}); + }); 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