diff --git a/.gitignore b/.gitignore index 5a7c22ff57..314274509b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ pnpm-lock.yaml ## analysis files bundle-analysis + +## mock files packages/**/chat-engine/core packages/**/chat-engine/docs packages/**/chatbot/docs @@ -47,5 +49,12 @@ packages/**/chatbot/docs .codebuddy/memory .codebuddy/plans mock-server +packages/**/docs +packages/pro-components/chat/**/docs/ +.codebuddy + +.zip openspec -.fue \ No newline at end of file +*.d.ts +*.zip +.fue diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 751a7b97fa..1351e1030f 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ "recommendations": [ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "streetsidesoftware.code-spell-checker" + "streetsidesoftware.code-spell-checker", + "gongfeng.gongfeng-copilot" ] } \ No newline at end of file diff --git a/docs/agentic-architecture.md b/docs/agentic-architecture.md new file mode 100644 index 0000000000..2f68e7b5fa --- /dev/null +++ b/docs/agentic-architecture.md @@ -0,0 +1,251 @@ +# Agentic 应用全景架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Agent 应用层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 剧本创作助手 │ │ 剧本医生 │ │ 智能问答 │ │ 指标释义助手 │ │ ... │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +├─────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────┐ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ 用户输入 │ │ 智能交互基建 (TDesign Chat) │ │ +│ │ ┌──────┬───────┐ │ │ │ │ +│ │ │文本框│上传 │ │ │ ┌──────────┐ ┌──────────┐ ┌────────────────┐ ┌──────────────────┐ │ │ +│ │ │/语音 │附件 │ │ │ │ Copilot │ │ Chatbot │ │ScreenSensor │ │ ChatEngine │ │ │ +│ │ ├──────┼───────┤ │ │ └──────────┘ └──────────┘ │感知引擎 │ │对话引擎 │ │ │ +│ │ │绘制 │Dom │ │ │ ┌──────────┐ ┌──────────┐ │(Dom/OCR) │ │(FSM/SSE/Toolcall)│ │ │ +│ │ │标记 │点选 │ │ │ │SmartCanvas│ │PopBubble │ └────────────────┘ └──────────────────┘ │ │ +│ │ ├──────┼───────┤ │ │ └──────────┘ └──────────┘ │ │ +│ │ │拖拽 │截图 │ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ +│ │ │框选 │ │ │ │ │ 跨框架适配层 │ │ │ +│ │ └──────┴───────┘ │ │ │ ┌────────────────┐ ┌─────────────────┐ ┌─────────────────────┐│ │ │ +│ │ │ │ │ │MediaProcessor │ │ Render 渲染引擎 │ │ Generative UI ││ │ │ +│ │ 用户意图+页面语义 │ │ │ │媒体处理 │ │ (Toolcall Widget│ │ (json-render/A2UI) ││ │ │ +│ │ +视觉信息 │ │ │ │(Md/Image/Video)│ │ /Atomic) │ │ ││ │ │ +│ │ │ │ │ └────────────────┘ └─────────────────┘ └─────────────────────┘│ │ │ +│ └────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘ │ │ +│ │ ┌───────────┐ ┌──────────────────┐ ┌─────────────┐ ┌────────────┐ │ │ +│ │ │Ruying │ │TDesign AIGC Comps│ │Autotracker │ │ WebAI SDK │ │ │ +│ │ │Widgets │ │ │ │SDK │ │ │ │ │ +│ │ └───────────┘ └──────────────────┘ └─────────────┘ └────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +├═════════════════════════════════════════════════════════════════════════════════════════════════════╡ +│ 协议适配层 (Protocol Adapters) │ +│ │ +│ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌──────────────────┐ │ +│ │ AG-UI Protocol │ │ OpenClaw Protocol │ │ 上下文构建器 │ │ 多模态解析器 │ │ +│ │ (SSE / HTTP) │ │ (WebSocket / RPC) │ │ (会话/用户/项目) │ │ (文本/图片/语音)│ │ +│ └─────────┬──────────┘ └─────────┬──────────┘ └────────────────────┘ └──────────────────┘ │ +│ │ │ │ +╞════════════╪═══════════════════════╪═══════════════════════════════════════════════════════════════╡ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 后端 Agent 运行时 (灵活可选, 独立或组合接入) │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────┐ ┌────────────────────────────────────────────┐ │ │ +│ │ │ OpenClaw Gateway │ │ 自建 Agent 编排服务 │ │ │ +│ │ │ 开箱即用 · 快速验证 │ │ 深度定制 · 复杂编排 │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ · Gateway Server │ │ ┌─────────────┐ ┌─────────────────────┐ │ │ │ +│ │ │ · Session / Channel 管理 │ │ │ Main Agent │ │ Sub-Agents │ │ │ │ +│ │ │ · Agent Runtime (内置) │ │ │ (Planner) │─▶│ 对话│检索│工具│代码 │ │ │ │ +│ │ │ · Browser Control │ │ └─────────────┘ └─────────────────────┘ │ │ │ +│ │ │ · 设备认证 / 健康检测 │ │ ┌─────────────┐ ┌─────────────────────┐ │ │ │ +│ │ │ │ │ │ 提示词管理 │ │ 模型路由(多模型切换) │ │ │ │ +│ │ │ │ │ └─────────────┘ └─────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────┘ └────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 公共能力 (各运行时共享) │ │ │ +│ │ │ 对话管理 (会话状态/历史/记忆压缩) │ 人机协同 (Approval/Interactive Toolcall) │ │ │ +│ │ └─────────────────────────────────────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Event Bus & 异步任务队列 (支持离线/驻留 Agent) │ │ +│ │ │ │ +│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ +│ │ │ 事件总线 │ │ 任务调度器 │ │ 定时/Cron 触发 │ │ Agent 驻留管理 │ │ │ +│ │ │ (Pub/Sub) │ │ (优先级队列) │ │ (周期任务) │ │ (后台长驻Agent) │ │ │ +│ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +├═════════════════════════════════════════════════════════════════════════════════════════════════════╡ +│ 能力池 (Skills & MCP) │ +│ │ +│ ┌──────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐ │ +│ │ Skills 池 (内置能力) │ │ MCP 池 (外部工具) │ │ +│ │ │ │ │ │ +│ │ ┌────────┐ ┌────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ 文件 │ │ 网页 │ │ │ │ GitHub │ │ Jira │ │ Slack │ │ │ +│ │ │ 读写 │ │ 搜索 │ │ │ │ Server │ │ Server │ │ Server │ │ │ +│ │ └────────┘ └────────┘ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ 数据库 │ │ 图片 │ │ 邮件 │ │ │ │ 数据库 │ │ 监控 │ │ 日历 │ │ │ +│ │ │ 查询 │ │ 生成 │ │ 收发 │ │ │ │ MCP │ │ MCP │ │ MCP │ │ │ +│ │ └────────┘ └────────┘ └────────┘ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ ┌────────┐ ┌────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ 日程 │ │ 截屏 │ │ │ │ Figma │ │ Notion │ │ 自定义 │ │ │ +│ │ │ 管理 │ │ 录屏 │ │ │ │ MCP │ │ MCP │ │ MCP ... │ │ │ +│ │ └────────┘ └────────┘ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ │ │ +│ │ ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ │ │ ┌─────────────────────────────────────────┐ │ │ +│ │ ┊ 🔒 隔离执行沙箱 (Sandbox / E2B) ┊ │ │ │ MCP Protocol (stdio / SSE / Streamable) │ │ │ +│ │ ┊ ┊ │ │ │ Tool / Resource / Prompt / Sampling │ │ │ +│ │ ┊ ┌────────┐ ┌────────┐ ┊ │ │ └─────────────────────────────────────────┘ │ │ +│ │ ┊ │ 代码 │ │ Shell │ ┊ │ │ │ │ +│ │ ┊ │ 执行 │ │ 命令 │ ┊ │ │ │ │ +│ │ ┊ └────────┘ └────────┘ ┊ │ │ │ │ +│ │ ┊ · 容器级隔离 · 资源配额限制 ┊ │ │ │ │ +│ │ ┊ · 网络策略管控 · 超时自动回收 ┊ │ │ │ │ +│ │ └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ │ │ │ │ +│ │ │ │ │ │ +│ │ ┌─────────────────────────────────────┐ │ │ │ │ +│ │ │ group:fs │ group:runtime │ exec │ │ │ │ │ +│ │ │ 文件组 │ 运行时组 │ 执行器 │ │ │ │ │ +│ │ └─────────────────────────────────────┘ │ │ │ │ +│ └──────────────────────────────────────────┘ └──────────────────────────────────────────────┘ │ +│ │ +├═════════════════════════════════════════════════════════════════════════════════════════════════════╡ +│ 基础设施层 │ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐ │ +│ │ 知识库 │ │ 模型网关 │ │ 记忆存储 │ │ 提示词存储 │ │ 链路/指标 │ │ 审计日志 │ │ +│ │ (RAG/向量) │ │(Venus/多源)│ │ · 会话短期记忆 │ │ │ │ 存储 │ │ │ │ +│ │ │ │ │ │ · 用户长期记忆 │ │ │ │ │ │ │ │ +│ │ │ │ │ │ · 实体/关系图谱 │ │ │ │ │ │ │ │ +│ └────────────┘ └────────────┘ └──────────────────┘ └──────────────┘ └────────────┘ └──────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Prompt 安全防线 (模型网关前置) │ │ +│ │ Prompt 注入检测 │ 敏感词过滤 │ PII 脱敏 │ 越权指令拦截 │ 输出合规校验 │ │ +│ └──────────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ LLM 模型层 │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ GPT-4o │ │ Claude │ │ Gemini │ │ DeepSeek │ │ Kimi │ │ 混元 │ ... │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +├═════════════════════════════════════════════════════════════════════════════════════════════════════╡ +│ 公共组件 (贯穿全链路) │ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ RBAC │ │ Trace │ │ Audit │ │ 多租户 │ │ 实验管理 │ │ +│ │ 权限控制 │ │ 链路追踪 │ │ 审计日志 │ │ 隔离 │ │ A/B Test │ │ +│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ ┌──────────────────────────────────────┐ ┌──────────────────────────────────────────────────┐ │ +│ │ AgentOps / Evals (评测闭环) │ │ AI Guardrails (安全护栏) │ │ +│ │ │ │ │ │ +│ │ · Agent 行为追踪 & 可观测 │ │ · 输入/输出内容安全检测 │ │ +│ │ · 自动化评测 (Benchmark/回归) │ │ · 幻觉检测 & 事实性校验 │ │ +│ │ · 质量打分 & 人工标注 │ │ · 合规策略引擎 (政策/行业规范) │ │ +│ │ · 评测数据集管理 & A/B 对比 │ │ · 异常行为熔断 (Token/频率/权限) │ │ +│ └──────────────────────────────────────┘ └──────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +## 数据流说明 + +### 场景一:OpenClaw 协议 (微信等端) + +```mermaid +sequenceDiagram + actor User as 用户 + participant Client as 客户端 (微信)
TDesign Chat + participant Protocol as OpenClaw Protocol
(WebSocket) + participant Agent as OpenClaw Gateway
Agent Runtime + + User->>Client: ① 输入 (文本/语音/截图/Dom选取) + Client->>Client: ② ChatEngine 构建请求
(上下文组装 + 消息协议封装) + Client->>Protocol: ③ WebSocket connect + Protocol->>Agent: ④ 建立连接 (设备认证/Session) + Agent-->>Protocol: 连接确认 + Protocol-->>Client: 连接就绪 + + Client->>Protocol: ⑤ chat.send (用户消息) + Protocol->>Agent: 转发请求 + + loop Agent 执行循环 + Agent->>Agent: ⑥ 意图解析 + Planner 任务规划 + Agent->>Agent: ⑦ 调用 Skills / MCP 工具 + Agent->>Agent: ⑧ LLM 推理生成 + Agent-->>Protocol: ⑨ 流式推送 (TEXT/TOOL_CALL/ACTIVITY/STATE) + Protocol-->>Client: 流式转发 + Client->>Client: ⑩ 渲染展示
(Markdown/ToolCall Widget/Generative UI) + Client-->>User: 实时展示结果 + end + + opt 人机协同 (Human-in-the-Loop) + Agent-->>Protocol: 请求用户确认 (Approval) + Protocol-->>Client: 展示确认弹窗 + Client-->>User: 等待确认 + User->>Client: 确认/拒绝 + Client->>Protocol: 返回决策 + Protocol->>Agent: 继续/中止执行 + end +``` + +### 场景二:自建 Agent 编排服务 (小程序 / Web) + +```mermaid +sequenceDiagram + actor User as 用户 + participant Client as 客户端 (小程序/Web)
TDesign Chat + participant Protocol as AG-UI Protocol
(SSE / HTTP) + participant Agent as 自建 Agent
编排服务 + + User->>Client: ① 输入 (文本/语音/截图/Dom选取) + Client->>Client: ② ChatEngine 构建请求
(上下文组装 + 多模态预处理 + 消息格式化) + + Client->>Protocol: ③ HTTP POST (用户消息) + Protocol->>Agent: ④ 转发请求 (携带会话上下文) + + Agent->>Agent: ⑤ 意图解析 + Main Agent (Planner) 任务规划 + Agent->>Agent: ⑥ 分发 Sub-Agents (对话/检索/工具/代码) + + loop Sub-Agent 执行 + Agent->>Agent: ⑦ 调用 Skills / MCP 工具 + Agent->>Agent: ⑧ LLM 推理生成 + Agent-->>Protocol: ⑨ SSE 流式推送 (TEXT/TOOL_CALL/ACTIVITY/STATE) + Protocol-->>Client: SSE 事件流 + Client->>Client: ⑩ 渲染展示
(Markdown/ToolCall Widget/Generative UI) + Client-->>User: 实时展示结果 + end + + opt 人机协同 (Human-in-the-Loop) + Agent-->>Protocol: SSE 事件: 请求用户确认 + Protocol-->>Client: 展示确认交互 + Client-->>User: 等待确认 + User->>Client: 确认/拒绝 + Client->>Protocol: HTTP POST (用户决策) + Protocol->>Agent: 继续/中止执行 + end + + opt Interactive Toolcall (交互式工具调用) + Agent-->>Protocol: SSE 事件: Toolcall Widget + Protocol-->>Client: 渲染交互组件 (表单/选择器/地图等) + Client-->>User: 展示交互组件 + User->>Client: 填写/选择 + Client->>Protocol: HTTP POST (交互结果) + Protocol->>Agent: 工具调用携带用户输入继续执行 + end +``` + +## 关键设计点 + +1. **前端统一入口**:TDesign Chat (ChatEngine) 作为唯一前端引擎,通过协议适配器屏蔽后端差异 +2. **双协议支持**:AG-UI (SSE/HTTP) 和 OpenClaw (WebSocket) 并行,通过适配器屏蔽差异 +3. **后端灵活可选**:OpenClaw Gateway 开箱即用,自建编排服务深度定制,各自独立接入,按场景轻重自由选择 +4. **能力池化**:Skills (内置能力) + MCP (外部工具) 统一注册、按需调用,任何运行时均可接入 +5. **流式全链路**:从 LLM 推理到前端渲染,全程流式传输,支持增量更新 +6. **沙箱隔离**:代码执行、Shell 命令等高风险操作在隔离沙箱 (Sandbox/E2B) 中运行,容器级隔离 + 资源配额 + 网络管控 + 超时回收 +7. **安全纵深防御**:Prompt 注入检测 → 敏感词/PII 过滤 → AI Guardrails (输入输出安全检测 + 幻觉校验 + 合规策略 + 异常熔断),贯穿请求全生命周期 +8. **精细化记忆**:会话短期记忆 → 用户长期记忆 → 实体/关系图谱,支持记忆压缩与按需召回 +9. **评测闭环**:AgentOps 行为追踪 + 自动化评测 (Benchmark/回归) + 质量打分 + A/B 对比,驱动持续优化 +10. **异步 & 驻留**:Event Bus + 异步任务队列支持离线驻留 Agent,定时触发、后台长驻、优先级调度 diff --git a/docs/ai-infra-migrate.md b/docs/ai-infra-migrate.md new file mode 100644 index 0000000000..acf381ed53 --- /dev/null +++ b/docs/ai-infra-migrate.md @@ -0,0 +1,91 @@ +## 一、当前现状梳理 + +### 已有规范文档资产 + +| 文档 | 位置 | 内容概要 | 状态 | +|------|------|---------|------| +| **develop-install.md** | `packages/common/` | 6 种构建产物规范(dist/es/esm/lib/ejs)、package.json 入口配置标准 | ✅ 完整,可直接引用 | +| **api.md** | `packages/common/` | API 设计规范:TNode 类型、事件命名、v-model、size 默认值等 | ✅ 完整,可直接引用 | +| **css-naming.md** | `packages/common/` | BEM 命名规范、状态类名 `t-is-*`、尺寸类 `t-size-*` | ✅ 完整,可直接引用 | +| **naming.md** | `packages/common/` | 组件命名规范(被 css-naming.md 引用) | ✅ 存在 | +| **tdesign-react-spec-coding-guide.md** | `.codebuddy/` | React 编码指导概述(forwardRef、useDefaultProps 等) | ⚠️ 概述性,缺少可执行的 SOP | +| **CONTRIBUTING.md** | 根目录 | 贡献指南(分支策略、PR 流程) | ✅ 完整 | +| **PUBLISH.md** | 根目录 | 发布流程说明 | ✅ 完整 | + +### 已有工程工具 + +| 工具 | 位置 | 状态 | +|------|------|------| +| 组件脚手架 | `script/init-component/` | ⚠️ 旧版,路径写死 `src/`,不支持 monorepo | +| CSS 变量生成 | `packages/common/scripts/generate-css-vars.mjs` | ✅ 可用 | +| Rollup 构建配置 | `script/rollup.config.js` | ✅ 支持 6 种产物 | +| 快照测试框架 | `test/snap/` | ✅ CSR + SSR | + +### 缺失/待建设 + +| 项目 | 说明 | +|------|------| +| **Specs 体系** | 无结构化的 spec 定义目录 | +| **Skills 体系** | 无 AI 可执行的 SKILL.md SOP | +| **错误知识库** | 无 common-errors.md 类型的知识修正文档 | +| **模式库** | 组件开发模式散落在代码注释中,未系统化 | + +--- + +## 二、重要 Specs 规划 + +Specs 定义"**是什么**"——标准、规范、架构约束。 + +| Spec 名称 | 内容来源 | 核心内容 | +|-----------|---------|---------| +| **build-output-spec** | develop-install.md | 6 种产物格式定义(dist/es/esm/lib/ejs/umd)、package.json 入口字段标准、sideEffects 配置 | +| **api-design-spec** | api.md | TNode 类型系统、事件命名规范(on 前缀)、size 三档默认值、v-model 双向绑定 | +| **css-naming-spec** | css-naming.md | BEM `t-[block]__[element]--[modifier]`、状态类 `t-is-*`、尺寸类 `t-size-*` | +| **component-architecture-spec** | 散落规范整合 | 组件目录结构标准、type.ts/defaultProps.ts 生成规则、forwardRef 模式、入口导出规范 | +| **testing-strategy-spec** | test/ 目录分析 | 单测覆盖率标准、CSR/SSR 快照策略、Vitest 配置规范 | +| **aigc-component-spec** | pro-components 分析 | Chat 组件体系架构、独立构建链、`@tdesign-react/chat` 发布规范 | + +--- + +## 三、重要 Skills 规划 + +Skills 定义"**怎么做**"——可执行的 SOP 流程。 + +### A. 开发规范类(最高优先级) + +| Skill 名称 | 用途 | SOP 要点 | +|------------|------|---------| +| **`tdesign-create-component`** | 新组件脚手架 | 1) 选择目标包(components/pro-components) 2) 生成完整目录结构 3) 自动注册到 index.ts 和 site.config.mjs 4) 遵循 forwardRef + useDefaultProps 模式 | +| **`tdesign-component-style`** | 组件样式开发 | 1) BEM 类名生成 2) Less 变量引用路径 3) `t-is-*` 状态类使用 4) CSS Variables 文档生成 | +| **`tdesign-api-type-sync`** | API 类型同步 | 1) type.ts 生成规范 2) TNode 类型定义标准 3) 事件 Props `onXxx` 命名 4) defaultProps.ts 对齐 | + +### B. 测试辅助类 + +| Skill 名称 | 用途 | SOP 要点 | +|------------|------|---------| +| **`tdesign-test-component`** | 组件单元测试 | 1) Vitest + @testing-library/react 2) `__tests__/vitest-*.test.jsx` 命名 3) 渲染/事件/异步断言模式 | +| **`tdesign-snapshot-update`** | 快照维护 | 1) CSR/SSR 快照更新流程 2) 何时更新快照 vs 修复代码的决策 | + +### C. 发布运维类 + +| Skill 名称 | 用途 | SOP 要点 | +|------------|------|---------| +| **`tdesign-release`** | 版本发布 | 1) release 分支创建 2) CHANGELOG 生成 3) 6 种产物校验 4) npm publish 流程 | +| **`tdesign-build-verify`** | 构建验证 | 1) 6 种产物格式检查清单 2) package.json 入口字段验证 3) sideEffects 配置验证 | + +### D. 知识修正类 + +| Skill 名称 | 用途 | SOP 要点 | +|------------|------|---------| +| **`tdesign-common-errors`** | 常见错误参考 | lodash→lodash-es、type.ts 不可手动编辑、`useConfig()` vs 硬编码前缀、React 19 适配等 | +| **`tdesign-pattern-reference`** | 组件模式库 | forwardRef 标准模式、受控/非受控(useControlled)、TNode 渲染、虚拟滚动模式 | + +--- + +## 四、优先级建议 + +| 优先级 | Specs | Skills | +|--------|-------|--------| +| **P0** | build-output-spec, api-design-spec, css-naming-spec | `tdesign-create-component`, `tdesign-common-errors`, `tdesign-test-component` | +| **P1** | component-architecture-spec, aigc-component-spec | `tdesign-component-style`, `tdesign-pattern-reference`, `tdesign-release` | +| **P2** | testing-strategy-spec | `tdesign-monorepo-migrate`, `tdesign-api-type-sync`, `tdesign-build-verify` | diff --git a/packages/ai-core b/packages/ai-core index 7e277aa55c..19a407ea7e 160000 --- a/packages/ai-core +++ b/packages/ai-core @@ -1 +1 @@ -Subproject commit 7e277aa55cfbfffd481f0149eb2e30edc8b4d8ee +Subproject commit 19a407ea7e7c6d02e7e67229677caf0a2c3d8dfb diff --git a/packages/components/checkbox/index.ts b/packages/components/checkbox/index.ts index 94118847c5..f3fdb8c45a 100644 --- a/packages/components/checkbox/index.ts +++ b/packages/components/checkbox/index.ts @@ -1,10 +1,12 @@ import './style/index.js'; import _Checkbox from './Checkbox'; +import _CheckboxGroup from './CheckboxGroup'; export type { CheckboxProps } from './Checkbox'; export type { CheckboxGroupProps } from './CheckboxGroup'; export * from './type'; export const Checkbox = _Checkbox; +export const CheckboxGroup = _CheckboxGroup; export default Checkbox; diff --git a/packages/pro-components/chat/_util/reactify.tsx b/packages/pro-components/chat/_util/reactify.tsx index cf1f82f9e0..31eb4acea9 100644 --- a/packages/pro-components/chat/_util/reactify.tsx +++ b/packages/pro-components/chat/_util/reactify.tsx @@ -1,62 +1,29 @@ import React, { Component, createElement, createRef, 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], 10); - return majorVersion >= 19; -}; +import type { Root } from 'react-dom/client'; -// 增强版本的缓存管理 -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); - } +type AnyProps = { + [key: string]: any; +}; - return { - render: (element: React.ReactElement) => { - // 可选:避免相同元素的重复渲染 - if (cached.lastElement !== element) { - cached.root.render(element); - cached.lastElement = element; - } - }, - unmount: () => { - cached.root.unmount(); - rootCache.delete(container); - }, - }; - } +const hyphenateRE = /\B([A-Z])/g; +const hyphenate = (string: string) => string.replace(hyphenateRE, '-$1').toLowerCase(); - // React 17的实现 - return { - render: (element: React.ReactElement) => { - ReactDOM.render(element, container); - }, - unmount: () => { - ReactDOM.unmountComponentAtNode(container); - }, - }; +const styleObjectToString = (style: any) => { + if (!style) return ''; + if (typeof style === 'string') return style; + return Object.keys(style) + .reduce((acc: string[], key) => { + const value = style[key]; + const cssKey = key.replace(hyphenateRE, '-$1').toLowerCase(); + return acc.concat(`${cssKey}:${value}`); + }, []) + .join(';'); }; -// 检查是否是React元素 -const isReactElement = (obj: any): obj is React.ReactElement => - obj && typeof obj === 'object' && obj.$$typeof && obj.$$typeof.toString().includes('react'); +const isReactElement = (node: any): boolean => React.isValidElement(node); -// 检查是否是有效的React节点 const isValidReactNode = (node: any): node is React.ReactNode => node !== null && node !== undefined && @@ -66,73 +33,33 @@ const isValidReactNode = (node: any): node is React.ReactNode => 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 isReact19Plus = () => { + const version = React.version.split('.')[0]; + return parseInt(version, 10) >= 19; }; const reactify = ( WC: string, displayName?: string, ): React.ForwardRefExoticComponent & React.RefAttributes> => { - class Reactify extends Component { + class Reactify extends Component { eventHandlers: [string, EventListener][]; - slotRenderers: Map void>; + // 使用 Map 缓存每个 slot 的 React Root 实例 + slotInstances: Map< + string, + { + root: Root; + container: HTMLElement; + } + >; ref: React.RefObject; - constructor(props: AnyProps) { + constructor(props: T) { super(props); this.eventHandlers = []; - this.slotRenderers = new Map(); + this.slotInstances = new Map(); const { innerRef } = props; this.ref = innerRef || createRef(); } @@ -142,47 +69,29 @@ const reactify = ( this.ref.current?.addEventListener(event, val); } - // 防止重复处理的标记 - private processingSlots = new Set(); + // 根据shadow DOM中实际存在的slot解析正确的slot名 + private resolveSlotName(prop: string) { + const baseName = prop.endsWith('Slot') ? prop.slice(0, -4) : prop; + const kebabName = hyphenate(baseName); + const hasKebabSlot = this.ref.current?.shadowRoot?.querySelector(`slot[name="${kebabName}"]`) !== null; + return hasKebabSlot ? kebabName : baseName; + } // 处理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); + // 函数式 slot 每次调用可能产生新内容,这里简化处理, + // 实际场景中可能需要更复杂的挂载逻辑,或者 WebComponent 内部处理了 DOM 挂载 + // 这里保持原逻辑或根据 WebComponent 行为调整 return this.renderReactNodeToSlot(reactNode, prop); }; webComponent[prop] = renderSlot; - // 函数类型处理完成后立即移除标记 - this.processingSlots.delete(prop); } // 如果val是ReactNode,直接渲染到slot else if (isValidReactNode(val)) { @@ -195,77 +104,26 @@ const reactify = ( webComponent.update(); } this.renderReactNodeToSlot(val, prop); - // 渲染完成后移除处理标记 - this.processingSlots.delete(prop); }); + } else { + this.cleanupSlotRenderer(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); - } - - // 安全清理渲染器 - // eslint-disable-next-line class-methods-use-this - 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); + const instance = this.slotInstances.get(slotName); + if (instance) { + // React 18 使用 root.unmount() + setTimeout(() => { + instance.root.unmount(); + }, 0); + + if (instance.container.parentNode === this.ref.current) { + this.ref.current?.removeChild(instance.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; + this.slotInstances.delete(slotName); } - - // 对于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中 @@ -273,79 +131,32 @@ const reactify = ( const webComponent = this.ref.current; if (!webComponent) return; - // 检查是否已经有相同的slot容器存在,避免重复创建 - const existingContainers = webComponent.querySelectorAll(`[slot="${slotName}"]`); - if (existingContainers.length > 0) { - return; - } + const resolveSlotName = this.resolveSlotName(slotName); - // 直接创建容器并添加到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); - } + let instance = this.slotInstances.get(slotName); + + if (!instance) { + // 检查是否已经有相同的slot容器存在 + let container = webComponent.querySelector(`[slot="${resolveSlotName}"]`) as HTMLElement; + if (!container) { + container = document.createElement('div'); + container.style.display = 'contents'; + container.setAttribute('slot', resolveSlotName); + webComponent.appendChild(container); } + + // 创建 React Root + const root = createRoot(container); + + instance = { + container, + root, + }; + this.slotInstances.set(slotName, instance); } - // 保存cleanup函数 - this.slotRenderers.set(slotName, () => { - // 清理缓存 - this.lastRenderedElements.delete(slotName); - // 异步unmount避免竞态条件 - Promise.resolve().then(() => { - if (cleanupFn) { - cleanupFn(); - } - if (container.parentNode) { - container.parentNode.removeChild(container); - } - }); - }); + // 复用 root 进行增量更新,React 会自动处理 Diff,避免 DOM 销毁重建 + instance.root.render(reactNode); } update() { @@ -363,18 +174,18 @@ const reactify = ( 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]/)) { + if (isReactElement(val) && !prop.match(/^on[A-Za-z]/)) { const componentClass = this.ref.current?.constructor as any; const declaredSlots = componentClass?.slotProps || []; + // 如果webc组件的slotProps显式声明了这个slot,或prop以“Slot”结尾,就把传入的reactdom挂载到webc组件对应的占位符上 + const isDeclaredSlot = declaredSlots.includes(prop) || prop.endsWith('Slot'); - if (declaredSlots.includes(prop) || prop.endsWith('Slot')) { + // 允许通过shadow dom检测到的slot自动挂载 + const possibleSlotName = hyphenate(prop.endsWith('Slot') ? prop.slice(0, -4) : prop); + const hasShadowSlot = + this.ref.current?.shadowRoot?.querySelector(`slot[name="${possibleSlotName}"]`) !== null; + + if (isDeclaredSlot || hasShadowSlot) { this.handleSlotProp(prop, val); return; } @@ -420,7 +231,7 @@ const reactify = ( componentWillUnmount() { this.clearEventHandlers(); - this.clearSlotRenderers(); + this.clearSlotInstances(); } clearEventHandlers() { @@ -430,23 +241,32 @@ const reactify = ( this.eventHandlers = []; } - clearSlotRenderers() { - this.slotRenderers.forEach((cleanup) => { - this.safeCleanupRenderer(cleanup); + clearSlotInstances() { + this.slotInstances.forEach((instance) => { + // 卸载时清理 + setTimeout(() => { + instance.root.unmount(); + }, 0); }); - this.slotRenderers.clear(); - this.processingSlots.clear(); + this.slotInstances.clear(); } render() { const { children, className, ...rest } = this.props; - - return createElement(WC, { class: className, ...rest, ref: this.ref }, children); + // 仅将基本类型作为attribute传递,其余复杂类型在update中处理 + const filteredProps: Record = {}; + Object.keys(rest).forEach((key) => { + const val = (rest as Record)[key]; + if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') { + filteredProps[key] = val; + } + }); + return createElement(WC, { class: className, ...filteredProps, ref: this.ref }, children); } } const ReactifiedComponent = forwardRef((props, ref) => - createElement(Reactify, { ...props, innerRef: ref }), + createElement(Reactify as unknown as React.ComponentType, { ...props, innerRef: ref }), ) as React.ForwardRefExoticComponent & React.RefAttributes>; // Use provided displayName, or fall back to converting kebab-case tag name to PascalCase diff --git a/packages/pro-components/chat/chat-actionbar/_example/custom.tsx b/packages/pro-components/chat/chat-actionbar/_example/custom.tsx index dfee769317..d730878d74 100644 --- a/packages/pro-components/chat/chat-actionbar/_example/custom.tsx +++ b/packages/pro-components/chat/chat-actionbar/_example/custom.tsx @@ -1,15 +1,43 @@ -import React from 'react'; -import { Space } from 'tdesign-react'; +import React, { useState } from 'react'; +import { MessagePlugin, Space } from 'tdesign-react'; import { ChatActionBar } from '@tdesign-react/chat'; +import type { ChatActionBarAction } from '@tdesign-react/chat'; +import { HeartFilledIcon, HeartIcon } from 'tdesign-icons-react'; const ChatActionBarExample = () => { + const [isCustomActionActive, setIsCustomActionActive] = useState(false); + const onActions = (name, data) => { - console.log('消息事件触发:', name, data); + console.log('触发自定义事件:', name, data); + }; + + const handleHeartClick = () => { + setIsCustomActionActive((prev) => !prev); + MessagePlugin.success(isCustomActionActive ? '取消' : '点赞'); + console.log('自定义按钮状态:', isCustomActionActive ? '取消' : '点赞'); }; + const customIconActions: ChatActionBarAction[] = [ + // ChatActionBar提供的预设项 + 'good', + 'share', + + // 自定项:可传自定义icon、通过onClick定义事件回调 + isCustomActionActive ? ( + + ) : ( + + ), + ]; + return ( - + ); }; diff --git a/packages/pro-components/chat/chat-actionbar/chat-actionbar.md b/packages/pro-components/chat/chat-actionbar/chat-actionbar.md index c2f481e215..3bf033f9d1 100644 --- a/packages/pro-components/chat/chat-actionbar/chat-actionbar.md +++ b/packages/pro-components/chat/chat-actionbar/chat-actionbar.md @@ -18,7 +18,7 @@ spline: aigc ## 自定义 -目前仅支持有限的自定义,包括调整顺序,展示指定项 +支持完全自定义action {{ custom }} diff --git a/packages/pro-components/chat/chat-actionbar/index.tsx b/packages/pro-components/chat/chat-actionbar/index.tsx index 4f244d5dac..b017b54feb 100644 --- a/packages/pro-components/chat/chat-actionbar/index.tsx +++ b/packages/pro-components/chat/chat-actionbar/index.tsx @@ -1,54 +1,56 @@ import 'tdesign-web-components/lib/chat-action'; -import reactify from '../_util/reactify'; +import React from 'react'; -import type { TdChatActionProps } from 'tdesign-web-components'; +import reactify from '../_util/reactify'; -export const ChatActionBar: React.ForwardRefExoticComponent< - Omit & - React.RefAttributes & { - [key: string]: any; - } -> = reactify('t-chat-action'); +import type { TdChatActionProps, TdChatActionsName } from 'tdesign-web-components'; + +type ChatActionBarAction = + | TdChatActionsName + | React.ReactElement + | { + name: string; + render?: React.ReactNode; + ignoreWrapper?: boolean; + }; + +type ChatActionBarProps = Omit & { + actionBar?: boolean | ChatActionBarAction[]; + ref?: React.Ref; +}; + +const BaseChatActionBar = reactify('t-chat-action'); + +const normalizeSlotName = (raw: string) => raw.replace(/[^a-zA-Z0-9_-]/g, '-'); + +export const ChatActionBar = (props: ChatActionBarProps) => { + const { actionBar, ref, ...rest } = props; + const slotProps: Record = {}; + let mappedActionBar = actionBar; + + if (Array.isArray(actionBar)) { + mappedActionBar = actionBar.map((action, index) => { + if (React.isValidElement(action)) { + const key = action.key != null ? String(action.key) : `item-${index}`; + const slotName = normalizeSlotName(`action-${key}`); + slotProps[`${slotName}Slot`] = action; + return { name: slotName }; + } + return action; + }); + } + + return ( + + ); +}; export default ChatActionBar; +export type { ChatActionBarAction }; 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-engine/AGENTS.md b/packages/pro-components/chat/chat-engine/AGENTS.md index 0be0ebf04c..537e826195 100644 --- a/packages/pro-components/chat/chat-engine/AGENTS.md +++ b/packages/pro-components/chat/chat-engine/AGENTS.md @@ -2,210 +2,530 @@ > **SYSTEM PROMPT / INSTRUCTIONS** > This file contains the foundational rules, architectural context, and coding standards for the TDesign ChatEngine SDK. -> As an AI Agent (Cursor, Copilot, Windsurf), you MUST read and follow these instructions before generating any code using this SDK. +> As an AI Agent (Cursor, Copilot, Windsurf, CodeBuddy), you MUST read and follow these instructions before generating any code using this SDK. ## 1. Project Context & Identity - **Name**: TDesign ChatEngine (React) - **Type**: React Component Library / Headless SDK -- **Goal**: Provide a complete, production-ready solution for building AI chat applications, supporting standard protocols (AG-UI) and Generative UI. +- **Goal**: Provide a complete, production-ready solution for building AI chat applications, supporting standard protocols (AG-UI, OpenClaw) and Generative UI. - **Package**: `@tdesign-react/chat` - **Key Features**: - - Headless `ChatEngine` for logic reuse. + - Headless `ChatEngine` for logic reuse (framework-agnostic core). - `ChatBot` component for out-of-the-box usage. - - Native support for **AG-UI Protocol** (Agent-User Interaction). + - Multi-protocol support: **AG-UI**, **OpenClaw** (WebSocket), and **Custom**. - **Generative UI** engine (json-render & A2UI support). + - Resume & Replay: `resumeRun()` for breakpoint recovery. + - Typed Event Bus with history, `waitFor`, and custom events. -## 2. Core Concepts +## 2. Architecture Overview -### 2.1 ChatEngine (Headless Core) +``` +┌─────────────────────────────────────────────────┐ +│ React Layer (hooks/ + components/) │ +│ useChat, useAgentToolcall, useAgentState, │ +│ useAgentActivity, ToolCallRenderer, │ +│ ActivityRenderer, JsonRender, A2UI │ +├─────────────────────────────────────────────────┤ +│ Core (Framework-agnostic) │ +│ ┌───────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ ChatEngine│ │ EventBus │ │ Store │ │ +│ │ (Facade) │ │ (Pub/Sub) │ │ (Reactive) │ │ +│ └─────┬─────┘ └──────────────┘ └────────────┘ │ +│ │ │ +│ ┌─────┴──────────────────────────────────────┐ │ +│ │ StreamHandlers (Strategy Pattern) │ │ +│ │ Default | AGUI | OpenClaw │ │ +│ ├────────────────────────────────────────────┤ │ +│ │ Adapters │ │ +│ │ AGUI | OpenClaw | json-render | A2UI │ │ +│ ├────────────────────────────────────────────┤ │ +│ │ Server (Network Layer) │ │ +│ │ SSE Client | WebSocket Client | Batch │ │ +│ └────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## 3. Core Concepts + +### 3.1 ChatEngine (Headless Core) The `ChatEngine` is the logic core that handles: -- **Message Management**: Send, receive, update, delete, and history management. -- **Stream Processing**: Built-in SSE (Server-Sent Events) and fetch stream handling. -- **Protocol Adaptation**: Adapts backend data to frontend message formats (AG-UI or Custom). -- **Event Bus**: Pub/Sub system for cross-component communication and side effects. +- **Message Management**: Send, receive, update, delete, history management, and version branching (`regenerateAIMessage(keepVersion)`). +- **Stream Processing**: Built-in SSE and WebSocket handling via pluggable `StreamHandler` strategy. +- **Protocol Adaptation**: Adapts backend data to frontend message formats via protocol-specific adapters. +- **Event Bus**: Typed Pub/Sub system with history tracking, `waitFor`/`waitForMatch` Promise API, and custom events. +- **Resume & Replay**: `resumeRun()` for recovering interrupted Agent runs via `MESSAGES_SNAPSHOT`. -### 2.2 ChatBot (UI Component) +### 3.2 ChatBot (UI Component) `ChatBot` is a high-level component built on top of `ChatEngine` that provides: - **Complete UI**: Message list, input area, action bar, and auto-scroll. -- **Built-in Rendering**: Markdown (Cherry Markdown), Thinking process, Tool calls, Suggestions. +- **Built-in Rendering**: Markdown (Cherry Markdown), Thinking process, Tool calls, Suggestions, Search references. - **Slot Customization**: `headerSlot`, `messageSlot`, `senderSlot`, `footerSlot`. -### 2.3 AG-UI Protocol +### 3.3 Supported Protocols + +#### AG-UI Protocol A standard protocol for AI Agent interaction. - **Events**: - - `TEXT_MESSAGE_*`: Streaming text responses. - - `THINKING_*`: AI thought process visibility. - - `TOOL_CALL_*`: Agent tool usage (Start -> Args -> End -> Result). - - `ACTIVITY_*`: Dynamic content (Charts, Forms) via Snapshots & Deltas. - - `STATE_*`: Shared agent state synchronization. -- **Flow**: Stream-based, event-driven architecture. - -### 2.4 Generative UI -Dynamic UI generation based on AI output, adopting the **json-render** (Vercel Labs) philosophy with a dual-layer architecture: -- **Catalog (Constraint Layer)**: Defines what components AI can use (Zod Schema). + - `TEXT_MESSAGE_*` / `TEXT_MESSAGE_CHUNK`: Streaming text responses. + - `THINKING_*` / `THINKING_TEXT_MESSAGE_*`: AI thought process visibility. + - `TOOL_CALL_*`: Agent tool usage (Start → Args → End → Chunk → Result). + - `ACTIVITY_SNAPSHOT/DELTA`: Dynamic content (Charts, Forms) via Snapshots & JSON Patch Deltas. + - `STATE_SNAPSHOT/DELTA`: Shared agent state synchronization. + - `MESSAGES_SNAPSHOT`: Bulk message restore for resume/replay. + - `STEP_STARTED/FINISHED`: Multi-step agent progress tracking. + - `RUN_STARTED/FINISHED/ERROR`: Agent run lifecycle. + - `RAW`, `CUSTOM`: Extensible raw/custom events. +- **Flow**: SSE stream-based, event-driven architecture. + +#### OpenClaw Protocol +WebSocket-based protocol for real-time AI chat (long-lived connections). +- **Features**: Heartbeat keep-alive, connect handshake, RPC handler, history auto-load. +- **Config**: `{ heartbeatInterval?, client?: { id, version, mode }, protocolVersion?: { min, max } }` +- **Key Pattern**: `protocol: 'openclaw'` + `openclaw: { ... }` in `chatServiceConfig`. + +#### Custom Protocol +For non-standard backends, use `protocol: 'default'` with custom `onMessage` parser. + +### 3.4 Generative UI +Dynamic UI generation based on AI output, adopting the **json-render** philosophy with a dual-layer architecture: +- **Catalog (Constraint Layer)**: Defines what components AI can use (Zod Schema → LLM system prompt). - **Registry (Render Layer)**: Defines how components are rendered (React implementation). +- **Contexts**: Built-in `DataProvider`, `VisibilityProvider`, `ActionProvider`, `ValidationProvider` for state/data binding. - **Protocols**: - `json-render`: Native adjacency-list schema (flat structure), details [here](https://json-render.dev/docs). - - `A2UI`: Google's adjacency-list schema (supported via built-in adapter),details [here](https://a2ui.org/specification/v0.9-a2ui/). + - `A2UI`: Google's adjacency-list schema (supported via built-in adapter), details [here](https://a2ui.org/specification/v0.9-a2ui/). > **Note**: The ChatEngine Generative UI engine is optimized for and exclusively supports these flat adjacency-list schemas for efficient streaming and updates. -## 3. Key APIs & Hooks +## 4. Key APIs & Hooks -### 3.1 `useChat` +### 4.1 `useChat` The main hook for initializing and managing chat state. ```typescript const { chatEngine, messages, status } = useChat({ chatServiceConfig: { endpoint: '/api/chat', - protocol: 'agui', // Recommended: 'agui' for standard agents + protocol: 'agui', // 'agui' | 'openclaw' | 'default' stream: true, - // Optional: Custom headers or body - onRequest: (params) => ({ ...params, headers: { Authorization: '...' } }) + timeout: 60000, // Request timeout (ms) + maxRetries: 3, // Auto-retry count + retryInterval: 1000, // Retry delay (ms) + // OpenClaw-specific config + openclaw: { heartbeatInterval: 30000, client: { id: 'my-app' } }, + // Callbacks + onRequest: (params) => ({ ...params, headers: { Authorization: '...' } }), + onStart: (chunk) => console.log('First chunk:', chunk), + onMessage: (chunk, message, parsed) => { /* custom parse */ }, + onComplete: (isAborted, params, result) => { /* cleanup */ }, + onError: (err) => console.error(err), + onAbort: async () => { /* abort side effects */ }, + isValidChunk: (chunk) => chunk.data !== '[DONE]', // Chunk validator + onHistoryLoaded: (msgs) => { /* OpenClaw history */ }, }, defaultMessages: [] // Optional initial messages }); ``` -### 3.2 `useAgentToolcall` -Registers custom UI components for AI tool calls. +### 4.2 `useAgentToolcall` +Registers custom UI components for AI tool calls. Supports both **non-interactive** (auto-execute) and **interactive** (Human-in-the-Loop) modes. ```typescript +// Interactive mode (Human-in-the-Loop) useAgentToolcall({ - name: 'weather_query', // Must match backend tool name - component: WeatherCard, // React Component receiving { args, result, status } - // Optional: Subscribe to specific state keys during execution - subscribeKey: (props) => props.args?.taskId + name: 'search_flight', // Must match backend tool name + component: FlightSearchCard, // React Component receiving ToolcallComponentProps + subscribeKey: (props) => props.args?.taskId, // Optional: subscribe to agent state }); + +// Non-interactive mode (auto-execute with handler) +useAgentToolcall({ + name: 'get_weather', + component: WeatherCard, + handler: async (args) => { // Auto-executes, result fed back to agent + const res = await fetch(`/api/weather?city=${args.city}`); + return res.json(); + }, +}); + +// Batch registration +useAgentToolcall([config1, config2, config3]); + +// Returns management API +const { register, unregister, isRegistered, getRegistered, config } = useAgentToolcall(); ``` -### 3.3 `useAgentActivity` +**ToolcallComponentProps**: +```typescript +interface ToolcallComponentProps { + status: 'idle' | 'executing' | 'complete' | 'error'; + args: TArgs; // Parsed tool arguments + result?: TResult; // Tool execution result + error?: Error; // Error if failed + respond?: (response: TResponse) => void; // Interactive callback + agentState?: Record; // Subscribed agent state +} +``` + +### 4.3 `useAgentActivity` Registers components for dynamic content (Generative UI). ```typescript useAgentActivity({ - activityType: 'stock-chart', // Must match backend activity type - component: StockChart // React Component receiving { content } + activityType: 'stock-chart', // Must match backend activity type + component: StockChart, // React Component receiving ActivityComponentProps + description: 'Real-time stock chart', }); + +// Batch registration +useAgentActivity([activityConfig1, activityConfig2]); + +// Returns management API +const { register, unregister, isRegistered, getRegistered } = useAgentActivity(); +``` + +**ActivityComponentProps**: +```typescript +interface ActivityComponentProps { + activityType: string; + content: TContent; // Activity data (snapshot + deltas applied) + messageId: string; +} ``` -### 3.4 `useAgentState` -Subscribes to shared agent state (AG-UI `STATE_*` events). +### 4.4 `useAgentState` +Subscribes to shared agent state (AG-UI `STATE_SNAPSHOT/DELTA` events). ```typescript -const { stateMap, currentStateKey } = useAgentState(); -// Access state: stateMap['task_1'] +const { + stateMap, // Record - all state entries + currentStateKey, // Latest active state key + setStateMap, // Manual state setter + getCurrentState, // Get current active state data + getStateByKey, // Get state by specific key +} = useAgentState({ + initialState: {}, // Optional initial state + subscribeKey: 'task_1', // Optional: subscribe to specific key +}); +``` + +**Simplified selector hook**: +```typescript +// Auto-detects AgentStateProvider context; falls back to standalone subscription +const stateData = useAgentStateDataByKey('task_1'); +``` + +### 4.5 `ChatEngine` Instance Methods + +```typescript +interface IChatEngine { + // Message operations + sendUserMessage(params: ChatRequestParams): Promise; + sendAIMessage(options: { params?, content?, sendRequest? }): Promise; + sendSystemMessage(msg: string): void; + resumeRun(params?: ChatRequestParams): Promise; // Resume interrupted run + regenerateAIMessage(keepVersion?: boolean): Promise; // keepVersion=true for branching + setMessages(msgs, mode?: 'replace' | 'prepend' | 'append'): void; + clearMessages(): void; + + // Request control + sendRequest(params: ChatRequestParams): Promise; // Raw request (no auto message) + abortChat(): Promise; + + // AG-UI specific + getToolcallByName(name: string): ToolCall | undefined; + + // Extensibility + registerMergeStrategy(type: string, handler: MergeHandler): void; + + // State + readonly messages: ChatMessagesData[]; + readonly status: ChatStatus; + readonly messageStore: MessageStore; + readonly eventBus: IChatEventBus; + + // Lifecycle + init(config, messages?): void; + destroy(): void; +} +``` + +### 4.6 Event Bus API + +```typescript +interface IChatEventBus { + // Standard listeners + on(event: ChatEngineEventType, callback): UnsubscribeFn; + once(event, callback): UnsubscribeFn; + off(event, callback?): void; + emit(event, payload): void; + + // Promise-based waiting + waitFor(event, timeout?): Promise; + waitForMatch(event, filterFn, timeout?): Promise; + + // Custom events (user-defined event names) + onCustom(eventName: string, callback): UnsubscribeFn; + emitCustom(eventName: string, data: any): void; + + // History & management + getHistory(): EventHistoryItem[]; + clear(): void; + destroy(): void; +} ``` -### 3.5 Generative UI Helpers -- `generateCatalogPrompt`: Generates system prompt for LLM. -- `createCustomRegistry`: Creates component registry. -- `createJsonRenderActivityConfig`: Configures json-render activity. +**Event Types (`ChatEngineEventType`)**: + +| Category | Events | +|----------|--------| +| Lifecycle | `ENGINE_INIT`, `ENGINE_DESTROY` | +| Message | `MESSAGE_CREATE`, `MESSAGE_UPDATE`, `MESSAGE_DELETE`, `MESSAGE_CLEAR`, `MESSAGE_STATUS_CHANGE`, `MESSAGE_CONTENT_APPEND` | +| Request | `REQUEST_START`, `REQUEST_STREAM`, `REQUEST_COMPLETE`, `REQUEST_ERROR`, `REQUEST_ABORT` | +| AG-UI | `AGUI_RUN_START`, `AGUI_RUN_COMPLETE`, `AGUI_RUN_ERROR`, `AGUI_STATE_UPDATE`, `AGUI_TOOLCALL`, `AGUI_ACTIVITY` | +| Custom | `CUSTOM` | + +### 4.7 Generative UI Helpers -## 4. Coding Standards & Best Practices +| Function | Description | +|----------|-------------| +| `generateCatalogPrompt(catalog)` | Generates LLM system prompt from component catalog (Zod Schema). | +| `createCustomRegistry(components, options?)` | Creates component registry (extends built-in TDesign components). | +| `createJsonRenderActivityConfig(options)` | Creates json-render Activity config for `useAgentActivity`. | +| `createA2UIJsonRenderActivityConfig(options)` | Creates A2UI Activity config with built-in protocol adapter. | +| `tdesignRegistry` | Built-in TDesign component registry (Button, Input, Card, Text, Row, Col, Space, Column, Divider). | +| `a2uiRegistry` / `createA2UIRegistry(...)` | A2UI protocol component registries. | +| `withStableProps(Component)` | Performance HOC using `React.memo` + `react-fast-compare`. | +| `withA2UIBinding(Component, config)` | A2UI data-binding HOC (auto `valuePath`/`disabledPath`/`action.context`). | + +**Generative UI Contexts** (for building interactive json-render components): + +| Context | Hooks | +|---------|-------| +| `DataProvider` | `useDataValue`, `useDataBinding`, `useDataUpdate`, `useDataState`, `useDataStore` | +| `VisibilityProvider` | `useVisibility`, `useIsVisible` | +| `ActionProvider` | `useActions`, `useAction`, `ConfirmDialog` | +| `ValidationProvider` | `useValidation`, `useFieldValidation` | + +### 4.8 Content Type System + +```typescript +// Message roles +type ChatMessageRole = 'user' | 'assistant' | 'system'; + +// Message status +type ChatMessageStatus = 'pending' | 'streaming' | 'complete' | 'stop' | 'error'; +type ChatStatus = 'idle' | ChatMessageStatus; + +// Content types (18+ varieties) +type ChatContentType = 'text' | 'markdown' | 'search' | 'attachment' | 'thinking' + | 'image' | 'audio' | 'video' | 'suggestion' | 'reasoning' | 'toolcall' | 'activity'; + +// AI message content (union type) +type AIMessageContent = TextContent | MarkdownContent | ThinkingContent | ImageContent + | SearchContent | SuggestionContent | ReasoningContent | ToolCallContent | ActivityContent; +``` + +**Type Guards** (all exported): +`isUserMessage`, `isAIMessage`, `isTextContent`, `isMarkdownContent`, `isThinkingContent`, `isImageContent`, `isSearchContent`, `isSuggestionContent`, `isAttachmentContent`, `isToolCallContent`, `isActivityContent`, `isReasoningContent` + +### 4.9 Utility Functions + +| Function | Description | +|----------|-------------| +| `applyJsonPatch(state, delta)` | Immutable JSON Patch (RFC 6902) with structural sharing. | +| `safeParseJSON(value, fallback?, context?)` | Safe JSON.parse with fallback. | +| `findTargetElement(event, selector)` | DOM target finder (Shadow DOM aware). | +| `getMessageContentForCopy(message)` | Extract message text for clipboard. | +| Content factories | `createMarkdownContent`, `createTextContent`, `createThinkingContent`, `createReasoningContent`, `createSuggestionContent`, `createToolCallContent`, `createActivityContent`, `createAIMessageContent` | + +## 5. UI Components + +### Core Components +| Component | Description | +|-----------|-------------| +| `ChatBot` | All-in-one chat component (Web Component bridge). | +| `ChatList` | Message list with auto-scroll. | +| `ChatMessage` | Single message bubble. | +| `ChatSender` | Input area with file upload support. | +| `ChatActionBar` | Message actions (copy, like, dislike). | +| `ChatMarkdown` / `MarkdownEngine` | Markdown rendering. | +| `ChatThinking` | Thinking process display. | +| `ChatLoading` | Loading animation. | +| `Filecard` | File card display. | +| `Attachments` | Attachment display. | +| `ChatSearchContent` | Search reference content. | +| `ChatSuggestionContent` | Suggestion chips. | + +### Engine Renderer Components +| Component | Description | +|-----------|-------------| +| `ToolCallRenderer` | Auto-renders registered tool call UI. | +| `ActivityRenderer` | Auto-renders registered Activity UI. | +| `JsonRenderActivityRenderer` | json-render protocol renderer. | +| `A2UIJsonRenderActivityRenderer` | A2UI protocol renderer. | +| `ComponentErrorBoundary` | Error boundary for dynamic components. | + +## 6. Coding Standards & Best Practices - **Prefer Hooks**: Use `useChat`, `useAgentToolcall`, etc., over direct class instantiation when in React components. -- **Protocol First**: Prefer `protocol: 'agui'` for standard AI agent integration. It handles streaming, tool calls, and state automatically. +- **Protocol First**: Prefer `protocol: 'agui'` for standard AI agent integration. Use `protocol: 'openclaw'` for WebSocket-based real-time chat. It handles streaming, tool calls, and state automatically. - **Generative UI Safety**: Always use `Catalog` to constrain AI output. Never allow AI to generate arbitrary HTML/JS. -- **State Management**: Use `useAgentState` for cross-component state sharing instead of prop drilling or external stores when dealing with Agent state. -- **Customization**: +- **State Management**: Use `useAgentState` / `useAgentStateDataByKey` for cross-component state sharing instead of prop drilling or external stores when dealing with Agent state. +- **Customization**: - Use `ChatBot` slots (`messageSlot`, `headerSlot`) for minor tweaks. - Use `useChat` + atomic components (`ChatList`, `ChatSender`) for full custom layouts. +- **Performance**: Use `withStableProps` for Generative UI components to leverage structural sharing with `React.memo`. +- **Error Handling**: Wrap dynamic components with `ComponentErrorBoundary`. Use `onError` callback in config. +- **Message Modes**: Use `setMessages(msgs, 'append')` for pagination, `'prepend'` for history load, `'replace'` for full reset. -## 5. Anti-Patterns (What NOT to do) +## 7. Anti-Patterns (What NOT to do) -- ❌ **Do not** manually parse SSE streams if using `protocol: 'agui'`. The SDK handles this. +- ❌ **Do not** manually parse SSE streams if using `protocol: 'agui'` or `protocol: 'openclaw'`. The SDK handles this. - ❌ **Do not** modify `messages` state directly; use `chatEngine` methods (`sendUserMessage`, `setMessages`). - ❌ **Do not** use `dangerouslySetInnerHTML` for AI content; use built-in Markdown or Generative UI components. - ❌ **Do not** mix `protocol: 'agui'` with custom `onMessage` parsers unless you strictly need to override specific event handling. +- ❌ **Do not** create multiple `ChatEngine` instances for the same chat; use a single instance via `useChat`. +- ❌ **Do not** forget to call `destroy()` or rely on hook cleanup when unmounting — `useChat` handles this automatically. -## 6. Scenario Guide & Examples +## 8. Scenario Guide & Examples -This section maps common development scenarios to specific implementation patterns and example files found in `packages/pro-components/chat/chat-engine/_example`,and you can also refer to the `packages/pro-components/chat/chat-engine.md`for more details. +This section maps common development scenarios to specific implementation patterns and example files found in `packages/pro-components/chat/chat-engine/_example`. Also refer to `packages/pro-components/chat/chat-engine/chat-engine.md` for more details. -### 6.1 Basic Usage Scenarios +### 8.1 Basic Usage Scenarios -#### 6.1.1 Quick Start (Basic Chat) +#### 8.1.1 Quick Start (Basic Chat) - **Goal**: Create a simple chat interface with minimal configuration. - **Key Pattern**: `useChat` + `ChatBot` or `ChatList/ChatSender`. - **Example**: `basic.tsx` ```tsx -// Minimal setup const { chatEngine, messages } = useChat({ chatServiceConfig: { endpoint: '/api/chat', stream: true } }); ``` -#### 6.1.2 Managing History (Initial Messages) +#### 8.1.2 Managing History (Initial Messages) - **Goal**: Load chat history or set a welcome message. -- **Key Pattern**: `defaultMessages` prop or `chatEngine.setMessages()`. +- **Key Pattern**: `defaultMessages` prop or `chatEngine.setMessages(msgs, mode)`. - **Example**: `initial-messages.tsx` ```tsx -// Load history - -// Or dynamic load -useEffect(() => { - fetchHistory().then(msgs => chatEngine.setMessages(msgs)); -}, []); +// Load history with append mode +chatEngine.setMessages(historyMessages, 'append'); +// Or prepend for infinite scroll +chatEngine.setMessages(olderMessages, 'prepend'); ``` -#### 6.1.3 Controlling the Engine (Instance Methods) +#### 8.1.3 Controlling the Engine (Instance Methods) - **Goal**: Programmatically send messages, stop generation, or clear chat. -- **Key Pattern**: `chatEngine.sendUserMessage()`, `chatEngine.abort()`, `chatEngine.clearMessages()`. +- **Key Pattern**: `chatEngine.sendUserMessage()`, `chatEngine.abortChat()`, `chatEngine.clearMessages()`. - **Example**: `instance-methods.tsx` -#### 6.1.4 Custom UI Rendering +#### 8.1.4 Custom UI Rendering - **Goal**: Customize message bubbles, action bars, or input areas. - **Key Pattern**: Slots (`messageSlot`, `headerSlot`) or Custom Components in `onMessage`. - **Example**: `custom-content.tsx` -```tsx -// Custom message rendering via slot - msg.type === 'custom' ? : null} -/> -``` -### 6.2 AG-UI Protocol Scenarios (Agent Integration) +### 8.2 AG-UI Protocol Scenarios (Agent Integration) -#### 6.2.1 Standard Agent Integration +#### 8.2.1 Standard Agent Integration - **Goal**: Connect to an AG-UI compliant backend (supports streaming, tools, thinking). - **Key Pattern**: `protocol: 'agui'`. - **Example**: `agui-basic.tsx` -#### 6.2.2 Tool Calling (Human-in-the-Loop) -- **Goal**: Render UI for agent tool calls (e.g., weather widget, forms) and handle user interaction. +#### 8.2.2 Tool Calling (Human-in-the-Loop) +- **Goal**: Render UI for agent tool calls and handle user interaction. - **Key Pattern**: `useAgentToolcall` + `ToolCallRenderer`. - **Example**: `agui-toolcall.tsx` ```tsx useAgentToolcall({ name: 'search_flight', component: ({ args, respond }) => ( - respond(data)} // Return user input to agent /> ) }); ``` -#### 6.2.3 Real-time Status Subscription -- **Goal**: Display agent progress (e.g., "Scanning database...") outside the chat flow. +#### 8.2.3 Real-time Status Subscription +- **Goal**: Display agent progress outside the chat flow. - **Key Pattern**: `useAgentState` subscribing to `STATE_SNAPSHOT/DELTA` events. -- **Example**: `agui-comprehensive.tsx` (See `GlobalProgressBar` component) +- **Example**: `agui-comprehensive.tsx` ```tsx const { stateMap } = useAgentState(); const progress = stateMap['current_task']?.percent || 0; ``` -#### 6.2.4 Dynamic Content (Activity) +#### 8.2.4 Dynamic Content (Activity) - **Goal**: Display live-updating charts or dashboards driven by the agent. - **Key Pattern**: `useAgentActivity` + `ACTIVITY_SNAPSHOT/DELTA` events. - **Example**: `agui-activity.tsx` -### 6.3 Advanced / Headless Scenarios +#### 8.2.5 Resume Interrupted Run +- **Goal**: Recover an Agent run that was interrupted (e.g., page reload, network error). +- **Key Pattern**: `chatEngine.resumeRun()` + backend `MESSAGES_SNAPSHOT` event. +- **Example**: `agui-resume.tsx` +```tsx +// Resume the last run — backend sends MESSAGES_SNAPSHOT to restore state +const messageId = await chatEngine.resumeRun({ params: { runId: lastRunId } }); +``` + +#### 8.2.6 Chunk-based Streaming +- **Goal**: Handle `TEXT_MESSAGE_CHUNK` events for character-by-character streaming. +- **Key Pattern**: `protocol: 'agui'` with chunk events. +- **Example**: `agui-chunk.tsx` + +### 8.3 OpenClaw Protocol Scenarios + +#### 8.3.1 Basic OpenClaw Chat +- **Goal**: Connect to an OpenClaw WebSocket backend. +- **Key Pattern**: `protocol: 'openclaw'` + `openclaw: { ... }`. +- **Example**: `openclaw-basic.tsx` +```tsx +const { chatEngine, messages } = useChat({ + chatServiceConfig: { + endpoint: 'wss://api.example.com/ws', + protocol: 'openclaw', + openclaw: { + heartbeatInterval: 30000, + client: { id: 'my-app', version: '1.0' } + } + } +}); +``` + +#### 8.3.2 OpenClaw with Tool Calls & Activity +- **Goal**: Use tool calls and dynamic Activity content over OpenClaw protocol. +- **Example**: `openclaw-toolcall-activity.tsx` + +### 8.4 Generative UI Scenarios + +#### 8.4.1 Full Generative UI (json-render) +- **Goal**: Let AI generate complex UI based on a predefined component catalog. +- **Key Pattern**: `generateCatalogPrompt` (Constraint) + `createCustomRegistry` (Render) + `createJsonRenderActivityConfig`. +- **Example**: `agui-json-render-full-custom.tsx` + +#### 8.4.2 External/Side-Panel Rendering +- **Goal**: Render AI-generated UI in a side panel instead of the chat stream. +- **Key Pattern**: Listen to `AGUI_ACTIVITY` event via `eventBus` and render independently. +- **Example**: `agui-json-render-external-panel.tsx` +```tsx +chatEngine.eventBus.on(ChatEngineEventType.AGUI_ACTIVITY, (event) => { + if (event.activityType === 'dashboard') { + setSidePanelContent(event); + } +}); +``` + +#### 8.4.3 A2UI Protocol Support +- **Goal**: Render Google's A2UI protocol (flat-list schema) using the json-render engine. +- **Key Pattern**: `createA2UIJsonRenderActivityConfig`. +- **Example**: `agui-a2ui-json-render.tsx`, `custom-a2ui.tsx` + +### 8.5 Advanced / Headless Scenarios -#### 6.3.1 Headless Event Bus +#### 8.5.1 Headless Event Bus - **Goal**: Handle side effects (logging, analytics) without UI coupling. - **Key Pattern**: `chatEngine.eventBus.on()`. - **Example**: `headless-eventbus.tsx` @@ -215,7 +535,39 @@ chatEngine.eventBus.on(ChatEngineEventType.REQUEST_COMPLETE, (payload) => { }); ``` -#### 6.3.2 Complex Multi-Step Agent +#### 8.5.2 Promise-based Event Waiting +- **Goal**: Wait for a specific event before continuing execution. +- **Key Pattern**: `chatEngine.eventBus.waitFor()` / `waitForMatch()`. +```tsx +// Wait for run to complete (with 30s timeout) +const result = await chatEngine.eventBus.waitFor(ChatEngineEventType.AGUI_RUN_COMPLETE, 30000); +// Wait for a specific tool call +const toolResult = await chatEngine.eventBus.waitForMatch( + ChatEngineEventType.AGUI_TOOLCALL, + (event) => event.toolName === 'get_weather', + 10000 +); +``` + +#### 8.5.3 Complex Multi-Step Agent - **Goal**: Build a complex agent with planning, tools, and dynamic UI. - **Key Pattern**: Combining `useAgentToolcall`, `useAgentActivity`, and `useAgentState`. -- **Example**: `agui-comprehensive.tsx` (Travel Planner Agent) \ No newline at end of file +- **Example**: `agui-comprehensive.tsx` (Travel Planner Agent) + +#### 8.5.4 Version Branching (Regenerate) +- **Goal**: Regenerate AI response while keeping previous version. +- **Key Pattern**: `chatEngine.regenerateAIMessage(true)` with `keepVersion=true`. +```tsx +// Regenerate and keep history version +await chatEngine.regenerateAIMessage(true); +``` + +#### 8.5.5 Custom Merge Strategy +- **Goal**: Define custom content merging logic for streaming updates. +- **Key Pattern**: `chatEngine.registerMergeStrategy(type, handler)`. +```tsx +chatEngine.registerMergeStrategy('custom-chart', (existing, incoming) => ({ + ...existing, + data: { ...existing.data, ...incoming.data } +})); +``` diff --git a/packages/pro-components/chat/chat-engine/_example/agui-a2ui-json-render.tsx b/packages/pro-components/chat/chat-engine/_example/agui-a2ui-json-render.tsx new file mode 100644 index 0000000000..8e105b6048 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-a2ui-json-render.tsx @@ -0,0 +1,220 @@ +/** + * AG-UI + A2UI + json-render 集成示例 + * + * 演示内容: + * 1. 使用 AG-UI 协议(protocol: 'agui')接收流式数据 + * 2. 通过 ACTIVITY_SNAPSHOT/ACTIVITY_DELTA 事件传递 A2UI 消息 + * 3. A2UIJsonRenderActivityRenderer 将 A2UI 转换为 json-render Schema + * 4. 使用 json-render 高性能渲染引擎渲染 UI + * + * 核心概念: + * - AG-UI 协议负责消息流的结构化传输 + * - A2UI 协议负责 UI 的声明式定义 + * - json-render 提供高性能渲染能力 + * - 三者通过 Activity 机制无缝集成 + */ +import React, { useState, useRef, useMemo } from 'react'; +import { + ChatList, + ChatSender, + ChatMessage, + type ChatRequestParams, + isActivityContent, + ActivityRenderer, +} from '@tdesign-react/chat'; +import { useChat, useAgentActivity, createA2UIJsonRenderActivityConfig } from '@tdesign-react/chat'; +import { MessagePlugin } from 'tdesign-react'; + +// Mock Server 地址 +const MOCK_SERVER = 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com'; + +export default function AguiA2UIJsonRenderExample() { + const [inputValue, setInputValue] = useState('创建一个用户信息表单(A2UI + json-render)'); + const [currentStage, setCurrentStage] = useState(''); + const listRef = useRef(null); + + // 使用 useChat 创建 ChatEngine 实例 + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: `${MOCK_SERVER}/sse/a2ui-json-render-activity`, + // 开启 AG-UI 协议解析 + protocol: 'agui', + stream: true, + // 自定义请求参数 + onRequest: (params: ChatRequestParams) => { + const requestBody: Record = { + uid: 'agui-a2ui-json-render-demo', + prompt: params.prompt, + demoMode: true, + }; + + // 如果有用户操作消息,添加到请求体 + if ((params as any).userActionMessage) { + requestBody.userActionMessage = (params as any).userActionMessage; + console.log('📤 发送用户操作到服务端:', requestBody.userActionMessage); + } + + return { + body: JSON.stringify(requestBody), + }; + }, + // 生命周期回调 + onStart: (chunk) => { + console.log('[AG-UI] 流式传输开始:', chunk); + setCurrentStage('🚀 开始流式传输'); + }, + onComplete: (aborted, _params, event) => { + console.log('[AG-UI] 流式传输完成:', { aborted, event }); + setCurrentStage('✅ 传输完成'); + }, + onError: (err) => { + console.error('[AG-UI] 错误:', err); + const errorMsg = err instanceof Error ? err.message : '请求失败'; + MessagePlugin.error(`请求失败: ${errorMsg}`); + setCurrentStage(`❌ 错误: ${errorMsg}`); + }, + }, + }); + + // 创建 A2UI + json-render Activity 配置 + const a2uiJsonRenderConfig = useMemo( + () => + createA2UIJsonRenderActivityConfig({ + activityType: 'a2ui-json-render', + debug: true, // 开启调试模式 + // Action 处理器映射表(统一使用 actionHandlers) + actionHandlers: { + // 重置操作:本地处理 + reset: async (params) => { + console.log('🔄 重置表单:', params); + // 注意:表单数据清空由 json-render 内部的 DataProvider 管理 + MessagePlugin.info('表单已重置'); + setCurrentStage('表单已重置'); + }, + + // 提交操作:发送到服务端 + submit: async (params) => { + console.log('🎯 提交表单:', params); + setCurrentStage('提交表单...'); + + try { + await chatEngine.sendAIMessage({ + params: { + userActionMessage: { + name: 'submit', + params, + timestamp: new Date().toISOString(), + }, + }, + sendRequest: true, + }); + + setCurrentStage('表单提交成功'); + MessagePlugin.success('提交成功'); + listRef.current?.scrollList({ to: 'bottom' }); + } catch (error) { + console.error('❌ 提交失败:', error); + MessagePlugin.error(`提交失败: ${error instanceof Error ? error.message : '未知错误'}`); + setCurrentStage('提交失败'); + } + }, + + // 取消操作:本地处理 + cancel: async (params) => { + console.log('❌ 取消操作:', params); + MessagePlugin.info('已取消'); + setCurrentStage('已取消'); + }, + }, + }), + [chatEngine], + ); + + // 注册 A2UI + json-render Activity 渲染器 + useAgentActivity(a2uiJsonRenderConfig); + + // 发送消息 + const handleSend = async (e: CustomEvent<{ value: string }>) => { + const { value } = e.detail; + if (!value.trim()) return; + + // 重置状态 + setCurrentStage(''); + + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + // 停止生成 + const handleStop = () => { + chatEngine.abortChat(); + MessagePlugin.info('已停止生成'); + }; + + // 渲染消息内容(Activity 需要手动渲染到 slot) + const renderMessageContent = (item: any, index: number) => { + if (isActivityContent(item)) { + return ( +
+ +
+ ); + } + return null; + }; + + const renderMsgContents = (message: any) => { + if (Array.isArray(message.content)) { + return <>{message.content.map((item: any, index: number) => renderMessageContent(item, index))}; + } + return null; + }; + + return ( +
+ {/* 标题区域 */} +
+

AG-UI + A2UI + json-render 集成演示

+

+ 使用 A2UI 协议定义 UI,通过 json-render 高性能渲染引擎渲染 +

+ {currentStage && ( +
+ 当前状态: {currentStage} +
+ )} +
+ + {/* 消息列表 */} + + {messages.map((message) => ( + + {renderMsgContents(message)} + + ))} + + + {/* 输入区域 */} + ) => setInputValue(e.detail)} + onSend={handleSend as any} + onStop={handleStop} + /> +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/agui-basic.tsx b/packages/pro-components/chat/chat-engine/_example/agui-basic.tsx index 0babb0964f..5db910934a 100644 --- a/packages/pro-components/chat/chat-engine/_example/agui-basic.tsx +++ b/packages/pro-components/chat/chat-engine/_example/agui-basic.tsx @@ -53,7 +53,34 @@ export default function AguiBasicExample() { ); const result = await response.json(); if (result.success && result.data) { - const messages = AGUIAdapter.convertHistoryMessages(result.data); + const messages = AGUIAdapter.convertHistoryMessages([ + { + id: '34560ac6-b64a-4bcf-90a3-621dea69acce', + role: 'user', + content: [ + { + type: 'text', + data: '你好', + }, + { + type: 'attachment', + data: [ + { + fileType: 'image', + name: '20260205090555_40916970.png', + url: 'http://tlab-test-1258344699.cos-internal.ap-guangzhou.tencentcos.cn/ai-chat/env-agent-langgraph/statics/images/20260205090555_40916970.png', + }, + ], + }, + ], + }, + { + id: 'b3e5fa37-83ff-493e-91d9-0aba93b1ac98', + role: 'assistant', + content: + '你好!看起来你正在查看或编辑某个配置文件(可能是 package.json 或类似依赖列表),其中包含多个前端库及其版本号,还有一条报错信息:\n\n```\nDuplicate "previewTitle" attribute in JSX element\n```\n\n这通常是在使用 React/JSX 时,某个元素重复定义了 `previewTitle` 属性导致的语法错误。\n\n如果你需要:\n- **理解这个错误的原因和修复方法** → 我可以帮你分析(路由到 `knowledge`)\n- **排查具体哪段代码出问题** → 需要更多上下文(可路由到 `diagnosis`)\n- **直接修改配置或生成修复代码** → 明确说“帮我修复”或“生成修正后的代码”(路由到 `craft`)\n\n请告诉我你想做什么?😊', + }, + ]); chatEngine.setMessages(messages); listRef.current?.scrollList({ to: 'bottom' }); } diff --git a/packages/pro-components/chat/chat-engine/_example/agui-comprehensive.tsx b/packages/pro-components/chat/chat-engine/_example/agui-comprehensive.tsx index 13a52a5748..723d9a6dce 100644 --- a/packages/pro-components/chat/chat-engine/_example/agui-comprehensive.tsx +++ b/packages/pro-components/chat/chat-engine/_example/agui-comprehensive.tsx @@ -529,6 +529,7 @@ const TravelPlannerContent: React.FC = () => { setInputValue(''); }; + console.log('====message', messages); return (
{/* 右侧进度面板:使用 useAgentState 订阅状态 */} diff --git a/packages/pro-components/chat/chat-engine/_example/agui-json-render-external-panel.tsx b/packages/pro-components/chat/chat-engine/_example/agui-json-render-external-panel.tsx new file mode 100644 index 0000000000..aa4bede7f5 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-json-render-external-panel.tsx @@ -0,0 +1,259 @@ +/** + * AG-UI + json-render 旁路 UI 渲染示例 + * + * 演示内容: + * ⭐ 通过 eventBus.on('AGUI_ACTIVITY') 监听消息变化,在对话框外部独立渲染生成式 UI + * + * 使用场景: + * - 需要将生成的 UI 渲染到页面的其他区域,如弹窗、侧边栏等,进行额外的控制和管理 + */ +import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import { type ChatRequestParams, ActivityRenderer, ChatEngineEventType } from '@tdesign-react/chat'; +import { useChat, useAgentActivity } from '@tdesign-react/chat'; +import { MessagePlugin, Button, Card, Space, Input } from 'tdesign-react'; + +import { + createJsonRenderActivityConfig, + createCustomRegistry, + type JsonRenderActivityProps, +} from '@tdesign-react/chat'; + +// 导入自定义组件 +import { StatusCard, ProgressBar } from './components'; + +// Mock Server 地址 +const MOCK_SERVER = 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com'; + +export default function AguiJsonRenderExternalPanelExample() { + const [inputValue, setInputValue] = useState('创建一个任务进度表单,包含状态卡片和进度条'); + + // ==================== ⭐ 旁路 UI 渲染状态 ==================== + // 用于控制外部面板可见性 + const [externalPanelVisible, setExternalPanelVisible] = useState(true); + + // 外部 Activity 内容(通过 eventBus 监听获取) + const [externalActivity, setExternalActivity] = useState(null); + + // 使用 useChat 创建 ChatEngine 实例 + const { chatEngine, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: `${MOCK_SERVER}/sse/json-render-activity`, + protocol: 'agui', + stream: true, + onRequest: (params: ChatRequestParams) => { + const requestBody: Record = { + uid: 'agui-json-render-external-panel-demo', + prompt: params.prompt, + demoMode: true, + }; + + if ((params as any).userActionMessage) { + requestBody.userActionMessage = (params as any).userActionMessage; + } + + return { + body: JSON.stringify(requestBody), + }; + }, + onError: (err) => { + const errorMsg = err instanceof Error ? err.message : '请求失败'; + MessagePlugin.error(`请求失败: ${errorMsg}`); + }, + }, + }); + + // 创建自定义 ComponentRegistry(渲染层) + const customRegistry = useMemo( + () => + createCustomRegistry({ + StatusCard, + ProgressBar, + }), + [], + ); + + // 创建 Activity 配置 + const jsonRenderConfig = createJsonRenderActivityConfig({ + activityType: 'json-render-main-card', + registry: customRegistry, + actionHandlers: { + submit: async () => { + MessagePlugin.success('提交成功'); + }, + refresh: async () => { + MessagePlugin.info('数据已刷新'); + }, + export: async () => { + MessagePlugin.info('开始导出数据'); + // 模拟导出 + setTimeout(() => { + MessagePlugin.success('导出成功'); + }, 1000); + }, + }, + }); + + // 注册 json-render Activity 渲染器 + useAgentActivity(jsonRenderConfig); + + // ==================== ⭐ 使用 eventBus.AGUI_ACTIVITY 监听 Activity 变化 ==================== + useEffect(() => { + if (!chatEngine) return; + + // 监听 AG-UI Activity 事件(细粒度事件,无需自行判断类型) + const unsubscribe = chatEngine.eventBus.on(ChatEngineEventType.AGUI_ACTIVITY, (event) => { + if (event.activityType === 'json-render-main-card') { + setExternalActivity(event.content as JsonRenderActivityProps['content']); + setExternalPanelVisible(true); + } + }); + + return () => unsubscribe(); + }, [chatEngine]); + + // 清空 Activity + const clearActivity = useCallback(() => { + setExternalActivity(null); + }, []); + + // 发送消息 + const handleSend = async () => { + if (!inputValue.trim()) return; + + await chatEngine.sendUserMessage({ prompt: inputValue }); + setInputValue(''); + }; + + // 停止生成 + const handleStop = () => { + chatEngine.abortChat(); + MessagePlugin.info('已停止生成'); + }; + + const isLoading = status === 'pending' || status === 'streaming'; + + return ( + + {/* ==================== 左侧:控制面板 ==================== */} + + {/* 输入和控制区域 */} + 发送请求
}> + + setInputValue(value)} + onEnter={handleSend} + disabled={isLoading} + /> + + + + + + + + {isLoading && ( +
⏳ 正在生成 UI,请查看右侧面板...
+ )} +
+ + + + {/* ==================== ⭐ 右侧:外部渲染面板(核心功能) ==================== */} + {externalPanelVisible && ( + +
+
⚡ 外部渲染面板
+
+ 独立于对话框的 UI 渲染区域 +
+
+ + } + > +
+ {externalActivity ? ( + <> + {/* 调试:展示原始 JSON */} +
+ + 🔍 查看原始 Schema + +
+                    {JSON.stringify(externalActivity, null, 2)}
+                  
+
+ + {/* 使用 ActivityRenderer 渲染 */} +
+ +
+ + ) : ( +
+
📭
+
等待 Activity 事件...
+
发送消息后,生成的 UI 将在此渲染
+
+ )} +
+
+ )} + + {/* 重新打开外部面板按钮 */} + {!externalPanelVisible && ( + + )} + + ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/agui-json-render-full-custom.tsx b/packages/pro-components/chat/chat-engine/_example/agui-json-render-full-custom.tsx new file mode 100644 index 0000000000..c5ac618ee1 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-json-render-full-custom.tsx @@ -0,0 +1,307 @@ +/** + * AG-UI + json-render 完整自定义示例 + * + * 演示内容: + * 1. 使用 createCustomCatalog 定义自定义组件的 props schema(约束层) + * 2. 使用 createCustomRegistry 注册自定义组件的 React 实现(渲染层) + * 3. 完整的两层架构演示 + */ +import React, { useMemo, useRef, useState } from 'react'; +import { MessagePlugin } from 'tdesign-react'; +import { z } from 'zod'; +import { + ActivityRenderer, + ChatList, + ChatMessage, + ChatSender, + createCustomRegistry, + createJsonRenderActivityConfig, + generateCatalogPrompt, + isActivityContent, + useAgentActivity, + useChat, +} from '@tdesign-react/chat'; + +// 导入自定义组件 +import { NestedPanel, ProgressBar, StatusCard } from './components'; + +import type { ChatRequestParams } from '@tdesign-react/chat'; + +// Mock Server 地址 +const MOCK_SERVER = 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com'; + +export default function AguiJsonRenderFullCustomExample() { + const [inputValue, setInputValue] = useState('测试深层嵌套更新'); + const [currentStage, setCurrentStage] = useState(''); + const listRef = useRef(null); + + // ==================== 步骤 1: 创建自定义 Catalog(约束层) ==================== + // 定义自定义组件的 props schema 和 actions 白名单 + // 这个 Catalog 用于告诉 AI/服务端可以生成哪些组件及其约束 + // 注意:本 demo 主要演示前端渲染,Catalog 在实际生产中应传给后端服务(这里暂时用请求参数传递) + const customCatalog = useMemo( + () => + generateCatalogPrompt({ + name: 'my-dashboard', + components: { + // 定义 StatusCard 的 props schema + StatusCard: { + props: z.object({ + title: z.string(), + status: z.enum(['success', 'warning', 'error', 'info']), + description: z.string().nullable(), + icon: z.string().nullable(), + }), + description: 'Custom status card component for displaying status information', + }, + + // 定义 ProgressBar 的 props schema + ProgressBar: { + props: z.object({ + label: z.string().nullable(), + percentage: z.number().min(0).max(100), + showInfo: z.boolean().nullable(), + }), + description: 'Custom progress bar component for showing completion status', + }, + + // 定义 NestedPanel 的 props schema(用于验证深层嵌套更新) + NestedPanel: { + props: z.object({ + title: z.string(), + level: z.number().min(1).max(10).nullable(), + collapsed: z.boolean().nullable(), + borderColor: z.string().nullable(), + backgroundColor: z.string().nullable(), + }), + description: + 'Nested panel container for testing deep nesting updates. Can contain other components including itself.', + }, + }, + actions: { + // 除了内置的 submit/reset/cancel,添加自定义 actions + refresh: { description: 'Refresh data from server' }, + export: { description: 'Export data to file' }, + }, + }), + [], + ); + + // 使用 useChat 创建 ChatEngine 实例 + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: `${MOCK_SERVER}/sse/json-render-activity`, + protocol: 'agui', + stream: true, + onRequest: (params: ChatRequestParams) => { + const requestBody: Record = { + uid: 'agui-json-render-full-custom-demo', + prompt: params.prompt, + demoMode: true, + systemPrompt: customCatalog, + }; + + if ((params as any).userActionMessage) { + requestBody.userActionMessage = (params as any).userActionMessage; + } + + return { + body: JSON.stringify(requestBody), + }; + }, + onStart: () => { + setCurrentStage('🚀 开始流式传输'); + }, + onComplete: () => { + setCurrentStage('✅ 传输完成'); + }, + onError: (err) => { + const errorMsg = err instanceof Error ? err.message : '请求失败'; + MessagePlugin.error(`请求失败: ${errorMsg}`); + setCurrentStage(`❌ 错误: ${errorMsg}`); + }, + }, + }); + + // ==================== 步骤 2: 创建自定义 ComponentRegistry(渲染层) ==================== + // 注册自定义组件的 React 实现 + // 这个 Registry 用于告诉 Renderer 如何渲染组件 + const customRegistry = useMemo( + () => + createCustomRegistry({ + // 注册 React 组件实现(必须与 Catalog 中的组件名一致) + StatusCard, + ProgressBar, + NestedPanel, + // 注册基础 Div 组件(用于渲染后端返回的 Div 类型元素) + Div: ({ element, children }) => { + const { props } = element; + // const {props, children} = element; + return
{element.props.children || children}
; + }, + }), + [], + ); + + // ==================== 步骤 3: 创建 Activity 配置 ==================== + const jsonRenderConfig = createJsonRenderActivityConfig({ + activityType: 'json-render-main-card', + registry: customRegistry, // 使用自定义 registry(渲染层) + debug: true, + // 预定义 action handlers(必须与 Catalog 中的 actions 一致) + actionHandlers: { + // 内置 actions + submit: async (params) => { + setCurrentStage('提交表单...'); + + try { + await chatEngine.sendAIMessage({ + params: { + userActionMessage: { + name: 'submit', + params, + timestamp: new Date().toISOString(), + }, + }, + sendRequest: true, + }); + + setCurrentStage('表单提交成功'); + MessagePlugin.success('提交成功'); + listRef.current?.scrollList({ to: 'bottom' }); + } catch (error) { + MessagePlugin.error(`提交失败: ${error instanceof Error ? error.message : '未知错误'}`); + setCurrentStage('提交失败'); + } + }, + + reset: async () => { + MessagePlugin.info('表单已重置'); + setCurrentStage('表单已重置'); + }, + + cancel: async () => { + MessagePlugin.info('已取消'); + setCurrentStage('已取消'); + }, + + // 自定义 actions(与 Catalog 中定义的一致) + refresh: async (params) => { + MessagePlugin.info('数据已刷新'); + setCurrentStage('数据已刷新'); + + try { + await chatEngine.sendAIMessage({ + params: { + userActionMessage: { + name: 'refresh', + params, + timestamp: new Date().toISOString(), + }, + }, + sendRequest: true, + }); + } catch (error) { + console.error('刷新失败:', error); + } + }, + + export: async () => { + MessagePlugin.info('开始导出数据'); + setCurrentStage('导出数据中...'); + + // 模拟导出 + setTimeout(() => { + MessagePlugin.success('导出成功'); + setCurrentStage('导出完成'); + }, 1000); + }, + }, + }); + + // 注册 json-render Activity 渲染器 + useAgentActivity(jsonRenderConfig); + + // 发送消息 + const handleSend = async (e: CustomEvent<{ value: string }>) => { + const { value } = e.detail; + if (!value.trim()) return; + + setCurrentStage(''); + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + // 停止生成 + const handleStop = () => { + chatEngine.abortChat(); + MessagePlugin.info('已停止生成'); + }; + + // 渲染消息内容 + const renderMessageContent = (item: any, index: number) => { + if (isActivityContent(item)) { + return ( +
+ +
+ ); + } + return null; + }; + + const renderMsgContents = (message: any) => { + if (Array.isArray(message.content)) { + return <>{message.content.map((item: any, index: number) => renderMessageContent(item, index))}; + } + return null; + }; + + return ( +
+ {/* 标题区域 */} +
+

完整自定义组件 + json-render 演示

+

+ AI 生成符合 Catalog 约束的 JSON → json-render 引擎解析 → Registry 查找组件实现 → 渲染真实 UI +

+ {currentStage && ( +
+ 当前状态: {currentStage} +
+ )} +
+ + {/* 消息列表 */} + + {messages.map((message) => ( + + {renderMsgContents(message)} + + ))} + + + {/* 输入区域 */} + ) => setInputValue(e.detail)} + onSend={handleSend as any} + onStop={handleStop} + /> +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/agui-json-render.tsx b/packages/pro-components/chat/chat-engine/_example/agui-json-render.tsx new file mode 100644 index 0000000000..6224100f66 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-json-render.tsx @@ -0,0 +1,223 @@ +/** + * AG-UI + json-render 集成示例 + * + * 演示内容: + * 1. 使用 AG-UI 协议(protocol: 'agui')接收流式数据 + * 2. 通过 ACTIVITY_SNAPSHOT/ACTIVITY_DELTA 事件传递 json-render Schema + * 3. 使用 useAgentActivity 注册 json-render 渲染器 + * 4. 完整的 ChatEngine 实例用法展示 + * + * 核心概念: + * - AG-UI 协议负责消息流的结构化传输 + * - json-render 负责动态 UI 的高性能渲染 + * - 两者通过 Activity 机制无缝集成 + */ +import React, { useState, useRef, useMemo } from 'react'; +import { + ChatList, + ChatSender, + ChatMessage, + type ChatRequestParams, + isActivityContent, + ActivityRenderer, +} from '@tdesign-react/chat'; +import { useChat, useAgentActivity } from '@tdesign-react/chat'; +import { MessagePlugin } from 'tdesign-react'; + +import { createJsonRenderActivityConfig } from '../components/json-render'; + +// Mock Server 地址 +const MOCK_SERVER = 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com'; + +export default function AguiJsonRenderExample() { + const [inputValue, setInputValue] = useState('使用 json-render 创建一个用户信息表单'); + const [currentStage, setCurrentStage] = useState(''); + const listRef = useRef(null); + + // 使用 useChat 创建 ChatEngine 实例 + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: `${MOCK_SERVER}/sse/json-render-activity`, + // 开启 AG-UI 协议解析 + protocol: 'agui', + stream: true, + // 自定义请求参数 + onRequest: (params: ChatRequestParams) => { + const requestBody: Record = { + uid: 'agui-json-render-demo', + prompt: params.prompt, + demoMode: true, + }; + + // 如果有用户操作消息,添加到请求体 + if ((params as any).userActionMessage) { + requestBody.userActionMessage = (params as any).userActionMessage; + console.log('📤 发送用户操作到服务端:', requestBody.userActionMessage); + } + + return { + body: JSON.stringify(requestBody), + }; + }, + // 生命周期回调 + onStart: (chunk) => { + console.log('[AG-UI] 流式传输开始:', chunk); + setCurrentStage('🚀 开始流式传输'); + }, + onComplete: (aborted, _params, event) => { + console.log('[AG-UI] 流式传输完成:', { aborted, event }); + setCurrentStage('✅ 传输完成'); + }, + onError: (err) => { + console.error('[AG-UI] 错误:', err); + const errorMsg = err instanceof Error ? err.message : '请求失败'; + MessagePlugin.error(`请求失败: ${errorMsg}`); + setCurrentStage(`❌ 错误: ${errorMsg}`); + }, + }, + }); + + // 创建 json-render Activity 配置 + const jsonRenderConfig = useMemo( + () => + createJsonRenderActivityConfig({ + activityType: 'json-render', + debug: true, // 开启调试模式 + // Action 处理器映射表 + // 预定义所有可能的 action 及其处理函数 + actionHandlers: { + // 提交操作:发送到服务端 + submit: async (params) => { + console.log('🎯 提交表单:', params); + setCurrentStage('提交表单...'); + + try { + await chatEngine.sendAIMessage({ + params: { + userActionMessage: { + name: 'submit', + params, + timestamp: new Date().toISOString(), + }, + }, + sendRequest: true, + }); + + setCurrentStage('表单提交成功'); + MessagePlugin.success('提交成功'); + listRef.current?.scrollList({ to: 'bottom' }); + } catch (error) { + console.error('❌ 提交失败:', error); + MessagePlugin.error(`提交失败: ${error instanceof Error ? error.message : '未知错误'}`); + setCurrentStage('提交失败'); + } + }, + + // 重置操作:本地处理 + reset: async (params) => { + console.log('🔄 重置表单:', params); + // 注意:表单数据清空由 json-render 内部的 DataProvider 管理 + // 这里只需要显示提示消息 + MessagePlugin.info('表单已重置'); + setCurrentStage('表单已重置'); + }, + + // 取消操作:本地处理 + cancel: async (params) => { + console.log('❌ 取消操作:', params); + MessagePlugin.info('已取消'); + setCurrentStage('已取消'); + }, + }, + }), + [chatEngine], + ); + + // 注册 json-render Activity 渲染器 + useAgentActivity(jsonRenderConfig); + + // 发送消息 + const handleSend = async (e: CustomEvent<{ value: string }>) => { + const { value } = e.detail; + if (!value.trim()) return; + + // 重置状态 + setCurrentStage(''); + + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + // 停止生成 + const handleStop = () => { + chatEngine.abortChat(); + MessagePlugin.info('已停止生成'); + }; + + // 渲染消息内容(Activity 需要手动渲染到 slot) + const renderMessageContent = (item: any, index: number) => { + if (isActivityContent(item)) { + return ( +
+ +
+ ); + } + return null; + }; + + const renderMsgContents = (message: any) => { + if (Array.isArray(message.content)) { + return <>{message.content.map((item: any, index: number) => renderMessageContent(item, index))}; + } + return null; + }; + + return ( +
+ {/* 标题区域 */} +
+

AG-UI + json-render 集成演示

+

+ 使用 json-render 渲染动态 UI,支持流式更新和增量渲染 +

+ {currentStage && ( +
+ 当前状态: {currentStage} +
+ )} +
+ + {/* 消息列表 */} + + {messages.map((message) => ( + + {renderMsgContents(message)} + + ))} + + + {/* 输入区域 */} + ) => setInputValue(e.detail)} + onSend={handleSend as any} + onStop={handleStop} + /> +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/agui-resume.tsx b/packages/pro-components/chat/chat-engine/_example/agui-resume.tsx new file mode 100644 index 0000000000..3fd8531c16 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/agui-resume.tsx @@ -0,0 +1,261 @@ +import React, { ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { + type TdChatMessageConfig, + type ChatRequestParams, + type ChatMessagesData, + type TdChatActionsName, + type TdChatSenderParams, + ChatList, + ChatSender, + ChatMessage, + TdChatListApi, + ChatActionBar, + isAIMessage, + getMessageContentForCopy, + AGUIAdapter, + isToolCallContent, +} from '@tdesign-react/chat'; +import { Button, Space, MessagePlugin, Tag } from 'tdesign-react'; +import { useChat } from '../index'; +import CustomToolCallRenderer from './components/Toolcall'; + +// Mock 服务地址(本地开发使用 localhost:9001,线上使用云函数) +const MOCK_BASE_URL = 'http://127.0.0.1:9001'; + +/** + * AG-UI 断点恢复(Resume)示例 + * + * 演示场景: + * 1. 用户之前发起了一个对话请求(要求 AI 搜索并分析 React vs Vue) + * 2. 后端 Agent 开始执行(思考 → 工具调用 → 文本输出),但用户中途离开了页面 + * 3. 用户重新进入页面后: + * a. 先加载历史消息(只有已完成的消息) + * b. 发现有未完成的 run(pendingRun 标识) + * c. 调用 chatEngine.resumeRun() 发起 SSE 连接 + * d. 后端推 MESSAGES_SNAPSHOT 恢复已产生的中间内容(思考、工具调用、部分文本) + * e. 后端继续推增量事件(新的文本内容),直到 RUN_FINISHED + * + * 关键 API: + * - chatEngine.resumeRun(params) — 创建空 AI 消息 + 发起 sendRequest + * - MESSAGES_SNAPSHOT 事件 — 一次性恢复中间状态(replaceContent 语义) + */ +export default function AguiResumeExample() { + const listRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + const [resumeState, setResumeState] = useState<'idle' | 'loading' | 'resumed' | 'error'>('idle'); + const [pendingRunInfo, setPendingRunInfo] = useState<{ + runId: string; + threadId: string; + } | null>(null); + + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + // 续传接口 + endpoint: `${MOCK_BASE_URL}/sse/agui-resume`, + protocol: 'agui', + stream: true, + onRequest: (params: ChatRequestParams) => ({ + body: JSON.stringify({ + uid: 'resume-demo', + prompt: params.prompt, + threadId: params.threadId, + runId: params.runId, + }), + }), + onStart: (chunk) => { + console.log('[Resume] 流式传输开始:', chunk); + }, + onComplete: (aborted, params, event) => { + console.log('[Resume] 流式传输完成:', { aborted, event }); + if (!aborted) { + setResumeState('resumed'); + MessagePlugin.success('断点恢复完成!'); + } + }, + onError: (err) => { + console.error('[Resume] 错误:', err); + setResumeState('error'); + }, + }, + }); + + const senderLoading = useMemo( + () => status === 'pending' || status === 'streaming', + [status], + ); + + /** + * 模拟断点恢复的完整流程 + * + * 实际业务中,这个流程通常在页面 mount 时自动执行: + * 1. 请求历史消息接口 + * 2. 检查是否有 pendingRun + * 3. 如果有,自动调用 resumeRun + */ + const handleSimulateResume = useCallback(async () => { + setResumeState('loading'); + + try { + // Step 1: 请求历史消息 + console.log('[Resume] Step 1: 加载历史消息...'); + const response = await fetch(`${MOCK_BASE_URL}/api/conversation/resume-history`); + const result = await response.json(); + + if (!result.success || !result.data) { + throw new Error('加载历史消息失败'); + } + + const { messages: historyMessages, pendingRun } = result.data; + + // Step 2: 使用 convertHistoryMessages 转换已完成的历史消息 + const convertedMessages = AGUIAdapter.convertHistoryMessages(historyMessages); + chatEngine.setMessages(convertedMessages); + + console.log(`[Resume] 历史消息已加载: ${convertedMessages.length} 条`); + + // Step 3: 检查是否有未完成的 run + if (pendingRun) { + console.log('[Resume] Step 2: 发现未完成的 run:', pendingRun); + setPendingRunInfo(pendingRun); + MessagePlugin.info(`发现未完成的任务 (runId: ${pendingRun.runId}),正在恢复...`); + + // Step 4: 调用 resumeRun 发起续传 + // 这会创建一条空的 AI 消息,然后发起 SSE 请求 + // 后端会先推 MESSAGES_SNAPSHOT 恢复中间状态,然后推增量事件 + console.log('[Resume] Step 3: 发起 resumeRun...'); + const messageId = await chatEngine.resumeRun({ + threadId: pendingRun.threadId, + runId: pendingRun.runId, + }); + console.log(`[Resume] resumeRun 完成,消息 ID: ${messageId}`); + } else { + console.log('[Resume] 没有未完成的 run'); + setResumeState('idle'); + MessagePlugin.info('没有需要恢复的任务'); + } + } catch (error) { + console.error('[Resume] 恢复失败:', error); + setResumeState('error'); + MessagePlugin.error('断点恢复失败'); + } + }, [chatEngine]); + + // 正常发送消息(恢复完成后可以继续对话) + const handleSend = async (e: CustomEvent) => { + const { value } = e.detail; + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + listRef.current?.scrollList({ to: 'bottom' }); + }; + + // 消息属性配置 + const messageProps: TdChatMessageConfig = { + user: { + variant: 'base', + placement: 'right', + }, + assistant: { + placement: 'left', + chatContentProps: { + thinking: { maxHeight: 300 }, + }, + }, + }; + + // 渲染消息内容 + const renderMsgContents = (message: ChatMessagesData): ReactNode => { + const contentElements = message.content?.map((item, index) => { + if (isToolCallContent(item)) { + return ( +
+ +
+ ); + } + return null; + }); + + return ( + <> + {contentElements} + {isAIMessage(message) && message.status === 'complete' ? ( + console.log('action:', name)} + copyText={getMessageContentForCopy(message)} + /> + ) : null} + + ); + }; + + // 状态标签 + const getResumeStatusTag = () => { + const config: Record = { + idle: { theme: 'default', text: '未开始' }, + loading: { theme: 'warning', text: '恢复中...' }, + resumed: { theme: 'success', text: '恢复完成' }, + error: { theme: 'danger', text: '恢复失败' }, + }; + const c = config[resumeState]; + return {c.text}; + }; + + return ( +
+ {/* 操作栏 */} +
+ + + + {getResumeStatusTag()} + {pendingRunInfo && ( + + threadId: {pendingRunInfo.threadId} + + )} + +
+ + {/* 消息列表 */} + + {messages.map((message, idx) => ( + + {renderMsgContents(message, idx === messages.length - 1)} + + ))} + + + {/* 输入框 */} + setInputValue(e.detail)} + onSend={handleSend} + onStop={() => chatEngine.abortChat()} + /> +
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/agui-test.tsx b/packages/pro-components/chat/chat-engine/_example/agui-test.tsx index 6a296ccde0..74dfbeabb6 100644 --- a/packages/pro-components/chat/chat-engine/_example/agui-test.tsx +++ b/packages/pro-components/chat/chat-engine/_example/agui-test.tsx @@ -281,7 +281,7 @@ export default function AguiBasicExample() { const { chatEngine, messages, status } = useChat({ defaultMessages: [], chatServiceConfig: { - endpoint: 'http://localhost:9000/sse/agui-activity', + endpoint: 'http://localhost:9001/sse/agui-activity', // 开启 AG-UI 协议解析支持 protocol: 'agui', stream: true, diff --git a/packages/pro-components/chat/chat-engine/_example/basic.tsx b/packages/pro-components/chat/chat-engine/_example/basic.tsx index 8c1b0a8a60..93db4d2a15 100644 --- a/packages/pro-components/chat/chat-engine/_example/basic.tsx +++ b/packages/pro-components/chat/chat-engine/_example/basic.tsx @@ -18,7 +18,7 @@ export default function BasicExample() { const { chatEngine, messages, status } = useChat({ defaultMessages: [], chatServiceConfig: { - endpoint: 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com/sse/normal', + endpoint: 'http://localhost:9001/sse/normal', stream: true, // 数据转换 onMessage: (chunk: SSEChunkData): AIMessageContent => { diff --git a/packages/pro-components/chat/chat-engine/_example/components/NestedPanel.tsx b/packages/pro-components/chat/chat-engine/_example/components/NestedPanel.tsx new file mode 100644 index 0000000000..60d1ffa06c --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/NestedPanel.tsx @@ -0,0 +1,78 @@ +/** + * 自定义业务组件:NestedPanel + * 支持嵌套子组件的面板容器 + * + * 用于验证 json-render 多重嵌套层级下更新深层子组件 props 的能力 + */ +import React from 'react'; +import type { ComponentRenderProps } from '@tdesign-react/chat'; + +export const NestedPanel: React.FC = ({ element, children }) => { + const { + title, + level = 1, + collapsed = false, + borderColor = '#e0e0e0', + backgroundColor = '#fafafa' + } = element.props as { + title: string; + level?: number; + collapsed?: boolean; + borderColor?: string; + backgroundColor?: string; + }; + + // 根据层级计算左边距 + const paddingLeft = (level - 1) * 16; + + // 根据层级调整样式 + const levelColors = [ + { border: '#1890ff', bg: '#e6f7ff' }, + { border: '#52c41a', bg: '#f6ffed' }, + { border: '#faad14', bg: '#fffbe6' }, + { border: '#f5222d', bg: '#fff2f0' }, + ]; + + const colorScheme = levelColors[(level - 1) % levelColors.length]; + + console.log(`====NestedPanel render: ${title}, level: ${level}, collapsed: ${collapsed}`); + + return ( +
+ {/* 标题区域 */} +
+ L{level} + {title} + {collapsed && (已折叠)} +
+ + {/* 内容区域 */} + {!collapsed && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/packages/pro-components/chat/chat-engine/_example/components/ProgressBar.tsx b/packages/pro-components/chat/chat-engine/_example/components/ProgressBar.tsx new file mode 100644 index 0000000000..6816c68ca7 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/ProgressBar.tsx @@ -0,0 +1,64 @@ +/** + * 自定义业务组件:ProgressBar + * 展示进度条 + * + * 注意:无需手动添加 React.memo 比较函数 + * createCustomRegistry 会自动使用 withStableProps 包装, + * 基于 react-fast-compare 进行高效深比较 + */ +import React from 'react'; +import type { ComponentRenderProps } from '@json-render/react'; + +export const ProgressBar: React.FC = ({ element }) => { + const { label, percentage, showInfo = true } = element.props as { + label?: string; + percentage: number; + showInfo?: boolean; + }; + + // 根据进度百分比决定颜色 + let color: string; + if (percentage < 30) { + color = '#f5222d'; // 红色 + } else if (percentage < 70) { + color = '#faad14'; // 橙色 + } else { + color = '#52c41a'; // 绿色 + } + + console.log("====ProgressBar render") + return ( +
+ {label && ( +
+ {label} +
+ )} +
+
+
+
+ {showInfo && ( + + {percentage}% + + )} +
+
+ ); +}; diff --git a/packages/pro-components/chat/chat-engine/_example/components/StatusCard.tsx b/packages/pro-components/chat/chat-engine/_example/components/StatusCard.tsx new file mode 100644 index 0000000000..a07899a5d9 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/components/StatusCard.tsx @@ -0,0 +1,78 @@ +/** + * 自定义业务组件:StatusCard + * 展示状态信息卡片 + * + * 注意:无需手动添加 React.memo 比较函数 + * createCustomRegistry 会自动使用 withStableProps 包装, + * 基于 react-fast-compare 进行高效深比较 + */ +import React from 'react'; +import type { ComponentRenderProps } from '@json-render/react'; + +export const StatusCard: React.FC = ({ element }) => { + const { title, status, description, icon } = element.props as { + title: string; + status: 'success' | 'warning' | 'error' | 'info'; + description?: string; + icon?: string; + }; + + const statusColors = { + success: '#52c41a', + warning: '#faad14', + error: '#f5222d', + info: '#1890ff', + }; + + const statusIcons = { + success: '✓', + warning: '⚠', + error: '✗', + info: 'ℹ', + }; + + return ( +
+
+ {icon || statusIcons[status]} +
+
+
+ {title} +
+ {description && ( +
+ {description} +
+ )} +
+
+ ); +}; diff --git a/packages/pro-components/chat/chat-engine/_example/components/index.ts b/packages/pro-components/chat/chat-engine/_example/components/index.ts index 03f4ec18a3..99c204a674 100644 --- a/packages/pro-components/chat/chat-engine/_example/components/index.ts +++ b/packages/pro-components/chat/chat-engine/_example/components/index.ts @@ -6,3 +6,8 @@ export { PlanningStatePanel } from './PlanningStatePanel'; export { WeatherCard } from './WeatherCard'; // export { Toolcall } from './Toolcall'; // export { LoginComponent } from './login'; + +// json-render custom components +export { NestedPanel } from './NestedPanel'; +export { ProgressBar } from './ProgressBar'; +export { StatusCard } from './StatusCard'; diff --git a/packages/pro-components/chat/chat-engine/_example/custom-a2ui.tsx b/packages/pro-components/chat/chat-engine/_example/custom-a2ui.tsx new file mode 100644 index 0000000000..8ea6e78dc6 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/custom-a2ui.tsx @@ -0,0 +1,383 @@ +/** + * 自定义协议 + A2UI 示例 + * + * 演示内容: + * 1. 不依赖 AG-UI 协议,使用自定义 SSE 协议 + * 2. 通过 useA2UISurface hook 处理 A2UI 消息(基于 json-render adapter) + * 3. 配合 useChat 的 onMessage 回调实现自定义解析 + * 4. 完整的 ChatEngine 实例用法展示 + * + * 核心概念: + * - 自定义协议格式:{ type: 'text' | 'a2ui', ... } + * - 把自定义协议消息转换为 A2UI v0.9.1 标准消息后,交给 useA2UISurface 处理 + * - 渲染端使用 A2UISurfaceRenderer + tdesignRegistry / a2uiRegistry + */ +import React, { useCallback, useRef, useState } from 'react'; +import { MessagePlugin } from 'tdesign-react'; +import { + a2uiRegistry, + A2UISurfaceRenderer, + ChatList, + ChatMessage, + ChatSender, + useA2UISurface, + useChat, +} from '@tdesign-react/chat'; + +import type { A2UIMessage } from '@tdesign/ai-chat-engine'; +import type { + AIMessageContent, + ChatMessagesData, + ChatRequestParams, + SSEChunkData, + TdChatSenderParams, +} from '@tdesign-react/chat'; + +// Mock Server 地址 +const MOCK_SERVER = 'https://1257786608-9i9j1kpa67.ap-guangzhou.tencentscf.com'; + +/** + * 自定义协议消息类型 + */ +interface CustomMessage { + type: 'text' | 'a2ui'; + // 文本消息 + msg?: string; + // A2UI 消息 + surfaceId?: string; + operations?: CustomA2UIOperation[]; + data?: Record; +} + +/** + * 自定义 A2UI 操作类型 + * 简化版的操作格式,更易于后端实现 + */ +interface CustomA2UIOperation { + type: 'create' | 'update' | 'patch' | 'delete'; + surfaceId: string; + componentId?: string; + payload?: any; +} + +/** + * 将自定义协议转换为 A2UI v0.9.1 标准格式 + */ +function convertToA2UIMessages(msg: CustomMessage, initialData?: Record): A2UIMessage[] { + if (msg.type !== 'a2ui' || !msg.surfaceId) return []; + + const messages: A2UIMessage[] = []; + + if (msg.operations) { + for (const op of msg.operations) { + switch (op.type) { + case 'create': + // 创建 Surface + messages.push({ + createSurface: { + surfaceId: op.surfaceId, + catalogId: 'default', + }, + }); + // 如果有初始数据 + if (initialData) { + messages.push({ + updateDataModel: { + surfaceId: op.surfaceId, + path: '/', + op: 'replace', + value: initialData, + }, + }); + } + // 如果有 payload(root 组件),转换为 updateComponents + if (op.payload) { + const components = flattenComponents(op.payload); + messages.push({ + updateComponents: { + surfaceId: op.surfaceId, + components, + }, + }); + } + break; + + case 'update': + // 更新整个组件树 + if (op.payload) { + const components = flattenComponents(op.payload); + messages.push({ + updateComponents: { + surfaceId: op.surfaceId, + components, + }, + }); + } + break; + + case 'patch': + // 增量更新单个组件 + if (op.componentId && op.payload) { + messages.push({ + updateComponents: { + surfaceId: op.surfaceId, + components: [{ id: op.componentId, component: op.payload.component || 'Unknown', ...op.payload }], + }, + }); + } + break; + + case 'delete': + messages.push({ + deleteSurface: { + surfaceId: op.surfaceId, + }, + }); + break; + } + } + } + + return messages; +} + +/** + * 将嵌套组件树展平为组件数组 + */ +function flattenComponents(root: any): any[] { + const components: any[] = []; + let idCounter = 0; + + function flatten(node: any): string { + const nodeId = node.id || `comp_${(idCounter += 1)}`; + const component = { ...node, id: nodeId }; + + if (Array.isArray(node.children)) { + component.children = node.children.map((child: any) => (typeof child === 'string' ? child : flatten(child))); + } + + if (node.child && typeof node.child === 'object') { + component.child = flatten(node.child); + } + + components.push(component); + return nodeId; + } + + flatten({ ...root, id: root.id || 'root' }); + return components; +} + +export default function CustomA2UIExample() { + const [inputValue, setInputValue] = useState('帮我创建一个用户信息表单'); + const listRef = useRef(null); + + // 跟踪 A2UI Surface 关联的消息 ID + const [surfaceMessageMap, setSurfaceMessageMap] = useState>(new Map()); + const currentMessageIdRef = useRef(''); + + // A2UI Surface 控制器:负责消息分发与状态管理 + const a2uiSurface = useA2UISurface({ debug: false }); + + // Action 处理映射:表单的 submit / cancel + const actionHandlers = { + submit: async (params: Record) => { + MessagePlugin.success(`表单提交成功: ${JSON.stringify(params)}`); + }, + cancel: async () => { + MessagePlugin.info('用户取消了操作'); + }, + reset: async () => { + MessagePlugin.info('已重置表单'); + }, + }; + + // 自定义消息处理函数 + const handleCustomMessage = useCallback( + (chunk: SSEChunkData): AIMessageContent | null => { + try { + const data = chunk.data as CustomMessage; + + if (data.type === 'text' && data.msg) { + // 文本消息:返回标准格式让引擎处理 + return { + type: 'text', + data: data.msg, + } as AIMessageContent; + } + if (data.type === 'a2ui' && data.surfaceId) { + const { surfaceId } = data; + // A2UI 消息:转换并交给 useA2UISurface 处理 + const a2uiMessages = convertToA2UIMessages(data, data.data); + if (a2uiMessages.length > 0) { + a2uiSurface.processMessages(a2uiMessages); + // 记录 Surface 与消息的关联 + setSurfaceMessageMap((prev) => { + const next = new Map(prev); + next.set(surfaceId, currentMessageIdRef.current); + return next; + }); + } + // A2UI 消息不返回内容,由 Surface 渲染 + return null; + } + } catch (err) { + console.error('解析自定义协议消息失败:', err); + } + return null; + }, + [a2uiSurface], + ); + + // 使用 useChat 创建 ChatEngine 实例(自定义协议) + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: `${MOCK_SERVER}/sse/a2ui-custom`, + stream: true, + // 使用 onMessage 回调处理自定义协议 + onMessage: (chunk, message) => { + // 记录当前消息 ID + if (message?.id) { + currentMessageIdRef.current = message.id; + } + return handleCustomMessage(chunk); + }, + onRequest: (params: ChatRequestParams) => ({ + body: JSON.stringify({ + uid: 'custom-a2ui-demo', + prompt: params.prompt, + }), + }), + onStart: () => { + // 清理旧的 Surface + a2uiSurface.clearAllSurfaces(); + setSurfaceMessageMap(new Map()); + }, + onComplete: () => { + // 流式传输完成 + }, + onError: (err) => { + const errorMsg = err instanceof Error ? err.message : '请求失败'; + MessagePlugin.error(errorMsg); + }, + }, + }); + + // 发送消息 + const handleSend = async (e: CustomEvent) => { + const { value } = e.detail; + if (!value.trim()) return; + + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + // 停止生成 + const handleStop = () => { + chatEngine.abortChat(); + MessagePlugin.info('已停止生成'); + }; + + // 渲染 ChatMessage 内部的扩展内容:关联的 A2UI Surface + const renderMsgContents = (message: ChatMessagesData) => { + const relatedSurfaceId = Array.from(surfaceMessageMap.entries()).find(([, msgId]) => msgId === message.id)?.[0]; + if (!relatedSurfaceId || !a2uiSurface.hasSurface(relatedSurfaceId)) return null; + return ( +
+ +
+ ); + }; + + return ( +
+ {/* 标题区域 */} +
+

自定义协议 + A2UI 示例

+

+ 不依赖 AG-UI,使用自定义 SSE 协议配合 useA2UISurface(基于 json-render adapter)实现动态表单 +

+
+ + {/* 消息列表:外层 div 接管布局样式,避开 ChatList 类型签名不含 style 的问题 */} +
+ + {messages.map((message) => ( + + {renderMsgContents(message)} + + ))} + +
+ + {/* 渲染未关联到消息的 Surface(独立 Surface) */} + {a2uiSurface.surfaceIds + .filter((id) => !surfaceMessageMap.has(id)) + .map((surfaceId) => ( +
+ +
+ ))} + + {/* 输入区域 */} + setInputValue(e.detail)} + onSend={handleSend} + onStop={handleStop} + /> + + {/* 快捷操作 */} +
+ setInputValue('帮我创建一个用户信息表单')} /> + { + MessagePlugin.info('自定义协议格式更简单,适合自研后端;AG-UI 协议更标准,适合通用场景'); + }} + /> +
+
+ ); +} + +// 快捷操作按钮 +function QuickAction({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/openclaw-basic.tsx b/packages/pro-components/chat/chat-engine/_example/openclaw-basic.tsx new file mode 100644 index 0000000000..1011ec0b81 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/openclaw-basic.tsx @@ -0,0 +1,88 @@ +import React, { useState, useRef } from 'react'; +import { + ChatList, + ChatSender, + ChatMessage, + type TdChatSenderParams, + type ChatRequestParams, +} from '@tdesign-react/chat'; +import { useChat } from '@tdesign-react/chat'; + +/** + * OpenClaw 协议基础示例 + * + * 学习目标: + * - 开启 OpenClaw 协议支持(protocol: 'openclaw') + * - 理解 OpenClaw WebSocket 连接和流式消息传输 + * - 通过 onRequest 传入业务参数(sessionKey、token 等) + * + * 注意:需要启动 Mock Server(mock-server/online2/)才能正常运行 + * 启动命令:cd mock-server/online2 && node app.js + */ +export default function OpenClawBasicExample() { + const [inputValue, setInputValue] = useState('你好,请介绍一下自己'); + const listRef = useRef(null); + + const { chatEngine, messages, status } = useChat({ + defaultMessages: [], + chatServiceConfig: { + // WebSocket 端点 + endpoint: 'ws://127.0.0.1:18789', + // 开启 OpenClaw 协议 + protocol: 'openclaw', + stream: true, + + // OpenClaw 专属配置(仅协议层必需项,有默认值可不传) + // 通过 onRequest 传入业务参数 + // sessionKey、token 等敏感/业务相关参数在这里配置 + onRequest: (params: ChatRequestParams) => ({ + sessionKey: 'demo-session', + message: params.prompt, + auth: { + token: 'xx', + }, + }), + + // 生命周期回调 + onStart: (chunk) => { + console.log('OpenClaw 流式传输开始:', chunk); + }, + onComplete: (aborted, params) => { + console.log('OpenClaw 流式传输完成:', { aborted, params }); + }, + onError: (err) => { + console.error('OpenClaw 错误:', err); + }, + }, + }); + + 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/openclaw-toolcall-activity.tsx b/packages/pro-components/chat/chat-engine/_example/openclaw-toolcall-activity.tsx new file mode 100644 index 0000000000..4691b8e3ee --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/openclaw-toolcall-activity.tsx @@ -0,0 +1,364 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Button, Card, Space, Input, Select, Tag } from 'tdesign-react'; +import { + ChatList, + ChatSender, + ChatMessage, + ToolCallRenderer, + useAgentToolcall, + useChat, + isToolCallContent, +} from '@tdesign-react/chat'; +import { CheckCircleFilledIcon, LoadingIcon, CloudIcon } from 'tdesign-icons-react'; +import type { + ChatMessagesData, + ChatRequestParams, + ToolCall, + ToolcallComponentProps, + AIMessageContent, +} from '@tdesign-react/chat'; + +// ==================== 类型定义 ==================== + +// Toolcall: 天气查询 +interface WeatherArgs { + city: string; +} + +interface WeatherResult { + temperature: number; + condition: string; + humidity: number; +} + +// Toolcall: 用户偏好收集(Human-in-the-Loop 交互式) +interface UserPreferencesArgs { + destination: string; +} + +interface UserPreferencesResponse { + budget: number; + interests: string[]; + accommodation: string; +} + +// ==================== 工具组件 ==================== + +/** + * 1. 天气查询组件(非交互式 Toolcall) + * + * 展示 OpenClaw stream=tool 两阶段(start → result) + */ +const WeatherCard: React.FC> = ({ + status, + args, + result, + error, +}) => { + if (error) { + return ( + +
查询天气失败: {error.message}
+
+ ); + } + + return ( + +
+ + + {args?.city || '...'} 天气 + + {status === 'executing' && ( + + + 查询中 + + )} + {status === 'complete' && ( + + + 已完成 + + )} +
+ + {status === 'executing' && ( +
正在获取天气信息...
+ )} + + {status === 'complete' && result && ( + +
🌡️ 温度: {result.temperature}°C
+
☁️ 天气: {result.condition}
+
💧 湿度: {result.humidity}%
+
+ )} +
+ ); +}; + +/** + * 2. 用户偏好设置组件(交互式 Toolcall / Human-in-the-Loop) + * + * 展示 OpenClaw stream=tool + node.invoke 双向交互: + * - 后端通过 tool stream 推送表单 + * - 用户填写后通过 respond(桥接 node.invoke RPC)回传 + */ +const UserPreferencesForm: React.FC< + ToolcallComponentProps +> = ({ status, args, 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 ( + +
+ 请设置 {args?.destination || ''} 旅游偏好 +
+ +
+
预算(元)
+ setBudget(Number(value))} + placeholder="请输入预算" + /> +
+
+
兴趣爱好
+ setAccommodation(value as string)} + options={[ + { label: '经济型', value: '经济型' }, + { label: '舒适型', value: '舒适型' }, + { label: '豪华型', value: '豪华型' }, + ]} + /> +
+ +
+
+ ); +}; + +// ==================== 主组件 ==================== + +/** + * OpenClaw 协议 - Toolcall + 历史消息 综合示例 + * + * 学习目标: + * - Toolcall:通过 stream=tool 实现工具调用(phase=start/result,对照 OpenClaw 实际协议) + * - 双向 Action:通过 node.invoke RPC 支持 Human-in-the-Loop 交互 + * - 历史消息:OpenClaw Gateway 在 connect 响应中自动推送历史消息,ChatEngine 自动回填 + * - 完整演示:文本 + Toolcall 混合流 + * + * 关键字触发说明(Mock Server 根据用户消息分发不同流式模式): + * - 输入包含 "交互" / "互动" / "偏好":触发交互式 Toolcall 演示(Human-in-the-Loop) + * → 天气查询 + 用户偏好表单 → 用户填写提交 → 服务端接收后继续推送总结文本 + * - 输入包含 "tool" / "工具" / "天气":触发非交互式 Toolcall 演示 + * - 其他输入:普通文本对话 + * + * 注意:需要启动 Mock Server:cd mock-server/online2 && node app.js + */ +export default function OpenClawToolcallActivity() { + const listRef = useRef(null); + const [inputValue, setInputValue] = useState('帮我做个交互式旅行规划'); + + // 注册 Toolcall 组件 + useAgentToolcall([ + { + name: 'get_weather', + description: '查询城市天气', + parameters: [{ name: 'city', type: 'string', required: true }], + component: WeatherCard as any, + }, + { + name: 'collect_preferences', + description: '收集用户旅游偏好', + parameters: [{ name: 'destination', type: 'string', required: true }], + component: UserPreferencesForm as any, + }, + ]); + + // 聊天配置 - 使用 OpenClaw WebSocket 协议 + const { chatEngine, messages, status } = useChat({ + chatServiceConfig: { + endpoint: 'ws://127.0.0.1:18789', + protocol: 'openclaw', + stream: true, + // 通过 onRequest 传入业务参数 + onRequest: (params: ChatRequestParams) => ({ + sessionKey: 'demo-toolcall-activity', + message: params.prompt, + auth: { + token: 'xx', + }, + }), + + // 历史消息回调:OpenClaw Gateway 在 connect 响应中自动推送历史消息 + // ChatEngine 内部会自动调用 setMessages 回填,此处可做额外处理(如 UI 提示) + onHistoryLoaded: (historyMessages) => { + console.log('[OpenClaw] History auto-loaded from connect response:', historyMessages.length, 'messages'); + }, + + // 生命周期回调 + onStart: (chunk) => { + console.log('[OpenClaw] Stream started:', chunk); + }, + onComplete: (aborted, params) => { + console.log('[OpenClaw] Stream completed:', { aborted, params }); + }, + onError: (err) => { + console.error('[OpenClaw] Error:', err); + }, + }, + }); + + const senderLoading = useMemo( + () => status === 'pending' || status === 'streaming', + [status], + ); + + // 消息配置 + const messageProps: Record = { + user: { variant: 'base', placement: 'right' }, + assistant: { placement: 'left' }, + }; + + // 处理 Toolcall 响应(Human-in-the-Loop) + const handleToolCallRespond = useCallback( + async (toolcall: ToolCall, response: any) => { + console.log('[OpenClaw] Toolcall respond:', toolcall.toolCallName, response); + + // 对于交互式 toolcall(如 collect_preferences), + // 将用户响应通过 sendAIMessage 传递给后端 + if (toolcall.toolCallName === 'collect_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: AIMessageContent, index: number) => { + // 渲染 Toolcall 组件 + if (isToolCallContent(item)) { + return ( +
+ +
+ ); + } + + return null; + }, + [handleToolCallRespond], + ); + + const renderMsgContents = (message: ChatMessagesData) => ( + <>{message.content?.map((item: any, index: number) => renderMessageContent(item, index))} + ); + + const sendHandler = async (e: any) => { + const { value } = e.detail; + await chatEngine.sendUserMessage({ prompt: value }); + setInputValue(''); + }; + + return ( +
+ {/* 提示信息 */} +
+ 💡 输入提示:交互/互动/偏好 → 🌟 交互式 Toolcall 演示(Human-in-the-Loop)|{' '} + 天气/tool → Toolcall + + 📜 历史消息由 Gateway connect 响应自动推送 + +
+ +
+ + {messages.map((message) => ( + + {renderMsgContents(message)} + + ))} + + + setInputValue(e.detail)} + onSend={sendHandler} + onStop={() => chatEngine.abortChat()} + /> +
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/_example/performance-test.tsx b/packages/pro-components/chat/chat-engine/_example/performance-test.tsx new file mode 100644 index 0000000000..2f127cfbe1 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/performance-test.tsx @@ -0,0 +1,280 @@ +import React, { useRef } from 'react'; +import { ChatList, ChatMessage, isActivityContent, useChat, useAgentActivity, ActivityRenderer } from '@tdesign-react/chat'; +import { Button, Card, Space, Tag, Progress } from 'tdesign-react'; +import type { ActivityComponentProps } from '@tdesign-react/chat'; + +/** + * 性能测试 Demo + * + * 验证核心优化效果: + * - 场景 1:并发 + 隔离性验证 + * - A/B/C 持续更新 + * - D 更新几次后停止 + * - E 仅初始化,不接收任何 delta(完全隔离) + * - 场景 2:微任务批量合并验证 + * + * 使用方法: + * 1. 启动 mock server: cd mock-server/online2 && node app.js + * 2. 打开 React DevTools Profiler + * 3. 点击 Start Recording + * 4. 点击测试按钮 + * 5. 停止 Recording 查看结果 + */ + +const MOCK_SERVER_BASE = 'http://localhost:9001'; + +// ==================== Activity 组件 ==================== + +interface ComponentContent { + id: string; + label: string; + value: number; +} + +// 组件 A - 蓝色进度条(持续更新) +const ComponentA: React.FC> = ({ content }) => { + const renderCount = useRef(0); + renderCount.current += 1; + + return ( + +
+ {content.label} + + + render: {renderCount.current} + +
+
+ ); +}; + +// 组件 B - 橙色进度条(持续更新) +const ComponentB: React.FC> = ({ content }) => { + const renderCount = useRef(0); + renderCount.current += 1; + + return ( + +
+ {content.label} + + + render: {renderCount.current} + +
+
+ ); +}; + +// 组件 C - 红色进度条(持续更新) +const ComponentC: React.FC> = ({ content }) => { + const renderCount = useRef(0); + renderCount.current += 1; + + return ( + +
+ {content.label} + + + render: {renderCount.current} + +
+
+ ); +}; + +// 组件 D - 绿色计数器(更新几次后停止) +const ComponentD: React.FC> = ({ content }) => { + const renderCount = useRef(0); + renderCount.current += 1; + + return ( + +
+ {content.label} + + (更新后停止) + + render: {renderCount.current} + +
+
+ ); +}; + +// 组件 E - 粉色计数器(完全隔离,仅初始化) +const ComponentE: React.FC> = ({ content }) => { + const renderCount = useRef(0); + renderCount.current += 1; + + return ( + +
+ {content.label} + {content.value} + (完全隔离,预期 render=1) + 1 ? 'danger' : 'success'}> + render: {renderCount.current} + +
+
+ ); +}; + +// 场景 2:批量合并测试组件 +interface BatchMergeContent { + id: string; + label: string; + count: number; + batch: number; +} + +const BatchMergeActivity: React.FC> = ({ content }) => { + const renderCount = useRef(0); + renderCount.current += 1; + + return ( + +
+ {content.label} +
+ + Delta: {content.count} + + + Batch: {content.batch} + +
+ + render: {renderCount.current} + +
+
+ ); +}; + +// ==================== 主组件 ==================== + +const PerformanceTest: React.FC = () => { + // 注册所有 Activity 组件 + useAgentActivity([ + // 场景 1:并发 + 隔离性测试 - 5 个组件 + { activityType: 'comp-a', component: ComponentA as React.FC }, + { activityType: 'comp-b', component: ComponentB as React.FC }, + { activityType: 'comp-c', component: ComponentC as React.FC }, + { activityType: 'comp-d', component: ComponentD as React.FC }, + { activityType: 'comp-e', component: ComponentE as React.FC }, + // 场景 2:批量合并测试 + { activityType: 'batch-merge', component: BatchMergeActivity as React.FC }, + ]); + + // 场景 1:并发 + 隔离性验证 + const { chatEngine: concurrentEngine, messages: concurrentMessages, status: concurrentStatus } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: `${MOCK_SERVER_BASE}/sse/concurrent-isolation-test`, + protocol: 'agui', + stream: true, + onRequest: () => ({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }), + }, + }); + + // 场景 2:微任务批量合并验证 + const { chatEngine: batchEngine, messages: batchMessages, status: batchStatus } = useChat({ + defaultMessages: [], + chatServiceConfig: { + endpoint: `${MOCK_SERVER_BASE}/sse/batch-merge-test`, + protocol: 'agui', + stream: true, + onRequest: () => ({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }), + }, + }); + + const renderMsgContents = (message: any) => { + if (Array.isArray(message.content)) { + return ( + <> + {message.content.map((item: any, index: number) => { + if (isActivityContent(item)) { + return ( +
+ +
+ ); + } + return null; + })} + + ); + } + return null; + }; + + const isLoading = concurrentStatus === 'streaming' || batchStatus === 'streaming'; + + return ( + + {/* 测试按钮 */} + + + + + + + + + + {/* 场景 1 结果 */} + {concurrentMessages.length > 0 && ( + + + {concurrentMessages.map((message) => ( + + {renderMsgContents(message)} + + ))} + + + )} + + {/* 场景 2 结果 */} + {batchMessages.length > 0 && ( + + + {batchMessages.map((message) => ( + + {renderMsgContents(message)} + + ))} + + + )} + + ); +}; + +export default PerformanceTest; diff --git a/packages/pro-components/chat/chat-engine/_example/prompt.md b/packages/pro-components/chat/chat-engine/_example/prompt.md new file mode 100644 index 0000000000..f549daf1d7 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/_example/prompt.md @@ -0,0 +1,565 @@ +# my-dashboard UI Component Catalog + +You can generate dynamic UI using the following components and JSON schema format. + +## Output Format + +Generate a JSON object with this structure: +```json +{ + "root": "element_id", // ID of the root element + "elements": { + "element_id": { + "key": "element_id", // Same as the key in elements object + "type": "ComponentName", // One of the available components + "props": { ... }, // Component-specific props + "children": ["child_id_1", "child_id_2"] // Optional: IDs of child elements + } + }, + "data": { // Optional: Initial data model for data binding + "fieldName": "value" + } +} +``` + +## AG-UI ACTIVITY_DELTA Format (for Incremental Updates) + +For updating existing UI, you can also generate AG-UI ACTIVITY_DELTA messages: +```json +{ + "type": "ACTIVITY_DELTA", + "messageId": "unique_message_id", + "activityType": "json-render-main-card", + "patch": [ + {"op": "add", "path": "/elements/new-element", "value": {...}}, + {"op": "replace", "path": "/elements/parent/children", "value": [...]} + ] +} +``` + +### JSON Patch Operations for ACTIVITY_DELTA + +**Add New Element:** +```json +{"op": "add", "path": "/elements/new-button", "value": { + "key": "new-button", + "type": "Button", + "props": {"children": "Click Me", "theme": "primary"} +}} +``` + +**Update Element Properties:** +```json +{"op": "replace", "path": "/elements/my-card/props/title", "value": "Updated Title"} +``` + +**Replace Children Array:** +```json +{"op": "replace", "path": "/elements/container/children", "value": ["child1", "child2", "new-child"]} +``` + +**Add Child to Parent:** +```json +{"op": "add", "path": "/elements/parent/children/-", "value": "new-child-id"} +``` + +**Update Data Model:** +```json +{"op": "replace", "path": "/data/user/name", "value": "John Doe"} +``` + +**Complete ACTIVITY_DELTA Example:** +```json +{ + "type": "ACTIVITY_DELTA", + "messageId": "update_123456", + "activityType": "json-render-main-card", + "patch": [ + { + "op": "add", + "path": "/elements/success-message", + "value": { + "key": "success-message", + "type": "Text", + "props": { + "content": "Operation completed successfully!", + "color": "success" + } + } + }, + { + "op": "add", + "path": "/elements/main-container/children/-", + "value": "success-message" + } + ] +} +``` + +## JSON-Render Data Structure Details + +Understanding the structure is crucial for generating valid UI: + +### Root Level Properties +- `root`: String - ID of the root element (must exist in elements) +- `elements`: Object - Contains all UI elements indexed by their IDs +- `data`: Object - Optional data model for form bindings and dynamic content + +### Element Structure +Each element in the `elements` object has: +- `key`: String - Unique identifier (should match the object key) +- `type`: String - Component type name (must be from available components) +- `props`: Object - Component-specific properties +- `children`: Array - Optional array of child element IDs + +### Element Hierarchy Rules +1. **Root Element**: Must be specified in `root` and exist in `elements` +2. **Parent-Child Relationship**: Parent elements reference children by ID in `children` array +3. **Child Order**: Order in `children` array determines rendering order +4. **Unique IDs**: Each element must have a unique ID across the entire structure +5. **Container Components**: Only components marked with "can have children" support `children` + +### Data Binding Paths +Form components can bind to data using dot notation: +- `"valuePath": "user.name"` → binds to `data.user.name` +- `"valuePath": "settings.theme"` → binds to `data.settings.theme` +- `"disabledPath": "form.disabled"` → controls disabled state from `data.form.disabled` + +### Element ID Best Practices +- Use descriptive names: `"user_name_field"` instead of `"field1"` +- Use consistent naming: `snake_case` or `kebab-case` +- Include purpose: `"submit_button"`, `"main_container"`, `"status_card"` + +## Available Components + +### Button +Button component with action support + +**Props:** +- `label`: string - Button text (alternative to children) +- `children`: string - Button text +- `variant`: "base" | "outline" | "dashed" | "text" - Button style variant +- `theme`: "default" | "primary" | "success" | "warning" | "danger" - Color theme +- `size`: "small" | "medium" | "large" - Button size +- `disabled`: boolean - Whether button is disabled +- `loading`: boolean - Whether to show loading state +- `block`: boolean - Whether button should be full width +- `action`: string | { action: string, params?: object } - ActionBinding to trigger on click + +### Input +Input component for text entry + +**Props:** +- `value`: string - Input value +- `placeholder`: string - Placeholder text +- `type`: "text" | "password" | "number" - Input type +- `disabled`: boolean - Whether input is disabled +- `clearable`: boolean - Whether to show clear button +- `maxlength`: number - Maximum input length +- `showLimitNumber`: boolean - Whether to show character count + +### TextField +Form text field with label and data binding + +**Props:** +- `label`: string (required) - Field label +- `name`: string - Form field name +- `valuePath`: string (required) - Data binding path in data model +- `placeholder`: string - Placeholder text +- `required`: boolean - Whether field is required +- `type`: "text" | "password" | "email" | "number" | "tel" - Input type +- `disabled`: boolean - Whether field is disabled +- `disabledPath`: string - Data path to control disabled state dynamically +- `helperText`: string - Helper text below the field + +### Text +Text display component + +**Props:** +- `content`: string (required) - Text content to display +- `variant`: "body" | "caption" | "label" - Text style variant +- `color`: "default" | "primary" | "secondary" | "success" | "warning" | "error" - Text color +- `weight`: "normal" | "medium" | "bold" - Font weight + +### Card +Card container component +*This component can have children.* + +**Props:** +- `title`: string - Card title +- `description`: string - Card description +- `bordered`: boolean - Whether to show border +- `shadow`: boolean - Whether to show shadow +- `loading`: boolean - Whether to show loading state + +### Row +Grid row layout (24-column grid system) +*This component can have children.* + +**Props:** +- `gutter`: number - Gap between columns in pixels +- `justify`: "start" | "end" | "center" | "space-around" | "space-between" - Horizontal alignment +- `align`: "top" | "middle" | "bottom" - Vertical alignment + +### Col +Grid column layout (use inside Row) +*This component can have children.* + +**Props:** +- `span`: number (1-24) - Column width, out of 24 total +- `offset`: number - Column offset from left + +### Space +Space layout component for spacing children +*This component can have children.* + +**Props:** +- `direction`: "horizontal" | "vertical" - Layout direction +- `size`: "small" | "medium" | "large" | number - Gap size +- `align`: "start" | "center" | "end" | "baseline" - Alignment + +### Column +Vertical column layout +*This component can have children.* + +**Props:** +- `gap`: number - Gap between children in pixels +- `align`: "start" | "center" | "end" | "stretch" - Horizontal alignment + +### Divider +Divider line + +**Props:** +- `layout`: "horizontal" | "vertical" - Divider direction +- `dashed`: boolean - Whether to use dashed line + +### StatusCard +Custom status card component for displaying status information + +**Props:** +- `title`: string +- `status`: "success" | "warning" | "error" | "info" +- `description`: string (nullable) +- `icon`: string (nullable) + +### ProgressBar +Custom progress bar component for showing completion status + +**Props:** +- `label`: string (nullable) +- `percentage`: number +- `showInfo`: boolean (nullable) + +## Available Actions + +Actions can be triggered by Button components. Use the `action` prop: +- Simple: `"action": "actionName"` +- With params: `"action": { "action": "actionName", "params": { ... } }` + +- `submit`: Submit form data to server +- `reset`: Reset form to initial state +- `cancel`: Cancel current operation +- `refresh`: Refresh data from server +- `export`: Export data to file + +## Data Binding + +Use `valuePath` to bind form fields to the data model: +- `"valuePath": "user.name"` binds to `data.user.name` +- `"disabledPath": "formDisabled"` controls disabled state from `data.formDisabled` + +## Examples + +### 1. Simple Form Structure +A basic form with validation and data binding: +```json +{ + "root": "main_card", + "elements": { + "main_card": { + "key": "main_card", + "type": "Card", + "props": { + "title": "User Registration", + "bordered": true + }, + "children": [ + "form_layout" + ] + }, + "form_layout": { + "key": "form_layout", + "type": "Column", + "props": { + "gap": 16 + }, + "children": [ + "name_field", + "email_field", + "button_group" + ] + }, + "name_field": { + "key": "name_field", + "type": "TextField", + "props": { + "label": "Full Name", + "valuePath": "user.name", + "placeholder": "Enter your full name", + "required": true + } + }, + "email_field": { + "key": "email_field", + "type": "TextField", + "props": { + "label": "Email Address", + "valuePath": "user.email", + "placeholder": "Enter your email", + "type": "email", + "required": true + } + }, + "button_group": { + "key": "button_group", + "type": "Space", + "props": { + "direction": "horizontal", + "size": "medium" + }, + "children": [ + "submit_btn", + "reset_btn" + ] + }, + "submit_btn": { + "key": "submit_btn", + "type": "Button", + "props": { + "children": "Register", + "theme": "primary", + "action": { + "action": "submit", + "params": { + "form": "registration" + } + } + } + }, + "reset_btn": { + "key": "reset_btn", + "type": "Button", + "props": { + "children": "Reset", + "variant": "outline", + "action": "reset" + } + } + }, + "data": { + "user": { + "name": "", + "email": "" + } + } +} +``` + +### 2. Dashboard Layout +A more complex layout with grid system and multiple components: +```json +{ + "root": "dashboard", + "elements": { + "dashboard": { + "key": "dashboard", + "type": "Column", + "props": { + "gap": 24 + }, + "children": [ + "header", + "content_row" + ] + }, + "header": { + "key": "header", + "type": "Card", + "props": { + "title": "Dashboard Overview" + }, + "children": [ + "status_text" + ] + }, + "status_text": { + "key": "status_text", + "type": "Text", + "props": { + "content": "System is running normally", + "color": "success", + "weight": "medium" + } + }, + "content_row": { + "key": "content_row", + "type": "Row", + "props": { + "gutter": 16 + }, + "children": [ + "left_col", + "right_col" + ] + }, + "left_col": { + "key": "left_col", + "type": "Col", + "props": { + "span": 16 + }, + "children": [ + "main_content" + ] + }, + "right_col": { + "key": "right_col", + "type": "Col", + "props": { + "span": 8 + }, + "children": [ + "sidebar_card" + ] + }, + "main_content": { + "key": "main_content", + "type": "Card", + "props": { + "title": "Main Content", + "shadow": true + }, + "children": [ + "content_text" + ] + }, + "content_text": { + "key": "content_text", + "type": "Text", + "props": { + "content": "This is the main content area." + } + }, + "sidebar_card": { + "key": "sidebar_card", + "type": "Card", + "props": { + "title": "Quick Actions" + }, + "children": [ + "action_buttons" + ] + }, + "action_buttons": { + "key": "action_buttons", + "type": "Column", + "props": { + "gap": 8 + }, + "children": [ + "refresh_btn", + "export_btn" + ] + }, + "refresh_btn": { + "key": "refresh_btn", + "type": "Button", + "props": { + "children": "Refresh Data", + "block": true, + "action": "refresh" + } + }, + "export_btn": { + "key": "export_btn", + "type": "Button", + "props": { + "children": "Export Report", + "variant": "outline", + "block": true, + "action": "export" + } + } + }, + "data": { + "lastUpdated": "2026-01-27T08:53:33.135Z" + } +} +``` + +### 3. Dynamic Content with Data Binding +Form with conditional disabled states: +```json +{ + "root": "settings_form", + "elements": { + "settings_form": { + "key": "settings_form", + "type": "Card", + "props": { + "title": "Settings" + }, + "children": [ + "form_fields" + ] + }, + "form_fields": { + "key": "form_fields", + "type": "Column", + "props": { + "gap": 16 + }, + "children": [ + "enable_notifications", + "email_field", + "save_btn" + ] + }, + "enable_notifications": { + "key": "enable_notifications", + "type": "TextField", + "props": { + "label": "Enable Notifications", + "valuePath": "settings.notifications", + "type": "text" + } + }, + "email_field": { + "key": "email_field", + "type": "TextField", + "props": { + "label": "Notification Email", + "valuePath": "settings.email", + "disabledPath": "settings.emailDisabled", + "placeholder": "Enter email for notifications" + } + }, + "save_btn": { + "key": "save_btn", + "type": "Button", + "props": { + "children": "Save Settings", + "theme": "primary", + "action": "submit" + } + } + }, + "data": { + "settings": { + "notifications": "enabled", + "email": "", + "emailDisabled": false + } + } +} +``` diff --git a/packages/pro-components/chat/chat-engine/chat-engine.md b/packages/pro-components/chat/chat-engine/chat-engine.md index 65a44cdedc..78268542c9 100644 --- a/packages/pro-components/chat/chat-engine/chat-engine.md +++ b/packages/pro-components/chat/chat-engine/chat-engine.md @@ -22,7 +22,7 @@ ChatEngine 是一个底层对话引擎(Headless Core),提供灵活的 Hook 最简单的示例,使用 `useChat` Hook 创建对话引擎,组合 `ChatList`、`ChatMessage`、`ChatSender` 组件构建对话界面。 -{{ basic }} +{{ agui-test }} ## 基础用法 @@ -65,7 +65,7 @@ ChatEngine 是一个底层对话引擎(Headless Core),提供灵活的 Hook - **自定义操作栏**:如果组件库内置的 [`ChatActionbar`](/react-chat/components/chat-actionbar) 不能满足需求,可以通过 `slot='actionbar'` 属性来渲染自定义组件。 -- **自定义输入区域**:如果需要自定义ChatSender输入区,可用插槽详见[ChatSender插槽](/react-chat/components/chat-sender?tab=api#插槽) +- **自定义输入区域**:如果需要自定义ChatSender输入区,可用插槽详见[ChatSender插槽](/react-chat/components/chat-sender?tab=api#插槽) {{ custom-content }} @@ -139,9 +139,9 @@ ChatEngine 围绕工具调用提供了几个核心 Hook,它们各司其职, 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":[]}}} // @@ -151,18 +151,18 @@ const GlobalProgressBar: React.FC = () => { // {"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}
@@ -206,7 +206,106 @@ AG-UI 协议支持通过 `ACTIVITY_*` 事件展示动态内容组件(如实时 {{ agui-comprehensive }} +### 断点恢复(Resume) + +当用户离开页面后重新进入时,如果后端 Agent 仍在运行,可以通过断点恢复机制续接进行中的任务。 + +**核心流程**: +1. 请求历史消息接口,获取已完成的消息 + `pendingRun` 标识 +2. 使用 `AGUIAdapter.convertHistoryMessages` 转换并渲染已完成的消息 +3. 如果有 `pendingRun`,调用 `chatEngine.resumeRun({ threadId, runId })` 发起续传 +4. 后端推 `MESSAGES_SNAPSHOT` 事件一次性恢复已产生的中间内容(思考、工具调用、部分文本) +5. 后端继续推增量事件直到 `RUN_FINISHED` + +{{ agui-resume }} + + +## OpenClaw 协议 + +[OpenClaw](https://openclaw.io) 是一个基于 WebSocket 的 AI Agent 网关协议,采用 RPC 风格的消息通信,支持实时双向交互、流式消息推送、连接保活等特性。ChatEngine 内置了对 OpenClaw 协议的支持,可以方便地接入 OpenClaw Gateway。 + +### 设计原则 + +- **配置精简**:OpenClaw 专属配置只保留协议层必需项(`heartbeatInterval`、`client`、`protocolVersion`),业务参数通过 `onRequest` 回调传入 +- **复用外层配置**:`endpoint`、`maxRetries`、`retryInterval`、`timeout` 等通用网络配置复用 `ChatNetworkConfig` +- **安全考虑**:token 等敏感信息不在静态配置中暴露,通过 `onRequest` 动态获取 + +### 基础用法 + +设置 `protocol: 'openclaw'`,即可启用 OpenClaw WebSocket 连接。ChatEngine 会自动处理: +- WebSocket 连接建立和 `connect.challenge` 握手 +- 心跳保活和自动重连 +- 将 OpenClaw 事件(`chat`、`agent`)自动转换为 `AIMessageContent` 格式 + +> ⚠️ 本示例需要启动本地 Mock Server:`cd mock-server/online2 && node app.js` + +{{ openclaw-basic }} + + +## 生成式UI + +生成式 UI(Generative UI)是指由 AI/LLM 动态生成的用户界面。ChatEngine 基于 Vercel Lib [json-render](https://json-render.dev/docs) 理念构建了完整的生成式 UI 解决方案,让 AI 能够在**受约束的Catalog**内安全地生成 UI,输出始终可预测、可渲染。 + +### 核心设计理念 + +生成式 UI 采用**双层架构**实现安全可控的 AI UI 生成: + +- **Catalog(约束层)**:定义 AI 可以使用的组件词汇表,通过 Zod Schema 约束每个组件的 props,确保 AI 生成的 JSON 符合预期结构。使用 `generateCatalogPrompt` 将 Catalog 转换为 AI 系统提示词 +- **Registry(渲染层)**:定义组件的 React 实现,将 AI 生成的 JSON Schema 渲染为真实的 UI 组件。通过 `createCustomRegistry` 注册自定义组件 + +这种设计确保了: +- **安全可控**:AI 只能生成 Catalog 中定义的组件,无法生成任意代码 +- **输出可预测**:JSON 输出始终符合 Schema 约束,每次都能正确渲染 +- **流式渲染**:支持边生成边渲染,通过 AG-UI 协议的 `ACTIVITY_SNAPSHOT`(全量)和 `ACTIVITY_DELTA`(JSON Patch 增量)实现高性能流式更新 + +### 基础示例 + +演示生成式 UI 的完整使用流程: + +1. **定义 Catalog**:使用 `generateCatalogPrompt` 生成 AI 系统提示词,告诉 AI 可以生成哪些组件(如 Card、Button、Input 等)以及每个组件接受的 props +2. **注册 Registry**:使用 `createCustomRegistry` 注册自定义组件的 React 实现,定义这些组件如何渲染 +3. **配置 Activity**:使用 `createJsonRenderActivityConfig` 创建 Activity 配置,定义 Action 处理器(如表单提交、按钮点击等交互) + +AI 生成符合 Catalog 约束的 JSON → json-render 引擎解析 → Registry 查找组件实现 → 渲染真实 UI。 + +{{ agui-json-render-full-custom }} + +### 旁路UI渲染 + +在某些场景下,需要将 AI 生成的 UI 渲染到对话框之外的区域(如侧边栏、弹窗、独立面板等)。本示例演示如何通过 `eventBus.on(ChatEngineEventType.AGUI_ACTIVITY)` 监听 Activity 事件,获取生成的 UI Schema,然后在任意位置使用 `ActivityRenderer` 进行独立渲染。适用于需要对生成式 UI 进行额外控制和管理的场景。 + +{{ agui-json-render-external-panel }} + +### A2UI 协议渲染 + +除了原生 json-render Schema,ChatEngine 还支持 [A2UI(Agent to UI)协议 v0.9.1](https://a2ui.org/specification/v0.9.1-a2ui/)。A2UI 是 Google 专为 AI 动态渲染设计的流式 UI 协议,其核心特点是 **UI 结构与数据分离**: + +- **四种消息类型**:`createSurface`(创建画布)、`updateComponents`(更新组件结构)、`updateDataModel`(更新数据模型)、`deleteSurface`(删除画布) +- **扁平化组件列表**:组件以邻接表形式定义,通过 ID 引用建立父子关系,支持任意顺序发送 +- **数据绑定**:组件 props 可通过 `{ "path": "/user/name" }` 语法绑定到数据模型,支持双向绑定(表单输入自动更新本地数据) +- **渐进式渲染**:客户端增量解析消息流,边接收边渲染 + +使用 `createA2UIJsonRenderActivityConfig` 可以将 A2UI 协议消息自动转换为 json-render Schema 进行渲染,复用现有的 Registry 组件实现。 +{{ agui-a2ui-json-render }} ## API @@ -414,6 +513,124 @@ Activity 的统一渲染组件,负责根据 Activity 类型自动查找配置 | messageId | string | 消息 ID | +### 生成式 UI API + +以下是生成式 UI 相关的核心 API,用于创建和配置 AI 动态生成的界面组件。 + +#### generateCatalogPrompt + +生成 AI 系统提示词的工具函数,用于告诉 AI/LLM 可以生成哪些组件及其 props 约束。生成的 prompt 包含组件文档、props 说明、actions 白名单和 JSON Schema 示例。 + +##### 参数 + +| 属性名 | 类型 | 说明 | 必传 | +| -------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---- | +| name | string | Catalog 名称,用于标识组件集合 | N | +| components | Record | 自定义组件定义,key 为组件名,value 包含 props(Zod Schema 或字符串描述)和 description | N | +| actions | Record | 自定义 actions 白名单,除内置的 submit/reset/cancel 外的额外操作 | N | +| includeExample | boolean | 是否在 prompt 中包含 JSON Schema 示例,默认 true | N | +| templateMode | 'default' \\| 'a2ui' \\| 'custom' | Prompt 模板模式:default(标准 json-render)、a2ui(A2UI 协议)、custom(自定义模板) | N | +| customTemplate | (context: { name, components, actions }) => string | 自定义模板生成器函数,仅当 templateMode='custom' 时使用 | N | + +##### 返回值 + +| 类型 | 说明 | +| ------ | -------------------------------------------------------- | +| string | 完整的 AI 系统提示词,包含组件文档、props 说明和使用示例 | + +##### ComponentDoc 类型 + +| 属性名 | 类型 | 说明 | +| ----------- | ------------------------------------- | ----------------------------------------------------- | +| description | string | 组件描述 | +| props | Record \\| ZodObject | Props 定义,支持字符串描述或 Zod Schema | +| hasChildren | boolean | 是否支持子组件 | + +--- + +#### createJsonRenderActivityConfig + +创建 json-render Activity 配置的工厂函数,用于配置生成式 UI 的渲染行为和交互处理。 + +##### 参数 (JsonRenderActivityConfigOptions) + +| 属性名 | 类型 | 说明 | 必传 | +| -------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ---- | +| activityType | string | Activity 类型标识,需要与后端返回的 activityType 一致,默认 'json-render' | N | +| registry | ComponentRegistry | 组件注册表,默认使用内置的 tdesignRegistry | N | +| actionHandlers | Record) => void \\| Promise> | Action 处理器映射表,定义按钮点击等交互的处理函数 | N | +| debug | boolean | 是否显示调试信息,默认 false | N | +| description | string | Activity 描述信息 | N | + +##### 返回值 + +| 类型 | 说明 | +| ---------------------------------------- | --------------------------------------------- | +| ActivityConfig | Activity 配置对象,可传入 useAgentActivity 注册 | + +--- + +#### createA2UIJsonRenderActivityConfig + +创建 A2UI + json-render Activity 配置的工厂函数,用于将 A2UI 协议自动转换为 json-render Schema 进行渲染。 + +##### 参数 (JsonRenderActivityConfigOptions) + +| 属性名 | 类型 | 说明 | 必传 | +| -------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ---- | +| activityType | string | Activity 类型标识,默认 'a2ui-json-render' | N | +| registry | ComponentRegistry | 组件注册表,默认使用 a2uiRegistry(支持 valuePath/disabledPath 自动绑定) | N | +| actionHandlers | Record) => void \\| Promise> | Action 处理器映射表 | N | +| debug | boolean | 是否显示调试信息,默认 false | N | +| description | string | Activity 描述信息 | N | + +##### 返回值 + +| 类型 | 说明 | +| ----------------- | --------------------------------------------- | +| ActivityConfig | Activity 配置对象,可传入 useAgentActivity 注册 | + +--- + +#### createCustomRegistry + +创建自定义组件注册表的工厂函数,用于扩展内置组件,注册业务自定义组件的 React 实现。 + +##### 参数 + +| 参数名 | 类型 | 说明 | 必传 | +| ---------------- | --------------------------- | ------------------------------------------------------------------------ | ---- | +| customComponents | ComponentRegistry | 自定义组件映射表,key 为组件名(需与 Catalog 中一致),value 为 React 组件 | Y | +| options | CreateCustomRegistryOptions | 配置选项 | N | + +##### CreateCustomRegistryOptions + +| 属性名 | 类型 | 说明 | 必传 | +| ----------------- | ------- | ------------------------------------------------------------------------------------------ | ---- | +| enableStableProps | boolean | 是否自动使用 React.memo + 深比较优化组件性能,默认 false(ElementRenderer 已有内置优化) | N | + +##### 返回值 + +| 类型 | 说明 | +| ----------------- | -------------------------------------------------- | +| ComponentRegistry | 合并后的组件注册表,包含内置组件和自定义组件 | + +##### ComponentRegistry 类型 + +组件注册表是一个组件名到 React 组件的映射对象: + +```typescript +type ComponentRegistry = Record>; +``` + +##### ComponentRenderProps + +| 属性名 | 类型 | 说明 | +| -------- | ------------------- | ---------------------------------------- | +| element | JsonRenderElement | 元素对象,包含 type、props、children 等 | +| children | React.ReactNode | 子元素渲染结果 | + + ## 常见问题 ### 回调机制 vs 事件总线如何选择? diff --git a/packages/pro-components/chat/chat-engine/components/activity/index.ts b/packages/pro-components/chat/chat-engine/components/activity/index.ts index 25a7e496c6..336e966867 100644 --- a/packages/pro-components/chat/chat-engine/components/activity/index.ts +++ b/packages/pro-components/chat/chat-engine/components/activity/index.ts @@ -1,3 +1,4 @@ +export { ACTIVITY_EVENT_DETAIL_KEY, ACTIVITY_REGISTERED_EVENT, activityRegistry } from './registry'; export * from './registry'; export * from './render'; export * from './types'; diff --git a/packages/pro-components/chat/chat-engine/components/activity/registry.ts b/packages/pro-components/chat/chat-engine/components/activity/registry.ts index 5e1cc3cc6d..71d20c016f 100644 --- a/packages/pro-components/chat/chat-engine/components/activity/registry.ts +++ b/packages/pro-components/chat/chat-engine/components/activity/registry.ts @@ -1,108 +1,19 @@ -import React from 'react'; +import { createRegistryManager } from '../shared'; -import type { ActivityComponentProps, ActivityConfig, ActivityRegistry } from './types'; +import type { ActivityComponentProps, ActivityConfig } from './types'; + +/** Activity 注册事件名称 */ +export const ACTIVITY_REGISTERED_EVENT = 'activity-registered'; + +/** Activity 注册事件 detail 键名 */ +export const ACTIVITY_EVENT_DETAIL_KEY = 'activityType'; /** * Activity 注册表管理器 * 业务方注册具体的 activityType(如 json-render-main-card),精确匹配 */ -class ActivityRegistryManager { - private registry: ActivityRegistry = {}; - - // 组件渲染函数缓存,避免重复创建 React 组件 - private renderFunctionCache = new Map< - string, - React.MemoExoticComponent> - >(); - - /** - * 注册一个 Activity 配置 - */ - register(config: ActivityConfig): void { - const existingConfig = this.registry[config.activityType]; - - // 如果组件发生变化,清除旧的缓存 - if (existingConfig && existingConfig.component !== config.component) { - this.renderFunctionCache.delete(config.activityType); - } - - this.registry[config.activityType] = config; - - // 触发注册事件,支持动态注册 - window.dispatchEvent( - new CustomEvent('activity-registered', { - detail: { activityType: config.activityType }, - }), - ); - } - - /** - * 获取指定类型的 Activity 配置 - */ - get(activityType: string): ActivityConfig | undefined { - return this.registry[activityType]; - } - - /** - * 获取或创建缓存的组件渲染函数 - */ - getRenderFunction( - activityType: string, - ): React.MemoExoticComponent> | null { - const config = this.registry[activityType]; - if (!config) return null; - - // 检查缓存 - let memoizedComponent = this.renderFunctionCache.get(activityType); - - if (!memoizedComponent) { - // 创建 memo 化的组件 - memoizedComponent = React.memo((props: ActivityComponentProps) => React.createElement(config.component, props)); - - // 缓存组件 - this.renderFunctionCache.set(activityType, memoizedComponent); - } - - return memoizedComponent; - } - - /** - * 获取所有已注册的 Activity 配置 - */ - getAll(): ActivityRegistry { - return { ...this.registry }; - } - - /** - * 取消注册指定的 Activity - */ - unregister(activityType: string): void { - delete this.registry[activityType]; - this.renderFunctionCache.delete(activityType); - } - - /** - * 清空所有注册的 Activity - */ - clear(): void { - this.registry = {}; - this.renderFunctionCache.clear(); - } - - /** - * 检查指定类型是否已注册 - */ - has(activityType: string): boolean { - return activityType in this.registry; - } - - /** - * 获取所有已注册的 Activity 类型 - */ - getRegisteredTypes(): string[] { - return Object.keys(this.registry); - } -} - -// 导出单例实例 -export const activityRegistry = new ActivityRegistryManager(); +export const activityRegistry = createRegistryManager({ + getKey: (config) => config.activityType, + eventName: ACTIVITY_REGISTERED_EVENT, + eventDetailKey: ACTIVITY_EVENT_DETAIL_KEY, +}); diff --git a/packages/pro-components/chat/chat-engine/components/activity/render.tsx b/packages/pro-components/chat/chat-engine/components/activity/render.tsx index 44ed253b4f..bc07c494f7 100644 --- a/packages/pro-components/chat/chat-engine/components/activity/render.tsx +++ b/packages/pro-components/chat/chat-engine/components/activity/render.tsx @@ -1,10 +1,10 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import React, { Component, useEffect, useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import isEqual from 'react-fast-compare'; -import { activityRegistry } from './registry'; +import { ComponentErrorBoundary, useRegistrationListener } from '../shared'; +import { ACTIVITY_EVENT_DETAIL_KEY, ACTIVITY_REGISTERED_EVENT, activityRegistry } from './registry'; -import type { ErrorInfo } from 'react'; import type { ActivityData } from '@tdesign/ai-chat-engine'; import type { ActivityComponentProps } from './types'; @@ -12,39 +12,6 @@ interface ActivityRendererProps { activity: ActivityData; } -interface ErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -/** - * Activity 错误边界组件 - * 捕获子组件渲染错误,防止整个对话列表崩溃 - * TODO: 后续支持配置化的错误 UI - */ -class ActivityErrorBoundary extends Component<{ children: React.ReactNode; activityType: string }, ErrorBoundaryState> { - constructor(props: { children: React.ReactNode; activityType: string }) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - console.error(`[ActivityRenderer] Error in activity "${this.props.activityType}":`, error, errorInfo); - } - - render() { - if (this.state.hasError) { - return null; - } - - return this.props.children; - } -} - /** * 默认的 Activity 渲染器 * 当没有注册对应类型的组件时使用 @@ -66,8 +33,13 @@ const DefaultActivityRenderer: React.FC = ({ activity }) */ export const ActivityRenderer = React.memo( ({ activity }) => { - // 添加注册状态监听 - const [isRegistered, setIsRegistered] = useState(() => !!activityRegistry.getRenderFunction(activity.activityType)); + // 使用公共 Hook 监听动态注册 + const { MemoizedComponent } = useRegistrationListener({ + componentKey: activity.activityType, + eventName: ACTIVITY_REGISTERED_EVENT, + eventDetailKey: ACTIVITY_EVENT_DETAIL_KEY, + getRenderFunction: activityRegistry.getRenderFunction, + }); // 缓存组件 props const componentProps = useMemo( @@ -79,40 +51,15 @@ export const ActivityRenderer = React.memo( [activity.activityType, activity.content, activity.messageId], ); - // 监听组件注册事件,支持动态注册 - useEffect(() => { - if (!isRegistered) { - const handleRegistered = (event: CustomEvent) => { - // 精确匹配 - if (event.detail?.activityType === activity.activityType) { - setIsRegistered(true); - } - }; - - // 添加事件监听 - window.addEventListener('activity-registered', handleRegistered as EventListener); - - return () => { - window.removeEventListener('activity-registered', handleRegistered as EventListener); - }; - } - }, [activity.activityType, isRegistered]); - - // 使用 registry 的缓存渲染函数 - const MemoizedComponent = useMemo( - () => activityRegistry.getRenderFunction(activity.activityType), - [activity.activityType, isRegistered], - ); - // 如果没有注册对应的组件,使用默认渲染器 if (!MemoizedComponent) { return ; } return ( - + - + ); }, (prevProps, nextProps) => { @@ -120,17 +67,14 @@ export const ActivityRenderer = React.memo( if (prevProps.activity.activityType !== nextProps.activity.activityType) { return false; } - // 2. messageId 变化必须重渲染 if (prevProps.activity.messageId !== nextProps.activity.messageId) { return false; } - // 3. content 引用相同,跳过渲染 if (prevProps.activity.content === nextProps.activity.content) { return true; } - // 4. 使用 react-fast-compare 进行高效深比较 // 比 JSON.stringify 快 3-5 倍,且能正确处理循环引用 return isEqual(prevProps.activity.content, nextProps.activity.content); diff --git a/packages/pro-components/chat/chat-engine/components/index.ts b/packages/pro-components/chat/chat-engine/components/index.ts index 2417d32ed6..cdf9dee852 100644 --- a/packages/pro-components/chat/chat-engine/components/index.ts +++ b/packages/pro-components/chat/chat-engine/components/index.ts @@ -1,3 +1,6 @@ export * from './activity'; export * from './provider/agent-state'; + +// json-render Components +export * from './json-render'; export * from './toolcall'; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/button.tsx b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/button.tsx new file mode 100644 index 0000000000..4c225a0d97 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/button.tsx @@ -0,0 +1,108 @@ +/** + * TDesign Button 组件适配 json-render + * + * 这是纯净的 json-render Button 组件,不包含 A2UI 协议绑定逻辑 + * 如需 A2UI 支持,请使用 a2uiRegistry 中的 A2UIButton + */ + +import React, { useCallback } from 'react'; +import { Button } from 'tdesign-react'; + +import type { ActionBinding } from '@json-render/core'; +import type { ButtonProps } from 'tdesign-react'; +import type { ComponentRenderProps } from '../../types'; + +/** + * json-render Button 组件 + * 符合 @json-render/react 的 ComponentRenderProps 接口 + */ +export const JsonRenderButton: React.FC = ({ + element, + children, + onAction, + loading: parentLoading, +}) => { + const { + label, + variant = 'base', + size = 'medium', + theme = 'default', + disabled = false, + loading = false, + block = false, + shape = 'rectangle', + ghost = false, + action, + onClick, + ...restProps + } = element.props as ButtonProps & { + label?: string; + action?: string | ActionBinding; + [key: string]: any; + }; + + // 处理点击事件 + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (onClick) { + onClick(e); + } + + if (action && onAction) { + // 协议适配:归一化 action 字段 + // - 字符串简写:"submit" + // - 标准 ActionBinding:{ action, params? } + // - 兼容旧协议(A2UI / 旧版 mock 数据):{ name, context? } + let actionObj: ActionBinding; + if (typeof action === 'string') { + actionObj = { action, params: {} }; + } else { + const raw = action as ActionBinding & { + name?: string; + context?: Record; + }; + actionObj = { + ...raw, + action: raw.action ?? raw.name ?? '', + params: raw.params ?? raw.context ?? {}, + }; + } + + if (!actionObj.action) { + console.error( + '[JsonRenderButton] 按钮 action 字段缺失或不符合 ActionBinding 协议(应为字符串或 { action, params? }),实际收到:', + action, + ); + return; + } + + onAction(actionObj); + } + }, + [onClick, action, onAction], + ); + + const isLoading = loading || parentLoading; + const content = label || children; + + return ( + + ); +}; + +JsonRenderButton.displayName = 'JsonRenderButton'; + +export default JsonRenderButton; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/card.tsx b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/card.tsx new file mode 100644 index 0000000000..b731e63108 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/card.tsx @@ -0,0 +1,56 @@ +/** + * TDesign Card 组件适配 json-render + */ + +import React from 'react'; +import { Card } from 'tdesign-react'; +import type { CardProps } from 'tdesign-react'; +import { ComponentRenderProps } from '../../types'; + +/** + * json-render Card 组件 + * 符合 @json-render/react 的 ComponentRenderProps 接口 + */ +export const JsonRenderCard: React.FC = ({ element, children }) => { + const { + title, + subtitle, + description, + avatar, + cover, + actions, + bordered = true, + size = 'medium', + loading = false, + shadow = false, + hoverable = false, + header, + footer, + ...restProps + } = element.props as CardProps; + + return ( + + {children} + + ); +}; + +JsonRenderCard.displayName = 'JsonRenderCard'; + +export default JsonRenderCard; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/input.tsx b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/input.tsx new file mode 100644 index 0000000000..6356698694 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/input.tsx @@ -0,0 +1,116 @@ +/** + * TDesign Input 组件适配 json-render + * + * 这是纯净的 json-render Input 组件,不包含 A2UI 协议绑定逻辑 + * 如需 A2UI 支持,请使用 a2uiRegistry 中的 A2UITextField + */ + +import React from 'react'; +import { Input, Space } from 'tdesign-react'; +import type { InputProps } from 'tdesign-react'; +import type { ComponentRenderProps } from '../../types'; +import { useDataBinding } from '../..'; + +/** + * json-render Input 组件(基础版本,不带数据绑定) + */ +export const JsonRenderInput: React.FC = ({ element }) => { + const { + value, + defaultValue, + placeholder, + disabled = false, + readonly = false, + size = 'medium', + type = 'text', + maxlength, + clearable = false, + status, + onChange, + onBlur, + onFocus, + onEnter, + onClear, + ...restProps + } = element.props as InputProps; + + return ( + + ); +}; + +JsonRenderInput.displayName = 'JsonRenderInput'; + +/** + * json-render TextField 组件(带 label 和 valuePath 数据绑定) + * + * 这是标准 json-render 的 TextField,支持 valuePath 但不支持 A2UI 的 disabledPath + * 如需完整 A2UI 支持,请使用 a2uiRegistry 中的 A2UITextField + */ +export const JsonRenderTextField: React.FC = ({ element }) => { + const { + label, + valuePath, + placeholder, + type = 'text', + disabled = false, + size = 'medium', + ...restProps + } = element.props as InputProps & { + label?: string; + valuePath?: string; + }; + + // 细粒度订阅 + 稳定的 setValue(类似 useState 的 API) + const [value = '', setValue] = useDataBinding(valuePath!); + + if (label) { + return ( + + + + + ); + } + + return ( + + ); +}; + +JsonRenderTextField.displayName = 'JsonRenderTextField'; + +export default JsonRenderInput; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/layout.tsx b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/layout.tsx new file mode 100644 index 0000000000..1c5824526a --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/layout.tsx @@ -0,0 +1,137 @@ +/** + * TDesign 布局组件适配 json-render + * 包含 Row、Col、Space、Column、Divider 等布局组件 + */ + +import React from 'react'; +import { Row, Col, Space, Divider } from 'tdesign-react'; +import type { RowProps, ColProps, SpaceProps, DividerProps } from 'tdesign-react'; +import { type ComponentRenderProps } from '../../types'; + +/** + * json-render Row 组件 + */ +export const JsonRenderRow: React.FC = ({ element, children }) => { + const { + align = 'top', + gutter = 0, + justify = 'start', + ...restProps + } = element.props as RowProps; + + return ( + + {children} + + ); +}; + +JsonRenderRow.displayName = 'JsonRenderRow'; + +/** + * json-render Col 组件 + */ +export const JsonRenderCol: React.FC = ({ element, children }) => { + const { span, offset, order, push, pull, flex, ...restProps } = element.props as ColProps; + + return ( + + {children} + + ); +}; + +JsonRenderCol.displayName = 'JsonRenderCol'; + +/** + * json-render Space 组件 + */ +export const JsonRenderSpace: React.FC = ({ element, children }) => { + const { + align = 'center', + direction = 'horizontal', + size = 'small', + separator, + breakLine = false, + ...restProps + } = element.props as SpaceProps; + + return ( + + {children} + + ); +}; + +JsonRenderSpace.displayName = 'JsonRenderSpace'; + +/** + * json-render Column 组件 + * 垂直布局的便捷组件(基于 Space direction="vertical") + */ +export interface JsonRenderColumnProps extends Omit { + /** 间距大小 */ + gap?: number | string; +} + +export const JsonRenderColumn: React.FC = ({ element, children }) => { + const { + gap, + size = gap || 'small', + align = 'stretch', + ...restProps + } = element.props as JsonRenderColumnProps; + + return ( + + {children} + + ); +}; + +JsonRenderColumn.displayName = 'JsonRenderColumn'; + +/** + * json-render Divider 组件 + */ +export const JsonRenderDivider: React.FC = (props) => { + const { + align = 'center', + dashed = false, + layout = 'horizontal', + children, + // 过滤掉不支持的属性 + lineColor: _lineColor, + theme: _theme, + onAction: _onAction, + ...restProps + } = props as any; + + return ( + + {children} + + ); +}; + +JsonRenderDivider.displayName = 'JsonRenderDivider'; + +export default { + JsonRenderRow, + JsonRenderCol, + JsonRenderSpace, + JsonRenderColumn, + JsonRenderDivider, +}; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/text.tsx b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/text.tsx new file mode 100644 index 0000000000..5b3436b8f2 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/catalog/atomic/text.tsx @@ -0,0 +1,32 @@ +/** + * Text 组件 - 用于 json-render 静态文本展示 + */ + +import React from 'react'; +import { type ComponentRenderProps } from '../../types'; + +/** + * json-render Text 组件 + * 简单的文本展示组件 + */ +export const JsonRenderText: React.FC = ({ element, children }) => { + const { content, style, className, ...restProps } = element.props as { + content?: string; + style?: React.CSSProperties; + className?: string; + [key: string]: any; + }; + + // 优先使用 content(Schema 中定义的),其次是 children(子元素) + const text = content || children; + + return ( + + {text} + + ); +}; + +JsonRenderText.displayName = 'JsonRenderText'; + +export default JsonRenderText; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/catalog/catalog-to-prompt.ts b/packages/pro-components/chat/chat-engine/components/json-render/catalog/catalog-to-prompt.ts new file mode 100644 index 0000000000..56f7b6a553 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/catalog/catalog-to-prompt.ts @@ -0,0 +1,1254 @@ +/** + * TDesign Catalog 定义(约束层) + * 用于定义组件 props schema 和 actions 白名单 + * + * 概念区分: + * - Catalog(本文件):约束层,定义组件 props 的 Zod schemas 和 actions 白名单(给 AI/服务端用) + * - ComponentRegistry(catalog/index.ts):渲染层,映射组件名到 React 组件(给 Renderer 用) + */ + +import { z } from 'zod'; + +type CatalogConfig = { + name: string; + components: Record; + actions?: Record; + validation?: 'strict' | 'warn'; +}; + +function createCatalog(config: T) { + const actions = config.actions || {}; + + return { + ...config, + actions, + componentNames: Object.keys(config.components), + actionNames: Object.keys(actions), + }; +} + +/** + * TDesign 内置组件 Catalog + * 定义所有内置组件的 props schema 和可用 actions + * + * 这个 Catalog 用于: + * 1. 告诉 AI/LLM 可以生成哪些组件 + * 2. 约束每个组件的 props 类型 + * 3. 定义可触发的 actions 白名单 + */ +export const tdesignCatalog = createCatalog({ + name: 'tdesign', + components: { + // ==================== 基础组件 ==================== + + Button: { + props: z.object({ + // 支持 label 或 children 两种方式传递按钮文本 + label: z.string().optional(), + children: z.string().optional(), + variant: z.enum(['base', 'outline', 'dashed', 'text']).nullable(), + theme: z.enum(['default', 'primary', 'success', 'warning', 'danger']).nullable(), + size: z.enum(['small', 'medium', 'large']).nullable(), + disabled: z.boolean().nullable(), + loading: z.boolean().nullable(), + block: z.boolean().nullable(), + // action 支持字符串(action 名称)或 ActionBinding 对象 { action, params } + action: z + .union([ + z.string(), + z.object({ + action: z.string(), + params: z.record(z.string(), z.any()).optional(), + }), + ]) + .nullable(), + }), + description: 'TDesign Button component with action support', + }, + + Input: { + props: z.object({ + value: z.string().nullable(), + placeholder: z.string().nullable(), + type: z.enum(['text', 'password', 'number']).nullable(), + disabled: z.boolean().nullable(), + clearable: z.boolean().nullable(), + maxlength: z.number().nullable(), + showLimitNumber: z.boolean().nullable(), + }), + description: 'TDesign Input component', + }, + + TextField: { + props: z.object({ + label: z.string(), + name: z.string().optional(), // 表单字段名称(可选) + valuePath: z.string(), // 数据绑定路径(必需) + placeholder: z.string().nullable(), + required: z.boolean().nullable(), + type: z.enum(['text', 'password', 'email', 'number', 'tel']).nullable(), + disabled: z.boolean().nullable(), + helperText: z.string().nullable(), + }), + description: 'Form text field with label and data binding (combines Input + label)', + }, + + Text: { + props: z.object({ + content: z.string(), + variant: z.enum(['body', 'caption', 'label']).nullable(), + color: z.enum(['default', 'primary', 'secondary', 'success', 'warning', 'error']).nullable(), + weight: z.enum(['normal', 'medium', 'bold']).nullable(), + }), + description: 'Text display component', + }, + + Card: { + props: z.object({ + title: z.string().nullable(), + description: z.string().nullable(), + bordered: z.boolean().nullable(), + shadow: z.boolean().nullable(), + loading: z.boolean().nullable(), + }), + hasChildren: true, + description: 'TDesign Card container', + }, + + // ==================== 布局组件 ==================== + + Row: { + props: z.object({ + gutter: z.number().nullable(), + justify: z.enum(['start', 'end', 'center', 'space-around', 'space-between']).nullable(), + align: z.enum(['top', 'middle', 'bottom']).nullable(), + }), + hasChildren: true, + description: 'Grid row layout', + }, + + Col: { + props: z.object({ + span: z.number().min(1).max(24).nullable(), + offset: z.number().nullable(), + }), + hasChildren: true, + description: 'Grid column layout', + }, + + Space: { + props: z.object({ + direction: z.enum(['horizontal', 'vertical']).nullable(), + // size 支持字符串枚举或数字 + size: z.union([z.enum(['small', 'medium', 'large']), z.number(), z.string()]).nullable(), + align: z.enum(['start', 'center', 'end', 'baseline']).nullable(), + }), + hasChildren: true, + description: 'Space layout component', + }, + + Column: { + props: z.object({ + gap: z.number().nullable(), + align: z.enum(['start', 'center', 'end', 'stretch']).nullable(), + }), + hasChildren: true, + description: 'Vertical column layout', + }, + + Divider: { + props: z.object({ + layout: z.enum(['horizontal', 'vertical']).nullable(), + dashed: z.boolean().nullable(), + }), + description: 'Divider line', + }, + }, + + actions: { + submit: { + description: 'Submit form data to server', + }, + reset: { + description: 'Reset form to initial state (handled by Button component automatically)', + }, + cancel: { + description: 'Cancel current operation', + }, + }, + + validation: 'strict', +}); + +/** + * 创建自定义 Catalog(扩展 TDesign 内置 Catalog) + * + * @param customConfig - 自定义 Catalog 配置 + * @returns 合并后的 Catalog + * + * @example + * ```typescript + * import { createCustomCatalog } from '@tdesign-react/chat'; + * import { z } from 'zod'; + * + * const customCatalog = createCustomCatalog({ + * name: 'my-dashboard', + * components: { + * StatusCard: { + * props: z.object({ + * title: z.string(), + * status: z.enum(['success', 'warning', 'error', 'info']), + * description: z.string().nullable(), + * }), + * description: 'Custom status card component', + * }, + * ProgressBar: { + * props: z.object({ + * label: z.string().nullable(), + * percentage: z.number().min(0).max(100), + * showInfo: z.boolean().nullable(), + * }), + * description: 'Custom progress bar component', + * }, + * }, + * actions: { + * refresh: { description: 'Refresh data' }, + * export: { description: 'Export to file' }, + * }, + * }); + * + * // 使用自定义 Catalog + * // 1. 在服务端:告诉 AI 可以生成哪些组件 + * // 2. 在前端:配合 createCustomRegistry 提供渲染能力 + * ``` + */ +export function createCustomCatalog(customConfig: { + name: string; + components?: Record; + actions?: Record; + validation?: 'strict' | 'warn'; +}) { + // 获取 TDesign 内置配置 + const tdesignComponents = tdesignCatalog.components; + const tdesignActions = tdesignCatalog.actions; + + // 合并配置 + return createCatalog({ + name: customConfig.name, + components: { + ...tdesignComponents, + ...(customConfig.components || {}), + }, + actions: { + ...tdesignActions, + ...(customConfig.actions || {}), + }, + validation: customConfig.validation || 'strict', + }); +} + +/** + * 导出 TDesign Catalog 的组件列表(给 AI prompt 用) + */ +export const tdesignComponentList = tdesignCatalog.componentNames as string[]; + +/** + * 导出 TDesign Catalog 的 actions 列表(给 AI prompt 用) + */ +export const tdesignActionList = tdesignCatalog.actionNames as string[]; + +/** + * 内置组件的详细 Props 文档(用于生成详细 prompt) + */ +const BUILTIN_COMPONENT_DOCS: Record< + string, + { + description: string; + props: Record; + hasChildren?: boolean; + } +> = { + Button: { + description: 'Button component with action support', + props: { + label: 'string - Button text (alternative to children)', + children: 'string - Button text', + variant: '"base" | "outline" | "dashed" | "text" - Button style variant', + theme: '"default" | "primary" | "success" | "warning" | "danger" - Color theme', + size: '"small" | "medium" | "large" - Button size', + disabled: 'boolean - Whether button is disabled', + loading: 'boolean - Whether to show loading state', + block: 'boolean - Whether button should be full width', + action: 'string | { action: string, params?: object } - ActionBinding to trigger on click', + }, + }, + Input: { + description: 'Input component for text entry', + props: { + value: 'string - Input value', + placeholder: 'string - Placeholder text', + type: '"text" | "password" | "number" - Input type', + disabled: 'boolean - Whether input is disabled', + clearable: 'boolean - Whether to show clear button', + maxlength: 'number - Maximum input length', + showLimitNumber: 'boolean - Whether to show character count', + }, + }, + TextField: { + description: 'Form text field with label and data binding', + props: { + label: 'string (required) - Field label', + name: 'string - Form field name', + valuePath: 'string (required) - Data binding path in data model', + placeholder: 'string - Placeholder text', + required: 'boolean - Whether field is required', + type: '"text" | "password" | "email" | "number" | "tel" - Input type', + disabled: 'boolean - Whether field is disabled', + disabledPath: 'string - Data path to control disabled state dynamically', + helperText: 'string - Helper text below the field', + }, + }, + Text: { + description: 'Text display component', + props: { + content: 'string (required) - Text content to display', + variant: '"body" | "caption" | "label" - Text style variant', + color: '"default" | "primary" | "secondary" | "success" | "warning" | "error" - Text color', + weight: '"normal" | "medium" | "bold" - Font weight', + }, + }, + Card: { + description: 'Card container component', + props: { + title: 'string - Card title', + description: 'string - Card description', + bordered: 'boolean - Whether to show border', + shadow: 'boolean - Whether to show shadow', + loading: 'boolean - Whether to show loading state', + }, + hasChildren: true, + }, + Row: { + description: 'Grid row layout (24-column grid system)', + props: { + gutter: 'number - Gap between columns in pixels', + justify: '"start" | "end" | "center" | "space-around" | "space-between" - Horizontal alignment', + align: '"top" | "middle" | "bottom" - Vertical alignment', + }, + hasChildren: true, + }, + Col: { + description: 'Grid column layout (use inside Row)', + props: { + span: 'number (1-24) - Column width, out of 24 total', + offset: 'number - Column offset from left', + }, + hasChildren: true, + }, + Space: { + description: 'Space layout component for spacing children', + props: { + direction: '"horizontal" | "vertical" - Layout direction', + size: '"small" | "medium" | "large" | number - Gap size', + align: '"start" | "center" | "end" | "baseline" - Alignment', + }, + hasChildren: true, + }, + Column: { + description: 'Vertical column layout', + props: { + gap: 'number - Gap between children in pixels', + align: '"start" | "center" | "end" | "stretch" - Horizontal alignment', + }, + hasChildren: true, + }, + Divider: { + description: 'Divider line', + props: { + layout: '"horizontal" | "vertical" - Divider direction', + dashed: 'boolean - Whether to use dashed line', + }, + }, +}; + +/** + * Prompt 模板模式 + * - 'default': 默认模板,生成标准的 JSON Schema UI + * - 'a2ui': A2UI 模板,使用 A2UI 特定的消息格式和协议 + * - 'custom': 自定义模板,完全由 customTemplate 参数控制 + */ +export type PromptTemplateMode = 'default' | 'a2ui' | 'custom'; + +/** + * 组件文档定义类型 + * props 支持两种格式: + * 1. Record - 人可读的 props 描述,如 { label: 'string - Button text' } + * 2. ZodObject - Zod schema 对象,会自动转换为描述 + */ +export interface ComponentDoc { + description: string; + /** Props 定义:可以是字符串描述映射,也可以是 Zod schema */ + props: Record | z.ZodObject; + hasChildren?: boolean; +} + +/** + * 内部使用的标准化组件文档类型(props 已转为字符串) + */ +export interface NormalizedComponentDoc { + description: string; + props: Record; + hasChildren?: boolean; +} + +/** + * 将 Zod schema 转换为 props 描述字符串 + */ +function zodSchemaToPropsDoc(schema: z.ZodObject): Record { + const result: Record = {}; + const { shape } = schema; + + for (const [key, value] of Object.entries(shape)) { + result[key] = zodTypeToString(value as z.ZodTypeAny); + } + + return result; +} + +/** + * 将 Zod 类型转换为可读字符串 + */ +function zodTypeToString(type: z.ZodTypeAny): string { + // 处理 nullable + if (type instanceof z.ZodNullable) { + return `${zodTypeToString(type.unwrap() as z.ZodTypeAny)} (nullable)`; + } + + // 处理 optional + if (type instanceof z.ZodOptional) { + return `${zodTypeToString(type.unwrap() as z.ZodTypeAny)} (optional)`; + } + + // 处理 enum + if (type instanceof z.ZodEnum) { + const values = type.options as string[]; + return values.map((v) => `"${v}"`).join(' | '); + } + + // 处理 union + if (type instanceof z.ZodUnion) { + // eslint-disable-next-line no-underscore-dangle + const options = (type as any)._def.options as z.ZodTypeAny[]; + return options.map((o) => zodTypeToString(o)).join(' | '); + } + + // 处理基础类型 + if (type instanceof z.ZodString) return 'string'; + if (type instanceof z.ZodNumber) return 'number'; + if (type instanceof z.ZodBoolean) return 'boolean'; + if (type instanceof z.ZodObject) return 'object'; + if (type instanceof z.ZodArray) return 'array'; + + return 'any'; +} + +/** + * 标准化组件文档(将 Zod schema 转换为字符串描述) + */ +function normalizeComponentDoc(doc: ComponentDoc): NormalizedComponentDoc { + let props: Record; + + if (doc.props instanceof z.ZodObject) { + props = zodSchemaToPropsDoc(doc.props); + } else { + props = doc.props; + } + + return { + description: doc.description, + props, + hasChildren: doc.hasChildren, + }; +} + +/** + * 自定义模板生成器函数类型 + * 接收标准化后的组件文档(props 已转为字符串描述) + */ +export type CustomTemplateGenerator = (context: { + name: string; + components: Record; + actions: Record; +}) => string; + +/** + * 生成增强版 Catalog Prompt(包含完整 Props 文档和 Schema 示例) + * + * @param options - 配置选项 + * @returns 详细的 AI Prompt 字符串 + */ +export function generateCatalogPrompt( + options: { + name?: string; + components?: Record; + actions?: Record; + /** 是否包含 Schema 示例 */ + includeExample?: boolean; + /** + * Prompt 模板模式 + * - 'default': 标准 JSON Schema UI 模板 + * - 'a2ui': A2UI 协议模板(需服务端配合) + * - 'custom': 使用自定义模板生成器 + */ + templateMode?: PromptTemplateMode; + /** + * 自定义模板生成器(仅当 templateMode='custom' 时使用) + * 接收组件和 actions 上下文,返回完整的 prompt 字符串 + */ + customTemplate?: CustomTemplateGenerator; + } = {}, +): string { + const { + name = 'tdesign', + components = {}, + actions = {}, + includeExample = true, + templateMode = 'default', + customTemplate, + } = options; + + // 合并内置组件和自定义组件,并标准化(将 Zod schema 转为字符串描述) + const mergedComponents = { ...BUILTIN_COMPONENT_DOCS, ...components }; + const allComponents: Record = {}; + for (const [key, doc] of Object.entries(mergedComponents)) { + allComponents[key] = normalizeComponentDoc(doc); + } + + // 合并内置 actions 和自定义 actions + const builtinActions = { + submit: { description: 'Submit form data to server' }, + reset: { description: 'Reset form to initial state' }, + cancel: { description: 'Cancel current operation' }, + }; + const allActions = { ...builtinActions, ...actions }; + + // 自定义模板模式 + if (templateMode === 'custom' && customTemplate) { + return customTemplate({ name, components: allComponents, actions: allActions }); + } + + // A2UI 模板模式 - 预留给服务端实现具体模板 + if (templateMode === 'a2ui') { + return generateA2UIPrompt({ name, components: allComponents, actions: allActions, includeExample }); + } + + // 默认模板 + const lines: string[] = [ + `# ${name} UI Component Catalog`, + '', + 'You can generate dynamic UI using the following components and JSON schema format.', + '', + '## Output Format', + '', + 'Generate a JSON object with this structure:', + '```json', + '{', + ' "root": "element_id", // ID of the root element', + ' "elements": {', + ' "element_id": {', + ' "key": "element_id", // Same as the key in elements object', + ' "type": "ComponentName", // One of the available components', + ' "props": { ... }, // Component-specific props', + ' "children": ["child_id_1", "child_id_2"] // Optional: IDs of child elements', + ' }', + ' },', + ' "data": { // Optional: Initial data model for data binding', + ' "fieldName": "value"', + ' }', + '}', + '```', + '', + '## AG-UI ACTIVITY_DELTA Format (for Incremental Updates)', + '', + 'For updating existing UI, you can also generate AG-UI ACTIVITY_DELTA messages:', + '```json', + '{', + ' "type": "ACTIVITY_DELTA",', + ' "messageId": "unique_message_id",', + ' "activityType": "json-render-main-card",', + ' "patch": [', + ' {"op": "add", "path": "/elements/new-element", "value": {...}},', + ' {"op": "replace", "path": "/elements/parent/children", "value": [...]}', + ' ]', + '}', + '```', + '', + '### JSON Patch Operations for ACTIVITY_DELTA', + '', + '**Add New Element:**', + '```json', + '{"op": "add", "path": "/elements/new-button", "value": {', + ' "key": "new-button",', + ' "type": "Button",', + ' "props": {"children": "Click Me", "theme": "primary"}', + '}}', + '```', + '', + '**Update Element Properties:**', + '```json', + '{"op": "replace", "path": "/elements/my-card/props/title", "value": "Updated Title"}', + '```', + '', + '**Replace Children Array:**', + '```json', + '{"op": "replace", "path": "/elements/container/children", "value": ["child1", "child2", "new-child"]}', + '```', + '', + '**Add Child to Parent:**', + '```json', + '{"op": "add", "path": "/elements/parent/children/-", "value": "new-child-id"}', + '```', + '', + '**Update Data Model:**', + '```json', + '{"op": "replace", "path": "/data/user/name", "value": "John Doe"}', + '```', + '', + '**Complete ACTIVITY_DELTA Example:**', + '```json', + '{', + ' "type": "ACTIVITY_DELTA",', + ' "messageId": "update_123456",', + ' "activityType": "json-render-main-card",', + ' "patch": [', + ' {', + ' "op": "add",', + ' "path": "/elements/success-message",', + ' "value": {', + ' "key": "success-message",', + ' "type": "Text",', + ' "props": {', + ' "content": "Operation completed successfully!",', + ' "color": "success"', + ' }', + ' }', + ' },', + ' {', + ' "op": "add",', + ' "path": "/elements/main-container/children/-",', + ' "value": "success-message"', + ' }', + ' ]', + '}', + '```', + '', + '## JSON-Render Data Structure Details', + '', + 'Understanding the structure is crucial for generating valid UI:', + '', + '### Root Level Properties', + '- `root`: String - ID of the root element (must exist in elements)', + '- `elements`: Object - Contains all UI elements indexed by their IDs', + '- `data`: Object - Optional data model for form bindings and dynamic content', + '', + '### Element Structure', + 'Each element in the `elements` object has:', + '- `key`: String - Unique identifier (should match the object key)', + '- `type`: String - Component type name (must be from available components)', + '- `props`: Object - Component-specific properties', + '- `children`: Array - Optional array of child element IDs', + '', + '### Element Hierarchy Rules', + '1. **Root Element**: Must be specified in `root` and exist in `elements`', + '2. **Parent-Child Relationship**: Parent elements reference children by ID in `children` array', + '3. **Child Order**: Order in `children` array determines rendering order', + '4. **Unique IDs**: Each element must have a unique ID across the entire structure', + '5. **Container Components**: Only components marked with "can have children" support `children`', + '', + '### Data Binding Paths', + 'Form components can bind to data using dot notation:', + '- `"valuePath": "user.name"` → binds to `data.user.name`', + '- `"valuePath": "settings.theme"` → binds to `data.settings.theme`', + '- `"disabledPath": "form.disabled"` → controls disabled state from `data.form.disabled`', + '', + '### Element ID Best Practices', + '- Use descriptive names: `"user_name_field"` instead of `"field1"`', + '- Use consistent naming: `snake_case` or `kebab-case`', + '- Include purpose: `"submit_button"`, `"main_container"`, `"status_card"`', + '', + '## Available Components', + '', + ]; + + // 组件文档 + for (const [name, doc] of Object.entries(allComponents)) { + lines.push(`### ${name}`); + lines.push(doc.description); + if (doc.hasChildren) { + lines.push('*This component can have children.*'); + } + lines.push(''); + lines.push('**Props:**'); + for (const [propName, propDesc] of Object.entries(doc.props)) { + lines.push(`- \`${propName}\`: ${propDesc}`); + } + lines.push(''); + } + + // Actions + lines.push('## Available Actions'); + lines.push(''); + lines.push('Actions can be triggered by Button components. Use the `action` prop:'); + lines.push('- Simple: `"action": "actionName"`'); + lines.push('- With params: `"action": { "action": "actionName", "params": { ... } }`'); + lines.push(''); + for (const [name, def] of Object.entries(allActions)) { + lines.push(`- \`${name}\`: ${def.description}`); + } + lines.push(''); + + // Data Binding + lines.push('## Data Binding'); + lines.push(''); + lines.push('Use `valuePath` to bind form fields to the data model:'); + lines.push('- `"valuePath": "user.name"` binds to `data.user.name`'); + lines.push('- `"disabledPath": "formDisabled"` controls disabled state from `data.formDisabled`'); + lines.push(''); + + // 示例 + if (includeExample) { + lines.push('## Examples'); + lines.push(''); + + lines.push('### 1. Simple Form Structure'); + lines.push('A basic form with validation and data binding:'); + lines.push('```json'); + lines.push( + JSON.stringify( + { + root: 'main_card', + elements: { + main_card: { + key: 'main_card', + type: 'Card', + props: { title: 'User Registration', bordered: true }, + children: ['form_layout'], + }, + form_layout: { + key: 'form_layout', + type: 'Column', + props: { gap: 16 }, + children: ['name_field', 'email_field', 'button_group'], + }, + name_field: { + key: 'name_field', + type: 'TextField', + props: { + label: 'Full Name', + valuePath: 'user.name', + placeholder: 'Enter your full name', + required: true, + }, + }, + email_field: { + key: 'email_field', + type: 'TextField', + props: { + label: 'Email Address', + valuePath: 'user.email', + placeholder: 'Enter your email', + type: 'email', + required: true, + }, + }, + button_group: { + key: 'button_group', + type: 'Space', + props: { direction: 'horizontal', size: 'medium' }, + children: ['submit_btn', 'reset_btn'], + }, + submit_btn: { + key: 'submit_btn', + type: 'Button', + props: { + children: 'Register', + theme: 'primary', + action: { action: 'submit', params: { form: 'registration' } }, + }, + }, + reset_btn: { + key: 'reset_btn', + type: 'Button', + props: { + children: 'Reset', + variant: 'outline', + action: 'reset', + }, + }, + }, + data: { + user: { name: '', email: '' }, + }, + }, + null, + 2, + ), + ); + lines.push('```'); + lines.push(''); + + lines.push('### 2. Dashboard Layout'); + lines.push('A more complex layout with grid system and multiple components:'); + lines.push('```json'); + lines.push( + JSON.stringify( + { + root: 'dashboard', + elements: { + dashboard: { + key: 'dashboard', + type: 'Column', + props: { gap: 24 }, + children: ['header', 'content_row'], + }, + header: { + key: 'header', + type: 'Card', + props: { title: 'Dashboard Overview' }, + children: ['status_text'], + }, + status_text: { + key: 'status_text', + type: 'Text', + props: { + content: 'System is running normally', + color: 'success', + weight: 'medium', + }, + }, + content_row: { + key: 'content_row', + type: 'Row', + props: { gutter: 16 }, + children: ['left_col', 'right_col'], + }, + left_col: { + key: 'left_col', + type: 'Col', + props: { span: 16 }, + children: ['main_content'], + }, + right_col: { + key: 'right_col', + type: 'Col', + props: { span: 8 }, + children: ['sidebar_card'], + }, + main_content: { + key: 'main_content', + type: 'Card', + props: { title: 'Main Content', shadow: true }, + children: ['content_text'], + }, + content_text: { + key: 'content_text', + type: 'Text', + props: { content: 'This is the main content area.' }, + }, + sidebar_card: { + key: 'sidebar_card', + type: 'Card', + props: { title: 'Quick Actions' }, + children: ['action_buttons'], + }, + action_buttons: { + key: 'action_buttons', + type: 'Column', + props: { gap: 8 }, + children: ['refresh_btn', 'export_btn'], + }, + refresh_btn: { + key: 'refresh_btn', + type: 'Button', + props: { + children: 'Refresh Data', + block: true, + action: 'refresh', + }, + }, + export_btn: { + key: 'export_btn', + type: 'Button', + props: { + children: 'Export Report', + variant: 'outline', + block: true, + action: 'export', + }, + }, + }, + data: { + lastUpdated: new Date().toISOString(), + }, + }, + null, + 2, + ), + ); + lines.push('```'); + lines.push(''); + + lines.push('### 3. Dynamic Content with Data Binding'); + lines.push('Form with conditional disabled states:'); + lines.push('```json'); + lines.push( + JSON.stringify( + { + root: 'settings_form', + elements: { + settings_form: { + key: 'settings_form', + type: 'Card', + props: { title: 'Settings' }, + children: ['form_fields'], + }, + form_fields: { + key: 'form_fields', + type: 'Column', + props: { gap: 16 }, + children: ['enable_notifications', 'email_field', 'save_btn'], + }, + enable_notifications: { + key: 'enable_notifications', + type: 'TextField', + props: { + label: 'Enable Notifications', + valuePath: 'settings.notifications', + type: 'text', + }, + }, + email_field: { + key: 'email_field', + type: 'TextField', + props: { + label: 'Notification Email', + valuePath: 'settings.email', + disabledPath: 'settings.emailDisabled', + placeholder: 'Enter email for notifications', + }, + }, + save_btn: { + key: 'save_btn', + type: 'Button', + props: { + children: 'Save Settings', + theme: 'primary', + action: 'submit', + }, + }, + }, + data: { + settings: { + notifications: 'enabled', + email: '', + emailDisabled: false, + }, + }, + }, + null, + 2, + ), + ); + lines.push('```'); + } + + return lines.join('\n'); +} + +/** + * A2UI 协议专用 Prompt 生成器 + * 生成符合 A2UI 消息格式的系统提示词 + * + * @internal 内部函数,由 generateDetailedCatalogPrompt 调用 + */ +function generateA2UIPrompt(context: { + name: string; + components: Record; + actions: Record; + includeExample?: boolean; +}): string { + const { name, components, actions, includeExample = true } = context; + + const lines: string[] = [ + `# ${name} A2UI Component Catalog`, + '', + 'You can generate dynamic UI using A2UI protocol messages.', + '', + '## AG-UI ACTIVITY_DELTA Format', + '', + 'When generating UI updates, use the AG-UI ACTIVITY_DELTA message format:', + '```json', + '{', + ' "type": "ACTIVITY_DELTA",', + ' "messageId": "unique_message_id",', + ' "activityType": "json-render-main-card",', + ' "patch": [', + ' {"op": "add", "path": "/elements/element-id", "value": {...}},', + ' {"op": "replace", "path": "/elements/parent/children", "value": [...]}', + ' ]', + '}', + '```', + '', + '## JSON Patch Operations for AG-UI ACTIVITY_DELTA', + '', + 'The `patch` array contains JSON Patch operations that modify the UI structure:', + '', + '### Add New Element', + 'Create a new UI element in the elements object:', + '```json', + '{"op": "add", "path": "/elements/new-element-id", "value": {', + ' "key": "new-element-id",', + ' "type": "ComponentName",', + ' "props": {...},', + ' "children": ["child1", "child2"] // optional', + '}}', + '```', + '', + '### Update Element Properties', + 'Modify specific properties of an existing element:', + '```json', + '{"op": "replace", "path": "/elements/element-id/props/title", "value": "New Title"}', + '```', + '', + '### Replace Children Array', + 'Update the children of a container element:', + '```json', + '{"op": "replace", "path": "/elements/parent-id/children", "value": ["child1", "child2", "new-child"]}', + '```', + '', + '### Add Child to Parent', + "Append a child to the end of parent's children array:", + '```json', + '{"op": "add", "path": "/elements/parent-id/children/-", "value": "new-child-id"}', + '```', + '', + '### Remove Element', + "Delete an element (ensure it's removed from parent's children first):", + '```json', + '{"op": "remove", "path": "/elements/element-id"}', + '```', + '', + '### Update Data Model', + 'Modify the data model for form bindings:', + '```json', + '{"op": "replace", "path": "/data/user/name", "value": "John Doe"}', + '```', + '', + '### Complete AG-UI ACTIVITY_DELTA Example', + 'Adding a status card and updating container children:', + '```json', + '{', + ' "type": "ACTIVITY_DELTA",', + ' "messageId": "update_1234567890",', + ' "activityType": "json-render-main-card",', + ' "patch": [', + ' {', + ' "op": "add",', + ' "path": "/elements/status-card",', + ' "value": {', + ' "key": "status-card",', + ' "type": "Card",', + ' "props": {', + ' "title": "Status Update",', + ' "description": "Operation completed successfully"', + ' }', + ' }', + ' },', + ' {', + ' "op": "replace",', + ' "path": "/elements/main-container/children",', + ' "value": ["existing-element", "status-card"]', + ' }', + ' ]', + '}', + '```', + '', + '## A2UI Message Types (Alternative Formats)', + '', + 'Besides AG-UI ACTIVITY_DELTA, A2UI also supports these message types:', + '', + '### createSurface', + 'Create a new UI surface with initial elements:', + '```json', + '{', + ' "type": "createSurface",', + ' "surfaceId": "unique_surface_id",', + ' "ui": {', + ' "root": "element_id",', + ' "elements": { ... }', + ' },', + ' "data": { ... }', + '}', + '```', + '', + '### updateComponents', + 'Update existing UI elements (similar to ACTIVITY_DELTA but different format):', + '```json', + '{', + ' "type": "updateComponents",', + ' "surfaceId": "existing_surface_id",', + ' "patch": [', + ' {"op": "add", "path": "/elements/new-element", "value": {...}}', + ' ]', + '}', + '```', + '', + '### updateDataModel', + 'Update data model without changing UI:', + '```json', + '{', + ' "type": "updateDataModel",', + ' "surfaceId": "existing_surface_id",', + ' "data": { ... }', + '}', + '```', + '', + '### deleteSurface', + 'Remove a UI surface:', + '```json', + '{', + ' "type": "deleteSurface",', + ' "surfaceId": "surface_to_delete"', + '}', + '```', + '', + '## Available Components', + '', + ]; + + // 组件文档 + for (const [compName, doc] of Object.entries(components)) { + lines.push(`### ${compName}`); + lines.push(doc.description); + if (doc.hasChildren) { + lines.push('*This component can have children.*'); + } + lines.push(''); + lines.push('**Props:**'); + for (const [propName, propDesc] of Object.entries(doc.props)) { + lines.push(`- \`${propName}\`: ${propDesc}`); + } + lines.push(''); + } + + // Actions + lines.push('## Available Actions'); + lines.push(''); + for (const [actionName, def] of Object.entries(actions)) { + lines.push(`- \`${actionName}\`: ${def.description}`); + } + lines.push(''); + + // 示例 + if (includeExample) { + lines.push('## Examples'); + lines.push(''); + + lines.push('### 1. Create a form surface:'); + lines.push('```json'); + lines.push( + JSON.stringify( + { + type: 'createSurface', + surfaceId: 'user_form_1', + ui: { + root: 'form_card', + elements: { + form_card: { + key: 'form_card', + type: 'Card', + props: { title: 'User Form' }, + children: ['name_field', 'submit_btn'], + }, + name_field: { + key: 'name_field', + type: 'TextField', + props: { label: 'Name', valuePath: 'user.name' }, + }, + submit_btn: { + key: 'submit_btn', + type: 'Button', + props: { children: 'Submit', theme: 'primary', action: 'submit' }, + }, + }, + }, + data: { user: { name: '' } }, + }, + null, + 2, + ), + ); + lines.push('```'); + lines.push(''); + + lines.push('### 2. Update existing surface with JSON Patch:'); + lines.push('Add a status card and update the form title:'); + lines.push('```json'); + lines.push( + JSON.stringify( + { + type: 'updateComponents', + surfaceId: 'user_form_1', + patch: [ + { + op: 'replace', + path: '/elements/form_card/props/title', + value: 'Updated User Form', + }, + { + op: 'add', + path: '/elements/status_card', + value: { + key: 'status_card', + type: 'Card', + props: { + title: 'Status', + description: 'Form is ready for submission', + }, + }, + }, + { + op: 'add', + path: '/elements/form_card/children/0', + value: 'status_card', + }, + ], + }, + null, + 2, + ), + ); + lines.push('```'); + lines.push(''); + + lines.push('### 3. Update data model:'); + lines.push('```json'); + lines.push( + JSON.stringify( + { + type: 'updateDataModel', + surfaceId: 'user_form_1', + data: { + user: { name: 'John Doe' }, + formStatus: 'ready', + }, + }, + null, + 2, + ), + ); + lines.push('```'); + } + + return lines.join('\n'); +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/contexts/actions.tsx b/packages/pro-components/chat/chat-engine/components/json-render/contexts/actions.tsx new file mode 100644 index 0000000000..5216819d2a --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/contexts/actions.tsx @@ -0,0 +1,316 @@ +'use client'; + +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { executeAction, resolveAction } from '@json-render/core'; + +import { useDataStore } from './data'; +import { useStableCallback } from './store'; + +import type { ReactNode } from 'react'; +import type { ActionBinding, ActionConfirm, ActionHandler, ResolvedAction } from '@json-render/core'; +import type { DataStore } from './data'; + +/** + * Pending confirmation state + */ +export interface PendingConfirmation { + /** The resolved action */ + action: ResolvedAction; + /** The action handler */ + handler: ActionHandler; + /** Resolve callback */ + resolve: () => void; + /** Reject callback */ + reject: () => void; +} + +/** + * Action context value + */ +export interface ActionContextValue { + /** Registered action handlers */ + handlers: Record; + /** Currently loading action names */ + loadingActions: Set; + /** Pending confirmation dialog */ + pendingConfirmation: PendingConfirmation | null; + /** Execute an action */ + execute: (action: ActionBinding) => Promise; + /** Confirm the pending action */ + confirm: () => void; + /** Cancel the pending action */ + cancel: () => void; + /** Register an action handler */ + registerHandler: (name: string, handler: ActionHandler) => void; +} + +const ActionContext = createContext(null); + +/** + * Props for ActionProvider + */ +export interface ActionProviderProps { + /** Initial action handlers */ + handlers?: Record; + /** Navigation function */ + navigate?: (path: string) => void; + children: ReactNode; +} + +/** + * Provider for action execution + * + * 性能优化: + * - execute 函数使用 DataStore 延迟读取 data,避免 data 变化导致函数重建 + * - handlers 和 navigate 通过 ref 访问,保持 execute 引用稳定 + * - Context value 只在必要时更新 + */ +export function ActionProvider({ handlers: initialHandlers = {}, navigate, children }: ActionProviderProps) { + // 获取 DataStore 实例,不订阅状态变化 + const dataStore = useDataStore(); + + const [handlers, setHandlers] = useState>(initialHandlers); + const [loadingActions, setLoadingActions] = useState>(new Set()); + const [pendingConfirmation, setPendingConfirmation] = useState(null); + + // 使用 ref 存储依赖,保持 execute 函数引用稳定 + const storeRef = useRef(dataStore); + storeRef.current = dataStore; + + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + + const navigateRef = useRef(navigate); + navigateRef.current = navigate; + + const registerHandler = useCallback((name: string, handler: ActionHandler) => { + setHandlers((prev) => ({ ...prev, [name]: handler })); + }, []); + + // executeRef 用于递归调用 + const executeRef = useRef<(action: ActionBinding) => Promise>(); + + // execute 函数引用稳定,通过 ref 访问最新依赖 + const execute = useStableCallback(async (action: ActionBinding) => { + const store = storeRef.current; + const data = store.getData(); + const setState = (path: string, value: unknown) => store.setByPath(path, value); + const currentHandlers = handlersRef.current; + const currentNavigate = navigateRef.current; + + const resolved = resolveAction(action, data); + const handler = currentHandlers[resolved.action]; + + if (!handler) { + console.warn(`No handler registered for action: ${resolved.action}`); + return; + } + + // If confirmation is required, show dialog + if (resolved.confirm) { + return new Promise((resolve, reject) => { + setPendingConfirmation({ + action: resolved, + handler, + resolve: () => { + setPendingConfirmation(null); + resolve(); + }, + reject: () => { + setPendingConfirmation(null); + reject(new Error('Action cancelled')); + }, + }); + }).then(async () => { + setLoadingActions((prev) => new Set(prev).add(resolved.action)); + try { + await executeAction({ + action: resolved, + handler, + setState, + navigate: currentNavigate, + executeAction: async (name) => { + const subAction: ActionBinding = { action: name }; + await executeRef.current?.(subAction); + }, + }); + } finally { + setLoadingActions((prev) => { + const next = new Set(prev); + next.delete(resolved.action); + return next; + }); + } + }); + } + + // Execute immediately + setLoadingActions((prev) => new Set(prev).add(resolved.action)); + try { + await executeAction({ + action: resolved, + handler, + setState, + navigate: currentNavigate, + executeAction: async (name) => { + const subAction: ActionBinding = { action: name }; + await executeRef.current?.(subAction); + }, + }); + } finally { + setLoadingActions((prev) => { + const next = new Set(prev); + next.delete(resolved.action); + return next; + }); + } + }); + + executeRef.current = execute; + + const confirm = useCallback(() => { + pendingConfirmation?.resolve(); + }, [pendingConfirmation]); + + const cancel = useCallback(() => { + pendingConfirmation?.reject(); + }, [pendingConfirmation]); + + const value = useMemo( + () => ({ + handlers, + loadingActions, + pendingConfirmation, + execute, + confirm, + cancel, + registerHandler, + }), + [handlers, loadingActions, pendingConfirmation, execute, confirm, cancel, registerHandler], + ); + + return {children}; +} + +/** + * Hook to access action context + */ +export function useActions(): ActionContextValue { + const ctx = useContext(ActionContext); + if (!ctx) { + throw new Error('useActions must be used within an ActionProvider'); + } + return ctx; +} + +/** + * Hook to execute an action + */ +export function useAction(action: ActionBinding): { + execute: () => Promise; + isLoading: boolean; +} { + const { execute, loadingActions } = useActions(); + const isLoading = loadingActions.has(action.action); + + const executeAction = useCallback(() => execute(action), [execute, action]); + + return { execute: executeAction, isLoading }; +} + +/** + * Props for ConfirmDialog component + */ +export interface ConfirmDialogProps { + /** The confirmation config */ + confirm: ActionConfirm; + /** Called when confirmed */ + onConfirm: () => void; + /** Called when cancelled */ + onCancel: () => void; +} + +/** + * Default confirmation dialog component + */ +export function ConfirmDialog({ confirm, onConfirm, onCancel }: ConfirmDialogProps) { + const isDanger = confirm.variant === 'danger'; + + return ( +
+
e.stopPropagation()} + > +

+ {confirm.title} +

+

+ {confirm.message} +

+
+ + +
+
+
+ ); +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/contexts/data.tsx b/packages/pro-components/chat/chat-engine/components/json-render/contexts/data.tsx new file mode 100644 index 0000000000..4f40f16595 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/contexts/data.tsx @@ -0,0 +1,290 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useRef, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { produce } from "immer"; +import { + getByPath, + setByPath as setByPathMutable, + type DataModel, + type AuthState, +} from "@json-render/core"; +import { Store, createStoreContext, useStableCallback } from "./store"; + +/** + * DataStore 状态类型 + */ +export interface DataStoreState { + /** 数据模型 */ + data: DataModel; + /** 认证状态 */ + authState?: AuthState; +} + +/** + * DataStore - 管理表单/组件的数据状态 + * + * 性能优化: + * - 使用外部 Store 模式,避免 Context 传递整个 data 导致级联重渲染 + * - 支持细粒度订阅:组件只订阅自己需要的路径 + * - 配合 Structural Sharing,通过引用比较判断变化 + */ +export class DataStore extends Store { + private onDataChange?: (path: string, value: unknown) => void; + + constructor( + initialData: DataModel = {}, + authState?: AuthState, + onDataChange?: (path: string, value: unknown) => void, + ) { + super({ data: initialData, authState }); + this.onDataChange = onDataChange; + } + + /** + * 获取数据模型 + */ + getData(): DataModel { + return this.getState().data; + } + + /** + * 获取认证状态 + */ + getAuthState(): AuthState | undefined { + return this.getState().authState; + } + + /** + * 通过路径获取值 + */ + getByPath(path: string): unknown { + return getByPath(this.getState().data, path); + } + + /** + * 通过路径设置值(使用 immer 实现 Structural Sharing) + */ + setByPath(path: string, value: unknown): void { + this.updateState((prev) => + produce(prev, (draft) => { + setByPathMutable(draft.data, path, value); + }) + ); + this.onDataChange?.(path, value); + } + + /** + * 批量更新多个路径 + */ + updatePaths(updates: Record): void { + this.updateState((prev) => + produce(prev, (draft) => { + for (const [path, value] of Object.entries(updates)) { + setByPathMutable(draft.data, path, value); + this.onDataChange?.(path, value); + } + }) + ); + } + + /** + * 合并新数据到现有数据 + */ + mergeData(newData: DataModel): void { + this.updateState((prev) => + produce(prev, (draft) => { + for (const [key, value] of Object.entries(newData)) { + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof draft.data[key] === "object" && + draft.data[key] !== null + ) { + Object.assign(draft.data[key] as object, value); + } else { + draft.data[key] = value; + } + } + }) + ); + } + + /** + * 更新认证状态 + */ + setAuthState(authState: AuthState | undefined): void { + this.updateState((prev) => ({ ...prev, authState })); + } + + /** + * 设置 onDataChange 回调 + */ + setOnDataChange(callback: ((path: string, value: unknown) => void) | undefined): void { + this.onDataChange = callback; + } +} + +// 创建 Store Context +const { + Provider: DataStoreProvider, + useStore: useDataStore, + useSelector: useDataSelector, + useStoreState: useDataStoreState, +} = createStoreContext("DataStore"); + +export { useDataStore, useDataSelector, useDataStoreState }; + +/** + * Data context value(兼容旧 API) + */ +export interface DataContextValue { + /** The current data model */ + data: DataModel; + /** Auth state for visibility evaluation */ + authState?: AuthState; + /** Get a value by path */ + get: (path: string) => unknown; + /** Set a value by path */ + set: (path: string, value: unknown) => void; + /** Update multiple values at once */ + update: (updates: Record) => void; +} + +/** + * Props for DataProvider + */ +export interface DataProviderProps { + /** Initial data model */ + initialData?: DataModel; + /** Auth state */ + authState?: AuthState; + /** Callback when data changes */ + onDataChange?: (path: string, value: unknown) => void; + children: ReactNode; +} + +/** + * Provider for data model context + * + * 性能优化: + * - 内部使用 DataStore,支持细粒度订阅 + * - 提供兼容旧 API 的 useData() hook + * - 推荐使用 useDataValue() 进行细粒度订阅 + */ +export function DataProvider({ + initialData = {}, + authState, + onDataChange, + children, +}: DataProviderProps) { + // 创建稳定的 store 实例 + const storeRef = useRef(null); + if (!storeRef.current) { + storeRef.current = new DataStore(initialData, authState, onDataChange); + } + const store = storeRef.current; + + // 同步 initialData 变化(服务端推送更新) + useEffect(() => { + store.mergeData(initialData); + }, [initialData, store]); + + // 同步 authState 变化 + useEffect(() => { + store.setAuthState(authState); + }, [authState, store]); + + // 同步 onDataChange 回调 + useEffect(() => { + store.setOnDataChange(onDataChange); + }, [onDataChange, store]); + + return {children}; +} + +/** + * 获取指定路径的值(细粒度订阅) + * + * 性能优化:只有指定路径的值变化时才重渲染 + * + * @example + * const name = useDataValue('/userInfo/name'); + */ +export function useDataValue(path?: string): T | undefined { + const store = useDataStore(); + + const subscribe = useCallback( + (onStoreChange: () => void) => store.subscribe(onStoreChange), + [store], + ); + + const getSnapshot = useCallback(() => { + if (!path) return undefined; + return store.getByPath(path) as T | undefined; + }, [store, path]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * 数据绑定 hook(类似 useState 的 API) + * + * 返回 [value, setValue] 元组,value 支持细粒度订阅,setValue 引用稳定 + * + * @example + * const [name, setName] = useDataBinding('/userInfo/name'); + */ +export function useDataBinding( + path?: string, +): [T | undefined, (value: T) => void] { + const store = useDataStore(); + const value = useDataValue(path); + + const setValue = useStableCallback((newValue: T) => { + if (path) { + store.setByPath(path, newValue); + } + }); + + return [value, setValue]; +} + +/** + * 批量更新多个路径 + * + * @example + * const updatePaths = useDataUpdate(); + * updatePaths({ '/name': 'Alice', '/age': 30 }); + */ +export function useDataUpdate(): (updates: Record) => void { + const store = useDataStore(); + + return useStableCallback((updates: Record) => { + store.updatePaths(updates); + }); +} + +/** + * 获取整个 data 和 authState(订阅所有变化) + * + * ⚠️ 警告:此 hook 会订阅整个 data 对象,任何数据变化都会触发重渲染 + * 仅在以下场景使用: + * - 需要遍历整个 data 对象 + * - 需要同时访问 data 和 authState + * - ValidationProvider 等基础设施层 + * + * 普通组件请使用: + * - useDataValue() - 细粒度订阅 + * - useDataBinding() - 双向绑定 + */ +export function useDataState(): { data: DataModel; authState?: AuthState } { + const state = useDataStoreState(); + return { data: state.data, authState: state.authState }; +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/contexts/index.ts b/packages/pro-components/chat/chat-engine/components/json-render/contexts/index.ts new file mode 100644 index 0000000000..7b6d0cdf8c --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/contexts/index.ts @@ -0,0 +1,38 @@ +// Contexts +export { + DataProvider, + useDataValue, + useDataBinding, + useDataUpdate, + useDataState, + useDataStore, + type DataProviderProps, +} from "./data"; + +export { + VisibilityProvider, + useVisibility, + useIsVisible, + type VisibilityContextValue, + type VisibilityProviderProps, +} from "./visibility"; + +export { + ActionProvider, + useActions, + useAction, + ConfirmDialog, + type ActionContextValue, + type ActionProviderProps, + type PendingConfirmation, + type ConfirmDialogProps, +} from "./actions"; + +export { + ValidationProvider, + useValidation, + useFieldValidation, + type ValidationContextValue, + type ValidationProviderProps, + type FieldValidationState, +} from "./validation"; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/contexts/store.tsx b/packages/pro-components/chat/chat-engine/components/json-render/contexts/store.tsx new file mode 100644 index 0000000000..3ceae80410 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/contexts/store.tsx @@ -0,0 +1,174 @@ +import React, { + createContext, + useContext, + useRef, + useSyncExternalStore, + useCallback, + type ReactNode, +} from "react"; + +/** + * 泛型 Store 基类 + * + * 提供统一的外部状态管理模式,支持细粒度订阅。 + * 设计原则: + * 1. 状态存储在 React 外部,避免 Context 传递整个状态导致的级联重渲染 + * 2. 使用 useSyncExternalStore 实现细粒度订阅 + * 3. 配合 Structural Sharing 使用,通过引用比较判断变化 + */ +export class Store { + private state: T; + private listeners = new Set<() => void>(); + + constructor(initialState: T) { + this.state = initialState; + } + + /** + * 获取当前状态 + */ + getState(): T { + return this.state; + } + + /** + * 设置新状态并通知订阅者 + */ + setState(newState: T): void { + // 引用相同则不触发更新 + if (this.state === newState) return; + this.state = newState; + this.emitChange(); + } + + /** + * 通过 updater 函数更新状态 + */ + updateState(updater: (prev: T) => T): void { + const newState = updater(this.state); + this.setState(newState); + } + + /** + * 订阅状态变化 + */ + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + /** + * 通知所有订阅者 + */ + protected emitChange(): void { + this.listeners.forEach((listener) => listener()); + } +} + +/** + * 创建 Store Context 的工厂函数 + * + * 返回: + * - Provider: 提供 store 实例的组件 + * - useStore: 获取 store 实例的 hook + * - useSelector: 细粒度订阅 store 状态的 hook + */ +export function createStoreContext>( + displayName: string, +) { + const StoreContext = createContext(null); + + /** + * Store Provider + */ + function StoreProvider({ + store, + children, + }: { + store: S; + children: ReactNode; + }) { + // store 实例应该是稳定的,不需要 useMemo + return ( + {children} + ); + } + StoreProvider.displayName = `${displayName}Provider`; + + /** + * 获取 store 实例 + */ + function useStore(): S { + const store = useContext(StoreContext); + if (!store) { + throw new Error(`use${displayName} must be used within ${displayName}Provider`); + } + return store; + } + + /** + * 细粒度订阅 store 状态 + * + * @param selector - 从 state 中选取需要的部分 + * @returns 选取的状态片段 + * + * 性能优化: + * - selector 返回的引用不变时,组件不会重渲染 + * - 配合 Structural Sharing 使用效果最佳 + */ + function useSelector(selector: (state: T) => R): R { + const store = useStore(); + + const getSnapshot = useCallback(() => { + return selector(store.getState()); + }, [store, selector]); + + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); + } + + /** + * 订阅整个 state(谨慎使用,会导致任何变化都重渲染) + */ + function useStoreState(): T { + const store = useStore(); + + const getSnapshot = useCallback(() => { + return store.getState(); + }, [store]); + + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); + } + + return { + Provider: StoreProvider, + useStore, + useSelector, + useStoreState, + }; +} + +/** + * 使用 ref 保持稳定引用的 hook + * + * 用于需要在回调中访问最新值,但不希望回调函数重建的场景 + */ +export function useStableRef(value: T): React.MutableRefObject { + const ref = useRef(value); + ref.current = value; + return ref; +} + +/** + * 创建稳定的回调函数 + * + * 类似 useCallback,但依赖通过 ref 访问,回调函数引用永远稳定 + */ +export function useStableCallback any>( + callback: T, +): T { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useCallback(((...args) => callbackRef.current(...args)) as T, []); +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/contexts/tree.tsx b/packages/pro-components/chat/chat-engine/components/json-render/contexts/tree.tsx new file mode 100644 index 0000000000..c00ef13746 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/contexts/tree.tsx @@ -0,0 +1,87 @@ +import { createContext, useCallback, useContext, useSyncExternalStore } from 'react'; + +import { Store } from './store'; + +import type { Spec, UIElement } from '@json-render/core'; +import type { ComponentRegistry, ComponentRenderer } from '../types'; + +/** + * TreeStore - 外部 store,支持细粒度订阅 + * + * 继承自通用 Store 基类,提供 Spec 的管理能力: + * - 配合上游 Structural Sharing(结构共享)使用 + * - 上游 applyPatchImmutable 只重建被修改的节点 + * - 未修改的节点保持原引用 + * - getSnapshot 直接比较引用即可判断是否变化 + */ +export class TreeStore extends Store { + constructor() { + super(null); + } + + setTree(tree: Spec | null) { + this.setState(tree); + } + + getTree() { + return this.getState(); + } + + getElement(key: string): UIElement | undefined { + return this.getState()?.elements[key]; + } + + getRoot(): string | undefined { + return this.getState()?.root; + } +} + +/** + * Context 只传递稳定引用 + */ +interface RenderContextValue { + store: TreeStore; + registry: ComponentRegistry; + loading?: boolean; + fallback?: ComponentRenderer; +} + +export const RenderContext = createContext(null); + +export function useRenderContext(): RenderContextValue { + const ctx = useContext(RenderContext); + if (!ctx) throw new Error('ElementRenderer must be used within RenderContext'); + return ctx; +} + +/** + * 使用 useSyncExternalStore 订阅特定 element + * + * 依赖上游的 Structural Sharing: + * - 如果 element 引用没变,说明内容没变,直接返回 + * - 如果 element 引用变了,说明内容变了,返回新引用触发重渲染 + */ +export function useElement(elementKey: string): UIElement | undefined { + const { store } = useRenderContext(); + + const subscribe = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]); + + // 直接返回 element 引用 + // 由于上游使用 Structural Sharing,引用相同 = 内容相同 + const getSnapshot = useCallback(() => store.getElement(elementKey), [store, elementKey]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * Hook: 订阅 root key + */ +export function useRoot(): string | undefined { + const { store } = useRenderContext(); + + const subscribe = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]); + + const getSnapshot = useCallback(() => store.getRoot(), [store]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/contexts/validation.tsx b/packages/pro-components/chat/chat-engine/components/json-render/contexts/validation.tsx new file mode 100644 index 0000000000..45e380e6b0 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/contexts/validation.tsx @@ -0,0 +1,230 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, + type ReactNode, +} from "react"; +import { + runValidation, + type ValidationConfig, + type ValidationFunction, + type ValidationResult, +} from "@json-render/core"; +import { useDataState } from "./data"; + +/** + * Field validation state + */ +export interface FieldValidationState { + /** Whether the field has been touched */ + touched: boolean; + /** Whether the field has been validated */ + validated: boolean; + /** Validation result */ + result: ValidationResult | null; +} + +/** + * Validation context value + */ +export interface ValidationContextValue { + /** Custom validation functions from catalog */ + customFunctions: Record; + /** Validation state by field path */ + fieldStates: Record; + /** Validate a field */ + validate: (path: string, config: ValidationConfig) => ValidationResult; + /** Mark field as touched */ + touch: (path: string) => void; + /** Clear validation for a field */ + clear: (path: string) => void; + /** Validate all fields */ + validateAll: () => boolean; + /** Register field config */ + registerField: (path: string, config: ValidationConfig) => void; +} + +const ValidationContext = createContext(null); + +/** + * Props for ValidationProvider + */ +export interface ValidationProviderProps { + /** Custom validation functions from catalog */ + customFunctions?: Record; + children: ReactNode; +} + +/** + * Provider for validation + */ +export function ValidationProvider({ + customFunctions = {}, + children, +}: ValidationProviderProps) { + const { data, authState } = useDataState(); + const [fieldStates, setFieldStates] = useState< + Record + >({}); + const [fieldConfigs, setFieldConfigs] = useState< + Record + >({}); + + const registerField = useCallback( + (path: string, config: ValidationConfig) => { + setFieldConfigs((prev) => ({ ...prev, [path]: config })); + }, + [], + ); + + const validate = useCallback( + (path: string, config: ValidationConfig): ValidationResult => { + const value = data[path.split("/").filter(Boolean).join(".")]; + const result = runValidation(config, { + value, + dataModel: data, + customFunctions, + authState, + }); + + setFieldStates((prev) => ({ + ...prev, + [path]: { + touched: prev[path]?.touched ?? true, + validated: true, + result, + }, + })); + + return result; + }, + [data, customFunctions, authState], + ); + + const touch = useCallback((path: string) => { + setFieldStates((prev) => ({ + ...prev, + [path]: { + ...prev[path], + touched: true, + validated: prev[path]?.validated ?? false, + result: prev[path]?.result ?? null, + }, + })); + }, []); + + const clear = useCallback((path: string) => { + setFieldStates((prev) => { + const { [path]: _, ...rest } = prev; + return rest; + }); + }, []); + + const validateAll = useCallback(() => { + let allValid = true; + + for (const [path, config] of Object.entries(fieldConfigs)) { + const result = validate(path, config); + if (!result.valid) { + allValid = false; + } + } + + return allValid; + }, [fieldConfigs, validate]); + + const value = useMemo( + () => ({ + customFunctions, + fieldStates, + validate, + touch, + clear, + validateAll, + registerField, + }), + [ + customFunctions, + fieldStates, + validate, + touch, + clear, + validateAll, + registerField, + ], + ); + + return ( + + {children} + + ); +} + +/** + * Hook to access validation context + */ +export function useValidation(): ValidationContextValue { + const ctx = useContext(ValidationContext); + if (!ctx) { + throw new Error("useValidation must be used within a ValidationProvider"); + } + return ctx; +} + +/** + * Hook to get validation state for a field + */ +export function useFieldValidation( + path: string, + config?: ValidationConfig, +): { + state: FieldValidationState; + validate: () => ValidationResult; + touch: () => void; + clear: () => void; + errors: string[]; + isValid: boolean; +} { + const { + fieldStates, + validate: validateField, + touch: touchField, + clear: clearField, + registerField, + } = useValidation(); + + // Register field on mount + React.useEffect(() => { + if (config) { + registerField(path, config); + } + }, [path, config, registerField]); + + const state = fieldStates[path] ?? { + touched: false, + validated: false, + result: null, + }; + + const validate = useCallback( + () => validateField(path, config ?? { checks: [] }), + [path, config, validateField], + ); + + const touch = useCallback(() => touchField(path), [path, touchField]); + const clear = useCallback(() => clearField(path), [path, clearField]); + + return { + state, + validate, + touch, + clear, + errors: state.result?.errors ?? [], + isValid: state.result?.valid ?? true, + }; +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/contexts/visibility.tsx b/packages/pro-components/chat/chat-engine/components/json-render/contexts/visibility.tsx new file mode 100644 index 0000000000..5c6aa1fb90 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/contexts/visibility.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React, { + createContext, + useContext, + useRef, + type ReactNode, +} from "react"; +import { + evaluateVisibility, + type VisibilityCondition, + type VisibilityContext as CoreVisibilityContext, +} from "@json-render/core"; +import { useDataStore, type DataStore } from "./data"; +import { useStableCallback } from "./store"; + +/** + * Visibility context value + */ +export interface VisibilityContextValue { + /** Evaluate a visibility condition */ + isVisible: (condition: VisibilityCondition | undefined) => boolean; + /** Get the current visibility context (for advanced use) */ + getCtx: () => CoreVisibilityContext; +} + +const VisibilityContext = createContext(null); + +/** + * Props for VisibilityProvider + */ +export interface VisibilityProviderProps { + children: ReactNode; +} + +/** + * Provider for visibility evaluation + * + * 性能优化: + * - 不订阅 data 变化,避免 data 变化导致所有组件重渲染 + * - isVisible 函数在调用时才读取最新 data(延迟读取) + * - Context value 保持稳定引用 + * + * 设计说明: + * - Visibility 判断通常在渲染时执行,不需要触发重渲染 + * - 当 data 变化时,ElementRenderer 会因其他原因重渲染,然后调用 isVisible + * - 此时 isVisible 读取最新的 data 进行判断 + */ +export function VisibilityProvider({ children }: VisibilityProviderProps) { + // 获取 DataStore 实例,不订阅状态变化 + const dataStore = useDataStore(); + + // 使用 ref 存储 store,保持函数引用稳定 + const storeRef = useRef(dataStore); + storeRef.current = dataStore; + + // 延迟读取:isVisible 在调用时才读取最新 data + const isVisible = useStableCallback( + (condition: VisibilityCondition | undefined) => { + const store = storeRef.current; + const ctx: CoreVisibilityContext = { + dataModel: store.getData(), + authState: store.getAuthState(), + }; + return evaluateVisibility(condition, ctx); + }, + ); + + // 获取当前 context(用于高级场景) + const getCtx = useStableCallback((): CoreVisibilityContext => { + const store = storeRef.current; + return { + dataModel: store.getData(), + authState: store.getAuthState(), + }; + }); + + // Context value 使用 ref 保持稳定引用 + const valueRef = useRef({ isVisible, getCtx }); + + return ( + + {children} + + ); +} + +/** + * Hook to access visibility evaluation + */ +export function useVisibility(): VisibilityContextValue { + const ctx = useContext(VisibilityContext); + if (!ctx) { + throw new Error("useVisibility must be used within a VisibilityProvider"); + } + return ctx; +} + +/** + * Hook to check if a condition is visible + */ +export function useIsVisible( + condition: VisibilityCondition | undefined, +): boolean { + const { isVisible } = useVisibility(); + return isVisible(condition); +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/index.ts b/packages/pro-components/chat/chat-engine/components/json-render/index.ts new file mode 100644 index 0000000000..cfa52695b9 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/index.ts @@ -0,0 +1,52 @@ +/** + * json-render 集成模块入口 + * 导出所有核心 API 和类型 + */ + +// ==================== 核心组件 ==================== +// 主要渲染器组件 +export { + JsonRenderActivityRenderer, + type JsonRenderActivityRendererProps, +} from './renderer/JsonRenderActivityRenderer'; + +// A2UI 渲染器组件 +export { + A2UIJsonRenderActivityRenderer, + type A2UIJsonRenderActivityRendererProps, +} from './renderer/A2UIJsonRenderActivityRenderer'; + +// A2UI Surface React 集成(自定义协议场景使用) +export { + type A2UISurfaceController, + A2UISurfaceRenderer, + type A2UISurfaceRendererProps, + useA2UISurface, + type UseA2UISurfaceOptions, +} from './renderer/A2UISurface'; + +// ==================== 上下文 (Contexts) ==================== +export * from './contexts'; + +// ==================== 注册表 (Registry) ==================== +export type { A2UIBindingConfig, CreateCustomRegistryOptions, JsonRenderActivityConfigOptions } from './registry'; +export { + A2UIButton, + a2uiRegistry, + A2UITextField, + createA2UIRegistry, + createCustomRegistry, + tdesignRegistry, + withA2UIBinding, + withStableProps, +} from './registry'; + +// ==================== 配置工厂 ==================== +export { createA2UIJsonRenderActivityConfig, createJsonRenderActivityConfig } from './registry'; + +// ==================== 目录 (Catalog) ==================== +export * from './catalog/catalog-to-prompt'; + +// ==================== 类型定义 ==================== +export type { JSONUIProviderProps } from './renderer/JsonUIRenderer'; +export type * from './types'; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/registry/a2ui-binding.tsx b/packages/pro-components/chat/chat-engine/components/json-render/registry/a2ui-binding.tsx new file mode 100644 index 0000000000..3755e74f9e --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/registry/a2ui-binding.tsx @@ -0,0 +1,265 @@ +/** + * A2UI 数据绑定 HOC + * + * 统一处理 A2UI 协议的标准字段: + * - valuePath: 值的数据绑定路径(如 /userInfo/name) + * - disabledPath: disabled 状态的数据绑定路径(如 /formDisabled) + * - action.context: action 参数中的动态数据绑定 + * + * 性能优化: + * - 使用 React.memo + 精确值比较避免不必要渲染 + * - Action 参数延迟解析(触发时才计算) + * - 使用 useRef 缓存回调函数避免重建 + * + * 使用方式: + * ```tsx + * // 创建支持 A2UI 绑定的 Input + * const A2UIInput = withA2UIBinding(Input, { + * valueField: 'value', + * onChangeField: 'onChange', + * }); + * + * // 创建支持 action 的 Button + * const A2UIButton = withA2UIBinding(Button, { + * supportsAction: true, + * actionTrigger: 'onClick', // 默认 + * }); + * + * // Input 支持 onEnter 触发 action + * const A2UISearchInput = withA2UIBinding(Input, { + * valueField: 'value', + * onChangeField: 'onChange', + * supportsAction: true, + * actionTrigger: 'onEnter', + * }); + * ``` + */ + +import React, { memo, useCallback, useMemo, useRef } from 'react'; +import { normalizeActionBinding, resolveActionParams } from '@tdesign/ai-chat-engine'; + +import { useDataBinding, useDataStore, useDataValue } from '..'; + +import type { ActionBinding } from '@json-render/core'; +import type { ComponentRenderProps } from '../types'; +/** + * A2UI 绑定配置 + */ +export interface A2UIBindingConfig { + /** 组件的值字段名,默认 'value' */ + valueField?: string; + /** 组件的 onChange 字段名,默认 'onChange' */ + onChangeField?: string; + /** 是否支持 action 绑定,默认 false */ + supportsAction?: boolean; + /** + * Action 触发事件名,默认 'onClick' + * 可设置为 'onEnter'、'onChange' 等 + */ + actionTrigger?: string; +} + +/** + * A2UI 数据绑定 HOC 内部组件 + * 处理实际的数据绑定逻辑 + */ +interface A2UIBoundInnerProps

extends ComponentRenderProps { + WrappedComponent: React.ComponentType

; + valueField: string; + onChangeField: string; + supportsAction: boolean; + actionTrigger: string; +} + +function A2UIBoundInner

>({ + element, + children, + onAction, + WrappedComponent, + valueField, + onChangeField, + supportsAction, + actionTrigger, +}: A2UIBoundInnerProps

) { + // 提取 A2UI 特有字段 + const { + valuePath, + disabledPath, + action, + disabled: staticDisabled, + ...componentProps + } = element.props as P & { + valuePath?: string; + disabledPath?: string; + action?: string | ActionBinding; + disabled?: boolean; + }; + + // 细粒度订阅:只订阅需要的路径 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [boundValue, setBoundValue] = useDataBinding(valuePath!); + const disabledValue = useDataValue(disabledPath); + + // 获取 store(用于 action 触发时读取最新 data) + const store = useDataStore(); + + // 使用 ref 缓存 store,action 触发时获取最新值 + const storeRef = useRef(store); + storeRef.current = store; + + // 计算 disabled 状态 + const boundDisabled = useMemo(() => { + if (disabledPath) { + return Boolean(disabledValue); + } + return staticDisabled ?? false; + }, [disabledPath, disabledValue, staticDisabled]); + + // 创建稳定的 onChange 处理器(useDataBinding 已返回稳定函数) + const handleChange = useCallback( + (newValue: unknown) => { + if (valuePath && setBoundValue) { + setBoundValue(newValue as any); + } + }, + [valuePath, setBoundValue], + ); + + // 创建稳定的 action 处理器(延迟解析,触发时才获取最新 data) + const handleAction = useCallback(() => { + if (!action || !onAction) return; + + // 协议适配:归一化 action 字段 + // - 字符串简写:"submit" + // - 标准 ActionBinding:{ action, params? } + // - 兼容旧协议(A2UI / 旧版 mock 数据):{ name, context? } + const actionObj = normalizeActionBinding(action as any); + + if (!actionObj) { + console.error( + '[withA2UIBinding] action 字段缺失或不符合 ActionBinding 协议(应为字符串或 { action, params? }),实际收到:', + action, + ); + return; + } + + // 使用最新的 data 解析参数 + const currentData = storeRef.current.getData(); + const resolvedParams = actionObj.params + ? resolveActionParams(actionObj.params as Record, currentData) + : {}; + + const resolvedAction: ActionBinding = { + ...actionObj, + params: resolvedParams, + }; + + onAction(resolvedAction); + }, [action, onAction]); + + // 构建最终 props + const finalProps = useMemo(() => { + const props: any = { + ...componentProps, + disabled: boundDisabled, + }; + + // 如果有 valuePath,注入值和 onChange + if (valuePath !== undefined) { + props[valueField] = boundValue; + props[onChangeField] = handleChange; + } + + // 如果支持 action,注入到指定的触发事件 + if (supportsAction && action) { + const originalHandler = componentProps[actionTrigger]; + props[actionTrigger] = (...args: any[]) => { + // 先调用原始处理器 + if (typeof originalHandler === 'function') { + originalHandler(...args); + } + // 再触发 action + handleAction(); + }; + } + + return props; + }, [ + componentProps, + boundDisabled, + valuePath, + valueField, + boundValue, + onChangeField, + handleChange, + supportsAction, + action, + actionTrigger, + handleAction, + ]); + + return {children}; +} + +/** + * A2UI 数据绑定 HOC + * + * 自动处理 A2UI 协议的标准字段,让原子组件保持纯净 + * + * @param WrappedComponent 原始组件 + * @param config 绑定配置 + */ +export function withA2UIBinding

>( + WrappedComponent: React.ComponentType

, + config: A2UIBindingConfig = {}, +): React.FC { + const { + valueField = 'value', + onChangeField = 'onChange', + supportsAction = false, + actionTrigger = 'onClick', + } = config; + + const A2UIBoundComponent: React.FC = (props) => ( + + ); + + A2UIBoundComponent.displayName = `withA2UIBinding(${ + WrappedComponent.displayName || WrappedComponent.name || 'Component' + })`; + + // 使用 memo 包装,通过精确比较避免不必要渲染 + return memo(A2UIBoundComponent, (prevProps, nextProps) => { + // element 引用相同,跳过渲染 + if (prevProps.element === nextProps.element) return true; + + // 比较关键字段 + const prevEl = prevProps.element; + const nextEl = nextProps.element; + + if (prevEl.type !== nextEl.type) return false; + + // 比较 props(浅比较) + const prevElProps = prevEl.props || {}; + const nextElProps = nextEl.props || {}; + const prevKeys = Object.keys(prevElProps); + const nextKeys = Object.keys(nextElProps); + + if (prevKeys.length !== nextKeys.length) return false; + + for (const key of prevKeys) { + if (prevElProps[key] !== nextElProps[key]) return false; + } + + return true; + }); +} + +export default withA2UIBinding; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/registry/a2ui-registry.tsx b/packages/pro-components/chat/chat-engine/components/json-render/registry/a2ui-registry.tsx new file mode 100644 index 0000000000..634aa3bfac --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/registry/a2ui-registry.tsx @@ -0,0 +1,158 @@ +/** + * A2UI 专用组件注册表 + * + * 使用 withA2UIBinding HOC 包装原子组件,自动处理 A2UI 协议字段: + * - valuePath: 值的数据绑定(如 /userInfo/name) + * - disabledPath: disabled 状态的数据绑定(如 /formDisabled) + * - action.context: action 参数中的动态数据绑定({ path: '/xxx' } 格式) + * + * 与 tdesignRegistry 的区别: + * - tdesignRegistry: 纯净的 TDesign 组件,用于直接的 json-render schema + * - a2uiRegistry: 支持 A2UI 协议的组件,用于 A2UI → json-render 转换后的渲染 + * + * 使用方式: + * ```tsx + * // 业务使用 A2UI 协议时 + * const config = createA2UIJsonRenderActivityConfig({ + * registry: a2uiRegistry, // 内置 A2UI 组件 + * // 或扩展自定义组件 + * registry: createA2UIRegistry({ + * MyCustomComponent: withA2UIBinding(MyComponent, { supportsAction: true }), + * }), + * }); + * ``` + */ + +import React from 'react'; +import { Button, Input, Space } from 'tdesign-react'; + +// 导入纯净组件和布局组件(这些不需要 A2UI 绑定) +import { JsonRenderCard } from '../catalog/atomic/card'; +import { + JsonRenderCol, + JsonRenderColumn, + JsonRenderDivider, + JsonRenderRow, + JsonRenderSpace, +} from '../catalog/atomic/layout'; +import { JsonRenderText } from '../catalog/atomic/text'; +import { withA2UIBinding } from './a2ui-binding'; + +import type { ButtonProps, InputProps } from 'tdesign-react'; +import type { ComponentRegistry } from '../types'; + +// ==================== 基础组件包装器 ==================== + +/** + * 基础 Input 组件(用于 HOC 包装) + * 接收标准 InputProps,由 HOC 注入 value/onChange/disabled + */ +const BaseInput: React.FC = ({ label, ...props }) => { + if (label) { + return ( + + + + + ); + } + return ; +}; + +BaseInput.displayName = 'BaseInput'; + +/** + * 基础 Button 组件(用于 HOC 包装) + * 接收标准 ButtonProps,由 HOC 注入 onClick/disabled + */ +const BaseButton: React.FC = ({ label, children, ...props }) => { + const content = label || children; + return ; +}; + +BaseButton.displayName = 'BaseButton'; + +// ==================== A2UI 组件(通过 HOC 生成)==================== + +/** + * A2UI TextField 组件 + * 自动支持 valuePath/disabledPath 数据绑定 + */ +export const A2UITextField = withA2UIBinding(BaseInput, { + valueField: 'value', + onChangeField: 'onChange', + supportsAction: false, +}); + +/** + * A2UI Button 组件 + * 自动支持 disabledPath 和 action.context 动态绑定 + */ +export const A2UIButton = withA2UIBinding(BaseButton, { + supportsAction: true, +}); + +// ==================== A2UI Registry ==================== + +/** + * A2UI 专用组件注册表 + * + * 用于 A2UI 协议转换后的渲染,组件自动支持: + * - valuePath: 值绑定到 dataModel + * - disabledPath: disabled 状态绑定到 dataModel + * - action.context: action 参数动态解析 + * + * @example + * ```tsx + * import { a2uiRegistry } from './catalog/a2ui-registry'; + * + * const config = createA2UIJsonRenderActivityConfig({ + * registry: a2uiRegistry, // 使用 A2UI 专用 registry + * actionHandlers: { ... }, + * }); + * ``` + */ +export const a2uiRegistry: ComponentRegistry = { + // A2UI 绑定组件(通过 HOC 包装) + TextField: A2UITextField, + Button: A2UIButton, + + // 纯净组件(布局类不需要 A2UI 绑定) + Card: JsonRenderCard, + Text: JsonRenderText, + Row: JsonRenderRow, + Col: JsonRenderCol, + Space: JsonRenderSpace, + Column: JsonRenderColumn, + Divider: JsonRenderDivider, +}; + +/** + * 创建自定义 A2UI 组件注册表 + * + * 基于 a2uiRegistry 扩展自定义组件 + * 自定义组件如需支持 A2UI 协议,请使用 withA2UIBinding 包装 + * + * @example + * ```tsx + * import { createA2UIRegistry, withA2UIBinding } from '@tdesign-react/chat'; + * + * // 创建支持 A2UI 的自定义组件 + * const A2UIDatePicker = withA2UIBinding(DatePicker, { + * valueField: 'value', + * onChangeField: 'onChange', + * }); + * + * const customRegistry = createA2UIRegistry({ + * DatePicker: A2UIDatePicker, + * }); + * ``` + */ +export function createA2UIRegistry(customComponents: ComponentRegistry): ComponentRegistry { + return { + ...a2uiRegistry, + ...customComponents, + }; +} + +export default a2uiRegistry; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/registry/config.tsx b/packages/pro-components/chat/chat-engine/components/json-render/registry/config.tsx new file mode 100644 index 0000000000..b9f045fea5 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/registry/config.tsx @@ -0,0 +1,186 @@ +/** + * json-render Activity 配置工厂函数 + * 便捷创建 ActivityConfig 用于注册 + */ + +import React from 'react'; + +import { A2UIJsonRenderActivityRenderer } from '../renderer/A2UIJsonRenderActivityRenderer'; +import { JsonRenderActivityRenderer } from '../renderer/JsonRenderActivityRenderer'; +import { a2uiRegistry, tdesignRegistry } from '.'; + +import type { ActivityConfig } from '../../activity/types'; +import type { ComponentRegistry,JsonRenderActivityProps } from '../types'; + +/** + * json-render Activity 配置选项 + */ +export interface JsonRenderActivityConfigOptions { + /** Activity 类型标识,默认 'json-render' */ + activityType?: string; + /** 组件注册表,默认使用 tdesignRegistry */ + registry?: ComponentRegistry; + /** + * Action 处理器映射表 + * + * 重要:json-render 采用预定义 action 模式 + * - AI/服务端只能生成在此预定义的 action 名称 + * - 每个 action 对应一个具体的处理函数 + * - 这确保了生成式 UI 的安全性和可控性 + * + * @example + * ```tsx + * actionHandlers: { + * // 表单提交 + * submit: async (params) => { + * await api.submitForm(params); + * MessagePlugin.success('提交成功'); + * }, + * + * // 表单重置 + * reset: async (params) => { + * MessagePlugin.info('表单已重置'); + * }, + * + * // 删除操作 + * delete: async (params) => { + * await api.deleteItem(params.id); + * }, + * + * // 刷新数据 + * refresh: async () => { + * await refetchData(); + * } + * } + * ``` + */ + actionHandlers?: Record) => void | Promise>; + /** 显示调试信息 */ + debug?: boolean; + /** 描述信息 */ + description?: string; +} + +/** + * 创建 json-render Activity 配置 + * + * @example + * 基础用法 - 预定义 action handlers + * ```tsx + * const jsonRenderConfig = createJsonRenderActivityConfig({ + * activityType: 'json-render', + * actionHandlers: { + * submit: async (params) => { + * console.log('提交表单:', params); + * await api.submit(params); + * MessagePlugin.success('提交成功'); + * }, + * reset: async (params) => { + * MessagePlugin.info('表单已重置'); + * }, + * delete: async (params) => { + * await api.delete(params.id); + * } + * }, + * }); + * + * useAgentActivity(jsonRenderConfig); + * ``` + * + * @example + * 结合 ChatEngine 发送消息到服务端 + * ```tsx + * const jsonRenderConfig = createJsonRenderActivityConfig({ + * actionHandlers: { + * submit: async (params) => { + * // 发送到服务端处理 + * await chatEngine.sendAIMessage({ + * params: { userActionMessage: { action: 'submit', params } }, + * sendRequest: true, + * }); + * }, + * reset: async (params) => { + * // 本地处理,不发送到服务端 + * MessagePlugin.info('已重置'); + * }, + * }, + * }); + * ``` + * + * @example + * 配合自定义组件注册表 + * ```tsx + * import { createCustomRegistry } from './catalog'; + * + * const jsonRenderConfig = createJsonRenderActivityConfig({ + * registry: createCustomRegistry({ + * MyCustomComponent: MyComponentRenderer, + * }), + * actionHandlers: { + * custom_action: async (params) => { + * // 处理自定义操作 + * }, + * }, + * }); + * ``` + */ +export function createJsonRenderActivityConfig( + options: JsonRenderActivityConfigOptions = {}, +): ActivityConfig { + const { + activityType = 'json-render', + registry = tdesignRegistry, + actionHandlers = {}, + debug = false, + description = 'json-render 动态 UI 渲染器', + } = options; + + return { + activityType, + component: React.memo((props: JsonRenderActivityProps) => ( + + )), + description, + }; +} + +/** + * 创建 A2UI + json-render Activity 配置 + * 支持将 A2UI 协议转换为 json-render Schema 渲染 + * + * 注意:默认使用 a2uiRegistry,自动支持 valuePath/disabledPath/action.context 绑定 + * + * @example + * ```tsx + * const a2uiJsonRenderConfig = createA2UIJsonRenderActivityConfig({ + * activityType: 'a2ui-json-render', + * actionHandlers: { + * submit: async (params) => { + * console.log('提交:', params); + * }, + * cancel: async (params) => { + * console.log('取消'); + * }, + * }, + * }); + * + * useAgentActivity(a2uiJsonRenderConfig); + * ``` + */ +export function createA2UIJsonRenderActivityConfig(options: JsonRenderActivityConfigOptions = {}): ActivityConfig { + const { + activityType = 'a2ui-json-render', + registry = a2uiRegistry, // A2UI 默认使用 a2uiRegistry + actionHandlers = {}, + debug = false, + description = 'A2UI + json-render 适配渲染器', + } = options; + + return { + activityType, + component: React.memo((props: any) => ( + + )), + description, + }; +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/registry/index.ts b/packages/pro-components/chat/chat-engine/components/json-render/registry/index.ts new file mode 100644 index 0000000000..5daed741b4 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/registry/index.ts @@ -0,0 +1,234 @@ +/** + * TDesign ComponentRegistry(React 组件注册表) + * 用于 json-render 渲染层的组件映射 + * + * 重要概念区分: + * - ComponentRegistry(本文件):渲染层,映射组件名到 React 组件(传给 Renderer) + * - Catalog(catalog.ts):约束层,定义组件 props schema 和 actions 白名单(给 AI/服务端) + * + * Registry 分类: + * - tdesignRegistry: 纯净的 TDesign 组件,用于直接的 json-render schema + * - a2uiRegistry: 支持 A2UI 协议的组件,自动处理 valuePath/disabledPath/action.context + * + * 详见:ARCHITECTURE.md + */ + +import React from 'react'; +import isEqual from 'react-fast-compare'; +import { JsonRenderButton } from '../catalog/atomic/button'; +import { JsonRenderInput, JsonRenderTextField } from '../catalog/atomic/input'; +import { JsonRenderCard } from '../catalog/atomic/card'; +import { JsonRenderText } from '../catalog/atomic/text'; +import { + JsonRenderRow, + JsonRenderCol, + JsonRenderSpace, + JsonRenderColumn, + JsonRenderDivider, +} from '../catalog/atomic/layout'; +import type { ComponentRegistry, ComponentRenderProps } from '../types'; + +/** + * 高性能组件包装器(可选) + * 使用 React.memo + react-fast-compare 实现深比较 + * + * 注意:由于 ElementRenderer 已经实现了 React.memo + 深比较优化, + * 大多数情况下叶子组件不需要再使用 withStableProps 包装。 + * + * 使用场景: + * - 组件内部有复杂的计算逻辑,希望进一步减少重渲染 + * - 组件使用了 Context,需要避免 Context 变化导致的不必要渲染 + * + * 原理: + * - json-render 每次渲染都会创建新的 element 对象引用 + * - 默认的 React.memo 浅比较会认为 props 变化了 + * - 使用 react-fast-compare 进行高效深比较,只在内容真正变化时才重渲染 + * + * 性能说明: + * - react-fast-compare 比 JSON.stringify 更快(短路比较) + * - 发现第一个不同属性时立即停止,不会遍历整个对象 + * - 处理了循环引用等边缘情况 + */ +export function withStableProps

( + Component: React.ComponentType

, +): React.MemoExoticComponent> { + return React.memo(Component, (prevProps, nextProps) => { + const prevElement = prevProps.element as any; + const nextElement = nextProps.element as any; + + // 1. children 变化必须重渲染 + // 深层更新时,父组件的 element 可能不变,但 children(子组件树)会变化 + if (prevProps.children !== nextProps.children) { + return false; + } + + // 2. element 引用相同,跳过渲染 + if (prevElement === nextElement) return true; + + // 3. 快速路径:id 或 type 不同,需要重渲染 + if (prevElement.id !== nextElement.id || prevElement.type !== nextElement.type) { + return false; + } + + // 4. 使用 react-fast-compare 进行高效深比较 + return isEqual(prevElement.props, nextElement.props); + }); +} + +/** + * TDesign 内置组件注册表(渲染层) + * + * 这是框架内置的原子组件集合,提供基础 UI 渲染能力 + * 业务层可以通过 createCustomRegistry 扩展自定义组件 + * + * 使用方式: + * ```tsx + * import { tdesignRegistry } from '@tdesign-react/chat'; + * + * const config = createJsonRenderActivityConfig({ + * registry: tdesignRegistry, + * actionHandlers: { ... }, + * }); + * ``` + * + * Schema 示例: + * ```json + * { + * "root": "btn1", + * "elements": { + * "btn1": { + * "key": "btn1", + * "type": "Button", + * "props": { + * "variant": "base", + * "theme": "primary", + * "children": "点击我", + * "action": "submit" + * } + * } + * } + * } + * ``` + */ +export const tdesignRegistry: ComponentRegistry = { + // 基础组件 + Button: JsonRenderButton, + Input: JsonRenderInput, + TextField: JsonRenderTextField, + Card: JsonRenderCard, + Text: JsonRenderText, + + // 布局组件 + Row: JsonRenderRow, + Col: JsonRenderCol, + Space: JsonRenderSpace, + Column: JsonRenderColumn, + Divider: JsonRenderDivider, + + // 别名(兼容不同命名风格) + button: JsonRenderButton, + input: JsonRenderInput, + textfield: JsonRenderTextField, + card: JsonRenderCard, + text: JsonRenderText, + row: JsonRenderRow, + col: JsonRenderCol, + space: JsonRenderSpace, + column: JsonRenderColumn, + divider: JsonRenderDivider, +}; + +/** + * createCustomRegistry 配置选项 + */ +export interface CreateCustomRegistryOptions { + /** + * 是否自动包装组件以优化性能 + * 使用 React.memo + react-fast-compare 深比较 element.props + * + * 注意:由于 ElementRenderer 已经实现了 memo 优化, + * 默认关闭此选项。仅在组件有复杂内部逻辑时考虑开启。 + * + * @default false + */ + enableStableProps?: boolean; +} + +/** + * 创建自定义组件注册表(扩展内置组件) + * + * 用于渲染层:扩展自定义业务组件的 React 实现 + * + * 性能说明: + * - ElementRenderer 已经使用 React.memo + 深比较优化,会自动跳过无变化节点 + * - 默认情况下,自定义组件无需额外的 memo 包装 + * - 如果组件有复杂内部逻辑,可以设置 enableStableProps: true 进行双重优化 + * + * @example + * ```tsx + * import { createCustomRegistry } from '@tdesign-react/chat'; + * import type { ComponentRenderProps } from '@json-render/react'; + * + * // 定义自定义组件(无需手动 React.memo) + * const StatusCard: React.FC = ({ element }) => ( + *

{element.props.status}
+ * ); + * + * const ProgressBar: React.FC = ({ element }) => ( + *
+ * ); + * + * // 扩展 registry(ElementRenderer 已有优化,无需额外包装) + * const customRegistry = createCustomRegistry({ + * StatusCard, + * ProgressBar, + * }); + * + * // 如果组件有复杂内部逻辑,可以开启双重优化 + * const customRegistry = createCustomRegistry( + * { StatusCard, ProgressBar }, + * { enableStableProps: true } + * ); + * ``` + * + * 注意: + * - 这里只定义渲染层的组件映射 + * - 约束层(Catalog)需要使用 createCustomCatalog 定义(见 catalog.ts) + * - 两者需要保持组件名称一致 + */ +export function createCustomRegistry( + customComponents: ComponentRegistry, + options: CreateCustomRegistryOptions = {}, +): ComponentRegistry { + const { enableStableProps = false } = options; + + // 如果启用性能优化,自动包装组件 + const processedComponents: ComponentRegistry = {}; + + if (enableStableProps) { + for (const [name, Component] of Object.entries(customComponents)) { + processedComponents[name] = withStableProps(Component as React.ComponentType); + } + } else { + Object.assign(processedComponents, customComponents); + } + + return { + ...tdesignRegistry, + ...processedComponents, + }; +} + +// ==================== 重新导出 A2UI Registry ==================== +// A2UI 专用组件,支持 valuePath/disabledPath/action.context 自动绑定 +export { a2uiRegistry, createA2UIRegistry, A2UITextField, A2UIButton } from './a2ui-registry'; + + +// 配置工厂 +export type { JsonRenderActivityConfigOptions } from './config'; +// 默认导出配置函数 +export { createJsonRenderActivityConfig, createA2UIJsonRenderActivityConfig } from './config'; + +// ==================== 重新导出 A2UI Binding HOC ==================== +export { withA2UIBinding } from './a2ui-binding'; +export type { A2UIBindingConfig } from './a2ui-binding'; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/renderer/A2UIJsonRenderActivityRenderer.tsx b/packages/pro-components/chat/chat-engine/components/json-render/renderer/A2UIJsonRenderActivityRenderer.tsx new file mode 100644 index 0000000000..4649c64a49 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/renderer/A2UIJsonRenderActivityRenderer.tsx @@ -0,0 +1,235 @@ +/** + * A2UI v0.9.1 + json-render Activity 渲染器 + * 将 A2UI v0.9.1 协议转换为 json-render Schema 进行渲染 + * + * 工作流程: + * 1. 接收 ACTIVITY_SNAPSHOT/DELTA 中的 A2UI content + * 2. 区分消息类型:UI型 vs 纯数据型 + * 3. UI型:转换为 Schema 并注册到 SurfaceStateManager,渲染 UI + * 4. 纯数据型:通过 SurfaceStateManager 更新数据,触发订阅者重渲染,本组件不渲染 + * + * 消息分类: + * - UI型消息:包含 createSurface / updateComponents / deleteSurface → 需要渲染/更新 UI + * - 纯数据型消息:仅包含 updateDataModel → 只更新状态,不渲染新 UI + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + convertA2UIMessagesToJsonRender, + extractSurfaceId, + hasCreationMessages, + hasDeletionMessages, + isUIMessages, + surfaceStateManager, +} from '@tdesign/ai-chat-engine'; + +import { JsonRenderActivityRenderer } from './JsonRenderActivityRenderer'; + +import type { A2UIMessage, JsonRenderSchema } from '@tdesign/ai-chat-engine'; +import type { ComponentRegistry, JsonRenderActivityProps } from '../types'; + +export interface A2UIJsonRenderActivityRendererProps extends Omit { + /** A2UI content(包含 messages 数组) */ + content: { + messages?: A2UIMessage[]; + [key: string]: any; + }; + /** 组件注册表(必须) */ + registry: ComponentRegistry; + /** Action 处理器(可选) */ + actionHandlers?: Record) => void | Promise>; + /** 显示调试信息 */ + debug?: boolean; +} + +/** + * A2UI v0.9.1 + json-render Activity 渲染器组件 + */ +export const A2UIJsonRenderActivityRenderer: React.FC = ({ + activityType, + content, + messageId, + ext, + registry, + actionHandlers, + debug = false, +}) => { + // 设置调试模式 + useEffect(() => { + surfaceStateManager.setDebug(debug); + }, [debug]); + + // 用于触发重渲染的版本号 + const [schemaVersion, setSchemaVersion] = useState(0); + + // 分析消息类型 + const { surfaceId, isUI, isDeletion, isCreation, messages } = useMemo(() => { + const msgs = content.messages; + if (!Array.isArray(msgs) || msgs.length === 0) { + return { surfaceId: null, isUI: false, isDeletion: false, isCreation: false, messages: [] }; + } + return { + surfaceId: extractSurfaceId(msgs), + isUI: isUIMessages(msgs), + isDeletion: hasDeletionMessages(msgs), + isCreation: hasCreationMessages(msgs), + messages: msgs, + }; + }, [content.messages]); + + // 处理消息并获取 Schema + const initialSchema = useMemo(() => { + if (!surfaceId || messages.length === 0) { + return null; + } + + if (debug) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 处理消息:', { + messageId, + surfaceId, + messagesCount: messages.length, + messageTypes: messages.map( + (m) => + Object.keys(m).filter((k) => + ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface'].includes(k), + )[0], + ), + isUI, + isCreation, + isDeletion, + cachedSurfaces: surfaceStateManager.getAllSurfaceIds(), + }); + } + + // 删除型消息:清理缓存 + if (isDeletion) { + surfaceStateManager.deleteSurface(surfaceId); + if (debug) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 删除 Surface:', surfaceId); + } + return null; + } + + // 创建型消息:转换并注册 + if (isCreation) { + const schema = convertA2UIMessagesToJsonRender(messages); + if (schema) { + // 提取 catalogId + const catalogId = messages.find((m) => m.createSurface)?.createSurface?.catalogId; + surfaceStateManager.registerSurface(surfaceId, schema, catalogId); + + if (debug) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 创建型消息,注册 Surface:', { + surfaceId, + elementsCount: Object.keys(schema.elements).length, + data: schema.data, + }); + } + } + return schema; + } + + // 更新型消息:通过 SurfaceStateManager 更新数据 + for (const msg of messages) { + if (msg.updateDataModel) { + const { path, op, value } = msg.updateDataModel; + const success = surfaceStateManager.updateData(surfaceId, path, op || 'replace', value); + + if (debug) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 更新型消息,更新数据:', { + surfaceId, + path, + op: op || 'replace', + value, + success, + }); + } + } + } + + // 更新型消息不需要渲染,返回 null + return null; + }, [messages, surfaceId, isCreation, isDeletion, isUI, debug, messageId]); + + // 订阅状态变化的回调 + const handleSchemaUpdate = useCallback(() => { + if (debug) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 收到状态更新通知,触发重渲染'); + } + setSchemaVersion((v) => v + 1); + }, [debug]); + + // 订阅 Surface 状态变化(仅当是创建型消息时) + useEffect(() => { + if (!isCreation || !surfaceId) { + return; + } + + if (debug) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 订阅 Surface 状态:', surfaceId); + } + + const unsubscribe = surfaceStateManager.subscribe(surfaceId, handleSchemaUpdate); + + return () => { + if (debug) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 取消订阅 Surface 状态:', surfaceId); + } + unsubscribe(); + }; + }, [isCreation, surfaceId, handleSchemaUpdate, debug]); + + // 获取当前 Schema(考虑版本变化) + const currentSchema = useMemo(() => { + if (!isCreation || !surfaceId) { + return null; + } + // schemaVersion 变化时从缓存获取最新 Schema + return surfaceStateManager.getSchema(surfaceId) || initialSchema; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isCreation, surfaceId, initialSchema, schemaVersion]); + + // 非 UI 型消息不渲染(仅 updateDataModel 的消息) + if (!isUI) { + if (debug && messages.length > 0) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 纯数据更新消息,跳过渲染'); + } + return null; + } + + // 删除型消息:返回 null(UI 已被删除) + if (isDeletion && !isCreation) { + if (debug) { + // eslint-disable-next-line no-console + console.log('[A2UI Adapter] 删除型消息,跳过渲染'); + } + return null; + } + + // schema 尚未就绪:等待下一次更新 + if (!currentSchema) { + return null; + } + + // 渲染 UI + return ( + + ); +}; + +export default A2UIJsonRenderActivityRenderer; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/renderer/A2UISurface.tsx b/packages/pro-components/chat/chat-engine/components/json-render/renderer/A2UISurface.tsx new file mode 100644 index 0000000000..12ebe3cffb --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/renderer/A2UISurface.tsx @@ -0,0 +1,268 @@ +/** + * A2UI Surface React 集成 + * + * 在 React 端提供两类 API: + * 1. useA2UISurface:管理一组 A2UI Surface 的生命周期,处理 A2UI v0.9.1 消息流 + * 2. A2UISurfaceRenderer:渲染指定 surfaceId 的 UI,订阅 surfaceStateManager 状态变化 + * + * 设计原则: + * - 协议解析 / Surface 状态管理:复用 ai-core 的 json-render 适配器(surfaceStateManager + convertA2UIMessagesToJsonRender) + * - React 相关逻辑(hook / 订阅 / 渲染):实现在 react 仓库 + * - 不再依赖已废弃的 adapters/a2ui 模块 + */ + +import React, { useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; +import { + applyA2UIDataUpdate, + applyA2UIUpdates, + convertA2UIMessagesToJsonRender, + groupMessagesBySurface, + surfaceStateManager, +} from '@tdesign/ai-chat-engine'; + +import { JsonRenderActivityRenderer } from './JsonRenderActivityRenderer'; + +import type { A2UIMessage, JsonRenderSchema } from '@tdesign/ai-chat-engine'; +import type { ComponentRegistry } from '../types'; + +/* ------------------------------------------------------------------ */ +/* A2UI Surface hook */ +/* ------------------------------------------------------------------ */ + +/** + * useA2UISurface hook 返回值 + */ +export interface A2UISurfaceController { + /** 当前活跃的 Surface ID 列表(已按出现顺序排列) */ + surfaceIds: string[]; + /** 处理一批 A2UI v0.9.1 消息(createSurface / updateComponents / updateDataModel / deleteSurface) */ + processMessages: (messages: A2UIMessage[]) => void; + /** 清除所有 Surface 缓存与本地记录 */ + clearAllSurfaces: () => void; + /** 检查指定 Surface 是否存在 */ + hasSurface: (surfaceId: string) => boolean; +} + +/** + * useA2UISurface 配置 + */ +export interface UseA2UISurfaceOptions { + /** 是否打印调试日志 */ + debug?: boolean; +} + +/** + * 管理一组 A2UI Surface 的生命周期 + * + * 内部状态: + * - 仅维护"哪些 surfaceId 是当前 hook 创建的"这一份本地快照(不存 schema,避免与 surfaceStateManager 双源) + * - 真正的 schema/data 由 surfaceStateManager 持有,通过 A2UISurfaceRenderer 订阅渲染 + */ +export function useA2UISurface(options: UseA2UISurfaceOptions = {}): A2UISurfaceController { + const { debug = false } = options; + + // 当前 hook 持有的 surfaceId 集合(用版本号驱动 useSyncExternalStore 重渲染) + const surfaceIdsRef = useRef([]); + const versionRef = useRef(0); + const listenersRef = useRef void>>(new Set()); + + const subscribe = useCallback((listener: () => void) => { + listenersRef.current.add(listener); + return () => { + listenersRef.current.delete(listener); + }; + }, []); + + // 缓存版本相关的快照对象,避免无限重渲染 + const snapshotRef = useRef<{ ids: string[]; version: number }>({ + ids: surfaceIdsRef.current, + version: versionRef.current, + }); + const getSnapshot = useCallback(() => { + if (snapshotRef.current.version !== versionRef.current) { + snapshotRef.current = { ids: surfaceIdsRef.current.slice(), version: versionRef.current }; + } + return snapshotRef.current; + }, []); + + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + const notify = useCallback(() => { + versionRef.current += 1; + listenersRef.current.forEach((listener) => listener()); + }, []); + + const addSurfaceId = useCallback( + (surfaceId: string) => { + if (!surfaceIdsRef.current.includes(surfaceId)) { + surfaceIdsRef.current = [...surfaceIdsRef.current, surfaceId]; + notify(); + } + }, + [notify], + ); + + const removeSurfaceId = useCallback( + (surfaceId: string) => { + if (surfaceIdsRef.current.includes(surfaceId)) { + surfaceIdsRef.current = surfaceIdsRef.current.filter((id) => id !== surfaceId); + notify(); + } + }, + [notify], + ); + + /** + * 处理一批 A2UI v0.9.1 消息 + * + * 路由策略: + * - createSurface + updateComponents(同批):调用 convertA2UIMessagesToJsonRender 一次性产出 schema 并 registerSurface + * - 已存在 surface 上的 updateComponents:调用 applyA2UIUpdates 增量更新现有 schema + * - updateDataModel:通过 surfaceStateManager.updateData 走标准订阅通知路径 + * - deleteSurface:调用 surfaceStateManager.deleteSurface 并从本地列表移除 + */ + const processMessages = useCallback( + (messages: A2UIMessage[]) => { + if (!Array.isArray(messages) || messages.length === 0) return; + + const grouped = groupMessagesBySurface(messages); + + grouped.forEach((surfaceMessages, surfaceId) => { + // 先处理删除:删除后该批后续消息无意义 + const hasDelete = surfaceMessages.some((msg) => msg.deleteSurface); + if (hasDelete) { + surfaceStateManager.deleteSurface(surfaceId); + removeSurfaceId(surfaceId); + if (debug) { + // eslint-disable-next-line no-console + console.log('[useA2UISurface] 删除 Surface:', surfaceId); + } + return; + } + + const hasCreate = surfaceMessages.some((msg) => msg.createSurface); + const existed = surfaceStateManager.hasSurface(surfaceId); + + // 创建型 / 首次出现 → 整批转换并注册 + if (hasCreate || !existed) { + const schema = convertA2UIMessagesToJsonRender(surfaceMessages); + if (schema) { + const catalogId = surfaceMessages.find((m) => m.createSurface)?.createSurface?.catalogId; + surfaceStateManager.registerSurface(surfaceId, schema, catalogId); + addSurfaceId(surfaceId); + if (debug) { + // eslint-disable-next-line no-console + console.log('[useA2UISurface] 注册 Surface:', surfaceId); + } + // 同批内已经包含 updateDataModel 的初始数据,convertA2UIMessagesToJsonRender 已处理 + // 不需要再次走 updateData 路径 + return; + } + } + + // 已存在的 Surface:分别派发各类消息 + let mergedSchema: JsonRenderSchema | null = surfaceStateManager.getSchema(surfaceId); + let schemaDirty = false; + + for (const msg of surfaceMessages) { + if (msg.updateComponents && mergedSchema) { + mergedSchema = applyA2UIUpdates(mergedSchema, msg.updateComponents.components as any[]); + schemaDirty = true; + } else if (msg.updateDataModel) { + // updateDataModel 走 surfaceStateManager 标准订阅路径 + const { path, op, value } = msg.updateDataModel; + surfaceStateManager.updateData(surfaceId, path, op || 'replace', value); + } + } + + // 组件树变化:通过 updateSchema 通知订阅者 + if (schemaDirty && mergedSchema) { + surfaceStateManager.updateSchema(surfaceId, mergedSchema); + addSurfaceId(surfaceId); + } + }); + }, + [addSurfaceId, removeSurfaceId, debug], + ); + + const clearAllSurfaces = useCallback(() => { + // 仅清除本 hook 创建的 surface,避免影响其他模块 + surfaceIdsRef.current.forEach((id) => surfaceStateManager.deleteSurface(id)); + surfaceIdsRef.current = []; + notify(); + }, [notify]); + + const hasSurface = useCallback((surfaceId: string) => surfaceStateManager.hasSurface(surfaceId), []); + + return useMemo( + () => ({ + surfaceIds: snapshot.ids, + processMessages, + clearAllSurfaces, + hasSurface, + }), + [snapshot, processMessages, clearAllSurfaces, hasSurface], + ); +} + +/* ------------------------------------------------------------------ */ +/* A2UI Surface Renderer */ +/* ------------------------------------------------------------------ */ + +export interface A2UISurfaceRendererProps { + /** Surface ID */ + surfaceId: string; + /** 组件注册表(必传) */ + registry: ComponentRegistry; + /** Action 处理器映射,与 JsonRenderActivityRenderer.actionHandlers 协议一致 */ + actionHandlers?: Record) => void | Promise>; +} + +/** + * 渲染指定 Surface 的 UI + * 内部订阅 surfaceStateManager 状态变化,自动响应 schema/data 更新 + * + * 渲染委托给 JsonRenderActivityRenderer,复用其 DataProvider/VisibilityProvider/ActionProvider 链路 + */ +export const A2UISurfaceRenderer: React.FC = ({ surfaceId, registry, actionHandlers }) => { + // 订阅指定 surface 的 schema 变化 + const subscribe = useCallback( + (listener: () => void) => surfaceStateManager.subscribe(surfaceId, listener), + [surfaceId], + ); + + // 缓存最近一次 schema 引用,确保 getSnapshot 引用稳定(避免 useSyncExternalStore 抖动) + const lastSchemaRef = useRef(null); + const getSnapshot = useCallback(() => { + const next = surfaceStateManager.getSchema(surfaceId); + if (next !== lastSchemaRef.current) { + lastSchemaRef.current = next; + } + return lastSchemaRef.current; + }, [surfaceId]); + + const schema = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + if (!schema) { + return null; + } + + return ( + + ); +}; + +export default A2UISurfaceRenderer; + +/* ------------------------------------------------------------------ */ +/* Re-export 给消费方使用的工具 */ +/* ------------------------------------------------------------------ */ + +export { applyA2UIDataUpdate, applyA2UIUpdates, convertA2UIMessagesToJsonRender }; +export type { A2UIMessage, JsonRenderSchema }; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/renderer/JsonRenderActivityRenderer.tsx b/packages/pro-components/chat/chat-engine/components/json-render/renderer/JsonRenderActivityRenderer.tsx new file mode 100644 index 0000000000..7dada1d1e8 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/renderer/JsonRenderActivityRenderer.tsx @@ -0,0 +1,104 @@ +/** + * json-render Activity 渲染器 + * 基于 TDesign ChatEngine 的 Activity 机制集成 json-render + * + * 核心特性: + * 1. 支持 ACTIVITY_SNAPSHOT 全量渲染 + * 2. 支持 ACTIVITY_DELTA 增量更新(Delta Merge 由数据层完成,此处接收完整 Schema) + * 3. 使用 React.memo + react-fast-compare 优化渲染性能 + * + */ + +import React, { useMemo } from 'react'; +import isEqual from 'react-fast-compare'; +import { JsonRenderElement } from './JsonUIRenderer'; +import { DataProvider, ActionProvider, VisibilityProvider } from '../contexts'; +import type { JsonRenderActivityProps, ComponentRegistry } from '../types'; + +export interface JsonRenderActivityRendererProps extends JsonRenderActivityProps { + /** 组件注册表(必须) */ + registry: ComponentRegistry; + /** + * Action 处理器映射表 + * + * 示例: + * ```tsx + * const actionHandlers = { + * submit: async (params) => { ... }, + * reset: async (params) => { ... }, + * cancel: async (params) => { ... }, + * }; + * ``` + */ + actionHandlers?: Record) => void | Promise>; + /** 显示调试信息 */ + debug?: boolean; +} + +/** + * json-render Activity 渲染器组件 + */ +const JsonRenderActivityRendererInner: React.FC = ({ + activityType, + content, + messageId, + registry, + actionHandlers = {}, +}) => { + // 直接在渲染阶段做校验 + const isValidSchema = content && content.root && content.elements && content.elements[content.root]; + + // 数据处理:使用 useMemo 缓存,只在 content.data 变化时重新计算 + const renderData = useMemo(() => content?.data || {}, [content?.data]); + + // todo: Schema 无效时显示加载状态 + // if (!isValidSchema) { + // return ( + //
+ // 数据初始化中... + //
+ // ); + // } + return ( +
+ + + + + + + +
+ ); +}; + +/** + * 使用 React.memo 包装,配合 react-fast-compare 进行高效的深比较 + * + * 对比策略: + * 1. registry 引用比较(通常是稳定的) + * 2. actionHandlers 引用比较(建议使用 useMemo 稳定化) + * 3. content 使用 react-fast-compare 深比较(比 JSON.stringify 快 3-5 倍) + */ +export const JsonRenderActivityRenderer = React.memo( + JsonRenderActivityRendererInner, + (prevProps, nextProps) => { + // registry 变化必须重渲染 + if (prevProps.registry !== nextProps.registry) return false; + + // actionHandlers 变化必须重渲染 + if (prevProps.actionHandlers !== nextProps.actionHandlers) return false; + + // content 引用相同,跳过渲染 + if (prevProps.content === nextProps.content) return true; + + // 使用 react-fast-compare 进行高效深比较 + // 比 JSON.stringify 快 3-5 倍,且能正确处理循环引用 + return isEqual(prevProps.content, nextProps.content); + }, +); + +/** + * 默认导出 + */ +export default JsonRenderActivityRenderer; diff --git a/packages/pro-components/chat/chat-engine/components/json-render/renderer/JsonUIRenderer.tsx b/packages/pro-components/chat/chat-engine/components/json-render/renderer/JsonUIRenderer.tsx new file mode 100644 index 0000000000..b5be6e5405 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/renderer/JsonUIRenderer.tsx @@ -0,0 +1,180 @@ +'use client'; + +import React, { useLayoutEffect, useMemo, useRef } from 'react'; + +import { + ActionProvider, + ConfirmDialog, + DataProvider, + useActions, + useIsVisible, + ValidationProvider, + VisibilityProvider, +} from '../contexts'; +import { RenderContext, TreeStore, useElement, useRenderContext, useRoot } from '../contexts/tree'; + +import type { ComponentType, ReactNode } from 'react'; +import type { Catalog } from '@json-render/core'; +import type { ComponentRegistry, RendererProps } from '../types'; + +/** + * ElementRenderer - 使用 selector 模式订阅特定 element + * + * 性能优化原理: + * 1. 上游使用 Structural Sharing,未修改的节点保持原引用 + * 2. useSyncExternalStore 的 getSnapshot 返回 element 引用 + * 3. 引用相同 → 不重渲染;引用不同 → 重渲染 + */ +const ElementRenderer = React.memo(function ElementRenderer({ elementKey }: { elementKey: string }) { + const { registry, loading, fallback } = useRenderContext(); + const element = useElement(elementKey); + const isVisible = useIsVisible(element?.visible); + const { execute } = useActions(); + + // Don't render if element doesn't exist or not visible + if (!element || !isVisible) { + return null; + } + + // Get the component renderer + const Component = registry[element.type] ?? fallback; + + if (!Component) { + return null; + } + + // 子组件独立订阅 + const children = element.children?.map((childKey) => ); + + return ( + + {children} + + ); +}); + +/** + * Root renderer - 订阅 root 变化 + */ +function RootRenderer() { + const root = useRoot(); + + if (!root) { + return null; + } + + return ; +} + +/** + * Main renderer component + * + * 架构说明: + * - JsonRenderElement 作为入口,不因 tree 变化而重渲染 + * - tree 更新通过 store.setTree() 通知订阅者 + * - 每个 ElementRenderer 独立订阅自己的 element + * - 配合上游 Structural Sharing,只有真正变化的节点才重渲染 + */ +export function JsonRenderElement({ tree, registry, loading, fallback }: RendererProps) { + // 创建稳定的 store 引用 + const storeRef = useRef(null); + if (!storeRef.current) { + storeRef.current = new TreeStore(); + } + const store = storeRef.current; + + // 确保子组件在 useEffect 或布局计算前能获取到最新的 tree。 + useLayoutEffect(() => { + store.setTree(tree?.root ? tree : null); + }, [tree, store]); + + const contextValue = useMemo( + () => ({ + store, + registry, + loading, + fallback, + }), + [store, registry, loading, fallback], + ); + + // 边界保护 + if (!tree?.root) { + return null; + } + + return ( + + + + ); +} + +/** + * Props for JSONUIProvider + */ +export interface JSONUIProviderProps { + /** Component registry */ + registry: ComponentRegistry; + /** Initial data model */ + initialData?: Record; + /** Auth state */ + authState?: { isSignedIn: boolean; user?: Record }; + /** Action handlers */ + actionHandlers?: Record) => Promise | unknown>; + /** Navigation function */ + navigate?: (path: string) => void; + /** Custom validation functions */ + validationFunctions?: Record) => boolean>; + /** Callback when data changes */ + onDataChange?: (path: string, value: unknown) => void; + children: ReactNode; +} + +export function JSONUIProvider({ + initialData, + authState, + actionHandlers, + navigate, + validationFunctions, + onDataChange, + children, +}: JSONUIProviderProps) { + return ( + + + + + {children} + + + + + + ); +} + +/** + * Renders the confirmation dialog when needed + */ +function ConfirmationDialogManager() { + const { pendingConfirmation, confirm, cancel } = useActions(); + + if (!pendingConfirmation?.action.confirm) { + return null; + } + + return ; +} + +/** + * Helper to create a renderer component from a catalog + */ +export function createRendererFromCatalog( + _catalog: C, + registry: ComponentRegistry, +): ComponentType> { + return function CatalogRenderer(props: Omit) { + return ; + }; +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/renderer/_index.tsx b/packages/pro-components/chat/chat-engine/components/json-render/renderer/_index.tsx new file mode 100644 index 0000000000..df584c51bd --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/renderer/_index.tsx @@ -0,0 +1,201 @@ +'use client'; + +import React from 'react'; + +import { + ActionProvider, + ConfirmDialog, + DataProvider, + useActions, + useIsVisible, + ValidationProvider, + VisibilityProvider, +} from '../contexts'; + +import type { ComponentType, ReactNode } from 'react'; +import type { ActionBinding, Catalog, Spec, UIElement } from '@json-render/core'; + +/** + * Props passed to component renderers + */ +export interface ComponentRenderProps

> { + /** The element being rendered */ + element: UIElement; + /** Rendered children */ + children?: ReactNode; + /** Execute an action */ + onAction?: (action: ActionBinding) => void; + /** Whether the parent is loading */ + loading?: boolean; +} + +/** + * Component renderer type + */ +export type ComponentRenderer

> = ComponentType>; + +/** + * Registry of component renderers + */ +export type ComponentRegistry = Record>; + +/** + * Props for the Renderer component + */ +export interface RendererProps { + /** The UI tree to render */ + tree: Spec | null; + /** Component registry */ + registry: ComponentRegistry; + /** Whether the tree is currently loading/streaming */ + loading?: boolean; + /** Fallback component for unknown types */ + fallback?: ComponentRenderer; +} + +/** + * Element renderer component + */ +function ElementRenderer({ + element, + tree, + registry, + loading, + fallback, +}: { + element: UIElement; + tree: Spec; + registry: ComponentRegistry; + loading?: boolean; + fallback?: ComponentRenderer; +}) { + const isVisible = useIsVisible(element.visible); + const { execute } = useActions(); + + // Don't render if not visible + if (!isVisible) { + return null; + } + + // Get the component renderer + const Component = registry[element.type] ?? fallback; + + if (!Component) { + console.warn(`No renderer for component type: ${element.type}`); + return null; + } + + // Render children + const children = element.children?.map((childKey) => { + const childElement = tree.elements[childKey]; + if (!childElement) { + return null; + } + return ( + + ); + }); + + return ( + + {children} + + ); +} + +/** + * Main renderer component + */ +export function JsonRenderElement({ tree, registry, loading, fallback }: RendererProps) { + if (!tree || !tree.root) { + return null; + } + + const rootElement = tree.elements[tree.root]; + if (!rootElement) { + return null; + } + + return ( + + ); +} + +/** + * Props for JSONUIProvider + */ +export interface JSONUIProviderProps { + /** Component registry */ + registry: ComponentRegistry; + /** Initial data model */ + initialData?: Record; + /** Auth state */ + authState?: { isSignedIn: boolean; user?: Record }; + /** Action handlers */ + actionHandlers?: Record) => Promise | unknown>; + /** Navigation function */ + navigate?: (path: string) => void; + /** Custom validation functions */ + validationFunctions?: Record) => boolean>; + /** Callback when data changes */ + onDataChange?: (path: string, value: unknown) => void; + children: ReactNode; +} + +/** + * Combined provider for all JSONUI contexts + */ +export function JSONUIProvider({ + initialData, + authState, + actionHandlers, + navigate, + validationFunctions, + onDataChange, + children, +}: JSONUIProviderProps) { + return ( + + + + + {children} + + + + + + ); +} + +/** + * Renders the confirmation dialog when needed + */ +function ConfirmationDialogManager() { + const { pendingConfirmation, confirm, cancel } = useActions(); + + if (!pendingConfirmation?.action.confirm) { + return null; + } + + return ; +} + +/** + * Helper to create a renderer component from a catalog + */ +export function createRendererFromCatalog( + _catalog: C, + registry: ComponentRegistry, +): ComponentType> { + return function CatalogRenderer(props: Omit) { + return ; + }; +} diff --git a/packages/pro-components/chat/chat-engine/components/json-render/types.ts b/packages/pro-components/chat/chat-engine/components/json-render/types.ts new file mode 100644 index 0000000000..aa1e192698 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/json-render/types.ts @@ -0,0 +1,104 @@ +/** + * json-render 集成相关类型定义 + */ + +import type { ComponentType, ReactNode } from 'react'; +import type { ActionBinding, Spec, UIElement } from '@json-render/core'; +import type { JsonRenderSchema } from '@tdesign/ai-chat-engine'; + +/** + * Props passed to component renderers + */ +export interface ComponentRenderProps

> { + /** The element being rendered */ + element: UIElement; + /** Rendered children */ + children?: ReactNode; + /** Execute an action */ + onAction?: (action: ActionBinding) => void; + /** Whether the parent is loading */ + loading?: boolean; +} + +/** + * Component renderer type + */ +export type ComponentRenderer

> = ComponentType>; + +/** + * Registry of component renderers + */ +export type ComponentRegistry = Record>; + +/** + * Props for the Renderer component + */ +export interface RendererProps { + /** The UI tree to render */ + tree: Spec | null; + /** Component registry */ + registry: ComponentRegistry; + /** Whether the tree is currently loading/streaming */ + loading?: boolean; + /** Fallback component for unknown types */ + fallback?: ComponentRenderer; +} + +/** + * json-render Activity 内容格式 + * 用于 ACTIVITY_SNAPSHOT 和 ACTIVITY_DELTA 事件 + */ +// export interface JsonRenderSchema extends Spec { +// // json-render 标准 Spec 结构 +// root: string; +// elements: Record; +// // 可选的数据模型 +// data?: Record; +// } + +/** + * 组件目录(Catalog)定义 + * 映射组件类型到 React 组件实现 + * 使用 @json-render/react 的 ComponentRegistry 类型 + */ +export type ComponentCatalog = ComponentRegistry; + +/** + * 渲染上下文配置 + */ +export interface JsonRenderContext { + /** 渲染模式:direct(直接模式) | adapter(适配模式) */ + mode: 'direct' | 'adapter'; + /** 组件目录 */ + catalog: ComponentCatalog; + /** 可选:自定义数据 */ + customData?: Record; +} + +/** + * 增量更新信息 + * 从 event-mapper 的 deltaInfo 传递过来 + */ +export interface DeltaInfo { + /** 新增元素的起始索引 */ + fromIndex: number; + /** 新增元素的结束索引 */ + toIndex: number; +} + +/** + * json-render Activity 渲染器 Props + */ +export interface JsonRenderActivityProps { + /** Activity 类型 */ + activityType: string; + /** json-render Schema 内容 */ + content: JsonRenderSchema; + /** 关联的消息 ID */ + messageId: string; + /** 扩展属性(包含 deltaInfo) */ + ext?: { + deltaInfo?: DeltaInfo; + [key: string]: any; + }; +} diff --git a/packages/pro-components/chat/chat-engine/components/shared/ComponentErrorBoundary.tsx b/packages/pro-components/chat/chat-engine/components/shared/ComponentErrorBoundary.tsx new file mode 100644 index 0000000000..6270ae970f --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/shared/ComponentErrorBoundary.tsx @@ -0,0 +1,44 @@ +import React, { Component, ErrorInfo } from 'react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + /** 组件标识,用于错误日志 */ + componentName: string; + /** 日志前缀,如 'ActivityRenderer' 或 'ToolCallRenderer' */ + logPrefix?: string; + /** 自定义错误渲染,默认返回 null */ + fallback?: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +/** + * 通用错误边界组件 + * 捕获子组件渲染错误,防止整个对话列表崩溃 + */ +export class ComponentErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + const { componentName, logPrefix = 'ComponentRenderer' } = this.props; + console.error(`[${logPrefix}] Error in "${componentName}":`, error, errorInfo); + } + + render() { + if (this.state.hasError) { + return this.props.fallback ?? null; + } + + return this.props.children; + } +} diff --git a/packages/pro-components/chat/chat-engine/components/shared/createRegistry.ts b/packages/pro-components/chat/chat-engine/components/shared/createRegistry.ts new file mode 100644 index 0000000000..fb6a047aad --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/shared/createRegistry.ts @@ -0,0 +1,144 @@ +import React from 'react'; + +/** + * 基础配置接口 - 所有注册配置必须包含组件 + */ +export interface BaseConfig { + component: React.FC; +} + +/** + * Registry 配置选项 + */ +export interface RegistryOptions { + /** 从配置中获取唯一标识符的函数 */ + getKey: (config: TConfig) => string; + /** 注册事件名称 */ + eventName: string; + /** 事件 detail 中的键名 */ + eventDetailKey: string; +} + +/** + * 泛型 Registry 管理器接口 + */ +export interface IRegistryManager { + register(config: TConfig): boolean; + get(key: string): TConfig | undefined; + getRenderFunction(key: string): React.MemoExoticComponent> | null; + getAll(): Record; + unregister(key: string): void; + clear(): void; + has(key: string): boolean; + getRegisteredKeys(): string[]; +} + +/** + * 创建泛型 Registry 管理器 + * 用于统一管理 Activity 和 Toolcall 的注册逻辑 + */ +export function createRegistryManager( + options: RegistryOptions, +): IRegistryManager { + const { getKey, eventName, eventDetailKey } = options; + + // 内部状态 + const registry: Record = {}; + const renderFunctionCache = new Map>>(); + + return { + /** + * 注册配置 + * @returns 是否成功注册(如果已存在相同配置则返回 false) + */ + register(config: TConfig): boolean { + const key = getKey(config); + const existingConfig = registry[key]; + + // 如果已存在且组件相同,跳过注册(真正的防重复) + if (existingConfig && existingConfig.component === config.component) { + return false; + } + + // 如果组件发生变化,清除旧的缓存 + if (existingConfig) { + renderFunctionCache.delete(key); + } + + registry[key] = config; + + // 只在真正有变化时触发注册事件 + window.dispatchEvent( + new CustomEvent(eventName, { + detail: { [eventDetailKey]: key }, + }), + ); + + return true; + }, + + /** + * 获取指定 key 的配置 + */ + get(key: string): TConfig | undefined { + return registry[key]; + }, + + /** + * 获取或创建缓存的组件渲染函数 + */ + getRenderFunction(key: string): React.MemoExoticComponent> | null { + const config = registry[key]; + if (!config) return null; + + let memoizedComponent = renderFunctionCache.get(key); + + if (!memoizedComponent) { + // 创建 memo 化的组件 + memoizedComponent = React.memo((props: TProps) => React.createElement(config.component, props)); + + // 缓存组件 + renderFunctionCache.set(key, memoizedComponent); + } + + return memoizedComponent; + }, + + /** + * 获取所有已注册的配置 + */ + getAll(): Record { + return { ...registry }; + }, + + /** + * 取消注册 + */ + unregister(key: string): void { + delete registry[key]; + renderFunctionCache.delete(key); + }, + + /** + * 清空所有注册 + */ + clear(): void { + Object.keys(registry).forEach((key) => delete registry[key]); + renderFunctionCache.clear(); + }, + + /** + * 检查指定 key 是否已注册 + */ + has(key: string): boolean { + return key in registry; + }, + + /** + * 获取所有已注册的 key + */ + getRegisteredKeys(): string[] { + return Object.keys(registry); + }, + }; +} diff --git a/packages/pro-components/chat/chat-engine/components/shared/index.ts b/packages/pro-components/chat/chat-engine/components/shared/index.ts new file mode 100644 index 0000000000..59bf26a119 --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/shared/index.ts @@ -0,0 +1,6 @@ +export { createRegistryManager } from './createRegistry'; +export type { BaseConfig, RegistryOptions, IRegistryManager } from './createRegistry'; + +export { ComponentErrorBoundary } from './ComponentErrorBoundary'; + +export { useRegistrationListener } from './useRegistrationListener'; diff --git a/packages/pro-components/chat/chat-engine/components/shared/useRegistrationListener.ts b/packages/pro-components/chat/chat-engine/components/shared/useRegistrationListener.ts new file mode 100644 index 0000000000..15687e742c --- /dev/null +++ b/packages/pro-components/chat/chat-engine/components/shared/useRegistrationListener.ts @@ -0,0 +1,59 @@ +import { useState, useEffect, useMemo } from 'react'; + +interface UseRegistrationListenerOptions { + /** 组件唯一标识 */ + componentKey: string; + /** 监听的事件名称 */ + eventName: string; + /** 事件 detail 中的键名 */ + eventDetailKey: string; + /** 获取渲染函数的方法 */ + getRenderFunction: (key: string) => React.MemoExoticComponent> | null; +} + +interface UseRegistrationListenerResult { + /** 是否已注册 */ + isRegistered: boolean; + /** 缓存的 Memo 组件 */ + MemoizedComponent: React.MemoExoticComponent> | null; +} + +/** + * 动态注册监听 Hook + * 统一处理 Activity 和 Toolcall 的动态注册逻辑 + */ +export function useRegistrationListener( + options: UseRegistrationListenerOptions, +): UseRegistrationListenerResult { + const { componentKey, eventName, eventDetailKey, getRenderFunction } = options; + + // 注册状态 + const [isRegistered, setIsRegistered] = useState(() => !!getRenderFunction(componentKey)); + + // 监听组件注册事件,支持动态注册 + useEffect(() => { + if (!isRegistered) { + const handleRegistered = (event: CustomEvent) => { + // 精确匹配 + if (event.detail?.[eventDetailKey] === componentKey) { + setIsRegistered(true); + } + }; + + window.addEventListener(eventName, handleRegistered as EventListener); + + return () => { + window.removeEventListener(eventName, handleRegistered as EventListener); + }; + } + }, [componentKey, eventName, eventDetailKey, isRegistered]); + + // 使用 registry 的缓存渲染函数 + const MemoizedComponent = useMemo( + () => getRenderFunction(componentKey), + // eslint-disable-next-line react-hooks/exhaustive-deps + [componentKey, isRegistered], + ); + + return { isRegistered, MemoizedComponent }; +} diff --git a/packages/pro-components/chat/chat-engine/components/toolcall/registry.ts b/packages/pro-components/chat/chat-engine/components/toolcall/registry.ts index eac7b27ee6..f0e15b7ea6 100644 --- a/packages/pro-components/chat/chat-engine/components/toolcall/registry.ts +++ b/packages/pro-components/chat/chat-engine/components/toolcall/registry.ts @@ -1,90 +1,18 @@ -import React from 'react'; +import { createRegistryManager } from '../shared'; -import type { AgentToolcallConfig, AgentToolcallRegistry, ToolcallComponentProps } from './types'; +import type { AgentToolcallConfig, ToolcallComponentProps } from './types'; + +/** Toolcall 注册事件名称 */ +export const TOOLCALL_REGISTERED_EVENT = 'toolcall-registered'; + +/** Toolcall 注册事件 detail 键名 */ +export const TOOLCALL_EVENT_DETAIL_KEY = 'name'; /** * 全局 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(); +export const agentToolcallRegistry = createRegistryManager({ + getKey: (config) => config.name, + eventName: TOOLCALL_REGISTERED_EVENT, + eventDetailKey: TOOLCALL_EVENT_DETAIL_KEY, +}); diff --git a/packages/pro-components/chat/chat-engine/components/toolcall/render.tsx b/packages/pro-components/chat/chat-engine/components/toolcall/render.tsx index 06891d142a..6b8c14c179 100644 --- a/packages/pro-components/chat/chat-engine/components/toolcall/render.tsx +++ b/packages/pro-components/chat/chat-engine/components/toolcall/render.tsx @@ -1,11 +1,11 @@ -import React, { Component, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { AGUIEventType } from '@tdesign/ai-chat-engine'; import { AgentStateContext, useAgentStateDataByKey } from '../../hooks/useAgentState'; -import { agentToolcallRegistry } from './registry'; +import { ComponentErrorBoundary, useRegistrationListener } from '../shared'; +import { agentToolcallRegistry, TOOLCALL_EVENT_DETAIL_KEY, TOOLCALL_REGISTERED_EVENT } from './registry'; import { isNonInteractiveConfig } from './types'; -import type { ErrorInfo } from 'react'; import type { ToolCall } from '@tdesign/ai-chat-engine'; import type { ToolcallComponentProps } from './types'; @@ -14,39 +14,6 @@ interface ToolCallRendererProps { onRespond?: (toolCall: ToolCall, response: any) => void; } -interface ErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -/** - * ToolCall 错误边界组件 - * 捕获子组件渲染错误,防止整个对话列表崩溃 - */ -class ToolCallErrorBoundary extends Component<{ children: React.ReactNode; toolCallName: string }, ErrorBoundaryState> { - constructor(props: { children: React.ReactNode; toolCallName: string }) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - console.error(`[ToolCallRenderer] Error in tool "${this.props.toolCallName}":`, error, errorInfo); - } - - render() { - if (this.state.hasError) { - // 空白兜底,仅在控制台输出错误 - return null; - } - - return this.props.children; - } -} - export const ToolCallRenderer = React.memo( ({ toolCall, onRespond }) => { const [actionState, setActionState] = useState<{ @@ -63,10 +30,13 @@ export const ToolCallRenderer = React.memo( return cfg; }, [toolCall.toolCallName]); - // 添加注册状态监听 - const [isRegistered, setIsRegistered] = useState( - () => !!agentToolcallRegistry.getRenderFunction(toolCall.toolCallName), - ); + // 使用公共 Hook 监听动态注册 + const { MemoizedComponent } = useRegistrationListener({ + componentKey: toolCall.toolCallName, + eventName: TOOLCALL_REGISTERED_EVENT, + eventDetailKey: TOOLCALL_EVENT_DETAIL_KEY, + getRenderFunction: agentToolcallRegistry.getRenderFunction, + }); // 缓存参数解析 const args = useMemo(() => { @@ -175,24 +145,6 @@ export const ToolCallRenderer = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, [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); @@ -209,21 +161,14 @@ export const ToolCallRenderer = React.memo( [actionState.status, args, actionState.result, actionState.error, handleRespond, agentState], ); - // 使用registry的缓存渲染函数 - const MemoizedComponent = useMemo( - () => agentToolcallRegistry.getRenderFunction(toolCall.toolCallName), - // eslint-disable-next-line react-hooks/exhaustive-deps - [toolCall.toolCallName, isRegistered], - ); - if (!MemoizedComponent) { return null; } return ( - + - + ); }, (prevProps, nextProps) => @@ -234,32 +179,6 @@ export const ToolCallRenderer = React.memo( prevProps.toolCall.eventType === nextProps.toolCall.eventType && 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 }; diff --git a/packages/pro-components/chat/chat-engine/hooks/useAgentActivity.ts b/packages/pro-components/chat/chat-engine/hooks/useAgentActivity.ts index e799277c78..21189e7a34 100644 --- a/packages/pro-components/chat/chat-engine/hooks/useAgentActivity.ts +++ b/packages/pro-components/chat/chat-engine/hooks/useAgentActivity.ts @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import { useCallback, useEffect, useRef } from 'react'; import { activityRegistry } from '../components/activity/registry'; @@ -75,7 +74,6 @@ export function useAgentActivity( } const configs = Array.isArray(config) ? config : [config]; - configs.forEach((cfg) => { if (activityRegistry.has(cfg.activityType)) { console.warn(`[useAgentActivity] Activity 类型 "${cfg.activityType}" 已存在于注册表中,将被覆盖`); @@ -89,6 +87,7 @@ export function useAgentActivity( return () => { configs.forEach((cfg) => { activityRegistry.unregister(cfg.activityType); + // eslint-disable-next-line react-hooks/exhaustive-deps autoRegisteredTypesRef.current.delete(cfg.activityType); }); }; diff --git a/packages/pro-components/chat/chat-engine/hooks/useChat.ts b/packages/pro-components/chat/chat-engine/hooks/useChat.ts index e8344bdc39..ce8641dc9f 100644 --- a/packages/pro-components/chat/chat-engine/hooks/useChat.ts +++ b/packages/pro-components/chat/chat-engine/hooks/useChat.ts @@ -17,9 +17,10 @@ export const useChat = ({ defaultMessages: initialMessages, chatServiceConfig }: const chatEngine = chatEngineRef.current; - const syncState = (state: ChatMessagesData[]) => { - setMessage(state); - setStatus(state.at(-1)?.status || 'idle'); + const syncState = (state?: ChatMessagesData[]) => { + const msgs = state || []; + setMessage(msgs); + setStatus(msgs.at(-1)?.status || 'idle'); }; const subscribeToChat = () => { @@ -31,18 +32,28 @@ export const useChat = ({ defaultMessages: initialMessages, chatServiceConfig }: }); }; - const initChat = () => { - // @ts-ignore - chatEngine.init(chatServiceConfig, initialMessages); - // @ts-ignore - syncState(initialMessages); - subscribeToChat(); - }; - // 初始化聊天引擎 useEffect(() => { + let isMounted = true; + + const initChat = async () => { + // @ts-ignore + await chatEngine.init(chatServiceConfig, initialMessages); + + // 如果在 init 完成之前组件已被 unmount(StrictMode cleanup),跳过后续操作 + if (!isMounted) return; + + // @ts-ignore + syncState(initialMessages); + subscribeToChat(); + }; + initChat(); - return () => msgSubscribeRef.current?.(); + + return () => { + isMounted = false; + msgSubscribeRef.current?.(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/packages/pro-components/chat/chat-engine/llms.txt b/packages/pro-components/chat/chat-engine/llms.txt index e2b649d039..0baac5e703 100644 --- a/packages/pro-components/chat/chat-engine/llms.txt +++ b/packages/pro-components/chat/chat-engine/llms.txt @@ -6,32 +6,87 @@ - [Generative UI](https://tdesign.tencent.com/react-chat/genui): Explore Generative UI capabilities. ## Project Identity -A React SDK for building production-ready AI chat applications. It features a headless `ChatEngine` for logic reuse, a `ChatBot` component for rapid development, and native support for the **AG-UI Protocol** and **Generative UI**. +A React SDK for building production-ready AI chat applications. It features a headless `ChatEngine` for logic reuse, a `ChatBot` component for rapid development, and native support for **AG-UI Protocol**, **OpenClaw Protocol** (WebSocket), and **Generative UI** (json-render & A2UI). ## Core Concepts -- **ChatEngine**: Headless logic core handling message state, SSE streaming, and event bus. -- **ChatBot**: All-in-one UI component with built-in rendering for Markdown, Thinking, and Tools. -- **AG-UI**: Standard protocol for Agent-UI interaction (Tool calls, State sync, Activity). -- **Generative UI**: AI-generated UI based on **json-render** architecture (Catalog + Registry), supporting both native adjacency-list schema and **A2UI** protocol via adapter. +- **ChatEngine**: Headless logic core handling message state, SSE/WebSocket streaming, event bus, and resume/replay. +- **ChatBot**: All-in-one UI component with built-in rendering for Markdown, Thinking, Tools, and Suggestions. +- **AG-UI**: Standard SSE protocol for Agent-UI interaction (Tool calls, State sync, Activity, Messages Snapshot). +- **OpenClaw**: WebSocket protocol for real-time AI chat with heartbeat, handshake, and history auto-load. +- **Generative UI**: AI-generated UI based on **json-render** architecture (Catalog + Registry), supporting both native adjacency-list schema and **A2UI** protocol via adapter. Includes `DataProvider`, `VisibilityProvider`, `ActionProvider`, `ValidationProvider` contexts. ## Key APIs ### Hooks - `useChat(config)`: Initialize chat engine and state. Returns `{ chatEngine, messages, status }`. -- `useAgentToolcall(config)`: Register custom UI components for AI tool calls. -- `useAgentActivity(config)`: Register components for dynamic content (Generative UI). -- `useAgentState(options)`: Subscribe to shared agent state updates. +- `useAgentToolcall(config)`: Register custom UI for AI tool calls. Supports single, array, and batch registration. Returns `{ register, unregister, isRegistered, getRegistered, config }`. +- `useAgentActivity(config)`: Register components for dynamic content (Generative UI). Returns `{ register, unregister, isRegistered, getRegistered }`. +- `useAgentState(options)`: Subscribe to shared agent state updates. Returns `{ stateMap, currentStateKey, setStateMap, getCurrentState, getStateByKey }`. +- `useAgentStateDataByKey(stateKey?)`: Simplified state selector (auto-detects Context vs standalone). ### Components -- ``: Main UI component. Props: `chatServiceConfig`, `headerSlot`, `messageSlot`, etc. +- ``: Main UI component. Props: `chatServiceConfig`, `headerSlot`, `messageSlot`, `senderSlot`, `footerSlot`, etc. - ``, ``, ``: Atomic components for custom layouts. +- ``, ``, ``, ``, ``: Auxiliary UI components. +- ``, ``, ``, ``: Content display components. - ``: Renders tool calls based on registered configuration. - ``: Renders generative UI based on registered configuration. +- ``, ``: Protocol-specific renderers. +- ``: Error boundary for dynamic components. + +### ChatEngine Instance Methods +- `sendUserMessage(params)`: Send user message and trigger AI response. +- `sendAIMessage(options)`: Programmatically create AI message. +- `sendSystemMessage(msg)`: Add system message. +- `resumeRun(params?)`: Resume interrupted Agent run (backend sends `MESSAGES_SNAPSHOT`). +- `regenerateAIMessage(keepVersion?)`: Regenerate response; `keepVersion=true` for branching. +- `sendRequest(params)`: Raw request without auto message creation. +- `abortChat()`: Abort current streaming request. +- `setMessages(msgs, mode?)`: Set messages with mode `'replace'` | `'prepend'` | `'append'`. +- `clearMessages()`: Clear all messages. +- `getToolcallByName(name)`: Get tool call object by name (AG-UI). +- `registerMergeStrategy(type, handler)`: Register custom content merge logic. + +### Event Bus +- `eventBus.on(event, callback)`: Subscribe to events. Returns unsubscribe function. +- `eventBus.once(event, callback)`: One-time subscription. +- `eventBus.waitFor(event, timeout?)`: Promise-based event waiting. +- `eventBus.waitForMatch(event, filterFn, timeout?)`: Wait for event matching filter. +- `eventBus.onCustom(name, callback)`: Subscribe to custom user-defined events. +- `eventBus.emitCustom(name, data)`: Emit custom events. +- `eventBus.getHistory()`: Get event history. +- Events: `ENGINE_INIT`, `ENGINE_DESTROY`, `MESSAGE_CREATE`, `MESSAGE_UPDATE`, `MESSAGE_DELETE`, `MESSAGE_CLEAR`, `MESSAGE_STATUS_CHANGE`, `MESSAGE_CONTENT_APPEND`, `REQUEST_START`, `REQUEST_STREAM`, `REQUEST_COMPLETE`, `REQUEST_ERROR`, `REQUEST_ABORT`, `AGUI_RUN_START`, `AGUI_RUN_COMPLETE`, `AGUI_RUN_ERROR`, `AGUI_STATE_UPDATE`, `AGUI_TOOLCALL`, `AGUI_ACTIVITY`, `CUSTOM`. ### Configuration Types -- `ChatServiceConfig`: `{ endpoint: string, protocol: 'agui' | 'custom', stream: boolean, onRequest?, onMessage? }` -- `AgentToolcallConfig`: `{ name: string, component: ReactComponent, subscribeKey?: fn }` -- `ActivityConfig`: `{ activityType: string, component: ReactComponent }` +- `ChatServiceConfig`: `{ endpoint, protocol: 'agui' | 'openclaw' | 'default', stream, timeout?, maxRetries?, retryInterval?, openclaw?, onRequest?, onStart?, onMessage?, onComplete?, onError?, onAbort?, isValidChunk?, onHistoryLoaded? }` +- `AgentToolcallConfig`: `{ name, component, handler?, subscribeKey? }` — with `handler` → non-interactive; without → interactive (Human-in-the-Loop) +- `ActivityConfig`: `{ activityType, component, description? }` +- `OpenClawConfig`: `{ heartbeatInterval?, client?: { id, version, mode }, protocolVersion?: { min, max } }` + +### Generative UI Helpers +- `generateCatalogPrompt(catalog)`: Generate LLM system prompt from Catalog. +- `createCustomRegistry(components, options?)`: Create component registry. +- `createJsonRenderActivityConfig(options)`: Configure json-render Activity. +- `createA2UIJsonRenderActivityConfig(options)`: Configure A2UI Activity. +- `tdesignRegistry`: Built-in TDesign components (Button, Input, Card, Text, Row, Col, Space, Column, Divider). +- `a2uiRegistry`, `createA2UIRegistry(...)`: A2UI component registries. +- `withStableProps(Component)`: Performance HOC (`React.memo` + deep compare). +- `withA2UIBinding(Component, config)`: A2UI data-binding HOC. + +### Generative UI Contexts +- `DataProvider` + `useDataValue`, `useDataBinding`, `useDataUpdate`, `useDataState`, `useDataStore` +- `VisibilityProvider` + `useVisibility`, `useIsVisible` +- `ActionProvider` + `useActions`, `useAction`, `ConfirmDialog` +- `ValidationProvider` + `useValidation`, `useFieldValidation` + +### Type Guards +- `isUserMessage`, `isAIMessage`, `isTextContent`, `isMarkdownContent`, `isThinkingContent`, `isImageContent`, `isSearchContent`, `isSuggestionContent`, `isAttachmentContent`, `isToolCallContent`, `isActivityContent`, `isReasoningContent` + +### Utilities +- `applyJsonPatch(state, delta)`: Immutable JSON Patch (RFC 6902) with structural sharing. +- `safeParseJSON(value, fallback?, context?)`: Safe JSON.parse. +- `getMessageContentForCopy(message)`: Extract text for clipboard. +- Content factories: `createMarkdownContent`, `createTextContent`, `createThinkingContent`, `createReasoningContent`, `createSuggestionContent`, `createToolCallContent`, `createActivityContent`, `createAIMessageContent` ## Minimal Perfect Snippets @@ -44,7 +99,7 @@ export default function App() { @@ -56,7 +111,7 @@ export default function App() { ```tsx import { ChatBot, useAgentToolcall } from '@tdesign-react/chat'; -// Component receives: args (input), result (output), status (idle/executing/complete/error) +// Component receives: args, result, status, respond (for interactive mode) const WeatherCard = ({ args, result, status }) => (

Weather in {args.city}

@@ -66,14 +121,48 @@ const WeatherCard = ({ args, result, status }) => ( ); export default function App() { - // Register tool to match backend tool name useAgentToolcall({ name: 'get_weather', component: WeatherCard }); - return ; } ``` -### 3. Custom Layout (Headless Mode) +### 3. Interactive Tool Call (Human-in-the-Loop) +```tsx +import { ChatBot, useAgentToolcall } from '@tdesign-react/chat'; + +const FlightForm = ({ args, respond, status }) => ( +
{ e.preventDefault(); respond({ confirmed: true }); }}> +

Book flight to {args.destination}?

+ +
+); + +export default function App() { + useAgentToolcall({ name: 'book_flight', component: FlightForm }); + return ; +} +``` + +### 4. Generative UI (json-render) +```tsx +import { ChatBot, useAgentActivity, createJsonRenderActivityConfig, createCustomRegistry } from '@tdesign-react/chat'; + +const registry = createCustomRegistry({ + StatusCard: ({ element }) =>
{element.props.title}
+}); + +const config = createJsonRenderActivityConfig({ + activityType: 'json-render', + registry +}); + +export default function App() { + useAgentActivity(config); + return ; +} +``` + +### 5. Custom Layout (Headless Mode) ```tsx import { useChat, ChatList, ChatSender } from '@tdesign-react/chat'; @@ -84,32 +173,30 @@ export default function CustomChat() { return (
- {/* Renders messages automatically */} - + chatEngine.sendUserMessage({ params: { prompt: e.detail.value } })} + onSend={(e) => chatEngine.sendUserMessage({ params: { prompt: e.detail.value } })} />
); } ``` -### 4. External State Subscription (Progress Bar) +### 6. External State Subscription (Progress Bar) ```tsx import { useAgentState } from '@tdesign-react/chat'; -// Renders outside the chat window export function GlobalProgress() { - // Subscribe to AG-UI STATE_* events const { stateMap } = useAgentState(); - const taskState = stateMap['current_task']; // Key defined by backend + const taskState = stateMap['current_task']; if (!taskState) return null; return ; } ``` -### 6. Side-Panel Generative UI +### 7. Side-Panel Generative UI ```tsx import { useChat, ChatEngineEventType, ActivityRenderer } from '@tdesign-react/chat'; @@ -118,7 +205,6 @@ export function SplitViewChat() { const [sideContent, setSideContent] = useState(null); useEffect(() => { - // Listen for specific activity events to render in side panel return chatEngine.eventBus.on(ChatEngineEventType.AGUI_ACTIVITY, (event) => { if (event.activityType === 'dashboard') { setSideContent(event); @@ -137,4 +223,86 @@ export function SplitViewChat() {
); } -``` \ No newline at end of file +``` + +### 8. A2UI Protocol Support +```tsx +import { useAgentActivity, createA2UIJsonRenderActivityConfig } from '@tdesign-react/chat'; + +export default function A2UIChat() { + useAgentActivity(createA2UIJsonRenderActivityConfig({ + activityType: 'a2ui-render', + actionHandlers: { + submit: async (data) => console.log('Form submitted:', data) + } + })); + + return ; +} +``` + +### 9. OpenClaw WebSocket Chat +```tsx +import { ChatBot } from '@tdesign-react/chat'; + +export default function OpenClawChat() { + return ( + + ); +} +``` + +### 10. Resume Interrupted Run +```tsx +import { useChat } from '@tdesign-react/chat'; + +export function ResumableChat() { + const { chatEngine, messages } = useChat({ + chatServiceConfig: { endpoint: '/api/chat', protocol: 'agui' } + }); + + const handleResume = async () => { + // Backend sends MESSAGES_SNAPSHOT to restore previous state, then continues streaming + await chatEngine.resumeRun({ params: { runId: 'last-run-id' } }); + }; + + return ( +
+ + {/* chat UI */} +
+ ); +} +``` + +### 11. Event Bus Advanced Usage +```tsx +import { useChat, ChatEngineEventType } from '@tdesign-react/chat'; + +export function EventBusDemo() { + const { chatEngine } = useChat({ /* config */ }); + + useEffect(() => { + // Promise-based: wait for specific event with timeout + chatEngine.eventBus.waitFor(ChatEngineEventType.AGUI_RUN_COMPLETE, 30000) + .then((result) => console.log('Run completed:', result)) + .catch(() => console.log('Timeout')); + + // Custom events for cross-component communication + chatEngine.eventBus.onCustom('my-custom-event', (data) => { + console.log('Custom event:', data); + }); + + return () => chatEngine.eventBus.clear(); + }, [chatEngine]); +} +``` diff --git a/packages/pro-components/chat/chat-message/_example/action.tsx b/packages/pro-components/chat/chat-message/_example/action.tsx index 6a51ea0074..f658ebc173 100644 --- a/packages/pro-components/chat/chat-message/_example/action.tsx +++ b/packages/pro-components/chat/chat-message/_example/action.tsx @@ -23,10 +23,9 @@ export default function ChatMessageExample() { name="TDesignAI" role={message.role} content={message.content} - > - {/* 植入插槽用来追加消息底部操作栏 */} - - + // 植入插槽用来追加消息底部操作栏 + actionbar={} + /> ); } diff --git a/packages/pro-components/chat/chat-message/_example/content.tsx b/packages/pro-components/chat/chat-message/_example/content.tsx index ec112b2010..a6a0dac64c 100644 --- a/packages/pro-components/chat/chat-message/_example/content.tsx +++ b/packages/pro-components/chat/chat-message/_example/content.tsx @@ -121,6 +121,36 @@ export default function ChatMessageExample() { }, ]} > + 附件内容 + ); } diff --git a/packages/pro-components/chat/chat-sender/_example/custom.tsx b/packages/pro-components/chat/chat-sender/_example/custom.tsx index 446a56e357..b2944fc4e6 100644 --- a/packages/pro-components/chat/chat-sender/_example/custom.tsx +++ b/packages/pro-components/chat/chat-sender/_example/custom.tsx @@ -123,10 +123,9 @@ const ChatSenderExample = () => { attachmentsProps={{ items: files, }} - > - {/* 自定义输入框上方区域,可用来引用内容或提示场景 */} - {showRef && ( -
+ // 自定义输入框上方区域,可用来引用内容或提示场景 + innerHeader={ + showRef ? ( {
-
- )} - {/* 自定义输入框底部区域slot,可以增加模型选项 */} -
+ ) : null + } + // 自定义输入框底部区域,可以增加模型选项 + footerPrefix={ -
- {/* 自定义输入框左侧区域slot,可以用来触发工具场景切换 */} -
+ } + // 自定义输入框左侧区域,可以用来触发工具场景切换 + inputPrefix={ {options.filter((item) => item.value === scene)[0].content} -
- {/* 自定义提交区域slot */} -
- {!loading ? ( + } + // 自定义提交区域 + actions={ + !loading ? ( + /> ) : ( - - )} -
- +