From 1ee21d1c7ff0c2b05217641537b7033b2f43505d Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 29 Oct 2026 18:39:50 +0800 Subject: [PATCH] feat: polish intelligent answer interactions --- docs/agent-answer-ui.md | 19 +- .../answer-action-components.test.tsx | 70 ++++ .../answer-ui/answer-action-components.tsx | 234 +++++++++++ .../agent/answer-ui/answer-content-nav.tsx | 72 ++++ .../agent/answer-ui/answer-motion-context.ts | 4 + .../agent/answer-ui/answer-motion.tsx | 72 ++++ .../agent/answer-ui/answer-result.tsx | 29 ++ .../agent/answer-ui/answer-selection-tray.tsx | 59 +++ .../agent/answer-ui/answer-selection.ts | 6 + .../answer-ui/answer-task-layout.test.ts | 40 ++ .../agent/answer-ui/answer-task-layout.ts | 35 ++ .../answer-ui-interaction-context.ts | 2 + .../answer-ui/answer-ui-interactions.tsx | 23 +- .../agent/answer-ui/answer-ui-state.test.tsx | 107 +++++ .../agent/answer-ui/answer-ui-view.test.tsx | 129 +++++- .../agent/answer-ui/answer-ui-view.tsx | 366 +++++++----------- .../answer-ui/answer-view-state-provider.tsx | 92 +++++ .../agent/answer-ui/answer-view-state.test.ts | 53 +++ .../agent/answer-ui/answer-view-state.ts | 129 ++++++ src/features/agent/answer-ui/catalog.ts | 26 +- .../agent/answer-ui/interactive-chart.tsx | 192 ++++++--- .../answer-ui/interactive-data-table.tsx | 51 ++- src/shared/ui/alert.tsx | 29 ++ src/styles.css | 49 ++- .../agent-answer-components-motion.e2e.ts | 113 ++++++ tests/browser/agent-chart-motion.e2e.ts | 88 +++++ 26 files changed, 1762 insertions(+), 327 deletions(-) create mode 100644 src/features/agent/answer-ui/answer-action-components.test.tsx create mode 100644 src/features/agent/answer-ui/answer-action-components.tsx create mode 100644 src/features/agent/answer-ui/answer-content-nav.tsx create mode 100644 src/features/agent/answer-ui/answer-motion-context.ts create mode 100644 src/features/agent/answer-ui/answer-motion.tsx create mode 100644 src/features/agent/answer-ui/answer-result.tsx create mode 100644 src/features/agent/answer-ui/answer-selection-tray.tsx create mode 100644 src/features/agent/answer-ui/answer-selection.ts create mode 100644 src/features/agent/answer-ui/answer-task-layout.test.ts create mode 100644 src/features/agent/answer-ui/answer-task-layout.ts create mode 100644 src/features/agent/answer-ui/answer-ui-state.test.tsx create mode 100644 src/features/agent/answer-ui/answer-view-state-provider.tsx create mode 100644 src/features/agent/answer-ui/answer-view-state.test.ts create mode 100644 src/features/agent/answer-ui/answer-view-state.ts create mode 100644 src/shared/ui/alert.tsx create mode 100644 tests/browser/agent-answer-components-motion.e2e.ts create mode 100644 tests/browser/agent-chart-motion.e2e.ts diff --git a/docs/agent-answer-ui.md b/docs/agent-answer-ui.md index c0a917d..c9e2227 100644 --- a/docs/agent-answer-ui.md +++ b/docs/agent-answer-ui.md @@ -9,12 +9,27 @@ ## 可用内容与交互 -`Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、 -`MapLocation`、`FollowUpForm` 共九种组件。图表可切换折线、柱状和数据表; +`Stack`、`Section`、`Text`、`Result`、`Metric`、`Table`、`Chart`、`Evidence`、 +`MapLocation`、`FollowUpForm` 共十种组件。图表可切换折线、柱状和数据表; 表格可搜索、排序和选择行,图表可切换已加载区间、显隐序列并选点追问; 证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始, 由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。 +`Chart` 的页签、图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与选行在视图切换后保留。 + +其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。 + +| 组件 | 交互与内容处理 | +| --- | --- | +| `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 | +| `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 | +| `Metric` | 保留原始数值、单位与来源,仅加入分析的按钮图标过渡 | +| `Table` | 搜索排序即时响应;选行操作栏进入退出使用统一过渡;空结果不叠加选行提示 | +| `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 | +| `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 | +| `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 | +| 报告辅助分区与分析内容篮 | 分区、内容篮及所选引用详情统一展开收起;保留表单与分析目标草稿 | + Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。 消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同 `block_id` 更新原块;下一轮不会修改上一轮回答。 diff --git a/src/features/agent/answer-ui/answer-action-components.test.tsx b/src/features/agent/answer-ui/answer-action-components.test.tsx new file mode 100644 index 0000000..c2b7d23 --- /dev/null +++ b/src/features/agent/answer-ui/answer-action-components.test.tsx @@ -0,0 +1,70 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AnswerUiView } from "./answer-ui-view"; +import { parseAnswerUiBlock } from "./contract"; +vi.mock("echarts-for-react", () => ({ default: () =>
})); +function block(type: string, props: Record) { + const result = parseAnswerUiBlock({ version: "answer-ui@1", block_id: "actions", fallback_text: "资料", spec: { root: "action", elements: { action: { type, props, children: [] } } } }); + if (!result) throw new Error("invalid fixture"); + return result; +} +const location = block("MapLocation", { label: "定位要素", source: "查询结果", layer: "pipes", feature_ids: ["P1", "P2"] }); +const form = block("FollowUpForm", { title: "现场资料", description: "用户补充", fields: [{ name: "record", label: "记录", kind: "text", required: true }] }); +describe("answer action states", () => { + it.each([ + [{ located_ids: ["P1", "P2"], missing_ids: [] }, "success", "已定位 2 个"], + [{ located_ids: ["P1"], missing_ids: ["P2"] }, "partial", "另有 1 个未找到"], + [{ located_ids: [], missing_ids: ["P1", "P2"] }, "error", "未找到可定位"], + [undefined, "unconfirmed", "未返回定位结果"] + ])("reports verified location outcomes %j", async (result, state, text) => { + render(); + fireEvent.click(screen.getByRole("button", { name: "定位要素" })); + await waitFor(() => expect(screen.getByRole("group", { name: "定位要素" })).toHaveAttribute("data-state", state)); + expect(screen.getByRole("status")).toHaveTextContent(text); + }); + it("explains unsupported location", () => { + render(); + expect(screen.getByRole("button", { name: "定位要素" })).toBeDisabled(); + expect(screen.getByText("当前视图不支持地图定位,可查看关联要素编号。")).toBeInTheDocument(); + }); + it("locks pending location and supports retry after rejection", async () => { + let reject!: (error: Error) => void; + const locate = vi.fn().mockImplementationOnce(() => new Promise((_, fail) => { reject = fail; })).mockResolvedValue({ located_ids: ["P1"], missing_ids: ["P2"] }); + render(); + fireEvent.click(screen.getByRole("button", { name: "定位要素" })); + expect(screen.getByRole("group", { name: "定位要素" })).toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("button", { name: "正在定位…" })).toBeDisabled(); + await act(async () => reject(new Error("网络失败"))); + fireEvent.click(screen.getByRole("button", { name: "重试定位要素" })); + await waitFor(() => expect(locate).toHaveBeenCalledTimes(2)); + }); + it("associates required errors, retains failed drafts and clears success when editing", async () => { + let complete!: () => void; + const submit = vi.fn().mockRejectedValueOnce(new Error("发送失败")).mockImplementationOnce(() => new Promise((resolve) => { complete = resolve; })); + render(); + const editor = screen.getByLabelText("记录(必填)"); + const formElement = screen.getByRole("form", { name: "现场资料" }); + fireEvent.submit(formElement); + expect(editor).toHaveAttribute("aria-invalid", "true"); + expect(editor).toHaveAccessibleDescription("请填写记录。"); + fireEvent.change(editor, { target: { value: "草稿" } }); + fireEvent.submit(formElement); + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("发送失败")); + expect(editor).toHaveValue("草稿"); + fireEvent.submit(formElement); + fireEvent.submit(formElement); + expect(submit).toHaveBeenCalledTimes(2); + expect(formElement).toHaveAttribute("aria-busy", "true"); + await act(async () => complete()); + expect(screen.getByRole("status")).toHaveTextContent("资料已发送"); + fireEvent.change(editor, { target: { value: "更新后的草稿" } }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByRole("group", { name: "现场资料" })).toHaveAttribute("data-state", "idle"); + }); + it("confirms draft transfer without claiming submission", async () => { + render(); + fireEvent.change(screen.getByLabelText("记录(必填)"), { target: { value: "草稿" } }); + fireEvent.click(screen.getByRole("button", { name: "带入对话" })); + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("资料已带入对话,请确认后发送")); + }); +}); diff --git a/src/features/agent/answer-ui/answer-action-components.tsx b/src/features/agent/answer-ui/answer-action-components.tsx new file mode 100644 index 0000000..c213c50 --- /dev/null +++ b/src/features/agent/answer-ui/answer-action-components.tsx @@ -0,0 +1,234 @@ +import { useContext, useId, useRef, useState } from "react"; +import { ArrowUpRight, CheckCircle2, FileCheck2, Info, LoaderCircle, MapPin, TriangleAlert } from "lucide-react"; +import { useAnswerMotion } from "./answer-motion-context"; +import { motion } from "motion/react"; +import { agentEase } from "../components/agent-motion"; +import { AnswerDisclosure, AnswerDisclosureChevron } from "./answer-motion"; +import { Button } from "@/shared/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card"; +import { DateTimeField } from "@/shared/ui/date-time-field"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; +import { AnswerInteractionContext as InteractionContext, type AnswerUiMapTarget } from "./answer-ui-interaction-context"; +import { SelectForAnalysis } from "./answer-ui-interactions"; +import { useAnswerNodeState } from "./answer-view-state-provider"; +import { answerStatePointer, type AnswerFormState } from "./answer-view-state"; + +type ActionState = "idle" | "pending" | "success" | "partial" | "unconfirmed" | "error"; +function ActionFeedback({ state, children }: { state: ActionState; children: string }) { + const shouldAnimate = useAnswerMotion(); + const animate = shouldAnimate(); + const Icon = state === "pending" ? LoaderCircle : state === "success" ? CheckCircle2 : state === "error" || state === "partial" ? TriangleAlert : Info; + return {children}; +} + +export function LocateButton({ + label, + source, + ...target +}: AnswerUiMapTarget & { label: string; source: string }) { + const { disabled, onLocate } = useContext(InteractionContext); + const runningRef = useRef(false); + const [showIds, setShowIds] = useState(false); + const [state, setState] = useState("idle"); + const running = state === "pending"; + const [feedback, setFeedback] = useState(null); + return ( + + + ); +} + +type FormField = { + name: string; + label: string; + kind: "text" | "datetime" | "number"; + required: boolean; +}; +export function FollowUpForm({ + title, + description, + fields +}: { + title: string; + description: string; + fields: FormField[]; +}) { + const { disabled, onSubmit, submitMode } = useContext(InteractionContext); + const prefix = useId(); + const [{ values }, patch] = useAnswerNodeState("FollowUpForm", { title, description, fields }); + const submittingRef = useRef(false); + const [state, setState] = useState("idle"); + const submitting = state === "pending"; + const [invalidFields, setInvalidFields] = useState([]); + const setValue = (name: string, value: string) => { + patch({ values: { ...values, [name]: value } }, [`values/${answerStatePointer(name)}`]); + setState("idle"); + setFeedback(null); + setInvalidFields((current) => current.filter((field) => field !== name)); + }; + const [feedback, setFeedback] = useState(null); + return ( + + + {title} + {description} + + +
{ + event.preventDefault(); + if (!onSubmit || disabled || submittingRef.current) return; + const missing = fields.filter((field) => field.required && !values[field.name]?.trim()).map((field) => field.name); + setInvalidFields(missing); + if (missing.length) { + setState("error"); + setFeedback("请填写必填资料。"); + return; + } + const entries = fields + .filter((field) => values[field.name]?.trim()) + .map( + (field) => + `${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}` + ); + if (!entries.length) { + setState("error"); + setFeedback("请至少填写一项资料。"); + return; + } + if (entries.join("\n").length > 9000) { + setState("error"); + setFeedback("补充资料过长,请分次发送。"); + return; + } + submittingRef.current = true; + setState("pending"); + setFeedback(null); + try { + await onSubmit( + `我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}` + ); + setState("success"); + setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。"); + } catch (error) { + setState("error"); + setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。"); + } finally { + submittingRef.current = false; + } + }} + > + {fields.map((field) => ( +
+ {field.kind === "datetime" ? ( + setValue(field.name, value)} + /> + ) : ( +
+ + {field.kind === "text" ? ( +