// 组件目录两段式检索:先让模型挑组件,再只喂挑中那几个的语法。 // // 为什么要拆(实测账):目录全量喂入时,单次生成的 inputTokens ≈ 29,000—— // 58 个组件的完整契约、示例、反模式全在里面,而一个页面通常只用到 5~10 个。 // 更要命的是**让模型同时干两件事**:从几十个候选里认组件 + 按语法写声明。 // 思考模式下这一步的代价看得见:输出 token 中位 4,724,其中绝大部分是 // "该用哪个组件"的推敲(非思考模式只要 732,产物体量却相当)。 // // 拆开之后: // 第一段——只喂"组件名 + 一句话用途"的索引(全库不到 2k token),让模型挑名字。 // 语义匹配本来就是模型擅长的,比程序侧关键词打分稳(架构册 §3 的原设计 // 走的是关键词计分,那条路在 whenToUse 边界重叠时容易挑错)。 // 第二段——只把挑中组件的完整小节喂回去,让它专心写声明。 // // 三条保守设计: // 1. **生成候选只含业务组件**。atom/composite 是业务组件内部的实现材料,不再交给 // 页面模型拼装;原生 Tailwind 的页面骨架、区块卡片与响应式布局由 // BlueprintCompiler 确定性生成。 // 2. **第一段失败就整体回退业务目录**——但目录已超过 prompt 预算(256KB)时不能 // 回退全量,改为索引 + 路由命中/底稿已用组件的子集(否则生成入口会直接 4503)。 // 3. **契约闸门仍看全量**。喂给模型的是业务子集,但基础声明合法性仍以注册目录为准—— // 否则模型引用了子集外的组件,闸门会误判为"组件不存在"。 import type { PageCatalogEntry } from './PageDeclarationValidator'; const CJK_RUN = /[一-鿿]+/g; const ASCII_WORD = /[a-z][a-z0-9]{2,}/g; /** * 路由用分词:中文取二元组,英文取 ≥3 字母的词。 * * 这是通用检索算法,不属于任何组件库。领域关键词与路由表由 Host 注入, * 所以 soma-ai 不需要、也不应当导入 `soma-ui` 的实现。 */ export function tokenizeCatalogRoutingText(text: string): string[] { const tokens: string[] = []; const lower = text.toLowerCase(); for (const match of lower.matchAll(ASCII_WORD)) tokens.push(match[0]); for (const run of lower.matchAll(CJK_RUN)) { const chunk = run[0]; if (chunk.length === 1) { tokens.push(chunk); continue; } for (let index = 0; index + 2 <= chunk.length; index += 1) { tokens.push(chunk.slice(index, index + 2)); } } return tokens; } /** 页面生成只编排业务层;page 为第三方包/未来目录的兼容层级。 */ export const PAGE_GENERATION_LAYERS = new Set(['solution', 'page']); export function isBusinessPageComponent(entry: PageCatalogEntry): boolean { return PAGE_GENERATION_LAYERS.has(String(entry.layer ?? '').toLowerCase()); } /** * 新页面只开放业务组件;revise 可把底稿已经使用的旧层组件带回上下文,避免无关漂移。 * preserve 只按名字放行,不会把同层的其他基础件一起重新开放。 */ export function selectPageGenerationCatalog( entries: PageCatalogEntry[], preserve: readonly string[] = [], ): PageCatalogEntry[] { const preserved = new Set(preserve); return entries.filter((entry) => isBusinessPageComponent(entry) || preserved.has(entry.name)); } /** 组件小节的起始标记:`## <名字> \`<层>\` v<版本>` */ const SECTION_HEAD = /^## ([A-Z][A-Za-z0-9]*) `/; /** 源目录上限:Host 传入的全量 markdown(检索真源,不直接进 prompt) */ export const SOURCE_CATALOG_MAX_BYTES = 1024 * 1024; /** 喂模型的目录上限:分层检索之后真正进 system 的那份 */ export const PROMPT_CATALOG_MAX_BYTES = 256 * 1024; export type CatalogRetrievalPlan = 'full' | 'subset' | 'index'; export interface CatalogRetrievalDecision { plan: CatalogRetrievalPlan; reason: 'revise' | 'explicit' | 'below-threshold' | 'auto-scale'; entryCount: number; catalogBytes: number; inputBytes: number; } export interface CatalogRetrievalPolicyInput { mode: 'standard' | 'preview' | 'visualize' | 'revise'; override?: string; entryCount: number; catalogMarkdown: string; inputText: string; } const AUTO_MIN_ENTRIES = 100; const AUTO_MIN_CATALOG_BYTES = 96 * 1024; const AUTO_FORCE_CATALOG_BYTES = 192 * 1024; const AUTO_MIN_CATALOG_SHARE = 0.25; /** * 目录检索计划。小目录仍走全量;达到规模阈值后自动切到「全量轻索引 + 选中契约」。 * 全量 markdown 一旦超过 prompt 预算(256KB),即使 revise / 事故开关 off 也强制分层—— * 否则生成入口会把真源目录当 prompt 直接拒掉。 */ export function resolveCatalogRetrievalPlan(input: CatalogRetrievalPolicyInput): CatalogRetrievalDecision { const catalogBytes = utf8Bytes(input.catalogMarkdown); const inputBytes = utf8Bytes(input.inputText); const base = { entryCount: input.entryCount, catalogBytes, inputBytes }; const overPromptBudget = catalogBytes > PROMPT_CATALOG_MAX_BYTES; const override = input.override?.trim().toLowerCase(); if (override === 'off' && !overPromptBudget) return { plan: 'full', reason: 'explicit', ...base }; if (override === 'on' || override === 'subset') { return { plan: 'subset', reason: 'explicit', ...base }; } if (override === 'index') return { plan: 'index', reason: 'explicit', ...base }; if (overPromptBudget) { return { plan: 'index', reason: 'auto-scale', ...base }; } if (input.mode === 'revise') { return { plan: 'full', reason: 'revise', ...base }; } const share = catalogBytes / Math.max(1, catalogBytes + inputBytes); const scaled = catalogBytes >= AUTO_MIN_CATALOG_BYTES && (input.entryCount >= AUTO_MIN_ENTRIES || catalogBytes >= AUTO_FORCE_CATALOG_BYTES) && share >= AUTO_MIN_CATALOG_SHARE; return scaled ? { plan: 'index', reason: 'auto-scale', ...base } : { plan: 'full', reason: 'below-threshold', ...base }; } function utf8Bytes(value: string): number { return new TextEncoder().encode(value).byteLength; } export interface CatalogSubset { /** 喂给模型的目录 markdown(前言 + 选中组件的小节) */ markdown: string; /** 实际选中的组件名(含恒含项) */ picked: string[]; /** 相对全量 markdown 的字节占比,便于观测收益 */ ratio: number; } /** * 组件索引:每行「名字 | 层 | 一句话用途」。 * 这是第一段的全部输入——不带 props、不带示例、不带反模式。 */ export function buildComponentIndex(entries: PageCatalogEntry[]): string { return entries.map(componentIndexLine).join('\n'); } /** 索引里的一行。领域分片检索按组件挑行,故单独抽出来——两处必须逐字一致。 */ function componentIndexLine(entry: PageCatalogEntry): string { // 契约类型只声明了闸门用得到的字段;whenToUse/layer/description 是 // 目录 JSON 里实际存在但类型里没写的展示位,故就地放宽 const extra = entry as { ai?: { whenToUse?: string }; layer?: string; description?: string }; const use = String(extra.ai?.whenToUse ?? extra.description ?? '').split(/[;;。]/)[0]; return `${entry.name} | ${extra.layer ?? ''} | ${use.slice(0, 60)}`; } // ── 轻索引的领域分片检索(先定域、再取该域索引) ────────────────────────────── // // 为什么要做:轻索引是第一段的**全部**输入,而它每个组件一行,绝对体积随库规模 // 线性增长(293→31KB,304→33.6KB,308→34.9KB)。库还会长,这一份要进两次上下文 // (第一段选型 + 第二段的"可用组件全表"),不切开的话它迟早比契约子集还贵。 // // ── 三条设计,按重要性排序 ────────────────────────────────────────────── // // 1. **不多一轮往返**。定域是程序侧做的(需求文本 × 各域关键词),不是再打一次模型。 // 账很清楚:分层检索本身已经花掉一次模型往返(实测 P50 5.4s 非思考 / 31.7s 思考), // 再加一轮"先问它属于哪个领域"要付第二次墙钟时间和第二处失败面,买到的却只是 // ~15KB **输入** token——输入是这条链上最便宜、最可缓存的部分。 // 关键词路由在这里够用,是因为它只做**剪枝**、不做决策:选哪几个组件仍然由模型 // 在剪枝后的候选里定。架构册当年否掉关键词计分,否的是"用关键词直接选组件" // (whenToUse 边界重叠时会挑错);把同一手法降级成粗粒度剪枝、且带保底,风险不同。 // // 2. **选错域必须还能救回来**,这比分片本身重要。四层兜底,任一层成立就不会掉组件: // a. 页面骨架由编译器生成,不依赖索引里的 atom/composite; // b. 命中多个域时**宁可多取**(取到最高分的 40% 以上,上限 8 片); // c. 未展开领域的组件**名字仍然全带**(只名字不带用途,便宜):模型点得到名字, // 第二段 assembleSubset 就会按名把完整契约补给它——路由错了也只是少一句提示; // d. 一个域都没命中、或剪完没省下多少(≥85% 全量)→ **直接回退全量索引**, // 也就是今天的行为。路由是纯优化:最坏情况等于不路由,不会更糟。 // // 3. **闸门永远看全量**。这里裁的只是"喂给模型的索引",校验用的 entries 仍是全集。 // // 路由表由组件库产出(soma-ui 的 `catalog/索引路由.json` / `buildSomaUiCatalogDomainRoutes`), // 本模块对它只做结构假设,不认识任何具体领域名——第三方组件包给自己的表即可。 // 表里没出现过的业务组件一律当作"恒含":**不认识就别藏起来**,fail-open。 /** 领域分片路由表的一片(与 soma-ui `CatalogDomainRoute` 结构一致,此处结构化解耦) */ export interface CatalogDomainRoute { id: string; title: string; /** core = 恒含,不参与路由;domain = 按需 */ kind: 'core' | 'domain'; keywords: string[]; components: string[]; } export type CatalogRoutingReason = | 'routed' | 'forced' | 'disabled' | 'no-routes' | 'no-text' | 'no-match' | 'no-gain'; export interface CatalogRoutingDecision { /** false = 这次用的是全量索引 */ routed: boolean; reason: CatalogRoutingReason; domains: string[]; scores: Record; } /** 最多展开几片:再多就等于全量,不如直接回退 */ const ROUTE_MAX_DOMAINS = 8; /** 命中分低于最高分的这个比例就不取(宁可多取一片,也别把弱相关全拉进来) */ const ROUTE_SCORE_SHARE = 0.4; /** 剪枝后仍占全量这么大就没意义,回退全量 */ const ROUTE_MIN_GAIN_RATIO = 0.85; /** * 需求文本 → 该展开哪几个领域片。 * * 打分只是"命中了几个该域的关键词",不做归一化:关键词条数各域相当, * 而任何更聪明的权重都会让"为什么选中这片"变得不可解释——出问题时没人能复现。 */ export function routeCatalogDomains( text: string, routes: readonly CatalogDomainRoute[], options: { forced?: readonly string[]; maxDomains?: number } = {}, ): CatalogRoutingDecision { const domains = routes.filter((route) => route.kind === 'domain'); if (options.forced && options.forced.length > 0) { const known = new Set(domains.map((route) => route.id)); const picked = options.forced.filter((id) => known.has(id)); if (picked.length > 0) return { routed: true, reason: 'forced', domains: picked, scores: {} }; } if (domains.length === 0) return { routed: false, reason: 'no-routes', domains: [], scores: {} }; if (!text.trim()) return { routed: false, reason: 'no-text', domains: [], scores: {} }; const grams = new Set(tokenizeCatalogRoutingText(text)); const scores: Record = {}; let top = 0; for (const route of domains) { let hits = 0; for (const keyword of route.keywords) if (grams.has(keyword)) hits += 1; scores[route.id] = hits; if (hits > top) top = hits; } if (top === 0) return { routed: false, reason: 'no-match', domains: [], scores }; const floor = Math.max(1, Math.ceil(top * ROUTE_SCORE_SHARE)); const picked = domains .filter((route) => scores[route.id] >= floor) // 分数相同按路由表顺序,产物可复现 .sort((a, b) => scores[b.id] - scores[a.id]) .slice(0, options.maxDomains ?? ROUTE_MAX_DOMAINS) .map((route) => route.id); return { routed: true, reason: 'routed', domains: picked, scores }; } export interface RoutedComponentIndex { /** 喂给模型的索引文本 */ text: string; /** false = 这一份就是全量索引 */ routed: boolean; reason: CatalogRoutingReason; domains: string[]; bytes: number; fullBytes: number; ratio: number; /** 带完整索引行的组件名;目录超预算且选型失败时用于受控兜底。 */ expanded: string[]; } export interface RoutedComponentIndexOptions { routes?: readonly CatalogDomainRoute[]; /** 需求/指令原文——定域的唯一依据 */ requirement?: string; /** 显式点名领域(事故开关/回归用);'off' 由调用方转成 disabled */ forcedDomains?: readonly string[]; /** 关掉路由,直接全量(等于今天的行为) */ disabled?: boolean; /** 无论路由结果如何都要留下的组件(底稿已用组件) */ keep?: readonly string[]; maxDomains?: number; /** 未展开领域是否附组件名清单(缺省 true——这是"选错域"最后一道保险) */ includeUnroutedNames?: boolean; } /** * 按需求把轻索引裁到相关领域;裁不动或没收益就原样返回全量。 * * **不变式**:任何组件的名字都一定出现在返回文本里——要么带着用途在展开的分片里, * 要么在末尾"未展开领域"的名字清单里。分域检索最贵的失败模式是组件对 AI 隐形, * 这条不变式让它不可能发生(而不是靠"每个组件都记得归域"的纪律)。 */ export function buildRoutedComponentIndex( entries: PageCatalogEntry[], options: RoutedComponentIndexOptions = {}, ): RoutedComponentIndex { const full = buildComponentIndex(entries); const fullBytes = utf8Bytes(full); const asFull = (reason: CatalogRoutingReason): RoutedComponentIndex => ({ text: full, routed: false, reason, domains: [], bytes: fullBytes, fullBytes, ratio: 1, expanded: entries.map((entry) => entry.name), }); if (options.disabled) return asFull('disabled'); const routes = options.routes ?? []; const decision = routeCatalogDomains(options.requirement ?? '', routes, { forced: options.forcedDomains, maxDomains: options.maxDomains, }); if (!decision.routed) return asFull(decision.reason); const picked = new Set(decision.domains); const expanded = new Set(); const routedNames = new Set(); for (const route of routes) { for (const name of route.components) routedNames.add(name); if (route.kind === 'core' || picked.has(route.id)) { for (const name of route.components) expanded.add(name); } } // 路由表不认识的组件(第三方组件包、刚加还没登记的新件)一律恒含:不认识就别藏起来 for (const entry of entries) if (!routedNames.has(entry.name)) expanded.add(entry.name); for (const name of options.keep ?? []) expanded.add(name); const expandedEntries = entries.filter((entry) => expanded.has(entry.name)); const lines = expandedEntries.map(componentIndexLine); const tail: string[] = []; if (options.includeUnroutedNames !== false) { const rest = routes.filter( (route) => route.kind === 'domain' && !picked.has(route.id), ); const groups = rest .map((route) => { const names = route.components.filter((name) => !expanded.has(name)); return names.length > 0 ? `${route.id}(${route.title}): ${names.join('、')}` : ''; }) .filter(Boolean); if (groups.length > 0) { tail.push( '', '# 未展开领域(只给名字,没给用途)。需求若属于其中之一,直接写这些名字即可——契约会按名补给你。', ...groups, ); } } const text = [...lines, ...tail].join('\n'); const bytes = utf8Bytes(text); // 剪完还占八成以上:省下的还不够抵掉多出来的那段说明,不如照旧给全量 if (bytes >= fullBytes * ROUTE_MIN_GAIN_RATIO) return asFull('no-gain'); return { text, routed: true, reason: decision.reason, domains: decision.domains, bytes, fullBytes, ratio: Number((bytes / Math.max(1, fullBytes)).toFixed(3)), expanded: expandedEntries.map((entry) => entry.name), }; } /** 事故开关:`off` 关掉路由,`a,b` 强制展开这几个领域 */ export function parseCatalogDomainOverride(raw: string | undefined): { disabled: boolean; forced: string[]; } { const value = raw?.trim(); if (!value) return { disabled: false, forced: [] }; if (value.toLowerCase() === 'off') return { disabled: true, forced: [] }; return { disabled: false, forced: value.split(/[,,\s]+/).filter(Boolean) }; } /** 第一段的提示词:只要名字,不要解释 */ export function buildSelectionPrompt(requirement: string, index: string): string { return [ '下面只列可用于页面生成的业务组件,每行是「组件名 | 分层 | 适用场景」。', '', index, '', `需求:${requirement}`, '', '挑出实现这个需求需要的业务组件,只输出名字。', '页面骨架、卡片容器、主题和空间布局由 SOMA 编译器生成,不要选择或发明基础组件。', '一般 2~8 个业务组件足够;不要把整份索引抄回来。', ].join('\n'); } /** * 把全量目录 markdown 切成「前言 + 各组件小节」。 * 前言(版式与视觉规范)必须保留——那是页面骨架的硬约束,与选了哪些组件无关。 */ export function splitCatalogMarkdown(markdown: string): { preamble: string; sections: Map; } { const lines = markdown.split('\n'); const sections = new Map(); let preambleEnd = lines.length; let current: string | null = null; let buffer: string[] = []; for (let i = 0; i < lines.length; i += 1) { const matched = SECTION_HEAD.exec(lines[i]); if (matched) { if (current) sections.set(current, buffer.join('\n')); else preambleEnd = Math.min(preambleEnd, i); current = matched[1]; buffer = [lines[i]]; } else if (current) { buffer.push(lines[i]); } } if (current) sections.set(current, buffer.join('\n')); return { preamble: lines.slice(0, preambleEnd).join('\n'), sections }; } /** * 按选中的组件名组装子集目录。 * 选不中任何一个已知组件时返回 null——调用方据此回退全量。 */ export function assembleSubset( markdown: string, entries: PageCatalogEntry[], picked: string[], options: { includeFullIndex?: boolean; fullIndexText?: string } = {}, ): CatalogSubset | null { const { preamble, sections } = splitCatalogMarkdown(markdown); if (sections.size === 0) return null; const known = new Set(entries.map((entry) => entry.name)); const wanted = new Set(); for (const name of picked) { if (known.has(name)) wanted.add(name); } // 模型一个都没挑对(名字全是编的)→ 回退业务目录,别拿一份空目录去生成 if (wanted.size === 0) return null; // 保持全量目录里的原始顺序(按层分组的可读顺序),不按选中顺序打乱 const ordered = [...sections.keys()].filter((name) => wanted.has(name)); const body = ordered.map((name) => sections.get(name)!).join('\n'); // 变体:把**全库索引**(名字 + 一句话用途,不到 2k token)一并带上。 // 假说:纯子集时首轮过闸率反而掉(69% → 38%),一种解释是模型不知道"还有什么可用", // 挑漏了就只能用裸 div 凑。带上索引让它知道全貌,契约仍只给挑中的几个—— // 这条要用同一批需求实测才算数,所以做成可开关的变体而不是直接改默认。 // fullIndexText:领域分片检索裁过的那一份。裁不动时调用方传的就是全量,行为不变。 const indexBlock = options.includeFullIndex ? `\n## 可用组件全表(仅名字与用途;要写某个组件必须先确认下面有它的契约)\n\n` + `${options.fullIndexText ?? buildComponentIndex(entries)}\n` : ''; // 把本轮真正带有完整契约的组件名单放在提示词末尾,压过前言/模型先验里可能 // 出现的 atom/composite 名称。目录裁剪是性能优化,名单才是页面 blocks 的硬边界; // 闸门仍会对任何越界名称 fail-closed。 const allowList = [ '## 本次页面 blocks 允许使用的组件(硬白名单)', '', ordered.join(', '), '', 'component 字段只能逐字使用上面名单中的名称;页面骨架、原生卡片与响应式布局由编译器生成,不能写入 blocks。', ].join('\n'); const subset = `${preamble}${indexBlock}\n${body}\n${allowList}\n`; return { markdown: subset, picked: ordered, ratio: Number((subset.length / markdown.length).toFixed(3)), }; } /** 从模型返回里抠出组件名——容忍它多写解释、写成列表、加引号 */ export function parseSelection(raw: unknown, entries: PageCatalogEntry[]): string[] { const known = new Set(entries.map((entry) => entry.name)); const text = typeof raw === 'string' ? raw : JSON.stringify(raw ?? ''); const found: string[] = []; for (const match of text.matchAll(/[A-Z][A-Za-z0-9]{2,}/g)) { const name = match[0]; if (known.has(name) && !found.includes(name)) found.push(name); } return found; } /** * 从底稿 HTML / 蓝图里收集已用组件名(revise 分层时必须带上,否则改页会丢掉原组件契约)。 */ export function collectReferencedComponentNames(input: { catalog: PageCatalogEntry[]; html?: string; blueprint?: { blocks?: unknown[] }; extra?: readonly string[]; }): string[] { const known = new Set(input.catalog.map((entry) => entry.name)); const found: string[] = []; const add = (name: string): void => { if (known.has(name) && !found.includes(name)) found.push(name); }; for (const name of input.extra ?? []) add(name); const html = input.html ?? ''; for (const match of html.matchAll(/data-soma-component="([A-Za-z][A-Za-z0-9]*)"/g)) { add(match[1]); } walkBlueprintBlocks(input.blueprint?.blocks, add); return found; } function walkBlueprintBlocks(blocks: unknown, add: (name: string) => void): void { if (!Array.isArray(blocks)) return; for (const block of blocks) { if (!block || typeof block !== 'object') continue; const rec = block as { component?: unknown; blocks?: unknown[] }; if (typeof rec.component === 'string') add(rec.component); if (Array.isArray(rec.blocks)) walkBlueprintBlocks(rec.blocks, add); } }