fix(workbench): stabilize investigation presentation rendering
Keep investigation graphs stable during updates, defer expensive chart rendering, and prevent stale results when switching tasks. Adopt reference annotations and consolidate frontend documentation.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Check, FileText, X } from "lucide-react";
|
||||
import { AgentChartView } from "@/features/agent";
|
||||
import { DeferredRender } from "@/shared/ui/deferred-render";
|
||||
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
@@ -82,13 +83,13 @@ export function InvestigationChartGroup({ group, reviewing, onCloseChart, refere
|
||||
{tile && selected.command.evidence_refs.length ? <Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`查看图表依据 ${title}`} title="查看图表依据"><FileText size={14} aria-hidden /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto p-2">{references(selected.command.evidence_refs)}</PopoverContent>
|
||||
</Popover> : null}
|
||||
<FloatingWindow title={title} fillContent>{() => content(true)}</FloatingWindow>
|
||||
<FloatingWindow title={title} fillContent>{() => <DeferredRender label={`正在加载图表 ${title}`} className="h-full min-h-0">{content(true)}</DeferredRender>}</FloatingWindow>
|
||||
<Button type="button" variant="ghost" size="icon-sm" disabled={reviewing} aria-label={`关闭图表 ${title}`}
|
||||
onClick={() => group.items.forEach((item) => onCloseChart(item.command.chart_id))}><X size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={tile ? "flex min-h-0 flex-1 flex-col px-2 pb-2 pt-0" : "px-3 pb-2 pt-0"}>
|
||||
{content(tile, tile)}
|
||||
<DeferredRender label={`正在加载图表 ${title}`} className={tile ? "h-full min-h-0" : undefined}>{content(tile, tile)}</DeferredRender>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
|
||||
import { cloneElement, isValidElement, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type SVGProps } from "react";
|
||||
import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
|
||||
import { MessageResponse } from "@/shared/ai-elements/message";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { LoadingSkeleton } from "@/shared/ui/deferred-render";
|
||||
import type { InvestigationDiagram, InvestigationMapTarget } from "../api/investigation-report";
|
||||
import { InvestigationExpandCard } from "./investigation-expand-card";
|
||||
import {
|
||||
@@ -48,34 +49,62 @@ function DiagramUnavailable() {
|
||||
export function InvestigationDiagramView({
|
||||
diagram,
|
||||
renderDetails,
|
||||
running = false,
|
||||
onLocate
|
||||
}: {
|
||||
diagram: InvestigationDiagram;
|
||||
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
|
||||
running?: boolean;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [hasRendered, setHasRendered] = useState(false);
|
||||
const source = styleInvestigationDiagram(diagram);
|
||||
const latestSource = useRef({ source, revision: 0 });
|
||||
if (latestSource.current.source !== source) latestSource.current = { source, revision: latestSource.current.revision + 1 };
|
||||
const components = useMemo(() => ({ pre: ({ children }: { children?: ReactNode }) => {
|
||||
if (!isValidElement<{ className?: string; "data-block"?: string }>(children)) return children;
|
||||
// Streamdown memoizes code by class and source position. A same-line edit
|
||||
// needs a new revision class to update the mounted Mermaid without a reset.
|
||||
return cloneElement(children, { "data-block": "true",
|
||||
className: `${children.props.className ?? ""} investigation-diagram-revision-${latestSource.current.revision}` });
|
||||
} }), []);
|
||||
const plugins = useMemo(() => ({ mermaid: {
|
||||
...investigationDiagramPlugins.mermaid,
|
||||
getMermaid(config?: Parameters<typeof investigationDiagramPlugins.mermaid.getMermaid>[0]) {
|
||||
const renderer = investigationDiagramPlugins.mermaid.getMermaid(config);
|
||||
return { ...renderer, async render(id: string, nextSource: string) {
|
||||
const result = await renderer.render(id, nextSource);
|
||||
// Keep the mounted graph during refresh without allowing a late layout
|
||||
// to overwrite a newer source (including an immediately cached result).
|
||||
// Markdown code blocks append a newline to their text content.
|
||||
if (nextSource.trimEnd() !== latestSource.current.source.trimEnd()) throw new Error("调查图示已更新,忽略旧排版结果");
|
||||
return result;
|
||||
} };
|
||||
}
|
||||
} }), []);
|
||||
const diagramRoot = useRef<HTMLDivElement>(null);
|
||||
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
|
||||
const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId);
|
||||
const displayedStates = [...new Set(diagram.node_refs.map((node) => investigationNodeState(node, running)))];
|
||||
if (!displayedStates.length) displayedStates.push("unknown");
|
||||
const displayedStates = [...new Set(diagram.node_refs.map((node) => investigationNodeState(node)))];
|
||||
if (!displayedStates.length) displayedStates.push("reference");
|
||||
const [fontsReady, setFontsReady] = useState(
|
||||
() => typeof document === "undefined" || !document.fonts
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const root = diagramRoot.current;
|
||||
if (!root) return;
|
||||
const paint = () => paintInvestigationDiagramStates(root, diagram, running);
|
||||
const paint = () => {
|
||||
paintInvestigationDiagramStates(root, diagram);
|
||||
if (root.querySelector("[data-investigation-renderer] svg, [data-investigation-renderer] [role=status]")) {
|
||||
setHasRendered(true);
|
||||
}
|
||||
};
|
||||
paint();
|
||||
// Mermaid renders asynchronously. Reapply the latest snapshot when it
|
||||
// replaces the SVG; observing children avoids reacting to our own styles.
|
||||
const observer = new MutationObserver(paint);
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}, [diagram, running]);
|
||||
}, [diagram]);
|
||||
useEffect(() => {
|
||||
if (!document.fonts) return;
|
||||
let active = true;
|
||||
@@ -92,11 +121,11 @@ export function InvestigationDiagramView({
|
||||
const content = (close: (restoreFocus?: boolean) => void) => (
|
||||
<>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
|
||||
调查路线与分支参考,标记来自最近整理,不实时同步执行状态。点击节点查看说明与依据。
|
||||
</p>
|
||||
<div
|
||||
ref={diagramRoot}
|
||||
className="investigation-diagram mt-3"
|
||||
className="investigation-diagram relative mt-3"
|
||||
onPointerDownCapture={(event) => {
|
||||
const node = (event.target as Element).closest?.(".node");
|
||||
const ref = diagram.node_refs.find((item) => getNodeId(node) === item.node_id);
|
||||
@@ -116,11 +145,14 @@ export function InvestigationDiagramView({
|
||||
if (ref) setSelectedNodeId(ref.node_id);
|
||||
}}
|
||||
>
|
||||
{!hasRendered ? <LoadingSkeleton label="正在加载调查流程图" className="border-0" /> : null}
|
||||
<div data-investigation-renderer="" aria-hidden={!hasRendered || undefined}
|
||||
className={hasRendered ? undefined : "pointer-events-none absolute inset-x-0 top-0 opacity-0"}>
|
||||
{fontsReady ? (
|
||||
<MessageResponse
|
||||
key={diagram.mermaid}
|
||||
icons={diagramControlIcons}
|
||||
plugins={investigationDiagramPlugins}
|
||||
plugins={plugins}
|
||||
components={components}
|
||||
mermaid={{
|
||||
errorComponent: DiagramUnavailable,
|
||||
config: investigationDiagramConfig
|
||||
@@ -128,10 +160,9 @@ export function InvestigationDiagramView({
|
||||
controls={{
|
||||
mermaid: { fullscreen: false, download: false, copy: false, panZoom: true }
|
||||
}}
|
||||
>{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram, running)}\n\`\`\``}</MessageResponse>
|
||||
) : (
|
||||
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
|
||||
)}
|
||||
>{`\`\`\`mermaid\n${source}\n\`\`\``}</MessageResponse>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
|
||||
@@ -165,7 +196,7 @@ export function InvestigationDiagramView({
|
||||
}
|
||||
>
|
||||
{node.label}
|
||||
<span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node, running)].label}</span>
|
||||
<span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node)].label}</span>
|
||||
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
|
||||
</button>
|
||||
))}
|
||||
@@ -177,7 +208,8 @@ export function InvestigationDiagramView({
|
||||
aria-label={`节点详情 ${selected.label}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected, running)].label}</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected)].label}</p>
|
||||
{selected.detail ? <p className="whitespace-pre-wrap break-words text-xs leading-5 text-[var(--ink-muted)]">{selected.detail}</p> : null}
|
||||
{renderDetails(selected)}
|
||||
{onLocate && selected.map_targets?.length ? <Button size="sm" variant="outline" onClick={() => { close(false); onLocate(selected.map_targets!); }}>在地图查看</Button> : null}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AnswerUiBlock } from "@/features/agent";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import { LoadingSkeleton } from "@/shared/ui/deferred-render";
|
||||
import {
|
||||
PresentationItem,
|
||||
PresentationViewport,
|
||||
@@ -211,6 +212,9 @@ export function InvestigationPresentationPanel({
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!reviewing && !localPreview && status === "connecting" && !charts.length && !blocks.length ? (
|
||||
<LoadingSkeleton label="正在加载调查展示" />
|
||||
) : null}
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{cameraPaused ? (
|
||||
<PresentationItem key="camera" immediate={immediate}>
|
||||
@@ -318,6 +322,7 @@ export function InvestigationPresentationPanel({
|
||||
!overlay &&
|
||||
!blocks.length &&
|
||||
!records.length &&
|
||||
status !== "connecting" &&
|
||||
!relatedLayers.length ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
当前没有展示结果,可切换历史动作回看地图与结果。
|
||||
|
||||
@@ -181,10 +181,11 @@ export function InvestigationTaskReport({
|
||||
// progress snapshot owns its whole graph, including an explicit null. A prior
|
||||
// round's final report must never hide the new round's progress.
|
||||
const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
|
||||
const savedDiagram = task.latest_progress?.progress.diagram;
|
||||
const cumulativeDiagram = savedDiagram?.edges != null ? savedDiagram : null;
|
||||
const diagram = live
|
||||
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
|
||||
? cumulativeDiagram ?? (progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot))
|
||||
: selection.diagram;
|
||||
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
|
||||
const latestAvailable = !live;
|
||||
const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
|
||||
const pendingActions = task.actions.filter(isPendingInvestigationAction);
|
||||
@@ -531,7 +532,6 @@ export function InvestigationTaskReport({
|
||||
<InvestigationDiagramView
|
||||
key={live ? `live:${task.task_id}` : `report:${snapshot?.revision}`}
|
||||
diagram={diagram}
|
||||
running={diagramRunning}
|
||||
onLocate={onLocate}
|
||||
renderDetails={(node) => (
|
||||
<>
|
||||
|
||||
@@ -99,8 +99,9 @@ export function PresentationHistorySwitcher({
|
||||
</div>
|
||||
{playback ? (
|
||||
<div className="flex min-w-0 items-center gap-2" role="group" aria-label="历史播放">
|
||||
<Button type="button" variant="outline" size="icon-sm" className="size-8 shrink-0"
|
||||
<Button type="button" variant="ghost" size="icon-sm" className="size-8 shrink-0 text-muted-foreground after:inset-0"
|
||||
aria-label={playback.playing ? "暂停历史播放" : "播放历史"}
|
||||
title={playback.playing ? "暂停历史播放" : "播放历史"}
|
||||
disabled={!playable.length} onClick={playback.onToggle}>
|
||||
{playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />}
|
||||
</Button>
|
||||
@@ -113,8 +114,11 @@ export function PresentationHistorySwitcher({
|
||||
{selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length}
|
||||
</span>
|
||||
<Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}>
|
||||
<SelectTrigger aria-label="历史播放速度" className="h-8 w-[68px] shrink-0 gap-1 px-2 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent position="popper" className="workbench-shadcn-surface z-[90]">
|
||||
<SelectTrigger aria-label="历史播放速度"
|
||||
className="form-field-inset h-8 w-[54px] shrink-0 justify-center gap-1 border-0 px-1.5 text-xs tabular-nums text-muted-foreground shadow-none hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground [&>svg]:size-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end" sideOffset={4} className="workbench-shadcn-surface z-[90] min-w-[72px]">
|
||||
{[0.5, 1, 2].map((speed) => <SelectItem key={speed} value={String(speed)} className="text-xs">{speed}×</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -216,18 +216,7 @@ export function ConditionDetailPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={detailKey}
|
||||
className="relative h-full min-h-0"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div key={detailKey} className="relative h-full min-h-0">{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user