feat(agent): render local interactive answers and fold completed process
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user