feat(agent): render local interactive answers and fold completed process
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { StreamingTokenResponse } from "../components/streaming-token-response";
|
||||
import { AnswerUiView, type AnswerUiMapTarget } from "./answer-ui-view";
|
||||
import type { AnswerUiPart } from "./contract";
|
||||
|
||||
export function AnswerPartsView({
|
||||
parts,
|
||||
messageId,
|
||||
disabled,
|
||||
streaming,
|
||||
streamDone,
|
||||
onLocate,
|
||||
onSubmit
|
||||
}: {
|
||||
parts: AnswerUiPart[];
|
||||
messageId: string;
|
||||
disabled: boolean;
|
||||
streaming: boolean;
|
||||
streamDone: boolean;
|
||||
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{parts.map((part, index) =>
|
||||
part.type === "answer-ui" ? (
|
||||
<AnswerUiView
|
||||
key={`ui-${part.block.block_id}`}
|
||||
block={part.block}
|
||||
disabled={disabled}
|
||||
onLocate={onLocate}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
) : (
|
||||
<StreamingTokenResponse
|
||||
key={`text-${index}`}
|
||||
className="leading-6"
|
||||
content={part.text}
|
||||
messageId={`${messageId}-${index}`}
|
||||
streamDone={streamDone}
|
||||
streaming={streaming}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
import { getAnswerPresentation } from "./answer-presentation";
|
||||
import type { AnswerUiPart } from "./contract";
|
||||
|
||||
const ui = (id: string): AnswerUiPart => ({
|
||||
type: "answer-ui",
|
||||
block: {
|
||||
version: "answer-ui@1",
|
||||
block_id: id,
|
||||
fallback_text: id,
|
||||
spec: {
|
||||
root: "text",
|
||||
elements: { text: { type: "Text", props: { text: id, tone: "neutral" }, children: [] } }
|
||||
}
|
||||
}
|
||||
});
|
||||
const parts: AnswerUiPart[] = [
|
||||
{ type: "text", text: "正在核对资料。" },
|
||||
ui("report-chart"),
|
||||
ui("temporary"),
|
||||
{ type: "text", text: "查询结束。" }
|
||||
];
|
||||
const message: AgentChatMessage = {
|
||||
id: "a",
|
||||
role: "assistant",
|
||||
content: "正在核对资料。",
|
||||
answerParts: parts
|
||||
};
|
||||
describe("final report and collapsible process", () => {
|
||||
it("shows all incremental content while waiting for final_answer", () => {
|
||||
expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false });
|
||||
});
|
||||
it("keeps only the final report and explicitly selected components expanded", () => {
|
||||
const view = getAnswerPresentation({
|
||||
...message,
|
||||
finalAnswer: {
|
||||
content: "最后的报告内容。",
|
||||
uiBlockIds: ["report-chart", "report-chart", "missing"]
|
||||
}
|
||||
});
|
||||
expect(view.report).toEqual([{ type: "text", text: "最后的报告内容。" }, parts[1]]);
|
||||
expect(view.process).toEqual([parts[0], parts[2], parts[3]]);
|
||||
expect(view.final).toBe(true);
|
||||
});
|
||||
it("folds every UI block when the final report does not reference any", () => {
|
||||
const view = getAnswerPresentation({
|
||||
...message,
|
||||
finalAnswer: { content: "报告", uiBlockIds: [] }
|
||||
});
|
||||
expect(view.report).toEqual([{ type: "text", text: "报告" }]);
|
||||
expect(view.process).toEqual(parts);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AgentChatMessage } from "../types";
|
||||
import type { AnswerUiPart } from "./contract";
|
||||
|
||||
export function getAnswerPresentation(message: AgentChatMessage): {
|
||||
report: AnswerUiPart[];
|
||||
process: AnswerUiPart[];
|
||||
final: boolean;
|
||||
} {
|
||||
const parts =
|
||||
message.answerParts ??
|
||||
(message.content ? [{ type: "text" as const, text: message.content }] : []);
|
||||
if (!message.finalAnswer) return { report: parts, process: [], final: false };
|
||||
const selected = new Set(message.finalAnswer.uiBlockIds);
|
||||
const report: AnswerUiPart[] = [{ type: "text", text: message.finalAnswer.content }];
|
||||
for (const id of selected) {
|
||||
const part = parts.find((item) => item.type === "answer-ui" && item.block.block_id === id);
|
||||
if (part) report.push(part);
|
||||
}
|
||||
const process = parts.filter((part) =>
|
||||
part.type === "answer-ui"
|
||||
? !selected.has(part.block.block_id)
|
||||
: part.text.trim() !== message.finalAnswer!.content.trim()
|
||||
);
|
||||
return { report, process, final: true };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AnswerUiView } from "./answer-ui-view";
|
||||
import { parseAnswerUiBlock } from "./contract";
|
||||
|
||||
function block(type: string, props: Record<string, unknown>) {
|
||||
const parsed = parseAnswerUiBlock({
|
||||
version: "answer-ui@1",
|
||||
block_id: "test",
|
||||
fallback_text: "演练",
|
||||
spec: { root: "node", elements: { node: { type, props, children: [] } } }
|
||||
});
|
||||
if (!parsed) throw new Error("invalid test fixture");
|
||||
return parsed;
|
||||
}
|
||||
describe("local interactive answer renderer", () => {
|
||||
it("renders through json-render and lets readers inspect exact chart data", () => {
|
||||
render(
|
||||
<AnswerUiView
|
||||
block={block("Chart", {
|
||||
title: "演练压力",
|
||||
chart_type: "line",
|
||||
source: "演练数据",
|
||||
x_data: ["08:00", "09:00"],
|
||||
series: [{ name: "压力", data: [0.3, 0.2] }]
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^数据$/ }));
|
||||
expect(screen.getByRole("table")).toHaveTextContent("0.3");
|
||||
expect(screen.getByRole("table")).toHaveTextContent("09:00");
|
||||
expect(screen.getByRole("button", { name: /^数据$/ })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
it("runs map location only after a user click and reports missing IDs", async () => {
|
||||
const locate = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ located_ids: ["DEMO-P001"], missing_ids: ["DEMO-P002"] });
|
||||
render(
|
||||
<AnswerUiView
|
||||
block={block("MapLocation", {
|
||||
label: "定位演练监测点",
|
||||
layer: "scada",
|
||||
feature_ids: ["DEMO-P001", "DEMO-P002"],
|
||||
source: "演练数据"
|
||||
})}
|
||||
onLocate={locate}
|
||||
/>
|
||||
);
|
||||
expect(locate).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "定位演练监测点" }));
|
||||
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("另有 1 个未找到"));
|
||||
expect(locate).toHaveBeenCalledWith({
|
||||
layer: "scada",
|
||||
feature_ids: ["DEMO-P001", "DEMO-P002"]
|
||||
});
|
||||
});
|
||||
it("starts blank, requires input and sends one user message without claiming evidence is saved", async () => {
|
||||
let complete!: () => void;
|
||||
const submit = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
complete = resolve;
|
||||
})
|
||||
);
|
||||
render(
|
||||
<AnswerUiView
|
||||
block={block("FollowUpForm", {
|
||||
title: "补充现场资料",
|
||||
description: "由用户提供",
|
||||
fields: [{ name: "location", label: "现场位置", kind: "text", required: true }]
|
||||
})}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByLabelText("现场位置(必填)")).toHaveValue("");
|
||||
fireEvent.submit(screen.getByRole("form", { name: "补充现场资料" }));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("请填写必填资料");
|
||||
fireEvent.change(screen.getByLabelText("现场位置(必填)"), {
|
||||
target: { value: "用户指定位置" }
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("form", { name: "补充现场资料" }));
|
||||
expect(submit).toHaveBeenCalledTimes(1);
|
||||
expect(submit.mock.calls[0]).toEqual([expect.stringContaining("现场位置:用户指定位置")]);
|
||||
expect(screen.getByRole("button", { name: "正在发送…" })).toBeDisabled();
|
||||
complete();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("资料已发送,请查看 Agent 回复")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
|
||||
import { createContext, useContext, useId, useState } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { AgentChartView } from "../components/agent-ui-envelope-renderer";
|
||||
import { answerUiCatalog } from "./catalog";
|
||||
import type { AnswerUiBlock } from "./contract";
|
||||
|
||||
export type AnswerUiMapTarget = {
|
||||
layer: "junctions" | "pipes" | "valves" | "pumps" | "tanks" | "reservoirs" | "scada";
|
||||
feature_ids: string[];
|
||||
};
|
||||
type Interactions = {
|
||||
disabled: boolean;
|
||||
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
};
|
||||
const InteractionContext = createContext<Interactions>({ disabled: true });
|
||||
|
||||
const { registry } = defineRegistry(answerUiCatalog, {
|
||||
actions: {},
|
||||
components: {
|
||||
Stack: ({ props, children }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"answer-ui-stack",
|
||||
props.columns === "two" && "answer-ui-stack-comparison",
|
||||
props.gap === "compact" && "answer-ui-stack-compact"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Section: ({ props, children }) => (
|
||||
<section className="answer-ui-section">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">{props.title}</h3>
|
||||
{props.description ? (
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{props.description}</p>
|
||||
) : null}
|
||||
<div className="mt-3 space-y-3">{children}</div>
|
||||
</section>
|
||||
),
|
||||
Text: ({ props }) => (
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap text-sm leading-6",
|
||||
props.tone === "warning"
|
||||
? "text-[var(--status-warning-foreground)]"
|
||||
: props.tone === "info"
|
||||
? "text-[var(--action-blue-hover)]"
|
||||
: "text-[var(--ink)]"
|
||||
)}
|
||||
>
|
||||
{props.text}
|
||||
</p>
|
||||
),
|
||||
Metric: ({ props }) => (
|
||||
<div className="min-w-0 py-2">
|
||||
<p className="text-xs text-[var(--ink-muted)]">{props.label}</p>
|
||||
<p className="mt-1 break-words text-xl font-semibold tabular-nums text-[var(--ink)]">
|
||||
{props.value}
|
||||
{props.unit ? <span className="ml-1 text-xs font-normal">{props.unit}</span> : null}
|
||||
</p>
|
||||
{props.source ? (
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">来源:{props.source}</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
Table: ({ props }) => (
|
||||
<DataTable title={props.title} columns={props.columns} rows={props.rows} />
|
||||
),
|
||||
Chart: ({ props }) => <AnswerChart {...props} />,
|
||||
Evidence: ({ props }) => (
|
||||
<Disclosure label={props.title} className="answer-ui-evidence">
|
||||
<div className="space-y-2 py-2 text-xs leading-5">
|
||||
<p className="text-[var(--ink-muted)]">
|
||||
{
|
||||
(
|
||||
{
|
||||
observed: "查询记录",
|
||||
human_input: "用户提供",
|
||||
inference: "分析推断",
|
||||
demo: "演练数据"
|
||||
} as const
|
||||
)[props.kind]
|
||||
}{" "}
|
||||
· {props.source}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{props.summary}</p>
|
||||
{props.observed_at ? <p>记录时间:{props.observed_at}</p> : null}
|
||||
{props.evidence_id ? <p>资料编号:{props.evidence_id}</p> : null}
|
||||
</div>
|
||||
</Disclosure>
|
||||
),
|
||||
MapLocation: ({ props }) => <LocateButton {...props} />,
|
||||
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
|
||||
}
|
||||
});
|
||||
|
||||
export function AnswerUiView({
|
||||
block,
|
||||
disabled = false,
|
||||
onLocate,
|
||||
onSubmit
|
||||
}: { block: AnswerUiBlock } & Partial<Interactions>) {
|
||||
return (
|
||||
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit }}>
|
||||
<div className="answer-ui-root" data-answer-ui={block.block_id}>
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={block.spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
</div>
|
||||
</InteractionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTable({
|
||||
title,
|
||||
columns,
|
||||
rows
|
||||
}: {
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: string[][];
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
role="region"
|
||||
aria-label={title}
|
||||
tabIndex={0}
|
||||
className="overflow-x-auto rounded-lg border border-[var(--surface-divider)]"
|
||||
>
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<caption className="px-3 py-2 text-left font-medium text-[var(--ink)]">{title}</caption>
|
||||
<thead className="bg-[var(--surface-subtle)]">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th key={index} className="whitespace-nowrap px-3 py-2 font-medium">
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={index} className="border-t border-[var(--surface-divider)]">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="min-w-24 px-3 py-2 align-top">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChartProps = {
|
||||
title: string;
|
||||
chart_type: "line" | "bar";
|
||||
x_data: string[];
|
||||
series: Array<{ name: string; data: number[] }>;
|
||||
source: string;
|
||||
};
|
||||
function AnswerChart(props: ChartProps) {
|
||||
const [mode, setMode] = useState<"line" | "bar" | "table">(props.chart_type);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div role="group" aria-label={`${props.title}显示方式`} className="mb-2 flex flex-wrap gap-1">
|
||||
{(
|
||||
[
|
||||
["line", "折线"],
|
||||
["bar", "柱状"],
|
||||
["table", "数据"]
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-pressed={mode === value}
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{mode === "table" ? (
|
||||
<DataTable
|
||||
title={props.title}
|
||||
columns={["时间或类别", ...props.series.map((series) => series.name)]}
|
||||
rows={props.x_data.map((label, index) => [
|
||||
label,
|
||||
...props.series.map((series) => String(series.data[index]))
|
||||
])}
|
||||
/>
|
||||
) : (
|
||||
<AgentChartView chart={{ ...props, chart_type: mode }} />
|
||||
)}
|
||||
<p className="mt-2 whitespace-pre-wrap text-xs leading-5 text-[var(--ink-muted)]">
|
||||
来源:{props.source}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocateButton({
|
||||
label,
|
||||
source,
|
||||
...target
|
||||
}: AnswerUiMapTarget & { label: string; source: string }) {
|
||||
const { disabled, onLocate } = useContext(InteractionContext);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || running || !onLocate}
|
||||
onClick={async () => {
|
||||
setRunning(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const result = await onLocate?.(target);
|
||||
const output = result as { located_ids?: string[]; missing_ids?: string[] } | undefined;
|
||||
setFeedback(
|
||||
output?.missing_ids?.length
|
||||
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
|
||||
: "已定位到地图"
|
||||
);
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : "地图定位失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{running ? "正在定位…" : label}
|
||||
</Button>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
|
||||
{feedback ? (
|
||||
<p role="status" className="mt-1 text-xs leading-5">
|
||||
{feedback}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FormField = {
|
||||
name: string;
|
||||
label: string;
|
||||
kind: "text" | "datetime" | "number";
|
||||
required: boolean;
|
||||
};
|
||||
function FollowUpForm({
|
||||
title,
|
||||
description,
|
||||
fields
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
fields: FormField[];
|
||||
}) {
|
||||
const { disabled, onSubmit } = useContext(InteractionContext);
|
||||
const prefix = useId();
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
return (
|
||||
<form
|
||||
className="answer-ui-form space-y-3"
|
||||
aria-label={title}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (fields.some((field) => field.required && !values[field.name]?.trim())) {
|
||||
setFeedback("请填写必填资料。");
|
||||
return;
|
||||
}
|
||||
if (!onSubmit || disabled || submitting) return;
|
||||
const entries = fields
|
||||
.filter((field) => values[field.name]?.trim())
|
||||
.map(
|
||||
(field) =>
|
||||
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
|
||||
);
|
||||
if (!entries.length) {
|
||||
setFeedback("请至少填写一项资料。");
|
||||
return;
|
||||
}
|
||||
if (entries.join("\n").length > 9000) {
|
||||
setFeedback("补充资料过长,请分次发送。");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
await onSubmit(
|
||||
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
|
||||
);
|
||||
setFeedback("资料已发送,请查看 Agent 回复。");
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
|
||||
</div>
|
||||
{fields.map((field) =>
|
||||
field.kind === "datetime" ? (
|
||||
<DateTimeField
|
||||
key={field.name}
|
||||
label={`${field.label}${field.required ? "(必填)" : ""}`}
|
||||
value={values[field.name] ?? ""}
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
onChange={(value) => setValues((current) => ({ ...current, [field.name]: value }))}
|
||||
/>
|
||||
) : (
|
||||
<div key={field.name} className="space-y-1.5">
|
||||
<label htmlFor={`${prefix}-${field.name}`} className="block text-xs font-medium">
|
||||
{field.label}
|
||||
{field.required ? "(必填)" : ""}
|
||||
</label>
|
||||
{field.kind === "text" ? (
|
||||
<Textarea
|
||||
id={`${prefix}-${field.name}`}
|
||||
value={values[field.name] ?? ""}
|
||||
maxLength={500}
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setValues((current) => ({ ...current, [field.name]: value }));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`${prefix}-${field.name}`}
|
||||
type="number"
|
||||
value={values[field.name] ?? ""}
|
||||
maxLength={500}
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setValues((current) => ({ ...current, [field.name]: value }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
>
|
||||
{submitting ? "正在发送…" : "发送补充资料"}
|
||||
</Button>
|
||||
{feedback ? (
|
||||
<p role="status" className="text-xs leading-5">
|
||||
{feedback}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
const shortText = z.string().max(200);
|
||||
const text = z.string().max(4000);
|
||||
const optionalText = shortText.nullable();
|
||||
const featureId = z.string().min(1).max(128);
|
||||
|
||||
/** This is the source of the generated Agent contract, not a remotely hosted registry. */
|
||||
export const answerUiCatalog = defineCatalog(schema, {
|
||||
actions: {},
|
||||
components: {
|
||||
Stack: {
|
||||
props: z
|
||||
.object({ columns: z.enum(["one", "two"]), gap: z.enum(["compact", "normal"]) })
|
||||
.strict(),
|
||||
slots: ["default"],
|
||||
description: "组织内容;双列在窄面板自动改为单列。"
|
||||
},
|
||||
Section: {
|
||||
props: z.object({ title: shortText, description: text.nullable() }).strict(),
|
||||
slots: ["default"],
|
||||
description: "带标题的资料分区,保留真实内容层级。"
|
||||
},
|
||||
Text: {
|
||||
props: z.object({ text, tone: z.enum(["neutral", "info", "warning"]) }).strict(),
|
||||
description: "简短说明、判断边界或资料缺口。"
|
||||
},
|
||||
Metric: {
|
||||
props: z
|
||||
.object({ label: shortText, value: shortText, unit: optionalText, source: optionalText })
|
||||
.strict(),
|
||||
description: "展示真实指标及其单位、来源;不能臆造读数。"
|
||||
},
|
||||
Table: {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
columns: z.array(shortText).min(1).max(8),
|
||||
rows: z.array(z.array(z.string().max(1000)).max(8)).max(50)
|
||||
})
|
||||
.strict(),
|
||||
description: "按相同字段比较资料,避免无法比较的指标混列。"
|
||||
},
|
||||
Chart: {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
x_data: z.array(shortText).min(1).max(100),
|
||||
series: z
|
||||
.array(
|
||||
z
|
||||
.object({ name: shortText, data: z.array(z.number().finite()).min(1).max(100) })
|
||||
.strict()
|
||||
)
|
||||
.min(1)
|
||||
.max(8),
|
||||
source: text
|
||||
})
|
||||
.strict(),
|
||||
description:
|
||||
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
|
||||
},
|
||||
Evidence: {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
summary: text,
|
||||
source: shortText,
|
||||
evidence_id: optionalText,
|
||||
observed_at: optionalText,
|
||||
kind: z.enum(["observed", "human_input", "inference", "demo"])
|
||||
})
|
||||
.strict(),
|
||||
description: "展示证据依据;明确区分查询记录、用户事实、推断和演练数据。"
|
||||
},
|
||||
MapLocation: {
|
||||
props: z
|
||||
.object({
|
||||
label: shortText,
|
||||
layer: z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]),
|
||||
feature_ids: z.array(featureId).min(1).max(100),
|
||||
source: shortText
|
||||
})
|
||||
.strict(),
|
||||
description: "用户点击后定位已有要素;ID 必须来自查询或用户明确提供,不绘制臆测影响范围。"
|
||||
},
|
||||
FollowUpForm: {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
description: text,
|
||||
fields: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/),
|
||||
label: shortText,
|
||||
kind: z.enum(["text", "datetime", "number"]),
|
||||
required: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.min(1)
|
||||
.max(12)
|
||||
})
|
||||
.strict(),
|
||||
description:
|
||||
"让用户主动填写并提交补充资料;无默认事实。提交产生用户消息,是否写入业务证据由现有 Agent 工具处理。"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const ANSWER_UI_VERSION = "answer-ui@1" as const;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAnswerUiBlock } from "./contract";
|
||||
|
||||
const block = {
|
||||
version: "answer-ui@1",
|
||||
block_id: "reading",
|
||||
fallback_text: "仍待核对",
|
||||
spec: {
|
||||
root: "note",
|
||||
elements: { note: { type: "Text", props: { text: "仍待核对", tone: "neutral" }, children: [] } }
|
||||
}
|
||||
};
|
||||
describe("untrusted answer components", () => {
|
||||
it("accepts registered text and rejects arbitrary code, URLs and unknown components", () => {
|
||||
expect(parseAnswerUiBlock(block)).not.toBeNull();
|
||||
for (const node of [
|
||||
{ type: "iframe", props: { src: "https://example.com" }, children: [] },
|
||||
{
|
||||
...block.spec.elements.note,
|
||||
props: { ...block.spec.elements.note.props, script: "alert(1)" }
|
||||
},
|
||||
{ ...block.spec.elements.note, on: { click: { action: "fetch" } } }
|
||||
])
|
||||
expect(
|
||||
parseAnswerUiBlock({ ...block, spec: { root: "note", elements: { note: node } } })
|
||||
).toBeNull();
|
||||
});
|
||||
it("rejects missing, duplicate, cyclic, unreachable and excessive trees", () => {
|
||||
const stack = { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["note"] };
|
||||
for (const children of [["missing"], ["note", "note"], ["root"]])
|
||||
expect(
|
||||
parseAnswerUiBlock({
|
||||
...block,
|
||||
spec: { root: "root", elements: { root: { ...stack, children }, ...block.spec.elements } }
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseAnswerUiBlock({
|
||||
...block,
|
||||
spec: {
|
||||
...block.spec,
|
||||
elements: { ...block.spec.elements, unused: block.spec.elements.note }
|
||||
}
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
it("requires aligned, finite chart values and unambiguous form fields", () => {
|
||||
const chart = {
|
||||
type: "Chart",
|
||||
props: {
|
||||
title: "演练压力",
|
||||
chart_type: "line",
|
||||
source: "演练数据",
|
||||
x_data: ["08:00"],
|
||||
series: [{ name: "压力", data: [0.3] }]
|
||||
},
|
||||
children: []
|
||||
};
|
||||
const withNode = (node: unknown) => ({
|
||||
...block,
|
||||
spec: { root: "note", elements: { note: node } }
|
||||
});
|
||||
expect(parseAnswerUiBlock(withNode(chart))).not.toBeNull();
|
||||
for (const data of [[0.3, 0.2], [Infinity], [NaN]])
|
||||
expect(
|
||||
parseAnswerUiBlock(
|
||||
withNode({ ...chart, props: { ...chart.props, series: [{ name: "压力", data }] } })
|
||||
)
|
||||
).toBeNull();
|
||||
const field = { name: "location", label: "位置", kind: "text", required: true };
|
||||
expect(
|
||||
parseAnswerUiBlock(
|
||||
withNode({
|
||||
type: "FollowUpForm",
|
||||
props: { title: "核验", description: "用户补充", fields: [field, field] },
|
||||
children: []
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { z } from "zod";
|
||||
import { answerUiCatalog, ANSWER_UI_VERSION } from "./catalog";
|
||||
|
||||
const key = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
|
||||
const element = z
|
||||
.object({ type: z.string(), props: z.record(z.unknown()), children: z.array(key).max(48) })
|
||||
.strict();
|
||||
const specSchema = z.object({ root: key, elements: z.record(element) }).strict();
|
||||
const payloadSchema = z
|
||||
.object({
|
||||
version: z.literal(ANSWER_UI_VERSION),
|
||||
block_id: key,
|
||||
fallback_text: z.string().min(1).max(4000),
|
||||
spec: specSchema
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type AnswerUiBlock = {
|
||||
version: typeof ANSWER_UI_VERSION;
|
||||
block_id: string;
|
||||
fallback_text: string;
|
||||
spec: Spec;
|
||||
};
|
||||
export type AnswerUiPart =
|
||||
{ type: "text"; text: string } | { type: "answer-ui"; block: AnswerUiBlock };
|
||||
|
||||
export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null {
|
||||
const parsed = payloadSchema.safeParse(value);
|
||||
if (!parsed.success || JSON.stringify(parsed.data).length > 100000) return null;
|
||||
const { spec } = parsed.data;
|
||||
const nodes = Object.keys(spec.elements);
|
||||
if (!nodes.length || nodes.length > 48 || nodes.some((name) => !key.safeParse(name).success))
|
||||
return null;
|
||||
const definitions = answerUiCatalog.data.components;
|
||||
for (const node of Object.values(spec.elements)) {
|
||||
const definition = Object.hasOwn(definitions, node.type)
|
||||
? definitions[node.type as keyof typeof definitions]
|
||||
: undefined;
|
||||
if (!definition?.props.safeParse(node.props).success) return null;
|
||||
if (!["Stack", "Section"].includes(node.type) && node.children.length) return null;
|
||||
if (node.type === "Chart") {
|
||||
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
|
||||
if (data.series.some((series) => series.data.length !== data.x_data.length)) return null;
|
||||
}
|
||||
if (node.type === "Table") {
|
||||
const data = node.props as { columns: unknown[]; rows: unknown[][] };
|
||||
if (data.rows.some((row) => row.length !== data.columns.length)) return null;
|
||||
}
|
||||
if (node.type === "FollowUpForm") {
|
||||
const data = node.props as { fields: Array<{ name: string }> };
|
||||
if (new Set(data.fields.map((field) => field.name)).size !== data.fields.length) return null;
|
||||
}
|
||||
}
|
||||
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 !== nodes.length) return null;
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||
|
||||
export type AgentSessionStreamDataEventType =
|
||||
| "execution_plan"
|
||||
| "final_answer"
|
||||
| "token"
|
||||
| "progress"
|
||||
| "todo_update"
|
||||
@@ -88,6 +89,7 @@ export type AgentSessionStreamDataEventType =
|
||||
| "tool_call"
|
||||
| "frontend_action"
|
||||
| "frontend_action_result"
|
||||
| "answer_ui"
|
||||
| "ui_envelope"
|
||||
| "session_title"
|
||||
| "done"
|
||||
@@ -584,6 +586,7 @@ function emitSessionSseEvent(
|
||||
|
||||
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
|
||||
return (
|
||||
value === "final_answer" ||
|
||||
value === "token" ||
|
||||
value === "progress" ||
|
||||
value === "todo_update" ||
|
||||
@@ -595,6 +598,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
||||
value === "tool_call" ||
|
||||
value === "frontend_action" ||
|
||||
value === "frontend_action_result" ||
|
||||
value === "answer_ui" ||
|
||||
value === "ui_envelope" ||
|
||||
value === "session_title" ||
|
||||
value === "done" ||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
|
||||
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
|
||||
import { getAnswerPresentation } from "../answer-ui/answer-presentation";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
@@ -68,9 +72,7 @@ import {
|
||||
} from "./agent-motion";
|
||||
import { AgentHistoryPanel } from "./agent-history-panel";
|
||||
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
|
||||
import { AgentOperationalBrief } from "./agent-operational-brief";
|
||||
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
|
||||
import { StreamingTokenResponse } from "./streaming-token-response";
|
||||
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
|
||||
import {
|
||||
findSpeechSelectionStartOffset,
|
||||
@@ -130,6 +132,7 @@ type AgentCommandPanelProps = {
|
||||
onSelectModel?: (model: string) => void;
|
||||
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
|
||||
onSubmitPrompt: (prompt: string) => Promise<void> | void;
|
||||
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onStopPrompt?: () => void;
|
||||
onReplyPermission?: (
|
||||
permission: AgentPermissionRequest,
|
||||
@@ -164,7 +167,6 @@ export function AgentCommandPanel({
|
||||
sessionHistoryLoading = false,
|
||||
activeSessionId,
|
||||
qaOnly = false,
|
||||
statusLabel = "Agent 后端连接中",
|
||||
connectionStatus = "connecting",
|
||||
streaming = false,
|
||||
messages = [],
|
||||
@@ -185,6 +187,7 @@ export function AgentCommandPanel({
|
||||
onSelectModel,
|
||||
onApprovalModeChange,
|
||||
onSubmitPrompt,
|
||||
onLocateAnswerFeatures,
|
||||
onStopPrompt,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
@@ -327,15 +330,6 @@ export function AgentCommandPanel({
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
);
|
||||
const operationalBrief = (
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<aside
|
||||
@@ -468,45 +462,9 @@ export function AgentCommandPanel({
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{floatingPresentation ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation && !historyOpen ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
aria-hidden={historyOpen}
|
||||
className={cn(
|
||||
"space-y-2 px-3 pb-3",
|
||||
historyOpen && "invisible pointer-events-none"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<AgentContent className="agent-panel-main-content flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<Conversation
|
||||
data-agent-conversation-state={hasConversation ? "active" : "empty"}
|
||||
className={cn(
|
||||
"agent-panel-conversation min-h-0",
|
||||
floatingPresentation && "agent-panel-conversation-canvas"
|
||||
@@ -517,9 +475,6 @@ export function AgentCommandPanel({
|
||||
"gap-4 p-3",
|
||||
messages.length === 0 && "min-h-full",
|
||||
floatingPresentation && "agent-panel-conversation-content",
|
||||
floatingPresentation &&
|
||||
hasConversation &&
|
||||
"agent-panel-conversation-content-has-brief",
|
||||
floatingPresentation &&
|
||||
!hasConversation &&
|
||||
"agent-panel-conversation-content-empty"
|
||||
@@ -544,6 +499,8 @@ export function AgentCommandPanel({
|
||||
onResumeSpeech={resumeSpeech}
|
||||
onStopSpeech={stopSpeech}
|
||||
qaOnly={qaOnly}
|
||||
onLocateAnswerFeatures={onLocateAnswerFeatures}
|
||||
onSubmitAnswerForm={onSubmitPrompt}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
@@ -1233,7 +1190,9 @@ function BackendMessageList({
|
||||
onPauseSpeech,
|
||||
onResumeSpeech,
|
||||
onStopSpeech,
|
||||
qaOnly
|
||||
qaOnly,
|
||||
onLocateAnswerFeatures,
|
||||
onSubmitAnswerForm
|
||||
}: {
|
||||
presentation: AgentCommandPanelPresentation;
|
||||
messages: AgentChatMessage[];
|
||||
@@ -1253,6 +1212,8 @@ function BackendMessageList({
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
qaOnly: boolean;
|
||||
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmitAnswerForm: (text: string) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -1261,7 +1222,9 @@ function BackendMessageList({
|
||||
animate="animate"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{messages.map((message, index) => (
|
||||
{messages.map((message, index) => {
|
||||
const answer = getAnswerPresentation(message);
|
||||
return (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
className={cn("w-full", message.role === "user" && "flex justify-end")}
|
||||
@@ -1287,7 +1250,7 @@ function BackendMessageList({
|
||||
依据:{message.taskContext.execution_generation == null ? "历史资料 · 轮次未记录" : `第 ${message.taskContext.execution_generation} 轮`}
|
||||
{message.taskContext.report_revision == null ? " · 尚无报告" : ` · 报告第 ${message.taskContext.report_revision} 版`}
|
||||
</p> : null}
|
||||
{message.role === "assistant" && !qaOnly ? (
|
||||
{message.role === "assistant" && !qaOnly && !answer.final ? (
|
||||
<AgentMessageProgress
|
||||
progress={message.progress}
|
||||
running={streaming && index === messages.length - 1}
|
||||
@@ -1307,12 +1270,14 @@ function BackendMessageList({
|
||||
onResume={onResumeSpeech}
|
||||
onStop={onStopSpeech}
|
||||
>
|
||||
<StreamingTokenResponse
|
||||
className="leading-6"
|
||||
content={message.content}
|
||||
<AnswerPartsView
|
||||
parts={answer.report}
|
||||
messageId={message.id}
|
||||
streamDone={streamRenderState[message.id]?.done ?? !streaming}
|
||||
streaming={streaming && index === messages.length - 1}
|
||||
disabled={streaming}
|
||||
streaming={!answer.final && streaming && index === messages.length - 1}
|
||||
streamDone={answer.final || (streamRenderState[message.id]?.done ?? !streaming)}
|
||||
onLocate={onLocateAnswerFeatures}
|
||||
onSubmit={onSubmitAnswerForm}
|
||||
/>
|
||||
</AgentSpeechMessage>
|
||||
) : (
|
||||
@@ -1321,7 +1286,16 @@ function BackendMessageList({
|
||||
) : streaming && index === messages.length - 1 ? (
|
||||
<span className="text-[var(--ink-muted)]">正在生成...</span>
|
||||
) : null}
|
||||
{message.role === "assistant" && !qaOnly ? (
|
||||
{message.role === "assistant" && answer.final && (answer.process.length > 0 || (!qaOnly && message.progress?.length)) ? (
|
||||
<Disclosure key="completed-process" label="查看处理过程" className="border-t border-[var(--surface-divider)]">
|
||||
<div className="space-y-3 py-2">
|
||||
{!qaOnly ? <AgentMessageProgress progress={message.progress} running={false} /> : null}
|
||||
<AnswerPartsView parts={answer.process} messageId={`${message.id}-process`} disabled streaming={false} streamDone />
|
||||
{!qaOnly ? <AgentMessageDetails message={message} /> : null}
|
||||
</div>
|
||||
</Disclosure>
|
||||
) : null}
|
||||
{message.role === "assistant" && !qaOnly && !answer.final ? (
|
||||
<AgentMessageDetails
|
||||
message={message}
|
||||
onReplyPermission={onReplyPermission}
|
||||
@@ -1333,7 +1307,8 @@ function BackendMessageList({
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { StatusIcon } from "@/shared/ui/status";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
import type { parseFrontendChart } from "../frontend-action/chart";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
@@ -70,9 +71,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChartView({ chart }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>> }) {
|
||||
return <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
||||
}
|
||||
|
||||
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
const data = normalizeSafeChartData(envelope.data);
|
||||
const data = normalizeSafeChartData(envelope.data, 100);
|
||||
|
||||
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFrontendChart } from "./chart";
|
||||
|
||||
describe("frontend chart boundary", () => {
|
||||
const chart = { title: "压力", chart_type: "line", x_data: ["t1", "t2"], series: [{ name: "D1", data: [20, 21] }] };
|
||||
it("accepts finite aligned series and rejects code, unaligned data and excessive points", () => {
|
||||
expect(parseFrontendChart(chart)).not.toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, formatter: "function(){}" })).toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20] }] })).toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, x_data: Array(101).fill("t") })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const chartSchema = z.object({
|
||||
title: z.string().max(200), chart_type: z.enum(["line", "bar"]),
|
||||
x_data: z.array(z.string().max(128)).min(1).max(100),
|
||||
series: z.array(z.object({ name: z.string().max(128), data: z.array(z.number().finite()).min(1).max(100) }).strict()).min(1).max(8),
|
||||
}).strict().refine((value) => value.series.every((series) => series.data.length === value.x_data.length));
|
||||
export const parseFrontendChart = (value: unknown) => {
|
||||
const parsed = chartSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
};
|
||||
@@ -82,3 +82,9 @@ export type {
|
||||
AgentUiResult
|
||||
} from "./types";
|
||||
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
|
||||
|
||||
export { AgentChartView } from "./components/agent-ui-envelope-renderer";
|
||||
export { parseFrontendChart } from "./frontend-action/chart";
|
||||
export { parseAnswerUiBlock } from "./answer-ui/contract";
|
||||
export type { AnswerUiBlock, AnswerUiPart } from "./answer-ui/contract";
|
||||
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
|
||||
|
||||
@@ -2,6 +2,8 @@ export type AgentChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
|
||||
finalAnswer?: { content: string; uiBlockIds: string[] };
|
||||
progress?: AgentChatProgress[];
|
||||
permissions?: AgentPermissionRequest[];
|
||||
questions?: AgentQuestionRequest[];
|
||||
|
||||
Reference in New Issue
Block a user