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 (
+
+
+
+
+
{target.feature_ids.length} 个关联要素 · 依据:{source}
+
查看关联要素}>
+ {target.feature_ids.join("、")}
+
+
+ {feedback || running || !onLocate ?
{running ? "正在查找关联要素…" : feedback ?? "当前视图不支持地图定位,可查看关联要素编号。"} : null}
+
+
+ );
+}
+
+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}
+
+
+
+
+
+ );
+}
diff --git a/src/features/agent/answer-ui/answer-content-nav.tsx b/src/features/agent/answer-ui/answer-content-nav.tsx
new file mode 100644
index 0000000..9cad947
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-content-nav.tsx
@@ -0,0 +1,72 @@
+import { useRef, type RefObject } from "react";
+import { ChartNoAxesCombined, ClipboardPenLine, Gauge, MapPin, BookOpenCheck, Table2, ListTree, ChevronDown, CircleCheck } from "lucide-react";
+import { Badge } from "@/shared/ui/badge";
+import { Button } from "@/shared/ui/button";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
+import type { AnswerUiBlock } from "./contract";
+
+const contentKinds = [
+ { type: "Result", label: "结果", icon: CircleCheck, selector: "[data-answer-result]" },
+ { type: "Metric", label: "指标", icon: Gauge, selector: ".answer-ui-metric" },
+ { type: "Chart", label: "趋势", icon: ChartNoAxesCombined, selector: ".answer-ui-chart" },
+ { type: "Table", label: "表格", icon: Table2, selector: "[data-answer-table]" },
+ { type: "Evidence", label: "依据", icon: BookOpenCheck, selector: ".answer-ui-evidence" },
+ { type: "MapLocation", label: "地图", icon: MapPin, selector: ".answer-ui-location" },
+ { type: "FollowUpForm", label: "补充资料", icon: ClipboardPenLine, selector: ".answer-ui-form" }
+] as const;
+
+/** Navigate the mounted answer so chart selections and unsent forms survive. */
+export function AnswerContentNav({ block, contentRef }: {
+ block: AnswerUiBlock;
+ contentRef: RefObject;
+}) {
+ const navigationTarget = useRef(null);
+ const elements = Object.values(block.spec.elements);
+ const items = contentKinds.map((kind) => ({
+ ...kind, count: elements.filter((element) => element.type === kind.type).length
+ })).filter((item) => item.count > 0);
+ if (items.length < 2) return null;
+
+ return (
+
+ );
+}
diff --git a/src/features/agent/answer-ui/answer-motion-context.ts b/src/features/agent/answer-ui/answer-motion-context.ts
new file mode 100644
index 0000000..4346e63
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-motion-context.ts
@@ -0,0 +1,4 @@
+import { createContext, useContext } from "react";
+
+export const AnswerMotionContext = createContext<() => boolean>(() => false);
+export const useAnswerMotion = () => useContext(AnswerMotionContext);
diff --git a/src/features/agent/answer-ui/answer-motion.tsx b/src/features/agent/answer-ui/answer-motion.tsx
new file mode 100644
index 0000000..9828f8c
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-motion.tsx
@@ -0,0 +1,72 @@
+import { forwardRef, useCallback, useLayoutEffect, useRef, useState, type ComponentProps, type ReactElement, type ReactNode } from "react";
+import { motion, useAnimationControls, useReducedMotion } from "motion/react";
+import { ChevronDown } from "lucide-react";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
+import { agentEase } from "../components/agent-motion";
+
+import { AnswerMotionContext, useAnswerMotion } from "./answer-motion-context";
+
+/** Initial rendering is instant; subsequent transitions follow pointer or keyboard modality. */
+export function AnswerMotionScope({ children, ...props }: ComponentProps<"div">) {
+ const pointer = useRef(false);
+ const reducedMotion = useReducedMotion();
+ const shouldAnimate = useCallback(() => pointer.current && !reducedMotion, [reducedMotion]);
+ return
+ { pointer.current = true; }}
+ onKeyDownCapture={() => { pointer.current = false; }}
+ onClickCapture={(event) => { if (event.detail === 0) pointer.current = false; }}>{children}
+ ;
+}
+
+/** Keep the same mounted editors; retiring content is immediately inert and hidden from AT. */
+export const AnswerReveal = forwardRef & { open: boolean }>(
+ function AnswerReveal({ open, children, ...props }, ref) {
+ const shouldAnimate = useAnswerMotion();
+ const reducedMotion = useReducedMotion();
+ const controls = useAnimationControls();
+ const previous = useRef(open);
+ const [retained, setRetained] = useState(open);
+ useLayoutEffect(() => {
+ const changed = previous.current !== open;
+ previous.current = open;
+ controls.stop();
+ if (!changed || !shouldAnimate() || reducedMotion) {
+ controls.set({ opacity: 1, y: 0 });
+ setRetained(open);
+ return;
+ }
+ let cancelled = false;
+ if (open) {
+ setRetained(true);
+ controls.set({ opacity: 0, y: -4 });
+ }
+ void controls.start(open ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }, {
+ duration: 0.18, ease: agentEase
+ }).then(() => { if (!cancelled) setRetained(open); });
+ return () => { cancelled = true; controls.stop(); };
+ }, [controls, open, reducedMotion, shouldAnimate]);
+ return {children};
+ }
+);
+
+export function AnswerDisclosure({ open, onOpenChange, trigger, children, contentClassName, ...props }: {
+ open: boolean; onOpenChange: (open: boolean) => void; trigger: ReactElement; children: ReactNode;
+ contentClassName?: string;
+} & Omit, "children" | "open" | "onOpenChange">) {
+ return
+ {trigger}
+
+ {children}
+
+ ;
+}
+
+export function AnswerDisclosureChevron({ open }: { open: boolean }) {
+ const shouldAnimate = useAnswerMotion();
+ return
+
+ ;
+}
diff --git a/src/features/agent/answer-ui/answer-result.tsx b/src/features/agent/answer-ui/answer-result.tsx
new file mode 100644
index 0000000..91c5bff
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-result.tsx
@@ -0,0 +1,29 @@
+import { CircleCheck, CircleHelp, CircleX, TriangleAlert } from "lucide-react";
+import { Badge } from "@/shared/ui/badge";
+
+const resultStatuses = {
+ success: { label: "成功", icon: CircleCheck, color: "text-[var(--status-success-foreground)]" },
+ partial: { label: "部分完成", icon: TriangleAlert, color: "text-[var(--status-warning-foreground)]" },
+ unconfirmed: { label: "未确认", icon: CircleHelp, color: "text-muted-foreground" },
+ error: { label: "失败", icon: CircleX, color: "text-destructive" }
+};
+
+/** A summary of recorded evidence, never an executable operation or approval. */
+export function AnswerResult({ title, summary, status, source }: {
+ title: string;
+ summary: string;
+ status: keyof typeof resultStatuses;
+ source: string | null;
+}) {
+ const { label, icon: Icon, color } = resultStatuses[status];
+ return
+
+
+
{title}
+ {label}
+
+ {summary}
+ {source ? 依据:{source}
: null}
+ ;
+}
diff --git a/src/features/agent/answer-ui/answer-selection-tray.tsx b/src/features/agent/answer-ui/answer-selection-tray.tsx
new file mode 100644
index 0000000..b98e5e2
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-selection-tray.tsx
@@ -0,0 +1,59 @@
+import { useId, useState } from "react";
+import { ArrowUpRight, Layers2, X } from "lucide-react";
+import { Button } from "@/shared/ui/button";
+import { Textarea } from "@/shared/ui/textarea";
+import type { AnswerUiSelection } from "./answer-ui-interaction-context";
+import { AnswerDisclosure, AnswerDisclosureChevron, AnswerReveal } from "./answer-motion";
+import { getSelectionText } from "./answer-selection";
+
+export function AnswerSelectionTray({ selections, disabled, onRemove, onClear, onAsk }: {
+ selections: AnswerUiSelection[];
+ disabled: boolean;
+ onRemove: (selection: AnswerUiSelection) => void;
+ onClear: () => void;
+ onAsk: (selection: AnswerUiSelection) => Promise | void;
+}) {
+ const id = useId();
+ const [intent, setIntent] = useState("");
+ const [running, setRunning] = useState(false);
+ const [error, setError] = useState(null);
+ return 0}>
+
+
已选 {selections.length} 项分析内容
+
+
+
+ {selections.map((selection) => -
+
+
+
)}
+
+
+ ;
+}
+
+function SelectionDetail({ selection }: { selection: AnswerUiSelection }) {
+ const [open, setOpen] = useState(false);
+ return
+ {selection.title}
+ }>
+ {selection.text}
+ ;
+}
diff --git a/src/features/agent/answer-ui/answer-selection.ts b/src/features/agent/answer-ui/answer-selection.ts
new file mode 100644
index 0000000..c3a84f6
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-selection.ts
@@ -0,0 +1,6 @@
+import type { AnswerUiSelection } from "./answer-ui-interaction-context";
+
+export const ANSWER_SELECTION_LIMIT = 8000;
+export function getSelectionText(selections: AnswerUiSelection[]) {
+ return selections.map((selection, index) => `${index + 1}. ${selection.title}\n${selection.text}`).join("\n\n");
+}
diff --git a/src/features/agent/answer-ui/answer-task-layout.test.ts b/src/features/agent/answer-ui/answer-task-layout.test.ts
new file mode 100644
index 0000000..d7e9cdb
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-task-layout.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import type { Spec } from "@json-render/core";
+import { getAnswerTaskLayout } from "./answer-task-layout";
+
+describe("answer task composition", () => {
+ const nodes: Spec["elements"] = {
+ root: { type: "Stack", props: { columns: "one" }, children: ["comparison", "data", "evidence", "mixed", "form"] },
+ comparison: { type: "Stack", props: { columns: "two" }, children: ["metric-a", "metric-b"] },
+ "metric-a": { type: "Metric", props: {} },
+ "metric-b": { type: "Metric", props: {} },
+ data: { type: "Section", props: { title: "趋势" }, children: ["chart", "table"] },
+ chart: { type: "Chart", props: {} },
+ table: { type: "Table", props: {} },
+ evidence: { type: "Evidence", props: {} },
+ mixed: { type: "Section", props: { title: "判断与依据" }, children: ["note", "basis"] },
+ note: { type: "Text", props: {} },
+ basis: { type: "Evidence", props: {} },
+ form: { type: "FollowUpForm", props: {} }
+ };
+ it("composes independent content while keeping authored comparisons and mixed sections together", () => {
+ expect(getAnswerTaskLayout({ root: "root", elements: nodes })).toEqual({
+ overview: ["comparison", "mixed"], analysis: ["data"], evidence: ["evidence"], followUp: ["form"]
+ });
+ });
+ it("preserves an explicitly authored comparison as the root", () => {
+ expect(getAnswerTaskLayout({ root: "comparison", elements: nodes })).toBeNull();
+ });
+ it("keeps explicit content roles and comparison intent instead of guessing from component types", () => {
+ const elements: Spec["elements"] = { ...nodes,
+ data: { ...nodes.data, props: { title: "读数依据", role: "evidence" } },
+ mixed: { ...nodes.mixed, props: { title: "处理结果", role: "result" } }
+ };
+ expect(getAnswerTaskLayout({ root: "root", elements })).toEqual({
+ overview: ["comparison", "mixed"], analysis: [], evidence: ["data", "evidence"], followUp: ["form"]
+ });
+ expect(getAnswerTaskLayout({ root: "root", elements: { ...elements,
+ root: { ...elements.root, props: { ...elements.root.props, intent: "comparison" } }
+ } })).toBeNull();
+ });
+});
diff --git a/src/features/agent/answer-ui/answer-task-layout.ts b/src/features/agent/answer-ui/answer-task-layout.ts
new file mode 100644
index 0000000..b4c6004
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-task-layout.ts
@@ -0,0 +1,35 @@
+import type { Spec } from "@json-render/core";
+
+export type AnswerTaskLayout = {
+ overview: string[];
+ analysis: string[];
+ evidence: string[];
+ followUp: string[];
+};
+
+type ContentRole = keyof AnswerTaskLayout;
+
+/** Keep authored comparisons and mixed sections intact. Only compose independent roots. */
+export function getAnswerTaskLayout(spec: Spec): AnswerTaskLayout | null {
+ const root = spec.elements[spec.root];
+ if (root.type !== "Stack" || root.props.columns !== "one" || root.props.intent === "comparison") return null;
+ const roleFor = (id: string): ContentRole => {
+ const node = spec.elements[id];
+ if (node.type === "Section") {
+ const roles: Record = { result: "overview", analysis: "analysis", evidence: "evidence", follow_up: "followUp" };
+ const role = typeof node.props.role === "string" ? roles[node.props.role] : undefined;
+ if (role) return role;
+ }
+ if (node.type === "Evidence") return "evidence";
+ if (node.type === "FollowUpForm") return "followUp";
+ if (["Chart", "Table", "MapLocation"].includes(node.type)) return "analysis";
+ if (node.children?.length) {
+ const roles = new Set(node.children.map(roleFor));
+ if (roles.size === 1) return [...roles][0];
+ }
+ return "overview";
+ };
+ const groups: AnswerTaskLayout = { overview: [], analysis: [], evidence: [], followUp: [] };
+ for (const id of root.children ?? []) groups[roleFor(id)].push(id);
+ return groups;
+}
diff --git a/src/features/agent/answer-ui/answer-ui-interaction-context.ts b/src/features/agent/answer-ui/answer-ui-interaction-context.ts
index 2f7909b..edd3123 100644
--- a/src/features/agent/answer-ui/answer-ui-interaction-context.ts
+++ b/src/features/agent/answer-ui/answer-ui-interaction-context.ts
@@ -10,6 +10,8 @@ export type AnswerUiInteractions = {
onLocate?: (target: AnswerUiMapTarget) => Promise | void;
onSubmit?: (text: string) => Promise | void;
onAsk?: (selection: AnswerUiSelection) => Promise | void;
+ onToggleSelection?: (selection: AnswerUiSelection) => void;
+ selections?: AnswerUiSelection[];
evidenceFilter?: string;
submitMode?: "send" | "draft";
};
diff --git a/src/features/agent/answer-ui/answer-ui-interactions.tsx b/src/features/agent/answer-ui/answer-ui-interactions.tsx
index 3b9035f..abec4d2 100644
--- a/src/features/agent/answer-ui/answer-ui-interactions.tsx
+++ b/src/features/agent/answer-ui/answer-ui-interactions.tsx
@@ -1,6 +1,9 @@
import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context";
import { useContext, useState } from "react";
-import { MessageSquarePlus } from "lucide-react";
+import { Check, MessageSquarePlus, Plus } from "lucide-react";
+import { motion } from "motion/react";
+import { useAnswerMotion } from "./answer-motion-context";
+import { agentEase } from "../components/agent-motion";
import { Button } from "@/shared/ui/button";
export function AskAboutContent({ title, text, compact = false }: AnswerUiSelection & { compact?: boolean }) {
@@ -22,3 +25,21 @@ export function AskAboutContent({ title, text, compact = false }: AnswerUiSelect
{compact ? null : "针对这部分提问"}
{error ? {error}
: null};
}
+
+export function SelectForAnalysis({ title, text, compact = false, label }: AnswerUiSelection & { compact?: boolean; label?: string }) {
+ const shouldAnimate = useAnswerMotion();
+ const { disabled, onToggleSelection, selections = [] } = useContext(AnswerInteractionContext);
+ if (!onToggleSelection) return null;
+ const selected = selections.some((item) => item.title === title && item.text === text);
+ return ;
+}
diff --git a/src/features/agent/answer-ui/answer-ui-state.test.tsx b/src/features/agent/answer-ui/answer-ui-state.test.tsx
new file mode 100644
index 0000000..b619cbc
--- /dev/null
+++ b/src/features/agent/answer-ui/answer-ui-state.test.tsx
@@ -0,0 +1,107 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { AnswerUiView } from "./answer-ui-view";
+import { parseAnswerUiBlock } from "./contract";
+import { normalizeAnswerViewState, type AnswerViewState } from "./answer-view-state";
+
+vi.mock("echarts-for-react", () => ({ default: () => }));
+function block(type: string, props: Record, id = "node") {
+ const value = parseAnswerUiBlock({ version: "answer-ui@1", block_id: "state", fallback_text: "演练", spec: { root: id, elements: { [id]: { type, props, children: [] } } } });
+ if (!value) throw new Error("Invalid fixture");
+ return value;
+}
+const chart = (labels = ["一", "二"]) => block("Chart", { title: "压力", chart_type: "line", x_data: labels, series: [{ name: "入口", data: labels.map((_, index) => index) }], source: "测试" });
+const form = (kind = "text") => block("FollowUpForm", { title: "补充", description: "主动填写", fields: [{ name: "record", kind, label: "现场记录", required: false }, { name: "other", kind: "text", label: "其他资料", required: false }] });
+
+describe("controlled native answer state", () => {
+ it("keeps independent drafts for identically shaped nodes and never changes model props", () => {
+ const current = form();
+ const node = current.spec.elements.node;
+ current.spec = { root: "root", elements: { root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["first", "second"] }, first: structuredClone(node), second: structuredClone(node) } };
+ const changed = vi.fn();
+ const original = JSON.stringify(current.spec);
+ render();
+ const inputs = screen.getAllByLabelText("现场记录");
+ fireEvent.change(inputs[0], { target: { value: "第一份草稿" } });
+ fireEvent.change(inputs[1], { target: { value: "第二份草稿" } });
+ expect(inputs[0]).toHaveValue("第一份草稿");
+ expect(inputs[1]).toHaveValue("第二份草稿");
+ expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ nodes: { first: { type: "FollowUpForm", values: { record: "第一份草稿" } }, second: { type: "FollowUpForm", values: { record: "第二份草稿" } } } }), ["/nodes/second/values/record"]);
+ expect(JSON.stringify(current.spec)).toBe(original);
+ });
+
+ it("records only the edited form field and restores it after remount", () => {
+ const current = form();
+ let saved: AnswerViewState = normalizeAnswerViewState(current.spec);
+ const changed = vi.fn((next: AnswerViewState) => { saved = next; });
+ const rendered = render();
+ fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "尚未提交的草稿" } });
+ expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ nodes: { node: { type: "FollowUpForm", values: { record: "尚未提交的草稿" } } } }), ["/nodes/node/values/record"]);
+ rendered.unmount();
+ render();
+ expect(screen.getByLabelText("现场记录")).toHaveValue("尚未提交的草稿");
+ expect(screen.getByLabelText("其他资料")).toHaveValue("");
+ });
+
+ it("keeps chart data-tab search across data refresh and clears row selection without remounting", () => {
+ const current = chart();
+ const state = normalizeAnswerViewState(current.spec);
+ if (state.nodes.node.type === "Chart") state.nodes.node.view = "table";
+ const { rerender } = render();
+ const search = screen.getByRole("textbox", { name: "搜索压力" });
+ fireEvent.change(search, { target: { value: "一" } });
+ fireEvent.click(screen.getByRole("checkbox", { name: "选择第 1 行" }));
+ expect(screen.getByRole("checkbox", { name: "选择第 1 行" })).toBeChecked();
+ // A local owner can omit the external snapshot while keeping the same block instance.
+ rerender();
+ expect(screen.getByRole("textbox", { name: "搜索压力" })).toBe(search);
+ expect(search).toHaveValue("一");
+ expect(screen.getByRole("checkbox", { name: "选择第 1 行" })).not.toBeChecked();
+ expect(screen.getByRole("tab", { name: "数据" })).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("restores chart data selections atomically when a delayed snapshot changes hidden series and range", () => {
+ const current = block("Chart", { title: "压力", chart_type: "line", x_data: ["08:00", "09:00", "10:00"], series: [{ name: "入口", data: [1, 2, 3] }, { name: "出口", data: [3, 4, 5] }], source: "测试" });
+ const changed = vi.fn();
+ const { rerender } = render();
+ const restored = normalizeAnswerViewState(current.spec, { nodes: { node: { type: "Chart", view: "table", hidden: [1], range: [0, 1], point: 1, inspecting: true,
+ table: { query: "09:00", sort: { column: 1, descending: true }, selection: [1] } } } });
+ rerender();
+ expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
+ expect(screen.getByRole("checkbox", { name: "选择第 2 行" })).toBeChecked();
+ expect(screen.getByRole("columnheader", { name: "入口" })).toHaveAttribute("aria-sort", "descending");
+ expect(screen.queryByRole("columnheader", { name: "出口" })).not.toBeInTheDocument();
+ expect(changed).not.toHaveBeenCalled();
+ // A subsequent deliberate series change clears invalid derived-table indices.
+ fireEvent.click(screen.getByRole("button", { name: "出口" }));
+ expect(screen.getByRole("checkbox", { name: "选择第 2 行" })).not.toBeChecked();
+ expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
+ expect(changed).toHaveBeenLastCalledWith(expect.anything(), ["/nodes/node/hidden", "/nodes/node/table/sort", "/nodes/node/table/selection"]);
+ });
+
+ it("preserves form drafts during streaming repeats and drops a draft when the field kind changes", () => {
+ const { rerender } = render();
+ const input = screen.getByLabelText("现场记录");
+ fireEvent.change(input, { target: { value: "未发送" } });
+ rerender();
+ expect(screen.getByLabelText("现场记录")).toBe(input);
+ expect(input).toHaveValue("未发送");
+ rerender();
+ expect(screen.getByLabelText("现场记录")).toHaveValue(null);
+ });
+
+ it("restores evidence expansion and reports ambiguous chart label migration", () => {
+ const evidence = block("Evidence", { title: "依据", summary: "记录内容", source: "测试", kind: "observed", observed_at: null, evidence_id: null });
+ const saved = normalizeAnswerViewState(evidence.spec, { nodes: { node: { type: "Evidence", open: true } } });
+ const { unmount } = render();
+ expect(screen.getByText("记录内容")).toBeVisible();
+ unmount();
+ const original = chart();
+ const state = normalizeAnswerViewState(original.spec);
+ if (state.nodes.node.type === "Chart") { state.nodes.node.point = 1; state.nodes.node.inspecting = true; }
+ const mounted = render();
+ mounted.rerender();
+ expect(screen.getByRole("status")).toHaveTextContent("无法唯一对应,已清除选择");
+ expect(screen.queryByRole("region", { name: "数据点详情" })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/features/agent/answer-ui/answer-ui-view.test.tsx b/src/features/agent/answer-ui/answer-ui-view.test.tsx
index 9f5cc1f..2d128f9 100644
--- a/src/features/agent/answer-ui/answer-ui-view.test.tsx
+++ b/src/features/agent/answer-ui/answer-ui-view.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { AnswerUiView } from "./answer-ui-view";
@@ -15,7 +15,134 @@ function block(type: string, props: Record) {
if (!parsed) throw new Error("invalid test fixture");
return parsed;
}
+
+function taskBlock(includeData = true) {
+ const children = ["metric", ...(includeData ? ["table"] : []), "evidence", "form"];
+ const fixture = {
+ version: "answer-ui@1", block_id: "task", fallback_text: "演练分析",
+ spec: { root: "root", elements: {
+ root: { type: "Stack", props: { columns: "one", gap: "normal" }, children },
+ metric: { type: "Metric", props: { label: "当前压力", value: "0.2130", unit: "MPa", source: "监测快照" }, children: [] },
+ ...(includeData ? { table: { type: "Table", props: { title: "入口记录", columns: ["对象", "压力(MPa)"], rows: [["北区入口", "0.2130"], ["南区入口", "0.2870"]] }, children: [] } } : {}),
+ evidence: { type: "Evidence", props: { title: "读数依据", summary: "仅用于演练", source: "测试查询", kind: "demo", evidence_id: "DEMO-26", observed_at: null }, children: [] },
+ form: { type: "FollowUpForm", props: { title: "核验资料", description: "主动补充", fields: [{ name: "record", label: "现场记录", kind: "text", required: true }] }, children: [] }
+ } }
+ };
+ const parsed = parseAnswerUiBlock(fixture);
+ if (!parsed) throw new Error("invalid task fixture");
+ return parsed;
+}
describe("local interactive answer renderer", () => {
+ it("adapts response and report composition without replacing a reader's form draft", async () => {
+ const user = userEvent.setup();
+ const fixture = taskBlock();
+ fixture.spec.elements.root.props = { columns: "one", gap: "normal", intent: "response", density: "compact" };
+ const { rerender, container } = render();
+ const editor = screen.getByLabelText("现场记录(必填)");
+ await user.type(editor, "保留的现场草稿");
+ expect(container.querySelector("[data-answer-task-layout]")).toBeNull();
+ const report = { ...fixture, spec: { ...fixture.spec, elements: { ...fixture.spec.elements,
+ root: { ...fixture.spec.elements.root, props: { columns: "one", gap: "normal", intent: "report", density: "comfortable" } }
+ } } };
+ rerender();
+ expect(container.querySelector("[data-answer-task-layout]")).not.toBeNull();
+ expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
+ expect(editor).toHaveValue("保留的现场草稿");
+ });
+ it("renders evidence-bound result states without treating historical results as live alerts", () => {
+ const fixture = (status: string) => block("Result", { title: "管道定位结果", summary: "已定位 3 根管道;高亮尚未确认。", status, source: "地图定位回执" });
+ const { rerender } = render();
+ const result = screen.getByRole("group", { name: "管道定位结果" });
+ expect(result).toHaveTextContent("部分完成");
+ expect(result).toHaveTextContent("依据:地图定位回执");
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ for (const [status, label] of [["success", "成功"], ["unconfirmed", "未确认"], ["error", "失败"]]) {
+ rerender();
+ expect(result).toHaveTextContent(label);
+ expect(result).toHaveAttribute("data-result-status", status);
+ }
+ });
+ it("uses the supplied note title without changing the warning meaning", () => {
+ render();
+ expect(screen.getByRole("note")).toHaveTextContent("能力限制");
+ expect(screen.getByRole("note")).not.toHaveTextContent("注意事项");
+ });
+ it("combines metrics, selected rows and evidence into one deliberate quote without sending", async () => {
+ const user = userEvent.setup();
+ const ask = vi.fn();
+ const submit = vi.fn();
+ render();
+ await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
+ await user.click(screen.getByRole("checkbox", { name: "选择第 1 行" }));
+ await user.click(screen.getByRole("button", { name: "将所选行加入分析" }));
+ await user.click(screen.getByRole("button", { name: /读数依据.*测试查询/ }));
+ await user.click(screen.getByRole("button", { name: "将读数依据加入分析" }));
+ const tray = screen.getByRole("region", { name: "分析内容篮" });
+ expect(tray).toHaveTextContent("已选 3 项分析内容");
+ expect(ask).not.toHaveBeenCalled();
+ await user.type(within(tray).getByRole("textbox"), "比较差异并核查原因");
+ await user.click(within(tray).getByRole("button", { name: "带入对话" }));
+ expect(ask).toHaveBeenCalledWith({ title: "3 项内容的联合分析", text: expect.stringContaining("分析目标:比较差异并核查原因") });
+ const quote = ask.mock.calls[0][0].text;
+ for (const exact of ["0.2130MPa", "来源:监测快照", "对象:北区入口;压力(MPa):0.2130", "资料编号:DEMO-26", "引用内容块:task"]) expect(quote).toContain(exact);
+ expect(submit).not.toHaveBeenCalled();
+ await user.click(within(tray).getByRole("button", { name: "移除当前压力" }));
+ expect(tray).toHaveTextContent("已选 2 项分析内容");
+ expect(screen.getByRole("button", { name: "将当前压力加入分析" })).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("keeps the same form and selected content when more data arrives and support sections collapse", async () => {
+ const user = userEvent.setup();
+ const ask = vi.fn();
+ const { rerender } = render();
+ const editor = screen.getByLabelText("现场记录(必填)");
+ await user.type(editor, "未发送的现场草稿");
+ await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
+ rerender();
+ expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
+ expect(editor).toHaveValue("未发送的现场草稿");
+ await user.click(screen.getByRole("button", { name: "收起补充资料" }));
+ expect(editor.closest("[data-answer-reveal]")).toHaveAttribute("inert");
+ await waitFor(() => expect(editor).not.toBeVisible());
+ await user.click(screen.getByRole("button", { name: "展开补充资料" }));
+ await waitFor(() => expect(editor).toBeVisible());
+ expect(editor).toHaveValue("未发送的现场草稿");
+ expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容");
+ const updated = taskBlock();
+ updated.spec.elements.table.props.rows = [["新增入口", "0.24"]];
+ rerender();
+ expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
+ expect(editor).toHaveValue("未发送的现场草稿");
+ });
+
+ it("rejects oversized row quotes without truncating values or removing existing selections", async () => {
+ const user = userEvent.setup();
+ const fixture = taskBlock();
+ fixture.spec.elements.table.props.rows = Array.from({ length: 12 }, (_, index) => [String(index), "长".repeat(800)]);
+ render();
+ await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
+ await user.click(screen.getByRole("checkbox", { name: "选择入口记录的全部筛选结果" }));
+ await user.click(screen.getByRole("button", { name: "将所选行加入分析" }));
+ expect(screen.getByRole("status")).toHaveTextContent("8000 字");
+ expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容");
+ expect(screen.getByRole("checkbox", { name: "选择第 12 行" })).toBeChecked();
+ });
+
+ it("expresses supplied context and cautions as readable notes without announcing historical alerts", () => {
+ const { rerender } = render();
+ expect(screen.getByRole("note")).toHaveTextContent("注意事项");
+ expect(screen.getByRole("note")).toHaveTextContent("压力记录尚未经过现场核验。");
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
+ rerender();
+ expect(screen.getByRole("note")).toHaveTextContent("补充说明");
+ rerender();
+ expect(screen.queryByRole("note")).not.toBeInTheDocument();
+ expect(screen.getByText("普通回答")).toBeInTheDocument();
+ });
it("offers a named metric quote action and retains the exact value, unit and source", async () => {
const user = userEvent.setup();
const ask = vi.fn();
diff --git a/src/features/agent/answer-ui/answer-ui-view.tsx b/src/features/agent/answer-ui/answer-ui-view.tsx
index 8691d80..8d2b2cd 100644
--- a/src/features/agent/answer-ui/answer-ui-view.tsx
+++ b/src/features/agent/answer-ui/answer-ui-view.tsx
@@ -1,48 +1,55 @@
-import { AskAboutContent } from "./answer-ui-interactions";
-import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
-import { useContext, useId, useState } from "react";
+import { LocateButton, FollowUpForm } from "./answer-action-components";
+import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
+import { Renderer, defineRegistry } from "@json-render/react";
+import { useContext, useRef, useState, type ReactNode } from "react";
import {
- ArrowUpRight,
- ChevronDown,
+ Info,
+ TriangleAlert,
ClipboardList,
Database,
FileCheck2,
FlaskConical,
Lightbulb,
- MapPin,
MessageSquare
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Badge } from "@/shared/ui/badge";
-import { Card, CardContent } from "@/shared/ui/card";
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
-import { DateTimeField } from "@/shared/ui/date-time-field";
-import { Input } from "@/shared/ui/input";
-import { Textarea } from "@/shared/ui/textarea";
+import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
+import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
import { cn } from "@/shared/ui/cn";
import { InteractiveChart } from "./interactive-chart";
import { InteractiveDataTable } from "./interactive-data-table";
-import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
+import { AnswerResult } from "./answer-result";
+import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget, type AnswerUiSelection } from "./answer-ui-interaction-context";
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract";
+import { AnswerContentNav } from "./answer-content-nav";
+import { getAnswerTaskLayout } from "./answer-task-layout";
+import { AnswerSelectionTray } from "./answer-selection-tray";
+import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
+import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
+import { ANSWER_SELECTION_LIMIT, getSelectionText } from "./answer-selection";
-const { registry } = defineRegistry(answerUiCatalog, {
+const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {},
components: {
Stack: ({ props, children }) => (
{children}
),
Section: ({ props, children }) => (
-
+
{props.title}
@@ -53,22 +60,21 @@ const { registry } = defineRegistry(answerUiCatalog, {
{children}
),
- Text: ({ props }) => (
-
- {props.text}
-
+ Text: ({ props }) => props.tone === "neutral" ? (
+
+ {props.title ?
{props.title}
: null}
+
{props.text}
+
+ ) : (
+
+ {props.tone === "warning" ? : }
+ {props.title ?? (props.tone === "warning" ? "注意事项" : "补充说明")}
+ {props.text}
+
),
+ Result: ({ props }) => ,
Metric: ({ props }) => (
-
+
{props.label}
@@ -80,21 +86,35 @@ const { registry } = defineRegistry(answerUiCatalog, {
{props.source ? (
来源:{props.source}
) : null}
-
+
),
Table: ({ props }) => (
-
+
),
- Chart: ({ props }) => ,
+ Chart: ({ props }) => ,
Evidence: ({ props }) => ,
MapLocation: ({ props }) => ,
FollowUpForm: ({ props }) =>
}
});
-export function AnswerUiView({
+const registry = withAnswerNodeIdentity(baseRegistry);
+
+type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial;
+export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
+ return
+
+ ;
+}
+
+function AnswerUiBody({
block,
disabled = false,
onLocate,
@@ -102,25 +122,80 @@ export function AnswerUiView({
onAsk,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial) {
- const [filter, setFilter] = useState("all");
+ const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
+ const [selections, setSelections] = useState([]);
+ const [selectionError, setSelectionError] = useState(null);
+ const contentRef = useRef(null);
+ const taskLayout = getAnswerTaskLayout(block.spec);
+ const root = block.spec.elements[block.spec.root];
+ const density = root.type === "Stack" ? root.props.density : undefined;
+ const intent = root.type === "Stack" ? root.props.intent : undefined;
+ const hasTaskLayout = intent !== "response" && Boolean(taskLayout?.analysis.length && (taskLayout.evidence.length || taskLayout.followUp.length));
+ const ask = onAsk ? (selection: AnswerUiSelection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined;
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
+ const evidenceFilters = kinds.length > 1 ?
+ {["all", ...kinds].map((kind) => )}
+
: null;
+ const renderNodes = (ids: string[]) => ids.map((id) => );
return (
onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined }}>
-
- {kinds.length > 1 ?
- {["all", ...kinds].map((kind) => )}
-
: null}
-
-
-
-
+ onAsk: ask, selections,
+ onToggleSelection: onAsk ? (selection) => {
+ setSelectionError(null);
+ const alreadySelected = selections.some((item) => item.title === selection.title && item.text === selection.text);
+ if (alreadySelected) {
+ setSelections((current) => current.filter((item) => item.title !== selection.title || item.text !== selection.text));
+ return;
+ }
+ const next = [...selections, selection];
+ if (next.length > 8 || getSelectionText(next).length > ANSWER_SELECTION_LIMIT) {
+ setSelectionError("一次最多选择 8 项、8000 字内容。请减少所选数据行,或移除已有内容后重试。");
+ return;
+ }
+ setSelections(next);
+ } : undefined }}>
+
+
+
+ <>
+ {taskLayout ?
+ {taskLayout.overview.length ?
{renderNodes(taskLayout.overview)}
: null}
+ {taskLayout.analysis.length ?
+ {hasTaskLayout ? 分析内容直接探索已加载数据
: null}
+ {renderNodes(taskLayout.analysis)}
+ : null}
+ {taskLayout.evidence.length || taskLayout.followUp.length ?
+ {taskLayout.evidence.length ? }>
+ {evidenceFilters}{renderNodes(taskLayout.evidence)}
+ : null}
+ {taskLayout.followUp.length ? }>
+ {renderNodes(taskLayout.followUp)}
+ : null}
+
: null}
+
: <>{evidenceFilters}
>}
+ >
+
+ {selectionError ? {selectionError}
: null}
+ {ask ? { setSelectionError(null); setSelections((current) => current.filter((item) => item !== selection)); }}
+ onClear={() => { setSelections([]); setSelectionError(null); }} onAsk={ask} /> : null}
+
);
}
+function TaskSupport({ title, description, icon, children, enhanced }: { title: string; description: string; icon: ReactNode; children: ReactNode; enhanced: boolean }) {
+ const [open, setOpen] = useState(true);
+ return
+ {icon}{title}{description}
+
+ }>{children};
+}
+
const evidenceKinds = {
observed: { label: "查询记录", icon: Database },
human_input: { label: "用户提供", icon: MessageSquare },
@@ -136,14 +211,15 @@ function EvidenceCard(props: {
observed_at: string | null;
evidence_id: string | null;
}) {
- const [open, setOpen] = useState(false);
+ const [{ open }, patch] = useAnswerNodeState("Evidence", props);
+ const setOpen = (open: boolean) => patch({ open });
const meta = evidenceKinds[props.kind];
const Icon = meta.icon;
const { evidenceFilter } = useContext(InteractionContext);
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
return (
-
-
+
{meta.label}
-
-
-
-
+
+ }>
{props.summary}
{props.observed_at || props.evidence_id ? (
@@ -186,186 +256,10 @@ function EvidenceCard(props: {
) : null}
) : null}
-
-
-
- );
-}
-
-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, submitMode } = useContext(InteractionContext);
- const prefix = useId();
- const [values, setValues] = useState>({});
- const [submitting, setSubmitting] = useState(false);
- const [feedback, setFeedback] = useState(null);
- return (
-