// SOMA 页面生成器:自然语言需求 + 组件目录 → HTML + 单一 SOMA DSL 页面声明。 // // 生成协议(开放世界低代码的 AI 生成链路核心): // 1. system = SOMA DSL 速查 + 组件目录 Markdown + 硬规则;user = 需求(标记为数据); // 2. 结构化产出 { html, uiYaml, eventYaml: "", notes? }; // 3. PageDeclarationValidator 质量闸门; // 4. 未通过 → 问题清单回喂模型自修复重试(最多 1 次); // 5. 返回声明 + 残留问题清单(issues 为空才可落盘渲染)。 // // 四种模式(共用同一条生成协议:同一 schema、同一闸门、同一回喂机制): // - standard:产物交付开发者,notes 说明 Domain 契约; // - preview:"说话 → 声明 → 渲染"即时预览,initProps 自带演示数据; // - visualize:两段式链路第二段——输入不是页面需求而是"需要展示的结果内容" // (分析文本 / JSON 数据 / 混合),查组件目录挑选展示形态产出同构声明。 // - revise:会话式改页(B4)——输入是现有页面声明(baseDeclaration)+ 修改指令 // (instruction),在现声明上做最小增量修改,未提及部分逐字保留, // 输出完整新声明(仍与 preview 同构,零 Domain 渲染)。 import { compilePageDeclaration } from 'soma-core/server/page-declaration'; import { getAiSemaphore } from '../ai-shared/AiConcurrency'; import { openProgress } from './GenerationProgress'; import { AiApiError, AiErrorCodes } from '../ai-shared/AiError'; import { resolveAiRuntimeConfig, type AiRuntimeConfig } from '../ai-shared/AiRuntimeConfig'; import { AiStructuredOutputClient, type JsonSchemaObject, type TokenUsage, } from '../ai-shared/AiStructuredOutputClient'; import { createAiTimeoutBudget } from '../ai-shared/AiTimeout'; import { detectBlueprintDrift, detectReviseDrift } from './ReviseDriftGuard'; import { smokeRenderDeclaration } from './RenderSmokeDetector'; import { recordFailure } from './FailureJournal'; import { assembleSubset, buildRoutedComponentIndex, buildSelectionPrompt, collectReferencedComponentNames, isBusinessPageComponent, parseCatalogDomainOverride, parseSelection, PROMPT_CATALOG_MAX_BYTES, resolveCatalogRetrievalPlan, selectPageGenerationCatalog, SOURCE_CATALOG_MAX_BYTES, type CatalogDomainRoute, } from './CatalogRetrieval'; import { resolveCatalogDomainRoutes } from './CatalogDomainRoutes'; import { recordGeneration, type GenerationOutcome } from './GenerationMetrics'; import { BLUEPRINT_LOCALES, BLUEPRINT_SCHEMA, BlueprintCompileError, compileBlueprint, isBlueprintBlockCompatible, migrateLegacyBlueprintBlocks, TEXT_BLOCK, type BlueprintLocale, type SomaPageBlueprint, } from './BlueprintCompiler'; import { resolvePageSlug } from './PageSlug'; import { validatePageDeclarations, type PageCatalogEntry, type PageDeclarations, } from './PageDeclarationValidator'; const MAX_REQUIREMENT_BYTES = 64 * 1024; const MAX_CONTENT_BYTES = 256 * 1024; /** * 输出 token 预算。 * * 关键事实(2026-08-11 实弹):**思考模型(deepseek-v4-flash 等)的推理 token 计入 * 输出预算**——8192 会被推理吃光,页面声明还没写完就 finish_reason=length, * 表现为"AI 输出不符合函数契约"(实弹 12 条里 7 条败于此)。故按思考模型的 * 用量给足预算:推理数千 token + 页面声明 2~4K token。 */ const MAX_OUTPUT_TOKENS = 24576; /** 单次生成注入的接口条数上限:清单进 system 提示词,不能由调用方随手撑爆上下文 */ const MAX_DATA_APIS = 40; const MAX_CATALOG_FALLBACK_COMPONENTS = 32; /** 生成模式(四种模式共用同一条生成协议,差别只在输入语义与附加 primer) */ export type GenerateSomaPageMode = 'preview' | 'standard' | 'visualize' | 'revise'; /** * 重试纪律(项目所有者裁决):ai-api 统一"简单重试"——不区分上游/失败类型, * 程序发现问题(解析失败/闸门不过)就再打一轮,**最多请求三次**; * 网络层不再额外重试(客户端 maxRetries=0),保证全链上游请求总数 ≤ 3。 */ const MAX_AI_REQUESTS = 3; /** 上游空输出的识别式:与 AiStructuredOutputClient 的文案对齐(那里是唯一产出点) */ const UPSTREAM_EMPTY_PATTERN = /空输出/; const RETRYABLE_UPSTREAM_ERRORS = new Set([ AiErrorCodes.AI_UPSTREAM_TIMEOUT, AiErrorCodes.AI_UPSTREAM_ERROR, AiErrorCodes.AI_RATE_LIMITED, ]); /** 空输出重试的退避基数:第 N 轮等 N × 这个值 */ const EMPTY_RETRY_BACKOFF_MS = 250; /** 连续空输出后追加的一句提醒(思考模型会把答案留在思考里、正文什么都不写) */ const EMPTY_OUTPUT_NUDGE = '重要:上一次回复是空的。请直接输出 JSON 结果本身——' + '不要只在思考过程里给出答案,最终回复里必须有完整的 JSON。'; /** 可被中断的等待:退避期间用户关页面/超时预算到点,必须立刻停 */ /** 把可用接口渲染成喂模型的紧凑清单(字段字典只给名字与类型,够绑字段即可) */ function renderDataApis(apis: readonly GenerationDataApi[]): string { const lines = apis.map((api) => { const head = `- \`${api.path}\`${api.method ? ` (${api.method})` : ''}${api.description ? ` —— ${api.description}` : ''}`; const fields = Object.entries(api.fields ?? {}); if (fields.length === 0) return head; const dict = fields .map(([key, meta]) => `${key}${meta?.name ? `(${meta.name})` : ''}${meta?.type ? `:${meta.type}` : ''}`) .join('、'); return `${head}\n 字段:${dict}`; }); return ['# 可用数据接口', '', ...lines].join('\n'); } function delay(ms: number, signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); const onAbort = (): void => { clearTimeout(timer); reject(signal?.reason); }; signal?.addEventListener('abort', onAbort, { once: true }); }); } function resolveMaxAttempts(_config: AiRuntimeConfig): number { return MAX_AI_REQUESTS; } const PAGE_SCHEMA: JsonSchemaObject = { type: 'object', additionalProperties: false, required: ['html', 'uiYaml', 'eventYaml'], properties: { html: { type: 'string', minLength: 20, description: 'SOMA UI 模板:真实骨架/容器组件 + 语义锚点' }, uiYaml: { type: 'string', minLength: 10, description: '唯一 SOMA DSL:必须以 {{ 开头、}} 收口,包含 page.domain/ui/event/data 与组件块', }, eventYaml: { type: 'string', enum: [''], description: '退役兼容字段,必须严格为空字符串;事件只能写进 uiYaml 的 event 段', }, notes: { type: 'string', description: '给开发者的简短说明(Domain 需提供哪些字段与方法)' }, }, }; /** SOMA 页面 DSL 速查(喂给模型的最小完备规则集) */ const SOMA_DSL_PRIMER = ` # SOMA 页面 DSL 规则(必须严格遵守) ## html(UI 模板) - 最外层必须是唯一的
容器(与 SOMA DSL 的 page.domain 完全一致),全部内容写在其内部; - 此 declaration 兼容路径只用于历史页修订;新页面必须走 blueprint,由编译器生成受控的 原生 Tailwind 页面。兼容路径不得伪造任何组件库的页面壳或容器; - 并排布局仅用语义 DOM,不能自造运行时布局协议; - 纯结构骨架:锚点元素带 data-soma-id="snake_case_id",文本占位一律写 "--",禁止内联脚本; - 兼容路径不要输出 Tailwind class;Host 只为 BlueprintCompiler 的封闭词汇构建 Tailwind CSS; - 列表:容器锚点内放 ; - 组件实例:
(内部结构由组件提供,禁止手写组件内部锚点); - 组件必填插槽:宿主元素内放 。 ## uiYaml(唯一 SOMA DSL) - 必须以单独一行 \`{{\` 开头、以单独一行 \`}}\` 收口;顶层必须有 \`page:\`,其 \`domain:\` 与 HTML host 相同; - 页面绑定写在 \`page.ui\`:绑定路径字符串(page.xxx / 行变量.xxx)、\`somaId@attr: 路径\`、 \`somaId.model: 路径\`、\`somaId.when: 路径或 "!路径"\`;列表写成 \`列表锚点: { each: "page.列表 as 行变量", 子锚点: 行变量.字段 }\`; - 组件实例不能写进 page.ui。每个 \`data-soma-component\` 实例必须用实例 id 建顶层块: \`实例id: { ui: { prop名: 绑定路径或字面量 }, event: { on: { emit名: Pipeline } } }\`; - 页面 DOM 事件写在 \`page.event.on\`;每条键为 \`somaId.事件名\`,值为 Pipeline; - 数据对象写在 \`page.data\`,不得输出 event/style/data 页面侧车。 - Pipeline 动词只有 NAV_STATIC | NAV_ACTION | UI_REFRESH | UI_INTERACT; - UI_INTERACT 的动作**只有 open | close | toggle 三个**(没有 show / hide / display); - 列表作用域内的页面事件在列表锚点键下嵌套。 ## 硬规则 - 页面业务内容只使用组件目录中 solution/page 层的业务组件、prop、emit;atom/composite 只允许 作为业务组件内部实现,不得由页面直接拼装; - 页面永远不引用组件内部锚点; - 文案国际化通道 page._locale.key 仅在 standard 模式可用;preview / visualize 模式禁止使用 (那两种模式没有 Domain 与 locale 资产,文案直接写字面量或走 initProps 字段); - JSON envelope 的 eventYaml 必须是空字符串,不得输出 styleYaml/dataYaml; - notes 里列出页面 Domain Class 需要提供的字段与方法签名。 `.trim(); /** 第一段的产物契约:只要名字 */ const COMPONENT_SELECTION_SCHEMA = { type: 'object' as const, properties: { components: { type: 'array', items: { type: 'string' }, description: '需要用到的组件名(取自索引,不要编)', }, }, required: ['components'], }; /** * 第一段用哪个模型。 * * 选组件是**分类任务**——从索引里挑名字,不需要推理。拿生成用的重模型干这活是浪费: * 实测思考模型跑第一段,单次要想 30s+,还有近半数因不支持强制 tool_choice 直接失败, * 压缩目录省下的时间全被它吃回去(输入 -60%,总耗时反而 +29%)。 * 所以允许单独指定一个轻模型;不指定则退回生成模型(行为不变,但收益也没了)。 */ function selectionModel(config: AiRuntimeConfig): string { return process.env.SOMA_AI_SELECTION_MODEL ?? config.aiApi.model ?? config.model; } /** * 喂给生成链的接口描述(API 注册表记录的投影)。 * 只带模型组织页面真正需要的东西:路径、干什么、有哪些字段—— * 不带方法之外的调用细节,更不可能带凭据(注册表本身就不许存)。 */ export interface GenerationDataApi { /** 注册名(人读) */ name: string; /** 接口路径——蓝图 data 段只能逐字使用它 */ path: string; method?: string; description?: string; /** 字段字典:字段名 → 中文名/类型(生成链据此绑字段、起中文表头) */ fields?: Record; } export interface GenerateSomaPageInput { /** * 页面需求描述。standard / preview 模式必填; * visualize 模式下可选——有则作为"用户原始提问"上下文喂给模型。 */ requirement?: string; /** * 需要展示的结果内容原文(仅 visualize 模式,必填)。 * 形态任意:一段分析文本 / 半结构化清单 / JSON 串 / 混合体,一律按数据处理。 */ content?: string; /** * 现有页面统一声明(仅 revise 模式,必填)——修改的基线底稿。 * html/uiYaml 必须非空;退役侧车字段必须为空。 */ baseDeclaration?: PageDeclarations; /** * revise 的蓝图底稿(**首选路径**):改一份 JSON 比改一整页文本可靠得多, * 漂移对账也退化为区块级 JSON 比对。给了它就走蓝图修订, * 只有历史产物(没有蓝图的旧声明)才回退到 baseDeclaration 文本修订。 */ baseBlueprint?: SomaPageBlueprint; /** * 修改指令(仅 revise 模式,必填),如"把表格换成卡片瀑布流""加一个导出按钮"。 * 一律按数据处理( 标签包裹),防提示词注入。 */ instruction?: string; /** * 可用数据接口(可选)——来自 soma-core 的 API 注册表。 * * 给了它,模型才被允许产出取数声明(蓝图 data 段 → SOMA DSL 的 page.data), * 页面从"initProps 演示数据"变成"绑真实接口"。不给就是既有行为:数据内联。 * * **能力归平台,组织归应用**:平台提供闸门与词汇,而"这一次生成允许绑哪些接口" * 由调用方决定——同一套生成链,给销售的那次可以只给客户接口,给财务的只给账款接口。 */ dataApis?: GenerationDataApi[]; /** 组件目录 Markdown(喂模型)——由 soma-core generateAiCatalog 产出 */ catalogMarkdown: string; /** 组件目录结构化条目(校验闸门用)——与 markdown 同源 */ catalog: PageCatalogEntry[]; /** * 轻索引的领域分片路由表(可选)——由 Host/组件包产出并显式注入。 * * 给了它,第一段的组件索引会先按需求裁到相关领域再喂模型;不给就照旧喂全量索引。 * **只影响喂给模型的候选面,不影响校验闸门**—— * 闸门永远看全量 entries,否则模型引用了裁掉的组件会被误判成"组件不存在"。 * 混装第三方组件包时由调用方给出合表;表里没有的业务组件按 fail-open 留在业务索引。 */ catalogDomains?: CatalogDomainRoute[]; /** * preview:产物用于即时预览——要求 initProps 自带演示数据、 * 事件不依赖页面 Domain 方法,零 Domain 代码即可完整渲染。 * visualize:两段式 AI 链第二段——把 content(任意形态的结果内容) * 转化为展示页面声明,数据内嵌 initProps,产物与 preview 同构。 * revise:会话式改页——在 baseDeclaration 上按 instruction 做最小增量修改, * 未提及部分逐字保留,产物仍与 preview 同构。 * standard(默认):产物交付开发者,notes 说明 Domain 契约。 */ /** * 产物形态。缺省 'blueprint':模型只出组织意图 JSON,统一声明由 BlueprintCompiler * 确定性编译(输出 token -59%、截断/YAML/锚点/幻觉键/host 边界五类失败结构性消失)。 * 'declaration' 只保留给历史页修订兼容;新 Host 应始终使用 blueprint, * 以便由编译器输出可由 Host Tailwind 构建的封闭 class 词汇。 * revise 模式下由是否给了 baseBlueprint 决定,此字段不参与。 */ output?: 'blueprint' | 'declaration'; /** * 生成页上编译器自带文案(眉题、空集合占位、区块导语)的语言,缺省 zh-CN。 * 只作用于 BlueprintCompiler 产物;标题、props、段落等业务文案仍来自蓝图。 */ locale?: BlueprintLocale; mode?: GenerateSomaPageMode; signal?: AbortSignal; /** * 进度任务号(可选,客户端生成)。给了就把阶段实时写进进度存储, * 等待方可以用 `/api/ai-page/progress?taskId=` 查"现在到哪一步了"。 * 不给就一路空操作——进度是旁路,绝不影响生成本身。 */ taskId?: string; } type ComponentCounts = Map; function addComponentCount(counts: ComponentCounts, name: string): void { counts.set(name, (counts.get(name) ?? 0) + 1); } function componentCountsFromHtml(html: string | undefined): ComponentCounts { const counts: ComponentCounts = new Map(); // HTML 合法属性既可双引号、单引号,也可无引号。层级硬门不能依赖模型遵守示例里的 // 双引号,否则换一种等价写法就能绕过 atom/composite 禁令。 for (const match of (html ?? '').matchAll( /data-soma-component\s*=\s*(?:"([A-Za-z][A-Za-z0-9]*)"|'([A-Za-z][A-Za-z0-9]*)'|([A-Za-z][A-Za-z0-9]*))/gi, )) { addComponentCount(counts, match[1] ?? match[2] ?? match[3]); } return counts; } function componentCountsFromBlueprint(blueprint: SomaPageBlueprint | undefined): ComponentCounts { const counts: ComponentCounts = new Map(); const walk = (blocks: unknown): void => { if (!Array.isArray(blocks)) return; for (const block of blocks) { if (!block || typeof block !== 'object') continue; const record = block as { component?: unknown; blocks?: unknown[] }; if (typeof record.component === 'string') addComponentCount(counts, record.component); walk(record.blocks); } }; walk(blueprint?.blocks); return counts; } const DELIVERY_ONLY_COMPONENTS = new Set([ 'ReleaseReadinessCard', 'DeploymentCard', ]); const DELIVERY_INTENT_PATTERN = /发布|部署|上线|发版|devops|release|deployment|ci\/?cd|流水线/i; const SOMA_INTERNAL_INTENT_PATTERN = /soma|registry|canonical\s+(?:soma\s+)?dsl|组件目录|页面蓝图|服务端(?:内存)?编译|渲染冒烟/i; const SOMA_INTERNAL_CONTENT_PATTERNS: ReadonlyArray = [ [/soma-(?:core|ai|ui)/i, 'Soma 内部模块'], [/\bregistry\b/i, 'Registry'], [/canonical\s+(?:soma\s+)?dsl/i, 'canonical DSL'], [/页面蓝图|业务组件目录|solution\/page/i, '生成蓝图/组件目录'], [/服务端(?:内存)?编译|真实渲染冒烟/i, '编译/渲染闸门'], [/生成期闸门|发布候选|等待发布/i, '生成/发布状态'], ]; /** * 生成工作台负责交付,生成出来的页面只负责用户业务。 * 发布控件只在用户明确要求发布/DevOps 页面时可选;Soma 自身生成、编译、Registry * 等实现细节必须由原始需求明确点名,否则视为工作台元数据泄漏并回喂重做。 */ function generatedPageContentIssues( blueprint: SomaPageBlueprint, requirement: string, mode: GenerateSomaPageMode, ): string[] { if (mode === 'revise') return []; const issues: string[] = []; const counts = componentCountsFromBlueprint(blueprint); if (!DELIVERY_INTENT_PATTERN.test(requirement)) { for (const name of DELIVERY_ONLY_COMPONENTS) { if ((counts.get(name) ?? 0) > 0) { issues.push(`[页面内容] 用户未要求发布/部署页面,不得使用交付组件 "${name}"`); } } } const serialized = JSON.stringify({ title: blueprint.title, subtitle: blueprint.subtitle, blocks: blueprint.blocks, }); if (!SOMA_INTERNAL_INTENT_PATTERN.test(requirement)) { for (const [pattern, label] of SOMA_INTERNAL_CONTENT_PATTERNS) { if (pattern.test(serialized)) { issues.push(`[页面内容] ${label} 属于 Workbench 交付过程,不得写入用户页面`); } } } return issues; } /** * 页面层只能新增业务组件。revise 为兼容历史声明,允许保留底稿中既有基础组件, * 但输出数量不能超过底稿数量,因此不能借修订入口继续新增 atom/composite。 */ function businessComponentPolicyIssues( output: ComponentCounts, catalog: PageCatalogEntry[], legacyAllowance: ComponentCounts, ): string[] { const byName = new Map(catalog.map((entry) => [entry.name, entry])); const issues: string[] = []; for (const [name, count] of output) { if (name === TEXT_BLOCK) continue; const entry = byName.get(name); if (!entry) { issues.push(`[业务组件] 页面引用了目录外组件 "${name}";生成页面只能使用注册的 solution/page 业务组件`); continue; } if (isBusinessPageComponent(entry)) continue; const allowed = legacyAllowance.get(name) ?? 0; if (count <= allowed) continue; issues.push( `[业务组件] 页面新增了 ${entry.layer ?? '未分层'} 层组件 "${name}";` + '生成页面只能编排 solution/page 业务组件,atom/composite 只能封装在业务组件内部', ); } return issues; } /** * 跑三段质量闸门,逐项记录通过/失败,同时保持 issues 合并清单的既有语义不变。 * * ①②在同一轮内各自独立跑并把问题拼在一起(现有自修复回喂要的是"一次性喂给模型 * 全部问题",不做短路——业务组件层级不过,SOMA 声明该有的问题照样报); * ③只在①②都过、且非 standard 模式时才跑(空 Domain 渲染冒烟是①②之后的加验, * standard 产物按设计要配人写 Domain 供数,空渲染不是缺陷)。 */ async function runSomaGate( declaration: PageDeclarations & { notes?: string }, catalog: PageCatalogEntry[], legacyAllowance: ComponentCounts, allowedApiPaths: string[], mode: GenerateSomaPageMode, onBeforeSmoke?: () => void, ): Promise<{ issues: string[]; checks: SomaGateChecks }> { const businessIssues = businessComponentPolicyIssues( componentCountsFromHtml(declaration.html), catalog, legacyAllowance, ); const declarationIssues = validatePageDeclarations(declaration, catalog, { allowedApiPaths }); const businessComponentLayer = businessIssues.length === 0; const somaDeclaration = declarationIssues.length === 0; let issues = [...businessIssues, ...declarationIssues]; let renderSmoke: boolean | null = null; if (issues.length === 0 && mode !== 'standard') { onBeforeSmoke?.(); const smoke = await smokeRenderDeclaration(declaration, catalog); renderSmoke = smoke.issues.length === 0; if (smoke.issues.length > 0) issues = smoke.issues; } return { issues, checks: { businessComponentLayer, somaDeclaration, renderSmoke } }; } /** * 零 Domain 模式(preview / visualize)的共用铁律。 * * 这两种模式产物直接进运行时渲染,没有 Domain 类、没有 locale 资产、没有接口, * 声明里出现任何"运行期才存在"的引用都会当场炸——故把边界写成枚举式白名单, * 让模型一次生成到位(实测:模型会自行发明 "/text" 这类键与 page._locale 路径)。 */ const ZERO_DOMAIN_RULES = ` ## 零 Domain 铁律(本模式没有 Domain 类 / locale / 后端接口) - page.ui 的值只能使用绑定、\`.model\`、\`.when\`、\`@attr\` 与 \`each\` 语法,禁止发明 "/text"、"text"、"html"、"value"、"content" 等键; - 组件 props 只能写在对应实例的顶层块 \`实例id.ui\`,不能写进 page.ui; - 绑定命名空间**只有 page.** 一个(列表内可用行变量); page._locale.* 在本模式不存在,禁止出现;禁止裸字段名(如 title)。 - page.ui 的页面文本绑定绝不写文案字面量——所有文案放进 page.initProps,绑定只写 page.* 路径。 - page.initProps 与 ui 必须一一对账:ui 里每一个 page.X 都要在 initProps 里有值, initProps 里的键就写完整路径("page.X": 值)。 - 事件内嵌在 page.event.on 或组件块 event.on;UI_INTERACT 动作只有 open | close | toggle; JSON envelope 的 eventYaml 始终为空字符串。 - html 最外层必须且只能有一个
,与 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, }; }