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
+57
View File
@@ -0,0 +1,57 @@
import type { UIMessageChunk } from "ai";
/** Canonical parts are projected independently of an attached browser response. */
export function createAnswerStreamProjection(options: {
messageId: string;
sessionId: string;
write: (chunk: UIMessageChunk) => void;
onChange: () => void;
}) {
const parts: Record<string, unknown>[] = [];
let segment = 0;
let textId = "answer";
let currentText: Record<string, unknown> | undefined;
let finished = false;
const endText = () => {
if (!currentText) return;
currentText.state = "done";
options.write({ type: "text-end", id: textId });
currentText = undefined;
textId = `answer-${++segment}`;
};
const appendText = (delta: string) => {
if (!delta || finished) return;
if (!currentText) {
currentText = { type: "text", text: "", state: "streaming" };
parts.push(currentText);
options.write({ type: "text-start", id: textId });
}
currentText.text = `${String(currentText.text)}${delta}`;
options.write({ type: "text-delta", id: textId, delta });
options.write({ type: "data-stream_token", transient: true, data: {
session_id: options.sessionId, message_id: options.messageId, content: delta,
} } as UIMessageChunk);
options.onChange();
};
const appendData = (event: string, data: Record<string, unknown>) => {
if (finished) return;
if (event === "answer_ui") endText();
const type = `data-${event}`;
const id = [data.id, data.request_id, data.envelope_id, data.block_id].find((value): value is string => typeof value === "string");
const existing = id === undefined ? -1 : parts.findIndex((part) => part.type === type && part.id === id);
const part = id ? { type, id, data } : { type, data };
if (existing >= 0) parts[existing] = part;
else parts.push(part);
options.write(part as UIMessageChunk);
options.onChange();
};
const finish = (finishReason: "stop" | "error" = "stop") => {
if (finished) return;
if (!parts.length) appendText("Agent 已完成处理。");
endText();
finished = true;
options.onChange();
options.write({ type: "finish", finishReason });
};
return { parts, appendText, appendData, finish, get finished() { return finished; } };
}
+454
View File
@@ -0,0 +1,454 @@
{
"version": "answer-ui@1",
"description": "Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.",
"schema": {
"type": "object",
"properties": {
"root": {
"type": "string"
},
"elements": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"enum": [
"Stack",
"Section",
"Text",
"Metric",
"Table",
"Chart",
"Evidence",
"MapLocation",
"FollowUpForm"
]
},
"props": {
"type": "object",
"additionalProperties": {}
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"slots": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"visible": {},
"repeat": {}
},
"required": [
"type",
"props",
"children"
],
"additionalProperties": false
}
}
},
"required": [
"root",
"elements"
],
"additionalProperties": false
},
"components": [
"Stack",
"Section",
"Text",
"Metric",
"Table",
"Chart",
"Evidence",
"MapLocation",
"FollowUpForm"
],
"props": {
"Stack": {
"type": "object",
"properties": {
"columns": {
"type": "string",
"enum": [
"one",
"two"
]
},
"gap": {
"type": "string",
"enum": [
"compact",
"normal"
]
}
},
"required": [
"columns",
"gap"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Section": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"description": {
"anyOf": [
{
"type": "string",
"maxLength": 4000
},
{
"type": "null"
}
]
}
},
"required": [
"title",
"description"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Text": {
"type": "object",
"properties": {
"text": {
"type": "string",
"maxLength": 4000
},
"tone": {
"type": "string",
"enum": [
"neutral",
"info",
"warning"
]
}
},
"required": [
"text",
"tone"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Metric": {
"type": "object",
"properties": {
"label": {
"type": "string",
"maxLength": 200
},
"value": {
"type": "string",
"maxLength": 200
},
"unit": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"source": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
}
},
"required": [
"label",
"value",
"unit",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Table": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"columns": {
"type": "array",
"items": {
"type": "string",
"maxLength": 200
},
"minItems": 1,
"maxItems": 8
},
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string",
"maxLength": 1000
},
"maxItems": 8
},
"maxItems": 50
}
},
"required": [
"title",
"columns",
"rows"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Chart": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"chart_type": {
"type": "string",
"enum": [
"line",
"bar"
]
},
"x_data": {
"type": "array",
"items": {
"type": "string",
"maxLength": 200
},
"minItems": 1,
"maxItems": 100
},
"series": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 200
},
"data": {
"type": "array",
"items": {
"type": "number"
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"name",
"data"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 8
},
"source": {
"type": "string",
"maxLength": 4000
}
},
"required": [
"title",
"chart_type",
"x_data",
"series",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Evidence": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"summary": {
"type": "string",
"maxLength": 4000
},
"source": {
"type": "string",
"maxLength": 200
},
"evidence_id": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"observed_at": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"kind": {
"type": "string",
"enum": [
"observed",
"human_input",
"inference",
"demo"
]
}
},
"required": [
"title",
"summary",
"source",
"evidence_id",
"observed_at",
"kind"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"MapLocation": {
"type": "object",
"properties": {
"label": {
"type": "string",
"maxLength": 200
},
"layer": {
"type": "string",
"enum": [
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]
},
"feature_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
},
"source": {
"type": "string",
"maxLength": 200
}
},
"required": [
"label",
"layer",
"feature_ids",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"FollowUpForm": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"description": {
"type": "string",
"maxLength": 4000
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,39}$"
},
"label": {
"type": "string",
"maxLength": 200
},
"kind": {
"type": "string",
"enum": [
"text",
"datetime",
"number"
]
},
"required": {
"type": "boolean"
}
},
"required": [
"name",
"label",
"kind",
"required"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 12
}
},
"required": [
"title",
"description",
"fields"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
}
+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;
};