feat(agent): render local interactive answers and fold completed process

This commit is contained in:
2026-10-12 18:54:18 +08:00
parent e0eb6a721e
commit 2c5bf15cce
30 changed files with 1610 additions and 75 deletions
@@ -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>
);
}
+116
View File
@@ -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();
});
});
+63
View File
@@ -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;
}
+4
View File
@@ -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;
};
+6
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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)); }
}