feat: expand intelligent investigation workspace
This commit is contained in:
@@ -10,7 +10,6 @@ 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";
|
||||
|
||||
@@ -75,7 +74,6 @@ export function LocateButton({
|
||||
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>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { canExpandAnswer } from "./answer-presentation";
|
||||
|
||||
const contentKinds = [
|
||||
{ type: "Result", label: "结果", icon: CircleCheck, selector: "[data-answer-result]" },
|
||||
@@ -25,7 +26,7 @@ export function AnswerContentNav({ block, contentRef }: {
|
||||
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;
|
||||
if (items.length < 2 || !canExpandAnswer([{ type: "answer-ui", block }])) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="回答内容导航" className="answer-content-nav">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { act, fireEvent, render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../api/answer-view-state";
|
||||
import { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
|
||||
import { AnswerViewPersistenceContext } from "../session-state/answer-view-persistence-context";
|
||||
import type { AnswerUiBlock } from "./contract";
|
||||
import { AnswerPartsView } from "./answer-parts-view";
|
||||
|
||||
vi.mock("../components/streaming-token-response", () => ({ StreamingTokenResponse: () => null }));
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
const block: AnswerUiBlock = {
|
||||
version: "answer-ui@1", block_id: "block", phase: "final", fallback_text: "资料",
|
||||
spec: { root: "form", elements: { form: { type: "FollowUpForm", children: [], props: {
|
||||
title: "资料", description: "补充", fields: [{ name: "note", label: "现场记录", kind: "text", required: false }]
|
||||
} } } }
|
||||
};
|
||||
const remote: AnswerViewStateRecord = {
|
||||
version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 4,
|
||||
schema_digest: "digest", updated_at: null, nodes: { form: { type: "FollowUpForm", values: { note: "远端" } } }, block: { evidenceFilter: "all" }
|
||||
};
|
||||
function fixture() {
|
||||
let restore!: (value: AnswerViewStateRecord) => void;
|
||||
const client = { getAnswerViewState: vi.fn(() => new Promise<AnswerViewStateRecord>((resolve) => { restore = resolve; })),
|
||||
putAnswerViewState: vi.fn(async (_session: string, write: AnswerViewStateWrite) => ({ ...remote, revision: 5, nodes: write.nodes, block: write.block })) };
|
||||
const scope = new AnswerViewStateScope("actor:project", client);
|
||||
const view = (persist: boolean, value = block) => <AnswerViewPersistenceContext.Provider value={{ scope, sessionId: "session" }}>
|
||||
<AnswerPartsView parts={[{ type: "answer-ui", block: value }]} messageId="message" disabled={false} streaming={!persist} streamDone={persist} persist={persist} />
|
||||
</AnswerViewPersistenceContext.Provider>;
|
||||
return { scope, client, view, restore: (record: AnswerViewStateRecord) => restore(record) };
|
||||
}
|
||||
|
||||
it("keeps the same form DOM and selection when persistence starts, preserving edits against late restore", async () => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture();
|
||||
const rendered = render(f.view(false));
|
||||
const input = screen.getByLabelText("现场记录") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "本地草稿" } });
|
||||
input.focus(); input.setSelectionRange(1, 3);
|
||||
rendered.rerender(f.view(false, structuredClone(block)));
|
||||
expect(screen.getByLabelText("现场记录")).toBe(input);
|
||||
expect(f.client.getAnswerViewState).not.toHaveBeenCalled();
|
||||
rendered.rerender(f.view(true, structuredClone(block)));
|
||||
expect(screen.getByLabelText("现场记录")).toBe(input);
|
||||
expect(input.value).toBe("本地草稿");
|
||||
expect(input.selectionStart).toBe(1);
|
||||
expect(input.selectionEnd).toBe(3);
|
||||
expect(f.client.getAnswerViewState).toHaveBeenCalledTimes(1);
|
||||
await act(async () => f.restore(remote));
|
||||
expect(input.value).toBe("本地草稿");
|
||||
await act(async () => vi.advanceTimersByTimeAsync(500));
|
||||
expect(f.client.putAnswerViewState.mock.calls[0][1]).toMatchObject({ expected_revision: 4, nodes: { form: { values: { note: "本地草稿" } } } });
|
||||
rendered.unmount(); f.scope.dispose();
|
||||
});
|
||||
|
||||
it("never reads or writes process blocks even when persistence is requested", async () => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture();
|
||||
const rendered = render(f.view(true, { ...block, phase: "process" }));
|
||||
fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "过程草稿" } });
|
||||
expect(screen.queryByText("正在恢复交互状态")).toBeNull();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(2000));
|
||||
rendered.unmount();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
||||
expect(f.client.getAnswerViewState).not.toHaveBeenCalled();
|
||||
expect(f.client.putAnswerViewState).not.toHaveBeenCalled();
|
||||
f.scope.dispose();
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useAnswerViewPersistence, useAnswerViewState } from "../session-state/use-answer-view-state";
|
||||
import type { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
|
||||
import { StreamingTokenResponse } from "../components/streaming-token-response";
|
||||
import { AnswerUiView, type AnswerUiMapTarget } from "./answer-ui-view";
|
||||
import type { AnswerUiPart } from "./contract";
|
||||
import type { AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
|
||||
export function AnswerPartsView({
|
||||
parts,
|
||||
@@ -9,30 +13,33 @@ export function AnswerPartsView({
|
||||
disabled,
|
||||
streaming,
|
||||
streamDone,
|
||||
persist = false,
|
||||
onLocate,
|
||||
onSubmit,
|
||||
onAsk
|
||||
}: {
|
||||
parts: AnswerUiPart[];
|
||||
messageId: string;
|
||||
disabled: boolean;
|
||||
streaming: boolean;
|
||||
streamDone: boolean;
|
||||
persist?: boolean;
|
||||
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
onAsk?: (selection: AnswerUiSelection) => void;
|
||||
}) {
|
||||
const persistence = useAnswerViewPersistence();
|
||||
return (
|
||||
<div className="space-y-4" data-answer-parts>
|
||||
<div className="flex min-w-0 flex-col gap-5" data-answer-parts>
|
||||
{parts.map((part, index) =>
|
||||
part.type === "answer-ui" ? (
|
||||
<AnswerUiView
|
||||
<PersistableAnswerUiView
|
||||
key={`ui-${part.block.block_id}`}
|
||||
block={part.block}
|
||||
persistence={persistence}
|
||||
active={persist && part.block.phase !== "process"}
|
||||
messageId={messageId}
|
||||
disabled={disabled}
|
||||
onLocate={onLocate}
|
||||
onSubmit={onSubmit}
|
||||
onAsk={onAsk}
|
||||
/>
|
||||
) : (
|
||||
<StreamingTokenResponse
|
||||
@@ -48,3 +55,37 @@ export function AnswerPartsView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
type PersistableProps = ComponentProps<typeof AnswerUiView> & {
|
||||
messageId: string;
|
||||
active: boolean;
|
||||
persistence: { scope: AnswerViewStateScope; sessionId: string } | null;
|
||||
};
|
||||
function PersistableAnswerUiView({ persistence, messageId, active, ...props }: PersistableProps) {
|
||||
if (!persistence) return <AnswerUiView {...props} />;
|
||||
return <SavedAnswerUiView key={`${persistence.scope.scope}:${persistence.sessionId}:${messageId}:${props.block.block_id}`}
|
||||
{...props} messageId={messageId} active={active} persistence={persistence} />;
|
||||
}
|
||||
function SavedAnswerUiView({ persistence, messageId, active, ...props }: PersistableProps & { persistence: NonNullable<PersistableProps["persistence"]> }) {
|
||||
const saved = useAnswerViewState(persistence.scope, persistence.sessionId, messageId, props.block.block_id, props.block.spec, active);
|
||||
const [confirmReload, setConfirmReload] = useState(false);
|
||||
const label = { loading: "正在恢复交互状态", saving: "正在保存", saved: "已保存", unsaved: "未保存", conflict: "保存冲突" }[saved.status];
|
||||
return <div className="space-y-2" data-answer-state={active ? saved.status : undefined}>
|
||||
<AnswerUiView {...props} viewState={saved.state} onViewStateChange={saved.update} />
|
||||
{active ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span role="status">{label}</span>
|
||||
{saved.error ? <span>{saved.error}</span> : null}
|
||||
{saved.status === "unsaved" ? <Button size="sm" variant="ghost" onClick={saved.retry}>重试保存</Button> : null}
|
||||
{saved.status === "conflict" && !confirmReload ? <>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmReload(true)}>重新加载远端</Button>
|
||||
<Button size="sm" variant="ghost" onClick={saved.resave}>保留本地并重新保存</Button>
|
||||
</> : null}
|
||||
{confirmReload ? <div role="alert" className="flex flex-wrap items-center gap-2">
|
||||
<span>重新加载将替换本块的本地筛选与表单草稿。</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setConfirmReload(false); saved.reload(); }}>确认替换本地草稿</Button>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="取消重新加载" onClick={() => setConfirmReload(false)}><X aria-hidden /></Button>
|
||||
</div> : null}
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -9,9 +9,6 @@ export type AnswerUiInteractions = {
|
||||
disabled: boolean;
|
||||
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,45 +0,0 @@
|
||||
import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
import { useContext, useState } from "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 }) {
|
||||
const { onAsk, disabled } = useContext(AnswerInteractionContext);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
if (!onAsk) return null;
|
||||
return <div className={compact ? "answer-ui-metric-ask" : undefined}><Button type="button"
|
||||
size={compact ? "icon-sm" : "sm"} variant="ghost"
|
||||
className={compact ? "text-muted-foreground" : "h-9 gap-1.5 text-xs text-muted-foreground"}
|
||||
aria-label={compact ? `针对${title}提问` : undefined}
|
||||
title={compact ? `针对${title}提问` : undefined}
|
||||
disabled={disabled || running} onClick={async () => {
|
||||
setRunning(true); setError(null);
|
||||
try { await onAsk({ title, text }); }
|
||||
catch (error) { setError(error instanceof Error ? error.message : "引用暂未带入对话,请重试。"); }
|
||||
finally { setRunning(false); }
|
||||
}}>
|
||||
<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>;
|
||||
}
|
||||
@@ -43,24 +43,22 @@ describe("controlled native answer state", () => {
|
||||
expect(screen.getByLabelText("其他资料")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("keeps chart data-tab search across data refresh and clears row selection without remounting", () => {
|
||||
it("keeps chart data-tab search across data refresh 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.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "数据" })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("restores chart data selections atomically when a delayed snapshot changes hidden series and range", () => {
|
||||
it("restores chart search and sort while discarding legacy row selection", () => {
|
||||
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} />);
|
||||
@@ -68,15 +66,15 @@ describe("controlled native answer state", () => {
|
||||
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.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
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.
|
||||
// A subsequent deliberate series change clears the derived table sort.
|
||||
fireEvent.click(screen.getByRole("button", { name: "出口" }));
|
||||
expect(screen.getByRole("checkbox", { name: "选择第 2 行" })).not.toBeChecked();
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
|
||||
expect(changed).toHaveBeenLastCalledWith(expect.anything(), ["/nodes/node/hidden", "/nodes/node/table/sort", "/nodes/node/table/selection"]);
|
||||
expect(changed).toHaveBeenLastCalledWith(expect.anything(), ["/nodes/node/hidden", "/nodes/node/table/sort"]);
|
||||
});
|
||||
|
||||
it("preserves form drafts during streaming repeats and drops a draft when the field kind changes", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AnswerUiView } from "./answer-ui-view";
|
||||
@@ -33,6 +33,29 @@ function taskBlock(includeData = true) {
|
||||
return parsed;
|
||||
}
|
||||
describe("local interactive answer renderer", () => {
|
||||
it("shows navigation for long answers while keeping short metric and evidence replies quiet", () => {
|
||||
const fixture = taskBlock(false);
|
||||
fixture.spec.elements.root.children = ["metric", "evidence"];
|
||||
delete fixture.spec.elements.form;
|
||||
const { rerender } = render(<AnswerUiView block={fixture} />);
|
||||
expect(screen.getByRole("group", { name: "当前压力" })).toBeVisible();
|
||||
expect(screen.queryByRole("navigation", { name: "回答内容导航" })).not.toBeInTheDocument();
|
||||
const longer = structuredClone(fixture);
|
||||
longer.spec.elements.evidence.props.summary = "待核对的监测资料。".repeat(150);
|
||||
rerender(<AnswerUiView block={longer} />);
|
||||
expect(screen.getByRole("navigation", { name: "回答内容导航" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("uses a single form and evidence card's own headings without fixed wrapper instructions", () => {
|
||||
render(<AnswerUiView block={taskBlock()} onSubmit={vi.fn()} />);
|
||||
expect(screen.getByLabelText("现场记录(必填)")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: /读数依据/ })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "收起补充资料" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "收起核查依据" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("直接探索已加载数据")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("区分记录、推断与用户资料")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adapts response and report composition without replacing a reader's form draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fixture = taskBlock();
|
||||
@@ -68,38 +91,33 @@ describe("local interactive answer renderer", () => {
|
||||
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 () => {
|
||||
it("shows data and evidence without row selection, quoting or analysis controls", 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: "将所选行加入分析" }));
|
||||
render(<AnswerUiView block={taskBlock()} onSubmit={submit} />);
|
||||
expect(screen.getByRole("group", { name: "当前压力" })).toHaveTextContent("0.2130MPa");
|
||||
expect(screen.getByRole("table")).toHaveTextContent("北区入口");
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
|
||||
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);
|
||||
await waitFor(() => expect(screen.getByText("仅用于演练")).toBeVisible());
|
||||
expect(screen.getByText("DEMO-26")).toBeVisible();
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "分析内容篮" })).not.toBeInTheDocument();
|
||||
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 () => {
|
||||
it("keeps the same form 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 { rerender } = render(<AnswerUiView block={taskBlock(false)} 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()} />);
|
||||
const grouped = taskBlock();
|
||||
grouped.spec.elements.root.children = [...(grouped.spec.elements.root.children ?? []), "extra-form"];
|
||||
grouped.spec.elements["extra-form"] = { ...grouped.spec.elements.form, props: {
|
||||
title: "其他资料", description: "可选补充", fields: [{ name: "extra", label: "其他记录", kind: "text", required: false }]
|
||||
} };
|
||||
rerender(<AnswerUiView block={grouped} onSubmit={vi.fn()} />);
|
||||
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
|
||||
expect(editor).toHaveValue("未发送的现场草稿");
|
||||
await user.click(screen.getByRole("button", { name: "收起补充资料" }));
|
||||
@@ -108,25 +126,22 @@ describe("local interactive answer renderer", () => {
|
||||
await user.click(screen.getByRole("button", { name: "展开补充资料" }));
|
||||
await waitFor(() => expect(editor).toBeVisible());
|
||||
expect(editor).toHaveValue("未发送的现场草稿");
|
||||
expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容");
|
||||
await user.click(screen.getByRole("button", { name: "收起补充资料" }));
|
||||
const updated = taskBlock();
|
||||
updated.spec.elements.table.props.rows = [["新增入口", "0.24"]];
|
||||
rerender(<AnswerUiView block={updated} onAsk={ask} onSubmit={vi.fn()} />);
|
||||
rerender(<AnswerUiView block={updated} onSubmit={vi.fn()} />);
|
||||
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
|
||||
await waitFor(() => expect(editor).toBeVisible());
|
||||
expect(editor).toHaveValue("未发送的现场草稿");
|
||||
});
|
||||
|
||||
it("rejects oversized row quotes without truncating values or removing existing selections", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("preserves long original cell values without requiring a selection or quote action", () => {
|
||||
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();
|
||||
render(<AnswerUiView block={fixture} />);
|
||||
expect(screen.getAllByText("长".repeat(800))).toHaveLength(12);
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expresses supplied context and cautions as readable notes without announcing historical alerts", () => {
|
||||
@@ -143,16 +158,14 @@ describe("local interactive answer renderer", () => {
|
||||
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();
|
||||
it("retains a metric's exact value, unit and source without action buttons", () => {
|
||||
render(<AnswerUiView block={block("Metric", {
|
||||
label: "当前压力", value: "0.3000", unit: "MPa", source: "已加载记录",
|
||||
})} onAsk={ask} />);
|
||||
await user.click(screen.getByRole("button", { name: "针对当前压力提问" }));
|
||||
expect(ask).toHaveBeenCalledWith({
|
||||
title: "当前压力", text: "当前压力:0.3000MPa\n来源:已加载记录\n引用内容块:test",
|
||||
});
|
||||
})} />);
|
||||
const metric = screen.getByRole("group", { name: "当前压力" });
|
||||
expect(metric).toHaveTextContent("0.3000MPa");
|
||||
expect(metric).toHaveTextContent("来源:已加载记录");
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
it("renders through json-render and lets readers inspect exact chart data with keyboard tabs", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -199,22 +212,21 @@ describe("local interactive answer renderer", () => {
|
||||
feature_ids: ["DEMO-P001", "DEMO-P002"]
|
||||
});
|
||||
});
|
||||
it("filters and sorts loaded rows, then quotes the selected original values without sending", async () => {
|
||||
it("filters and sorts loaded rows without selecting or sending content", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ask = vi.fn();
|
||||
const submit = vi.fn();
|
||||
render(<AnswerUiView block={block("Table", {
|
||||
title: "监测记录", columns: ["监测点", "压力"],
|
||||
rows: [["南站", "0.32"], ["北站", "0.21"], ["南站二号", "0.26"]]
|
||||
})} onAsk={ask} onSubmit={submit} />);
|
||||
})} onSubmit={submit} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "搜索监测记录" }), "南站");
|
||||
expect(screen.getByText("2 / 3 行")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "按压力排序" }));
|
||||
const rows = screen.getAllByRole("row");
|
||||
expect(rows[1]).toHaveTextContent("南站二号");
|
||||
await user.click(screen.getByRole("checkbox", { name: "选择第 3 行" }));
|
||||
await user.click(screen.getByRole("button", { name: "针对这部分提问" }));
|
||||
expect(ask).toHaveBeenCalledWith({ title: "监测记录", text: expect.stringContaining("监测点:南站二号;压力:0.26") });
|
||||
expect(rows[1]).toHaveTextContent("0.26");
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps form values after a failed submission", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 {
|
||||
@@ -15,22 +14,20 @@ import {
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Card, CardContent } 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 { AnswerResult } from "./answer-result";
|
||||
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget, type AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions } 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: baseRegistry } = defineRegistry(answerUiCatalog, {
|
||||
actions: {},
|
||||
@@ -86,10 +83,6 @@ const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
|
||||
{props.source ? (
|
||||
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
|
||||
) : null}
|
||||
<div className="answer-ui-metric-actions flex items-center">
|
||||
<SelectForAnalysis compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
|
||||
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
@@ -119,43 +112,25 @@ function AnswerUiBody({
|
||||
disabled = false,
|
||||
onLocate,
|
||||
onSubmit,
|
||||
onAsk,
|
||||
submitMode = "send"
|
||||
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
|
||||
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 isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
|
||||
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
|
||||
return (
|
||||
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter,
|
||||
onAsk: ask, selections,
|
||||
onToggleSelection: onAsk ? (selection) => {
|
||||
setSelectionError(null);
|
||||
const alreadySelected = selections.some((item) => item.title === selection.title && item.text === selection.text);
|
||||
if (alreadySelected) {
|
||||
setSelections((current) => current.filter((item) => item.title !== selection.title || item.text !== selection.text));
|
||||
return;
|
||||
}
|
||||
const next = [...selections, selection];
|
||||
if (next.length > 8 || getSelectionText(next).length > ANSWER_SELECTION_LIMIT) {
|
||||
setSelectionError("一次最多选择 8 项、8000 字内容。请减少所选数据行,或移除已有内容后重试。");
|
||||
return;
|
||||
}
|
||||
setSelections(next);
|
||||
} : undefined }}>
|
||||
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter }}>
|
||||
<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}>
|
||||
@@ -163,35 +138,30 @@ function AnswerUiBody({
|
||||
{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 />}>
|
||||
{taskLayout.evidence.length ? <TaskSupport grouped={isGroup(taskLayout.evidence)} title="核查依据" 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 />}>
|
||||
{taskLayout.followUp.length ? <TaskSupport grouped={isGroup(taskLayout.followUp)} title="补充资料" 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 }) {
|
||||
function TaskSupport({ title, icon, children, grouped }: { title: string; icon: ReactNode; children: ReactNode; grouped: 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>
|
||||
return <AnswerDisclosure open={grouped ? open : true} onOpenChange={setOpen} className="answer-task-support-section" data-grouped={grouped || undefined}
|
||||
contentClassName={cn("answer-task-support-content space-y-2.5", grouped && "pt-2")}
|
||||
trigger={<Button type="button" variant="ghost" hidden={!grouped} className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !grouped && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
|
||||
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}</span>
|
||||
<AnswerDisclosureChevron open={open} />
|
||||
</Button>}>{children}</AnswerDisclosure>;
|
||||
}
|
||||
@@ -256,10 +226,6 @@ function EvidenceCard(props: {
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<SelectForAnalysis title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
|
||||
<AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
|
||||
</div>
|
||||
</AnswerDisclosure>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const table = (rows = [["a", "1"], ["b", "2"]], 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", () => {
|
||||
it("ignores legacy row selection, 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: [] });
|
||||
@@ -37,7 +37,7 @@ describe("answer view state compatibility", () => {
|
||||
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" } });
|
||||
expect(result).toEqual({ nodes: { node: { type: "Table", query: "查".repeat(2000), sort: null, selection: [] } }, block: { evidenceFilter: "all" } });
|
||||
});
|
||||
|
||||
it("tracks form and nested chart-table edits at field granularity", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
// `selection` is an empty legacy v1 wire field, never an interactive row selection.
|
||||
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> };
|
||||
@@ -39,11 +40,10 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
|
||||
}
|
||||
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) };
|
||||
selection: [] };
|
||||
}
|
||||
if (node.type === "FollowUpForm") {
|
||||
const values = record(state.values);
|
||||
@@ -74,8 +74,7 @@ export function migrateAnswerViewState(previous: Spec, next: Spec, input: Answer
|
||||
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 (!same(before.props.columns, after.props.columns)) value.sort = null;
|
||||
}
|
||||
if (value.type === "FollowUpForm") {
|
||||
const fields = before.props.fields as Array<{ name: string; kind: string }>;
|
||||
@@ -98,7 +97,6 @@ export function migrateAnswerViewState(previous: Spec, next: Spec, input: Answer
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,30 @@ const block = {
|
||||
}
|
||||
};
|
||||
describe("untrusted answer components", () => {
|
||||
it("keeps explicit UI phases and rejects invalid phase values", () => {
|
||||
expect(parseAnswerUiBlock(block)?.phase).toBeUndefined();
|
||||
expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process");
|
||||
expect(parseAnswerUiBlock({ ...block, phase: "final" })?.phase).toBe("final");
|
||||
expect(parseAnswerUiBlock({ ...block, phase: "other" })).toBeNull();
|
||||
expect(parseAnswerUiBlock({ ...block, phase: null })).toBeNull();
|
||||
});
|
||||
it("accepts controlled task semantics and result states without accepting arbitrary presentation code", () => {
|
||||
const result = { type: "Result", props: { title: "定位结果", summary: "3 根管道已定位,高亮未确认。", status: "partial", source: "地图工具回执" }, children: [] };
|
||||
const spec = {
|
||||
root: "root", elements: {
|
||||
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "response", density: "compact" }, children: ["section"] },
|
||||
section: { type: "Section", props: { title: "本次结果", description: null, role: "result" }, children: ["result"] },
|
||||
result
|
||||
}
|
||||
};
|
||||
expect(parseAnswerUiBlock({ ...block, spec })).not.toBeNull();
|
||||
for (const props of [
|
||||
{ ...result.props, status: "running" },
|
||||
{ ...result.props, css: "display:none" },
|
||||
{ ...result.props, onClick: "fetch('/write')" }
|
||||
]) expect(parseAnswerUiBlock({ ...block, spec: { ...spec, elements: { ...spec.elements, result: { ...result, props } } } })).toBeNull();
|
||||
expect(parseAnswerUiBlock({ ...block, spec: { root: "note", elements: { note: { ...block.spec.elements.note, props: { text: "高亮操作未提供。", tone: "warning", title: "能力限制" } } } } })).not.toBeNull();
|
||||
});
|
||||
it("accepts registered text and rejects arbitrary code, URLs and unknown components", () => {
|
||||
expect(parseAnswerUiBlock(block)).not.toBeNull();
|
||||
for (const node of [
|
||||
|
||||
@@ -28,7 +28,7 @@ export const storedAnswerUiBlockSchema = z.object({
|
||||
export const reportUiBlocksSchema = z.array(z.object({
|
||||
block: storedAnswerUiBlockSchema,
|
||||
evidence_refs: z.array(z.string().min(1).max(128)).min(1).max(100)
|
||||
}).strict()).max(8);
|
||||
}).strict().refine((item) => item.block.phase !== "process", "过程界面不能发布为正式报告")).max(8);
|
||||
export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number];
|
||||
|
||||
export type AnswerUiBlock = {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
|
||||
import { InteractiveDataTable } from "./interactive-data-table";
|
||||
import { agentEase } from "../components/agent-motion";
|
||||
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
|
||||
|
||||
export type AnswerChartProps = {
|
||||
title: string;
|
||||
@@ -55,7 +54,7 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
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 setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]);
|
||||
const setPoint = (point: number) => patch({ point });
|
||||
const setInspecting = (inspecting: boolean) => patch({ inspecting });
|
||||
const visible = props.series.filter((_, index) => !hidden.includes(index));
|
||||
@@ -66,8 +65,6 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
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: animateInteraction && !reducedMotion,
|
||||
animationDuration: 180,
|
||||
@@ -115,7 +112,7 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)}
|
||||
disabled={visible.length === 1 && !hidden.includes(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"]); }}>
|
||||
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
|
||||
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
|
||||
</Button>)}
|
||||
</div>
|
||||
@@ -145,7 +142,7 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
}
|
||||
if (event.key === "Escape") setInspecting(false);
|
||||
}}>
|
||||
<span className="sr-only">点击图中数据点查看详情并追问,也可使用回车和左右方向键。</span>
|
||||
<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") {
|
||||
@@ -157,15 +154,13 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
|
||||
<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} />
|
||||
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
{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>
|
||||
<div className="space-y-2">
|
||||
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
|
||||
@@ -191,12 +186,6 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
<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>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span>
|
||||
|
||||
@@ -2,27 +2,21 @@ 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 { AnswerReveal } from "./answer-motion";
|
||||
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
|
||||
|
||||
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;
|
||||
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange }: {
|
||||
title: string; columns: string[]; rows: string[][];
|
||||
state?: Omit<AnswerTableState, "type">;
|
||||
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
|
||||
}) {
|
||||
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 { query, sort } = state;
|
||||
const filtered = useMemo(() => {
|
||||
const items = rows.map((cells, id) => ({ cells, id }))
|
||||
.filter(({ cells }) => cells.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
@@ -35,12 +29,10 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
|
||||
});
|
||||
return items;
|
||||
}, [rows, sort, query]);
|
||||
const selected = filtered.filter(({ id }) => selection.includes(id));
|
||||
const quote = selected.map(({ cells }) => cells.map((cell, index) => `${columns[index]}:${cell}`).join(";")).join("\n");
|
||||
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) => { patch({ query: event.currentTarget.value, selection: [] }); }} />
|
||||
onChange={(event) => { patch({ query: event.currentTarget.value }); }} />
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
|
||||
</div>
|
||||
<div role="region" aria-label={title} tabIndex={0}
|
||||
@@ -48,9 +40,6 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
|
||||
<Table>
|
||||
<TableCaption className="caption-top font-medium">{title}</TableCaption>
|
||||
<TableHeader className="bg-muted/50"><TableRow>
|
||||
<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}排序`}
|
||||
@@ -62,26 +51,15 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
|
||||
</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)} disabled={!selection.includes(id) && selection.length >= 100}
|
||||
onChange={(event) => setSelection(event.target.checked ? [...selection, id] : selection.filter((value) => value !== id))} /></TableCell>
|
||||
{filtered.map(({ cells, id }) => <TableRow key={id}>
|
||||
{cells.map((cell, index) => <TableCell key={index}
|
||||
className={cn("min-w-24", /^[-+]?\d+(\.\d+)?%?$/.test(cell) && "text-right tabular-nums")}>{cell}</TableCell>)}
|
||||
</TableRow>)}
|
||||
{!filtered.length ? <TableRow><TableCell colSpan={columns.length + 1} className="py-6 text-center text-xs text-muted-foreground">
|
||||
{!filtered.length ? <TableRow><TableCell colSpan={columns.length} className="py-6 text-center text-xs text-muted-foreground">
|
||||
{rows.length ? "当前筛选没有匹配的数据。" : "没有已加载的数据。"}
|
||||
</TableCell></TableRow> : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user