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
+100
View File
@@ -0,0 +1,100 @@
# 本地交互式回答
聊天保留 AI SDK `UIMessage`、AI Elements 与现有 Agent 后端,新增
`@json-render/core` / `@json-render/react` 的本地注册组件。
组件代码随前端构建发布,无 ChatKit iframe、domain key 或 Vercel/OpenAI
托管 UI 请求。模型仍使用 Agent 的既有配置与工具调用能力。
## 可用内容与交互
`Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、
`MapLocation`、`FollowUpForm` 共九种组件。图表可切换折线、柱状和数据表;
证据可折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。
Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。
消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同
`block_id` 更新原块;下一轮不会修改上一轮回答。
这是按内容块逐步出现的界面,不是逐 token 编译 JSX 或每个 JSON 属性都立即显示。
结构化消息与普通聊天共用持久化、历史与断线恢复通道。
生成期间展开显示过程;`final_answer` 到达后,仅展开最后报告的完整文字与
`ui_block_ids` 明确引用的已发布组件,其余文字、界面与工具轨迹默认折叠在
“查看处理过程”中。报告组件按引用顺序排列,同一组件不会同时作为报告和过程
挂载,避免重复表单。未被报告引用的过程界面只供回看。
已发布交互界面的普通/协作聊天在最终答复完成、且没有待完成工具时停止本轮原生
生成,防止最终报告之后反复自动修改界面。该收尾不标记后台调查任务完成;
后台执行继续等待其原有运行生命周期与 Server 完成/失败接口。
后台调查执行继续走任务报告与任务展示通道,聊天界面块不构成业务完成或浏览器回执。
## 扩展组件
1. 在 `src/features/agent/answer-ui/catalog.ts` 增加严格的 Zod 参数定义、用途描述与必要的数据上限。
2. 在 `answer-ui-view.tsx` 注册 React 组件。需要工作台交互时通过 Agent 公共 API 的回调接入,保持依赖方向。
3. 从本仓库运行 `pnpm export:answer-ui-contract`,更新相邻 Agent 仓库的
`src/chat/answer-ui-catalog.json`。组件目录是唯一参数定义源,生成文件需随两边一起交付。
4. 如新组件存在跨字段约束,同时更新两端的协议校验并加边界和交互测试。
导出脚本接受可选目标文件:
```bash
node scripts/export-answer-ui-contract.mjs /path/to/agent/src/chat/answer-ui-catalog.json
```
前端沿用 Zod 3,json-render 0.21 两个包通过作用域 override 使用相同 schema
运行时;实际组件渲染和参数校验已有聚焦测试。升级 json-render 时必须保留这组
兼容性测试,不能只根据包版本判断可用。Agent 原生插件仍使用其 Zod 4 工具边界,
执行后再按生成的 JSON Schema 校验组件参数。
## 校验边界
两端拒绝未知组件、额外属性、任意脚本/事件定义、缺失引用、循环、重复或游离节点。
每块最多 48 节点、深度 12、约 100 KB,图表必须对齐且数值有限;表格每行与列数
一致,表单字段名唯一。组件只消费明确的 props,没有动态代码、网络 URL 或任意动作 DSL。
无法解析的历史内容显示该块的 `fallback_text`。
## 验证
```bash
pnpm lint
pnpm typecheck
pnpm test src/features/agent/answer-ui src/features/workbench/hooks/agent-session-message-state.test.ts
pnpm test:browser tests/browser/agent-answer-ui.e2e.ts tests/browser/agent-start-page.e2e.ts
pnpm build
```
Agent 仓库:
```bash
bun run check
bun test tests/chat/answer-stream-projection.test.ts tests/chat/answer-ui.test.ts tests/routes/chatStream.test.ts tests/routes/taskCollaboration.test.ts tests/runtime/opencode.test.ts
bun run tests/integration/answer-ui-smoke.ts
```
原生 smoke 使用独立临时数据库,不发模型推理请求。浏览器用例用明确标注的演练
事件验证组件、更新、历史恢复、键盘展开证据、表单提交和 320/375px 布局,不能当作
真实模型已正确选择组件或现场资料已核验的证明。
前端与 Agent 需一起更新,Agent 进程需重新加载原生插件。无需新增环境变量、
domain key 或数据库迁移。当前开发实例通过现有监听机制重新加载;既有聊天与调查工作进程保留。
## 真实模型验收(显式启用)
以下浏览器用例直接连接本机 Agent,输入均为明确的演练资料。工作台地图与项目
读取使用测试数据,聊天、模型工具调用、流式 UI、聊天持久化与表单回传使用真实
服务。每次创建独立验收会话,结束后仅删除该会话,不删除既有用户记录。此用例
会发送模型请求,默认浏览器测试不执行它。
```bash
TJWATER_ANSWER_UI_REAL_AGENT_URL=http://127.0.0.1:18787 \
TJWATER_ANSWER_UI_REAL_MODEL_LABEL=专家 \
pnpm test:browser tests/browser/agent-answer-ui-real.e2e.ts --workers=1
```
先在 Agent 仓库启动隔离实例,可指定独立 INTEGRATION_DATA_DIR 保留联调数据。
可省略模型标签来使用默认模型。该用例不是生产 OAuth 登录或真实供水业务核验。
Vite 统一解析 React/React DOM,Playwright 按端口使用独立的依赖缓存,避免同时
运行的开发服务将旧临时目录的 React 预构建写回测试缓存。
+5 -1
View File
@@ -6,6 +6,7 @@
"packageManager": "pnpm@11.8.0",
"scripts": {
"dev": "vite",
"export:answer-ui-contract": "node scripts/export-answer-ui-contract.mjs",
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
"build:verify-css": "node scripts/verify-production-css.mjs",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
@@ -20,6 +21,8 @@
"dependencies": {
"@ai-sdk/react": "^4.0.9",
"@fontsource-variable/noto-sans-sc": "5.3.0",
"@json-render/core": "^0.21.0",
"@json-render/react": "^0.21.0",
"@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-collapsible": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.17",
@@ -85,7 +88,8 @@
"tw-animate-css": "^1.4.0",
"typescript": "^7.0.0",
"vite": "^7.0.0",
"vitest": "^4.1.9"
"vitest": "^4.1.9",
"zod-to-json-schema": "^3.25.2"
},
"msw": {
"workerDirectory": [
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineConfig({
trace: "on-first-retry"
},
webServer: {
command: `TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
command: `TJWATER_VITE_CACHE_DIR=node_modules/.vite-playwright-${testPort} TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
url: baseURL,
reuseExistingServer: false
},
+43
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@json-render/core>zod': 3.25.76
'@json-render/react>zod': 3.25.76
importers:
.:
@@ -14,6 +18,12 @@ importers:
'@fontsource-variable/noto-sans-sc':
specifier: 5.3.0
version: 5.3.0
'@json-render/core':
specifier: ^0.21.0
version: 0.21.0(zod@3.25.76)
'@json-render/react':
specifier: ^0.21.0
version: 0.21.0(react@19.2.7)(zod@3.25.76)
'@radix-ui/react-accordion':
specifier: ^1.2.14
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -207,6 +217,9 @@ importers:
vitest:
specifier: ^4.1.9
version: 4.1.10(@types/node@24.13.3)(jsdom@25.0.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0))
zod-to-json-schema:
specifier: ^3.25.2
version: 3.25.2(zod@3.25.76)
packages:
@@ -740,6 +753,16 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@json-render/core@0.21.0':
resolution: {integrity: sha512-vysEbTjO1y8mEgAsp0tztRyaWCkNsBrG8MhAqbndGK3qmMXjMRdCNtk21+AQRNKdRelFGVAwBgtUNW8tfjQlsw==}
peerDependencies:
zod: 3.25.76
'@json-render/react@0.21.0':
resolution: {integrity: sha512-z/fZg0L8fJe2dnnQjWkmMw409iBclSWcOqdjoBy8eLHeBr/7F7VipzkY1CfgA2Fa5uW+zxZ54OvWMtdDcsp/yg==}
peerDependencies:
react: ^19.2.3
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
@@ -4014,6 +4037,11 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
zod: ^3.25.28 || ^4
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -4552,6 +4580,17 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@json-render/core@0.21.0(zod@3.25.76)':
dependencies:
zod: 3.25.76
'@json-render/react@0.21.0(react@19.2.7)(zod@3.25.76)':
dependencies:
'@json-render/core': 0.21.0(zod@3.25.76)
react: 19.2.7
transitivePeerDependencies:
- zod
'@mapbox/geojson-rewind@0.5.2':
dependencies:
get-stream: 6.0.1
@@ -8060,6 +8099,10 @@ snapshots:
yocto-queue@0.1.0: {}
zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies:
zod: 3.25.76
zod@3.25.76: {}
zrender@6.1.0:
+5
View File
@@ -1,6 +1,11 @@
packages:
- .
# Keep the catalog and json-render on the existing application schema runtime.
overrides:
'@json-render/core>zod': 3.25.76
'@json-render/react>zod': 3.25.76
allowBuilds:
'@swc/core': true
esbuild: true
+22
View File
@@ -0,0 +1,22 @@
import process from "node:process";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { zodToJsonSchema } from "zod-to-json-schema";
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
const target = resolve(process.argv[2] ?? "../next-tjwater-agent/src/chat/answer-ui-catalog.json");
const contract = {
version: ANSWER_UI_VERSION,
description:
"Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.",
schema: answerUiCatalog.jsonSchema(),
components: answerUiCatalog.componentNames,
props: Object.fromEntries(
Object.entries(answerUiCatalog.data.components).map(([name, definition]) => [
name,
zodToJsonSchema(definition.props, { $refStrategy: "none" })
])
)
};
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
@@ -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)); }
}
+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();
});
}
+2
View File
@@ -63,7 +63,9 @@ export default defineConfig(({ mode }) => {
server: {
allowedHosts: ["next.demo.waternetwork.cn"]
},
cacheDir: process.env.TJWATER_VITE_CACHE_DIR ?? "node_modules/.vite",
resolve: {
dedupe: ["react", "react-dom"],
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))
}