import { Ajv } from "ajv"; import { z } from "zod"; import { createChartSampleTimeSchema } from "../frontendAction/command.js"; import catalog from "./answer-ui-catalog.json" with { type: "json" }; const ajv = new Ajv({ allErrors: false, strict: true }); const sampleTimeSchema = createChartSampleTimeSchema(); ajv.addFormat("date-time", (value: string) => sampleTimeSchema.safeParse(value).success); const propertyValidators = Object.fromEntries(Object.entries(catalog.props).map(([name, schema]) => [name, ajv.compile(schema)])); const key = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/); export const answerUiSpecSchema = z.object({ root: key, elements: z.record(z.object({ type: z.string(), props: z.record(z.unknown()), children: z.array(key).max(48), }).strict()) }).strict().superRefine((spec, context) => { const fail = () => context.addIssue({ code: z.ZodIssueCode.custom, message: "界面必须使用已注册组件、合法参数和至多 48 节点的无重复树结构" }); const names = Object.keys(spec.elements); if (!names.length || names.length > 48 || JSON.stringify(spec).length > 100000 || names.some((id) => !key.safeParse(id).success)) { fail(); return; } for (const node of Object.values(spec.elements)) { const validate = Object.hasOwn(propertyValidators, node.type) ? propertyValidators[node.type] : undefined; if (!validate?.(node.props) || (!["Stack", "Section"].includes(node.type) && node.children.length)) { fail(); return; } if (node.type === "Chart") { const props = node.props as { x_data: unknown[]; series: Array<{ data: unknown[]; sample_times?: Array }> }; if (props.series.some((series) => series.data.length !== props.x_data.length || (series.sample_times !== undefined && (series.sample_times.length !== props.x_data.length || series.sample_times.some((at, index) => at !== null && series.data[index] === null))))) { fail(); return; } } if (node.type === "Table") { const props = node.props as { columns: unknown[]; rows: unknown[][] }; if (props.rows.some((row) => row.length !== props.columns.length)) { fail(); return; } } if (node.type === "FollowUpForm") { const fields = node.props.fields as Array<{ name: string }>; if (new Set(fields.map((field) => field.name)).size !== fields.length) { fail(); return; } } } const visited = new Set(); const visit = (id: string, depth: number): boolean => { if (depth > 12 || visited.has(id) || !Object.hasOwn(spec.elements, id)) return false; visited.add(id); return spec.elements[id].children.every((child) => visit(child, depth + 1)); }; if (!visit(spec.root, 0) || visited.size !== names.length) fail(); }); export const answerUiInputSchema = z.object({ block_id: key, phase: z.enum(["process", "final"]).optional(), fallback_text: z.string().min(1).max(4000), spec: answerUiSpecSchema }).strict(); export const answerUiBlockSchema = answerUiInputSchema.extend({ version: z.literal("answer-ui@1") }) .refine((block) => JSON.stringify(block).length <= 100000, "界面块过大"); export const parseAnswerUiInput = (value: unknown) => { const parsed = answerUiInputSchema.safeParse(value); return parsed.success ? { version: "answer-ui@1" as const, ...parsed.data, phase: parsed.data.phase ?? "process" } : null; };