04-实践篇-让AI生成可视化页面-ai-json-ui的落地实践
让 AI 生成可视化页面:AI-JSON-UI 的一次落地实践

副标题:当我们想让 AI 直接吐出一张可交互的页面而不是几行文字,到底要在前端铺多少东西?这是 0612 系列第四篇——实践篇。
系列前作:
- 设计篇:我用前端那一套手艺,造了一个 AI-Native 工具
- 架构篇:前端怎么反客为主,把 AI 编排权拿回到自己手里
- 调优篇:AI 应用的性能直觉,前端工程师其实最敏锐
一、问题:让 AI 生成"可视化页面",到底意味着什么
做过 AI 工具就知道,有这么一类极其常见的场景:
用户:「帮我开个开发分支」
AI:「请告诉我分支名、基线分支、要不要建 worktree?」
用户:「feat/x,从main,要 worktree」
AI:「确认要……」
用户:「确认」
四轮对话,三次回车。前端的鼻子在这种交互前是会皱起来的——这跟"在 Postman 里手动拼请求体"有什么区别?
这件事的解法看似很显然——让 AI 直接生成一张可视化页面:表单、卡片、确认框,用户一次填完点提交,AI 拿到结构化结果继续推进。但"让 AI 生成可视化页面"这一句话,在工程上其实在问四件事:
- AI 输出的什么东西可以被前端"放心"渲染成 UI?
- 谁来约束 AI 输出的合法性,让它不能瞎编?
- 复杂页面里的级联、远程拉数据、嵌入业务组件这些活,谁干?
- 用户填完点确认,怎么让"页面提交"和"对话流"无缝接上?
我们在轻辔里把这一块单拎出来做了一个子系统,对外叫 AI-JSON-UI——它的核心契约就是:AI 输出 JSON 描述、前端渲染成可视化页面。这篇文章讲的就是把这套契约落到生产,过去半年踩过的实在的坑。
二、路线快选:为什么是 JSON,不是别的
在落地前我们认真评估过几条路。这里不展开打分,只把关键判断列出来:
| 路线 | 一句话评价 |
|---|---|
| AI 直接吐 HTML / JSX | 安全和一致性归零,直接砍 |
| AI 自由生成表单 schema + 通用引擎(Formily/amis/RJSF) | 重型依赖、AI 编不准、企业表单平台合理,对话场景不合理 |
| AI 选预定义模板 + 自研渲染器 | 渲染、状态、级联全要自己写,典型 NIH |
| AI 选模板 + json-render | 解决了渲染,但"模板爆炸"挡在前面 |
| AI 输出 FieldSpec → 编译成 JSON UI 描述 → json-render 渲染 | 当前方案 |
简单说:AI 自由度越高,前端要兜底的边界越无穷。一路砍下来,最稳的那条路是把 AI 锁在一个字段级的 JSON 描述语言里,让它只描述"这页有什么字段、字段长什么样、字段之间有什么关系"——这就是 AI-JSON-UI 的"JSON"那两个字真正的含义。
剩下的篇幅都用来讲这条路怎么落。
三、核心抽象:FieldSpec 是 AI 的"输出契约"

AI-JSON-UI 的中心是一个叫 FieldSpec 的东西——AI 不写 HTML、不写 React、不写 schema,AI 只写一个 FieldSpec 数组:
type FieldSpec =
| { id; type: "text"; label; required?; multiline? }
| { id; type: "select"; label; options[] }
| { id; type: "select"; label; optionsUrl } // 远程拉选项
| { id; type: "text"; dependsOn: { field; eq } } // 级联可见
| { id; type: "mfe"; module } // 复杂业务块
然后我们写一个纯函数编译器,把 FieldSpec 数组翻译成 json-render 能消化的 UI 描述(一份纯 JSON 的 UI 树):
| FieldSpec 语义 | 编译产物 |
|---|---|
| 字段 A 等于某值时才显示字段 B | json-render 的 visible 条件 |
| 选项要从某个 URL 拉 | 注册表的 loadOptions 动作 + watch |
| 字段是一个复杂业务块 | 注册表的 MfeSlot + 模块白名单 |
这一层抽象的实际收益有三个:
- AI 只需要懂 FieldSpec,不需要懂 UI 框架。给 AI 的 system prompt 短了一大截,它的"视野"被限定在"字段长什么样",不会去碰 className、theme、布局参数这些容易跑偏的维度。
- FieldSpec 的 Zod 是合法性的最后一道闸门。AI 编出任何不在 schema 里的字段都会被 reject。Zod 不是"加分项",是"安全模型"——后面 §六会展开。
- 复杂能力靠"编译器加一个 case"覆盖——级联、远程选项、复杂业务块都不需要新增模板。
最关键的设计决策:编译器整个是纯函数。给定一组 FieldSpec,编译产物长什么样是确定的。这件事在 AI 应用里太重要——它意味着你能单测、能 fuzz、能把 AI 出过事的输入存下来本地 replay。AI 应用之所以难调试,常常是因为整条管线没有一个纯函数的中间态。FieldSpec → JSON UI 描述这一段是纯函数,调试能力立刻拉回到普通前端水平。
四、传输层:tool 优先,Markdown 兜底
AI 怎么把 FieldSpec 送到前端?我们做了双通道:
| 通道 | 触发条件 | 形态 |
|---|---|---|
| 结构化工具调用 | 支持 tool call 的模型(OpenAI / DeepSeek) | tool call,参数即页面指令 |
| Markdown 块兜底 | 不支持 tool 的通道(如 Cursor agent 流) | HTML 注释 + JSON 围栏 |
两条路最终都归一化到同一个内部结构:模板 ID + 一坨经过 Zod 校验的入参。归一化函数做四件事:tool 优先、注释次之、宽松 JSON 兜底、最后一律过 Zod。
你看出来了——AI 是不是用 tool call、是不是符合规范,前端都不在乎;前端只在乎归一化后的页面指令是不是合法。这就是调优篇里反复讲的——边界永远在前端守。
五、提交回路:页面不是终点,是下一轮对话的开始
这是最容易做错的一环。
错误做法:把页面状态直接 POST 到一个业务接口,跳出对话流。
正确做法:把页面状态翻译成一条普通的 user message,塞进对话历史,让 AI 继续推进。
具体落地有两条路:tool 通道进来的,就走 tool result 回填;Markdown 块进来的,就等同用户手打了一句"我选的是 X、Y、Z"。无论走哪条,对话历史的连贯性都保住了——AI 看到的是"用户回了一个结构化的答案",而不是"突然有个表单状态从天而降"。
这个细节有多重要?我们早期做错过一次:页面提交直接走业务 API,结果用户填完之后再问一句"刚才那个分支建好了吗",AI 一脸懵——它根本不知道用户填了什么。后来才把这条回路改对。
让 AI 生成可视化页面,不是把页面接到业务,而是把页面接回对话——这件事是 AI-JSON-UI 和"传统动态表单"最本质的区别。
六、被现实磨出来的四块硬骨头
把一个 AI 生成的页面渲染出来不难,难的是把它放进生产对话流里反复跑还不出问题。这一节展开真正费功夫的四件事。
6.1 多轮对话里"页面中间态"的归属
最先暴露的是状态归属问题。一张 AI 生成的页面在对话流里其实有四种状态:
- AI 还在流式生成(部分字段已到、部分没到)
- 生成完整,等用户填写
- 用户填到一半切走又切回来
- 用户已提交,进入下一轮
最早我们把这些都堆在前端 React 局部 state 里,结果就是"切走再回来页面清空了"、“AI 又来一轮新页面时旧页面的中间值莫名其妙串台”。
后来分了三层来拆:
| 层 | 责任 | 跨轮次能不能存活 |
|---|---|---|
| 流式增量 | AI 一个 patch 一个 patch 推,前端按 JSON Patch 累积 UI 描述 | 不存活,本轮专属 |
| 当轮表单值 | 用户填写的中间值,绑在这一条对话消息上 | 不存活,但不会丢 |
| 已提交结果 | 写进对话历史的"用户回答" | 永久存活 |
关键是引入了一层消息维度的 namespace——每张页面的状态以"它属于哪一条 AI 消息"为 key。AI 在第 N 轮吐了一张页面,状态就锁在第 N 轮;用户切走再回来,第 N 轮那条消息底下的 UI 还是原样;AI 在第 N+2 轮再吐一张新页面,新旧两张互不干扰。
流式中断也要单独处理。AI 流到一半被中断(用户按停、网络断),前端不能把已经长出来的字段直接抹掉,而是把这张页面切到一个暂停态面板——清楚地告诉用户"这张页面还没生成完,你可以丢弃它,或者让 AI 接着生成"。没生成完的 UI 不能假装已经生成完——这条规则在 AI-JSON-UI 里被写得很重。
6.2 数据校验有三道关,缺一不可
校验是这套系统里最容易被低估的一块。我们最终落到三道关:
第一道:结构校验——AI 吐出来的页面指令本身长得对不对。模板 ID 必须在白名单里,每个模板的入参必须过 Zod。AI 想编个 template: "rocket-launcher" 直接被拒。
第二道:字段语义校验——FieldSpec 描述的字段是不是自洽。一个 select 字段不能既给 options 又给 optionsUrl;带 dependsOn 的字段引用的目标必须是同一张页面里存在的字段 ID。这一层是纯函数,单独测。
第三道:用户输入校验——表单实际填写值合不合规。必填项空了要红字提示、文本字段长度限制、select 选了不在 options 里的值要拦。这一层和传统表单校验没啥两样,但有个微妙差别:AI 的 system prompt 里不能写"如果用户填错就再问一遍"——校验失败应该原地提示并阻止提交,而不是发回去让 AI 用自然语言再纠缠一轮。
漏哪一道都会出事:
- 漏第一道,AI 偶发的"在 props 里塞
<script>"会真的渲染出来; - 漏第二道,AI 编出的 dependsOn 指向不存在的字段,页面永远不显示,用户以为卡死;
- 漏第三道,必填没填用户照样能点提交,下游业务接口收到一堆空字符串。
6.3 复杂页面:级联和远程选项才是分水岭
简单页面(几个独立字段并排放着)很容易做,难的是这两件事:
- 级联:字段 B 是否显示、是否必填、选项有哪些,取决于字段 A 的当前值。
- 远程选项:字段的下拉选项不是 AI 写死的,要去拉真实接口。比如"选基线分支"——AI 不可能知道你这个仓库当前有哪些分支,得让前端去 git 里读。
我们试过的几种走法:
| 走法 | 问题 |
|---|---|
| 让 AI 在每个字段的 options 里直接写死 | AI 编的选项常常不存在;切上下文还得重新让 AI 编一遍 |
| 让 AI 调一个 tool 拉选项,再回来出页面 | 多一轮往返,页面出现得慢;AI 还可能忘了带回某些必填字段 |
| AI 只声明"这个字段的选项从这个 URL 拉",前端自己去拉 | 走通 |
第三种就是 FieldSpec 里 optionsUrl 的语义。编译器把它翻译成 json-render 的 watch + loadOptions 动作,前端在页面挂载时去调一个我们自己写的轻 BFF。BFF 是一个按资源类型路由的统一端点:分支列表、最近文件、目录结构、worktree 实例……每种"AI 知道但拿不到"的数据都挂在这条路径下,AI 只需要告诉前端"我要哪一种"。
级联同理。FieldSpec 写 dependsOn: { field: "kind", eq: "feature" },编译器翻译成 json-render 的可见性条件,由它的状态机驱动。AI 不需要懂"怎么实现可见性",它只需要描述"这个字段什么时候出现"。
这一层的设计原则是:让 AI 描述意图,前端实现机制。AI 写"这个选项要远程拉",前端负责怎么拉、什么时候拉、拉失败怎么办;AI 写"这个字段依赖那个字段",前端负责怎么响应、状态变化时怎么重渲。这条原则是 AI-JSON-UI 能从"玩具表单"长成"复杂业务页面生成器"的关键。
6.4 业务组件复用:微前端槽位的尝试
最后一块是最不好啃的——有些字段它就是没办法用通用控件解决。
比如"在页面里嵌一个文件树选择器、可以多选、可以预览、还要支持本地路径选择回调",或者"嵌一段实时的资源预览卡,从某个内部系统拉数据"。这种东西如果硬要塞进通用 catalog,就会把 catalog 撑变形——你会发现自己在通用控件里加各种业务专属 props,最后这个"通用控件"只为了一个场景而存在。
我们的做法是在 FieldSpec 里加了一种 type: mfe 的字段类型——它本质上是一个槽位:AI 声明"这里要挂一个名叫 X 的复杂块",编译器把它翻译成注册表里的 MFE 槽位组件,运行时按白名单查找模块并挂载。
这条路上踩过的坑也不少:
- 白名单是必须的。不能让 AI 想挂什么就挂什么,否则等于把"AI 自由生成 React"那条路又开了一遍——AI 会编出
module: "../../etc/passwd"之类的东西。模块名只能在我们预先注册过的清单里。 - MFE 模块的入参契约要分两层。第一层是宿主 → 模块的标准协议(当前页面状态、字段值、submit/cancel 回调);第二层是这个具体业务模块自己的私有参数(由 AI 在 FieldSpec 里填)。两层都过 Zod。
- MFE 模块要懂"自己是页面的一部分"。它不能在对话流里独立 submit,必须把内部状态汇报给宿主,由宿主统一收口走"提交回路"。否则会回到第五节那个老问题——状态绕过对话历史,AI 不知道用户做了什么。
这一层目前还在阶段性地完善——已经能支持几种复杂块(路径选择器、分支选择器、worktree 创建器),但更通用的"任意业务前端模块作为页面字段"还在按场景一个一个加。它最终会演化成一种受控的微前端协议,但前提是先把白名单、契约、状态汇报这三件事彻底锁死,否则就是把 NIH 陷阱以另一种形式重新打开。
七、一次完整的请求长什么样
把上面的东西串起来,跑一次"帮我开个开发分支":
- 用户打字。意图被前端的纯函数路由识别成"快速开发"。
- AI 拿到的 system prompt 里,已经塞了一段对 AI-JSON-UI 的指引——“2 个以上字段必须用多字段表单,不要拆成多轮”。
- AI 调一次结构化工具,参数是模板 ID 加一组 FieldSpec:分支名(文本,必填)、基线分支(select,main / dev)、是否建 worktree(select,是 / 否)。
- 前端归一化、过 Zod、编译成 JSON UI 描述、挂到对话流里。用户看到一张悬浮在对话气泡下方的页面卡片。
- 用户填好点提交。页面状态被翻译成 tool result 喂回 AI。
- AI 拿到"用户选了 feat/login、main、yes",下一步去调 git。
整个过程没有任何"自由 HTML"、没有任何 dangerouslySetInnerHTML、没有任何让 AI 直接吐 React 组件——但用户体验上,是 AI 给我吐了一张页面。
八、踩过的坑,列三个最痛的
坑一:让 AI 自己决定"要不要出页面"是不靠谱的。
最早我们以为 AI 会自觉判断"这一步信息不全,该出 UI 了"。实测下来 AI 的判断很随机——有时候会出,有时候会接着用纯文本问。最后是在 system prompt 里加了硬约束:需要 2 个或以上字段时,必须用多字段表单,禁止拆成多轮单字段问答。
加了这条约束后,AI 立刻乖了。写在 system prompt 里的约束才是有效约束,写在工具描述里的都只是"建议"。
坑二:流式渲染会让你的页面"长出一半就 submit"。
AI 是流式输出的,工具参数也是逐 token 来的。有一次我们没等流完就开始渲染,结果用户看到页面只长了两个字段,以为只有两项,填完点了确认。后来加了一个完整性 flag——模板必须完整 parse 通过才允许提交按钮亮起。
坑三:注册表里所有组件的 props 都必须用 Zod 描述。
看起来是个"基础设施"小事,漏写一次代价很高——AI 偶尔会编出 props 里塞 <script>alert(1)</script> 这类字符串。Zod 那一道闸门挡掉过不止一次。让 AI 生成可视化页面的安全模型是"白名单 + 校验",不是"过滤危险字符"。
九、回过头看:这套东西"前端味道"在哪儿
写到这儿你可能发现了——AI-JSON-UI 的设计哲学和设计篇讲的"前端口味的工程哲学"是一脉相承的:
- 状态机思维:AI 输出 → 归一化 → 校验 → 编译 → 渲染 → 提交回流,每一步都是确定的状态转换;
- 类型驱动:FieldSpec 的 Zod 是真源,AI 是"按 schema 填空",不是"自由发挥";
- 可单测的纯函数:编译器、归一化器、提交转换器——全是纯函数,全有单测;
- 不重复造轮子:渲染、状态、级联交给业界方案,我们只做决策和编译;
- 守边界:无论 AI 走的是哪条传输通道,最终都收敛到一个内部结构。
最后一段写给同样在做 AI 工具的前端同学:
别被"让 AI 生成可视化页面"这件事唬住。它听起来像是个魔法——AI 自由生成界面,前端洒洒水。但你真做下来会发现,能跑、能维护、能演进的版本,都是把"AI 的自由度"压在一个很小、很确定、很可校验的接口面上的。
AI-JSON-UI 选择把这个接口面叫 FieldSpec、用 JSON 表达、用纯函数编译——这只是其中一种解。但**"AI 输出 JSON、前端渲染 UI、对话流接住提交"这个三段式,是任何想做这件事的人都绕不过去的骨架**。
下一篇预告:当 AI 工具长出"快速开发流"时,AI 是怎么把 git worktree、本地路径选择和这套 AI-JSON-UI 串到一起的——也就是说,对话框最终会怎么变成一个轻量 IDE 的入口。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)