@@ -318,6 +322,7 @@ export function InvestigationPresentationPanel({
!overlay &&
!blocks.length &&
!records.length &&
+ status !== "connecting" &&
!relatedLayers.length ? (
当前没有展示结果,可切换历史动作回看地图与结果。
diff --git a/src/features/workbench/components/investigation-task-report.tsx b/src/features/workbench/components/investigation-task-report.tsx
index e2adb49..a2bb3ac 100644
--- a/src/features/workbench/components/investigation-task-report.tsx
+++ b/src/features/workbench/components/investigation-task-report.tsx
@@ -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({
(
<>
diff --git a/src/features/workbench/components/presentation-history-switcher.tsx b/src/features/workbench/components/presentation-history-switcher.tsx
index e4c6854..6e545eb 100644
--- a/src/features/workbench/components/presentation-history-switcher.tsx
+++ b/src/features/workbench/components/presentation-history-switcher.tsx
@@ -99,8 +99,9 @@ export function PresentationHistorySwitcher({
{playback ? (
-
@@ -113,8 +114,11 @@ export function PresentationHistorySwitcher({
{selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length}
diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.tsx
index e6ac454..70e37d9 100644
--- a/src/features/workbench/components/scheduled-condition-detail-panel.tsx
+++ b/src/features/workbench/components/scheduled-condition-detail-panel.tsx
@@ -216,18 +216,7 @@ export function ConditionDetailPanel({
);
return (
-
-
- {content}
-
-
+
{content}
);
}
diff --git a/src/features/workbench/hooks/use-investigation-presentation.test.tsx b/src/features/workbench/hooks/use-investigation-presentation.test.tsx
index 3cd9abd..51c90ba 100644
--- a/src/features/workbench/hooks/use-investigation-presentation.test.tsx
+++ b/src/features/workbench/hooks/use-investigation-presentation.test.tsx
@@ -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: [] }));
diff --git a/src/features/workbench/hooks/use-investigation-presentation.ts b/src/features/workbench/hooks/use-investigation-presentation.ts
index c97d1dd..dae2e60 100644
--- a/src/features/workbench/hooks/use-investigation-presentation.ts
+++ b/src/features/workbench/hooks/use-investigation-presentation.ts
@@ -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
(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([]);
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,
diff --git a/src/features/workbench/utils/investigation-diagram-reference.test.ts b/src/features/workbench/utils/investigation-diagram-reference.test.ts
new file mode 100644
index 0000000..7107857
--- /dev/null
+++ b/src/features/workbench/utils/investigation-diagram-reference.test.ts
@@ -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("接口超时");
+ });
+});
diff --git a/src/features/workbench/utils/investigation-diagram-style.test.ts b/src/features/workbench/utils/investigation-diagram-style.test.ts
index 2339ed9..1ed1434 100644
--- a/src/features/workbench/utils/investigation-diagram-style.test.ts
+++ b/src/features/workbench/utils/investigation-diagram-style.test.ts
@@ -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:
+ `` }));
+ 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: ''
+ });
+ 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);
});
});
diff --git a/src/features/workbench/utils/investigation-diagram-style.ts b/src/features/workbench/utils/investigation-diagram-style.ts
index 80bc51e..842d403 100644
--- a/src/features/workbench/utils/investigation-diagram-style.ts
+++ b/src/features/workbench/utils/investigation-diagram-style.ts
@@ -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 };
+const diagramCaches = new WeakMap