feat: polish intelligent answer interactions
This commit is contained in:
+17
-2
@@ -9,12 +9,27 @@
|
||||
|
||||
## 可用内容与交互
|
||||
|
||||
`Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、
|
||||
`MapLocation`、`FollowUpForm` 共九种组件。图表可切换折线、柱状和数据表;
|
||||
`Stack`、`Section`、`Text`、`Result`、`Metric`、`Table`、`Chart`、`Evidence`、
|
||||
`MapLocation`、`FollowUpForm` 共十种组件。图表可切换折线、柱状和数据表;
|
||||
表格可搜索、排序和选择行,图表可切换已加载区间、显隐序列并选点追问;
|
||||
证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
|
||||
由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。
|
||||
|
||||
`Chart` 的页签、图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与选行在视图切换后保留。
|
||||
|
||||
其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。
|
||||
|
||||
| 组件 | 交互与内容处理 |
|
||||
| --- | --- |
|
||||
| `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 |
|
||||
| `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 |
|
||||
| `Metric` | 保留原始数值、单位与来源,仅加入分析的按钮图标过渡 |
|
||||
| `Table` | 搜索排序即时响应;选行操作栏进入退出使用统一过渡;空结果不叠加选行提示 |
|
||||
| `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 |
|
||||
| `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 |
|
||||
| `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
|
||||
| 报告辅助分区与分析内容篮 | 分区、内容篮及所选引用详情统一展开收起;保留表单与分析目标草稿 |
|
||||
|
||||
Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。
|
||||
消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同
|
||||
`block_id` 更新原块;下一轮不会修改上一轮回答。
|
||||
|
||||
@@ -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;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
onAsk?: (selection: AnswerUiSelection) => Promise<void> | void;
|
||||
onToggleSelection?: (selection: AnswerUiSelection) => void;
|
||||
selections?: AnswerUiSelection[];
|
||||
evidenceFilter?: string;
|
||||
submitMode?: "send" | "draft";
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
import { useContext, useState } from "react";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import { Check, MessageSquarePlus, Plus } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useAnswerMotion } from "./answer-motion-context";
|
||||
import { agentEase } from "../components/agent-motion";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function AskAboutContent({ title, text, compact = false }: AnswerUiSelection & { compact?: boolean }) {
|
||||
@@ -22,3 +25,21 @@ export function AskAboutContent({ title, text, compact = false }: AnswerUiSelect
|
||||
<MessageSquarePlus size={14} aria-hidden />{compact ? null : "针对这部分提问"}
|
||||
</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 userEvent from "@testing-library/user-event";
|
||||
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");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function taskBlock(includeData = true) {
|
||||
const children = ["metric", ...(includeData ? ["table"] : []), "evidence", "form"];
|
||||
const fixture = {
|
||||
version: "answer-ui@1", block_id: "task", fallback_text: "演练分析",
|
||||
spec: { root: "root", elements: {
|
||||
root: { type: "Stack", props: { columns: "one", gap: "normal" }, children },
|
||||
metric: { type: "Metric", props: { label: "当前压力", value: "0.2130", unit: "MPa", source: "监测快照" }, children: [] },
|
||||
...(includeData ? { table: { type: "Table", props: { title: "入口记录", columns: ["对象", "压力(MPa)"], rows: [["北区入口", "0.2130"], ["南区入口", "0.2870"]] }, children: [] } } : {}),
|
||||
evidence: { type: "Evidence", props: { title: "读数依据", summary: "仅用于演练", source: "测试查询", kind: "demo", evidence_id: "DEMO-26", observed_at: null }, children: [] },
|
||||
form: { type: "FollowUpForm", props: { title: "核验资料", description: "主动补充", fields: [{ name: "record", label: "现场记录", kind: "text", required: true }] }, children: [] }
|
||||
} }
|
||||
};
|
||||
const parsed = parseAnswerUiBlock(fixture);
|
||||
if (!parsed) throw new Error("invalid task fixture");
|
||||
return parsed;
|
||||
}
|
||||
describe("local interactive answer renderer", () => {
|
||||
it("adapts response and report composition without replacing a reader's form draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fixture = taskBlock();
|
||||
fixture.spec.elements.root.props = { columns: "one", gap: "normal", intent: "response", density: "compact" };
|
||||
const { rerender, container } = render(<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 () => {
|
||||
const user = userEvent.setup();
|
||||
const ask = vi.fn();
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
import { AskAboutContent } from "./answer-ui-interactions";
|
||||
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
|
||||
import { useContext, useId, useState } from "react";
|
||||
import { LocateButton, FollowUpForm } from "./answer-action-components";
|
||||
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
|
||||
import { Renderer, defineRegistry } from "@json-render/react";
|
||||
import { useContext, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
ClipboardList,
|
||||
Database,
|
||||
FileCheck2,
|
||||
FlaskConical,
|
||||
Lightbulb,
|
||||
MapPin,
|
||||
MessageSquare
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { InteractiveChart } from "./interactive-chart";
|
||||
import { InteractiveDataTable } from "./interactive-data-table";
|
||||
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
|
||||
import { AnswerResult } from "./answer-result";
|
||||
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget, type AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
import { answerUiCatalog } from "./catalog";
|
||||
import type { AnswerUiBlock } from "./contract";
|
||||
import { AnswerContentNav } from "./answer-content-nav";
|
||||
import { getAnswerTaskLayout } from "./answer-task-layout";
|
||||
import { AnswerSelectionTray } from "./answer-selection-tray";
|
||||
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
|
||||
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
|
||||
import { ANSWER_SELECTION_LIMIT, getSelectionText } from "./answer-selection";
|
||||
|
||||
const { registry } = defineRegistry(answerUiCatalog, {
|
||||
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
|
||||
actions: {},
|
||||
components: {
|
||||
Stack: ({ props, children }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
data-answer-intent={props.intent}
|
||||
data-answer-density={props.density}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
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">
|
||||
<ClipboardList size={15} aria-hidden="true" />
|
||||
{props.title}
|
||||
@@ -53,22 +60,21 @@ const { registry } = defineRegistry(answerUiCatalog, {
|
||||
<div className="mt-3 space-y-3">{children}</div>
|
||||
</section>
|
||||
),
|
||||
Text: ({ props }) => (
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap text-sm leading-6",
|
||||
props.tone === "warning"
|
||||
? "text-[var(--status-warning-foreground)]"
|
||||
: props.tone === "info"
|
||||
? "text-[var(--action-blue-hover)]"
|
||||
: "text-[var(--ink)]"
|
||||
)}
|
||||
>
|
||||
{props.text}
|
||||
</p>
|
||||
Text: ({ props }) => props.tone === "neutral" ? (
|
||||
<div className="space-y-1">
|
||||
{props.title ? <h4 className="text-sm font-medium leading-6 text-foreground">{props.title}</h4> : null}
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">{props.text}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Alert role="note" className="border-border bg-card px-3.5 py-3 text-card-foreground has-[>svg]:gap-x-2.5">
|
||||
{props.tone === "warning" ? <TriangleAlert className="text-[var(--status-warning-foreground)]" aria-hidden /> : <Info className="text-muted-foreground" aria-hidden />}
|
||||
<AlertTitle className="text-[13px]">{props.title ?? (props.tone === "warning" ? "注意事项" : "补充说明")}</AlertTitle>
|
||||
<AlertDescription className="whitespace-pre-wrap text-muted-foreground">{props.text}</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
Result: ({ props }) => <AnswerResult {...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">
|
||||
<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">
|
||||
@@ -80,21 +86,35 @@ const { registry } = defineRegistry(answerUiCatalog, {
|
||||
{props.source ? (
|
||||
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
|
||||
) : null}
|
||||
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
|
||||
<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}` : ""}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
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} />,
|
||||
MapLocation: ({ props }) => <LocateButton {...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,
|
||||
disabled = false,
|
||||
onLocate,
|
||||
@@ -102,25 +122,80 @@ export function AnswerUiView({
|
||||
onAsk,
|
||||
submitMode = "send"
|
||||
}: { 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 evidenceFilters = kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
|
||||
{["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)}>
|
||||
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label}
|
||||
</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: 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}
|
||||
</Button>)}
|
||||
</div> : null}
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={block.spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
</div>
|
||||
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> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
|
||||
</>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 = {
|
||||
observed: { label: "查询记录", icon: Database },
|
||||
human_input: { label: "用户提供", icon: MessageSquare },
|
||||
@@ -136,14 +211,15 @@ function EvidenceCard(props: {
|
||||
observed_at: string | null;
|
||||
evidence_id: string | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [{ open }, patch] = useAnswerNodeState<AnswerEvidenceState>("Evidence", props);
|
||||
const setOpen = (open: boolean) => patch({ open });
|
||||
const meta = evidenceKinds[props.kind];
|
||||
const Icon = meta.icon;
|
||||
const { evidenceFilter } = useContext(InteractionContext);
|
||||
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="answer-ui-evidence">
|
||||
<CollapsibleTrigger asChild>
|
||||
<AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-ui-evidence" role="group" aria-label={props.title}
|
||||
contentClassName="px-3 pb-3 text-xs leading-6" trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
@@ -161,14 +237,8 @@ function EvidenceCard(props: {
|
||||
<Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0", open && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-3 pb-3 text-xs leading-6">
|
||||
<AnswerDisclosureChevron open={open} />
|
||||
</Button>}>
|
||||
<p className="whitespace-pre-wrap border-t border-border pt-3">{props.summary}</p>
|
||||
{props.observed_at || props.evidence_id ? (
|
||||
<dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground">
|
||||
@@ -186,186 +256,10 @@ function EvidenceCard(props: {
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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}` : ""}`} />
|
||||
</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>
|
||||
</AnswerDisclosure>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,20 +13,36 @@ export const answerUiCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Stack: {
|
||||
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(),
|
||||
slots: ["default"],
|
||||
description: "组织内容;双列在窄面板自动改为单列。"
|
||||
description: "组织内容。intent 表达简短答复、并列比较或报告;density 表达疏密,不控制字体和颜色。comparison 与双列在窄面板自动改单列。"
|
||||
},
|
||||
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"],
|
||||
description: "带标题的资料分区,保留真实内容层级。"
|
||||
description: "带标题的资料分区。role 标记结果、分析、依据或补充资料;前端据此组织任务内容,保留分区内部的原有顺序。"
|
||||
},
|
||||
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: "简短说明、判断边界或资料缺口。"
|
||||
},
|
||||
Result: {
|
||||
props: z.object({
|
||||
title: shortText,
|
||||
summary: text,
|
||||
status: z.enum(["success", "partial", "unconfirmed", "error"]),
|
||||
source: optionalText
|
||||
}).strict(),
|
||||
description: "概括已有工具结果:成功、部分完成、未确认或失败。source 标注真实依据;无依据用 unconfirmed。此组件不执行操作,不证明浏览器展示、业务写入或现场处置完成。"
|
||||
},
|
||||
Metric: {
|
||||
props: z
|
||||
.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 { BarChart3, ChevronDown, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
|
||||
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 = {
|
||||
title: string;
|
||||
@@ -16,6 +18,9 @@ export type AnswerChartProps = {
|
||||
series: Array<{ name: string; data: number[] }>;
|
||||
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"];
|
||||
function PointSelect({ label, value, labels, onChange }: {
|
||||
label: string; value: number; labels: string[]; onChange: (value: number) => void;
|
||||
@@ -26,16 +31,47 @@ function PointSelect({ label, value, labels, onChange }: {
|
||||
</Select>;
|
||||
}
|
||||
export function InteractiveChart(props: AnswerChartProps) {
|
||||
const [view, setView] = useState<string>(props.chart_type);
|
||||
const [hidden, setHidden] = useState<number[]>([]);
|
||||
const [range, setRange] = useState<[number, number]>([0, props.x_data.length - 1]);
|
||||
const [point, setPoint] = useState(0);
|
||||
const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const pointer = useRef(false);
|
||||
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 labels = props.x_data.slice(range[0], range[1] + 1);
|
||||
const pointIndex = Math.min(range[1], Math.max(range[0], point));
|
||||
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") => ({
|
||||
animation: false,
|
||||
animation: animateInteraction && !reducedMotion,
|
||||
animationDuration: 180,
|
||||
animationDurationUpdate: 180,
|
||||
color: colors.filter((_, index) => !hidden.includes(index)),
|
||||
aria: { enabled: true },
|
||||
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),
|
||||
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } }))
|
||||
});
|
||||
return <Card className="answer-ui-chart overflow-hidden">
|
||||
<CardHeader className="space-y-1 px-3.5 pb-3 pt-3.5">
|
||||
<CardTitle className="flex items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} />{props.title}</CardTitle>
|
||||
<CardDescription className="text-[11px]">{labels.length} / {props.x_data.length} 个数据点 · {visible.length} 组序列</CardDescription>
|
||||
return <Collapsible asChild><Card className="answer-ui-chart overflow-hidden" role="group" aria-label={props.title}
|
||||
onPointerDownCapture={() => { pointer.current = true; }}
|
||||
onKeyDownCapture={() => {
|
||||
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>
|
||||
<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}>
|
||||
<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="bar"><BarChart3 size={13} />柱状</TabsTrigger>
|
||||
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
|
||||
@@ -61,66 +114,93 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
{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)}
|
||||
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}
|
||||
</Button>)}
|
||||
</div>
|
||||
<Collapsible className="mb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||||
<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>
|
||||
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
|
||||
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
|
||||
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
|
||||
<span className="text-xs text-muted-foreground">至</span>
|
||||
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
|
||||
onChange={(end) => setRange([Math.min(range[0], end), end])} />
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-2 rounded-lg border bg-muted/30 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
|
||||
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
|
||||
<span className="text-xs text-muted-foreground">至</span>
|
||||
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
|
||||
onChange={(end) => setRange([Math.min(range[0], end), end])} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
|
||||
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
|
||||
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
|
||||
</CollapsibleContent>
|
||||
<motion.div data-chart-view={view} initial={false} animate={contentMotion}>
|
||||
{(["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 }}
|
||||
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
|
||||
if (event.componentType === "series" && typeof event.dataIndex === "number")
|
||||
setPoint(range[0] + event.dataIndex);
|
||||
if (event.componentType === "series" && typeof event.dataIndex === "number") {
|
||||
patch({ point: range[0] + event.dataIndex, inspecting: true });
|
||||
}
|
||||
} }} />
|
||||
</div>
|
||||
</TabsContent>)}
|
||||
<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]))])}
|
||||
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))}
|
||||
source={props.source} />
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
<div className="rounded-lg bg-muted/40 p-2.5">
|
||||
<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)} />
|
||||
{view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
|
||||
<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>
|
||||
<dl className="my-2 space-y-1.5 text-xs">
|
||||
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3">
|
||||
<dt className="min-w-0 text-muted-foreground">{series.name}</dt>
|
||||
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
|
||||
<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>)}
|
||||
</dl>
|
||||
<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}`} />
|
||||
</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)} />
|
||||
</div>
|
||||
<dl className="my-2 space-y-1.5 text-xs">
|
||||
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3">
|
||||
<dt className="min-w-0 text-muted-foreground">{series.name}</dt>
|
||||
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
|
||||
</div>)}
|
||||
</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]}`}
|
||||
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
|
||||
</div>
|
||||
</motion.section> : null}
|
||||
<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>
|
||||
</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 { Input } from "@/shared/ui/input";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
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;
|
||||
state?: Omit<AnswerTableState, "type">;
|
||||
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<{ column: number; descending: boolean } | null>(null);
|
||||
const [selection, setSelection] = useState<number[]>([]);
|
||||
const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
|
||||
const state = controlledState ?? localState;
|
||||
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 items = rows.map((cells, id) => ({ cells, id }))
|
||||
.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>
|
||||
<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"
|
||||
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>
|
||||
</div>
|
||||
<div role="region" aria-label={title} tabIndex={0}
|
||||
@@ -38,21 +48,23 @@ export function InteractiveDataTable({ title, columns, rows, source }: {
|
||||
<Table>
|
||||
<TableCaption className="caption-top font-medium">{title}</TableCaption>
|
||||
<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"
|
||||
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}排序`}
|
||||
onClick={() => setSort((current) => current?.column === index
|
||||
? current.descending ? null : { column: index, descending: true }
|
||||
: { column: index, descending: false })}>
|
||||
onClick={() => patch({ sort: sort?.column === index
|
||||
? sort.descending ? null : { column: index, descending: true }
|
||||
: { column: index, descending: false } })}>
|
||||
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />}
|
||||
</Button>
|
||||
</TableHead>)}
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{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)}
|
||||
onChange={(event) => setSelection((current) => event.target.checked ? [...current, id] : current.filter((value) => value !== id))} /></TableCell>
|
||||
<TableCell className="w-10"><Checkbox aria-label={`选择第 ${id + 1} 行`} checked={selection.includes(id)} disabled={!selection.includes(id) && selection.length >= 100}
|
||||
onChange={(event) => setSelection(event.target.checked ? [...selection, id] : selection.filter((value) => value !== id))} /></TableCell>
|
||||
{cells.map((cell, index) => <TableCell key={index}
|
||||
className={cn("min-w-24", /^[-+]?\d+(\.\d+)?%?$/.test(cell) && "text-right tabular-nums")}>{cell}</TableCell>)}
|
||||
</TableRow>)}
|
||||
@@ -62,9 +74,14 @@ export function InteractiveDataTable({ title, columns, rows, source }: {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{selected.length ? <div className="flex flex-wrap items-center justify-between gap-1">
|
||||
<span className="text-xs text-muted-foreground">已选 {selected.length} 行</span>
|
||||
<AskAboutContent title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
|
||||
</div> : <p className="text-[11px] text-muted-foreground">筛选与排序仅作用于已加载数据;勾选行可继续提问。</p>}
|
||||
<AnswerReveal open={selected.length > 0} className="flex flex-wrap items-center justify-between gap-1">
|
||||
<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}` : ""}`} />
|
||||
</div>
|
||||
</AnswerReveal>
|
||||
{!selected.length && filtered.length ? <p className="text-[11px] text-muted-foreground">勾选数据行可加入分析或继续提问。</p> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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-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-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-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-ask { display: contents; }
|
||||
.answer-ui-metric-ask > button { grid-column: 2; grid-row: 1; }
|
||||
@@ -2294,7 +2304,7 @@ details[open] > summary > .disclosure-accordion-indicator {
|
||||
gap: 0;
|
||||
border: 1px solid var(--surface-border);
|
||||
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 {
|
||||
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(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="true"] {
|
||||
border-color: var(--surface-border);
|
||||
@@ -2317,11 +2327,11 @@ details[open] > summary > .disclosure-accordion-indicator {
|
||||
color: var(--ink);
|
||||
}
|
||||
.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-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-location { display: flex; align-items: flex-start; gap: 10px; padding: 12px; border: 1px dashed #cbd5e1; border-radius: 12px; }
|
||||
.answer-ui-form { padding: 14px; border: 1px solid #e2e8f0; border-radius: 12px; background: #f8fafc; }
|
||||
.answer-ui-form :is(input, textarea) { 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: 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-color: var(--surface-border); background: var(--surface-reading); box-shadow: none; }
|
||||
.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: var(--surface-reading); }
|
||||
.answer-ui-table { scrollbar-gutter: stable; }
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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`) });
|
||||
});
|
||||
}
|
||||
@@ -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`) });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user