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[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
|
||||
import { applySessionStreamEvent, toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
|
||||
|
||||
describe("task context in persisted answers", () => {
|
||||
it("keeps the report reference of each answer when history is restored", () => {
|
||||
@@ -44,3 +44,42 @@ describe("task context in persisted answers", () => {
|
||||
expect(messages[0]?.content).toBe("报告引用未记录。");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rich answer replay", () => {
|
||||
const block = { version: "answer-ui@1", block_id: "reading", fallback_text: "演练读数", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } };
|
||||
it("keeps text, component and summary in their original order across history restoration", () => {
|
||||
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
|
||||
{ type: "text", text: "下面是演练数据。" },
|
||||
{ type: "data-answer_ui", id: "reading", data: block },
|
||||
{ type: "text", text: "需要现场核验。" },
|
||||
{ type: "data-final_answer", data: { content: "结论仍待确认。", ui_block_ids: ["reading"] } },
|
||||
] }]);
|
||||
const restored = toAgentChatMessages(messages, {}, {})[0];
|
||||
expect(restored.answerParts?.map((part) => part.type)).toEqual(["text", "answer-ui", "text"]);
|
||||
expect(restored.content).toContain("演练读数");
|
||||
expect(restored.answerParts).toContainEqual({ type: "text", text: "需要现场核验。" });
|
||||
expect(restored.content).not.toContain("需要现场核验。");
|
||||
});
|
||||
it("updates a block once and appends later tokens after it without duplicating final text", () => {
|
||||
let messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "text", text: "演练:" }] }]);
|
||||
messages = applySessionStreamEvent(messages, { type: "answer_ui", data: block });
|
||||
messages = applySessionStreamEvent(messages, { type: "answer_ui", data: { ...block, fallback_text: "新读数" } });
|
||||
messages = applySessionStreamEvent(messages, { type: "token", data: { content: "仍待核验。", ui_block_ids: ["reading"] } });
|
||||
messages = applySessionStreamEvent(messages, { type: "final_answer", data: { content: "仍待核验。", ui_block_ids: ["reading"] } });
|
||||
const answer = toAgentChatMessages(messages, {}, {})[0];
|
||||
expect(answer.answerParts?.map((part) => part.type)).toEqual(["text", "answer-ui", "text"]);
|
||||
expect(answer.content.match(/新读数/g)).toHaveLength(1);
|
||||
expect(answer.content.match(/仍待核验/g)).toHaveLength(1);
|
||||
});
|
||||
it("falls back to text when an old or malformed UI block cannot be rendered", () => {
|
||||
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "data-answer_ui", data: { ...block, version: "unknown" } }] }]);
|
||||
expect(toAgentChatMessages(messages, {}, {})[0].answerParts).toEqual([{ type: "text", text: "演练读数" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a malformed persisted data payload from crashing the conversation", () => {
|
||||
for (const data of [null, undefined, "invalid"]) {
|
||||
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "data-answer_ui", data }] }]);
|
||||
expect(toAgentChatMessages(messages, {}, {})[0].content).toBe("这段结构化内容无法显示。");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
parseAnswerUiBlock,
|
||||
type AnswerUiPart,
|
||||
applyPermissionResponse,
|
||||
agentTaskContextReferenceSchema,
|
||||
applyQuestionResponse,
|
||||
@@ -20,6 +22,7 @@ type AgentUiDataParts = {
|
||||
task_context: Record<string, unknown>;
|
||||
execution_plan: Record<string, unknown>;
|
||||
progress: Record<string, unknown>;
|
||||
final_answer: Record<string, unknown>;
|
||||
todo_update: Record<string, unknown>;
|
||||
permission_request: Record<string, unknown>;
|
||||
permission_response: Record<string, unknown>;
|
||||
@@ -29,6 +32,7 @@ type AgentUiDataParts = {
|
||||
tool_call: Record<string, unknown>;
|
||||
frontend_action: Record<string, unknown>;
|
||||
frontend_action_result: Record<string, unknown>;
|
||||
answer_ui: Record<string, unknown>;
|
||||
ui_envelope: Record<string, unknown>;
|
||||
session_title: Record<string, unknown>;
|
||||
};
|
||||
@@ -115,10 +119,12 @@ export function toAgentChatMessages(
|
||||
return [];
|
||||
}
|
||||
|
||||
const answerParts = collectAnswerParts(message);
|
||||
let next: AgentChatMessage = {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: collectMessageText(message)
|
||||
content: answerParts ? answerParts.map((part) => part.type === "text" ? part.text : part.block.fallback_text).join("\n\n") : collectMessageText(message),
|
||||
...(answerParts ? { answerParts } : {})
|
||||
};
|
||||
|
||||
for (const part of message.parts) {
|
||||
@@ -128,6 +134,13 @@ export function toAgentChatMessages(
|
||||
if (part.type === "data-task_context") {
|
||||
const parsed = agentTaskContextReferenceSchema.safeParse(part.data);
|
||||
if (parsed.success) next = { ...next, taskContext: parsed.data };
|
||||
} else if (part.type === "data-final_answer") {
|
||||
const content = getDataString(part.data, "content");
|
||||
if (content?.trim()) {
|
||||
const refs = part.data?.ui_block_ids;
|
||||
const uiBlockIds = Array.isArray(refs) ? [...new Set(refs.filter((id): id is string => typeof id === "string"))].slice(0, 48) : [];
|
||||
next = { ...next, finalAnswer: { content, uiBlockIds } };
|
||||
}
|
||||
} else if (part.type === "data-progress") {
|
||||
next = {
|
||||
...next,
|
||||
@@ -164,6 +177,10 @@ export function toAgentChatMessages(
|
||||
}
|
||||
}
|
||||
|
||||
if (next.finalAnswer) {
|
||||
const selected = new Set(next.finalAnswer.uiBlockIds);
|
||||
next.content = [next.finalAnswer.content, ...(next.answerParts ?? []).flatMap((part) => part.type === "answer-ui" && selected.has(part.block.block_id) ? [part.block.fallback_text] : [])].join("\n\n");
|
||||
}
|
||||
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
|
||||
});
|
||||
}
|
||||
@@ -189,6 +206,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "final_answer" ||
|
||||
event.type === "progress" ||
|
||||
event.type === "execution_plan" ||
|
||||
event.type === "todo_update" ||
|
||||
@@ -196,6 +214,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
|
||||
event.type === "permission_response" ||
|
||||
event.type === "question_request" ||
|
||||
event.type === "question_response" ||
|
||||
event.type === "answer_ui" ||
|
||||
event.type === "ui_envelope" ||
|
||||
event.type === "session_title"
|
||||
) {
|
||||
@@ -374,11 +393,15 @@ function createAssistantUiMessage(): AgentUiMessage {
|
||||
}
|
||||
|
||||
function appendTextToUiMessage(message: AgentUiMessage, text: string): AgentUiMessage {
|
||||
const textIndex = message.parts.findIndex((part) => part.type === "text");
|
||||
let textIndex = -1;
|
||||
for (let index = message.parts.length - 1; index >= 0; index -= 1) {
|
||||
if (message.parts[index].type === "text") { textIndex = index; break; }
|
||||
}
|
||||
if (message.parts.slice(textIndex + 1).some((part) => part.type === "data-answer_ui")) textIndex = -1;
|
||||
if (textIndex === -1) {
|
||||
return {
|
||||
...message,
|
||||
parts: [{ type: "text", text }, ...message.parts]
|
||||
parts: [...message.parts, { type: "text", text }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,7 +424,8 @@ function upsertUiDataPart(
|
||||
const id =
|
||||
getDataString(data, "id") ??
|
||||
getDataString(data, "request_id") ??
|
||||
getDataString(data, "envelope_id");
|
||||
getDataString(data, "envelope_id") ??
|
||||
getDataString(data, "block_id");
|
||||
const existingIndex =
|
||||
id === undefined
|
||||
? -1
|
||||
@@ -479,3 +503,20 @@ function collectMessageText(message: AgentUiMessage) {
|
||||
function isAgentDataPart(part: AgentUiMessage["parts"][number]): part is AgentDataPart {
|
||||
return typeof part.type === "string" && part.type.startsWith("data-") && "data" in part;
|
||||
}
|
||||
|
||||
function collectAnswerParts(message: AgentUiMessage): AnswerUiPart[] | undefined {
|
||||
if (!message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer")) return undefined;
|
||||
const result: AnswerUiPart[] = [];
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text" && part.text) result.push({ type: "text", text: part.text });
|
||||
if (part.type === "data-answer_ui") {
|
||||
const data = typeof part.data === "object" && part.data !== null ? part.data : {};
|
||||
const { version, block_id, fallback_text, spec } = data;
|
||||
const block = parseAnswerUiBlock({ version, block_id, fallback_text, spec });
|
||||
if (block) result.push({ type: "answer-ui", block });
|
||||
else result.push({ type: "text", text: typeof fallback_text === "string" ? fallback_text.slice(0, 4000) : "这段结构化内容无法显示。" });
|
||||
}
|
||||
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1105,6 +1105,7 @@ export function MapWorkbenchPage({
|
||||
onApprovalModeChange: agent.setApprovalMode,
|
||||
onPromptChange: agent.setPromptDraft,
|
||||
onSubmitPrompt: agent.submitPrompt,
|
||||
onLocateAnswerFeatures: (target) => mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal),
|
||||
onStopPrompt: agent.stopPrompt,
|
||||
onReplyPermission: agent.replyPermission,
|
||||
onReplyQuestion: agent.replyQuestion,
|
||||
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
import { WorkbenchMapController } from "./workbench-map-controller";
|
||||
|
||||
describe("WorkbenchMapController", () => {
|
||||
it("returns only resolved IDs and rejects a missing target rather than fitting the whole network", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
await expect(controller.locateFeatures("junctions", ["J-1", "missing"])).resolves.toEqual({ located_ids: ["J-1"], missing_ids: ["missing"], fitted: true });
|
||||
expect(map.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ duration: 0 }));
|
||||
await expect(controller.locateFeatures("junctions", ["missing"])).rejects.toThrow("FEATURE_NOT_FOUND");
|
||||
});
|
||||
it("locates points with zoom 18 and keeps selected state untouched", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
@@ -61,7 +68,7 @@ describe("WorkbenchMapController", () => {
|
||||
it("drops cached features and presentation state when a dynamic source disappears", async () => {
|
||||
const map = createMap();
|
||||
map.getLayer.mockReturnValue({} as never);
|
||||
let collection = pointCollection;
|
||||
let collection: FeatureCollection = { ...pointCollection, features: [{ ...pointCollection.features[0], properties: { id: "V-1" } }] };
|
||||
const fetchFeatures = vi.fn(async () => collection);
|
||||
const controller = new WorkbenchMapController({
|
||||
getMap: () => map as unknown as MapLibreMap,
|
||||
@@ -93,6 +100,7 @@ describe("WorkbenchMapController", () => {
|
||||
...pointCollection,
|
||||
features: [{
|
||||
...pointCollection.features[0],
|
||||
properties: { id: "V-1" },
|
||||
geometry: { type: "Point", coordinates: [121.9, 29.1] }
|
||||
}]
|
||||
};
|
||||
|
||||
@@ -146,13 +146,35 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.setHighlight(target);
|
||||
}
|
||||
|
||||
private moveCameraToFeature(map: MapLibreMap, feature: Feature) {
|
||||
async locateFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal?: AbortSignal) {
|
||||
const requestRevision = this.beginTargetCommand();
|
||||
const target = { sourceId, featureId: featureIds[0] ?? "" };
|
||||
if (!this.requireTargetMap(target)) throw new Error(this.state.errorCode ?? "MAP_NOT_READY");
|
||||
const found: Array<{ id: string; feature: Feature }> = [];
|
||||
const missing: string[] = [];
|
||||
for (const id of [...new Set(featureIds)]) {
|
||||
signal?.throwIfAborted();
|
||||
const feature = await this.resolveTarget({ sourceId, featureId: id }, requestRevision);
|
||||
if (requestRevision !== this.targetRequestRevision) throw new Error("ACTION_SUPERSEDED");
|
||||
if (this.state.errorCode === "WFS_UNAVAILABLE" || this.state.errorCode === "SCADA_API_UNAVAILABLE") throw new Error(this.state.errorCode);
|
||||
if (feature) found.push({ id, feature }); else missing.push(id);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const map = this.requireTargetMap(target);
|
||||
if (!map || found.length === 0) throw new Error(this.state.errorCode ?? "FEATURE_NOT_FOUND");
|
||||
const combined: Feature = { type: "Feature", properties: {}, geometry: { type: "MultiPoint", coordinates: found.flatMap(({ feature }) => collectCoordinates(feature.geometry)) } };
|
||||
if (!this.moveCameraToFeature(map, combined, 0)) throw new Error("FEATURE_NOT_FOUND");
|
||||
this.setHighlight({ sourceId, featureId: found[0].id });
|
||||
return { located_ids: found.map(({ id }) => id), missing_ids: missing, fitted: true as const };
|
||||
}
|
||||
|
||||
private moveCameraToFeature(map: MapLibreMap, feature: Feature, durationOverride?: number) {
|
||||
const coordinates = collectCoordinates(feature.geometry);
|
||||
if (!coordinates.length) {
|
||||
this.setError("FEATURE_NOT_FOUND");
|
||||
return false;
|
||||
}
|
||||
const duration = this.options.reducedMotion?.() ? 0 : 600;
|
||||
const duration = durationOverride ?? (this.options.reducedMotion?.() ? 0 : 600);
|
||||
if (feature.geometry.type === "Point") {
|
||||
map.easeTo({
|
||||
center: coordinates[0] as [number, number],
|
||||
@@ -559,7 +581,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
sourceAvailabilityGeneration !== (this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0)
|
||||
) return null;
|
||||
this.pendingTargetSourceId = null;
|
||||
const feature = collection.features[0];
|
||||
const feature = collection.features.find((item) => String(item.properties?.id ?? item.properties?.device_id ?? item.id) === target.featureId);
|
||||
if (!feature) {
|
||||
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
|
||||
return null;
|
||||
|
||||
@@ -1808,3 +1808,22 @@ details[open] > summary > .disclosure-accordion-indicator {
|
||||
border-radius: 8px 0 0 0 !important;
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
/* Keep the conversation composer readable in resizable chat panels. */
|
||||
.agent-panel-shell form.agent-panel-control textarea,
|
||||
.agent-panel-overlay form.agent-panel-control textarea {
|
||||
min-height: 96px;
|
||||
max-height: clamp(96px, 24dvh, 192px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Locally registered answer components adapt to the resizable chat panel. */
|
||||
.answer-ui-root { container-type: inline-size; min-width: 0; overflow-wrap: anywhere; }
|
||||
.answer-ui-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
||||
.answer-ui-stack-compact { gap: 8px; }
|
||||
.answer-ui-section { min-width: 0; padding-top: 12px; border-top: 1px solid var(--surface-divider); }
|
||||
.answer-ui-evidence { border-top: 1px solid var(--surface-divider); }
|
||||
.answer-ui-form { padding: 12px; border: 1px solid var(--surface-divider); border-radius: 12px; background: var(--surface-subtle); }
|
||||
@container (min-width: 440px) {
|
||||
.answer-ui-stack-comparison { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user