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:
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const investigationNodeAnnotations = ["reference", "recorded", "needs_input", "failed", "not_taken"] as const;
|
||||
export type InvestigationNodeAnnotation = (typeof investigationNodeAnnotations)[number];
|
||||
const legacyAnnotations = {
|
||||
pending: "reference", running: "reference", completed: "recorded",
|
||||
waiting: "needs_input", waiting_human: "needs_input", skipped: "not_taken", cancelled: "not_taken"
|
||||
} as const satisfies Record<string, InvestigationNodeAnnotation>;
|
||||
|
||||
export function normalizeInvestigationNodeAnnotation(status: string | null | undefined): InvestigationNodeAnnotation {
|
||||
if (status == null) return "reference";
|
||||
if (status in legacyAnnotations) return legacyAnnotations[status as keyof typeof legacyAnnotations];
|
||||
return investigationNodeAnnotations.includes(status as InvestigationNodeAnnotation) ? status as InvestigationNodeAnnotation : "reference";
|
||||
}
|
||||
|
||||
/** Decode saved diagrams without rewriting historical documents. */
|
||||
export const investigationNodeAnnotationReadSchema = z.enum([
|
||||
...investigationNodeAnnotations, "pending", "running", "completed", "waiting", "waiting_human", "skipped", "cancelled"
|
||||
]).nullish().transform((status) => status == null ? status : normalizeInvestigationNodeAnnotation(status));
|
||||
@@ -59,14 +59,24 @@ describe("investigation report read contract", () => {
|
||||
expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]);
|
||||
expect(value.data_gaps?.[0]?.node_id).toBeNull();
|
||||
});
|
||||
it("distinguishes waiting for dependencies from human feedback without inventing missing states", () => {
|
||||
it("reads legacy waits as saved input conditions without inferring live waiting", () => {
|
||||
const value = investigationReportSchema.parse({ ...base, diagram: {
|
||||
title: "等待结果", mermaid: "flowchart LR\nA[外部回执] --> B[人工复核] --> C[下一步]",
|
||||
node_refs: [{ node_id: "A", label: "外部回执", status: "waiting" },
|
||||
{ node_id: "B", label: "人工复核", status: "waiting_human" },
|
||||
{ node_id: "C", label: "下一步" }]
|
||||
} });
|
||||
expect(value.diagram?.node_refs.map((node) => node.status)).toEqual(["waiting", "waiting_human", undefined]);
|
||||
expect(value.diagram?.node_refs.map((node) => node.status)).toEqual(["needs_input", "needs_input", undefined]);
|
||||
});
|
||||
it("reads host-generated cumulative graphs with failed attempts and failure reasons", () => {
|
||||
const value = investigationReportSchema.parse({ ...base, diagram: {
|
||||
title: "累计调查脉络", mermaid: 'flowchart TD\nold["首次读取(失败)"] --> retry["重新读取"]',
|
||||
node_refs: [{ node_id: "old", label: "首次读取", status: "failed", detail: "接口超时" },
|
||||
{ node_id: "retry", label: "重新读取", status: "completed" }],
|
||||
edges: [{ source: "old", target: "retry", label: "重试" }]
|
||||
} });
|
||||
expect(value.diagram?.node_refs[0]).toMatchObject({ status: "failed", detail: "接口超时" });
|
||||
expect(value.diagram?.edges).toEqual([{ source: "old", target: "retry", label: "重试" }]);
|
||||
});
|
||||
it.each([
|
||||
{ outcome: "other" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentTaskDiscussionContext, AnswerUiSelection } from "@/features/agent";
|
||||
import { reportUiBlocksSchema } from "@/features/agent";
|
||||
import { investigationNodeAnnotationReadSchema } from "./investigation-node-state";
|
||||
|
||||
export type InvestigationDiscussion = {
|
||||
prompt?: string;
|
||||
@@ -28,13 +29,20 @@ export const investigationDiagramSchema = z
|
||||
!/(?:^|[;\n])\s*click\b/.test(source),
|
||||
"调查图只接受不含指令或回调的 Mermaid 流程描述"
|
||||
),
|
||||
edges: z.array(z.object({
|
||||
source: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
|
||||
target: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
|
||||
label: text.max(200).nullish()
|
||||
}).strict()).max(200).nullish(),
|
||||
source_mermaid: text.max(20000).nullish(),
|
||||
node_refs: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
|
||||
label: text,
|
||||
status: z.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
|
||||
detail: text.max(2000).nullish(),
|
||||
status: investigationNodeAnnotationReadSchema,
|
||||
map_targets: z.array(investigationMapTargetSchema).max(100).optional(),
|
||||
evidence_refs: z.array(text).default([]),
|
||||
action_refs: z.array(z.string().uuid()).default([])
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("operational task contract", () => {
|
||||
const progress = { revision: 1, run_id: id, execution_generation: 2, published_at: time, source_session_id: "session",
|
||||
progress: { summary: "正在核对投诉时段记录", diagram: { title: "当前核查", mermaid: "flowchart TD\nA[读取记录]", node_refs: [{ node_id: "A", label: "读取记录", status: "running", map_targets: [{ source_id: "scada", feature_id: "P1" }] }] } } };
|
||||
const parsed = parseOperationalTask({ ...task(), latest_plan: null, latest_report: null, latest_progress: progress });
|
||||
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].status).toBe("running");
|
||||
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].status).toBe("reference");
|
||||
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].map_targets?.[0].feature_id).toBe("P1");
|
||||
expect(() => parseOperationalTask({ ...task(), latest_progress: { ...progress, execution_generation: 0 } })).toThrow();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { RefObject } from "react";
|
||||
import { useLayoutEffect, type RefObject } from "react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
@@ -15,6 +15,21 @@ const options = { scope: "preview" as const, enabled: false, task: null, project
|
||||
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it("never commits a previous task's display under the newly selected task scope", async () => {
|
||||
const commits: Array<{ scope: string; records: unknown[] }> = [];
|
||||
const { result, rerender } = renderHook(({ scopeId }) => {
|
||||
const presentation = useInvestigationPresentation({ ...options, scopeId });
|
||||
useLayoutEffect(() => {
|
||||
commits.push({ scope: scopeId, records: presentation.records });
|
||||
});
|
||||
return presentation;
|
||||
}, { initialProps: { scopeId: "task-a" } });
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "old task" }], commands: [] }));
|
||||
expect(result.current.records).toEqual([{ value: "old task" }]);
|
||||
rerender({ scopeId: "task-b" });
|
||||
expect(commits.filter((entry) => entry.scope === "task-b").every((entry) => entry.records.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("restores the last successful result when an unfinished display is aborted", async () => {
|
||||
const { result } = renderHook(() => useInvestigationPresentation(options));
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "saved" }], commands: [] }));
|
||||
|
||||
@@ -120,16 +120,25 @@ export function useInvestigationPresentation(options: Options) {
|
||||
const valuesRef = useRef(options);
|
||||
valuesRef.current = options;
|
||||
const storeRef = useRef(new PresentationViewHistory());
|
||||
const displayScope = useRef(windowScope);
|
||||
if (displayScope.current !== windowScope) {
|
||||
displayScope.current = windowScope;
|
||||
// The first render of a new task must not re-key the previous task's charts.
|
||||
// Keep saved history; effects below still own cancellation and map cleanup.
|
||||
storeRef.current.select(null);
|
||||
storeRef.current.update(emptyPresentationView());
|
||||
}
|
||||
const [, refresh] = useState(0);
|
||||
const publish = useCallback(() => refresh((value) => value + 1), []);
|
||||
const [localPreview, setLocalPreview] = useState(false);
|
||||
const localPreviewRef = useRef(false);
|
||||
const [cameraPaused, setCameraPaused] = useState(false);
|
||||
const cameraPausedRef = useRef(false);
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "live" | "history" | "disconnected" | "error"
|
||||
>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connection, setConnection] = useState<{
|
||||
scope: string;
|
||||
status: "connecting" | "live" | "history" | "disconnected" | "error";
|
||||
error: string | null;
|
||||
}>({ scope: windowScope, status: "disconnected", error: null });
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
|
||||
const activityIdRef = useRef(0);
|
||||
@@ -409,8 +418,9 @@ export function useInvestigationPresentation(options: Options) {
|
||||
clear,
|
||||
stopPulse: () => scene.stopPulse(),
|
||||
onStatus: (next, message) => {
|
||||
setStatus(!valuesRef.current.enabled && next === "live" ? "history" : next);
|
||||
setError(message ?? null);
|
||||
setConnection({ scope: windowScope,
|
||||
status: !valuesRef.current.enabled && next === "live" ? "history" : next,
|
||||
error: message ?? null });
|
||||
},
|
||||
execute: async (command, signal, event) => {
|
||||
await executeCommand(command, signal, "live", event);
|
||||
@@ -434,7 +444,8 @@ export function useInvestigationPresentation(options: Options) {
|
||||
publish,
|
||||
scene,
|
||||
subscribe,
|
||||
taskId
|
||||
taskId,
|
||||
windowScope
|
||||
]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -576,8 +587,8 @@ export function useInvestigationPresentation(options: Options) {
|
||||
evidence: visible.evidence,
|
||||
highlightLayers: visible.highlightLayers,
|
||||
cameraPaused: reviewing ? false : cameraPaused,
|
||||
status,
|
||||
error,
|
||||
status: connection.scope === windowScope ? connection.status : taskId ? "connecting" as const : "disconnected" as const,
|
||||
error: connection.scope === windowScope ? connection.error : null,
|
||||
activity,
|
||||
localPreview,
|
||||
hasHistory: taskEntries.length > 0,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { investigationDiagramSchema } from "../api/investigation-report";
|
||||
import { styleInvestigationDiagram } from "./investigation-diagram-style";
|
||||
|
||||
describe("investigation diagrams as process references", () => {
|
||||
it("reads a legacy running node as a neutral reference without suggesting live execution", () => {
|
||||
const diagram = investigationDiagramSchema.parse({ title: "流程参考", mermaid: "flowchart TD\nA[核查]",
|
||||
node_refs: [{ node_id: "A", label: "核查", status: "running" }] });
|
||||
const styled = styleInvestigationDiagram(diagram);
|
||||
expect(styled).toContain("class A tjReference;");
|
||||
expect(diagram.node_refs[0].status).toBe("reference");
|
||||
expect(styled).not.toContain("class A tjRunning;");
|
||||
expect(styled).not.toContain("class A tjRecordedRunning;");
|
||||
});
|
||||
|
||||
it("distinguishes a recorded finding from task completion and preserves failed paths", () => {
|
||||
const diagram = investigationDiagramSchema.parse({ title: "调查脉络", mermaid: "flowchart TD\nA[原尝试] --> B[替代路线]",
|
||||
node_refs: [{ node_id: "A", label: "原尝试", status: "failed", detail: "接口超时" },
|
||||
{ node_id: "B", label: "替代路线", status: "recorded" }] });
|
||||
const styled = styleInvestigationDiagram(diagram);
|
||||
expect(styled).toContain("class A tjFailed;");
|
||||
expect(styled).toContain("class B tjRecorded;");
|
||||
expect(styled.startsWith(diagram.mermaid)).toBe(true);
|
||||
expect(diagram.node_refs[0].detail).toBe("接口超时");
|
||||
});
|
||||
});
|
||||
@@ -70,54 +70,79 @@ describe("investigation SVG post-processing", () => {
|
||||
});
|
||||
|
||||
describe("investigation diagram presentation", () => {
|
||||
it("applies recorded states to bare class-attached declarations and ignores partial IDs", () => {
|
||||
it("styles explicit reference annotations and ignores partial node ids", () => {
|
||||
const styled = styleInvestigationDiagram({ title: "核查", mermaid: "flowchart TD\nQUERY:::tjAction --> REVIEW:::tjHuman",
|
||||
node_refs: [{ node_id: "QUERY", label: "核查", status: "running", evidence_refs: [], action_refs: [] },
|
||||
{ node_id: "REVIEW", label: "复核", status: "waiting_human", evidence_refs: [], action_refs: [] },
|
||||
{ node_id: "QUER", label: "无效引用", status: "completed", evidence_refs: [], action_refs: [] }] }, true);
|
||||
expect(styled).toContain("class QUERY tjRunning;");
|
||||
expect(styled).toContain("class REVIEW tjWaitingHuman;");
|
||||
node_refs: [{ node_id: "QUERY", label: "核查", status: "reference", evidence_refs: [], action_refs: [] },
|
||||
{ node_id: "REVIEW", label: "复核", status: "needs_input", evidence_refs: [], action_refs: [] },
|
||||
{ node_id: "QUER", label: "无效引用", status: "recorded", evidence_refs: [], action_refs: [] }] });
|
||||
expect(styled).toContain("class QUERY tjReference;");
|
||||
expect(styled).toContain("class REVIEW tjNeedsInput;");
|
||||
expect(styled).not.toContain("class QUER ");
|
||||
});
|
||||
it("retains source relationships and only styles declared business references", () => {
|
||||
it("keeps relationships and evidence neutral until a result is explicitly annotated", () => {
|
||||
const source = 'flowchart TD\nA["核查"] --> B;\nB -->|资料不足| C["记录缺口"]';
|
||||
const styled = styleInvestigationDiagram({
|
||||
title: "调查脉络",
|
||||
mermaid: source,
|
||||
node_refs: [
|
||||
{ node_id: "B", label: "判断", evidence_refs: ["e1"], action_refs: [] },
|
||||
{ node_id: "missing", label: "历史引用", evidence_refs: ["e2"], action_refs: [] }
|
||||
]
|
||||
});
|
||||
const styled = styleInvestigationDiagram({ title: "调查脉络", mermaid: source, node_refs: [
|
||||
{ node_id: "B", label: "判断", evidence_refs: ["e1"], action_refs: [] },
|
||||
{ node_id: "missing", label: "历史引用", evidence_refs: ["e2"], action_refs: [] }
|
||||
] });
|
||||
expect(styled.startsWith(source)).toBe(true);
|
||||
expect(styled).toContain("class B tjUnknown;");
|
||||
expect(styled).toContain("class B tjReference;");
|
||||
expect(styled).not.toContain("class missing ");
|
||||
expect(styled).not.toContain("class B tjCompleted;");
|
||||
expect(styled).not.toContain("class B tjRunning;");
|
||||
expect(styled).not.toContain("class B tjRecorded;");
|
||||
});
|
||||
it("uses blue only for an explicitly running current diagram, and neutralizes historical running classes", () => {
|
||||
const diagram = { title: "当前核查", mermaid: "flowchart TD\nA[查询]:::tjAgent --> B[判断]:::tjDecision",
|
||||
node_refs: [{ node_id: "A", label: "查询", status: "running" as const, evidence_refs: ["e1"], action_refs: [] }] };
|
||||
expect(styleInvestigationDiagram(diagram, true)).toContain("class A tjRunning;");
|
||||
expect(styleInvestigationDiagram(diagram, true)).not.toContain("class A tjEvidence;");
|
||||
const historical = styleInvestigationDiagram(diagram, false);
|
||||
expect(historical).not.toContain("class A tjRunning;");
|
||||
expect(historical).toContain("class A tjRecordedRunning;");
|
||||
expect(historical).not.toContain("#4263C7");
|
||||
it("neutralizes old running classes even when the legacy source supplied them", () => {
|
||||
const styled = styleInvestigationDiagram({ title: "旧图参考", mermaid: "flowchart TD\nA[查询]:::tjRunning",
|
||||
node_refs: [] });
|
||||
expect(styled).toContain("classDef tjRunning fill:#FFFFFF");
|
||||
expect(styled).not.toContain("#4263C7");
|
||||
});
|
||||
it.each([
|
||||
["pending", "tjPending"], ["running", "tjRunning"],
|
||||
["completed", "tjCompleted"], ["waiting", "tjWaiting"],
|
||||
["waiting_human", "tjWaitingHuman"], ["failed", "tjFailed"],
|
||||
["skipped", "tjSkipped"], ["cancelled", "tjCancelled"]
|
||||
] as const)("uses the explicit %s state even when evidence or actions are linked", (status, className) => {
|
||||
["reference", "tjReference"], ["recorded", "tjRecorded"], ["needs_input", "tjNeedsInput"],
|
||||
["failed", "tjFailed"], ["not_taken", "tjNotTaken"]
|
||||
] as const)("displays the saved %s annotation without inferring other node results", (status, className) => {
|
||||
const source = "flowchart LR\nA[核查] --> B[下一步]";
|
||||
const styled = styleInvestigationDiagram({ title: "核查", mermaid: source,
|
||||
node_refs: [{ node_id: "A", label: "核查", status, evidence_refs: ["e1"], action_refs: ["a1"] }] }, true);
|
||||
node_refs: [{ node_id: "A", label: "核查", status, evidence_refs: ["e1"], action_refs: ["a1"] }] });
|
||||
expect(styled.startsWith(source)).toBe(true);
|
||||
expect(styled).toContain(`class A ${className};`);
|
||||
expect(styled).not.toContain("class A tjEvidence;");
|
||||
expect(styled).not.toContain("class A tjAction;");
|
||||
expect(styled).not.toContain("class B tjCompleted;");
|
||||
expect(styled).not.toContain("class B tjRecorded;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("investigation diagram render reuse", () => {
|
||||
it("shares an in-flight layout and rebinds SVG ids for each mounted copy", async () => {
|
||||
const renderSvg = vi.fn(async (id: string) => ({ svg:
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" id="${id}"><defs><marker id="${id}-arrow"/></defs><path marker-end="url(#${id}-arrow)"/></svg>` }));
|
||||
vi.spyOn(mermaid, "getMermaid").mockReturnValue({ initialize: vi.fn(), render: renderSvg });
|
||||
const first = investigationDiagramPlugins.mermaid.getMermaid({ theme: "base" }).render("first-render", "flowchart TD\nA[核查]");
|
||||
const second = investigationDiagramPlugins.mermaid.getMermaid({ theme: "base" }).render("second-render", "flowchart TD\nA[核查]");
|
||||
const [original, reused] = await Promise.all([first, second]);
|
||||
expect(renderSvg).toHaveBeenCalledTimes(1);
|
||||
expect(original.svg).toContain('id="first-render"');
|
||||
expect(reused.svg).toContain('id="second-render"');
|
||||
expect(reused.svg).toContain("url(#second-render-arrow)");
|
||||
expect(reused.svg).not.toContain("first-render");
|
||||
await investigationDiagramPlugins.mermaid.getMermaid({ theme: "dark" }).render("dark-render", "flowchart TD\nA[核查]");
|
||||
expect(renderSvg).toHaveBeenCalledTimes(2);
|
||||
const configured = investigationDiagramPlugins.mermaid.getMermaid({ theme: "base" });
|
||||
configured.initialize({ theme: "forest" });
|
||||
await configured.render("configured-render", "flowchart TD\nA[核查]");
|
||||
expect(renderSvg).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries failed rendering and bounds retained layouts", async () => {
|
||||
const renderSvg = vi.fn().mockRejectedValueOnce(new Error("render failed")).mockResolvedValue({
|
||||
svg: '<svg xmlns="http://www.w3.org/2000/svg"/>'
|
||||
});
|
||||
vi.spyOn(mermaid, "getMermaid").mockReturnValue({ initialize: vi.fn(), render: renderSvg });
|
||||
const renderer = investigationDiagramPlugins.mermaid.getMermaid();
|
||||
await expect(renderer.render("first", "flowchart TD\nA[核查]")).rejects.toThrow("render failed");
|
||||
await renderer.render("retry", "flowchart TD\nA[核查]");
|
||||
expect(renderSvg).toHaveBeenCalledTimes(2);
|
||||
for (let index = 0; index < 32; index++) await renderer.render(`next-${index}`, `flowchart TD\nN${index}[步骤]`);
|
||||
await renderer.render("after-eviction", "flowchart TD\nA[核查]");
|
||||
expect(renderSvg).toHaveBeenCalledTimes(35);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mermaid, type MermaidConfig } from "@streamdown/mermaid";
|
||||
import type { InvestigationDiagram } from "../api/investigation-report";
|
||||
import { normalizeInvestigationNodeAnnotation } from "../api/investigation-node-state";
|
||||
|
||||
export const INVESTIGATION_DIAGRAM_FONT =
|
||||
'"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||
@@ -51,83 +52,105 @@ export const investigationDiagramConfig: MermaidConfig = {
|
||||
`
|
||||
};
|
||||
|
||||
type DiagramResult = { svg: string };
|
||||
type CachedDiagram = { id: string; result: Promise<DiagramResult> };
|
||||
const diagramCaches = new WeakMap<object, Map<string, CachedDiagram>>();
|
||||
|
||||
/** Keep group captions beside crossing links, without moving nodes or changing edges. */
|
||||
export const investigationDiagramPlugins = {
|
||||
mermaid: {
|
||||
...mermaid,
|
||||
getMermaid(config?: MermaidConfig) {
|
||||
const renderer = mermaid.getMermaid(config);
|
||||
let effectiveConfig = config;
|
||||
const cache = diagramCaches.get(renderer) ?? new Map<string, CachedDiagram>();
|
||||
diagramCaches.set(renderer, cache);
|
||||
return {
|
||||
initialize: (nextConfig: MermaidConfig) => renderer.initialize(nextConfig),
|
||||
initialize: (nextConfig: MermaidConfig) => { effectiveConfig = nextConfig; renderer.initialize(nextConfig); },
|
||||
async render(id: string, source: string) {
|
||||
// Streamdown's two-argument renderer uses Mermaid's temporary #d{id}
|
||||
// under body. It must remain measurable, but never enter page flow.
|
||||
// Scope the rule to this render, including fullscreen and error paths.
|
||||
const measurementStyle = document.createElement("style");
|
||||
measurementStyle.textContent = `body > [id=${JSON.stringify(`d${id}`)}] {
|
||||
position: fixed !important; inset: 0 auto auto 0;
|
||||
width: 100%; visibility: hidden; pointer-events: none;
|
||||
contain: layout paint;
|
||||
}`;
|
||||
document.head.append(measurementStyle);
|
||||
const result = await renderer.render(id, source).finally(() => measurementStyle.remove());
|
||||
// Mermaid's strict renderer emits HTML labels inside foreignObject,
|
||||
// including HTML void tags such as <br>. Parse its sanitized output in
|
||||
// an inert HTML fragment, not as a standalone XML document.
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = result.svg;
|
||||
const svg = template.content.firstElementChild;
|
||||
if (template.content.childElementCount !== 1 || svg?.localName !== "svg"
|
||||
|| svg.namespaceURI !== "http://www.w3.org/2000/svg" || svg.querySelector("parsererror")) {
|
||||
throw new Error("Mermaid did not return a valid SVG diagram");
|
||||
}
|
||||
try {
|
||||
svg.querySelectorAll(".cluster").forEach((cluster) => {
|
||||
const rect = Array.from(cluster.children).find((child) => child.tagName === "rect");
|
||||
const caption = cluster.querySelector(".cluster-label");
|
||||
if (!rect || !caption) return;
|
||||
const x = Number(rect.getAttribute("x"));
|
||||
const y = Number(rect.getAttribute("y"));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
caption.setAttribute(
|
||||
"transform",
|
||||
`translate(${x + 18}, ${y + (config?.flowchart?.subGraphTitleMargin?.top ?? 12)})`
|
||||
);
|
||||
// Mermaid paints crossing edges after clusters; keep captions above those edges.
|
||||
const clusters = cluster.parentElement;
|
||||
if (clusters?.classList.contains("clusters")) clusters.parentElement?.append(caption);
|
||||
});
|
||||
// Streamdown embeds this as HTML, just like Mermaid's original output.
|
||||
return { ...result, svg: svg.outerHTML };
|
||||
} catch {
|
||||
// Caption placement is cosmetic; never discard a usable original graph.
|
||||
return result;
|
||||
const key = JSON.stringify([effectiveConfig, source]);
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
cache.delete(key);
|
||||
cache.set(key, cached);
|
||||
const result = await cached.result;
|
||||
// SVG markers and CSS must belong to this mounted instance.
|
||||
return { ...result, svg: result.svg.replaceAll(cached.id, id) };
|
||||
}
|
||||
const result = renderSvg(id, source, effectiveConfig);
|
||||
const entry = { id, result };
|
||||
cache.set(key, entry);
|
||||
if (cache.size > 32) cache.delete(cache.keys().next().value!);
|
||||
try { return await result; }
|
||||
catch (error) { if (cache.get(key) === entry) cache.delete(key); throw error; }
|
||||
}
|
||||
};
|
||||
|
||||
async function renderSvg(id: string, source: string, renderConfig?: MermaidConfig) {
|
||||
// Streamdown's two-argument renderer uses Mermaid's temporary #d{id}
|
||||
// under body. It must remain measurable, but never enter page flow.
|
||||
// Scope the rule to this render, including fullscreen and error paths.
|
||||
const measurementStyle = document.createElement("style");
|
||||
measurementStyle.textContent = `body > [id=${JSON.stringify(`d${id}`)}] {
|
||||
position: fixed !important; inset: 0 auto auto 0;
|
||||
width: 100%; visibility: hidden; pointer-events: none;
|
||||
contain: layout paint;
|
||||
}`;
|
||||
document.head.append(measurementStyle);
|
||||
const result = await renderer.render(id, source).finally(() => measurementStyle.remove());
|
||||
// Mermaid's strict renderer emits HTML labels inside foreignObject,
|
||||
// including HTML void tags such as <br>. Parse its sanitized output in
|
||||
// an inert HTML fragment, not as a standalone XML document.
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = result.svg;
|
||||
const svg = template.content.firstElementChild;
|
||||
if (template.content.childElementCount !== 1 || svg?.localName !== "svg"
|
||||
|| svg.namespaceURI !== "http://www.w3.org/2000/svg" || svg.querySelector("parsererror")) {
|
||||
throw new Error("Mermaid did not return a valid SVG diagram");
|
||||
}
|
||||
try {
|
||||
svg.querySelectorAll(".cluster").forEach((cluster) => {
|
||||
const rect = Array.from(cluster.children).find((child) => child.tagName === "rect");
|
||||
const caption = cluster.querySelector(".cluster-label");
|
||||
if (!rect || !caption) return;
|
||||
const x = Number(rect.getAttribute("x"));
|
||||
const y = Number(rect.getAttribute("y"));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
caption.setAttribute(
|
||||
"transform",
|
||||
`translate(${x + 18}, ${y + (renderConfig?.flowchart?.subGraphTitleMargin?.top ?? 12)})`
|
||||
);
|
||||
// Mermaid paints crossing edges after clusters; keep captions above those edges.
|
||||
const clusters = cluster.parentElement;
|
||||
if (clusters?.classList.contains("clusters")) clusters.parentElement?.append(caption);
|
||||
});
|
||||
// Streamdown embeds this as HTML, just like Mermaid's original output.
|
||||
return { ...result, svg: svg.outerHTML };
|
||||
} catch {
|
||||
// Caption placement is cosmetic; never discard a usable original graph.
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const investigationDiagramStates = {
|
||||
pending: { className: "tjPending", label: "尚未开始", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "0" },
|
||||
running: { className: "tjRunning", label: "正在执行", fill: "#E8F0FF", stroke: "#4263C7", color: "#284574", width: 2.5, dash: "0" },
|
||||
completed: { className: "tjCompleted", label: "已完成", fill: "#EDF7F4", stroke: "#6B9E8B", color: "#2D5E4E", width: 1.8, dash: "0" },
|
||||
waiting: { className: "tjWaiting", label: "等待依赖 / 外部结果", fill: "#FFFBEA", stroke: "#C8AC60", color: "#78531B", width: 1.8, dash: "6 3" },
|
||||
waiting_human: { className: "tjWaitingHuman", label: "等待人工处理", fill: "#FFF6E8", stroke: "#C4A168", color: "#735018", width: 2, dash: "0" },
|
||||
failed: { className: "tjFailed", label: "执行失败", fill: "#FEF2F2", stroke: "#D96C75", color: "#922C3E", width: 2, dash: "0" },
|
||||
skipped: { className: "tjSkipped", label: "已跳过", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#64748B", width: 1.5, dash: "8 4" },
|
||||
cancelled: { className: "tjCancelled", label: "已取消", fill: "#F1F5F9", stroke: "#94A3B8", color: "#64748B", width: 1.5, dash: "2 3" },
|
||||
recorded_running: { className: "tjRecordedRunning", label: "记录时执行中", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "6 3" },
|
||||
unknown: { className: "tjUnknown", label: "未标注状态", fill: "#FFFFFF", stroke: "#CCD6E5", color: "#536784", width: 1.4, dash: "0" }
|
||||
reference: { className: "tjReference", label: "参考步骤", fill: "#FFFFFF", stroke: "#CCD6E5", color: "#536784", width: 1.4, dash: "0" },
|
||||
recorded: { className: "tjRecorded", label: "已有结果", fill: "#EDF7F4", stroke: "#6B9E8B", color: "#2D5E4E", width: 1.8, dash: "0" },
|
||||
needs_input: { className: "tjNeedsInput", label: "待补充条件", fill: "#FFFBEA", stroke: "#C8AC60", color: "#78531B", width: 1.8, dash: "6 3" },
|
||||
failed: { className: "tjFailed", label: "尝试失败", fill: "#FEF2F2", stroke: "#D96C75", color: "#922C3E", width: 2, dash: "0" },
|
||||
not_taken: { className: "tjNotTaken", label: "未采用路线", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#64748B", width: 1.5, dash: "8 4" }
|
||||
} as const;
|
||||
|
||||
export function investigationNodeState(
|
||||
node: InvestigationDiagram["node_refs"][number],
|
||||
running = false
|
||||
): keyof typeof investigationDiagramStates {
|
||||
if (node.status === "running" && !running) return "recorded_running";
|
||||
return node.status ?? "unknown";
|
||||
const legacyClasses = {
|
||||
tjPending: "reference", tjRunning: "reference", tjRecordedRunning: "reference", tjUnknown: "reference",
|
||||
tjCompleted: "recorded", tjWaiting: "needs_input", tjWaitingHuman: "needs_input",
|
||||
tjSkipped: "not_taken", tjCancelled: "not_taken"
|
||||
} as const;
|
||||
|
||||
export function investigationNodeState(node: InvestigationDiagram["node_refs"][number]): keyof typeof investigationDiagramStates {
|
||||
return normalizeInvestigationNodeAnnotation(node.status);
|
||||
}
|
||||
|
||||
function nodeStyle(state: typeof investigationDiagramStates[keyof typeof investigationDiagramStates]) {
|
||||
@@ -138,12 +161,12 @@ export const investigationDiagramNodeId = (node: Element | null | undefined) =>
|
||||
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
|
||||
|
||||
/** Update state-only changes in place, preserving the reader's pan and zoom. */
|
||||
export function paintInvestigationDiagramStates(root: Element, diagram: InvestigationDiagram, running: boolean) {
|
||||
export function paintInvestigationDiagramStates(root: Element, diagram: InvestigationDiagram) {
|
||||
const refs = new Map(diagram.node_refs.map((node) => [node.node_id, node]));
|
||||
const classes = Object.values(investigationDiagramStates).map((state) => state.className);
|
||||
const classes = [...Object.values(investigationDiagramStates).map((state) => state.className), ...Object.keys(legacyClasses)];
|
||||
root.querySelectorAll<SVGGElement>("svg g.node").forEach((node) => {
|
||||
const ref = refs.get(investigationDiagramNodeId(node) ?? "");
|
||||
const state = investigationDiagramStates[ref ? investigationNodeState(ref, running) : "unknown"];
|
||||
const state = investigationDiagramStates[ref ? investigationNodeState(ref) : "reference"];
|
||||
node.classList.remove(...classes);
|
||||
node.classList.add(state.className);
|
||||
node.setAttribute("aria-label", `${ref?.label ?? node.textContent?.trim() ?? "节点"}:${state.label}`);
|
||||
@@ -162,23 +185,22 @@ export function paintInvestigationDiagramStates(root: Element, diagram: Investig
|
||||
});
|
||||
}
|
||||
|
||||
/** Color explicit recorded states; relationships and evidence never imply completion. */
|
||||
export function styleInvestigationDiagram(diagram: InvestigationDiagram, running = false) {
|
||||
/** Color saved reference annotations without deriving state from an execution run. */
|
||||
export function styleInvestigationDiagram(diagram: InvestigationDiagram) {
|
||||
const assignments = diagram.node_refs.flatMap((node) => {
|
||||
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const inSource = new RegExp(`(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|:::|--|==|-\\.|$))`, "m").test(
|
||||
diagram.mermaid
|
||||
);
|
||||
if (!inSource) return [];
|
||||
const state = investigationDiagramStates[investigationNodeState(node, running)];
|
||||
const state = investigationDiagramStates[investigationNodeState(node)];
|
||||
return [`class ${node.node_id} ${state.className};`, `style ${node.node_id} ${nodeStyle(state)};`];
|
||||
});
|
||||
// Keep role/reference classes neutral. Their presence conveys association,
|
||||
// while only node_refs.status supplies an execution fact.
|
||||
// and only explicit node annotations describe saved findings.
|
||||
const roles = ["default", "tjAgent", "tjHuman", "tjDecision", "tjEvidence", "tjAction"]
|
||||
.map((name) => `classDef ${name} ${nodeStyle(investigationDiagramStates.unknown)};`);
|
||||
const states = Object.entries(investigationDiagramStates).map(([key, state]) =>
|
||||
`classDef ${state.className} ${nodeStyle(key === "running" && !running ? investigationDiagramStates.recorded_running : state)};`
|
||||
);
|
||||
return [diagram.mermaid, ...roles, ...states, ...assignments].join("\n");
|
||||
.map((name) => `classDef ${name} ${nodeStyle(investigationDiagramStates.reference)};`);
|
||||
const states = Object.values(investigationDiagramStates).map((state) => `classDef ${state.className} ${nodeStyle(state)};`);
|
||||
const legacy = Object.entries(legacyClasses).map(([name, annotation]) => `classDef ${name} ${nodeStyle(investigationDiagramStates[annotation])};`);
|
||||
return [diagram.mermaid, ...roles, ...states, ...legacy, ...assignments].join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user