用自然语言描述一个页面,浏览器里立刻生成可预览的 HTML。 核心是"决策编排",不是"文本生成":没有任何一次大语言模型调用。
模型在这里只做一件事 —— 选择。所有文案、数值、组件都预先写在候选池里,
模型回答"选哪个 / 要不要 / 打几分",我们负责把答案组装成 DesignSpec,再用纯函数渲染成 HTML。
用户描述
↓
[决策层] 元决策 → 组合决策 → 概率采样 → 递归校验 ← 只有这里是"不确定"的
↓
DesignSpec(纯 JSON:可序列化 / 可 diff / 可缓存)
↓
[渲染层] render(spec) × ThemePack → HTML 字符串 ← 纯同步,可单测
↓
[预览层] iframe srcDoc
npm install
npm run dev # http://localhost:5273
npm test # 122 个单测
npm run build # tsc --noEmit + vite build不配任何环境变量也能跑:没有 API Key 时会自动降级到离线 mock 传输,
并在界面顶部注明降级原因。本地开发和单测默认就走这条路。
复制 .env.example 为 .env.local:
| 变量 | 说明 |
|---|---|
VITE_JEV_TRANSPORT |
http / sdk / mock,默认 http(缺 Key 时自动降级为 mock) |
VITE_JEV_BASE_URL |
服务根地址,http 传输会请求 {BASE}/decide |
VITE_JEV_API_KEY |
见下方警告 |
VITE_JEV_TIMEOUT_MS |
单次决策超时,默认 2000;超时降级到 mock |
VITE_SAMPLING_TEMPERATURE |
采样温度 0.1~2.0 |
三种传输的差别:
http—— 按/decide规格手写fetch。请求体是{ state, questions }, 响应体是{ answers, usage }。适合自建网关 / BFF 转发。sdk—— 走@typesafe-ai/sdk的systemOne()。可选依赖,需要自己装:npm i @typesafe-ai/sdk。未安装时自动降级。mock—— 完全离线。语义重叠 + 关键词倾向 + 页面类型先验,三层都是确定性的、 可按描述复现。它是兜底与开发工具,不代表真实模型能力。
Vite 的 VITE_* 变量是编译期内联的。部署成公开静态站 = 等于公开你的 Key。
本地开发无所谓。要上线,正确做法只有一个:让 VITE_JEV_TRANSPORT=http 指向自建代理,
由代理持有真实 Key,浏览器只带一个自己的会话凭据。
src/jev/client.ts 的传输适配器就是为这件事留的接口位置 —— 加第四种传输,业务代码零改动。
两者是不同性质的东西,靠一个字符串名解耦:
- DesignSpec = 决策产物。回答"这个页面由什么构成":页面类型、用了哪套视觉语言、 有哪些 section、每段用哪个 variant 和 props。它是可序列化、可 diff、可缓存的纯数据。
- ThemePack = 视觉常量表。回答"长什么样":一组颜色 / 圆角 / 字体 / 阴影 / 间距档位。 它不参与决策,是本地注册的静态映射。
三条要点:
- spec 里只存
theme: "ink"这样的名字,不存颜色值。渲染时去注册表查。 所以换主题 = 改一个字符串 → 全页重渲染,0 次决策调用。 SectionSpec.overrides是第三层:在 pack 之上做局部覆盖,只改这一段看到的 CSS 变量。 没有它,"把首屏留白调大一档"就得动整站主题。- 职责边界:模型只产出枚举值(
radius: "lg"、variant: "centered"), 渲染层负责把令牌翻译成 CSS。模型从不输出 class 串或颜色值 —— Tailwind 的冲突类不报错、静默按 CSS 生成顺序决胜,极难排查; 归约成"单值令牌"之后,冲突变成类型错误。
| 模式 | 解决什么 | 状态 |
|---|---|---|
| 元决策 | 维度集合不能写死。"咖啡店"和"数据中台"该决策的维度不一样 | Phase 3 |
| 组合决策 | 速度。全部维度合成一次请求,单次前向传播 | ✅ Phase 1 |
| 概率采样 | 多样性。argmax 出 1 个主方案,从分布独立采样出 N 个变体 |
✅ Phase 2 |
| 递归校验 | 组合决策是并行盲评,可能选出自相矛盾的结构。三道闸门复查,不过则回退 | Phase 3 |
| 方案融合 | 不是第 5 种原语,是元决策的一个应用:问"每个维度从哪个方案继承" | Phase 4 |
| 对话式微调 | 一句话 → 解析出要改哪个维度 → 只重决策那一维 | Phase 4 |
两条容易搞错的纪律:
- 组合决策必须"一次问完"。 实测同一批 10 个判断,1 次 × 10 题约 439ms, 10 次 × 1 题约 4229ms,相差近 10 倍,而且后者会把 state 重复发送 10 遍。 更反直觉的是问题数几乎不影响耗时(10 题 ~439ms,250 题 ~939ms)。 所以正确方向永远是"一次问完",不是"少问几个"或"加缓存层"。
- 递归校验必须是独立的第二轮调用。 在同一个请求里问"评价你刚选出的结构好不好"
是让模型盲评自己 —— 它看不到自己对其它维度的答案,实测会返回没有信息的答案。
正确做法是把第一轮结果放进
state再发第二轮。
它们两两之间至少在两个维度上不同(明暗 / 圆角档 / 字体族 / 密度 / 主色温度):
| pack | 气质 | 明暗 | 圆角 | 字体 | 密度 |
|---|---|---|---|---|---|
paper 纸感暖白 |
米白底 + 陶土色 | light | md | 无衬线 | comfortable |
ink 墨黑终端 |
近黑底 + 冷蓝 | dark | sm | 等宽 | compact |
editorial 杂志衬线 |
奶油底 + 直角 | light | none | 衬线标题 | airy |
moss 苔藓自然 |
浅绿底 + 深绿 | light | lg | 无衬线 | airy |
neon 霓虹夜色 |
近黑紫底 + 荧光薄荷 | dark | lg | 无衬线 | comfortable |
sand 沙丘暖褐 |
沙色底 + 赭石 | light | lg | 无衬线 | comfortable |
slate 冷灰专业 |
冷灰底 + 正蓝 | light | sm | 无衬线 | comfortable |
candy 糖果明亮 |
粉调底 + 品红 | light | lg | 无衬线 | comfortable |
加一套主题只需要往 THEME_PACKS 里加一条 —— 维度表、选择器、UI 标签会自动跟上:
dimensions.ts里theme维度的 criteria 从 pack 注册表派生(THEME_CHOICE_CRITERIA)。 手写这份 criteria 的后果是"新主题永远不会被选中"——而且不报任何错,只是那套主题永远出不来。- pack 里新增的
hint字段就是喂给决策层的语义描述。色值对模型是噪声, "深底浅字的终端感、等宽字体、锐利小圆角"才是它能判断的东西。
Phase 2 顺带暴露了一个 Phase 1 埋下的真问题:BASE_CSS 里主按钮文字写死了 #fff。
在 ink / neon 这类主色很亮的深色主题下就是白底白字。修法是把它也变成令牌
(--dc-on-primary),并且单测里真的算 WCAG 对比度来钉住它。
decideOnce() 与 assemble() 被拆开,"只发一次请求"就不再依赖调用方的自觉:
decideOnce(prompt) → bundle(含那一次网络调用的结果)
assemble(bundle, {mode:"argmax"}) → 主方案
assemble(bundle, {mode:"sample", seed}) → 第 N 个变体 ← 同步、纯本地
数量的分支只发生在 assemble 那一侧,网络侧根本没有"次数"这个概念。
三件不显眼但必须做对的事:
- 变体[0] 必须是 argmax,不能是第 1 次采样。否则用户看到的头一张图是"随机抽的", 主预览和变体墙之间就断开了。
- 必须去重。 独立采样会撞车,温度越低撞得越狠。撞指纹就换种子重抽(默认最多 6 次), 重抽到头仍撞的如实收下并计入报告,不悄悄少给一个。
- 必须报告"哪些维度根本没变"。 某个维度在分布里概率 0.97,20 次采样它就 20 次都一样。 变体墙看起来在变,其实是另外几个维度在变。死维度不点出来, 「20 个变体」就是一个精心包装的谎言。
界面上的多样性条直接暴露四个数字,全部来自真实采样结果:
| 指标 | 含义 |
|---|---|
| 互不相同 N/20 | 指纹去重后的真实方案数 |
| 变化维度 M/12 | 12 个可采样维度里,真的出现过 ≥2 种取值的有几个 |
| 平均熵 | 各维度归一化熵(H / log k)的均值,1 = 取值均匀铺开 |
| 主题 K 种 | 这一批里出现的不同 ThemePack 数 |
score 型维度("需求具体度")被排除在"死维度"之外 —— 它评价的是用户描述本身,
一次调用只有一个值,在 N 个变体里恒定是结构性的,不是采样没起作用。
把它算进去会冤枉整批方案。右上角每个维度的小徽标后面跟着的是"出现过几种取值",
写着 1 的就是没动过的维度。
指标之外还给一句话结论(advisory):撞车太多、死维度太多、主题压根没动,
三种情况会直接说"把温度拉到 0.8 以上再看",而不是把数字丢给用户自己悟。
缩略图就是真 iframe,和主预览是同一份 HTML 字符串,只是套了一层 transform: scale()。
不是截图(要 canvas、会失真),也不是简化版渲染(两套模板必然"缩略图对了点开不一样")。
- iframe 内部固定按 1280×1200 布局 = 桌面端首屏。 缩放的是整个 iframe,不是它内部的字号 —— 后者会让媒体查询按小宽度生效, 看到的是手机版布局,那是完全不同的信息。
- 进视野才挂 iframe(
IntersectionObserver)。50 个变体全部挂载会让"生成"这一下卡住。 pointer-events: none:点缩略图由外层统一接管,否则点击会变成"聚焦到生成页内部"。- 条带 / 网格墙两种模式,靠
flex-1互换空间,不写死谁高谁低。
src/
├── jev/ 决策层(唯一"不确定"的地方)
│ ├── client.ts 传输适配器 http | sdk | mock + 超时 + 降级链
│ ├── primitives.ts choice / noul / score 类型、构造器、收窄与兜底
│ ├── sampling.ts 温度归一化、argmax、独立采样、确定性 RNG
│ ├── cache.ts 决策缓存(键 = hash(state + questions),稳定序列化)
│ └── mock.ts 离线 MockJev:语义重叠 + 关键词倾向 + 页面类型先验
├── orchestrator/ 编排层("怎么问、问几轮、怎么回退")
│ ├── dimensions.ts 维度表(13 个维度,含 theme;theme 的候选从 pack 派生)
│ ├── combinedDecision.ts ★ 主链路:decideOnce → assemble → 渲染
│ ├── variants.ts ★ Phase 2:一次决策 + N 次采样 + 去重 + 多样性报告
│ ├── metaDecision.ts Phase 3:决定"这次从哪些维度决策"
│ ├── recursive.ts Phase 3:三道闸门 + 最多 2 层回退
│ ├── fusion.ts Phase 4:多方案融合
│ └── refine.ts Phase 4:对话式微调
├── spec/ 数据层
│ ├── types.ts DesignSpec / SectionSpec / DecisionRecord
│ ├── blueprint.ts 页面蓝图注册表(5 类页面的文案候选池)+ 主题名词提取
│ └── serialize.ts 序列化 / 反序列化 / 结构指纹(去重靠它)
├── theme/
│ ├── packs.ts 8 套 ThemePack + hint + 令牌 → CSS 变量映射
│ └── override.ts 局部覆盖:applyOverride / overrideVars / styleString
├── components/ 渲染层(**返回 HTML 字符串的纯函数,不是 React 组件**)
│ ├── types.ts 组件调用契约 ComponentContext
│ ├── Navbar.ts Hero.ts Footer.ts
│ └── registry.ts 组件名 → 渲染函数
├── renderer/
│ ├── render.ts DesignSpec → 完整 HTML 文档;renderSectionAt 支持分段重渲染
│ └── sanitize.ts 转义 + 产物安全检查闸门
├── ui/ 工作台界面(React + Tailwind)
│ ├── InputBox.tsx PreviewFrame.tsx DimensionPanel.tsx
│ └── VariantGrid.tsx ★ Phase 2:缩略图墙 + 多样性条
└── App.tsx
与原始规格的几处偏差,都是刻意的:
components/*用.ts而不是.tsx—— 它们返回 HTML 字符串,不含 JSX。- 多出
jev/mock.ts、orchestrator/dimensions.ts、orchestrator/variants.ts、components/types.ts四个文件:mock 是离线降级的落点,维度表需要一个能被 Phase 3 复用的声明式形状,变体批量必须独立成模块(否则"20 个变体只发一次请求"会散在 UI 里), 组件契约单独成文件是为了不把渲染器和 spec 类型耦在一起。
| Phase | 内容 | 状态 |
|---|---|---|
| 1 | Jev client + 组合决策 + 单一主题 + 3 个组件 + iframe 预览 | ✅ 完成 |
| 2 | 8 套 ThemePack + 概率采样出 20 变体 + 缩略图墙 | ✅ 完成 |
| 3 | 元决策 + 递归校验 + 决策缓存(缓存已提前落地) | 待开始 |
| 4 | 方案融合 + 局部微调面板 + 对话式微调 | 待开始 |
| 5 | 1000 变体 + zip 导出 | 待开始 |
实时生成(debounce 300ms)、手动生成、从分布采样换一版、 生成 12/20/50 个变体、缩略图墙 / 网格墙切换、点缩略图切主预览、 采样温度滑块(0.1~2.0)、桌面/移动预览、 决策面板(取值 + 完整概率分布 + 置信度 + 无区分度标记)+ 多样性报告。
未实现(属后续 Phase):方案融合、局部微调滑块、对话式微调、批量导出。
orchestrator/ 下的四个文件已经写好契约与实现要点,函数体是明确的未实现标记。
- 拖温度滑块会把变体墙清空。 实时生成的 debounce effect 依赖了
run, 而run依赖temperature—— 拖滑块 →run重建 → effect 重跑 → 300ms 后 自动跑了一次 argmax → 顺手清空了变体集。 修法不是加个 guard,而是把"墙是否还有效"变成推导:墙里带着产生它的那句话 (spec.meta.prompt),描述一变自动失效。这样"什么时候该清空"这个判断从三处收敛到零处。 这个 bug 是浏览器验收脚本抓出来的,读代码看不出来。 - 网格墙的缩略图被压成 73.6px 高。 卡片在固定高度的网格里由
grid-auto-rows: auto定行高,行被均分到容器高度上,于是每张缩略图只露出最上面一条导航栏。min-h-0/h-full都救不了,因为问题出在轨道尺寸而不是元素尺寸。 修法:卡片给显式高度 + 容器grid-auto-rows: max-content。
界面上直接可读,不需要翻日志:
decide 调用 N—— 一次生成必须只 +1,一次批量生成 20 个变体也必须只 +1。 单调递增得比 1 快就是组合决策退化了。这是"没退化成 20 次请求"的现场证据。- 耗时拆成四段:
jev/assemble/render/total。 预期结构是jev吃掉几乎全部,另两个在小数点后 —— 看到这个结构才对。 批量多一行:批量 20 个 · 网络 1 次 · 采样 x ms · 渲染 y ms · 单个均 z ms · 合计 w ms。 - 多样性条:互不相同 / 变化维度 / 平均熵 / 主题种数,外加上面那行一句话结论。
- 缓存与降级状态:命中缓存时
jev ≈ 0ms、网络 0 次;降级时标明原因。 - 决策面板:每个维度的完整概率分布条 + 置信度。 分布接近均匀的维度会被标成「无区分度」—— 那种情况下用户看到的"选择"其实是噪声。
缩略图卡片带 data-variant-index / data-theme / data-page-type,
多样性条带 data-diversity(JSON)。这些是给验收脚本的稳定钩子,不是样式 class。
真实 Jev 的实测基线是温态 365–515ms、冷态 981–1013ms,不是 200ms。 所以验收口径按这个报,不做美化:
| 场景 | 目标 | 实测(mock 传输,浏览器内) |
|---|---|---|
| 温态单次决策 | ≈ 400ms | jev 65–70ms(那是 mock 的人为延迟) |
| 端到端(敲完最后一个字 → 预览更新) | — | ≈ 380–450ms,其中 300ms 是 debounce |
| 缓存命中 | ≈ 0ms,0 token | jev 0.2ms / total 0.2ms,网络 0 次 |
| 20 个变体 | 1 次决策 + 20 次本地采样 | 墙渲染完成 103–136ms,其中采样 0.8–1.4ms + 渲染 1.3–2.3ms |
| 1000 个变体 | 1 次决策 + 1000 次本地采样 | Phase 5 |
20 个变体的决策部分只花一次调用的钱。上表里的 100 多毫秒是 React 挂 20 个卡片 + 20 个 iframe 的 DOM 成本,不是决策成本 —— 两者的量级完全不同,混在一起报就会得出 "变体墙很慢"的错误结论。
- 13 个维度是硬编码的。 元决策是 Phase 3。
- 只有 3 个组件,所以页面必然是 导航 + 首屏 + 页脚。 这既是当前范围,也是"候选内容必须来自某处"这条约束的直接后果。
- 采样时 noul 做伯努利,score 不采样(连续量没有"采样"的语义)。
- 各维度独立采样,不做一致性校验。 所以 20 个变体里必然出现几个"不搭"的组合 (比如博客页配一个强转化导航)。这不是 bug,正是 Phase 3 递归校验要收拾的东西。
- 降级链止于 mock,没有做"用过期缓存兜底"的细分策略。
这是硬约束,不是偏好。所有文字都来自 spec/blueprint.ts 里按页面类型写好的候选池,
主题名词是从用户描述里剥出来的(去掉指令词与页面后缀),不是生成出来的。
如果哪一天想支持任意主题的内容,会撞上"候选内容从哪来"这个原始前提 ——
那时应该先决定是接真实数据源、本地合成、还是放弃这条约束,而不是偷偷加一个模型调用。