十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

生成式UI实战:从组件树到事件回传,补全AI代理交互短板

生成式UI实战:从组件树到事件回传,补全AI代理交互短板 在 Codex、Claude Code、Gemini 这类 AI 编码代理逐渐进入日常开发后一个长期被忽略的问题开始暴露代理只能通过终端文本和你对话。它知道用户需要确认参数、选择测试方案、查看结构化结果却没有办法直接给出一个按钮、一个表单或一个卡片。Mirafold 正是围绕这个问题出现的项目。从项目标题看它把 Generative UI生成式 UI作为代理能力的延伸目标是在 Codex、Claude Code、Gemini 等代理工具之上提供一套统一的界面生成和渲染机制。这篇文章会从交互形态、核心机制、最小实现、接入方式、验证排错和生产落地六个层面拆解这个模式到底是怎么工作的。1. 生成式 UI 解决的是代理交互的最后一公里1.1 编码代理的默认交互形态Codex、Claude Code、Gemini 这类工具的默认输出都是纯文本。文本这个输出协议有非常明显的优势通用、可靠、任何终端都能显示因此在很长一段时间里它是 agent 交互的标准答案。但它也有明显短板。只要任务涉及“让人做选择”或“让人输入结构化信息”文本交互就会变得低效。例如代理生成了一个数据清洗脚本在真正执行前需要确认清洗范围、输出路径、日期格式、是否覆盖原文件。这一串问题如果通过纯文本逐条追问用户的对话轮次会快速增加而每条追问的答案之间还有依赖关系用户必须记住上一个问题是什么这会把简单操作拖成疲劳对话。纯文本输出还有一个隐性成本代理无法展示“当前状态”。用户看到一段文字描述并不能直观判断哪些参数已经被采纳、哪些步骤已经完成、哪些按钮是下一步可以点击的。文本描述的是“事情是什么样”但用户真正需要的是“我接下来能做什么”。生成式 UI 要填补的就是这种从“阅读描述”到“直接操作”的差距。1.2 从项目标题理解 Mirafold 的定位Mirafold 出现在 Show HN 语境下通常意味着这是一个可被社区下载测试的独立项目。标题中的三个关键词值得注意Codex、Claude Code、Gemini。这三个词说明项目关注的不只是某一款代理工具而是多条代理技术路线之间的通用能力。由此可以理解Mirafold 想做的事有两层。第一层是让代理在对话中生成“界面描述”而不是直接输出 HTML 或调用前端代码。第二层是提供渲染器和事件回传机制把界面描述变成真正可点击、可输入、可反馈的交互控件。这种设计思路下模型不关心按钮最终渲染成 Web 组件还是桌面组件它只负责描述意图渲染端完成落地。这也解释了为什么这类项目往往选择跨端协议而不是绑定某个框架。如果界面结构本身是模型输出的一部分那么模型输出必须是一种与具体前端框架无关的中间语言否则每换一个代理、每换一个渲染端都要重写一套生成逻辑。1.3 与固定模板渲染的本质区别生成式 UI 最容易和传统模板渲染混淆。传统服务端渲染中页面结构由开发者在编译期写死模板引擎只负责把参数填充进去。例如{{ userName }}这种写法结构是固定的只是值在变化。而生成式 UI 中结构本身是模型推理的结果。区别可以概括为谁在运行时决定界面结构。模板渲染是“结构固定参数可变”生成式 UI 是“结构也可能不同”。模型可能根据用户刚才提到的数据库类型决定渲染一个下拉选择器还是渲染一个带图表的预览面板。这种灵活性带来了很好的体验但也带来了新的工程问题输出必须可控非法结构必须被拦截否则渲染端会收到无法理解的节点类型。因此生成式 UI 的工程实现里校验和降级是核心而不是渲染本身。2. 生成式 UI 的核心机制组件树、渲染器与事件回传2.1 组件树是模型与界面之间最小协议要让模型输出界面必须先定义一种模型容易生成、渲染端容易解析的中间格式。组件树是目前最常见的方案。它是一棵 JSON 树每个节点描述一个界面组件节点包含类型、属性和事件。下面是一个最小示例用来表达“迁移任务预览”面板{ version: 0.1.0, root: { type: grid, props: { columns: 2 }, children: [ { type: card, props: { title: 迁移任务预览 }, children: [ { type: text, props: { value: 准备迁移 128 张表预计耗时 6 分钟。 } }, { type: button, props: { label: 确认执行, variant: primary }, action: { name: confirm_migration, payload: { batch: 128 } } }, { type: button, props: { label: 调整参数 }, action: { name: open_form, payload: { fields: [table, timeout] } } } ] } ] } }每个节点包含四类信息type组件类型例如grid、card、button、text。props组件的展示属性。children子节点用于描述嵌套结构。action当前组件触发操作时发出的事件描述不直接写前端事件处理函数而是写业务语义。这里的关键设计是action。按钮不直接绑定某个函数而是发出confirm_migration这样的事件名。渲染端只负责把事件转发出去具体做什么由后端或代理决定。这样模型就不需要知道前端实现细节。2.2 渲染器完成“结构化描述到交互控件”的映射渲染器拿到组件树后要把 JSON 节点转换成真实可交互的控件。它做的事情本质上是一个映射函数输入节点描述输出组件实例。下面是一个最小渲染器的伪代码用于说明思路def render_node(node): node_type node.get(type) props node.get(props, {}) children [render_node(child) for child in node.get(children, [])] if node_type grid: return { component: Grid, columns: props.get(columns, 1), children: children, } if node_type card: return { component: Card, title: props.get(title, ), children: children, } if node_type button: return { component: Button, label: props.get(label, ), variant: props.get(variant, default), action: node.get(action), } if node_type text: return { component: Text, value: props.get(value, ), } raise ValueError(unknown node type: %s % node_type)这段代码最重要的规则是渲染器只做结构转换不执行业务逻辑。它不理解confirm_migration是什么意思只负责把事件描述原样挂到按钮组件上。业务逻辑必须留在另一端由事件处理器处理。这个边界一旦被打破渲染器就会变成业务代码的大杂烩组件树的中间协议价值也会消失。2.3 事件回传闭合交互循环界面生成只是链路的一半。用户点击按钮后事件必须能回传到代理或后端处理完成后还可能返回新的组件树做界面更新。这个闭环才是生成式 UI 和“打印一段 HTML”的最大区别。事件回传的结构可以设计得非常简单def handle_event(event): action_name event.get(action, {}).get(name) payload event.get(action, {}).get(payload, {}) if action_name confirm_migration: return run_migration(payload.get(batch, 0)) if action_name open_form: return generate_form_schema(payload.get(fields, [])) raise ValueError(unknown action: %s % action_name)这里的返回值可以是普通执行结果也可以是新的组件树。如果返回新的组件树渲染器就能用增量更新方式刷新界面。整个循环可以描述为模型生成组件树。渲染器将组件树渲染为界面。用户点击或输入产生事件。事件回传到代理或后端。处理结果或新组件树回到渲染器。界面更新等待下一次交互。这其实和传统 Web 请求回圈的思路类似区别在于“请求的内容”不再是页面 URL而是“用户操作事件”“响应的内容”也不再是 HTML 页面而是“新的界面状态描述”。3. 最小可运行案例让代理能渲染一个交互面板3.1 目录结构和运行环境理解机制之后最有效的方式是动手跑通一个最小链路。下面这个案例不依赖特殊框架只需要 Python 和一个轻量 Web 框架适合本地学习。项目目录可以这样组织mirafold-demo/ ├── render_server.py ├── schemas/ │ └── migration_panel.json └── requirements.txtrequirements.txt只需要一个依赖flask3.0安装依赖并启动服务pip install -r requirements.txt python render_server.py这里使用 Flask 是因为它足够轻量能把重点放在渲染逻辑上。实际项目如果已经有其他服务也可以选用 FastAPI、Express 或 Go 的 HTTP 框架协议设计思路不变。3.2 用 Flask 提供渲染和事件两个接口最小案例只需要两个接口一个接收组件树并返回渲染结果一个接收事件并返回处理结果。完整的render_server.py可以写成这样import json from flask import Flask, jsonify, request app Flask(__name__) def render_node(node): node_type node.get(type) props node.get(props, {}) children [render_node(child) for child in node.get(children, [])] if node_type grid: return { component: Grid, columns: props.get(columns, 1), children: children, } if node_type card: return { component: Card, title: props.get(title, ), children: children, } if node_type button: return { component: Button, label: props.get(label, ), variant: props.get(variant, default), action: node.get(action), } if node_type text: return { component: Text, value: props.get(value, ), } raise ValueError(unknown node type: %s % node_type) def handle_event(event): action_name event.get(action, {}).get(name) payload event.get(action, {}).get(payload, {}) if action_name confirm_migration: return {status: ok, message: 迁移任务已提交} if action_name open_form: return {status: ok, message: 打开表单面板} raise ValueError(unknown action: %s % action_name) app.post(/render) def render_schema_api(): data request.get_json(forceTrue) schema data.get(schema) if not schema: return jsonify({ok: False, error: missing schema}), 400 try: return jsonify({ok: True, view: render_node(schema.get(root))}) except ValueError as exc: return jsonify({ok: False, error: str(exc)}), 422 app.post(/event) def event_api(): data request.get_json(forceTrue) try: result handle_event(data) return jsonify({ok: True, result: result}) except ValueError as exc: return jsonify({ok: False, error: str(exc)}), 422 if __name__ __main__: app.run(host127.0.0.1, port8000, debugFalse)这个服务有两个值得注意的地方。第一render_node是纯函数不依赖 HTTP 请求上下文因此可以单独写单元测试。第二未知组件类型和未知 action 都会抛出异常并由接口层统一转成 422 响应这样代理可以立即感知到 schema 不合法。验证渲染接口curl -X POST http://127.0.0.1:8000/render \ -H Content-Type: application/json \ -d schemas/migration_panel.json如果传入前面那棵组件树会得到类似下面的结果{ ok: true, view: { component: Grid, columns: 2, children: [ { component: Card, title: 迁移任务预览, children: [ { component: Text, value: 准备迁移 128 张表预计耗时 6 分钟。 }, { component: Button, label: 确认执行, variant: primary, action: { name: confirm_migration, payload: { batch: 128 } } } ] } ] } }这一步跑通后你已经具备了生成式 UI 的最小运行时能解析组件树、能渲染节点、能处理事件。3.3 把渲染服务包装成代理可用的工具本地服务本身不会自动被代理调用。要让 Codex、Claude Code、Gemini 这类工具使用它需要把它暴露成代理可调用的工具常用的方式是通过 MCP 协议或各工具的自定义工具机制暴露两个能力ui_render和ui_event。代理侧可以这样理解整个流程模型需要展示一个面板时生成组件树。代理调用ui_render工具把组件树发送给渲染服务。用户在渲染出的界面上操作产生事件。渲染服务把事件转交回代理代理继续下一次推理。接入配置例如{ servers: [ { name: mirafold-render, command: python, args: [render_server.py], env: { MIRAFOLD_SESSION_DIR: ./sessions } } ] }这里不展开具体某个代理工具的配置格式因为不同工具、不同版本的配置差异较大。核心思路是让代理具备调用渲染服务的能力并把“生成组件树”和“渲染组件树”拆成两个职责。模型负责生成意图渲染器负责执行展示。4. 接入 Codex、Claude Code、Gemini 的差异与统一策略4.1 三个代理对自定义工具的扩展方式不同从实际使用习惯看三个代理虽然都是终端型 AI 助手但扩展方式有明显差异。下面这张表用于对比具体配置名要以各工具当前版本文档为准。代理交互入口自定义能力形式接入生成式 UI 时的关注点Codex CLI终端命令行项目配置、工具定义、工作流扩展工具调用能否传递结构化 JSONClaude Code终端命令行 / SDKSkills、Hooks、MCP 工具事件回传结果是否进入对话上下文Gemini CLI终端命令行 / APIExtensions、工具定义UI 会话状态如何与多轮对话保持一致这里的核心差异不在渲染端而在“工具调用返回结果是否能被模型观察到”。如果代理调用了ui_render但是渲染结果或事件结果没有进入模型上下文那么界面上的用户选择就无法参与后续推理整个闭环会断掉。4.2 面向接入层做统一封装即使三个代理的接入方式不同业务逻辑层也可以统一。只要把渲染服务和事件服务封装成两个稳定函数不管代理侧是 MCP 调用还是自定义工具调用内部实现可以复用。import requests RENDERER_BASE http://127.0.0.1:8000 def call_render(session_id, schema): resp requests.post(f{RENDERER_BASE}/render, json{ session: session_id, schema: schema, }) resp.raise_for_status() return resp.json() def call_event(session_id, action_name, payload): resp requests.post(f{RENDERER_BASE}/event, json{ session: session_id, action: { name: action_name, payload: payload, }, }) resp.raise_for_status() return resp.json()封装函数里放入session_id很重要。生成式 UI 的界面是有状态的如果每次渲染都不带会话标识后续增量更新时服务端无法区分这是哪个面板的组件树。最小案例可以不做会话持久化但进入多轮对话场景后session_id几乎是必须的。4.3 模型选择直接影响 schema 的合法性生成式 UI 的效果高度依赖模型能否稳定输出合法 JSON。不同代理背后使用的模型不同输出习惯也不一样。常见差异如下模型或代理常见输出习惯需要防护的点Codex 相关模型倾向直接输出 JSON 代码块过滤 Markdown 围栏Claude Code 相关模型倾向通过工具调用返回结构化参数工具参数 schema 要严格定义Gemini 相关模型倾向流式文本和工具调用混合对齐工具定义格式防止解析偏差这些行为会随模型版本变化不能当作固定事实。实际项目里要做的是在渲染器入口加一道统一的 schema 校验不管模型来自哪个工具只要组件树不合法就明确报错。校验不通过时不猜测修复而是把错误返回给代理让它重新生成。5. 验证链路从 schema 生成到事件回传的检查清单5.1 五个关键检查点生成式 UI 的验证不能只停留在“页面显示出来了”。要确认整个交互闭环处于健康状态至少检查五个点模型输出的组件树是合法 JSON。组件树中每个节点类型都能被渲染器识别。action 名称能在事件处理器注册表中找到。用户操作产生的事件能回传到代理上下文。状态更新后渲染器能根据新组件树刷新界面。这五个点分别对应协议层、渲染层、事件层、上下文层和状态层。任何一个断裂用户看到的都是“界面卡住”或“点了没反应”。5.2 预期输出示例以最小案例中的“确认执行”按钮为例正常的调用链应该产生以下特征渲染接口返回 200日志中出现按钮信息INFO renderer nodebutton label确认执行 actionconfirm_migration INFO renderer viewnodes:3 rendered1.2ms事件接口收到点击事件后返回 200{ ok: true, result: { status: ok, message: 迁移任务已提交 } }如果代理能力足够它会在下一次回复中引用message字段例如“迁移任务已提交接下来可以查看进度面板”。如果事件结果没有进入代理上下文界面虽然正常但代理仍然不知道用户点击了确认按钮。5.3 三个高频问题踩坑记录下面三个问题在接入生成式 UI 时出现频率最高每一个都有明确的现象和修复路径。坑一模型返回的 JSON 被 Markdown 代码块包装。模型为了提高可读性经常输出带json围栏的文本。如果代理直接把这段文本当作 schema 发给渲染器json.loads会直接报错。解决方式是在调用渲染器前做一次剥离处理import re import json def extract_json(raw): text raw.strip() fence re.match(r^(?:json)?\s*(.*?)\s*$, text, re.S) if fence: text fence.group(1) try: return json.loads(text) except json.JSONDecodeError: return None坑二渲染成功但界面不刷新。事件接口返回了普通结果但界面没有任何变化。常见原因是渲染器没有收到“更新后的组件树”。事件处理器返回的数据结构没有和渲染器对齐。解决方式是把事件接口的返回结构统一为两种一种是{ status: ok, schema: {...} }表示需要重渲染另一种是{ status: ok, message: ... }表示只回传代理不更新界面。渲染器收到带schema的返回时才刷新。坑三action 名称在代理侧和渲染器侧不一致。模型自行发明了一个action名称但事件处理器没有注册这个名称接口返回 422。根因是 schema 生成阶段缺少校验。解决方式是在渲染器入口维护一张 action 白名单校验失败时不渲染界面而是返回明确错误ALLOWED_ACTIONS {confirm_migration, open_form, cancel} def validate_action(action): name action.get(name) if name not in ALLOWED_ACTIONS: raise ValueError(action not allowed: %s % name)界面上的每个按钮都应该在渲染前完成 action 校验这样能避免把非法事件带到用户面前。6. 生产环境落地生成式 UI 不是前端框架替代品6.1 学习环境与生产环境的差异本地用 Flask 跑通最小案例和生产环境落地是两套标准。差异主要体现在运行时、状态、安全、可观测和回滚上。维度学习环境生产环境渲染服务本机运行内存态独立部署多实例会话状态不做持久化需要 Redis 或数据库存储schema 校验靠代码判断使用 JSON Schema 严格校验安全策略本地假设可信按不可信输入处理日志print 输出结构化日志带 trace_id错误恢复重启服务即可重试、降级、熔断回滚方式CtrlC无 UI 时退化为纯文本提示生产环境里最容易被忽略的是回滚。生成式 UI 一旦出问题用户可能完全无法操作界面。此时要有降级路径例如当渲染服务不可用时代理直接输出等价文本让用户仍然能完成任务。不要让学生环境里的“重启就好”带到生产环境。6.2 安全边界要放在“模型输出不可信”的前提上生成式 UI 的安全模型和传统前端不同。传统前端代码由开发团队编写经过代码审查生成式 UI 的组件树是模型现场生成的模型输出本身可能带有幻觉甚至可能被用户的输入影响。因此所有组件树都必须当作不可信输入处理。至少要做三件事第一只允许白名单组件。组件树里出现未知的component直接拒绝不要让渲染器尝试动态加载任意模块。否则恶意输入可能诱导渲染器加载危险组件。第二禁止在 schema 中传递可执行脚本。按钮事件只用 action 名称表达意图不传递函数体或可执行代码。事件数据是数据不是代码。第三限制 props 的长度和嵌套深度。模型可能在长文本输出中生成极大组件树导致渲染服务内存暴涨。渲染入口要加最大节点数、最大嵌套深度、字符串最大长度限制。这三条都不复杂但能挡掉大部分常见风险。安全防护的核心不是防御某个具体攻击而是默认模型输出不可信然后再决定哪些能力可以放行。6.3 发布前检查清单在把生成式 UI 接入正式代理流程前建议至少过一遍下面的清单组件树版本号和渲染器版本一致。schema 校验规则覆盖所有已有节点类型。action 白名单已按业务配置完整。渲染服务与代理之间的网络访问已确认。日志中带有 session_id 和 trace_id。会话恢复和超时逻辑已测试。事件接口有幂等处理防止重复点击导致重复操作。渲染服务的并发上限和耗时基准已评估。渲染不可用时代理能自动降级为纯文本提示。清单的意义不在于形式而在于强制你考虑那些“本地不会出错、生产一定会出错”的环节。7. 扩展方向与下一步实践7.1 从单一渲染器走向可复用组件协议当你有了自己的组件库可以把渲染器的映射配置化。例如维护一个component_registry.json让代理通过 schema 引用组件名渲染器根据注册表查找对应组件。这样新增一个组件时不需要修改模型提示词只需要更新注册表和校验规则。{ button: { props_schema: { label: {type: string, required: true}, variant: {enum: [default, primary, danger]} } } }这种配置一旦建立不同项目之间就可以共享同一套 UI 协议生成式 UI 就从“给代理加功能”变成了“给团队建基础能力”。7.2 让多个代理共享同一套 UI 会话因为组件树是模型无关的中间格式同一个会话可以被 Codex、Claude Code、Gemini 共同读取和更新。适合做“多代理协作查看一个任务面板”的场景一个代理负责生成计划另一个代理负责更新进度用户在同一块面板上看到整体状态。这要求渲染服务和代理之间不只是简单的请求响应还要有会话存储和状态同步。会话存储可以先用 Redis事件通过消息队列分发。此时渲染器实际变成了一个轻量前端运行时而代理是业务逻辑的决策者。7.3 最值得先做的一个练习如果只想做一件事建议手工构造一个“确认迁移”面板的组件树然后用第 3 章的最小案例渲染它再模拟一次点击事件观察事件是否回传成功。这个练习虽然简单但覆盖了三个核心环节schema 生成、渲染执行、事件回传。这三条链路完整走通后再去研究 MCP 接入、会话持久化和多代理协作难度会降低很多。回到最初的问题Mirafold 这类生成式 UI 工具并不是要把终端交互全部替换成浏览器界面而是弥补代理在复杂确认、结构化输入、状态反馈上的表达短板。这个模式真正的价值在于三点schema 是模型能稳定输出的中间语言renderer 是能执行这个语言的运行时事件回传是让界面和推理过程重新闭合的通道。后续再扩展组件协议、接入更多代理、加入持久化会话时只要这三条链路没有断裂生成式 UI 带来的体验提升就会非常明显。
返回列表