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
+128
View File
@@ -0,0 +1,128 @@
import { expect, test } from "@playwright/test";
import { parseAnswerUiBlock } from "../../src/features/agent/answer-ui/contract";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
// Explicit opt-in: two real conversation turns against a local dev Agent; ordinary learning checks may also run.
const realAgentUrl = process.env.TJWATER_ANSWER_UI_REAL_AGENT_URL;
test("real model publishes local components, restores them and receives form input", async ({
page,
request
}) => {
test.skip(
!realAgentUrl,
"Set TJWATER_ANSWER_UI_REAL_AGENT_URL to an isolated local development Agent."
);
test.setTimeout(300_000);
const target = new URL(realAgentUrl!);
expect(["127.0.0.1", "localhost"]).toContain(target.hostname);
const base = `${target.origin}/api/v1/agent/chat`;
const health = await request.get(`${target.origin}/health`);
expect(health.ok()).toBe(true);
expect(await health.json()).toMatchObject({ ready: true, warmed_up: true });
await mockWorkbenchContext(page);
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: target.origin,
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_ENABLE_MSW: "false"
})};`
})
);
const created = await request.post(`${base}/session`, { data: {} });
expect(created.ok()).toBe(true);
const id = (await created.json()).session_id as string;
const title = `本地交互回答验收 ${Date.now()}`;
const renamed = await request.patch(`${base}/session/${encodeURIComponent(id)}/title`, {
data: { title }
});
expect(renamed.ok()).toBe(true);
page.on("pageerror", (error) => console.error("Real answer browser error:", error.message));
try {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: "选择对话归属" }).click();
await page.getByRole("menuitem", { name: "个人会话", exact: true }).click();
await expect(panel.getByRole("button", { name: "选择 Agent 模型" })).toBeEnabled();
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await panel
.getByRole("button", { name: new RegExp(title) })
.first()
.click();
await panel
.locator("textarea")
.fill(
"这是本地界面功能验收,请仅使用我提供的演练资料,不代表真实现场事实:监测点 DEMO-P001,08:00 压力 0.30 MPa,09:00 压力 0.28 MPa,10:00 压力 0.18 MPa。请用 present_answer 发布本地交互式小结,必须包括 Chart 压力趋势(保留时间、单位和演练来源)、默认折叠的 Evidence(kind=demo),以及 FollowUpForm 收集一项‘现场位置’(text 类型、必填,不预填值)。最后提交 final_answer,并用 ui_block_ids 引用该界面块,使图表、证据、表单保留在最终报告中;其他过程折叠。再给出简短的演练结论,说明尚待现场核验。不要查询数据库、创建任务、写业务证据或保存记忆,只使用本轮文字、present_answer 和 final_answer。"
);
const requestedModelLabel = process.env.TJWATER_ANSWER_UI_REAL_MODEL_LABEL;
if (requestedModelLabel) {
await panel.getByRole("button", { name: "选择 Agent 模型" }).click();
await page.getByRole("menuitem", { name: new RegExp(requestedModelLabel) }).click();
}
const firstResponse = page.waitForResponse(
(response) => response.url() === `${base}/stream` && response.request().method() === "POST"
);
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
const stream = await firstResponse;
expect(stream.ok()).toBe(true);
// Assert rendered and persisted state; an HTTP response alone cannot prove rendering.
await expect(panel.locator("[data-answer-ui]").first()).toBeVisible({ timeout: 180_000 });
await expect(panel.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible({
timeout: 180_000
});
await panel.getByRole("button", { name: "数据", exact: true }).first().click();
await expect(panel.getByRole("table").first()).toContainText("0.18");
await expect(panel.getByRole("table").first()).toContainText("10:00");
await expect(panel.getByLabel("现场位置(必填)")).toHaveValue("");
const storedResponse = await request.get(`${base}/session/${encodeURIComponent(id)}`);
expect(storedResponse.ok()).toBe(true);
const stored = await storedResponse.json();
expect(stored.is_streaming).toBe(false);
const uiParts = stored.messages.flatMap(
(message: { parts?: Array<{ type: string; data?: Record<string, unknown> }> }) =>
message.parts?.filter((part) => part.type === "data-answer_ui") ?? []
);
expect(uiParts.length).toBeGreaterThan(0);
for (const part of uiParts) {
const { version, block_id, fallback_text, spec } = part.data;
expect(parseAnswerUiBlock({ version, block_id, fallback_text, spec })).not.toBeNull();
}
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await panel
.getByRole("button", { name: new RegExp(stored.title) })
.first()
.click();
await expect(panel.getByLabel("现场位置(必填)")).toHaveValue("");
await panel
.getByLabel("现场位置(必填)")
.fill("演练:东侧阀门旁,未实地核验。本轮仍为功能验收,请不要写业务证据或记忆,只确认收到。");
const secondResponse = page.waitForResponse(
(response) => response.url() === `${base}/stream` && response.request().method() === "POST"
);
await panel.getByRole("button", { name: "发送补充资料" }).click();
const submitted = await secondResponse;
const submittedBody = submitted.request().postDataJSON() as {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>;
};
expect(
submittedBody.messages
.filter((message) => message.role === "user")
.at(-1)
?.parts.some((part) => part.text?.includes("现场位置:演练:东侧阀门旁"))
).toBe(true);
expect(submitted.ok()).toBe(true);
await expect(panel.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible({
timeout: 180_000
});
await panel.screenshot({ path: "test-results/answer-ui-real-model.png" });
} finally {
const deleted = await request.delete(`${base}/session/${encodeURIComponent(id)}`);
expect(deleted.status()).toBe(204);
}
});
+230
View File
@@ -0,0 +1,230 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
const block = {
version: "answer-ui@1",
block_id: "pressure",
fallback_text: "演练压力读数,仍待现场核验。",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: { columns: "one", gap: "normal" },
children: ["metrics", "chart", "evidence", "location", "form"]
},
metrics: {
type: "Stack",
props: { columns: "two", gap: "compact" },
children: ["pressure", "flow"]
},
pressure: {
type: "Metric",
props: { label: "压力", value: "0.30", unit: "MPa", source: "演练数据" },
children: []
},
flow: {
type: "Metric",
props: { label: "流量", value: "18", unit: "m³/h", source: "演练数据" },
children: []
},
chart: {
type: "Chart",
props: {
title: "演练监测趋势",
chart_type: "line",
x_data: ["08:00", "09:00"],
series: [{ name: "压力(MPa)", data: [0.3, 0.28] }],
source: "浏览器验收演练数据"
},
children: []
},
evidence: {
type: "Evidence",
props: {
title: "查看演练证据",
summary: "仅用于组件验收,不代表已核验现场事实。",
source: "浏览器演练",
evidence_id: "DEMO-001",
observed_at: null,
kind: "demo"
},
children: []
},
location: {
type: "MapLocation",
props: {
label: "定位演练监测点",
layer: "scada",
feature_ids: ["DEMO-P001"],
source: "演练监测点"
},
children: []
},
form: {
type: "FollowUpForm",
props: {
title: "补充核验资料",
description: "填写后由你主动发送给 Agent。",
fields: [{ name: "location", label: "现场位置", kind: "text", required: true }]
},
children: []
}
}
}
};
for (const width of [1440, 375, 320]) {
test(`local rich answers stream, restore and submit at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page, {
sessions: [
{
session_id: "browser-session-1",
title: "演练结构化回答",
created_at: Date.now(),
updated_at: Date.now()
}
]
});
const parts = [
{ type: "text", text: "以下资料均为演练数据。" },
{ type: "data-answer_ui", id: "pressure", data: block },
{ type: "text", text: "仍需用户补充现场核验资料。" },
{
type: "data-final_answer",
data: { content: "仍需用户补充现场核验资料。", ui_block_ids: ["pressure"] }
}
];
await page.route("**/api/v1/agent/chat/session/browser-session-1", async (route) => {
if (route.request().method() !== "GET") return route.fallback();
await route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
session_id: "browser-session-1",
title: "演练结构化回答",
purpose: "ordinary_chat",
status: "active",
is_streaming: false,
messages: [
{
id: "user-fixture",
role: "user",
parts: [{ type: "text", text: "查看演练监测资料" }]
},
{ id: "rich-answer", role: "assistant", parts }
]
}
});
});
const prompts: string[] = [];
await page.route("**/api/v1/agent/chat/stream", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
const body = route.request().postDataJSON() as {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>;
};
const text =
body.messages
.filter((m) => m.role === "user")
.at(-1)
?.parts.filter((p) => p.type === "text")
.map((p) => p.text)
.join("") ?? "";
prompts.push(text);
const chunks =
prompts.length === 1
? [
{ type: "start", messageId: "rich-answer" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: parts[0].text },
{ type: "text-end", id: "answer" },
{ ...parts[1], data: { ...block, fallback_text: "演练初始数据" } },
parts[1],
{ type: "text-start", id: "answer-1" },
{ type: "text-delta", id: "answer-1", delta: parts[2].text },
{ type: "text-end", id: "answer-1" },
parts[3],
{ type: "finish", finishReason: "stop" }
]
: [
{ type: "start", messageId: "form-answer" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: "已收到补充资料,尚未保存业务证据。" },
{ type: "text-end", id: "answer" },
{ type: "finish", finishReason: "stop" }
];
await route.fulfill({
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
},
body: chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page
.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ })
.click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.locator("textarea").fill("查看演练监测资料");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
const ui = panel.locator('[data-answer-ui="pressure"]');
await expect(ui).toHaveCount(1);
const process = panel
.locator("details")
.filter({ has: page.locator("summary", { hasText: "查看处理过程" }) });
await expect(process).not.toHaveAttribute("open");
await expect(panel.getByText("以下资料均为演练数据。", { exact: true })).toBeHidden();
await process.locator("summary").click();
await expect(panel.getByText("以下资料均为演练数据。", { exact: true })).toBeVisible();
await process.locator("summary").click();
if (width >= 1024) {
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
for (const panelWidth of [400, 640]) {
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(panelWidth, box.y + box.height / 2);
await page.mouse.up();
await expect(panel).toHaveCSS("width", `${panelWidth}px`);
const columns = await ui
.locator(".answer-ui-stack-comparison")
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length);
expect(columns).toBe(panelWidth === 640 ? 2 : 1);
}
}
await expect(ui.getByText("0.30", { exact: false }).first()).toBeVisible();
await ui.getByRole("button", { name: "数据", exact: true }).click();
await expect(ui.getByRole("table")).toContainText("0.28");
const evidence = ui.locator("summary", { hasText: "查看演练证据" });
await evidence.focus();
await page.keyboard.press("Enter");
await expect(ui.getByText("仅用于组件验收,不代表已核验现场事实。")).toBeVisible();
await expect(panel.getByText("仍需用户补充现场核验资料。", { exact: true })).toHaveCount(1);
const bounds = await ui.evaluate((element) => {
const rect = element.getBoundingClientRect();
const panel = element.closest("aside")!.getBoundingClientRect();
return rect.left >= panel.left && rect.right <= panel.right;
});
expect(bounds).toBe(true);
await ui.getByRole("table").scrollIntoViewIfNeeded();
await panel.screenshot({ path: `test-results/answer-ui-${width}.png` });
// Restore from canonical persisted parts through the actual history selector.
await panel.getByRole("button", { name: /历史/ }).click();
await page.getByRole("button", { name: /演练结构化回答/ }).click();
await expect(ui).toHaveCount(1);
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("");
expect(prompts).toHaveLength(1);
await ui.getByLabel("现场位置(必填)").fill("演练管段东侧");
await ui.getByRole("button", { name: "发送补充资料" }).click();
await expect.poll(() => prompts.length).toBe(2);
expect(prompts[1]).toContain("现场位置:演练管段东侧");
await expect(
panel.getByText("已收到补充资料,尚未保存业务证据。", { exact: true })
).toBeVisible();
});
}