起点
我一开始以为,AI UI 生成的问题,是「如何让 LLM 写出更好的 React」。
后来发现,真正的问题恰恰是:不应该让 LLM 直接写 React。
SpecPulse 最终走了一条不同的路径:
Natural Language → LLM → UISpec → Deterministic Generator → React
SpecPulse 是我为验证这条路径搭的动手项目:用自然语言描述需求,LLM 把它转成一份声明式的 UI spec(一棵 JSON 树),再由确定性生成器编译成真实 React 组件。它起于一个很具体的挫败感——我需要快速搭一个内部工具页面,描述需求 → 等设计 → 等排期 → 手写组件 → 调试样式,来回折腾。我想能不能跳过中间那些环节,直接把自然语言变成能跑的 React 页面。概念上很简单:
prompt ──► LLM ──► UI spec (JSON tree) ──► React component ──► Vite preview
(src/agent) (src/spec) (src/generator) (preview/)
第一版只有四个文件,结构简单到近乎简陋。src/agent/agent.ts 里一个 createAgent(),把提示词丢给 LLM,LLM 返回一段 JSON,我用 src/generator/reactGenerator.ts 里一个 specToComponent(spec) 把它翻译成 JSX 源码,写到 preview/src/App.tsx,Vite 热更新一下,浏览器里就看到了。没有 Electron,没有 IPC,没有历史记录,没有调整框,没有编辑模式。就一个命令:
npm run build -- "a landing page for a cloud storage startup with navbar, hero heading, feature cards and a CTA button"
跑完打开 preview/,完事。
这个最小闭环跑通之后,我才开始意识到真正的难题不在"生成",而在"迭代"。第一次生成的东西基本都不对——布局太单调、组件用错了、颜色配得离谱。用户不会因为你生成了第一版就满意,他们会说"把标题改成蓝色"、"在 Hero 下面加一个 Stat 卡片"、"把三个 feature card 并排"。
所以我加了 src/cli/adjust.ts,它不再是从头生成,而是先读取已有记录的 spec.json,把提示词里的 #1.2 这类引用展开成具体节点描述,再让 LLM 输出 diff 后的完整 spec,最后用 specToComponent 重新编译。第一次实现的时候我发现一个坑:LLM 经常"顺手"改掉用户没要求动的部分,于是我在 adjust_system_prompt 里硬编码了"只改用户要求的部分,其余原样保留"——但 LLM 并不总是听话,后来又在 collectTypes 里加了校验,如果调整后丢失的组件类型超过一半就警告。
接着是编辑模式。调整框依赖 LLM,每次都要调接口,慢而且贵。我想做一个纯本地的编辑路径:在预览里点组件, Inspector 里直接改字段,Save 之后不经过 LLM,只跑一次 specToComponent 重新编译。这个能力现在在 preview/src/PreviewRoot.tsx 里——它通过 postMessage 从 Electron main 进程收到 spec,把编译好的 App 替换成 SpecEditor,保存时写回 spec.json 然后调用 src/cli/regenerate.ts。
历史记录是后面才补的。最早第一版生成完就覆盖了 App.tsx,前一个版本没了就没了。archive() 函数是我后来加进去的,每次 build 或 adjust 之前先把当前状态打包到 generated/<stamp>-<title>/,stamp 用当前时间格式化,safeTitle 把非法字符替换掉。用时间戳做目录名,是因为不想引入额外的数据库或 ID 生成逻辑——一个字符串拼接就能解决;代价是如果两次生成落在同一个秒内,目录会冲突,用户分不清哪个是哪个。prompt.txt 保留原始提示词,spec.json 保留中间表示,App.tsx 保留编译产物——三者缺一不可,但 spec.json 单独也能重新生成,所以 generator 被刻意设计成纯函数。
Export 功能则源于另一个场景:我想把生成结果发给别人看,但不想让人看到我的 prompt——prompt 里可能有内部项目名称或敏感业务信息。src/cli/export.ts 的做法是把 spec 内嵌进 HTML 的 window.__UIAGENT_SPEC__,同时把 CSS 和 JS 也 inline 进去,生成一个单文件 index.html,prompt.txt 不打包进去。部署的时候换 spec.json 就行,页面跟着变,不需要重新构建。
回头看,这个项目从一个"把文字变页面"的简单实验,演变成现在的 Electron + React + Vite 工作台,每一步都是因为遇到了第一个闭环解决不了的问题——迭代、本地编辑、历史追溯、安全导出。我没有先画架构图,是从一个命令、一个函数、一个预览窗口开始,一个个补上的。
不过第一版里有一个根本性取舍:我把 LLM 当作唯一的生成引擎,却把"迭代"和"编辑"也塞进了同一条链路。adjust 每次还是要调 LLM,慢而且贵;后来加的 SpecEditor 走纯本地路径、不经过 LLM,但两条路径在数据模型上并没有真正统一——adjust 靠 LLM 理解"只改要求的部分",SpecEditor 直接写 spec.json 不走校验。还有一个当时没意识到的问题:specToComponent 是纯函数、无副作用,这很好,但一旦 spec.json 字段类型不对,编译期不会报错,只是渲染偏离预期——"编译期校验"后来写进了 TODO 的 P2。
核心:为什么是 UISpec
先说结论。这个项目最值得带走的,不是 Electron、IPC、归档或单文件导出,而是第一天就做出的一个选择:不让 LLM 直接输出 React 代码,而是让它在中间吐出一份 UISpec——一棵结构化的 JSON 树。
// src/spec/types.ts
interface UISpec {
title: string;
root: UINode;
}
interface UINode {
type: string;
props: Record<string, unknown>;
children?: UINode[];
}
直接让 LLM 写 JSX,输出质量极不稳定:有时是完整组件,有时是半截代码,有时混入 Markdown。specToComponent 是纯函数,给定相同输入永远输出相同 JSX——LLM 的抖动被挡在了中间层之外。
但 spec 真正的红利是可定位:它是一棵有层级的树,每个节点都能被路径索引到,于是 #1.2 路径引用、SpecEditor 的 Inspector、历史归档与导出才有了落点。如果 LLM 直接输出 JSX 字符串,这些能力都无处安放。
所以这条原则可以概括为:在 AI 生成链路中,中间表示的质量决定了系统的天花板。 下面用 SpecPulse 的演进来展开它。
中途转向
最终停在这里:Electron 窗口左侧是提示词区,右侧是实时预览,组件可以点选获得 #1.2 路径引用,调整框支持增量 LLM diff,编辑模式走纯本地路径直接写 spec.json 再重新编译,历史记录按时间戳归档到 generated/<stamp>-<title>/,导出功能生成单文件 HTML,spec 内嵌为 window.__UIAGENT_SPEC__。
这个形态不是计划出来的,是踩出来的。
第一次转向:从"生成"到"迭代"
第一版只有一个命令:
npm run build -- "a landing page for a cloud storage startup with navbar, hero heading, feature cards and a CTA button"
src/cli/build.ts 里 createAgent() 被调用,提示词塞进 LLM,LLM 吐 JSON,specToComponent() 翻译成 JSX,写到 preview/src/App.tsx,Vite 热更新,浏览器里出现页面。行,但不好用。
问题出在"第一次"上。LLM 产出的布局总是太单调——所有东西垂直堆叠,Grid 和 Row 没按规则用,某个本该并排的 feature cards 被塞进没有容器的 Row 里,颜色配得离谱。用户不会因为你生成了第一版就满意,他们会说"把标题改成蓝色"、"在 Hero 下面加一个 Stat 卡片"、"把三个 feature card 并排"。
所以我要做增量调整。我写了 src/cli/adjust.ts,它的逻辑和 build 完全不同:不再从头生成,而是读取已有记录的 spec.json,把提示词里的 #1.2 这类引用用 expandRefs 展开成具体节点描述(比如 #1.2 [Heading「产品规格」]),再让 LLM 输出 diff 后的完整 spec。
第一次实现的时候我撞了一个坑:LLM 经常"顺手"改掉用户没要求动的部分。Hero 下面加卡片,结果 Navbar 的样式也被改了。我在 adjust_system_prompt 里硬编码了"只改用户要求的部分,其余原样保留",同时在 collectTypes 里加了校验——如果调整后丢失的组件类型超过一半,就打警告。
代价是什么?每次调整还是要调 LLM,慢而且贵。收益是:用户可以用自然语言描述增量变更,而不必重新生成整个页面。
但这条路径有隐患:LLM 并不总是听话,警告只是警告,没有回滚,没有确认。后来我在 TODO 里写下了"编译期校验 + 自动修复"和"LLM 违规降级",但那时候它们只是一个想法。
第二次转向:从"调整"到"编辑"
增量调整解决了"自然语言描述变更"的问题,但有两个缺点:每次都要调接口,贵且慢;LLM 的输出不可预测,改错地方你也不知道。
我想做一条纯本地的路径。在预览里点组件,Inspector 里直接改字段,Save 之后不经过 LLM,只跑一次 specToComponent 重新编译。
这个能力最终落在 preview/src/PreviewRoot.tsx 里——它通过 postMessage 从 Electron main 进程收到 spec,把编译好的 App 替换成 SpecEditor,保存时写回 spec.json,然后调用 src/cli/regenerate.ts。
但这两条路径在数据模型上并没有真正统一:adjust 的输出仍然要靠 LLM 理解"只改用户要求的部分",而 SpecEditor 的保存直接写 spec.json,不走任何校验。两条路并行存在,但谁也不说服谁。
代价是架构复杂度上升——你现在有两个不同的编辑入口,一个走 LLM,一个走本地;收益是用户有了更快的迭代路径,简单改动不需要等 LLM 响应。
第三次转向:从"覆盖"到"归档"
最早 build.ts 执行完就直接覆盖了 App.tsx,前一个版本没了就没了。用户调整了三次,第一次的结果彻底消失。
我加了 archive() 函数,在 build.ts 和 adjust.ts 里都有调用。每次执行之前先把当前状态打包到 generated/<stamp>-<title>/,里面存三个文件:prompt.txt(原始提示词)、spec.json(中间表示)、App.tsx(编译产物)。目录名用时间戳加安全化的标题。
选择时间戳而不是数据库或 ID 生成逻辑,是因为我想保持简单——一个字符串拼接就能解决问题。但时间戳作为唯一标识意味着,如果两次生成在同一个秒内完成,目录会冲突;后来我见过两次连续生成产生两个目录,用户根本分不清哪个是哪个。
代价是 generated/ 目录会随着使用膨胀,而且因为 prompt.txt 包含原始提示词(可能有内部项目名称或敏感信息),我把这个目录 gitignore 了。收益是:版本历史永远保留,spec.json 单独就能重新生成任何一版。
第四次转向:从"项目产物"到"可分发包"
项目做出来之后,我想把生成结果发给别人看。但 prompt 里可能有敏感信息——内部项目名称、业务上下文——我不能让接收方看到。
src/cli/export.ts 的做法是:读取某条记录的 spec.json,用 Vite 构建一个 spec-driven 的动态渲染运行时,然后把 spec 内嵌进 HTML 的 window.__UIAGENT_SPEC__,同时把 CSS 和 JS 也 inline 进去,生成单文件 index.html。prompt.txt 不打包进去。
部署的时候,换 spec.json 就行,页面跟着变,不需要重新构建。
这个决策当时看起来是对的——prompt 不暴露,spec 内嵌保证 file:// 双击也能渲染。但它带来了一个副作用:如果你想让导出后的页面可以在线编辑 spec,你得同时分发 spec.json,而 prompt.txt 的缺失让"这个页面最初是怎么描述出来的"这个信息永久丢失了。
还有,导出时内联的第三方依赖(比如 3D 场景用的 react-three-fiber)如果版本更新,你得手动重新构建。TODO 里写着"导出增强:内联第三方依赖、输出单 HTML 免构建可分享",但这是一个未来任务。
现在回头看
这个项目从"把文字变页面"的简单实验,演变成现在的形态,每一步都是因为遇到了第一个闭环解决不了的问题。我没有先画架构图,是从一个命令、一个函数、一个预览窗口开始,一个个补上的。
每次转向的代价都是架构复杂度上升,收益是用户痛点的缓解。两条路径并行(LLM 调整 vs 本地编辑)、历史归档机制、导出链路——这些都是后来补的,不是一开始就设计好的。
现在的 TODO 里还有 P0 级的编辑器体验问题(Undo/Redo、拖拽排序、快捷键、组件树视图)、P1 级的数据与版本化(编辑前自动快照、编辑保存失败保护、记录差异视图)、P2 级的 LLM 输出健壮性(编译期校验 + 自动修复、LLM 违规降级)。这些问题都源于同一件事:我没有在开始时把"迭代"和"编辑"的数据模型真正统一。
但这就是工程实践——先跑起来,再修补。
未曾采用
我把最终停下来的样子摊开给你看,然后再告诉你我走过哪些弯路。
现在的 SpecPulse 是四条路径交汇的结果:Electron 窗口左侧提示词、右侧实时预览;组件可以点选获得 #1.2 这类路径引用;调整框走 LLM diff 路径,src/cli/adjust.ts 里的 expandRefs 把引用展开成具体节点描述再让 LLM 输出完整 spec;编辑模式走纯本地路径,preview/src/PreviewRoot.tsx 通过 postMessage 收到 spec,替换成 SpecEditor,保存时写回 spec.json 再调用 src/cli/regenerate.ts 重新编译;历史记录按时间戳归档到 generated/<stamp>-<title>/,存 prompt.txt、spec.json、App.tsx 三个文件;导出功能生成单文件 HTML,spec 内嵌为 window.__UIAGENT_SPEC__,部署时换 spec.json 就行。
但这一套不是生来就这样的。在它成型之前,有好几个方向我曾经认真考虑过,甚至写了一半又拆掉。它们被放弃的原因各不相同——有的复杂度太高,有的收益太小,有的时机不对。
没有让 LLM 直接输出 React 代码
这是最先被放弃的一条路。我最初的设想是:提示词丢给 LLM,LLM 直接吐出 JSX,写进 preview/src/App.tsx,完事。中间层不需要了——为什么还要 spec?
我试过。LLM 直接输出的 JSX 质量极不稳定。有时候是完整的 React 组件,有时候是半截代码,有时候混入了 Markdown 格式。而 src/generator/reactGenerator.ts 里的 specToComponent 之所以存在,恰恰是为了解决这个问题——它把 LLM 的"不可靠输出"变成"确定性编译"。
spec 作为中间表示的价值在于:它是类型化的、结构化的、可以被编辑器理解和修改的。src/spec/types.ts 定义了 UISpec 类型,specToComponent 是纯函数,给定相同的 spec 输入永远输出相同的 JSX。这让编译期可以校验、可以让编辑模式存在、可以让 adjust 做 diff。
如果 LLM 直接输出代码,expandRefs 里的 #1.2 路径引用就没有意义了——代码是字符串,无法按节点定位。SpecEditor 里的 Inspector 也无法工作,因为它需要 spec 的节点结构来构建字段列表。
代价是多了一层中间表示,架构复杂度上升。收益是整个系统有了可预测性——LLM 的输出被约束在 spec 格式内,后续的编辑、调整、导出都有了基础。
没有基于 shadcn/ui 搭建设计系统
preview/src/ui.tsx 的设计系统是全手写的。我曾考虑引入 shadcn/ui,但四个主题(light / dark / midnight / aurora)需要 token 驱动的切换,每个组件都要响应 Page.theme,而 shadcn/ui 的 CSS 变量体系与 spec 的 theme prop 难以直接对应,还要加一层适配层。自建更轻量,且与 UISpec 类型完全对应。
没有用数据库管理历史记录
历史记录按 generated/<stamp>-<title>/ 存三个文件(prompt.txt / spec.json / App.tsx)。我考虑过 SQLite,但 generator 是纯函数,spec.json 单独就能重建任意版本,文件系统只需 ls 就能列记录;引入 SQLite 的建表、迁移、查询逻辑对个人工具不值得。generated/ 被 gitignore,是因为 prompt.txt 可能含内部项目名,隐私优先于功能。
没有把编辑能力做到纯浏览器端
编辑模式目前绑定在 Electron IPC 上。我考虑过抽一层 storage 接口、用 localStorage / IndexedDB 替代文件写入,但 Electron IPC 比浏览器端存储简单得多——不用处理跨源、容量、file:// 协议下的读写限制。作为个人工具,Electron 已足够;浏览器端可用在 TODO 里排 P4,时机未到。
工程原则:一个可推广的模式
我现在停下来想一想,这个项目最值得带走的东西是什么。
不是 Electron,不是 IPC,不是历史记录机制,也不是单文件导出。这些都是在解决问题过程中长出来的枝叶。
真正决定这个系统能否长成今天这个形态的,是一个在项目第一天就做出的选择:不让 LLM 直接输出 React 代码,而是让它在中间吐出一份 UISpec——一棵结构化的 JSON 树。
// src/spec/types.ts
interface UISpec {
title: string;
root: UINode;
}
interface UINode {
type: string;
props: Record<string, unknown>;
children?: UINode[];
}
这个决定在当时看起来是多此一举——为什么不让 LLM 直接写 JSX?因为直接输出的代码质量极不稳定,有时是完整组件,有时是半截代码,有时混入 Markdown。而 specToComponent 是纯函数,给定相同输入永远输出相同 JSX。这意味着编译结果是确定的,而 LLM 的抖动被挡在了中间层之外。
但真正让我后来能做 adjust、能做 SpecEditor、能做归档和导出的,是 spec 这个中间表示带来的一个隐藏红利:它是可定位的。
expandRefs 函数之所以能工作,能把手写提示词里的 #1.2 展开成 #1.2 [Heading「产品规格」],正是因为 spec 是一棵有层级的树,每个节点都能被路径索引到。如果 LLM 直接输出 JSX 字符串,这个路径引用就无处安放——字符串里没有节点,只有字符序列。
同样的,SpecEditor 里的 Inspector 能列出可编辑字段,也是因为它读的是 spec 的节点结构,而不是解析一段 JSX 源码。
所以这条原则可以概括为:在 AI 生成链路中,找到一个足够好的中间表示,让后续的迭代、编辑、追溯、导出都有地方可落——这比一次性设计完美的前端架构重要得多。
SpecPulse 的 spec 就是这样的中间表示。它不完美——adjust 路径和 SpecEditor 路径在数据模型上没有真正统一,TODO 里写着"编译期校验 + 自动修复"和"编辑前自动快照",这些都是补窟窿。但正是因为有了 spec,这些窟窿才有地方补。
我后来也试过不走 spec 的方案——比如让 LLM 直接输出 JSX,或者让 adjust 直接 diff 源码字符串。前者在第一次实验中就失败了,后者让我意识到:字符串 diff 和节点 diff 是两件完全不同的事,前者改错地方的概率远高于后者。
所以沉淀下来的原则不是"先跑起来再修补"——这是很多个人项目的默认姿势,不是我的独特经验。沉淀下来的是:
在 AI 驱动的系统里,中间表示的质量决定了系统的天花板。选好它,后续的每一次转向都有支点。
SpecPulse 的 spec 就是这个支点。它让 LLM 的不可预测性被约束在一个可控的格式里,让后续的编辑器、调整器、归档器、导出器都有了共同的数据基础。没有这个支点,这个项目从一开始就会散掉——因为 LLM 的输出是不确定的,而不确定性的东西没法被编辑、没法被追溯、没法被安全地分发给别人。
而这条原则并不只适用于 UI。SpecPulse 里的 UISpec,换成 Workflow Spec、Query Spec、Agent Plan、Data Pipeline Spec——结构完全一致:LLM 负责生成"意图和结构",确定性 Runtime 负责执行。 这也是我在 Agent / Harness 方向持续探索的东西:给 LLM 一个足够好的中间表示,把不可预测的生成和可预测的执行彻底分开。
所以这篇稿子真正想说的,不是"我做了一个 AI 页面生成器",而是:在 AI 应用工程里,中间表示 + 确定性执行层,才是让系统从"能 demo"走向"可迭代、可追溯、可分发"的关键。 UISpec 只是它恰好长在 UI 上的样子。
这是我在这个项目里带走的最重要的一条原则:在 AI 生成链路中,中间表示的质量决定了系统的天花板。
源码导航
源码已开源,默认分支 main:https://github.com/erishen/specpulse
README.mddocs/TODO.mdsrc/agent/agent.tssrc/cli/adjust.tssrc/cli/build.tssrc/cli/export.tssrc/cli/regenerate.tssrc/generator/reactGenerator.tssrc/spec/types.tspreview/src/PreviewRoot.tsxpreview/src/ui.tsx
项目地址
SpecPulse 是一个开源的动手项目,源码与完整使用说明见 GitHub:
- 仓库:https://github.com/erishen/specpulse(默认分支
main) - 本地运行:
git clone https://github.com/erishen/specpulse.git && cd specpulse && npm install && npm run dev
它定位为一个「用自然语言驱动 UI 生成」的学习 / 演示工作台,核心探索的是 AI 生成链路里中间表示(spec)的设计取舍,而非一个面向生产的成品框架。
FAQ
Q1:为什么不让 LLM 直接输出 React 代码,而要绕一层 spec?
直接输出的 JSX 质量极不稳定——有时是完整组件,有时是半截代码,有时混入了 Markdown 格式。specToComponent 把 LLM 的「不可靠输出」变成「确定性编译」:spec 是类型化、结构化的中间表示,specToComponent 是纯函数,给定相同输入永远输出相同 JSX,把 LLM 的抖动挡在中间层之外。更关键的是,spec 是一棵「可定位」的树,这让 #1.2 路径引用、SpecEditor 的 Inspector 字段列表、历史归档与导出都有了落点。
Q2:增量调整(adjust)和页面内编辑(SpecEditor)有什么区别?
adjust 走 LLM diff 路径:读取已有 spec.json,用 expandRefs 把提示词里的 #1.2 展开成节点描述,再让 LLM 输出 diff 后的完整 spec——慢、贵,且输出不可预测。SpecEditor 走纯本地路径:在预览里点组件、Inspector 改字段、Save 后只跑一次 specToComponent 重编译,不经过 LLM,快且确定。两者在数据模型上尚未真正统一:adjust 依赖 LLM 理解「只改要求的部分」,而 SpecEditor 保存直接写 spec.json、不走校验。
Q3:历史记录为什么用文件系统(generated/ 目录)而不是数据库?
generator 是纯函数,spec.json 单独就能重建任意版本;文件系统只需 ls 就能列出所有记录。引入 SQLite 要装依赖、建表、处理迁移、写查询逻辑,对个人工具而言这个复杂度不值得。generated/ 被 gitignore,是因为 prompt.txt 含原始提示词(可能带内部项目名),隐私优先于功能。
Q4:导出的单文件 HTML 为什么不含 prompt?
prompt 里可能有内部项目名或敏感业务信息,不想让接收方看到。src/cli/export.ts 把 spec 内嵌进 window.__UIAGENT_SPEC__,连同 CSS/JS 一起 inline,生成单文件 index.html,部署时换 spec.json 即可改页面、无需重建。副作用是丢失了「这个页面最初是怎么描述出来的」这一信息。
Q5:为什么设计系统不自建 shadcn/ui 而全手写?
需要 light / dark / midnight / aurora 四个主题的 token 驱动切换,每个组件都要响应 Page.theme。shadcn/ui 的 CSS 变量体系与 spec 的 theme prop 难以直接对应,要加一层适配层。自建的设计系统更轻量,且与 UISpec 类型完全对应,src/agent/agent.ts 的 SYSTEM_PROMPT 里明确写明了每个主题的适用场景。
Q6:spec 出错时会怎样?有编译期校验吗?
specToComponent 是纯函数、无副作用,所以一旦 spec.json 字段类型不对、或 className 用了无效 special value,编译阶段不会报错,只是渲染结果偏离预期。编译期校验目前仍是 TODO 里的 P2 任务(「编译期校验 + 自动修复」「LLM 违规降级」),尚未实现。
发表回复