,与 page.domain 同名。
`.trim();
/**
* 视觉规范指针:业务语义规范由 Host 注入的组件目录提供;页面外观由
* BlueprintCompiler 的封闭 Tailwind 词汇确定性输出。
*
* 定位(用户定调):美观是组件目录自带的知识,不该由调用方打提示词补丁——
* 任何 AI 读了组件目录就能产出美观页面。本常量只负责把模型的注意力指过去。
*/
const VISUAL_RULES_POINTER = `
## 视觉规范(产物必须是精致的成品页,不是裸骨架)
- 业务语义与选型遵守下方「组件目录」;页面骨架、标题区、区块卡片、响应式栅格、
文本层次和色板由 BlueprintCompiler 统一生成,禁止试图输出 html、CSS 或 class;
- 骨架只选 page / shell / cover,栅格只使用 blueprint schema 中的封闭值。
`.trim();
/** 完整统一声明 few-shot:模型照抄结构比读十条规则更可靠。 */
const WORKED_EXAMPLE = `
## 完整示例(照此结构产出,注意 HTML 与唯一声明彼此对账)
html:
uiYaml:
{{
page:
domain: SalesPage
initProps:
page.title: 本月经营概览
page.kpis:
- { label: 本月成交额, value: "¥862,415.30", delta: 12.4 }
- { label: 已完成订单, value: "1,286", delta: 8.1 }
page.orderBranches:
- { key: paid, label: 已支付, status_text: 已完成 }
- { key: shipped, label: 已发货, status_text: 运输中 }
ui:
page_title: page.title
kpi_board:
ui: { title: 本月经营概览, kpis: page.kpis }
order_lifecycle:
ui: { order_no: ORD-20260801, amount_text: "¥1,280.00", buyer_text: 陈静, branches: page.orderBranches }
}}
eventYaml: ""
要点:page_title/kpi_board/order_lifecycle 都在 HTML;组件实例各有同名顶层块;
uiYaml 用到的 page.title/kpis/orderBranches 在 page.initProps 里全部有值;
事件如果需要就写进 page.event.on 或实例块 event.on,eventYaml 仍为空字符串;
YAML 纪律:含逗号 / 冒号 / ¥ / % 等符号的文案值一律加双引号(如 amount: "¥1,280.00"),
flow 式 { } 里不带引号的逗号会把一个值劈成两个键,直接导致 YAML 解析失败。
`.trim();
/** 输出前自检:把闸门规则前移到模型自己身上,减少回喂轮次 */
const SELF_CHECK = `
## 输出前逐条自检(任一条不满足就地修正后再输出)
1. html 最外层是
且与 page.domain 同名?
2. uiYaml 是否以 {{ 开头、}} 收口,且只有一个 page 块?
3. uiYaml 里每个 page.* 路径都能在 page.initProps 找到对应值?
4. 用到的内容组件是否都属于目录的 solution/page 层,且组件名、prop 名、emit 名逐字存在?
5. 每个 page.ui 锚点与组件块 id 都能在 html 找到同名 data-soma-id?
6. 数据是否非空、逼真、条数合理(列表 3~5 条)?
7. eventYaml 是否严格为空,事件全部内嵌且 Pipeline 合法?
8. 逐条比对组件目录「版式与视觉规范」章节末尾的"交付前自查":真实页面骨架 / 标题区 /
响应式栅格 / 文本层次 / 语气色板 / 中文文案——全部满足?
9. 输出是单个严格 JSON 对象,没有 markdown 栅栏、没有额外解释文字?
`.trim();
/**
* 蓝图模式提示词(默认路径):模型只输出"页面由哪些组件按什么顺序组成、各自绑什么数据",
* 统一声明由 BlueprintCompiler 确定性编译。刻意极短——模型要写的东西越少,失败面越小。
*/
/** visualize + 蓝图:如何把"结果内容"映射成区块(选型速查,极简) */
const VISUALIZE_CONTENT_RULE = `
# 输入是"要展示的结果内容",不是页面需求
先判断内容所属业务与任务,再从目录挑 solution/page 业务组件;内容里含多种业务任务就分区。
不得使用 StatCard/DataTable/Chart 等 atom/composite 基础件拼页面。仅补充说明可用 text 区块。
数值与文案忠实取自内容原文;内容里没有的数据才自行编造逼真样例。
`.trim();
/** 蓝图修订:在现有蓝图上做最小增量修改 */
const BLUEPRINT_REVISE_PRIMER = `
# 任务:修订现有页面蓝图
是这个页面现在的蓝图, 是用户的修改要求。
输出**完整的新蓝图 JSON**(不是差量),规则:
- 只改指令要求的部分;**其余区块必须逐字保留**(component / props / 数据一个字符都不能变);
- 新增区块就往 blocks 里插;删除就移除;调整顺序就重排;
- notes 首行必须申报改动范围:
revise_scope {"touched":["被改区块的 component 名"],"added":["新增的"],"removed":["删除的"]}
grid 分组可写它的身份 "grid:<分组 title>"(无 title 时写 "grid:<在 blocks 中的零基索引>"),
也可把该组内全部新增/删除 component 名逐一写入对应数组;
程序会拿它和底稿逐块比对,**范围外的任何差异都会被判为漂移并打回重做**。
`.trim();
const BLUEPRINT_PRIMER = `
# 输出契约:页面蓝图(不是页面代码)
你不需要写 html / yaml / 事件——那是渲染引擎的活。你只需要**组织**:
挑组件、排顺序、填数据。输出单个严格 JSON 对象(禁止 markdown 栅栏、禁止解释文字):
{
"title": "页面主标题",
"slug": "semantic-page-slug",
"subtitle": "副标题(可选)",
"skeleton": "page",
"maxWidth": "default",
"blocks": [
{ "component": "目录中 solution/page 层的业务组件名", "props": { "契约中的prop": "业务数据" } },
{ "group": "grid", "columns": 3, "title": "业务分区", "blocks": [ …并排的业务组件区块… ] },
{ "component": "${TEXT_BLOCK}", "title": "说明", "paragraphs": ["第一段", "第二段"] }
]
}
规则:
- slug 是页面的独立公开路径名,必须根据**本次用户需求**生成语义化英文小写 slug,只能含字母、数字和连字符;
发布后直接通过 / 访问。例如“我的工作台”用 "myworkspace",“供应链驾驶舱”用 "supply-chain-dashboard";
不要把示例 slug、generated-page 或其他固定占位名当作所有页面的默认值;每个 fresh 页面都要有自己的页面身份,
同一页面 revise 才沿用原 slug。
不得使用 api / soma / pages / _next 等系统保留名。
- **最终页面只放用户要求的业务内容**。发布按钮、Registry 状态、页面蓝图、组件目录、Soma 模块、
canonical DSL、服务端编译、渲染冒烟、质量闸门等属于生成 Workbench,绝不能作为“说明生成成功”的
区块、指标、副标题或文案写进页面。只有原始需求本身明确是发布/DevOps 管理页面时,才可选择发布业务组件;
即使如此也只能展示用户的发布业务数据,不能展示本次页面的生成和交付过程。
- 顶层 skeleton 只能是 page / shell / cover;中后台选 shell,普通内容页选 page,营销首屏选 cover;
maxWidth 只能是 narrow / default / wide / flush。不要输出任何自造骨架名。
- component 必须逐字取自下方组件目录的 solution/page 层业务组件;props 的键必须是该组件目录里声明过的 prop;
禁止用 atom/composite 在页面层拼业务区块;基础件只能封装在业务组件内部;
想放纯文字段落用 "${TEXT_BLOCK}" 区块(paragraphs 数组)。
- block.title 是编译器使用的区块标题元数据,不是组件 prop;不要把它复制进 props。
props 的每一个键都必须在对应组件详情的 prop 表中逐字存在,未声明的键必须删除。
- Tailwind 直出页面不支持组件 emit;不要输出 feedback 或自造 click / row_click。
- 组件详情里标为“必填”的插槽无法由蓝图 blocks 表达,因此不要选择这类组件;
原生 Tailwind 页面骨架、区块卡片与响应式容器由编译器自动生成,不要把它们写进 blocks。
- **数据直接写进 props**:内容里有具体数值就忠实照搬;内容里没有就自行编一组逼真的
演示数据(金额带两位小数、百分比 90~99、日期近 7 日、列表 3~5 条),严禁空数组或"暂无数据"。
- 表格类组件请显式给 columns 并用**中文 label**,不要让表头露出英文字段名。
- 并排展示用 { "group": "grid", "columns": 1~6, "pattern": "auto-grid", "gap": "md", "blocks": [...] };
pattern / gap / itemMin / align / ratio 只能取目录规范的封闭词汇,其余按顺序纵向排列。
- 版式(页面壳、卡片、栅格、标题层次)由编译器统一生成,你不要操心样式与 class。
- 区块数量控制在 6 个以内,数据量够表达即可——简洁的蓝图比冗长的更好。
`.trim();
/**
* 取数规则(只在调用方给了 dataApis 时注入)。
*
* 写法上刻意只给模型两件事:一张清单、一个引用语法。接口怎么调、鉴权怎么带、
* 失败怎么兜底,全不是它的事——那些在运行时由数据对象层与宿主后端负责。
*/
const DATA_BINDING_PRIMER = `
# 取数规则(本次生成可以绑真实数据)
页面可以绑后端接口,而不是只写演示数据。两步:
1. 顶层加 "data" 段,声明这一页要哪些数据对象:
"data": [{ "object": "order", "api": "/api/orders/list" }]
2. 区块 props 用 "$data.<对象>.<字段>" 引用它:
{ "component": "DataTable", "props": { "rows": "$data.order.items" } }
铁律:
- api **只能逐字**取自下方「可用数据接口」清单;清单里没有的路径一律不许写,
写了会被闸门拒收(注册表是取数白名单)。
- 字段只用清单给出的字段字典里的名字;表头 label 用字典里的中文名。
- 页面不需要真实数据(纯说明页、营销页)就**别写 data 段**——没有它一切照旧。
- 绑了实时数据的 props 不要再塞演示数据:两者二选一,"$data.…" 或字面量。
- 列表要分页 / 排序 / 筛选 / 跨页全选时,给该数据对象加 "window": { "size": 50, "sort": [{ "field": "…", "order": "desc" }] },
并让列表组件用 "data_object": "<对象>" 接上(DataTable / QueryConsole 支持);此时不要再给它 rows / sort_key / filters——
这些都由窗口引擎持有,后端按字典哈希校验意图。sort 的 field 只能用字典里的字段名。
`.trim();
const PREVIEW_PRIMER = `
# 预览模式附加规则
- 【硬规则】html 最外层必须是 …
,XxxPage 与 uiYaml 的 page.domain 完全一致;
- 【硬规则】输出必须是单个严格 JSON 对象:禁止 markdown 代码栅栏(\`\`\`)、禁止任何解释文字,html/uiYaml 等多行内容用 \\n 转义写在字符串字段里;
- 【硬规则】uiYaml 里出现的每一个 page.* 绑定路径都必须同时出现在 page.initProps 中;
- page 块必须包含 initProps:为每个被绑定的 page.* 字段提供真实感演示数据
(如 initProps: { page.notices: ["满 99 包邮", "新人立减 10 元"], page.todos: [...] });
- 事件只使用组件 emit 与 UI_INTERACT 显隐并内嵌声明,不引用 page.方法(预览环境没有 Domain 代码);
- 列表演示数据 3~5 条,字段齐全,贴近需求的业务语境。
${ZERO_DOMAIN_RULES}
${VISUAL_RULES_POINTER}
${WORKED_EXAMPLE}
${SELF_CHECK}
`.trim();
const VISUALIZE_PRIMER = `
# 可视化渲染模式附加规则(visualize)
- 【硬规则】html 最外层必须是 …
,XxxPage 与 uiYaml 的 page.domain 完全一致;
- 【硬规则】输出必须是单个严格 JSON 对象:禁止 markdown 代码栅栏(\`\`\`)、禁止任何解释文字,html/uiYaml 等多行内容用 \\n 转义写在字符串字段里;
- 【硬规则】uiYaml 里出现的每一个 page.* 绑定路径都必须同时出现在 page.initProps 中;
- 你收到的 是"需要展示的结果内容",不是页面需求——可能是叙述文本、
半结构化清单、JSON 数据或混合体。你的任务是为它挑选最贴切的展示形态,
产出可直接渲染的页面声明。
- 先判断内容所属业务与任务,再从目录挑对应的 solution/page 业务组件;同一份内容含多种
业务任务时,用多个业务组件分区展示。不得退回 StatCard/DataTable/Chart 等基础件拼页面;
- 只有补充说明性文字可用 text 区块,页面主体必须由业务组件承载;
- 数据必须内嵌进声明:page.initProps 携带全部展示数据
(initProps 是预览演示数据通道,零 Domain 代码即可完整渲染);
数据分层纪律:内容里**有**具体数值/文案时忠实提取,禁止改写;内容里**没有**可提取的
具体数据时(例如只有一句话诉求),必须自行补一组逼真的演示数据填满组件——
金额带两位小数、百分比落在 90~99、日期取最近 7 日、列表 3~5 条、字段齐全——
并在 notes 首行注明"演示数据"。**严禁渲染空组件、空数组或"暂无数据"占位文案**;
- 事件只使用组件 emit 与 UI_INTERACT 显隐并内嵌声明,不引用 page.方法(渲染环境没有 Domain 代码);
- 仍然只能使用组件目录中实际存在的组件;上面点到的组件若不在目录中,
退回基础锚点排版展示对应内容。
${ZERO_DOMAIN_RULES}
${VISUAL_RULES_POINTER}
${WORKED_EXAMPLE}
${SELF_CHECK}
`.trim();
/**
* 会话式改页模式(revise,可行性评审 B4)附加规则。
*
* 核心约束是"最小增量修改 + 未提及部分逐字保留":修改幅度失控(整页重写、
* 锚点漂移、数据丢失)时,闸门的锚点/绑定/initProps 对账会把产物拦下回喂——
* 提示词负责让模型一次改到位,闸门负责兜住改崩的产物。
*/
const REVISE_PRIMER = `
# 会话式修订模式附加规则(revise)
- 【硬规则】html 最外层必须是 …
,XxxPage 与 page.domain 完全一致;
- 【硬规则】输出必须是单个严格 JSON 对象:禁止 markdown 代码栅栏(\`\`\`)、禁止任何解释文字,html/uiYaml 等多行内容用 \\n 转义写在字符串字段里;
- 【硬规则】uiYaml 里出现的每一个 page.* 绑定路径都必须同时出现在 page.initProps 中;
- 你收到的是一份**现有页面统一声明**()和一条**修改指令**()。
你的任务不是重新设计页面,而是在现有声明上做**最小增量修改**:
- 只改指令明确要求的部分;指令未提及的部分**逐字保留**——
锚点 data-soma-id、data-soma-host / page.domain、ui 绑定路径、
initProps 里的数据、事件订阅一律原样照抄,禁止重命名、禁止漂移、
禁止顺手"优化"或改写文案数值;
- 新增元素才允许引入新锚点 id(snake_case,不得与现有锚点重名)与新的
page.* 字段(必须同步补进 initProps);
- 删除仅限指令明确要求删除的部分,删除锚点时同步删除其 ui / 事件条目;
- 修改后 HTML 与统一声明仍须彼此对账:每个 ui 锚点在 html 中存在、每个 page.* 路径
在 initProps 中有值、事件只引用存在的锚点与目录内的 emit。
- 输出是**完整的新声明**(html + canonical uiYaml + 空 eventYaml),不是差量补丁;
产物与 preview 同构:initProps 自带全部数据,零 Domain 即可直接渲染。
- 事件只使用组件 emit 与 UI_INTERACT 显隐,不引用 page.方法(渲染环境没有 Domain 代码)。
- 【硬规则】notes 的**第一行**必须输出本次改动范围,格式固定:
revise_scope {"touched":["被修改的锚点id"],"added":["新增锚点id"],"removed":["删除的锚点id"]}
程序会拿它与基线逐项对账:**范围外的任何差异都会被判为漂移并打回重做**。
没改就写空数组;改了却不申报,比多申报更严重。第二行起才写给人看的说明。
${ZERO_DOMAIN_RULES}
${VISUAL_RULES_POINTER}
${SELF_CHECK}
`.trim();
/**
* 质量闸门的三段逐项判定(与 issues 合并清单同源,但不合并):
* ①业务组件层级 ②SOMA 规范声明 ③渲染冒烟。issues 只回答"过没过",
* checks 回答"具体是哪一段没过"——供 soma-web 发布就绪卡逐项展示。
*/
export interface SomaGateChecks {
/** ① businessComponentPolicyIssues:页面只能编排 solution/page 业务组件 */
businessComponentLayer: boolean;
/** ② validatePageDeclarations:SOMA DSL 语法与绑定对账 */
somaDeclaration: boolean;
/**
* ③ smokeRenderDeclaration:零 Domain 产物能否真的渲染出来。
* null = 没跑到这一步——①②未全过,或 mode === 'standard'
* (standard 产物按设计要配人写 Domain 供数,空渲染不是缺陷,本来就不跑)。
*/
renderSmoke: boolean | null;
}
export interface GenerateSomaPageResult extends PageDeclarations {
notes?: string;
/** 质量闸门残留问题;空数组才允许落盘渲染 */
issues: string[];
/**
* 质量闸门逐段判定(可选,加法式扩展字段,向后兼容)。
* 只记录**最后一次尝试**(决定 issues/attempts/repairs 的那一轮)的三段结果,
* 与 issues 语义对齐:
* - issues.length === 0(整体过闸)时三项都有定论:renderSmoke 在 standard 模式下
* 设计上不跑,记 null——"这一步不适用",不是"没测出来";
* - 闸门跑了但没全过时,businessComponentLayer / somaDeclaration 各自反映真实结果,
* renderSmoke 在①②未全过或 standard 模式下记 null(未跑到这一步);
* - 上游异常、输出无法解析、蓝图编译失败等发生在闸门校验**之前**的中途失败分支,
* 三段闸门根本没跑到,此字段整体不出现(undefined)——不编造"三段都失败"。
*/
checks?: SomaGateChecks;
attempts: number;
/**
* 蓝图路径的产物原件(模型真正输出的东西)。调用方应随声明一并保存:
* 下一次 revise 传回来当底稿,改 JSON 比改整页文本可靠得多。
*/
blueprint?: SomaPageBlueprint;
/** 服务端编译后的浏览器运行时资产;issues 为空时必有。 */
compiled?: {
sourceMarkup?: 'legacy-html' | 'component-tree';
html?: string;
uiConfig: unknown;
eventConfig: unknown;
triggerConfig: unknown;
timerConfig: unknown;
styleConfig?: unknown;
dataConfig?: unknown;
};
meta: {
model: string;
durationMs: number;
usage: TokenUsage;
/**
* 最终采用产物上触发的确定性兜底修补清单(空数组 = 提示词一次到位)。
* 兜底率是提示词质量的度量:实弹里应趋近于零。
*/
repairs: string[];
};
}
/**
* 调用方给的编译器文案语言:不给(undefined)即缺省 zh-CN,其余取值必须落在 BLUEPRINT_LOCALES 内。
*
* locale 是调用方参数,不是模型产物:非法值在入口按输入非法拒绝,不能混进蓝图问题清单回喂模型。
* 编程接口与 HTTP 服务共用这一把尺子,闭集只在 BLUEPRINT_LOCALES 一处。
*/
export function assertGenerateLocale(locale: unknown): BlueprintLocale | undefined {
if (locale === undefined) return undefined;
if (typeof locale !== 'string' || !(BLUEPRINT_LOCALES as readonly string[]).includes(locale)) {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, `locale 只能是 ${BLUEPRINT_LOCALES.join(' / ')}`);
}
return locale as BlueprintLocale;
}
export async function generateSomaPage(
input: GenerateSomaPageInput,
configOverride?: AiRuntimeConfig,
): Promise {
const mode = input.mode ?? 'standard';
const requirement = input.requirement?.trim() ?? '';
const content = input.content?.trim() ?? '';
const instruction = input.instruction?.trim() ?? '';
const base = input.baseDeclaration;
if (mode === 'visualize') {
// visualize 模式:content 必填,requirement 退化为可选上下文
if (!content) {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'visualize 模式下 content 不能为空');
}
if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_BYTES) {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'content 超过 256KB 上限');
}
} else if (mode === 'revise') {
// revise 模式:底稿二选一(蓝图首选/声明回退)+ instruction 必填
const blueprintBase = input.baseBlueprint;
const hasBlueprintBase = Boolean(blueprintBase)
&& Array.isArray(blueprintBase?.blocks)
&& blueprintBase!.blocks.length > 0;
const hasDeclarationBase = Boolean(base)
&& typeof base?.html === 'string' && Boolean(base.html.trim())
&& typeof base?.uiYaml === 'string' && Boolean(base.uiYaml.trim());
if (!hasBlueprintBase && !hasDeclarationBase) {
throw new AiApiError(
AiErrorCodes.AI_INPUT_INVALID,
'revise 模式需要底稿:baseBlueprint(首选,{ title, blocks })或 baseDeclaration(html/uiYaml)',
);
}
if (!instruction) {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'revise 模式下 instruction(修改指令)不能为空');
}
if (Buffer.byteLength(instruction, 'utf-8') > MAX_REQUIREMENT_BYTES) {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'instruction 超过 64KB 上限');
}
// 底稿体积上限(蓝图与声明各自计量,二选一)
const baseBytes = hasBlueprintBase
? Buffer.byteLength(JSON.stringify(blueprintBase), 'utf-8')
: Buffer.byteLength(
base!.html + base!.uiYaml,
'utf-8',
);
if (baseBytes > MAX_CONTENT_BYTES) {
throw new AiApiError(
AiErrorCodes.AI_INPUT_INVALID,
`${hasBlueprintBase ? 'baseBlueprint' : 'baseDeclaration'} 超过 256KB 上限`,
);
}
} else if (!requirement) {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'requirement 不能为空');
}
if (Buffer.byteLength(requirement, 'utf-8') > MAX_REQUIREMENT_BYTES) {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'requirement 超过 64KB 上限');
}
if (Buffer.byteLength(input.catalogMarkdown ?? '', 'utf-8') > SOURCE_CATALOG_MAX_BYTES) {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, '组件目录源超过 1MB 上限');
}
assertGenerateLocale(input.locale);
if (
mode !== 'revise'
&& input.output === 'declaration'
&& input.catalog.some((entry) => entry.geometry !== undefined)
) {
throw new AiApiError(
AiErrorCodes.AI_INPUT_INVALID,
'当前组件目录已启用结构化页面生成;fresh 生成必须走 blueprint,由编译器管理原生 Tailwind 页面',
);
}
// revise 的输入是现成声明,蓝图无从对账,恒走 declaration 路径
const reviseOnBlueprint = mode === 'revise' && Boolean(input.baseBlueprint);
// 蓝图底稿只迁移这一次:此后组件计数、目录保留项、提示词、slug、漂移对账与空输出兜底
// 一律用迁移后的底稿——模型逐字照抄的就是它,对账基线与兜底编译也必须是它。
const baseMigration = reviseOnBlueprint ? migrateLegacyBlueprintBlocks(input.baseBlueprint!) : undefined;
const baseBlueprint = baseMigration?.blueprint;
const migrationRepairs = baseMigration?.repairs ?? [];
const outputStyle = mode === 'revise'
? (reviseOnBlueprint ? 'blueprint' : 'declaration')
: (input.output ?? 'blueprint');
const useBlueprint = outputStyle === 'blueprint';
const legacyAllowance = mode === 'revise'
? (reviseOnBlueprint
? componentCountsFromBlueprint(baseBlueprint)
: componentCountsFromHtml(base?.html))
: new Map();
const referenced = collectReferencedComponentNames({
catalog: input.catalog,
html: mode === 'revise' ? base?.html : undefined,
blueprint: baseBlueprint,
});
const generationCatalog = selectPageGenerationCatalog(input.catalog, referenced);
// 先按业务层裁目录,再做蓝图兼容性裁剪。顺序不能反:atom/composite 即使能放进
// blocks,也不是页面模型的候选;骨架和卡片由编译器从封闭 Tailwind 词汇自动生成。
const promptCatalog = useBlueprint
? generationCatalog.filter(isBlueprintBlockCompatible)
: generationCatalog;
if (promptCatalog.length === 0) {
throw new AiApiError(
AiErrorCodes.AI_INPUT_INVALID,
'组件目录没有可用于页面生成的 solution/page 业务组件',
);
}
const businessFullCatalog = assembleSubset(
input.catalogMarkdown,
promptCatalog,
promptCatalog.map((entry) => entry.name),
);
if (!businessFullCatalog) {
throw new AiApiError(
AiErrorCodes.AI_INPUT_INVALID,
'组件目录 Markdown 与业务组件结构化目录不一致,无法建立安全生成候选集',
);
}
const compilerCatalog = input.catalog.filter((entry) =>
isBusinessPageComponent(entry) || legacyAllowance.has(entry.name));
const config = configOverride ?? resolveAiRuntimeConfig();
const client = new AiStructuredOutputClient(config);
const semaphore = getAiSemaphore(config.maxConcurrency);
const maxAttempts = resolveMaxAttempts(config);
// 超时预算 = 单次上游超时 × 尝试次数:config.timeoutMs 语义是"单次模型调用"的上限,
// 自修复回喂还要再打模型,整体预算必须按尝试次数放大,否则真模型
// 后续尝试还没跑完就被服务端提前掐断。
const budget = createAiTimeoutBudget(config.timeoutMs * maxAttempts, input.signal);
const startedAt = performance.now();
// 进度旁路:任务号缺省/非法时 openProgress 返回空写入器,下面无需任何分支判断
const progress = openProgress(input.taskId, maxAttempts);
/**
* 第一段:先问"用哪些组件",再只把这几个的语法喂给第二段。
*
* 为什么分层(实测账):全量目录入 prompt 时单次 inputTokens ≈ 29,000,
* 而一个页面通常只用 5~10 个组件。更要命的是让模型**同时**干两件事——
* 从几十个候选里认组件 + 按语法写声明;思考模式下这一步的代价看得见:
* 输出 token 中位 4,724(非思考只要 732,产物体量却相当),多出来的
* 绝大部分是"该用哪个组件"的推敲。
*
* 三条保守规则:失败且业务目录仍塞得进 prompt 才回退业务全量、页面骨架由编译器生成、
* 基础契约闸门仍看注册全量 entries(否则误判组件不存在)。
*/
let catalogMarkdown = businessFullCatalog.markdown;
let selectionUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
let selectedComponents: string[] = [];
const retrievalText = requirement || content || instruction;
const retrieval = resolveCatalogRetrievalPlan({
mode,
override: process.env.SOMA_AI_LAYERED_CATALOG,
entryCount: promptCatalog.length,
catalogMarkdown,
inputText: retrievalText,
});
// 轻索引的领域分片:按需求把"组件名 + 一句话用途"的全表裁到相关领域。
// 裁不动、没命中、或省得不够多时 routedIndex.text 就是全量索引本身——
// 也就是说这一步最坏等于今天的行为,不会引入新的失败面。
const domainOverride = parseCatalogDomainOverride(process.env.SOMA_AI_CATALOG_DOMAINS);
const routedIndex = buildRoutedComponentIndex(promptCatalog, {
routes: resolveCatalogDomainRoutes(promptCatalog, input.catalogDomains),
requirement: retrievalText,
forcedDomains: domainOverride.forced,
disabled: domainOverride.disabled,
keep: referenced,
});
const referencedSet = new Set(referenced);
const fallbackComponentNames = [
...referenced,
...routedIndex.expanded
.filter((name) => !referencedSet.has(name))
.slice(0, MAX_CATALOG_FALLBACK_COMPONENTS),
];
// plan === 'full' 时索引根本不进 prompt,别为一份没用上的东西写日志
if (routedIndex.routed && retrieval.plan !== 'full') {
console.warn(
`[soma-ai][生成链] 领域分片索引:展开 ${routedIndex.domains.join('/')},`
+ `索引压到全量的 ${(routedIndex.ratio * 100).toFixed(0)}%`,
);
}
if (retrieval.plan !== 'full' && retrievalText) {
try {
budget.throwIfExpired();
progress.update({ stage: 'selecting', note: '正在挑选需要的组件' });
const release = await semaphore.acquire(budget.signal);
let picked: string[] = [];
try {
const response = await client.generateObject({
system: '你是 SOMA 业务组件选型助手。只回答需要用到哪些 solution/page 业务组件,不要写任何声明或解释。',
user: buildSelectionPrompt(retrievalText, routedIndex.text),
schema: COMPONENT_SELECTION_SCHEMA,
model: selectionModel(config),
// 只回名字,给 1k 足够;给多了思考模式会顺手把理由也写出来
maxOutputTokens: 1024,
timeoutMs: Math.min(config.timeoutMs, budget.remainingMs()),
signal: budget.signal,
});
selectionUsage = response.usage;
picked = parseSelection(response.object?.components ?? response.object, promptCatalog);
} finally {
release();
}
const subset = assembleSubset(input.catalogMarkdown, promptCatalog, [...referenced, ...picked], {
includeFullIndex: retrieval.plan === 'index',
fullIndexText: routedIndex.text,
});
if (subset) {
catalogMarkdown = subset.markdown;
selectedComponents = subset.picked;
console.warn(
`[soma-ai][生成链] 分层检索:选中 ${subset.picked.length} 个组件,`
+ `目录压到全量的 ${(subset.ratio * 100).toFixed(0)}%`,
);
}
} catch (error) {
// 检索失败:小业务目录回退业务全量;超预算则用底稿 + 路由展开的业务组件兜底。
const overBudget = Buffer.byteLength(businessFullCatalog.markdown, 'utf-8') > PROMPT_CATALOG_MAX_BYTES;
console.warn(
`[soma-ai][生成链] 分层检索失败,${overBudget ? '改用路由/底稿业务组件子集' : '回退业务目录'}:`
+ `${error instanceof Error ? error.message : String(error)}`,
);
if (overBudget) {
const fallback = assembleSubset(
input.catalogMarkdown,
promptCatalog,
fallbackComponentNames,
{ includeFullIndex: true, fullIndexText: routedIndex.text },
);
if (fallback) catalogMarkdown = fallback.markdown;
}
}
}
if (Buffer.byteLength(catalogMarkdown, 'utf-8') > PROMPT_CATALOG_MAX_BYTES) {
const fallback = assembleSubset(
input.catalogMarkdown,
promptCatalog,
fallbackComponentNames,
{ includeFullIndex: true, fullIndexText: routedIndex.text },
);
if (fallback && Buffer.byteLength(fallback.markdown, 'utf-8') <= PROMPT_CATALOG_MAX_BYTES) {
catalogMarkdown = fallback.markdown;
selectedComponents = fallback.picked;
} else {
throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, '组件目录超过 256KB 上限');
}
}
// 取数授权归一化:只认路径合法的条目,清单本身也有上限——
// 它要进 system 提示词,无上限等于把上下文预算交给调用方随手撑爆。
const dataApis = (input.dataApis ?? [])
.filter((api): api is GenerationDataApi =>
Boolean(api) && typeof api.path === 'string' && api.path.startsWith('/'))
.slice(0, MAX_DATA_APIS);
const system = [
mode === 'visualize'
? '你是 SOMA 低代码结果可视化生成器。依据组件目录与 DSL 规则,把结果内容转化为可直接渲染的展示页面声明。'
: mode === 'revise'
? '你是 SOMA 低代码页面修订器。依据组件目录与 DSL 规则,在现有页面声明上按修改指令做最小增量修改,输出完整的新页面声明。'
: '你是 SOMA 低代码页面生成器。依据组件目录与 DSL 规则,把用户需求转化为可直接渲染的页面声明。',
...(useBlueprint
? [
BLUEPRINT_PRIMER,
...(reviseOnBlueprint ? [BLUEPRINT_REVISE_PRIMER] : []),
...(mode === 'visualize' ? [VISUALIZE_CONTENT_RULE] : []),
// 取数能力是调用方按次授权的:没给接口清单就连规则都不注入,
// 免得模型看见"可以绑数据"却无表可绑,凭空发明路径。
...(dataApis.length > 0 ? [DATA_BINDING_PRIMER, renderDataApis(dataApis)] : []),
]
: [
SOMA_DSL_PRIMER,
...(mode === 'preview' ? [PREVIEW_PRIMER] : []),
...(mode === 'visualize' ? [VISUALIZE_PRIMER] : []),
...(mode === 'revise' ? [REVISE_PRIMER] : []),
]),
'# 组件目录',
catalogMarkdown,
].join('\n\n');
const baseUser = mode === 'visualize'
? [
...(requirement
? ['', requirement, '', '']
: []),
'',
content,
'',
].join('\n')
: reviseOnBlueprint
? [
'',
JSON.stringify(baseBlueprint, null, 2),
'',
'',
'',
instruction,
'',
].join('\n')
: mode === 'revise'
? [
'',
'--- html ---',
base!.html,
'--- uiYaml ---',
base!.uiYaml,
'',
'',
'',
instruction,
'',
].join('\n')
: [
'',
requirement,
'',
].join('\n');
const usage: TokenUsage = {
inputTokens: selectionUsage.inputTokens,
outputTokens: selectionUsage.outputTokens,
};
let model = config.model;
let lastResult: PageDeclarations & { notes?: string } = { html: '', uiYaml: '', eventYaml: '' };
let issues: string[] = ['未生成'];
/**
* 三段闸门逐项判定,只在真正跑到闸门校验的那一轮才赋值;每轮开头重置为 undefined,
* 避免中途失败分支(上游异常/输出解析失败/蓝图编译失败)沿用前一轮的陈旧结论。
*/
let checks: SomaGateChecks | undefined;
/** 已发出的模型调用次数(含空输出重试)——指标要的是"打了几次上游",不是"过了几轮闸" */
let attempts = 0;
// 底稿协议迁移也是确定性修补:排在每一轮自己的修补前面,随 meta.repairs 与兜底日志上报
let repairs: string[] = [...migrationRepairs];
let lastBlueprint: SomaPageBlueprint | undefined;
/** 闸门通过时的轮次(0 = 始终没过);"首轮过闸"必须由循环记账,事后从 attempts 推不出来 */
let gatePassedAtAttempt = 0;
/**
* 生成链指标:成功也记。
* 失败样本回流(recordFailure)只覆盖失败面,回答不了"合格线达成没有"——
* 兜底率/过闸率的分母是**全部调用**,漏记成功就等于没有分母。
*/
const emitMetrics = (outcome: GenerationOutcome, errorCode?: string): void => {
recordGeneration({
mode,
outcome,
attempts,
firstAttemptPassed: gatePassedAtAttempt === 1,
gatePassedAtAttempt,
repaired: repairs.length > 0,
repairs,
issueCount: outcome === 'passed' ? 0 : issues.length,
blueprint: useBlueprint,
durationMs: Math.round(performance.now() - startedAt),
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
model,
errorCode,
});
};
try {
let user = baseUser;
const primaryModel = config.aiApi.model ?? config.model;
let generationModel = primaryModel;
let fallbackAttempted = false;
/**
* 空输出的额外重试预算。
*
* 为什么单独给:实测(各 4 次真实调用)deepseek-v4-flash 在 ~28k token 输入下
* 空输出率 25%~50%,而**空的那次回得飞快**——1.4~2.9 秒、思考文本只有一两百字
* (模型提前撂挑子);真正干完活的要 24~54 秒。既然一次空只花两秒,
* 就不该让它挤占"闸门回喂"那三次宝贵的尝试:那三次是留给"产物有问题、
* 带着问题清单再来一遍"的,与"上游没吐字"是两回事。
*
* 三次额外重试把连续全空的概率从 ~12%(3 次 × 50%)压到 ~1.5%(6 次),
* 代价是最坏情况多等约 6 秒。整体仍受超时预算约束。
*/
const MAX_EMPTY_RETRIES = 3;
let emptyRetries = 0;
for (let round = 0; round < maxAttempts; round++) {
attempts += 1;
// 每轮开头清空:本轮若中途失败退出(不到闸门校验那一步),不能沿用上一轮的判定
checks = undefined;
budget.throwIfExpired();
progress.update({ stage: 'queued', attempt: attempts, note: '排队等待模型' });
const release = await semaphore.acquire(budget.signal);
progress.update({
stage: 'generating',
attempt: attempts,
note: attempts === 1 ? '模型正在生成页面' : `第 ${attempts} 次尝试:重新生成`,
});
let response;
try {
response = await client.generateObject({
system,
user,
schema: useBlueprint ? BLUEPRINT_SCHEMA : PAGE_SCHEMA,
model: generationModel,
maxOutputTokens: MAX_OUTPUT_TOKENS,
timeoutMs: Math.min(config.timeoutMs, budget.remainingMs()),
signal: budget.signal,
});
} catch (error) {
// 容错解析抢救后仍拿不到 JSON 对象 → 算一次失败尝试,回喂"严格 JSON"
// 要求重试;最后一轮仍失败才对外抛出(与超时/上游错误不同,这类失败
// 是可回喂修复的输出格式问题)。
// 上游**什么都没给**:回喂"你上次 JSON 写坏了"毫无意义(它压根没写),
// 反而把输入撑长、把一次尝试白白花掉。实测这类失败三轮 outputTokens 都是 0、
// 每轮只要 5 秒——典型的上游瞬时抖动。策略:**原样重试 + 短退避**,
// 不加任何修正指令,退避给上游一点恢复时间(相对 5~15 秒的模型调用,几百毫秒不值一提)。
if (
error instanceof AiApiError
&& error.code === AiErrorCodes.AI_OUTPUT_INVALID
&& UPSTREAM_EMPTY_PATTERN.test(error.message)
) {
issues = [`[输出] ${error.message}`];
const withinEmptyBudget = emptyRetries < MAX_EMPTY_RETRIES;
if (withinEmptyBudget && round < maxAttempts - 1) {
emptyRetries += 1;
round -= 1; // 不占用闸门回喂的尝试预算:那三次留给"产物有问题"
const backoffMs = EMPTY_RETRY_BACKOFF_MS * emptyRetries;
progress.update({ note: '模型这一次没有产出内容,正在重试' });
console.warn(
`[soma-ai][生成链] 第 ${attempts} 次尝试上游空输出`
+ `(空重试 ${emptyRetries}/${MAX_EMPTY_RETRIES},退避 ${backoffMs}ms)`,
);
user = emptyRetries >= 2 ? `${baseUser}\n${EMPTY_OUTPUT_NUDGE}` : baseUser;
await delay(backoffMs, budget.signal);
continue;
}
const fallbackModel = config.aiApi.emptyOutputFallbackModel;
if (!fallbackAttempted && fallbackModel && fallbackModel !== generationModel) {
fallbackAttempted = true;
generationModel = fallbackModel;
model = fallbackModel;
round -= 1;
emptyRetries = 0;
user = `${baseUser}\n${EMPTY_OUTPUT_NUDGE}`;
progress.update({ note: `主模型连续空输出,切换备用模型 ${fallbackModel}` });
console.warn(`[soma-ai][生成链] 主模型 ${primaryModel} 连续空输出,切换备用模型 ${fallbackModel}`);
continue;
}
// 保留原蓝图时编译的也是迁移后的底稿,否则旧协议键会让兜底本身编译失败
const emergency = createEmptyOutputFallback({ ...input, baseBlueprint }, mode, requirement, content, instruction);
lastBlueprint = emergency.blueprint;
lastResult = emergency.declaration;
repairs = [...migrationRepairs, '连续空输出降级声明'];
const emergencyGate = await runSomaGate(
lastResult,
input.catalog,
legacyAllowance,
dataApis.map((api) => api.path),
mode,
);
issues = emergencyGate.issues;
if (issues.length > 0) {
// 兜底声明本身没扛住闸门:异常恢复分支直接抛出,不落 checks——
// 调用方走的是 error 路径,压根拿不到这个返回值。
throw new AiApiError(
AiErrorCodes.AI_OUTPUT_INVALID,
`空输出降级声明未通过闸门:${issues.join(';')}`,
);
}
checks = emergencyGate.checks;
gatePassedAtAttempt = attempts;
model = fallbackAttempted ? generationModel : primaryModel;
console.warn('[soma-ai][兜底触发] 连续空输出后交付可渲染降级声明');
break;
}
if (
error instanceof AiApiError
&& error.code === AiErrorCodes.AI_OUTPUT_INVALID
&& round < maxAttempts - 1
) {
const truncated = /截断|finish_reason=length/.test(error.message);
issues = [`[输出] ${error.message}`];
user = [
baseUser,
'',
truncated
? '上一次输出因过长被截断。请大幅精简:页面只保留必要区块,'
+ 'initProps 列表数据 3 条以内,不要写长文案与注释,确保 JSON 完整输出。'
: '上一次响应无法解析为 JSON 对象。',
'必须输出单个严格 JSON 对象:禁止 markdown 代码栅栏(```)、禁止任何解释文字,',
'多行内容(html/uiYaml)以 \\n 转义写进字符串字段,eventYaml 必须为空。请重新输出完整 JSON。',
].join('\n');
continue;
}
if (
error instanceof AiApiError
&& RETRYABLE_UPSTREAM_ERRORS.has(error.code)
&& round < maxAttempts - 1
) {
issues = [`[上游] ${error.message}`];
progress.update({
note: `第 ${attempts} 次上游调用未完成,正在进行第 ${attempts + 1} 次重试`,
});
const backoffMs = error.code === AiErrorCodes.AI_RATE_LIMITED ? 1_000 : 250;
await delay(backoffMs, budget.signal);
continue;
}
throw error;
} finally {
release();
}
usage.inputTokens += response.usage.inputTokens;
usage.outputTokens += response.usage.outputTokens;
model = response.model;
let normalized: { decl: PageDeclarations & { notes?: string }; repairs: string[] };
if (useBlueprint) {
progress.update({ stage: 'compiling', note: '正在把蓝图编译成页面声明' });
try {
const rawBlueprint = response.object as unknown as SomaPageBlueprint;
const repairedBlueprint = repairBlueprintContractHints(rawBlueprint, input.catalog);
const blueprint: SomaPageBlueprint = {
...repairedBlueprint.blueprint,
slug: resolvePageSlug({
candidate: rawBlueprint.slug,
baseSlug: baseBlueprint?.slug,
title: repairedBlueprint.blueprint.title,
requirement: retrievalText,
}),
};
const componentPolicyIssues = businessComponentPolicyIssues(
componentCountsFromBlueprint(blueprint),
input.catalog,
legacyAllowance,
);
const contentIssues = generatedPageContentIssues(blueprint, retrievalText, mode);
if (componentPolicyIssues.length > 0 || contentIssues.length > 0) {
throw new BlueprintCompileError([...componentPolicyIssues, ...contentIssues].join(';'));
}
const compiled = compileBlueprint(blueprint, compilerCatalog, 'GeneratedPage', {
allowedApiPaths: dataApis.map((api) => api.path),
locale: input.locale,
});
lastBlueprint = blueprint;
normalized = { decl: compiled, repairs: repairedBlueprint.repairs };
} catch (compileError) {
// 蓝图非法(用了目录外组件、缺 blocks…):问题清单回喂,让模型重出蓝图
const message = compileError instanceof BlueprintCompileError
? compileError.message
: String(compileError);
issues = [`[蓝图] ${message}`];
if (round < maxAttempts - 1) {
user = [baseUser, '', `上一次蓝图无法编译:${message}`, '请修正后重新输出完整蓝图 JSON。'].join('\n');
continue;
}
lastResult = { html: '', uiYaml: '', eventYaml: '' };
break;
}
} else {
normalized = normalizeDeclarations(coerceDeclarations(response.object), mode);
}
lastResult = normalized.decl;
repairs = [
...migrationRepairs,
...(response.salvaged ? ['容错解析(从原始文本抢救JSON)'] : []),
...normalized.repairs,
];
if (repairs.length > 0) {
// 兜底触发日志:提示词质量的实弹度量点(合格线:兜底率趋近于零)
console.warn(`[soma-ai][兜底触发] 第 ${attempts} 次尝试:${repairs.join(';')}`);
}
progress.update({ stage: 'validating', note: '正在校验产物是否合规' });
// 渲染冒烟:静态闸门过了才做——"能编译"之外再证明"能活"(共用三次预算,不额外打模型)。
// 只对**零 Domain 产物**冒烟(preview/visualize/revise/蓝图):它们必须自带 initProps
// 独立渲染;standard 产物按设计要配人写的 Domain 供数,空渲染不是缺陷。
const gate = await runSomaGate(
lastResult,
input.catalog,
legacyAllowance,
dataApis.map((api) => api.path),
mode,
() => progress.update({ stage: 'smoking', note: '正在试渲染,确认页面能真的长出来' }),
);
issues = gate.issues;
checks = gate.checks;
if (checks.renderSmoke === false) {
console.warn(`[soma-ai][冒烟未过] 第 ${attempts} 次尝试:${issues.join(';')}`);
}
if (reviseOnBlueprint && issues.length === 0 && lastBlueprint) {
issues = detectBlueprintDrift(baseBlueprint!, lastBlueprint, response.object as { notes?: string });
} else if (mode === 'revise' && !reviseOnBlueprint && issues.length === 0) {
// 静态闸门只保证产物自洽,漂移对账保证"没让改的地方一个字没动"
issues = detectReviseDrift(base!, lastResult);
}
if (issues.length === 0) {
gatePassedAtAttempt = attempts;
break;
}
// 自修复回喂:问题清单 + 上次产物,要求整体重出
user = [
baseUser,
'',
'上一次生成未通过 SOMA 校验闸门,问题清单:',
...issues.map((issue) => `- ${issue}`),
'',
'请修正全部问题后重新输出完整 JSON(不要输出差量)。',
].join('\n');
}
} catch (error) {
progress.finish(false, error instanceof Error ? error.message : '生成失败');
// 上游异常/超时/输出无救:链路中断也要留指标行,否则耗时与失败率的分母会偏小
emitMetrics('error', error instanceof AiApiError ? String(error.code) : undefined);
// 失败样本同样要回流。这条路径此前是漏的——而它恰是占比最大的失败类别
// (实测 28 次调用里 7 次终态失败全走这里),飞轮看不见它就等于看不见主要矛盾。
recordFailure({
mode,
input: mode === 'visualize' ? content : (instruction || requirement),
attempts,
issues: issues.length > 0
? issues
: [`[链路中断] ${error instanceof Error ? error.message : String(error)}`],
repairs,
durationMs: Math.round(performance.now() - startedAt),
outputTokens: usage.outputTokens,
model,
});
throw error;
} finally {
budget.dispose();
}
if (issues.length > 0) {
// 闸门未过的残留问题落服务端日志:真模型实弹排障的第一现场
console.warn(`[soma-ai][闸门未过] ${attempts} 次尝试后残留 ${issues.length} 个问题:${issues.join(';')}`);
}
progress.finish(
issues.length === 0,
issues.length === 0
? `完成:${attempts} 次尝试通过闸门`
: `闸门仍有 ${issues.length} 个问题`,
);
emitMetrics(issues.length === 0 ? 'passed' : 'failed');
if (issues.length > 0) {
// 失败样本回流:每次失败都进资产库(归因+统计),供提示词改动前后做回归对照
recordFailure({
mode,
input: mode === 'visualize' ? content : (instruction || requirement),
attempts,
issues,
repairs,
durationMs: Math.round(performance.now() - startedAt),
outputTokens: usage.outputTokens,
model,
});
}
const compiled = issues.length === 0 ? compileForBrowser(lastResult) : undefined;
return {
...lastResult,
issues,
attempts,
blueprint: lastBlueprint,
...(compiled ? { compiled } : {}),
...(checks ? { checks } : {}),
meta: {
model,
durationMs: Math.round(performance.now() - startedAt),
usage,
repairs,
},
};
}
function compileForBrowser(declaration: PageDeclarations): NonNullable {
const page = compilePageDeclaration(declaration);
return {
uiConfig: page.uiConfig,
sourceMarkup: page.runtimeAssets.sourceMarkup,
html: page.runtimeAssets.html,
...(page.runtimeAssets.artifact ? { artifact: page.runtimeAssets.artifact } : {}),
eventConfig: page.eventConfig,
triggerConfig: page.triggers,
timerConfig: page.timers,
...(page.styleConfig ? { styleConfig: page.styleConfig } : {}),
...(page.dataConfig ? { dataConfig: page.dataConfig } : {}),
};
}
function createEmptyOutputFallback(
input: GenerateSomaPageInput,
mode: GenerateSomaPageInput['mode'],
requirement: string,
content: string,
instruction: string,
): { declaration: PageDeclarations & { notes?: string }; blueprint?: SomaPageBlueprint } {
if (mode === 'revise') {
if (input.baseDeclaration) {
return {
declaration: {
...input.baseDeclaration,
notes: `上游连续空输出,保留原页面;修改指令未应用:${instruction.slice(0, 160)}`,
},
};
}
if (input.baseBlueprint) {
const blueprint: SomaPageBlueprint = {
...input.baseBlueprint,
slug: resolvePageSlug({
candidate: input.baseBlueprint.slug,
title: input.baseBlueprint.title,
requirement: instruction,
}),
};
return {
blueprint,
declaration: {
...compileBlueprint(blueprint, input.catalog, undefined, { locale: input.locale }),
notes: `上游连续空输出,保留原蓝图;修改指令未应用:${instruction.slice(0, 160)}`,
},
};
}
}
const subject = (mode === 'visualize' ? content : requirement).slice(0, 240);
// 连续空输出也不能绕过“页面主体必须是业务组件”。从本次注册目录里挑一个
// 蓝图可承载的 solution/page,并补齐契约必填 prop;text 只保留为故障说明。
// 没有可安全实例化的业务组件就 fail-closed,绝不交付纯 text 假页面。
const fallbackEntry = input.catalog
.filter((entry) => isBusinessPageComponent(entry) && isBlueprintBlockCompatible(entry))
.sort((left, right) =>
left.props.filter((prop) => prop.required).length
- right.props.filter((prop) => prop.required).length)[0];
if (!fallbackEntry) {
throw new AiApiError(
AiErrorCodes.AI_OUTPUT_INVALID,
'上游连续空输出,且目录没有可安全实例化的 solution/page 业务组件,拒绝生成纯文本页面',
);
}
const fallbackProps = Object.fromEntries(
fallbackEntry.props
.filter((prop) => prop.required)
.map((prop) => [prop.name, '暂不可用']),
);
const blueprint: SomaPageBlueprint = {
title: mode === 'visualize' ? '结果暂时无法完整生成' : '页面暂时降级展示',
slug: resolvePageSlug({ requirement: subject }),
subtitle: '上游模型连续未返回内容,可稍后重试;本页由 SOMA 安全降级生成。',
blocks: [
{ component: fallbackEntry.name, props: fallbackProps },
{
component: TEXT_BLOCK,
title: '原始请求',
paragraphs: [subject || '请求内容暂不可用。'],
},
],
};
return {
blueprint,
declaration: {
// 降级声明不绑数据;业务组件以契约安全默认值挂载,说明文字只作补充。
// 所以这里不传接口清单——没有 data 段就没有可校验的路径。
...compileBlueprint(blueprint, input.catalog, undefined, { locale: input.locale }),
notes: '上游连续空输出;已交付可渲染降级声明。',
},
};
}
/**
* 确定性兜底修补:机械可判定、修法唯一的问题不浪费一次模型重试。
*
* 定位说明(用户定调):提示词规格书才是让模型一次生成到位的主手段;
* 本函数是兜底安全网——每次触发都以 repairs 清单上报并打日志,
* 实弹中的兜底触发率就是提示词质量的度量(合格线:趋近于零)。
*
* 只覆盖 html 缺 data-soma-host 边界这一种不改变声明语义的修补。
* 旧 bind/show 与页面侧车不自动转换,统一交给唯一编译入口明确拒绝。
*/
function normalizeDeclarations(
decl: PageDeclarations & { notes?: string },
_mode: GenerateSomaPageMode,
): { decl: PageDeclarations & { notes?: string }; repairs: string[] } {
const repairs: string[] = [];
if (decl.html.trim() && !decl.html.includes('data-soma-host')) {
const hostClass = extractPageDomain(decl.uiYaml) ?? 'GeneratedPage';
decl.html = `\n${decl.html}\n
`;
repairs.push(`host包裹(data-soma-host="${hostClass}")`);
}
return { decl, repairs };
}
/**
* 蓝图契约的单义性修补。
*
* 模型很容易把“区块标题”误写成业务组件的 `title` prop;当该 prop 不在真实契约中时,
* 它对页面没有任何可执行语义,保留只会让后面的统一声明闸门白白消耗重试次数。这里仅
* 删除这一种确定无效、且不可能被组件消费的通用键;其他未知 prop 仍然原样交给契约闸门,
* 继续 fail-closed。若组件本身声明了 title,则完全不触碰。
*/
function repairBlueprintContractHints(
blueprint: SomaPageBlueprint,
catalog: readonly PageCatalogEntry[],
): { blueprint: SomaPageBlueprint; repairs: string[] } {
const byName = new Map(catalog.map((entry) => [entry.name, entry]));
const repairs: string[] = [];
type BlueprintItem = SomaPageBlueprint['blocks'][number];
type BlueprintLeaf = Exclude;
const repairLeaf = (block: BlueprintLeaf): BlueprintLeaf => {
if (block.component === TEXT_BLOCK || !block.props || typeof block.props !== 'object' || Array.isArray(block.props)) {
return block;
}
const entry = byName.get(block.component);
if (!entry || entry.props.some((prop) => prop.name === 'title') || !Object.hasOwn(block.props, 'title')) {
return block;
}
const title = block.props.title;
const props = { ...block.props };
delete props.title;
const next: typeof block = { ...block, props };
// 原生 Tailwind 卡片为每个语义区块提供统一标题落点,不能偷偷把未声明的
// title 塞进组件契约。
if (next.title === undefined && typeof title === 'string') {
next.title = title;
}
repairs.push(`契约修补:${block.component}.props.title 未声明,已${next.title === title ? '转为区块标题' : '删除'}`);
return next;
};
const repairBlock = (block: BlueprintItem): BlueprintItem => {
if (isBlueprintGroup(block)) {
return {
...block,
blocks: block.blocks.map((child) => repairLeaf(child)),
};
}
return repairLeaf(block);
};
return {
blueprint: {
...blueprint,
blocks: blueprint.blocks.map((block) => repairBlock(block)),
},
repairs,
};
}
function isBlueprintGroup(
block: SomaPageBlueprint['blocks'][number],
): block is Extract {
return (block as { group?: unknown }).group === 'grid';
}
/** 从统一声明的 page.domain 提取页面 Domain Class 名。 */
function extractPageDomain(uiYaml: string): string | null {
const match = uiYaml.match(/^\s*domain:\s*['"]?([A-Za-z_][A-Za-z0-9_]*)/m);
return match ? match[1] : null;
}
function coerceDeclarations(object: Record): PageDeclarations & { notes?: string } {
const str = (key: string): string => {
const value = object[key];
return typeof value === 'string' ? value : '';
};
return {
html: str('html'),
uiYaml: str('uiYaml'),
eventYaml: str('eventYaml'),
...(str('styleYaml') ? { styleYaml: str('styleYaml') } : {}),
...(str('dataYaml') ? { dataYaml: str('dataYaml') } : {}),
notes: str('notes') || undefined,
};
}