From 2c5bf15cceea91f054f7f1e93b4ca87339db07c1 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 12 Oct 2026 18:54:18 +0800 Subject: [PATCH] feat(agent): render local interactive answers and fold completed process --- docs/agent-answer-ui.md | 100 +++++ package.json | 6 +- playwright.config.ts | 2 +- pnpm-lock.yaml | 43 ++ pnpm-workspace.yaml | 5 + scripts/export-answer-ui-contract.mjs | 22 + .../agent/answer-ui/answer-parts-view.tsx | 46 +++ .../answer-ui/answer-presentation.test.ts | 54 +++ .../agent/answer-ui/answer-presentation.ts | 25 ++ .../agent/answer-ui/answer-ui-view.test.tsx | 91 +++++ .../agent/answer-ui/answer-ui-view.tsx | 379 ++++++++++++++++++ src/features/agent/answer-ui/catalog.ts | 116 ++++++ src/features/agent/answer-ui/contract.test.ts | 81 ++++ src/features/agent/answer-ui/contract.ts | 63 +++ src/features/agent/api/client.ts | 4 + .../agent/components/agent-command-panel.tsx | 101 ++--- .../components/agent-ui-envelope-renderer.tsx | 7 +- .../agent/frontend-action/chart.test.ts | 13 + src/features/agent/frontend-action/chart.ts | 11 + src/features/agent/index.ts | 6 + src/features/agent/types.ts | 2 + .../hooks/agent-session-message-state.test.ts | 41 +- .../hooks/agent-session-message-state.ts | 49 ++- src/features/workbench/map-workbench-page.tsx | 1 + .../map/workbench-map-controller.test.ts | 10 +- .../workbench/map/workbench-map-controller.ts | 28 +- src/styles.css | 19 + tests/browser/agent-answer-ui-real.e2e.ts | 128 ++++++ tests/browser/agent-answer-ui.e2e.ts | 230 +++++++++++ vite.config.ts | 2 + 30 files changed, 1610 insertions(+), 75 deletions(-) create mode 100644 docs/agent-answer-ui.md create mode 100644 scripts/export-answer-ui-contract.mjs create mode 100644 src/features/agent/answer-ui/answer-parts-view.tsx create mode 100644 src/features/agent/answer-ui/answer-presentation.test.ts create mode 100644 src/features/agent/answer-ui/answer-presentation.ts create mode 100644 src/features/agent/answer-ui/answer-ui-view.test.tsx create mode 100644 src/features/agent/answer-ui/answer-ui-view.tsx create mode 100644 src/features/agent/answer-ui/catalog.ts create mode 100644 src/features/agent/answer-ui/contract.test.ts create mode 100644 src/features/agent/answer-ui/contract.ts create mode 100644 src/features/agent/frontend-action/chart.test.ts create mode 100644 src/features/agent/frontend-action/chart.ts create mode 100644 tests/browser/agent-answer-ui-real.e2e.ts create mode 100644 tests/browser/agent-answer-ui.e2e.ts diff --git a/docs/agent-answer-ui.md b/docs/agent-answer-ui.md new file mode 100644 index 0000000..95e234d --- /dev/null +++ b/docs/agent-answer-ui.md @@ -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 预构建写回测试缓存。 diff --git a/package.json b/package.json index d4c527d..f7a7327 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/playwright.config.ts b/playwright.config.ts index a74d3e4..606438c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d5ed3d..1c3d588 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6a8c77f..9f27957 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/scripts/export-answer-ui-contract.mjs b/scripts/export-answer-ui-contract.mjs new file mode 100644 index 0000000..15d0bf5 --- /dev/null +++ b/scripts/export-answer-ui-contract.mjs @@ -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`); diff --git a/src/features/agent/answer-ui/answer-parts-view.tsx b/src/features/agent/answer-ui/answer-parts-view.tsx new file mode 100644 index 0000000..ea3c86e --- /dev/null +++ b/src/features/agent/answer-ui/answer-parts-view.tsx @@ -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 | void; + onSubmit?: (text: string) => Promise | void; +}) { + return ( +
+ {parts.map((part, index) => + part.type === "answer-ui" ? ( + + ) : ( + + ) + )} +
+ ); +} diff --git a/src/features/agent/answer-ui/answer-presentation.test.ts b/src/features/agent/answer-ui/answer-presentation.test.ts new file mode 100644 index 0000000..56c9482 --- /dev/null +++ b/src/features/agent/answer-ui/answer-presentation.test.ts @@ -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); + }); +}); diff --git a/src/features/agent/answer-ui/answer-presentation.ts b/src/features/agent/answer-ui/answer-presentation.ts new file mode 100644 index 0000000..8a6d23f --- /dev/null +++ b/src/features/agent/answer-ui/answer-presentation.ts @@ -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 }; +} diff --git a/src/features/agent/answer-ui/answer-ui-view.test.tsx b/src/features/agent/answer-ui/answer-ui-view.test.tsx new file mode 100644 index 0000000..1069ae9 --- /dev/null +++ b/src/features/agent/answer-ui/answer-ui-view.test.tsx @@ -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) { + 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( + + ); + 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( + + ); + 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((resolve) => { + complete = resolve; + }) + ); + render( + + ); + 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 回复") + ); + }); +}); diff --git a/src/features/agent/answer-ui/answer-ui-view.tsx b/src/features/agent/answer-ui/answer-ui-view.tsx new file mode 100644 index 0000000..99545f5 --- /dev/null +++ b/src/features/agent/answer-ui/answer-ui-view.tsx @@ -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 | void; + onSubmit?: (text: string) => Promise | void; +}; +const InteractionContext = createContext({ disabled: true }); + +const { registry } = defineRegistry(answerUiCatalog, { + actions: {}, + components: { + Stack: ({ props, children }) => ( +
+ {children} +
+ ), + Section: ({ props, children }) => ( +
+

{props.title}

+ {props.description ? ( +

{props.description}

+ ) : null} +
{children}
+
+ ), + Text: ({ props }) => ( +

+ {props.text} +

+ ), + Metric: ({ props }) => ( +
+

{props.label}

+

+ {props.value} + {props.unit ? {props.unit} : null} +

+ {props.source ? ( +

来源:{props.source}

+ ) : null} +
+ ), + Table: ({ props }) => ( + + ), + Chart: ({ props }) => , + Evidence: ({ props }) => ( + +
+

+ { + ( + { + observed: "查询记录", + human_input: "用户提供", + inference: "分析推断", + demo: "演练数据" + } as const + )[props.kind] + }{" "} + · {props.source} +

+

{props.summary}

+ {props.observed_at ?

记录时间:{props.observed_at}

: null} + {props.evidence_id ?

资料编号:{props.evidence_id}

: null} +
+
+ ), + MapLocation: ({ props }) => , + FollowUpForm: ({ props }) => + } +}); + +export function AnswerUiView({ + block, + disabled = false, + onLocate, + onSubmit +}: { block: AnswerUiBlock } & Partial) { + return ( + +
+ + + +
+
+ ); +} + +function DataTable({ + title, + columns, + rows +}: { + title: string; + columns: string[]; + rows: string[][]; +}) { + return ( +
+
+ + + + + {columns.map((column, index) => ( + + ))} + + + + {rows.map((row, index) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{title}
+ {column} +
+ {cell} +
+
+
+ ); +} + +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 ( +
+
+ {( + [ + ["line", "折线"], + ["bar", "柱状"], + ["table", "数据"] + ] as const + ).map(([value, label]) => ( + + ))} +
+ {mode === "table" ? ( + series.name)]} + rows={props.x_data.map((label, index) => [ + label, + ...props.series.map((series) => String(series.data[index])) + ])} + /> + ) : ( + + )} +

+ 来源:{props.source} +

+
+ ); +} + +function LocateButton({ + label, + source, + ...target +}: AnswerUiMapTarget & { label: string; source: string }) { + const { disabled, onLocate } = useContext(InteractionContext); + const [running, setRunning] = useState(false); + const [feedback, setFeedback] = useState(null); + return ( +
+ +

依据:{source}

+ {feedback ? ( +

+ {feedback} +

+ ) : null} +
+ ); +} + +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>({}); + const [submitting, setSubmitting] = useState(false); + const [feedback, setFeedback] = useState(null); + return ( +
{ + 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); + } + }} + > +
+

{title}

+

{description}

+
+ {fields.map((field) => + field.kind === "datetime" ? ( + setValues((current) => ({ ...current, [field.name]: value }))} + /> + ) : ( +
+ + {field.kind === "text" ? ( +