基于 Electron + React + Vite 的 AI UI 生成工作台,用自然语言描述需求,LLM 转为声明式 UI spec 并编译成真实 React 组件实时预览

作者:

🇬🇧 English

起点

我一开始以为,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.htmlprompt.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.tscreateAgent() 被调用,提示词塞进 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.tsadjust.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.htmlprompt.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.txtspec.jsonApp.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 生成链路中,中间表示的质量决定了系统的天花板。

源码导航

源码已开源,默认分支 mainhttps://github.com/erishen/specpulse

项目地址

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.tsSYSTEM_PROMPT 里明确写明了每个主题的适用场景。

Q6:spec 出错时会怎样?有编译期校验吗?

specToComponent 是纯函数、无副作用,所以一旦 spec.json 字段类型不对、或 className 用了无效 special value,编译阶段不会报错,只是渲染结果偏离预期。编译期校验目前仍是 TODO 里的 P2 任务(「编译期校验 + 自动修复」「LLM 违规降级」),尚未实现。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

首页 简历 商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号