import type { NextRequest } from 'next/server'; import type { IService } from 'soma-core'; import { ApiRegistryError, getApiRegistryStore, resolveRegistrySubject, } from 'soma-core/server/registry'; import { assertGenerateLocale, generateSomaPage } from '../../../domain/ai-page/PageGenerator'; import type { SomaPageBlueprint } from '../../../domain/ai-page/BlueprintCompiler'; import type { PageCatalogEntry, PageDeclarations } from '../../../domain/ai-page/PageDeclarationValidator'; import { AiApiError, AiErrorCodes } from '../../../domain/ai-shared/AiError'; import { aiErrorResponse, aiSuccessResponse } from '../../../domain/ai-shared/AiResponse'; import { AI_REQUEST_MAX_BYTES, readJsonWithLimit, RequestBodyTooLargeError, } from '../../common/RequestBodyLimit'; import { callerKey, parseRateLimit, sharedRateLimiter } from '../../common/RateLimiter'; import type { GenerationDataApi } from '../../../domain/ai-page/PageGenerator'; import { runService } from '../../common/ServiceRuntime'; import { resolveTrustedRequestContext } from '../../common/TrustedRequestContext'; /** 生成端点的按调用方限流;`SOMA_AI_GENERATE_RATE=off` 可关(本地压测用) */ async function enforceGenerateRateLimit(req: NextRequest): Promise { const rule = parseRateLimit(process.env.SOMA_AI_GENERATE_RATE, { limit: 30, windowMs: 60_000 }); const subject = resolveRegistrySubject(req); const actor = await resolveTrustedRequestContext(req); const decision = await sharedRateLimiter.hit(`ai-generate:${callerKey(actor, subject?.name)}`, rule); if (decision.allowed) { return null; } const response = aiErrorResponse(new AiApiError( AiErrorCodes.AI_RATE_LIMITED, `生成请求过于频繁,请 ${decision.retryAfterSeconds} 秒后重试`, )); return new Response(response.body, { status: 429, headers: { ...Object.fromEntries(response.headers), 'retry-after': String(decision.retryAfterSeconds) }, }); } export interface GenerateBody { requirement?: unknown; content?: unknown; baseDeclaration?: unknown; baseBlueprint?: unknown; instruction?: unknown; catalogMarkdown?: unknown; catalog?: unknown; mode?: unknown; /** * 取数授权(可选):本次生成允许绑定的已注册接口**注册名**清单。 * 不给就是既有行为——页面用 initProps 演示数据,不碰任何后端。 */ dataApis?: unknown; /** 取数授权(可选):按应用命名空间整取该 app 下的已注册接口 */ dataApp?: unknown; /** 进度任务号(客户端生成):给了就能一边等一边查"现在到哪一步" */ taskId?: unknown; /** * 编译器自带文案的语言(可选):zh-CN / en-US,不给即 zh-CN。 * 与 generateSomaPage 同一把尺子校验(assertGenerateLocale),词表外按输入非法拒绝。 */ locale?: unknown; } /** 服务端可信组件目录;Host 可在实例化服务时注入合成后的第三方组件包目录。 */ export interface TrustedGenerateCatalog { entries: readonly PageCatalogEntry[]; markdown: string; } export type TrustedGenerateCatalogProvider = () => TrustedGenerateCatalog; interface CatalogMarkdownSection { layer: string; text: string; } interface ParsedCatalogMarkdown { prelude: string; sections: Map; } export interface ResolvedGenerateCatalog { catalog: PageCatalogEntry[]; catalogMarkdown: string; } const CATALOG_LAYERS = new Set(['atom', 'composite', 'solution', 'template', 'page']); const CATALOG_HEADING = /^##[ \t]+([^\s`]+)[ \t]+`([^`\r\n]+)`(?:[ \t]+v[^\r\n]+)?[ \t]*\r?$/gm; function invalidCatalog(message: string): never { throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, `组件目录不可信:${message}`); } function parseCatalogMarkdown(markdown: string, source: '请求' | '服务端'): ParsedCatalogMarkdown { const matches = [...markdown.matchAll(CATALOG_HEADING)]; const sections = new Map(); for (let index = 0; index < matches.length; index += 1) { const match = matches[index]; const name = match[1]; const layer = match[2]; if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) { const message = `${source} Markdown 含非法组件标题:${name}`; if (source === '请求') invalidCatalog(message); throw new Error(`[GenerateService] ${message}`); } if (!CATALOG_LAYERS.has(layer)) { const message = `${source} Markdown 中组件 "${name}" 使用非法层级 "${layer}"`; if (source === '请求') invalidCatalog(message); throw new Error(`[GenerateService] ${message}`); } if (sections.has(name)) { const message = `${source} Markdown 重复声明组件 "${name}"`; if (source === '请求') invalidCatalog(message); throw new Error(`[GenerateService] ${message}`); } const start = match.index ?? 0; const end = matches[index + 1]?.index ?? markdown.length; sections.set(name, { layer, text: markdown.slice(start, end).trimEnd() }); } const first = matches[0]?.index; return { prelude: (first === undefined ? markdown : markdown.slice(0, first)).trimEnd(), sections, }; } function normalizeRequestCatalog(rawCatalog: unknown[]): PageCatalogEntry[] { const entries: PageCatalogEntry[] = []; const names = new Set(); rawCatalog.forEach((value, index) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { invalidCatalog(`catalog[${index}] 必须是组件目录对象`); } const entry = value as Record; const name = entry.name; const layer = entry.layer; if (typeof name !== 'string' || !/^[A-Z][A-Za-z0-9]*$/.test(name)) { invalidCatalog(`catalog[${index}].name 必须是 PascalCase 组件名`); } if (typeof layer !== 'string' || !CATALOG_LAYERS.has(layer)) { invalidCatalog(`组件 "${name}" 使用非法层级 "${String(layer)}"`); } if (names.has(name)) invalidCatalog(`结构化 catalog 重复声明组件 "${name}"`); names.add(name); for (const field of ['props', 'emits', 'slots'] as const) { if (!Array.isArray(entry[field])) { invalidCatalog(`组件 "${name}" 的 ${field} 必须是数组`); } } entries.push(value as PageCatalogEntry); }); return entries; } function indexTrustedCatalog(catalog: TrustedGenerateCatalog): Map { const entries = new Map(); for (const entry of catalog.entries) { if (entries.has(entry.name)) { throw new Error(`[GenerateService] 服务端可信目录重复声明组件 "${entry.name}"`); } entries.set(entry.name, entry); } return entries; } /** * HTTP 边界的目录信任收口。 * * - 服务端目录中已有的组件:结构化契约和 Markdown 全部采用服务端真值,请求只能选择,不能改层级; * - 请求中的未知组件一律拒绝;第三方 solution/page 必须先由 Host provider 注入可信目录; * - 请求 Markdown 的前言不进入模型,避免借“目录说明”夹带指令。Host 的可信规范应放进 provider 目录。 */ export function resolveGenerateCatalog( catalogMarkdown: string, rawCatalog: unknown[], trustedCatalog: TrustedGenerateCatalog, ): ResolvedGenerateCatalog { const requestedEntries = normalizeRequestCatalog(rawCatalog); const requestedByName = new Map(requestedEntries.map((entry) => [entry.name, entry])); const requestedMarkdown = parseCatalogMarkdown(catalogMarkdown, '请求'); const trustedByName = indexTrustedCatalog(trustedCatalog); const trustedMarkdown = parseCatalogMarkdown(trustedCatalog.markdown, '服务端'); for (const requested of requestedEntries) { const trusted = trustedByName.get(requested.name); if (!trusted) { invalidCatalog( `组件 "${requested.name}" 未在服务端可信目录注册;第三方组件必须由 Host trustedCatalogProvider 注入`, ); } if (requested.layer !== trusted.layer) { invalidCatalog( `组件 "${requested.name}" 的服务端层级是 "${String(trusted.layer)}",请求不能伪造为 "${String(requested.layer)}"`, ); } } for (const [name, section] of requestedMarkdown.sections) { const requested = requestedByName.get(name); if (!requested) invalidCatalog(`Markdown 声明了结构化 catalog 中不存在的组件 "${name}"`); if (section.layer !== requested.layer) { invalidCatalog( `组件 "${name}" 层级矛盾:Markdown 为 "${section.layer}",结构化 catalog 为 "${requested.layer}"`, ); } } const catalog: PageCatalogEntry[] = []; const sections: string[] = []; for (const requested of requestedEntries) { const trusted = trustedByName.get(requested.name)!; const trustedSection = trustedMarkdown.sections.get(requested.name); if (!trustedSection || trustedSection.layer !== trusted.layer) { throw new Error(`[GenerateService] 服务端可信目录缺少组件 "${requested.name}" 的同层 Markdown 契约`); } catalog.push(trusted); sections.push(trustedSection.text); } return { catalog, catalogMarkdown: [trustedMarkdown.prelude, ...sections].filter(Boolean).join('\n'), }; } /** * POST /api/ai-page/generate — 组件目录 → HTML + 单一 SOMA DSL 页面声明。 * - standard / preview:requirement(自然语言需求)必填; * - visualize:content(任意形态的结果内容)必填,requirement 可选(用户原始提问上下文); * - revise:baseDeclaration(现有页面统一声明)+ instruction(修改指令)必填—— * 会话式改页,未提及部分逐字保留,输出完整新声明。 * 可选 locale(zh-CN / en-US)决定蓝图编译产物上编译器自带文案的语言,缺省 zh-CN; * 按 baseDeclaration 修订不经编译器,不受它影响。 */ export default class GenerateService implements IService { /** * 可信目录只由 Host 注入:soma-ai 不依赖 soma-ui,没有也不该有「默认组件库」。 * * 但必须允许无参构造——soma-core 的服务装载器一律 `new ServiceClass()`, * AI 实现作为 Host 内部代码被自动装载、或 Host 直接把本类 re-export 成 default 时 * 都拿不到 provider。构造期抛错会被装载器吞掉,整个端点静默变成 404。 * 所以缺 provider 改在请求期 fail-closed:不回退任何目录,返回 AI_CONFIG_MISSING。 */ constructor( private readonly trustedCatalogProvider?: TrustedGenerateCatalogProvider, ) { if (trustedCatalogProvider !== undefined && typeof trustedCatalogProvider !== 'function') { throw new TypeError('GenerateService 的 trustedCatalogProvider 必须是返回可信目录的函数'); } } async post(req: NextRequest): Promise { return runService(async () => { try { const trustedCatalogProvider = this.trustedCatalogProvider; if (!trustedCatalogProvider) { throw new AiApiError( AiErrorCodes.AI_CONFIG_MISSING, '页面生成未配置可信组件目录:Host 需在 api/service/ai-page/Generate.ts 以子类向 ' + 'AiPageGenerateService 注入 trustedCatalogProvider', ); } // 按调用方限流:信号量限的是"同时在跑几个",防不住一个调用方串行地一直发, // 而每一次都是真金白银的上游调用。缺省 30 次/分钟(工坊连问、A/B 批跑都够)。 const limited = await enforceGenerateRateLimit(req); if (limited) { return limited; } // 入口闸:requirement 64KB / content 256KB / 目录源 1MB(喂模型 256KB)等业务上限都在 // 解析之后才判,保护的是"喂给模型的东西",不保护服务端内存。这一道在读之前判。 const body = (await readJsonWithLimit(req, AI_REQUEST_MAX_BYTES)) as GenerateBody | null; const mode = body?.mode === 'preview' ? 'preview' : body?.mode === 'visualize' ? 'visualize' : body?.mode === 'revise' ? 'revise' : 'standard'; let baseDeclaration: PageDeclarations | undefined; let baseBlueprint: SomaPageBlueprint | undefined; if (mode === 'visualize') { if (!body || typeof body.content !== 'string') { throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'visualize 模式请求体需要 { content, catalogMarkdown, catalog }'); } if (body.requirement !== undefined && typeof body.requirement !== 'string') { throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'requirement 提供时必须是字符串'); } } else if (mode === 'revise') { // 会话式改页两种底稿:**首选蓝图**(改 JSON,漂移对账退化为区块比对), // 回退统一声明(没有蓝图底稿时)。二选一必须给其一。 const blueprint = body?.baseBlueprint; const hasBlueprint = Boolean(blueprint) && typeof blueprint === 'object' && !Array.isArray(blueprint) && Array.isArray((blueprint as Record).blocks); const base = body?.baseDeclaration; const hasDeclaration = Boolean(base) && typeof base === 'object' && !Array.isArray(base) && typeof (base as Record).html === 'string' && typeof (base as Record).uiYaml === 'string'; if (!body || typeof body.instruction !== 'string' || (!hasBlueprint && !hasDeclaration)) { throw new AiApiError( AiErrorCodes.AI_INPUT_INVALID, 'revise 模式请求体需要 { instruction, catalogMarkdown, catalog } 与底稿二选一:' + 'baseBlueprint: { title, blocks } (首选)或 baseDeclaration: { html, uiYaml, … }', ); } if (hasBlueprint) { baseBlueprint = blueprint as SomaPageBlueprint; } else { const record = base as Record; for (const field of ['eventYaml', 'styleYaml', 'dataYaml'] as const) { if (typeof record[field] === 'string' && (record[field] as string).trim() !== '') { throw new AiApiError( AiErrorCodes.AI_INPUT_INVALID, `baseDeclaration.${field} 页面侧车已退役;请先离线迁移为单一 SOMA DSL`, ); } } baseDeclaration = { html: record.html as string, uiYaml: record.uiYaml as string, eventYaml: '', }; } } else if (!body || typeof body.requirement !== 'string') { throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, '请求体需要 { requirement, catalogMarkdown, catalog }'); } if (typeof body.catalogMarkdown !== 'string' || !Array.isArray(body.catalog)) { throw new AiApiError(AiErrorCodes.AI_INPUT_INVALID, 'catalogMarkdown 必须是字符串、catalog 必须是目录条目数组'); } // locale 与请求体形状一起判:先于取数授权解析与目录 provider,词表外不读目录、不打上游 const locale = assertGenerateLocale(body.locale); const dataApis = resolveDataApis(body); const resolvedCatalog = resolveGenerateCatalog( body.catalogMarkdown, body.catalog, trustedCatalogProvider(), ); const result = await generateSomaPage({ requirement: typeof body.requirement === 'string' ? body.requirement : undefined, content: typeof body.content === 'string' ? body.content : undefined, baseDeclaration, baseBlueprint, instruction: typeof body.instruction === 'string' ? body.instruction : undefined, catalogMarkdown: resolvedCatalog.catalogMarkdown, catalog: resolvedCatalog.catalog, ...(dataApis.length > 0 ? { dataApis } : {}), ...(locale ? { locale } : {}), mode, signal: req.signal, ...(typeof body.taskId === 'string' ? { taskId: body.taskId } : {}), }); return aiSuccessResponse(result); } catch (error) { if (error instanceof RequestBodyTooLargeError) { return aiErrorResponse(new AiApiError(AiErrorCodes.AI_INPUT_INVALID, error.message)); } return aiErrorResponse(error); } }); } } /** * 解析本次生成的取数授权。 * * **默认不授权任何接口**——这是刻意的:注册表里有什么是平台的事, * 这一次生成允许绑什么是应用的事。默认全给等于让每个生成的页面都能碰到 * 全部后端接口,那是把"组织"偷偷收归平台。 * * 两种指定方式(可叠加,按注册名去重): * - `dataApis: ["orders", "stock"]` —— 逐个点名(千人千面:给销售的那次只给客户接口); * - `dataApp: "shop"` —— 按应用命名空间整取。 * 点名了却查不到的接口直接报错:静默丢弃会让调用方以为授权了, * 结果模型无表可绑,产出一页假数据还不知道为什么。 */ export function resolveDataApis(body: GenerateBody): GenerationDataApi[] { const names: string[] = Array.isArray(body.dataApis) ? body.dataApis.filter((name): name is string => typeof name === 'string' && name !== '') : []; const app = typeof body.dataApp === 'string' && body.dataApp !== '' ? body.dataApp : undefined; if (names.length === 0 && !app) return []; const store = getApiRegistryStore(); const picked = new Map(); if (app) { for (const item of store.list({ app }).apis) { picked.set(item.name, projectApi(store.get(item.name))); } } for (const name of names) { if (picked.has(name)) continue; try { picked.set(name, projectApi(store.get(name))); } catch (error) { if (error instanceof ApiRegistryError) { throw new AiApiError( AiErrorCodes.AI_INPUT_INVALID, `dataApis 指定的接口未注册:${name}(先 POST /api/soma-registry/apis/register 登记它)`, ); } throw error; } } return [...picked.values()]; } /** 注册记录 → 喂模型的投影:只留组织页面用得上的字段,不带任何调用细节 */ function projectApi(record: { name: string; path: string; method: string; description?: string; fields?: Record; }): GenerationDataApi { return { name: record.name, path: record.path, method: record.method, ...(record.description ? { description: record.description } : {}), ...(record.fields ? { fields: record.fields } : {}), }; }