feat(agent): render local interactive answers and fold completed process
This commit is contained in:
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Generated
+43
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
const shortText = z.string().max(200);
|
||||
const text = z.string().max(4000);
|
||||
const optionalText = shortText.nullable();
|
||||
const featureId = z.string().min(1).max(128);
|
||||
|
||||
/** This is the source of the generated Agent contract, not a remotely hosted registry. */
|
||||
export const answerUiCatalog = defineCatalog(schema, {
|
||||
actions: {},
|
||||
components: {
|
||||
Stack: {
|
||||
props: z
|
||||
.object({ columns: z.enum(["one", "two"]), gap: z.enum(["compact", "normal"]) })
|
||||
.strict(),
|
||||
slots: ["default"],
|
||||
description: "组织内容;双列在窄面板自动改为单列。"
|
||||
},
|
||||
Section: {
|
||||
props: z.object({ title: shortText, description: text.nullable() }).strict(),
|
||||
slots: ["default"],
|
||||
description: "带标题的资料分区,保留真实内容层级。"
|
||||
},
|
||||
Text: {
|
||||
props: z.object({ text, tone: z.enum(["neutral", "info", "warning"]) }).strict(),
|
||||
description: "简短说明、判断边界或资料缺口。"
|
||||
},
|
||||
Metric: {
|
||||
props: z
|
||||
.object({ label: shortText, value: shortText, unit: optionalText, source: optionalText })
|
||||
.strict(),
|
||||
description: "展示真实指标及其单位、来源;不能臆造读数。"
|
||||
},
|
||||
Table: {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
columns: z.array(shortText).min(1).max(8),
|
||||
rows: z.array(z.array(z.string().max(1000)).max(8)).max(50)
|
||||
})
|
||||
.strict(),
|
||||
description: "按相同字段比较资料,避免无法比较的指标混列。"
|
||||
},
|
||||
Chart: {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
x_data: z.array(shortText).min(1).max(100),
|
||||
series: z
|
||||
.array(
|
||||
z
|
||||
.object({ name: shortText, data: z.array(z.number().finite()).min(1).max(100) })
|
||||
.strict()
|
||||
)
|
||||
.min(1)
|
||||
.max(8),
|
||||
source: text
|
||||
})
|
||||
.strict(),
|
||||
description:
|
||||
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
|
||||
},
|
||||
Evidence: {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
summary: text,
|
||||
source: shortText,
|
||||
evidence_id: optionalText,
|
||||
observed_at: optionalText,
|
||||
kind: z.enum(["observed", "human_input", "inference", "demo"])
|
||||
})
|
||||
.strict(),
|
||||
description: "展示证据依据;明确区分查询记录、用户事实、推断和演练数据。"
|
||||
},
|
||||
MapLocation: {
|
||||
props: z
|
||||
.object({
|
||||
label: shortText,
|
||||
layer: z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]),
|
||||
feature_ids: z.array(featureId).min(1).max(100),
|
||||
source: shortText
|
||||
})
|
||||
.strict(),
|
||||
description: "用户点击后定位已有要素;ID 必须来自查询或用户明确提供,不绘制臆测影响范围。"
|
||||
},
|
||||
FollowUpForm: {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
description: text,
|
||||
fields: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/),
|
||||
label: shortText,
|
||||
kind: z.enum(["text", "datetime", "number"]),
|
||||
required: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.min(1)
|
||||
.max(12)
|
||||
})
|
||||
.strict(),
|
||||
description:
|
||||
"让用户主动填写并提交补充资料;无默认事实。提交产生用户消息,是否写入业务证据由现有 Agent 工具处理。"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const ANSWER_UI_VERSION = "answer-ui@1" as const;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAnswerUiBlock } from "./contract";
|
||||
|
||||
const block = {
|
||||
version: "answer-ui@1",
|
||||
block_id: "reading",
|
||||
fallback_text: "仍待核对",
|
||||
spec: {
|
||||
root: "note",
|
||||
elements: { note: { type: "Text", props: { text: "仍待核对", tone: "neutral" }, children: [] } }
|
||||
}
|
||||
};
|
||||
describe("untrusted answer components", () => {
|
||||
it("accepts registered text and rejects arbitrary code, URLs and unknown components", () => {
|
||||
expect(parseAnswerUiBlock(block)).not.toBeNull();
|
||||
for (const node of [
|
||||
{ type: "iframe", props: { src: "https://example.com" }, children: [] },
|
||||
{
|
||||
...block.spec.elements.note,
|
||||
props: { ...block.spec.elements.note.props, script: "alert(1)" }
|
||||
},
|
||||
{ ...block.spec.elements.note, on: { click: { action: "fetch" } } }
|
||||
])
|
||||
expect(
|
||||
parseAnswerUiBlock({ ...block, spec: { root: "note", elements: { note: node } } })
|
||||
).toBeNull();
|
||||
});
|
||||
it("rejects missing, duplicate, cyclic, unreachable and excessive trees", () => {
|
||||
const stack = { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["note"] };
|
||||
for (const children of [["missing"], ["note", "note"], ["root"]])
|
||||
expect(
|
||||
parseAnswerUiBlock({
|
||||
...block,
|
||||
spec: { root: "root", elements: { root: { ...stack, children }, ...block.spec.elements } }
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseAnswerUiBlock({
|
||||
...block,
|
||||
spec: {
|
||||
...block.spec,
|
||||
elements: { ...block.spec.elements, unused: block.spec.elements.note }
|
||||
}
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
it("requires aligned, finite chart values and unambiguous form fields", () => {
|
||||
const chart = {
|
||||
type: "Chart",
|
||||
props: {
|
||||
title: "演练压力",
|
||||
chart_type: "line",
|
||||
source: "演练数据",
|
||||
x_data: ["08:00"],
|
||||
series: [{ name: "压力", data: [0.3] }]
|
||||
},
|
||||
children: []
|
||||
};
|
||||
const withNode = (node: unknown) => ({
|
||||
...block,
|
||||
spec: { root: "note", elements: { note: node } }
|
||||
});
|
||||
expect(parseAnswerUiBlock(withNode(chart))).not.toBeNull();
|
||||
for (const data of [[0.3, 0.2], [Infinity], [NaN]])
|
||||
expect(
|
||||
parseAnswerUiBlock(
|
||||
withNode({ ...chart, props: { ...chart.props, series: [{ name: "压力", data }] } })
|
||||
)
|
||||
).toBeNull();
|
||||
const field = { name: "location", label: "位置", kind: "text", required: true };
|
||||
expect(
|
||||
parseAnswerUiBlock(
|
||||
withNode({
|
||||
type: "FollowUpForm",
|
||||
props: { title: "核验", description: "用户补充", fields: [field, field] },
|
||||
children: []
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { z } from "zod";
|
||||
import { answerUiCatalog, ANSWER_UI_VERSION } from "./catalog";
|
||||
|
||||
const key = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
|
||||
const element = z
|
||||
.object({ type: z.string(), props: z.record(z.unknown()), children: z.array(key).max(48) })
|
||||
.strict();
|
||||
const specSchema = z.object({ root: key, elements: z.record(element) }).strict();
|
||||
const payloadSchema = z
|
||||
.object({
|
||||
version: z.literal(ANSWER_UI_VERSION),
|
||||
block_id: key,
|
||||
fallback_text: z.string().min(1).max(4000),
|
||||
spec: specSchema
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type AnswerUiBlock = {
|
||||
version: typeof ANSWER_UI_VERSION;
|
||||
block_id: string;
|
||||
fallback_text: string;
|
||||
spec: Spec;
|
||||
};
|
||||
export type AnswerUiPart =
|
||||
{ type: "text"; text: string } | { type: "answer-ui"; block: AnswerUiBlock };
|
||||
|
||||
export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null {
|
||||
const parsed = payloadSchema.safeParse(value);
|
||||
if (!parsed.success || JSON.stringify(parsed.data).length > 100000) return null;
|
||||
const { spec } = parsed.data;
|
||||
const nodes = Object.keys(spec.elements);
|
||||
if (!nodes.length || nodes.length > 48 || nodes.some((name) => !key.safeParse(name).success))
|
||||
return null;
|
||||
const definitions = answerUiCatalog.data.components;
|
||||
for (const node of Object.values(spec.elements)) {
|
||||
const definition = Object.hasOwn(definitions, node.type)
|
||||
? definitions[node.type as keyof typeof definitions]
|
||||
: undefined;
|
||||
if (!definition?.props.safeParse(node.props).success) return null;
|
||||
if (!["Stack", "Section"].includes(node.type) && node.children.length) return null;
|
||||
if (node.type === "Chart") {
|
||||
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
|
||||
if (data.series.some((series) => series.data.length !== data.x_data.length)) return null;
|
||||
}
|
||||
if (node.type === "Table") {
|
||||
const data = node.props as { columns: unknown[]; rows: unknown[][] };
|
||||
if (data.rows.some((row) => row.length !== data.columns.length)) return null;
|
||||
}
|
||||
if (node.type === "FollowUpForm") {
|
||||
const data = node.props as { fields: Array<{ name: string }> };
|
||||
if (new Set(data.fields.map((field) => field.name)).size !== data.fields.length) return null;
|
||||
}
|
||||
}
|
||||
const visited = new Set<string>();
|
||||
const visit = (id: string, depth: number): boolean => {
|
||||
if (depth > 12 || visited.has(id) || !Object.hasOwn(spec.elements, id)) return false;
|
||||
visited.add(id);
|
||||
return spec.elements[id].children.every((child) => visit(child, depth + 1));
|
||||
};
|
||||
if (!visit(spec.root, 0) || visited.size !== nodes.length) return null;
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||
|
||||
export type AgentSessionStreamDataEventType =
|
||||
| "execution_plan"
|
||||
| "final_answer"
|
||||
| "token"
|
||||
| "progress"
|
||||
| "todo_update"
|
||||
@@ -88,6 +89,7 @@ export type AgentSessionStreamDataEventType =
|
||||
| "tool_call"
|
||||
| "frontend_action"
|
||||
| "frontend_action_result"
|
||||
| "answer_ui"
|
||||
| "ui_envelope"
|
||||
| "session_title"
|
||||
| "done"
|
||||
@@ -584,6 +586,7 @@ function emitSessionSseEvent(
|
||||
|
||||
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
|
||||
return (
|
||||
value === "final_answer" ||
|
||||
value === "token" ||
|
||||
value === "progress" ||
|
||||
value === "todo_update" ||
|
||||
@@ -595,6 +598,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
||||
value === "tool_call" ||
|
||||
value === "frontend_action" ||
|
||||
value === "frontend_action_result" ||
|
||||
value === "answer_ui" ||
|
||||
value === "ui_envelope" ||
|
||||
value === "session_title" ||
|
||||
value === "done" ||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
|
||||
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
|
||||
import { getAnswerPresentation } from "../answer-ui/answer-presentation";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
@@ -68,9 +72,7 @@ import {
|
||||
} from "./agent-motion";
|
||||
import { AgentHistoryPanel } from "./agent-history-panel";
|
||||
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
|
||||
import { AgentOperationalBrief } from "./agent-operational-brief";
|
||||
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
|
||||
import { StreamingTokenResponse } from "./streaming-token-response";
|
||||
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
|
||||
import {
|
||||
findSpeechSelectionStartOffset,
|
||||
@@ -130,6 +132,7 @@ type AgentCommandPanelProps = {
|
||||
onSelectModel?: (model: string) => void;
|
||||
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
|
||||
onSubmitPrompt: (prompt: string) => Promise<void> | void;
|
||||
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onStopPrompt?: () => void;
|
||||
onReplyPermission?: (
|
||||
permission: AgentPermissionRequest,
|
||||
@@ -164,7 +167,6 @@ export function AgentCommandPanel({
|
||||
sessionHistoryLoading = false,
|
||||
activeSessionId,
|
||||
qaOnly = false,
|
||||
statusLabel = "Agent 后端连接中",
|
||||
connectionStatus = "connecting",
|
||||
streaming = false,
|
||||
messages = [],
|
||||
@@ -185,6 +187,7 @@ export function AgentCommandPanel({
|
||||
onSelectModel,
|
||||
onApprovalModeChange,
|
||||
onSubmitPrompt,
|
||||
onLocateAnswerFeatures,
|
||||
onStopPrompt,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
@@ -327,15 +330,6 @@ export function AgentCommandPanel({
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
);
|
||||
const operationalBrief = (
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<aside
|
||||
@@ -468,45 +462,9 @@ export function AgentCommandPanel({
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{floatingPresentation ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation && !historyOpen ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
aria-hidden={historyOpen}
|
||||
className={cn(
|
||||
"space-y-2 px-3 pb-3",
|
||||
historyOpen && "invisible pointer-events-none"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<AgentContent className="agent-panel-main-content flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<Conversation
|
||||
data-agent-conversation-state={hasConversation ? "active" : "empty"}
|
||||
className={cn(
|
||||
"agent-panel-conversation min-h-0",
|
||||
floatingPresentation && "agent-panel-conversation-canvas"
|
||||
@@ -517,9 +475,6 @@ export function AgentCommandPanel({
|
||||
"gap-4 p-3",
|
||||
messages.length === 0 && "min-h-full",
|
||||
floatingPresentation && "agent-panel-conversation-content",
|
||||
floatingPresentation &&
|
||||
hasConversation &&
|
||||
"agent-panel-conversation-content-has-brief",
|
||||
floatingPresentation &&
|
||||
!hasConversation &&
|
||||
"agent-panel-conversation-content-empty"
|
||||
@@ -544,6 +499,8 @@ export function AgentCommandPanel({
|
||||
onResumeSpeech={resumeSpeech}
|
||||
onStopSpeech={stopSpeech}
|
||||
qaOnly={qaOnly}
|
||||
onLocateAnswerFeatures={onLocateAnswerFeatures}
|
||||
onSubmitAnswerForm={onSubmitPrompt}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
@@ -1233,7 +1190,9 @@ function BackendMessageList({
|
||||
onPauseSpeech,
|
||||
onResumeSpeech,
|
||||
onStopSpeech,
|
||||
qaOnly
|
||||
qaOnly,
|
||||
onLocateAnswerFeatures,
|
||||
onSubmitAnswerForm
|
||||
}: {
|
||||
presentation: AgentCommandPanelPresentation;
|
||||
messages: AgentChatMessage[];
|
||||
@@ -1253,6 +1212,8 @@ function BackendMessageList({
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
qaOnly: boolean;
|
||||
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmitAnswerForm: (text: string) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -1261,7 +1222,9 @@ function BackendMessageList({
|
||||
animate="animate"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{messages.map((message, index) => (
|
||||
{messages.map((message, index) => {
|
||||
const answer = getAnswerPresentation(message);
|
||||
return (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
className={cn("w-full", message.role === "user" && "flex justify-end")}
|
||||
@@ -1287,7 +1250,7 @@ function BackendMessageList({
|
||||
依据:{message.taskContext.execution_generation == null ? "历史资料 · 轮次未记录" : `第 ${message.taskContext.execution_generation} 轮`}
|
||||
{message.taskContext.report_revision == null ? " · 尚无报告" : ` · 报告第 ${message.taskContext.report_revision} 版`}
|
||||
</p> : null}
|
||||
{message.role === "assistant" && !qaOnly ? (
|
||||
{message.role === "assistant" && !qaOnly && !answer.final ? (
|
||||
<AgentMessageProgress
|
||||
progress={message.progress}
|
||||
running={streaming && index === messages.length - 1}
|
||||
@@ -1307,12 +1270,14 @@ function BackendMessageList({
|
||||
onResume={onResumeSpeech}
|
||||
onStop={onStopSpeech}
|
||||
>
|
||||
<StreamingTokenResponse
|
||||
className="leading-6"
|
||||
content={message.content}
|
||||
<AnswerPartsView
|
||||
parts={answer.report}
|
||||
messageId={message.id}
|
||||
streamDone={streamRenderState[message.id]?.done ?? !streaming}
|
||||
streaming={streaming && index === messages.length - 1}
|
||||
disabled={streaming}
|
||||
streaming={!answer.final && streaming && index === messages.length - 1}
|
||||
streamDone={answer.final || (streamRenderState[message.id]?.done ?? !streaming)}
|
||||
onLocate={onLocateAnswerFeatures}
|
||||
onSubmit={onSubmitAnswerForm}
|
||||
/>
|
||||
</AgentSpeechMessage>
|
||||
) : (
|
||||
@@ -1321,7 +1286,16 @@ function BackendMessageList({
|
||||
) : streaming && index === messages.length - 1 ? (
|
||||
<span className="text-[var(--ink-muted)]">正在生成...</span>
|
||||
) : null}
|
||||
{message.role === "assistant" && !qaOnly ? (
|
||||
{message.role === "assistant" && answer.final && (answer.process.length > 0 || (!qaOnly && message.progress?.length)) ? (
|
||||
<Disclosure key="completed-process" label="查看处理过程" className="border-t border-[var(--surface-divider)]">
|
||||
<div className="space-y-3 py-2">
|
||||
{!qaOnly ? <AgentMessageProgress progress={message.progress} running={false} /> : null}
|
||||
<AnswerPartsView parts={answer.process} messageId={`${message.id}-process`} disabled streaming={false} streamDone />
|
||||
{!qaOnly ? <AgentMessageDetails message={message} /> : null}
|
||||
</div>
|
||||
</Disclosure>
|
||||
) : null}
|
||||
{message.role === "assistant" && !qaOnly && !answer.final ? (
|
||||
<AgentMessageDetails
|
||||
message={message}
|
||||
onReplyPermission={onReplyPermission}
|
||||
@@ -1333,7 +1307,8 @@ function BackendMessageList({
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { StatusIcon } from "@/shared/ui/status";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
import type { parseFrontendChart } from "../frontend-action/chart";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
@@ -70,9 +71,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChartView({ chart }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>> }) {
|
||||
return <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
||||
}
|
||||
|
||||
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
const data = normalizeSafeChartData(envelope.data);
|
||||
const data = normalizeSafeChartData(envelope.data, 100);
|
||||
|
||||
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFrontendChart } from "./chart";
|
||||
|
||||
describe("frontend chart boundary", () => {
|
||||
const chart = { title: "压力", chart_type: "line", x_data: ["t1", "t2"], series: [{ name: "D1", data: [20, 21] }] };
|
||||
it("accepts finite aligned series and rejects code, unaligned data and excessive points", () => {
|
||||
expect(parseFrontendChart(chart)).not.toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, formatter: "function(){}" })).toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20] }] })).toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, x_data: Array(101).fill("t") })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const chartSchema = z.object({
|
||||
title: z.string().max(200), chart_type: z.enum(["line", "bar"]),
|
||||
x_data: z.array(z.string().max(128)).min(1).max(100),
|
||||
series: z.array(z.object({ name: z.string().max(128), data: z.array(z.number().finite()).min(1).max(100) }).strict()).min(1).max(8),
|
||||
}).strict().refine((value) => value.series.every((series) => series.data.length === value.x_data.length));
|
||||
export const parseFrontendChart = (value: unknown) => {
|
||||
const parsed = chartSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
};
|
||||
@@ -82,3 +82,9 @@ export type {
|
||||
AgentUiResult
|
||||
} from "./types";
|
||||
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
|
||||
|
||||
export { AgentChartView } from "./components/agent-ui-envelope-renderer";
|
||||
export { parseFrontendChart } from "./frontend-action/chart";
|
||||
export { parseAnswerUiBlock } from "./answer-ui/contract";
|
||||
export type { AnswerUiBlock, AnswerUiPart } from "./answer-ui/contract";
|
||||
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
|
||||
|
||||
@@ -2,6 +2,8 @@ export type AgentChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
|
||||
finalAnswer?: { content: string; uiBlockIds: string[] };
|
||||
progress?: AgentChatProgress[];
|
||||
permissions?: AgentPermissionRequest[];
|
||||
questions?: AgentQuestionRequest[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
|
||||
import { applySessionStreamEvent, toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
|
||||
|
||||
describe("task context in persisted answers", () => {
|
||||
it("keeps the report reference of each answer when history is restored", () => {
|
||||
@@ -44,3 +44,42 @@ describe("task context in persisted answers", () => {
|
||||
expect(messages[0]?.content).toBe("报告引用未记录。");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rich answer replay", () => {
|
||||
const block = { version: "answer-ui@1", block_id: "reading", fallback_text: "演练读数", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } };
|
||||
it("keeps text, component and summary in their original order across history restoration", () => {
|
||||
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
|
||||
{ type: "text", text: "下面是演练数据。" },
|
||||
{ type: "data-answer_ui", id: "reading", data: block },
|
||||
{ type: "text", text: "需要现场核验。" },
|
||||
{ type: "data-final_answer", data: { content: "结论仍待确认。", ui_block_ids: ["reading"] } },
|
||||
] }]);
|
||||
const restored = toAgentChatMessages(messages, {}, {})[0];
|
||||
expect(restored.answerParts?.map((part) => part.type)).toEqual(["text", "answer-ui", "text"]);
|
||||
expect(restored.content).toContain("演练读数");
|
||||
expect(restored.answerParts).toContainEqual({ type: "text", text: "需要现场核验。" });
|
||||
expect(restored.content).not.toContain("需要现场核验。");
|
||||
});
|
||||
it("updates a block once and appends later tokens after it without duplicating final text", () => {
|
||||
let messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "text", text: "演练:" }] }]);
|
||||
messages = applySessionStreamEvent(messages, { type: "answer_ui", data: block });
|
||||
messages = applySessionStreamEvent(messages, { type: "answer_ui", data: { ...block, fallback_text: "新读数" } });
|
||||
messages = applySessionStreamEvent(messages, { type: "token", data: { content: "仍待核验。", ui_block_ids: ["reading"] } });
|
||||
messages = applySessionStreamEvent(messages, { type: "final_answer", data: { content: "仍待核验。", ui_block_ids: ["reading"] } });
|
||||
const answer = toAgentChatMessages(messages, {}, {})[0];
|
||||
expect(answer.answerParts?.map((part) => part.type)).toEqual(["text", "answer-ui", "text"]);
|
||||
expect(answer.content.match(/新读数/g)).toHaveLength(1);
|
||||
expect(answer.content.match(/仍待核验/g)).toHaveLength(1);
|
||||
});
|
||||
it("falls back to text when an old or malformed UI block cannot be rendered", () => {
|
||||
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "data-answer_ui", data: { ...block, version: "unknown" } }] }]);
|
||||
expect(toAgentChatMessages(messages, {}, {})[0].answerParts).toEqual([{ type: "text", text: "演练读数" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a malformed persisted data payload from crashing the conversation", () => {
|
||||
for (const data of [null, undefined, "invalid"]) {
|
||||
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "data-answer_ui", data }] }]);
|
||||
expect(toAgentChatMessages(messages, {}, {})[0].content).toBe("这段结构化内容无法显示。");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
parseAnswerUiBlock,
|
||||
type AnswerUiPart,
|
||||
applyPermissionResponse,
|
||||
agentTaskContextReferenceSchema,
|
||||
applyQuestionResponse,
|
||||
@@ -20,6 +22,7 @@ type AgentUiDataParts = {
|
||||
task_context: Record<string, unknown>;
|
||||
execution_plan: Record<string, unknown>;
|
||||
progress: Record<string, unknown>;
|
||||
final_answer: Record<string, unknown>;
|
||||
todo_update: Record<string, unknown>;
|
||||
permission_request: Record<string, unknown>;
|
||||
permission_response: Record<string, unknown>;
|
||||
@@ -29,6 +32,7 @@ type AgentUiDataParts = {
|
||||
tool_call: Record<string, unknown>;
|
||||
frontend_action: Record<string, unknown>;
|
||||
frontend_action_result: Record<string, unknown>;
|
||||
answer_ui: Record<string, unknown>;
|
||||
ui_envelope: Record<string, unknown>;
|
||||
session_title: Record<string, unknown>;
|
||||
};
|
||||
@@ -115,10 +119,12 @@ export function toAgentChatMessages(
|
||||
return [];
|
||||
}
|
||||
|
||||
const answerParts = collectAnswerParts(message);
|
||||
let next: AgentChatMessage = {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: collectMessageText(message)
|
||||
content: answerParts ? answerParts.map((part) => part.type === "text" ? part.text : part.block.fallback_text).join("\n\n") : collectMessageText(message),
|
||||
...(answerParts ? { answerParts } : {})
|
||||
};
|
||||
|
||||
for (const part of message.parts) {
|
||||
@@ -128,6 +134,13 @@ export function toAgentChatMessages(
|
||||
if (part.type === "data-task_context") {
|
||||
const parsed = agentTaskContextReferenceSchema.safeParse(part.data);
|
||||
if (parsed.success) next = { ...next, taskContext: parsed.data };
|
||||
} else if (part.type === "data-final_answer") {
|
||||
const content = getDataString(part.data, "content");
|
||||
if (content?.trim()) {
|
||||
const refs = part.data?.ui_block_ids;
|
||||
const uiBlockIds = Array.isArray(refs) ? [...new Set(refs.filter((id): id is string => typeof id === "string"))].slice(0, 48) : [];
|
||||
next = { ...next, finalAnswer: { content, uiBlockIds } };
|
||||
}
|
||||
} else if (part.type === "data-progress") {
|
||||
next = {
|
||||
...next,
|
||||
@@ -164,6 +177,10 @@ export function toAgentChatMessages(
|
||||
}
|
||||
}
|
||||
|
||||
if (next.finalAnswer) {
|
||||
const selected = new Set(next.finalAnswer.uiBlockIds);
|
||||
next.content = [next.finalAnswer.content, ...(next.answerParts ?? []).flatMap((part) => part.type === "answer-ui" && selected.has(part.block.block_id) ? [part.block.fallback_text] : [])].join("\n\n");
|
||||
}
|
||||
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
|
||||
});
|
||||
}
|
||||
@@ -189,6 +206,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "final_answer" ||
|
||||
event.type === "progress" ||
|
||||
event.type === "execution_plan" ||
|
||||
event.type === "todo_update" ||
|
||||
@@ -196,6 +214,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
|
||||
event.type === "permission_response" ||
|
||||
event.type === "question_request" ||
|
||||
event.type === "question_response" ||
|
||||
event.type === "answer_ui" ||
|
||||
event.type === "ui_envelope" ||
|
||||
event.type === "session_title"
|
||||
) {
|
||||
@@ -374,11 +393,15 @@ function createAssistantUiMessage(): AgentUiMessage {
|
||||
}
|
||||
|
||||
function appendTextToUiMessage(message: AgentUiMessage, text: string): AgentUiMessage {
|
||||
const textIndex = message.parts.findIndex((part) => part.type === "text");
|
||||
let textIndex = -1;
|
||||
for (let index = message.parts.length - 1; index >= 0; index -= 1) {
|
||||
if (message.parts[index].type === "text") { textIndex = index; break; }
|
||||
}
|
||||
if (message.parts.slice(textIndex + 1).some((part) => part.type === "data-answer_ui")) textIndex = -1;
|
||||
if (textIndex === -1) {
|
||||
return {
|
||||
...message,
|
||||
parts: [{ type: "text", text }, ...message.parts]
|
||||
parts: [...message.parts, { type: "text", text }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,7 +424,8 @@ function upsertUiDataPart(
|
||||
const id =
|
||||
getDataString(data, "id") ??
|
||||
getDataString(data, "request_id") ??
|
||||
getDataString(data, "envelope_id");
|
||||
getDataString(data, "envelope_id") ??
|
||||
getDataString(data, "block_id");
|
||||
const existingIndex =
|
||||
id === undefined
|
||||
? -1
|
||||
@@ -479,3 +503,20 @@ function collectMessageText(message: AgentUiMessage) {
|
||||
function isAgentDataPart(part: AgentUiMessage["parts"][number]): part is AgentDataPart {
|
||||
return typeof part.type === "string" && part.type.startsWith("data-") && "data" in part;
|
||||
}
|
||||
|
||||
function collectAnswerParts(message: AgentUiMessage): AnswerUiPart[] | undefined {
|
||||
if (!message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer")) return undefined;
|
||||
const result: AnswerUiPart[] = [];
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text" && part.text) result.push({ type: "text", text: part.text });
|
||||
if (part.type === "data-answer_ui") {
|
||||
const data = typeof part.data === "object" && part.data !== null ? part.data : {};
|
||||
const { version, block_id, fallback_text, spec } = data;
|
||||
const block = parseAnswerUiBlock({ version, block_id, fallback_text, spec });
|
||||
if (block) result.push({ type: "answer-ui", block });
|
||||
else result.push({ type: "text", text: typeof fallback_text === "string" ? fallback_text.slice(0, 4000) : "这段结构化内容无法显示。" });
|
||||
}
|
||||
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1105,6 +1105,7 @@ export function MapWorkbenchPage({
|
||||
onApprovalModeChange: agent.setApprovalMode,
|
||||
onPromptChange: agent.setPromptDraft,
|
||||
onSubmitPrompt: agent.submitPrompt,
|
||||
onLocateAnswerFeatures: (target) => mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal),
|
||||
onStopPrompt: agent.stopPrompt,
|
||||
onReplyPermission: agent.replyPermission,
|
||||
onReplyQuestion: agent.replyQuestion,
|
||||
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
import { WorkbenchMapController } from "./workbench-map-controller";
|
||||
|
||||
describe("WorkbenchMapController", () => {
|
||||
it("returns only resolved IDs and rejects a missing target rather than fitting the whole network", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
await expect(controller.locateFeatures("junctions", ["J-1", "missing"])).resolves.toEqual({ located_ids: ["J-1"], missing_ids: ["missing"], fitted: true });
|
||||
expect(map.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ duration: 0 }));
|
||||
await expect(controller.locateFeatures("junctions", ["missing"])).rejects.toThrow("FEATURE_NOT_FOUND");
|
||||
});
|
||||
it("locates points with zoom 18 and keeps selected state untouched", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
@@ -61,7 +68,7 @@ describe("WorkbenchMapController", () => {
|
||||
it("drops cached features and presentation state when a dynamic source disappears", async () => {
|
||||
const map = createMap();
|
||||
map.getLayer.mockReturnValue({} as never);
|
||||
let collection = pointCollection;
|
||||
let collection: FeatureCollection = { ...pointCollection, features: [{ ...pointCollection.features[0], properties: { id: "V-1" } }] };
|
||||
const fetchFeatures = vi.fn(async () => collection);
|
||||
const controller = new WorkbenchMapController({
|
||||
getMap: () => map as unknown as MapLibreMap,
|
||||
@@ -93,6 +100,7 @@ describe("WorkbenchMapController", () => {
|
||||
...pointCollection,
|
||||
features: [{
|
||||
...pointCollection.features[0],
|
||||
properties: { id: "V-1" },
|
||||
geometry: { type: "Point", coordinates: [121.9, 29.1] }
|
||||
}]
|
||||
};
|
||||
|
||||
@@ -146,13 +146,35 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.setHighlight(target);
|
||||
}
|
||||
|
||||
private moveCameraToFeature(map: MapLibreMap, feature: Feature) {
|
||||
async locateFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal?: AbortSignal) {
|
||||
const requestRevision = this.beginTargetCommand();
|
||||
const target = { sourceId, featureId: featureIds[0] ?? "" };
|
||||
if (!this.requireTargetMap(target)) throw new Error(this.state.errorCode ?? "MAP_NOT_READY");
|
||||
const found: Array<{ id: string; feature: Feature }> = [];
|
||||
const missing: string[] = [];
|
||||
for (const id of [...new Set(featureIds)]) {
|
||||
signal?.throwIfAborted();
|
||||
const feature = await this.resolveTarget({ sourceId, featureId: id }, requestRevision);
|
||||
if (requestRevision !== this.targetRequestRevision) throw new Error("ACTION_SUPERSEDED");
|
||||
if (this.state.errorCode === "WFS_UNAVAILABLE" || this.state.errorCode === "SCADA_API_UNAVAILABLE") throw new Error(this.state.errorCode);
|
||||
if (feature) found.push({ id, feature }); else missing.push(id);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const map = this.requireTargetMap(target);
|
||||
if (!map || found.length === 0) throw new Error(this.state.errorCode ?? "FEATURE_NOT_FOUND");
|
||||
const combined: Feature = { type: "Feature", properties: {}, geometry: { type: "MultiPoint", coordinates: found.flatMap(({ feature }) => collectCoordinates(feature.geometry)) } };
|
||||
if (!this.moveCameraToFeature(map, combined, 0)) throw new Error("FEATURE_NOT_FOUND");
|
||||
this.setHighlight({ sourceId, featureId: found[0].id });
|
||||
return { located_ids: found.map(({ id }) => id), missing_ids: missing, fitted: true as const };
|
||||
}
|
||||
|
||||
private moveCameraToFeature(map: MapLibreMap, feature: Feature, durationOverride?: number) {
|
||||
const coordinates = collectCoordinates(feature.geometry);
|
||||
if (!coordinates.length) {
|
||||
this.setError("FEATURE_NOT_FOUND");
|
||||
return false;
|
||||
}
|
||||
const duration = this.options.reducedMotion?.() ? 0 : 600;
|
||||
const duration = durationOverride ?? (this.options.reducedMotion?.() ? 0 : 600);
|
||||
if (feature.geometry.type === "Point") {
|
||||
map.easeTo({
|
||||
center: coordinates[0] as [number, number],
|
||||
@@ -559,7 +581,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
sourceAvailabilityGeneration !== (this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0)
|
||||
) return null;
|
||||
this.pendingTargetSourceId = null;
|
||||
const feature = collection.features[0];
|
||||
const feature = collection.features.find((item) => String(item.properties?.id ?? item.properties?.device_id ?? item.id) === target.featureId);
|
||||
if (!feature) {
|
||||
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
|
||||
return null;
|
||||
|
||||
@@ -1808,3 +1808,22 @@ details[open] > summary > .disclosure-accordion-indicator {
|
||||
border-radius: 8px 0 0 0 !important;
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
/* Keep the conversation composer readable in resizable chat panels. */
|
||||
.agent-panel-shell form.agent-panel-control textarea,
|
||||
.agent-panel-overlay form.agent-panel-control textarea {
|
||||
min-height: 96px;
|
||||
max-height: clamp(96px, 24dvh, 192px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Locally registered answer components adapt to the resizable chat panel. */
|
||||
.answer-ui-root { container-type: inline-size; min-width: 0; overflow-wrap: anywhere; }
|
||||
.answer-ui-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
||||
.answer-ui-stack-compact { gap: 8px; }
|
||||
.answer-ui-section { min-width: 0; padding-top: 12px; border-top: 1px solid var(--surface-divider); }
|
||||
.answer-ui-evidence { border-top: 1px solid var(--surface-divider); }
|
||||
.answer-ui-form { padding: 12px; border: 1px solid var(--surface-divider); border-radius: 12px; background: var(--surface-subtle); }
|
||||
@container (min-width: 440px) {
|
||||
.answer-ui-stack-comparison { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user