Files
next-tjwater-agent/src/chat/answer-ui.ts
T
jiang b8f7edb3ee
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m53s
feat(agent): improve investigation contracts and runtime readiness
2026-11-07 19:43:57 +08:00

51 lines
3.3 KiB
TypeScript

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<string | null> }> };
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<string>();
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;
};