feat: polish intelligent answer interactions

This commit is contained in:
2026-10-29 18:39:50 +08:00
parent 9474788db6
commit 1ee21d1c7f
26 changed files with 1762 additions and 327 deletions
+17 -2
View File
@@ -9,12 +9,27 @@
## 可用内容与交互 ## 可用内容与交互
`Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、 `Stack`、`Section`、`Text`、`Result`、`Metric`、`Table`、`Chart`、`Evidence`、
`MapLocation`、`FollowUpForm` 共九种组件。图表可切换折线、柱状和数据表; `MapLocation`、`FollowUpForm` 共十种组件。图表可切换折线、柱状和数据表;
表格可搜索、排序和选择行,图表可切换已加载区间、显隐序列并选点追问; 表格可搜索、排序和选择行,图表可切换已加载区间、显隐序列并选点追问;
证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始, 证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。 由用户主动提交,提交形成普通用户消息。业务证据是否保存以 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` 发布完整的局部界面块,可在两块之间继续输出文字。 Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。
消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同 消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同
`block_id` 更新原块;下一轮不会修改上一轮回答。 `block_id` 更新原块;下一轮不会修改上一轮回答。
@@ -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: () => <div /> }));
function block(type: string, props: Record<string, unknown>) {
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(<AnswerUiView block={location} onLocate={vi.fn().mockResolvedValue(result)} />);
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(<AnswerUiView block={location} />);
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(<AnswerUiView block={location} onLocate={locate} />);
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<void>((resolve) => { complete = resolve; }));
render(<AnswerUiView block={form} onSubmit={submit} />);
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(<AnswerUiView block={form} submitMode="draft" onSubmit={vi.fn()} />);
fireEvent.change(screen.getByLabelText("记录(必填)"), { target: { value: "草稿" } });
fireEvent.click(screen.getByRole("button", { name: "带入对话" }));
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("资料已带入对话,请确认后发送"));
});
});
@@ -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 <motion.p key={state} initial={animate ? { opacity: 0, y: 3 } : false} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }} role={state === "idle" ? undefined : "status"} className="mt-1 flex items-start gap-1.5 text-xs leading-5 text-muted-foreground"><Icon size={14} className={`mt-0.5 shrink-0 ${state === "pending" && animate ? "motion-safe:animate-spin" : ""}`} aria-hidden />{children}</motion.p>;
}
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<ActionState>("idle");
const running = state === "pending";
const [feedback, setFeedback] = useState<string | null>(null);
return (
<Card className="answer-ui-location" role="group" aria-label={label} data-state={state} aria-busy={running}>
<MapPin size={17} className="mt-1 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
if (!onLocate || disabled || runningRef.current) return;
runningRef.current = true;
setState("pending");
setFeedback(null);
try {
const result = await onLocate(target);
if (result === false) throw new Error("关联要素暂不可定位,请检查地图提示。");
const output = result as { located_ids?: string[]; missing_ids?: string[] } | undefined;
if (!output || !Array.isArray(output.located_ids)) {
setState("unconfirmed");
setFeedback("定位请求已处理,未返回定位结果,请查看地图确认。");
} else {
const located = output.located_ids.length;
const missing = output.missing_ids?.length ?? 0;
setState(!located ? "error" : missing ? "partial" : "success");
setFeedback(!located ? "未找到可定位的关联要素,请检查要素编号后重试。" : missing ? `已定位 ${located} 个,另有 ${missing} 个未找到` : `已定位 ${located} 个关联要素到地图`);
}
} catch (error) {
setState("error");
setFeedback(error instanceof Error ? error.message : "地图定位失败,请重试。");
} finally {
runningRef.current = false;
}
}}
>
{running ? "正在定位…" : state === "error" ? `重试${label}` : label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button>
<p className="mt-2 break-words text-xs leading-5 text-muted-foreground">{target.feature_ids.length} 个关联要素 · 依据:{source}</p>
<AnswerDisclosure open={showIds} onOpenChange={setShowIds} className="mt-1 text-[11px] leading-5 text-muted-foreground"
trigger={<Button type="button" variant="ghost" size="sm" className="h-9 gap-1 px-0 text-[11px] font-normal">查看关联要素<AnswerDisclosureChevron open={showIds} /></Button>}>
<p className="mt-1 break-words font-mono">{target.feature_ids.join("、")}</p>
</AnswerDisclosure>
<SelectForAnalysis title={label} text={`关联图层:${target.layer}\n要素编号:${target.feature_ids.join("、")}\n依据:${source}`} />
{feedback || running || !onLocate ? <ActionFeedback state={state}>{running ? "正在查找关联要素…" : feedback ?? "当前视图不支持地图定位,可查看关联要素编号。"}</ActionFeedback> : null}
</div>
</Card>
);
}
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<AnswerFormState>("FollowUpForm", { title, description, fields });
const submittingRef = useRef(false);
const [state, setState] = useState<ActionState>("idle");
const submitting = state === "pending";
const [invalidFields, setInvalidFields] = useState<string[]>([]);
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<string | null>(null);
return (
<Card className="answer-ui-form" role="group" aria-label={title} data-state={state} aria-busy={submitting}>
<CardHeader className="gap-2 border-b border-border p-3.5">
<CardTitle className="flex items-center gap-2 text-sm leading-6 tracking-normal"><FileCheck2 size={14} className="shrink-0 text-muted-foreground" aria-hidden />{title}</CardTitle>
<CardDescription className="text-xs leading-5">{description}</CardDescription>
</CardHeader>
<CardContent className="p-3.5">
<form
className="space-y-3"
aria-label={title}
aria-busy={submitting}
noValidate
onSubmit={async (event) => {
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) => (
<div key={field.name} role={field.kind === "datetime" ? "group" : undefined} aria-label={field.kind === "datetime" ? `${field.label}${field.required ? "(必填)" : ""}` : undefined} aria-invalid={invalidFields.includes(field.name) || undefined} aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}>
{field.kind === "datetime" ? (
<DateTimeField
key={field.name}
label={`${field.label}${field.required ? "(必填)" : ""}`}
value={values[field.name] ?? ""}
disabled={disabled || submitting || !onSubmit}
onChange={(value) => setValue(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}`}
aria-required={field.required}
aria-invalid={invalidFields.includes(field.name) || undefined}
aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValue(field.name, value);
}}
/>
) : (
<Input
className="h-10"
id={`${prefix}-${field.name}`}
aria-required={field.required}
aria-invalid={invalidFields.includes(field.name) || undefined}
aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}
type="number"
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValue(field.name, value);
}}
/>
)}
</div>
)}
{invalidFields.includes(field.name) ? <p id={`${prefix}-${field.name}-error`} className="mt-1 text-xs leading-5 text-destructive">请填写{field.label}。</p> : null}
</div>
))}
<Button
type="submit"
size="sm"
variant="default"
disabled={disabled || submitting || !onSubmit}
>
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback || submitting || !onSubmit ? <ActionFeedback state={state}>{submitting ? submitMode === "draft" ? "正在带入对话…" : "正在发送补充资料…" : feedback ?? "当前视图无法提交资料。"}</ActionFeedback> : null}
</form>
</CardContent>
</Card>
);
}
@@ -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<HTMLDivElement | null>;
}) {
const navigationTarget = useRef<HTMLElement | null>(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 (
<nav aria-label="回答内容导航" className="answer-content-nav">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-9 gap-1.5 px-2 text-xs font-normal">
<ListTree size={14} aria-hidden />浏览回答<ChevronDown size={12} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" data-agent-theme="chat" className="min-w-48"
onCloseAutoFocus={(event) => {
const target = navigationTarget.current;
navigationTarget.current = null;
if (!target?.isConnected) return;
event.preventDefault();
target.tabIndex = -1;
target.focus({ preventScroll: true });
target.scrollIntoView({ block: "start", behavior: "instant" });
}}>
{items.map(({ type, label, icon: Icon, selector, count }) => (
<DropdownMenuItem key={type} className="min-h-10 gap-2 text-xs"
aria-label={`查看${label},${count}项`}
onSelect={() => {
const target = contentRef.current?.querySelector<HTMLElement>(selector) ?? null;
const section = target?.closest(".answer-task-support-section[data-state=closed]");
section?.querySelector<HTMLButtonElement>(":scope > button")?.click();
navigationTarget.current = target;
}}>
<Icon size={14} aria-hidden />{label}
<Badge variant="secondary" className="ml-auto min-w-4 justify-center rounded-sm border-0 px-1 py-0 text-[10px] tabular-nums">
{count}
</Badge>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<span className="text-[11px] text-muted-foreground">
{items.map((item, index) => <span key={item.type} className="inline-block whitespace-nowrap">
{index > 0 ? <span aria-hidden> · </span> : null}{item.label}
</span>)}
</span>
</nav>
);
}
@@ -0,0 +1,4 @@
import { createContext, useContext } from "react";
export const AnswerMotionContext = createContext<() => boolean>(() => false);
export const useAnswerMotion = () => useContext(AnswerMotionContext);
@@ -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 <AnswerMotionContext.Provider value={shouldAnimate}>
<div {...props} onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => { pointer.current = false; }}
onClickCapture={(event) => { if (event.detail === 0) pointer.current = false; }}>{children}</div>
</AnswerMotionContext.Provider>;
}
/** Keep the same mounted editors; retiring content is immediately inert and hidden from AT. */
export const AnswerReveal = forwardRef<HTMLDivElement, ComponentProps<typeof motion.div> & { 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 <motion.div {...props} ref={ref} data-answer-reveal data-open={open}
hidden={!open && !retained} inert={!open} aria-hidden={!open || undefined}
initial={false} animate={controls}>{children}</motion.div>;
}
);
export function AnswerDisclosure({ open, onOpenChange, trigger, children, contentClassName, ...props }: {
open: boolean; onOpenChange: (open: boolean) => void; trigger: ReactElement; children: ReactNode;
contentClassName?: string;
} & Omit<ComponentProps<typeof Collapsible>, "children" | "open" | "onOpenChange">) {
return <Collapsible {...props} open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>{trigger}</CollapsibleTrigger>
<CollapsibleContent forceMount asChild>
<AnswerReveal open={open} className={contentClassName}>{children}</AnswerReveal>
</CollapsibleContent>
</Collapsible>;
}
export function AnswerDisclosureChevron({ open }: { open: boolean }) {
const shouldAnimate = useAnswerMotion();
return <motion.span aria-hidden className="inline-flex shrink-0" initial={false}
animate={{ rotate: open ? 180 : 0 }} transition={{ duration: shouldAnimate() ? 0.18 : 0, ease: agentEase }}>
<ChevronDown size={14} />
</motion.span>;
}
@@ -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 <section data-answer-result data-result-status={status} role="group" aria-label={title}
className="min-w-0 rounded-lg border border-border bg-card px-3.5 py-3 text-card-foreground">
<div className="flex flex-wrap items-start gap-x-2 gap-y-1.5">
<Icon className={`mt-1 size-4 shrink-0 ${color}`} aria-hidden />
<h3 className="min-w-0 flex-1 text-sm font-medium leading-6">{title}</h3>
<Badge variant="outline" className="mt-0.5 shrink-0 px-1.5 py-0 text-[11px] font-normal shadow-none">{label}</Badge>
</div>
<p className="mt-2 whitespace-pre-wrap text-base leading-7">{summary}</p>
{source ? <p className="mt-2 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">依据:{source}</p> : null}
</section>;
}
@@ -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> | void;
}) {
const id = useId();
const [intent, setIntent] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
return <AnswerReveal open={selections.length > 0}><section aria-label="分析内容篮" className="answer-selection-tray rounded-xl border border-border bg-card p-3">
<div className="flex items-center justify-between gap-2">
<h4 className="flex items-center gap-2 text-xs font-medium"><Layers2 size={15} aria-hidden />已选 {selections.length} 项分析内容</h4>
<Button type="button" variant="ghost" size="sm" className="h-8 text-xs" onClick={onClear} disabled={running}>清空选择</Button>
</div>
<ul className="my-2 max-h-48 space-y-1.5 overflow-y-auto">
{selections.map((selection) => <li key={`${selection.title}\n${selection.text}`} className="flex items-start gap-2 rounded-md bg-muted/40 px-2.5 py-2">
<SelectionDetail selection={selection} />
<Button type="button" size="icon-sm" variant="ghost" className="shrink-0" aria-label={`移除${selection.title}`} disabled={running} onClick={() => onRemove(selection)}><X size={13} aria-hidden /></Button>
</li>)}
</ul>
<label htmlFor={id} className="text-xs font-medium">想继续分析什么?</label>
<Textarea id={id} className="mt-1.5 min-h-16 text-xs" value={intent} maxLength={800}
placeholder="例如:比较差异,并核查可能的原因" onChange={(event) => setIntent(event.currentTarget.value)} />
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] leading-5 text-muted-foreground">保留原始数值与来源,确认后再发送。</p>
<Button type="button" size="sm" disabled={disabled || running} onClick={async () => {
setRunning(true); setError(null);
try {
await onAsk({
title: `${selections.length} 项内容的联合分析`,
text: `${intent.trim() ? `分析目标:${intent.trim()}\n\n` : ""}${getSelectionText(selections)}`
});
} catch (cause) { setError(cause instanceof Error ? cause.message : "内容暂未带入对话,请重试。"); }
finally { setRunning(false); }
}}><ArrowUpRight size={14} aria-hidden />{running ? "正在处理…" : "带入对话"}</Button>
</div>
{error ? <p role="status" className="mt-2 text-xs text-destructive">{error}</p> : null}
</section></AnswerReveal>;
}
function SelectionDetail({ selection }: { selection: AnswerUiSelection }) {
const [open, setOpen] = useState(false);
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="min-w-0 flex-1 text-xs leading-5"
trigger={<Button type="button" variant="ghost" className="h-auto min-h-9 w-full justify-between gap-2 whitespace-normal px-0 py-1 text-left text-xs">
<span className="min-w-0 break-words">{selection.title}</span><AnswerDisclosureChevron open={open} />
</Button>}>
<p className="mt-1 whitespace-pre-wrap break-words text-muted-foreground">{selection.text}</p>
</AnswerDisclosure>;
}
@@ -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");
}
@@ -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();
});
});
@@ -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<string, ContentRole> = { 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;
}
@@ -10,6 +10,8 @@ export type AnswerUiInteractions = {
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void; onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void; onSubmit?: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => Promise<void> | void; onAsk?: (selection: AnswerUiSelection) => Promise<void> | void;
onToggleSelection?: (selection: AnswerUiSelection) => void;
selections?: AnswerUiSelection[];
evidenceFilter?: string; evidenceFilter?: string;
submitMode?: "send" | "draft"; submitMode?: "send" | "draft";
}; };
@@ -1,6 +1,9 @@
import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context"; import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context";
import { useContext, useState } from "react"; 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"; import { Button } from "@/shared/ui/button";
export function AskAboutContent({ title, text, compact = false }: AnswerUiSelection & { compact?: boolean }) { export function AskAboutContent({ title, text, compact = false }: AnswerUiSelection & { compact?: boolean }) {
@@ -22,3 +25,21 @@ export function AskAboutContent({ title, text, compact = false }: AnswerUiSelect
<MessageSquarePlus size={14} aria-hidden />{compact ? null : "针对这部分提问"} <MessageSquarePlus size={14} aria-hidden />{compact ? null : "针对这部分提问"}
</Button>{error ? <p role="status" className="text-xs text-destructive">{error}</p> : null}</div>; </Button>{error ? <p role="status" className="text-xs text-destructive">{error}</p> : null}</div>;
} }
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 <Button type="button" variant={selected ? "secondary" : "ghost"} size={compact ? "icon-sm" : "sm"}
className={compact ? "shrink-0 text-muted-foreground" : "h-9 gap-1.5 text-xs"}
disabled={disabled} aria-pressed={selected} aria-label={label ?? `将${title}加入分析`}
title={label ?? `将${title}加入分析`} onClick={() => onToggleSelection({ title, text })}>
<motion.span key={String(selected)} className="inline-flex" aria-hidden
initial={shouldAnimate() ? { opacity: 0, scale: 0.8, rotate: -30 } : false}
animate={{ opacity: 1, scale: 1, rotate: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
{selected ? <Check size={14} /> : <Plus size={14} />}
</motion.span>
{compact ? null : selected ? "已加入分析" : "加入分析"}
</Button>;
}
@@ -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: () => <div aria-label="测试图形" /> }));
function block(type: string, props: Record<string, unknown>, 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(<AnswerUiView block={current} onViewStateChange={changed} onSubmit={vi.fn()} />);
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(<AnswerUiView block={current} viewState={saved} onViewStateChange={changed} onSubmit={vi.fn()} />);
fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "尚未提交的草稿" } });
expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ nodes: { node: { type: "FollowUpForm", values: { record: "尚未提交的草稿" } } } }), ["/nodes/node/values/record"]);
rendered.unmount();
render(<AnswerUiView block={current} viewState={saved} onSubmit={vi.fn()} />);
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(<AnswerUiView block={current} viewState={state} />);
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(<AnswerUiView block={chart(["一", "三"])} />);
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(<AnswerUiView block={current} viewState={normalizeAnswerViewState(current.spec)} onViewStateChange={changed} />);
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(<AnswerUiView block={current} viewState={restored} onViewStateChange={changed} />);
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(<AnswerUiView block={form()} onSubmit={vi.fn()} />);
const input = screen.getByLabelText("现场记录");
fireEvent.change(input, { target: { value: "未发送" } });
rerender(<AnswerUiView block={form()} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录")).toBe(input);
expect(input).toHaveValue("未发送");
rerender(<AnswerUiView block={form("number")} onSubmit={vi.fn()} />);
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(<AnswerUiView block={evidence} viewState={saved} />);
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(<AnswerUiView block={original} viewState={state} />);
mounted.rerender(<AnswerUiView block={chart(["一", "二", "二"])} />);
expect(screen.getByRole("status")).toHaveTextContent("无法唯一对应,已清除选择");
expect(screen.queryByRole("region", { name: "数据点详情" })).not.toBeInTheDocument();
});
});
@@ -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 { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { AnswerUiView } from "./answer-ui-view"; import { AnswerUiView } from "./answer-ui-view";
@@ -15,7 +15,134 @@ function block(type: string, props: Record<string, unknown>) {
if (!parsed) throw new Error("invalid test fixture"); if (!parsed) throw new Error("invalid test fixture");
return parsed; 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", () => { 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(<AnswerUiView block={fixture} onSubmit={vi.fn()} />);
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(<AnswerUiView block={report} onSubmit={vi.fn()} />);
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(<AnswerUiView block={fixture("partial")} />);
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(<AnswerUiView block={fixture(status)} />);
expect(result).toHaveTextContent(label);
expect(result).toHaveAttribute("data-result-status", status);
}
});
it("uses the supplied note title without changing the warning meaning", () => {
render(<AnswerUiView block={block("Text", { text: "当前工具未提供高亮操作。", tone: "warning", title: "能力限制" })} />);
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(<AnswerUiView block={taskBlock()} onAsk={ask} onSubmit={submit} />);
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(<AnswerUiView block={taskBlock(false)} onAsk={ask} onSubmit={vi.fn()} />);
const editor = screen.getByLabelText("现场记录(必填)");
await user.type(editor, "未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
rerender(<AnswerUiView block={taskBlock()} onAsk={ask} onSubmit={vi.fn()} />);
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(<AnswerUiView block={updated} onAsk={ask} onSubmit={vi.fn()} />);
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(<AnswerUiView block={fixture} onAsk={vi.fn()} />);
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(<AnswerUiView block={block("Text", {
text: "压力记录尚未经过现场核验。", tone: "warning",
})} />);
expect(screen.getByRole("note")).toHaveTextContent("注意事项");
expect(screen.getByRole("note")).toHaveTextContent("压力记录尚未经过现场核验。");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
rerender(<AnswerUiView block={block("Text", { text: "仅展示已加载的记录。", tone: "info" })} />);
expect(screen.getByRole("note")).toHaveTextContent("补充说明");
rerender(<AnswerUiView block={block("Text", { text: "普通回答", tone: "neutral" })} />);
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 () => { it("offers a named metric quote action and retains the exact value, unit and source", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const ask = vi.fn(); const ask = vi.fn();
+126 -232
View File
@@ -1,48 +1,55 @@
import { AskAboutContent } from "./answer-ui-interactions"; import { LocateButton, FollowUpForm } from "./answer-action-components";
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react"; import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
import { useContext, useId, useState } from "react"; import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react";
import { import {
ArrowUpRight, Info,
ChevronDown, TriangleAlert,
ClipboardList, ClipboardList,
Database, Database,
FileCheck2, FileCheck2,
FlaskConical, FlaskConical,
Lightbulb, Lightbulb,
MapPin,
MessageSquare MessageSquare
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Card, CardContent } from "@/shared/ui/card"; import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
import { DateTimeField } from "@/shared/ui/date-time-field"; import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { InteractiveChart } from "./interactive-chart"; import { InteractiveChart } from "./interactive-chart";
import { InteractiveDataTable } from "./interactive-data-table"; 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"; export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
import { answerUiCatalog } from "./catalog"; import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract"; 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: {}, actions: {},
components: { components: {
Stack: ({ props, children }) => ( Stack: ({ props, children }) => (
<div <div
className={cn( className={cn(
"answer-ui-stack", "answer-ui-stack",
props.columns === "two" && "answer-ui-stack-comparison", (props.columns === "two" || props.intent === "comparison") && "answer-ui-stack-comparison",
props.gap === "compact" && "answer-ui-stack-compact" props.gap === "compact" && "answer-ui-stack-compact"
)} )}
data-answer-intent={props.intent}
data-answer-density={props.density}
> >
{children} {children}
</div> </div>
), ),
Section: ({ props, children }) => ( Section: ({ props, children }) => (
<section className="answer-ui-section"> <section className="answer-ui-section" data-answer-role={props.role}>
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"> <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<ClipboardList size={15} aria-hidden="true" /> <ClipboardList size={15} aria-hidden="true" />
{props.title} {props.title}
@@ -53,22 +60,21 @@ const { registry } = defineRegistry(answerUiCatalog, {
<div className="mt-3 space-y-3">{children}</div> <div className="mt-3 space-y-3">{children}</div>
</section> </section>
), ),
Text: ({ props }) => ( Text: ({ props }) => props.tone === "neutral" ? (
<p <div className="space-y-1">
className={cn( {props.title ? <h4 className="text-sm font-medium leading-6 text-foreground">{props.title}</h4> : null}
"whitespace-pre-wrap text-sm leading-6", <p className="whitespace-pre-wrap text-sm leading-6 text-foreground">{props.text}</p>
props.tone === "warning" </div>
? "text-[var(--status-warning-foreground)]" ) : (
: props.tone === "info" <Alert role="note" className="border-border bg-card px-3.5 py-3 text-card-foreground has-[>svg]:gap-x-2.5">
? "text-[var(--action-blue-hover)]" {props.tone === "warning" ? <TriangleAlert className="text-[var(--status-warning-foreground)]" aria-hidden /> : <Info className="text-muted-foreground" aria-hidden />}
: "text-[var(--ink)]" <AlertTitle className="text-[13px]">{props.title ?? (props.tone === "warning" ? "注意事项" : "补充说明")}</AlertTitle>
)} <AlertDescription className="whitespace-pre-wrap text-muted-foreground">{props.text}</AlertDescription>
> </Alert>
{props.text}
</p>
), ),
Result: ({ props }) => <AnswerResult {...props} />,
Metric: ({ props }) => ( Metric: ({ props }) => (
<Card className="answer-ui-metric"> <Card className="answer-ui-metric" role="group" aria-label={props.label}>
<CardContent className="answer-ui-metric-content p-3"> <CardContent className="answer-ui-metric-content p-3">
<p className="answer-ui-metric-label text-xs leading-5 text-muted-foreground">{props.label}</p> <p className="answer-ui-metric-label text-xs leading-5 text-muted-foreground">{props.label}</p>
<p className="answer-ui-metric-value break-words text-2xl font-semibold tabular-nums text-foreground"> <p className="answer-ui-metric-value break-words text-2xl font-semibold tabular-nums text-foreground">
@@ -80,21 +86,35 @@ const { registry } = defineRegistry(answerUiCatalog, {
{props.source ? ( {props.source ? (
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p> <p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
) : null} ) : null}
<div className="answer-ui-metric-actions flex items-center">
<SelectForAnalysis compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} /> <AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
</div>
</CardContent> </CardContent>
</Card> </Card>
), ),
Table: ({ props }) => ( Table: ({ props }) => (
<InteractiveDataTable key={JSON.stringify([props.columns, props.rows])} title={props.title} columns={props.columns} rows={props.rows} /> <section data-answer-table aria-label={props.title} className="min-w-0">
<InteractiveDataTable title={props.title} columns={props.columns} rows={props.rows} />
</section>
), ),
Chart: ({ props }) => <InteractiveChart key={JSON.stringify(props)} {...props} />, Chart: ({ props }) => <InteractiveChart {...props} />,
Evidence: ({ props }) => <EvidenceCard {...props} />, Evidence: ({ props }) => <EvidenceCard {...props} />,
MapLocation: ({ props }) => <LocateButton {...props} />, MapLocation: ({ props }) => <LocateButton {...props} />,
FollowUpForm: ({ props }) => <FollowUpForm {...props} /> FollowUpForm: ({ props }) => <FollowUpForm {...props} />
} }
}); });
export function AnswerUiView({ const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(props.block.spec) }} />
</AnswerViewStateProvider>;
}
function AnswerUiBody({
block, block,
disabled = false, disabled = false,
onLocate, onLocate,
@@ -102,25 +122,80 @@ export function AnswerUiView({
onAsk, onAsk,
submitMode = "send" submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) { }: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [filter, setFilter] = useState("all"); const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
const [selections, setSelections] = useState<AnswerUiSelection[]>([]);
const [selectionError, setSelectionError] = useState<string | null>(null);
const contentRef = useRef<HTMLDivElement>(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 kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
return ( const evidenceFilters = kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, {["all", ...kinds].map((kind) => <Button key={kind} variant={filter === kind ? "secondary" : "outline"} size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}>
onAsk: onAsk ? (selection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined }}>
<div className="answer-ui-root" data-answer-ui={block.block_id}>
{kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
{["all", ...kinds].map((kind) => <Button key={kind} variant="outline" size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}>
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label} {kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label}
</Button>)} </Button>)}
</div> : null;
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter,
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 }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} />
<div ref={contentRef}>
<>
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
{taskLayout.analysis.length ? <section className="answer-task-analysis grid min-w-0 gap-3" aria-label="分析内容">
{hasTaskLayout ? <div className="answer-task-heading"><span>分析内容</span><span>直接探索已加载数据</span></div> : null}
{renderNodes(taskLayout.analysis)}
</section> : null}
{taskLayout.evidence.length || taskLayout.followUp.length ? <div className="answer-task-support grid min-w-0 gap-4">
{taskLayout.evidence.length ? <TaskSupport enhanced={hasTaskLayout} title="核查依据" description="区分记录、推断与用户资料" icon={<Database size={15} aria-hidden />}>
{evidenceFilters}{renderNodes(taskLayout.evidence)}
</TaskSupport> : null}
{taskLayout.followUp.length ? <TaskSupport enhanced={hasTaskLayout} title="补充资料" description="由你填写,主动提交" icon={<FileCheck2 size={15} aria-hidden />}>
{renderNodes(taskLayout.followUp)}
</TaskSupport> : null}
</div> : null} </div> : null}
<JSONUIProvider registry={registry}> </div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
<Renderer spec={block.spec} registry={registry} /> </>
</JSONUIProvider>
</div> </div>
{selectionError ? <p role="status" className="mt-3 text-xs leading-5 text-destructive">{selectionError}</p> : null}
{ask ? <AnswerSelectionTray selections={selections} disabled={disabled}
onRemove={(selection) => { setSelectionError(null); setSelections((current) => current.filter((item) => item !== selection)); }}
onClear={() => { setSelections([]); setSelectionError(null); }} onAsk={ask} /> : null}
</AnswerMotionScope>
</InteractionContext.Provider> </InteractionContext.Provider>
); );
} }
function TaskSupport({ title, description, icon, children, enhanced }: { title: string; description: string; icon: ReactNode; children: ReactNode; enhanced: boolean }) {
const [open, setOpen] = useState(true);
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-task-support-section"
contentClassName="answer-task-support-content space-y-2.5 pt-2"
trigger={<Button type="button" variant="ghost" className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !enhanced && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">{description}</span></span>
<AnswerDisclosureChevron open={open} />
</Button>}>{children}</AnswerDisclosure>;
}
const evidenceKinds = { const evidenceKinds = {
observed: { label: "查询记录", icon: Database }, observed: { label: "查询记录", icon: Database },
human_input: { label: "用户提供", icon: MessageSquare }, human_input: { label: "用户提供", icon: MessageSquare },
@@ -136,14 +211,15 @@ function EvidenceCard(props: {
observed_at: string | null; observed_at: string | null;
evidence_id: string | null; evidence_id: string | null;
}) { }) {
const [open, setOpen] = useState(false); const [{ open }, patch] = useAnswerNodeState<AnswerEvidenceState>("Evidence", props);
const setOpen = (open: boolean) => patch({ open });
const meta = evidenceKinds[props.kind]; const meta = evidenceKinds[props.kind];
const Icon = meta.icon; const Icon = meta.icon;
const { evidenceFilter } = useContext(InteractionContext); const { evidenceFilter } = useContext(InteractionContext);
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null; if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
return ( return (
<Collapsible open={open} onOpenChange={setOpen} className="answer-ui-evidence"> <AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-ui-evidence" role="group" aria-label={props.title}
<CollapsibleTrigger asChild> contentClassName="px-3 pb-3 text-xs leading-6" trigger={
<Button <Button
variant="ghost" variant="ghost"
type="button" type="button"
@@ -161,14 +237,8 @@ function EvidenceCard(props: {
<Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal"> <Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal">
{meta.label} {meta.label}
</Badge> </Badge>
<ChevronDown <AnswerDisclosureChevron open={open} />
size={14} </Button>}>
className={cn("shrink-0", open && "rotate-180")}
aria-hidden="true"
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 text-xs leading-6">
<p className="whitespace-pre-wrap border-t border-border pt-3">{props.summary}</p> <p className="whitespace-pre-wrap border-t border-border pt-3">{props.summary}</p>
{props.observed_at || props.evidence_id ? ( {props.observed_at || props.evidence_id ? (
<dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground"> <dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground">
@@ -186,186 +256,10 @@ function EvidenceCard(props: {
) : null} ) : null}
</dl> </dl>
) : null} ) : null}
<div className="mt-2 flex flex-wrap items-center gap-1">
<SelectForAnalysis title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
<AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} /> <AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
</CollapsibleContent>
</Collapsible>
);
}
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 className="answer-ui-location">
<MapPin size={17} className="mt-1 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
setRunning(true);
setFeedback(null);
try {
const result = await onLocate?.(target);
if (result === false) throw new Error("关联要素暂不可定位,请检查地图提示。");
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}
<ArrowUpRight size={14} aria-hidden="true" />
</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>
</div> </div>
); </AnswerDisclosure>
}
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<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(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
setSubmitting(false);
}
}}
>
<div className="flex items-start gap-2.5">
<span className="answer-ui-evidence-icon">
<FileCheck2 size={16} aria-hidden="true" />
</span>
<div className="min-w-0">
<h4 className="text-sm font-semibold">{title}</h4>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
</div>
</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
className="h-10"
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 ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback ? (
<p role="status" className="text-xs leading-5">
{feedback}
</p>
) : null}
</form>
); );
} }
@@ -0,0 +1,92 @@
/* eslint-disable react-refresh/only-export-components -- Local rendering adapter and its state hooks share private contexts. */
import { createContext, useContext, useLayoutEffect, useState, type ReactNode } from "react";
import { createStateStore, JSONUIProvider, useStateStore, type ComponentRegistry } from "@json-render/react";
import type { Spec } from "@json-render/core";
import {
answerStatePointer, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerNodeState, normalizeAnswerViewState,
type AnswerUiNodeState, type AnswerViewState, type AnswerViewStateChange
} from "./answer-view-state";
const NodeContext = createContext<string | undefined>(undefined);
const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined);
/** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */
export function withAnswerNodeIds(spec: Spec): Spec {
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) };
}
export function withAnswerNodeIdentity(registry: ComponentRegistry): ComponentRegistry {
return Object.fromEntries(Object.entries(registry).map(([type, Component]) => [type, function AnswerNodeRenderer(props) {
const id = (props.element as typeof props.element & { __answerNodeId?: string }).__answerNodeId;
return <NodeContext.Provider value={id}><Component {...props} /></NodeContext.Provider>;
}]));
}
export function AnswerViewStateProvider({ spec, registry, viewState, onViewStateChange, children }: {
spec: Spec; registry: ComponentRegistry; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange; children: ReactNode;
}) {
const [store] = useState(() => createStateStore(normalizeAnswerViewState(spec, viewState)));
// Capture the full prior schema, including in-place updates from callers.
const [previous, setPrevious] = useState(() => JSON.stringify(spec));
const serialized = JSON.stringify(spec);
useLayoutEffect(() => {
const current = store.getSnapshot() as AnswerViewState;
const source = viewState ?? current;
const next = previous === serialized ? normalizeAnswerViewState(spec, source) : migrateAnswerViewState(JSON.parse(previous) as Spec, spec, source);
const dirty = answerViewStateDirtyPaths(source, next);
const previousSpec = JSON.parse(previous) as Spec;
const resetCharts = Object.entries(next.nodes).filter(([id, node]) => {
const old = source.nodes[id];
if (node.type !== "Chart" || old?.type !== "Chart" || previous === serialized) return false;
const before = previousSpec.elements[id], after = spec.elements[id];
if (before?.type !== "Chart" || JSON.stringify([before.props.x_data, before.props.series]) === JSON.stringify([after.props.x_data, after.props.series])) return false;
const previousLabels = before.props.x_data as string[], labels = after.props.x_data as string[];
const relocate = (position: number) => {
const label = previousLabels[position];
return label !== undefined && previousLabels.filter((value) => value === label).length === 1 && labels.filter((value) => value === label).length === 1 ? labels.indexOf(label) : -1;
};
return relocate(old.point) < 0 || relocate(old.range[0]) < 0 || relocate(old.range[1]) < relocate(old.range[0]);
}).map(([id]) => id);
store.update({ "/nodes": next.nodes, "/block": next.block });
if (previous !== serialized) {
store.set("/notices", Object.fromEntries(resetCharts.map((id) => [id, "数据标签已变化,原选点或区间无法唯一对应,已清除选择。仍可查看当前数据。"])));
setPrevious(serialized);
}
if (dirty.length) onViewStateChange?.(next, dirty);
}, [store, previous, serialized, spec, viewState, onViewStateChange]);
const change: AnswerViewStateChange = (next, dirty) => {
store.update({ "/nodes": next.nodes, "/block": next.block });
onViewStateChange?.(next, dirty);
};
return <ChangeContext.Provider value={change}>
<JSONUIProvider registry={registry} store={store}>{children}</JSONUIProvider>
</ChangeContext.Provider>;
}
export function useAnswerNodeState<T extends AnswerUiNodeState>(type: T["type"], props: Record<string, unknown>) {
const id = useContext(NodeContext);
const store = useStateStore();
const onChange = useContext(ChangeContext);
// Derived controls (such as a chart's data table) have no schema node of their own.
const [local, setLocal] = useState(() => normalizeAnswerNodeState({ type, props, children: [] }) as T);
const owned = id && (store.get(`/nodes/${answerStatePointer(id)}`) as AnswerUiNodeState | undefined)?.type === type;
const state = (owned ? normalizeAnswerNodeState({ type, props, children: [] }, store.get(`/nodes/${answerStatePointer(id!)}`)) : local) as T;
const patch = (updates: Partial<Omit<T, "type">>, fields?: string[]) => {
const live = owned ? store.get(`/nodes/${answerStatePointer(id!)}`) as T : state;
const next = { ...live, ...updates } as T;
if (!owned || !onChange) { setLocal(next); return; }
const snapshot = store.getSnapshot() as AnswerViewState;
onChange({ nodes: { ...snapshot.nodes, [id!]: next }, block: snapshot.block },
(fields ?? Object.keys(updates)).map((field) => `/nodes/${answerStatePointer(id!)}/${field}`));
};
return [state, patch, id ? store.get(`/notices/${answerStatePointer(id)}`) as string | undefined : undefined] as const;
}
export function useAnswerBlockState() {
const store = useStateStore(), onChange = useContext(ChangeContext);
const block = store.get("/block") as AnswerViewState["block"];
return [block, (evidenceFilter: string) => {
const snapshot = store.getSnapshot() as AnswerViewState;
onChange?.({ nodes: snapshot.nodes, block: { evidenceFilter } }, ["/block/evidenceFilter"]);
}] as const;
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
function spec(type: string, props: Record<string, unknown>): Spec {
return { root: "node", elements: { node: { type, props, children: [] } } };
}
const chart = (labels = ["a", "b", "c"], names = ["入口", "出口"]) => spec("Chart", { chart_type: "line", x_data: labels, series: names.map((name) => ({ name, data: labels.map((_, index) => index) })) });
const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"]) => spec("Table", { rows, columns });
const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields });
describe("answer view state compatibility", () => {
it("clears row indices on reordered data, retains search, and clears sort when columns change", () => {
const before = table();
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } });
expect(migrateAnswerViewState(before, table([["b", "2"], ["a", "1"]]), state).nodes.node).toEqual({ type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [] });
expect(migrateAnswerViewState(before, table(undefined, ["读数", "对象"]), state).nodes.node).toMatchObject({ query: "a", sort: null, selection: [] });
expect(migrateAnswerViewState(before, structuredClone(before), state)).toEqual(state);
});
it("relocates chart ranges and points only by unique labels and resets hidden series after renaming", () => {
const before = chart();
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Chart", view: "bar", hidden: [1], range: [1, 2], point: 2, inspecting: true, table: { query: "b", sort: { column: 1, descending: false }, selection: [0] } } } });
expect(migrateAnswerViewState(before, chart(["z", "a", "b", "c"]), state).nodes.node).toMatchObject({ view: "bar", hidden: [1], range: [2, 3], point: 3, inspecting: true, table: { query: "b", selection: [] } });
expect(migrateAnswerViewState(before, chart(undefined, ["出口", "入口"]), state).nodes.node).toMatchObject({ view: "bar", hidden: [], table: { query: "b", sort: null, selection: [] } });
for (const labels of [["a", "b", "b"], ["a", "b"]]) expect(migrateAnswerViewState(before, chart(labels), state).nodes.node).toMatchObject({ range: [0, labels.length - 1], point: 0, inspecting: false });
});
it("preserves same-name same-kind drafts across equivalent streaming schemas and drops replaced fields", () => {
const before = form([{ name: "record", kind: "text" }, { name: "deleted", kind: "text" }]);
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "FollowUpForm", values: { record: "尚未提交", deleted: "旧值" } } } });
expect(migrateAnswerViewState(before, structuredClone(before), state)).toEqual(state);
expect(migrateAnswerViewState(before, form([{ name: "record", kind: "number" }, { name: "added", kind: "text" }]), state).nodes.node).toEqual({ type: "FollowUpForm", values: {} });
expect(migrateAnswerViewState(before, form(), state).nodes.node).toEqual({ type: "FollowUpForm", values: { record: "尚未提交" } });
});
it("only accepts supported state, bounds indices, preserves draft text, and resets missing evidence filters", () => {
const current = table();
const result = normalizeAnswerViewState(current, { nodes: { node: { type: "Table", query: "查".repeat(2000), sort: { column: 99, descending: true }, selection: [-1, 0, 0, 2] }, unknown: { type: "Evidence", open: true } }, block: { evidenceFilter: "gone" }, action: "ignored" });
expect(result).toEqual({ nodes: { node: { type: "Table", query: "查".repeat(2000), sort: null, selection: [0] } }, block: { evidenceFilter: "all" } });
});
it("tracks form and nested chart-table edits at field granularity", () => {
const current = form([{ name: "record", kind: "text" }, { name: "other", kind: "text" }]);
const before = normalizeAnswerViewState(current);
const after = normalizeAnswerViewState(current, { nodes: { node: { type: "FollowUpForm", values: { record: "草稿" } } } });
expect(answerViewStateDirtyPaths(before, after)).toEqual(["/nodes/node/values/record"]);
const chartState = normalizeAnswerViewState(chart());
const updated = structuredClone(chartState);
if (updated.nodes.node.type === "Chart") updated.nodes.node.table!.query = "a";
expect(answerViewStateDirtyPaths(chartState, updated)).toEqual(["/nodes/node/table/query"]);
});
});
@@ -0,0 +1,129 @@
import type { Spec } from "@json-render/core";
export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] };
export type AnswerChartState = { type: "Chart"; view: "line" | "bar" | "table"; hidden: number[]; range: [number, number]; point: number; inspecting: boolean; table?: Omit<AnswerTableState, "type"> };
export type AnswerFormState = { type: "FollowUpForm"; values: Record<string, string> };
export type AnswerEvidenceState = { type: "Evidence"; open: boolean };
export type AnswerUiNodeState = AnswerChartState | AnswerTableState | AnswerFormState | AnswerEvidenceState;
export type AnswerViewState = { nodes: Record<string, AnswerUiNodeState>; block: { evidenceFilter: string } };
export type AnswerViewStateChange = (next: AnswerViewState, dirtyPaths: string[]) => void;
export type AnswerViewStateRecord = AnswerViewState & {
version: "answer-view-state@1"; message_id: string; block_id: string; revision: number; schema_digest: string; updated_at: string | null;
};
type Node = Spec["elements"][string];
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const indices = (value: unknown, length: number) => Array.isArray(value) ? [...new Set(value.filter((index): index is number => Number.isInteger(index) && index >= 0 && index < length))].slice(0, 100) : [];
const index = (value: unknown, length: number, fallback = 0) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value < length ? value : fallback;
export const answerStatePointer = (value: string) => value.replace(/~/g, "~0").replace(/\//g, "~1");
/** Only the local component allowlist can contribute state. Model props stay unchanged. */
export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiNodeState | undefined {
const value = record(input);
const state = value.type === node.type ? value : {};
if (node.type === "Chart") {
const labels = node.props.x_data as string[];
const series = node.props.series as unknown[];
const proposed = Array.isArray(state.range) ? state.range : [];
const start = index(proposed[0], labels.length);
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
const hidden = indices(state.hidden, series.length);
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
const data = answerChartTableData(node, chart);
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
const { type: _type, ...tableState } = table;
chart.table = tableState;
return chart;
}
if (node.type === "Table") {
const columns = node.props.columns as unknown[];
const rows = node.props.rows as unknown[];
const sort = record(state.sort);
return { type: "Table", query: typeof state.query === "string" ? state.query : "",
sort: Number.isInteger(sort.column) && Number(sort.column) >= 0 && Number(sort.column) < columns.length && typeof sort.descending === "boolean" ? { column: Number(sort.column), descending: sort.descending } : null,
selection: indices(state.selection, rows.length) };
}
if (node.type === "FollowUpForm") {
const values = record(state.values);
return { type: "FollowUpForm", values: Object.fromEntries((node.props.fields as Array<{ name: string }>).filter(({ name }) => typeof values[name] === "string").map(({ name }) => [name, String(values[name])])) };
}
if (node.type === "Evidence") return { type: "Evidence", open: state.open === true };
}
export function answerChartTableData(node: Node, state: AnswerChartState) {
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: number[] }>;
const visible = series.filter((_, index) => !state.hidden.includes(index));
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data }) => String(data[state.range[0] + index]))]) };
}
export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState {
const state = record(input), nodes = record(state.nodes), block = record(state.block);
const kinds = Object.values(spec.elements).filter((node) => node.type === "Evidence").map((node) => node.props.kind);
return { nodes: Object.fromEntries(Object.entries(spec.elements).flatMap(([id, node]) => {
const value = normalizeAnswerNodeState(node, nodes[id]);
return value ? [[id, value]] : [];
})), block: { evidenceFilter: typeof block.evidenceFilter === "string" && kinds.includes(block.evidenceFilter) ? block.evidenceFilter : "all" } };
}
/** Migrate by explicit data identity, never by assuming old indices identify new data. */
export function migrateAnswerViewState(previous: Spec, next: Spec, input: AnswerViewState): AnswerViewState {
const state = normalizeAnswerViewState(next, input);
for (const [id, value] of Object.entries(state.nodes)) {
const before = previous.elements[id], after = next.elements[id], old = input.nodes[id];
if (!before || before.type !== after.type || !old || old.type !== value.type) continue;
if (value.type === "Table") {
if (!same(before.props.rows, after.props.rows)) value.selection = [];
if (!same(before.props.columns, after.props.columns)) { value.sort = null; value.selection = []; }
}
if (value.type === "FollowUpForm") {
const fields = before.props.fields as Array<{ name: string; kind: string }>;
const nextFields = after.props.fields as Array<{ name: string; kind: string }>;
value.values = Object.fromEntries(Object.entries(value.values).filter(([name]) => fields.some((field) => field.name === name && nextFields.some((nextField) => nextField.name === name && nextField.kind === field.kind))));
}
if (value.type === "Chart" && old.type === "Chart") {
const names = (node: Node) => (node.props.series as Array<{ name: string }>).map(({ name }) => name);
if (!same(names(before), names(after))) value.hidden = [];
const previousLabels = before.props.x_data as string[], labels = after.props.x_data as string[];
if (!same(previousLabels, labels) || !same(before.props.series, after.props.series)) {
const relocate = (position: number) => {
const label = previousLabels[position];
return label !== undefined && previousLabels.filter((item) => item === label).length === 1 && labels.filter((item) => item === label).length === 1 ? labels.indexOf(label) : -1;
};
const point = relocate(old.point), start = relocate(old.range[0]), end = relocate(old.range[1]);
if (point < 0 || start < 0 || end < start) {
value.point = 0; value.range = [0, labels.length - 1]; value.inspecting = false;
} else { value.point = point; value.range = [start, end]; }
}
if (value.table) {
const oldData = answerChartTableData(before, old), newData = answerChartTableData(after, value);
if (!same(oldData.rows, newData.rows)) value.table.selection = [];
if (!same(oldData.columns, newData.columns)) value.table.sort = null;
}
}
}
return state;
}
export function answerViewStateDirtyPaths(before: AnswerViewState, after: AnswerViewState): string[] {
const paths: string[] = [];
for (const [id, value] of Object.entries(after.nodes)) {
const old = before.nodes[id];
if (old && old.type !== value.type) { paths.push(`/nodes/${answerStatePointer(id)}`); continue; }
for (const [field, next] of Object.entries(value)) {
if (field === "type") continue;
const prefix = `/nodes/${answerStatePointer(id)}/${field}`;
if (field === "values" && value.type === "FollowUpForm") {
const oldValues = old?.type === "FollowUpForm" ? old.values : {};
for (const name of new Set([...Object.keys(oldValues), ...Object.keys(value.values)])) if (oldValues[name] !== value.values[name]) paths.push(`${prefix}/${answerStatePointer(name)}`);
} else if (field === "table" && value.type === "Chart" && value.table) {
const oldTable = old?.type === "Chart" ? old.table : undefined;
for (const [key, item] of Object.entries(value.table)) if (!same(oldTable?.[key as keyof typeof oldTable], item)) paths.push(`${prefix}/${key}`);
} else if (!same(old && (old as unknown as Record<string, unknown>)[field], next)) paths.push(prefix);
}
}
if (before.block.evidenceFilter !== after.block.evidenceFilter) paths.push("/block/evidenceFilter");
for (const id of Object.keys(before.nodes)) if (!Object.hasOwn(after.nodes, id)) paths.push(`/nodes/${answerStatePointer(id)}`);
return paths;
}
+21 -5
View File
@@ -13,20 +13,36 @@ export const answerUiCatalog = defineCatalog(schema, {
components: { components: {
Stack: { Stack: {
props: z props: z
.object({ columns: z.enum(["one", "two"]), gap: z.enum(["compact", "normal"]) }) .object({
columns: z.enum(["one", "two"]), gap: z.enum(["compact", "normal"]),
intent: z.enum(["response", "comparison", "report"]).optional(),
density: z.enum(["compact", "comfortable"]).optional()
})
.strict(), .strict(),
slots: ["default"], slots: ["default"],
description: "组织内容;双列在窄面板自动改为单列。" description: "组织内容。intent 表达简短答复、并列比较或报告;density 表达疏密,不控制字体和颜色。comparison 与双列在窄面板自动改单列。"
}, },
Section: { Section: {
props: z.object({ title: shortText, description: text.nullable() }).strict(), props: z.object({
title: shortText, description: text.nullable(),
role: z.enum(["result", "analysis", "evidence", "follow_up"]).optional()
}).strict(),
slots: ["default"], slots: ["default"],
description: "带标题的资料分区,保留真实内容层级。" description: "带标题的资料分区。role 标记结果、分析、依据或补充资料;前端据此组织任务内容,保留分区内部的原有顺序。"
}, },
Text: { Text: {
props: z.object({ text, tone: z.enum(["neutral", "info", "warning"]) }).strict(), props: z.object({ text, tone: z.enum(["neutral", "info", "warning"]), title: shortText.optional() }).strict(),
description: "简短说明、判断边界或资料缺口。" description: "简短说明、判断边界或资料缺口。"
}, },
Result: {
props: z.object({
title: shortText,
summary: text,
status: z.enum(["success", "partial", "unconfirmed", "error"]),
source: optionalText
}).strict(),
description: "概括已有工具结果:成功、部分完成、未确认或失败。source 标注真实依据;无依据用 unconfirmed。此组件不执行操作,不证明浏览器展示、业务写入或现场处置完成。"
},
Metric: { Metric: {
props: z props: z
.object({ label: shortText, value: shortText, unit: optionalText, source: optionalText }) .object({ label: shortText, value: shortText, unit: optionalText, source: optionalText })
@@ -1,13 +1,15 @@
import { useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import { BarChart3, ChevronDown, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react"; import { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
import { InteractiveDataTable } from "./interactive-data-table"; import { InteractiveDataTable } from "./interactive-data-table";
import { AskAboutContent } from "./answer-ui-interactions"; import { agentEase } from "../components/agent-motion";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
export type AnswerChartProps = { export type AnswerChartProps = {
title: string; title: string;
@@ -16,6 +18,9 @@ export type AnswerChartProps = {
series: Array<{ name: string; data: number[] }>; series: Array<{ name: string; data: number[] }>;
source: string; source: string;
}; };
import { useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerChartState } from "./answer-view-state";
const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"]; const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"];
function PointSelect({ label, value, labels, onChange }: { function PointSelect({ label, value, labels, onChange }: {
label: string; value: number; labels: string[]; onChange: (value: number) => void; label: string; value: number; labels: string[]; onChange: (value: number) => void;
@@ -26,16 +31,47 @@ function PointSelect({ label, value, labels, onChange }: {
</Select>; </Select>;
} }
export function InteractiveChart(props: AnswerChartProps) { export function InteractiveChart(props: AnswerChartProps) {
const [view, setView] = useState<string>(props.chart_type); const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
const [hidden, setHidden] = useState<number[]>([]); const reducedMotion = useReducedMotion();
const [range, setRange] = useState<[number, number]>([0, props.x_data.length - 1]); const pointer = useRef(false);
const [point, setPoint] = useState(0); const plot = useRef<HTMLDivElement>(null);
const [animateInteraction, setAnimateInteraction] = useState(false);
const contentMotion = useAnimationControls();
const pendingTransition = useRef(false);
useLayoutEffect(() => {
// Start after the new ECharts view has mounted, so rendering cannot consume
// the transition before the browser gets a chance to paint it.
if (pendingTransition.current) {
pendingTransition.current = false;
void contentMotion.start({ opacity: 1, y: 0 }, { duration: 0.18, ease: agentEase });
} else contentMotion.set({ opacity: 1, y: 0 });
}, [contentMotion, view]);
const setView = (next: string) => {
if (next === view) return;
const animate = pointer.current && !reducedMotion;
setAnimateInteraction(animate);
contentMotion.stop();
contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 });
pendingTransition.current = animate;
patch({ view: next as AnswerChartState["view"] });
};
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range", "table/selection"]);
const setPoint = (point: number) => patch({ point });
const setInspecting = (inspecting: boolean) => patch({ inspecting });
const visible = props.series.filter((_, index) => !hidden.includes(index)); const visible = props.series.filter((_, index) => !hidden.includes(index));
const labels = props.x_data.slice(range[0], range[1] + 1); const labels = props.x_data.slice(range[0], range[1] + 1);
const pointIndex = Math.min(range[1], Math.max(range[0], point)); const pointIndex = Math.min(range[1], Math.max(range[0], point));
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1; const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
const summaries = visible.map((series) => {
const values = series.data.slice(range[0], range[1] + 1);
return { name: series.name, first: values[0], last: values.at(-1)!, min: Math.min(...values), max: Math.max(...values) };
});
const rangeTitle = `${props.title} · ${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`;
const rangeText = `${labels.length} 个已加载数据点的本地统计\n${summaries.map((item) => `${item.name}:起点 ${item.first};终点 ${item.last};最小 ${item.min};最大 ${item.max}`).join("\n")}\n来源:${props.source}`;
const option = (type: "line" | "bar") => ({ const option = (type: "line" | "bar") => ({
animation: false, animation: animateInteraction && !reducedMotion,
animationDuration: 180,
animationDurationUpdate: 180,
color: colors.filter((_, index) => !hidden.includes(index)), color: colors.filter((_, index) => !hidden.includes(index)),
aria: { enabled: true }, aria: { enabled: true },
tooltip: { trigger: "axis", renderMode: "richText" }, tooltip: { trigger: "axis", renderMode: "richText" },
@@ -45,14 +81,31 @@ export function InteractiveChart(props: AnswerChartProps) {
series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1), series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1),
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } })) showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } }))
}); });
return <Card className="answer-ui-chart overflow-hidden"> return <Collapsible asChild><Card className="answer-ui-chart overflow-hidden" role="group" aria-label={props.title}
<CardHeader className="space-y-1 px-3.5 pb-3 pt-3.5"> onPointerDownCapture={() => { pointer.current = true; }}
<CardTitle className="flex items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} />{props.title}</CardTitle> onKeyDownCapture={() => {
<CardDescription className="text-[11px]">{labels.length} / {props.x_data.length} 个数据点 · {visible.length} 组序列</CardDescription> pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.title}</CardTitle>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
className="shrink-0 text-muted-foreground">
<SlidersHorizontal size={14} aria-hidden />
</Button>
</CollapsibleTrigger>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 px-3.5 pb-3.5 pt-0"> <CardContent className="space-y-3 px-3.5 pb-3.5 pt-0">
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView}> <Tabs value={view} onValueChange={setView}>
<TabsList aria-label={`${props.title}显示方式`} className="mb-3 grid w-full grid-cols-3"> <TabsList aria-label={`${props.title}显示方式`} className="relative mb-3 grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none">
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger> <TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger> <TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger> <TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
@@ -61,24 +114,13 @@ export function InteractiveChart(props: AnswerChartProps) {
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline" {props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)} className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)} disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => setHidden((current) => current.includes(index) ? current.filter((item) => item !== index) : [...current, index])}> onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort", "table/selection"]); }}>
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name} <span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
</Button>)} </Button>)}
</div> </div>
<Collapsible className="mb-2"> {!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1"> <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label="筛选数据范围"
className="group h-8 gap-1.5 px-1.5 font-normal text-muted-foreground">
<SlidersHorizontal size={13} aria-hidden />数据范围
<ChevronDown size={13} aria-hidden className="group-data-[state=open]:rotate-180" />
</Button>
</CollapsibleTrigger>
<span className="text-[11px] text-muted-foreground">
{fullRange ? "全部数据" : `${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}
</span>
</div>
<CollapsibleContent className="space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data} <PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} /> onChange={(start) => setRange([start, Math.max(start, range[1])])} />
@@ -89,24 +131,58 @@ export function InteractiveChart(props: AnswerChartProps) {
<Button type="button" variant="ghost" size="sm" disabled={fullRange} <Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button> onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> <motion.div data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}> {(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}>
<div role="img" aria-label={props.title}> <div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className="rounded-md focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1]
: Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0)));
patch({ point: next, inspecting: true });
}
if (event.key === "Escape") setInspecting(false);
}}>
<span className="sr-only">点击图中数据点查看详情并追问,也可使用回车和左右方向键。</span>
<ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }} <ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => { onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number") if (event.componentType === "series" && typeof event.dataIndex === "number") {
setPoint(range[0] + event.dataIndex); patch({ point: range[0] + event.dataIndex, inspecting: true });
}
} }} /> } }} />
</div> </div>
</TabsContent>)} </TabsContent>)}
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}> <TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable key={JSON.stringify([hidden, range])} title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]} <InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])} rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))}
source={props.source} /> source={props.source} />
</TabsContent> </TabsContent>
</motion.div>
</Tabs> </Tabs>
<div className="rounded-lg bg-muted/40 p-2.5"> {view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="flex items-center gap-2"><span className="text-xs text-muted-foreground">查看数据点</span> <div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
<SelectForAnalysis compact label="将当前区间加入分析" title={rangeTitle} text={rangeText} />
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{item.first} → {item.last}</span>
<span className="text-muted-foreground">最小 {item.min} · 最大 {item.max}</span>
</div>)}
</div>
</div> : null}
{view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false}
animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-medium">数据点详情</span>
<Button type="button" variant="ghost" size="icon-sm" aria-label="关闭数据点详情" onClick={() => { setInspecting(false); plot.current?.focus(); }}><X size={14} aria-hidden /></Button>
</div>
<div className="flex items-center gap-2"><span className="text-xs text-muted-foreground">时间或类别</span>
<PointSelect label="选择数据点" value={pointIndex - range[0]} labels={labels} onChange={(index) => setPoint(range[0] + index)} /> <PointSelect label="选择数据点" value={pointIndex - range[0]} labels={labels} onChange={(index) => setPoint(range[0] + index)} />
</div> </div>
<dl className="my-2 space-y-1.5 text-xs"> <dl className="my-2 space-y-1.5 text-xs">
@@ -115,12 +191,16 @@ export function InteractiveChart(props: AnswerChartProps) {
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd> <dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
</div>)} </div>)}
</dl> </dl>
<div className="flex flex-wrap items-center gap-1">
<SelectForAnalysis label="将当前数据点加入分析" title={`${props.title} · ${props.x_data[pointIndex]}`}
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
<AskAboutContent title={`${props.title} · ${props.x_data[pointIndex]}`} <AskAboutContent title={`${props.title} · ${props.x_data[pointIndex]}`}
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} /> text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
</div> </div>
</motion.section> : null}
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground"> <p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
<Database size={12} className="mt-1 shrink-0" /><span>来源:{props.source}。区间操作仅查看已加载数据。</span> <Database size={12} className="mt-1 shrink-0" /><span>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span>
</p> </p>
</CardContent> </CardContent>
</Card>; </Card></Collapsible>;
} }
@@ -1,18 +1,28 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox"; import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table";
import { AskAboutContent } from "./answer-ui-interactions"; import { AnswerReveal } from "./answer-motion";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
export function InteractiveDataTable({ title, columns, rows, source }: { import { useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerTableState } from "./answer-view-state";
export function InteractiveDataTable({ title, columns, rows, source, state: controlledState, onStateChange }: {
title: string; columns: string[]; rows: string[][]; source?: string; title: string; columns: string[]; rows: string[][]; source?: string;
state?: Omit<AnswerTableState, "type">;
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
}) { }) {
const [query, setQuery] = useState(""); const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
const [sort, setSort] = useState<{ column: number; descending: boolean } | null>(null); const state = controlledState ?? localState;
const [selection, setSelection] = useState<number[]>([]); const patch = onStateChange ?? patchLocal;
// The owning schema node migrates data changes. A complete restored snapshot
// can change chart columns/range and restore a valid selection atomically.
const { query, selection, sort } = state;
const setSelection = (selection: number[]) => patch({ selection });
const filtered = useMemo(() => { const filtered = useMemo(() => {
const items = rows.map((cells, id) => ({ cells, id })) const items = rows.map((cells, id) => ({ cells, id }))
.filter(({ cells }) => cells.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())); .filter(({ cells }) => cells.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
@@ -30,7 +40,7 @@ export function InteractiveDataTable({ title, columns, rows, source }: {
return <div className="min-w-0 space-y-2" data-interactive-table> return <div className="min-w-0 space-y-2" data-interactive-table>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs" <Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs"
onChange={(event) => { setQuery(event.currentTarget.value); setSelection([]); }} /> onChange={(event) => { patch({ query: event.currentTarget.value, selection: [] }); }} />
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span> <span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
</div> </div>
<div role="region" aria-label={title} tabIndex={0} <div role="region" aria-label={title} tabIndex={0}
@@ -38,21 +48,23 @@ export function InteractiveDataTable({ title, columns, rows, source }: {
<Table> <Table>
<TableCaption className="caption-top font-medium">{title}</TableCaption> <TableCaption className="caption-top font-medium">{title}</TableCaption>
<TableHeader className="bg-muted/50"><TableRow> <TableHeader className="bg-muted/50"><TableRow>
<TableHead scope="col" className="w-10"><span className="sr-only">选择行</span></TableHead> <TableHead scope="col" className="w-10"><Checkbox aria-label={`选择${title}的全部筛选结果`}
checked={filtered.length > 0 && selected.length === Math.min(filtered.length, 100)} disabled={!filtered.length}
onChange={(event) => setSelection(event.target.checked ? filtered.slice(0, 100).map(({ id }) => id) : [])} /></TableHead>
{columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap" {columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap"
aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}> aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}>
<Button size="sm" variant="ghost" className="h-9 gap-1 px-1 text-xs" aria-label={`按${column}排序`} <Button size="sm" variant="ghost" className="h-9 gap-1 px-1 text-xs" aria-label={`按${column}排序`}
onClick={() => setSort((current) => current?.column === index onClick={() => patch({ sort: sort?.column === index
? current.descending ? null : { column: index, descending: true } ? sort.descending ? null : { column: index, descending: true }
: { column: index, descending: false })}> : { column: index, descending: false } })}>
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />} {column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />}
</Button> </Button>
</TableHead>)} </TableHead>)}
</TableRow></TableHeader> </TableRow></TableHeader>
<TableBody> <TableBody>
{filtered.map(({ cells, id }) => <TableRow key={id} data-state={selection.includes(id) ? "selected" : undefined}> {filtered.map(({ cells, id }) => <TableRow key={id} data-state={selection.includes(id) ? "selected" : undefined}>
<TableCell className="w-10"><Checkbox aria-label={`选择第 ${id + 1} 行`} checked={selection.includes(id)} <TableCell className="w-10"><Checkbox aria-label={`选择第 ${id + 1} 行`} checked={selection.includes(id)} disabled={!selection.includes(id) && selection.length >= 100}
onChange={(event) => setSelection((current) => event.target.checked ? [...current, id] : current.filter((value) => value !== id))} /></TableCell> onChange={(event) => setSelection(event.target.checked ? [...selection, id] : selection.filter((value) => value !== id))} /></TableCell>
{cells.map((cell, index) => <TableCell key={index} {cells.map((cell, index) => <TableCell key={index}
className={cn("min-w-24", /^[-+]?\d+(\.\d+)?%?$/.test(cell) && "text-right tabular-nums")}>{cell}</TableCell>)} className={cn("min-w-24", /^[-+]?\d+(\.\d+)?%?$/.test(cell) && "text-right tabular-nums")}>{cell}</TableCell>)}
</TableRow>)} </TableRow>)}
@@ -62,9 +74,14 @@ export function InteractiveDataTable({ title, columns, rows, source }: {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{selected.length ? <div className="flex flex-wrap items-center justify-between gap-1"> <AnswerReveal open={selected.length > 0} className="flex flex-wrap items-center justify-between gap-1">
<span className="text-xs text-muted-foreground">已选 {selected.length} 行</span> <span className="text-xs text-muted-foreground">已选 {selected.length} 行{filtered.length > 100 ? " · 每次最多选择前 100 行" : ""}</span>
<div className="flex flex-wrap gap-1">
<Button type="button" variant="ghost" size="sm" className="h-9 text-xs" onClick={() => setSelection([])}>取消选行</Button>
<SelectForAnalysis label="将所选行加入分析" title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
<AskAboutContent title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} /> <AskAboutContent title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
</div> : <p className="text-[11px] text-muted-foreground">筛选与排序仅作用于已加载数据;勾选行可继续提问。</p>} </div>
</AnswerReveal>
{!selected.length && filtered.length ? <p className="text-[11px] text-muted-foreground">勾选数据行可加入分析或继续提问。</p> : null}
</div>; </div>;
} }
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentProps } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./cn";
const alertVariants = cva(
"relative grid w-full min-w-0 grid-cols-[0_1fr] items-start gap-y-1 rounded-lg border p-4 text-sm has-[>svg]:grid-cols-[1rem_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive: "border-destructive/50 bg-card text-destructive"
}
},
defaultVariants: { variant: "default" }
}
);
// Local shadcn/ui composition, adapted to the application's semantic tokens.
export function Alert({ className, variant, ...props }: ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
}
export function AlertTitle({ className, ...props }: ComponentProps<"div">) {
return <div data-slot="alert-title" className={cn("col-start-2 min-w-0 font-medium leading-5", className)} {...props} />;
}
export function AlertDescription({ className, ...props }: ComponentProps<"div">) {
return <div data-slot="alert-description" className={cn("col-start-2 min-w-0 text-sm leading-6", className)} {...props} />;
}
+40 -9
View File
@@ -2279,12 +2279,22 @@ details[open] > summary > .disclosure-accordion-indicator {
} }
.answer-ui-root { --surface-reading: #ffffff; --surface-control: #f1f5f9; --surface-border: #e2e8f0; --ink: #0f172a; --ink-muted: #64748b; } .answer-ui-root { --surface-reading: #ffffff; --surface-control: #f1f5f9; --surface-border: #e2e8f0; --ink: #0f172a; --ink-muted: #64748b; }
.answer-content-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; border-bottom: 1px solid var(--surface-divider); }
.answer-content-nav > span { min-width: 0; line-height: 1.6; }
.answer-ui-root :is(.answer-ui-metric, .answer-ui-chart, .answer-ui-evidence, .answer-ui-location, .answer-ui-form, [data-answer-table]) { scroll-margin-block: 12px; }
.answer-ui-root :is(.answer-ui-metric, .answer-ui-chart, .answer-ui-evidence, .answer-ui-location, .answer-ui-form, [data-answer-table]):focus-visible { outline: 2px solid var(--action-blue); outline-offset: 2px; }
.answer-ui-root .answer-ui-stack { gap: 14px; } .answer-ui-root .answer-ui-stack { gap: 14px; }
.answer-ui-root .answer-ui-stack-compact { gap: 10px; } .answer-ui-root .answer-ui-stack-compact { gap: 10px; }
.answer-ui-metric { min-width: 0; border: 1px solid #e2e8f0; border-radius: 12px; background: #f8fafc; box-shadow: none; } .answer-ui-root[data-answer-density="compact"] :is(.answer-task-layout, .answer-task-layout-simple, .answer-task-overview, .answer-task-analysis, .answer-task-support) { gap: 12px; }
.answer-ui-root .answer-ui-stack[data-answer-density="compact"] { gap: 10px; }
.answer-ui-root .answer-ui-stack[data-answer-density="comfortable"] { gap: 16px; }
.answer-ui-root[data-answer-density="comfortable"] :is(.answer-task-layout, .answer-task-layout-simple, .answer-task-overview, .answer-task-analysis, .answer-task-support) { gap: 20px; }
.answer-ui-root [data-answer-result] { scroll-margin-block: 12px; }
.answer-ui-root [data-answer-result]:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.answer-ui-metric { min-width: 0; border-color: var(--surface-border); background: var(--surface-reading); box-shadow: none; }
.answer-ui-metric-content { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 4px; row-gap: 4px; } .answer-ui-metric-content { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 4px; row-gap: 4px; }
.answer-ui-metric-label { grid-column: 1; grid-row: 1; min-width: 0; } .answer-ui-metric-label { grid-column: 1; grid-row: 1; min-width: 0; }
.answer-ui-metric-value { grid-column: 1 / -1; grid-row: 2; } .answer-ui-metric-value { grid-column: 1 / -1; grid-row: 2; color: var(--ink); }
.answer-ui-metric-source { grid-column: 1 / -1; grid-row: 3; } .answer-ui-metric-source { grid-column: 1 / -1; grid-row: 3; }
.answer-ui-metric-ask { display: contents; } .answer-ui-metric-ask { display: contents; }
.answer-ui-metric-ask > button { grid-column: 2; grid-row: 1; } .answer-ui-metric-ask > button { grid-column: 2; grid-row: 1; }
@@ -2294,7 +2304,7 @@ details[open] > summary > .disclosure-accordion-indicator {
gap: 0; gap: 0;
border: 1px solid var(--surface-border); border: 1px solid var(--surface-border);
border-radius: 12px; border-radius: 12px;
background: var(--surface-control); background: var(--surface-reading);
} }
.answer-ui-stack-comparison:has(> .answer-ui-metric):not(:has(> :not(.answer-ui-metric))) > .answer-ui-metric { .answer-ui-stack-comparison:has(> .answer-ui-metric):not(:has(> :not(.answer-ui-metric))) > .answer-ui-metric {
border: 0; border: 0;
@@ -2309,7 +2319,7 @@ details[open] > summary > .disclosure-accordion-indicator {
.answer-ui-stack-comparison:has(> .answer-ui-metric):not(:has(> :not(.answer-ui-metric))) > .answer-ui-metric:nth-child(2) { border-top: 0; } .answer-ui-stack-comparison:has(> .answer-ui-metric):not(:has(> :not(.answer-ui-metric))) > .answer-ui-metric:nth-child(2) { border-top: 0; }
.answer-ui-stack-comparison:has(> .answer-ui-metric):not(:has(> :not(.answer-ui-metric))) > .answer-ui-metric:nth-child(even) { border-left: 1px solid var(--surface-border); } .answer-ui-stack-comparison:has(> .answer-ui-metric):not(:has(> :not(.answer-ui-metric))) > .answer-ui-metric:nth-child(even) { border-left: 1px solid var(--surface-border); }
} }
.answer-ui-chart { min-width: 0; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; box-shadow: 0 1px 2px rgb(15 23 42 / 0.03); } .answer-ui-chart { min-width: 0; border-color: var(--surface-border); background: var(--surface-reading); box-shadow: none; }
.answer-ui-root .answer-ui-chart-series button[aria-pressed] { box-shadow: none; font-weight: 400; } .answer-ui-root .answer-ui-chart-series button[aria-pressed] { box-shadow: none; font-weight: 400; }
.answer-ui-root .answer-ui-chart-series button[aria-pressed="true"] { .answer-ui-root .answer-ui-chart-series button[aria-pressed="true"] {
border-color: var(--surface-border); border-color: var(--surface-border);
@@ -2317,11 +2327,11 @@ details[open] > summary > .disclosure-accordion-indicator {
color: var(--ink); color: var(--ink);
} }
.answer-ui-root .answer-ui-chart-series button[aria-pressed="false"] { color: var(--ink-muted); } .answer-ui-root .answer-ui-chart-series button[aria-pressed="false"] { color: var(--ink-muted); }
.answer-ui-evidence { min-width: 0; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; } .answer-ui-evidence { min-width: 0; border: 1px solid var(--surface-border); border-radius: var(--radius); background: var(--surface-reading); }
.answer-ui-evidence-icon { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 8px; background: #f1f5f9; color: #64748b; } .answer-ui-evidence-icon { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm); background: var(--surface-control); color: var(--ink-muted); }
.answer-ui-location { display: flex; align-items: flex-start; gap: 10px; padding: 12px; border: 1px dashed #cbd5e1; border-radius: 12px; } .answer-ui-location { display: flex; align-items: flex-start; gap: 10px; padding: 12px; border-color: var(--surface-border); background: var(--surface-reading); box-shadow: none; }
.answer-ui-form { padding: 14px; border: 1px solid #e2e8f0; border-radius: 12px; background: #f8fafc; } .answer-ui-form { min-width: 0; padding: 0; border-color: var(--surface-border); background: var(--surface-reading); box-shadow: none; }
.answer-ui-form :is(input, textarea) { background: #ffffff; } .answer-ui-form :is(input, textarea) { background: var(--surface-reading); }
.answer-ui-table { scrollbar-gutter: stable; } .answer-ui-table { scrollbar-gutter: stable; }
/* Shared report reading surfaces, independent of the map's material. */ /* Shared report reading surfaces, independent of the map's material. */
@@ -2434,3 +2444,24 @@ body:has(:is(.agent-history-dialog, .agent-topic-dialog)[data-state="open"]) [da
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
} }
/* One answer becomes a working surface when its actual content warrants it. */
.answer-task-layout, .answer-task-layout-simple { display: grid; min-width: 0; gap: 16px; }
.answer-task-overview { grid-column: 1 / -1; min-width: 0; }
.answer-task-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; border-top: 1px solid var(--surface-divider); padding-top: 12px; font-size: 12px; font-weight: 500; }
.answer-task-heading > span + span { font-size: 11px; font-weight: 400; color: var(--ink-muted); }
.answer-task-support-section { min-width: 0; border-top: 1px solid var(--surface-divider); }
.answer-task-layout-simple .answer-task-support-section { border: 0; }
.answer-task-layout-simple .answer-task-support-content { padding-top: 0; }
.answer-task-support:empty { display: none; }
.answer-ui-metric-actions { grid-column: 2; grid-row: 1; align-self: start; }
.answer-chart-range-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0 8px; }
.answer-chart-range-summary > :last-child { grid-column: 1 / -1; }
.answer-selection-tray { margin-top: 16px; }
.answer-selection-tray summary::marker { color: var(--ink-muted); }
@container (min-width: 800px) {
.answer-task-layout { grid-template-columns: minmax(0, 1fr) minmax(260px, 0.62fr); align-items: start; gap: 18px 24px; }
.answer-task-layout .answer-task-heading { border-top: 0; padding-top: 0; }
.answer-task-layout .answer-task-support { padding-left: 20px; border-left: 1px solid var(--surface-divider); }
.answer-task-layout .answer-task-support-section:first-child { border-top: 0; }
}
@@ -0,0 +1,113 @@
import { expect, test, type Locator } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
const block = {
version: "answer-ui@1", block_id: "components-motion", fallback_text: "组件交互演练,不代表现场事实。",
spec: { root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "report" }, children: ["result", "note", "metric", "table", "evidence", "form", "location"] },
result: { type: "Result", props: { title: "演练结果", summary: "待现场核验。", status: "unconfirmed", source: "浏览器演练" }, children: [] },
note: { type: "Text", props: { title: "数据边界", tone: "info", text: "仅供交互验收。" }, children: [] },
metric: { type: "Metric", props: { label: "演练入口压力", value: "0.2130", unit: "MPa", source: "浏览器演练" }, children: [] },
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-MOTION", observed_at: null }, children: [] },
form: { type: "FollowUpForm", props: { title: "现场核验", description: "由你补充后带入对话。", fields: [{ name: "record", label: "现场记录", kind: "text", required: true }] }, children: [] },
location: { type: "MapLocation", props: { label: "定位演练入口", layer: "junctions", feature_ids: ["DEMO-ENTRY"], source: "浏览器演练" }, children: [] },
} },
};
async function sampleToggle(button: Locator, selector: string) {
return button.evaluate(async (button, selector) => {
const reveal = button.closest("[data-answer-ui]")!.querySelector<HTMLElement>(selector)!;
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
button.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
const frames = [];
for (let index = 0; index < 18; index++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
frames.push({ opacity: Number(getComputedStyle(reveal).opacity), hidden: reveal.hidden,
inert: reveal.inert, ariaHidden: reveal.getAttribute("aria-hidden") });
}
return frames;
}, selector);
}
for (const width of [1440, 320]) for (const reduce of [false, true]) {
test(`answer component disclosures ${reduce ? "reduced" : "normal"} motion at ${width}px`, async ({ page }, testInfo) => {
test.setTimeout(60000);
await page.setViewportSize({ width, height: 1000 });
await page.emulateMedia({ reducedMotion: reduce ? "reduce" : "no-preference" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page);
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" }, body: responsesFixture([
{ type: "start", messageId: "components-answer" },
{ type: "data-answer_ui", id: block.block_id, data: block },
{ type: "data-final_answer", data: { content: "组件演练,待现场核验。", ui_block_ids: [block.block_id] } },
{ type: "finish", finishReason: "stop" },
]) });
});
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(".agent-panel-prompt-textarea").fill("检查演练组件");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel.locator('[data-answer-ui="components-motion"]')).toBeVisible();
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
const workspace = page.getByRole("region", { name: "展开报告工作区", exact: true });
const ui = workspace.locator('[data-answer-ui="components-motion"]');
const evidence = ui.locator(".answer-ui-evidence");
const opened = await sampleToggle(evidence.getByRole("button").first(), ".answer-ui-evidence [data-answer-reveal]");
expect(opened.some((frame) => frame.opacity > 0 && frame.opacity < 1)).toBe(!reduce);
await expect(evidence.getByText("尚未核验现场事实。", { exact: true })).toBeVisible();
const closed = await sampleToggle(evidence.getByRole("button").first(), ".answer-ui-evidence [data-answer-reveal]");
expect(closed[0].inert).toBe(true);
expect(closed[0].ariaHidden).toBe("true");
expect(closed.at(-1)!.hidden).toBe(true);
expect(closed.some((frame) => frame.opacity > 0 && frame.opacity < 1)).toBe(!reduce);
// Keyboard actions and restoration never replay the pointer transition.
await evidence.getByRole("button").first().focus();
await page.keyboard.press("Enter");
await expect(evidence.locator("[data-answer-reveal]")).toHaveCSS("opacity", "1");
const editor = ui.getByLabel("现场记录(必填)");
await editor.fill("保留同一个编辑器的现场草稿");
await editor.evaluate((element) => element.setAttribute("data-original-editor", "yes"));
await ui.getByRole("button", { name: "收起补充资料", exact: true }).click();
await expect(editor).toBeHidden();
await ui.getByRole("button", { name: "展开补充资料", exact: true }).click();
await expect(editor).toHaveValue("保留同一个编辑器的现场草稿");
await expect(editor).toHaveAttribute("data-original-editor", "yes");
await expect(ui.getByText("需要你补充", { exact: true })).toHaveCount(0);
const location = ui.locator(".answer-ui-location");
await location.getByRole("button", { name: "查看关联要素", exact: true }).click();
await expect(location.getByText("DEMO-ENTRY", { exact: true })).toBeVisible();
await ui.getByRole("checkbox", { name: "选择第 1 行", exact: true }).check();
await ui.getByRole("button", { name: "将所选行加入分析", exact: true }).click();
const tray = ui.getByRole("region", { name: "分析内容篮" });
await expect(tray).toContainText("已选 1 项分析内容");
await tray.getByRole("button", { name: "演练记录", exact: true }).click();
await expect(tray.getByText(/对象:入口一;压力(MPa):0.2130/)).toBeVisible();
await tray.getByRole("textbox").fill("核查演练资料");
await ui.getByRole("button", { name: "将演练入口压力加入分析", exact: true }).click();
await tray.getByRole("button", { name: "移除演练入口压力", exact: true }).click();
await expect(tray.getByRole("textbox")).toHaveValue("核查演练资料");
await expect(editor).toHaveValue("保留同一个编辑器的现场草稿");
await ui.getByRole("textbox", { name: "搜索演练记录" }).fill("不存在的入口");
await expect(ui.getByRole("cell", { name: "当前筛选没有匹配的数据。" })).toBeVisible();
await expect(ui.getByText("勾选数据行可加入分析或继续提问。", { exact: true })).toHaveCount(0);
await ui.getByRole("textbox", { name: "搜索演练记录" }).fill("");
await ui.getByRole("button", { name: "按压力(MPa)排序" }).click();
await expect(ui.getByRole("columnheader", { name: "压力(MPa)" })).toHaveAttribute("aria-sort", "ascending");
expect(await workspace.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(false);
await ui.getByRole("button", { name: "收起补充资料", exact: true }).click();
await ui.getByRole("button", { name: "展开补充资料", exact: true }).click();
// A reverse action while the exit is running must not hide a reopened editor.
await expect(editor).toBeVisible();
await expect(editor).toHaveValue("保留同一个编辑器的现场草稿");
await page.evaluate(() => document.fonts.ready);
await ui.locator(".answer-ui-form").screenshot({ path: testInfo.outputPath(`form-${width}-${reduce}.png`) });
await evidence.screenshot({ path: testInfo.outputPath(`evidence-${width}-${reduce}.png`) });
await tray.screenshot({ path: testInfo.outputPath(`basket-${width}-${reduce}.png`) });
});
}
+88
View File
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
// Synthetic readings exercise the same chart contract as the production answer.
const chart = {
version: "answer-ui@1", block_id: "chart-motion", fallback_text: "演练压力对比",
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
title: "节点17038压力:事件日前后同时段对比(m)", chart_type: "line",
x_data: ["07:00", "07:15", "07:30", "07:45", "08:00", "08:15", "08:30", "08:45", "09:00"],
series: [{ name: "6月1日(m)", data: [57.8, 63, 64, 66, 67, 68, 71, 73.8, 73.93] },
{ name: "6月2日(m)", data: [58.91, 62, 63, 65, 65, 66, 69, 71, 71.34] }],
source: "浏览器演练数据,待现场核验",
} } } },
};
for (const width of [1440, 375]) for (const reduce of [false, true]) {
test(`chart switches with ${reduce ? "reduced" : "normal"} motion at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
await page.emulateMedia({ reducedMotion: reduce ? "reduce" : "no-preference" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page);
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: "chart-answer" },
{ type: "data-answer_ui", id: chart.block_id, data: chart },
{ type: "data-final_answer", data: { content: "演练资料,待现场核验。", ui_block_ids: [chart.block_id] } },
{ type: "finish", finishReason: "stop" },
]) });
});
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(".answer-ui-chart");
await expect(ui).toBeVisible();
await expect(ui.getByRole("button", { name: "查看数据点", exact: true })).toHaveCount(0);
await expect(ui.getByRole("region", { name: "数据点详情" })).toHaveCount(0);
const content = ui.locator("[data-chart-view]");
const samples = await ui.getByRole("tab", { name: "柱状", exact: true }).evaluate(async (tab) => {
const content = tab.closest(".answer-ui-chart")!.querySelector("[data-chart-view]")!;
const values: number[] = [];
// Dispatch a real pointer modality before click, sampling the actual render over frames.
tab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
tab.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
(tab as HTMLElement).click();
for (let i = 0; i < 16; i++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
values.push(Number(getComputedStyle(content).opacity));
}
return values;
});
await expect(content).toHaveAttribute("data-chart-view", "bar");
if (reduce) expect(samples.every((value) => value === 1)).toBe(true);
else expect(samples.some((value) => value > 0 && value < 1)).toBe(true);
await expect(content).toHaveCSS("opacity", "1");
await ui.getByRole("tab", { name: "数据", exact: true }).click();
await expect(ui.getByRole("group", { name: "当前区间摘要" })).toHaveCount(0);
await ui.getByRole("textbox", { name: /搜索节点17038/ }).fill("08:30");
await ui.getByRole("checkbox", { name: "选择第 7 行", exact: true }).check();
const line = ui.getByRole("tab", { name: "折线", exact: true });
await line.focus();
await page.keyboard.press("Enter");
await expect(content).toHaveCSS("opacity", "1");
// Exercise the real SVG hit target as well as the keyboard alternative.
await ui.locator('svg path[stroke="#2563eb"]').last().click();
await expect(ui.getByRole("region", { name: "数据点详情" })).toContainText("73.93");
await ui.getByRole("button", { name: "关闭数据点详情" }).click();
const plot = ui.getByRole("group", { name: /按回车查看数据点/ });
await plot.focus();
await page.keyboard.press("End");
await expect(ui.getByRole("region", { name: "数据点详情" })).toContainText("73.93");
await ui.getByRole("button", { name: "关闭数据点详情" }).click();
await expect(plot).toBeFocused();
await ui.getByRole("tab", { name: "数据", exact: true }).click();
await expect(ui.getByRole("textbox", { name: /搜索节点17038/ })).toHaveValue("08:30");
await expect(ui.getByRole("checkbox", { name: "选择第 7 行", exact: true })).toBeChecked();
await line.click();
await expect(content).toHaveCSS("opacity", "1");
expect(await ui.evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(false);
await page.evaluate(() => document.fonts.ready);
await ui.screenshot({ path: testInfo.outputPath(`chart-${width}-${reduce}.png`) });
});
}