feat(chat): stream and persist local answer UI with final report references

This commit is contained in:
2026-10-12 18:54:18 +08:00
parent a31b76a793
commit f5c3cde2c2
20 changed files with 827 additions and 105 deletions
+43
View File
@@ -0,0 +1,43 @@
import { Ajv } from "ajv";
import { z } from "zod";
import catalog from "./answer-ui-catalog.json" with { type: "json" };
const ajv = new Ajv({ allErrors: false, strict: true });
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[] }> };
if (props.series.some((series) => series.data.length !== props.x_data.length)) { 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, fallback_text: z.string().min(1).max(4000), spec: answerUiSpecSchema }).strict();
export const parseAnswerUiInput = (value: unknown) => {
const parsed = answerUiInputSchema.safeParse(value);
return parsed.success ? { version: "answer-ui@1" as const, ...parsed.data } : null;
};