feat(workbench): redesign investigation reports and diagrams
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { styleInvestigationDiagram } from "./investigation-diagram-style";
|
||||
|
||||
describe("investigation diagram presentation", () => {
|
||||
it("retains source relationships and only styles declared business references", () => {
|
||||
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: [] }
|
||||
]
|
||||
});
|
||||
expect(styled.startsWith(source)).toBe(true);
|
||||
expect(styled).toContain("class B tjEvidence;");
|
||||
expect(styled).not.toContain("class missing ");
|
||||
expect(styled).not.toMatch(/completed|running|succeeded/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mermaid, type MermaidConfig } from "@streamdown/mermaid";
|
||||
import type { InvestigationDiagram } from "../api/investigation-report";
|
||||
|
||||
export const INVESTIGATION_DIAGRAM_FONT =
|
||||
'"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||
|
||||
export const investigationDiagramConfig: MermaidConfig = {
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
htmlLabels: true,
|
||||
fontFamily: INVESTIGATION_DIAGRAM_FONT,
|
||||
themeVariables: {
|
||||
fontFamily: INVESTIGATION_DIAGRAM_FONT,
|
||||
fontSize: "15px",
|
||||
background: "#FFFFFF",
|
||||
primaryColor: "#FFFFFF",
|
||||
primaryTextColor: "#26334D",
|
||||
primaryBorderColor: "#A5B5CC",
|
||||
secondaryColor: "#FFF6E8",
|
||||
secondaryTextColor: "#78531B",
|
||||
secondaryBorderColor: "#C3A575",
|
||||
tertiaryColor: "#F6F8FC",
|
||||
tertiaryBorderColor: "#CCD6E5",
|
||||
tertiaryTextColor: "#475976",
|
||||
lineColor: "#7689A6",
|
||||
edgeLabelBackground: "#FFFFFF",
|
||||
clusterBkg: "#F6F8FC",
|
||||
clusterBorder: "#CCD6E5"
|
||||
},
|
||||
flowchart: {
|
||||
curve: "stepAfter",
|
||||
nodeSpacing: 56,
|
||||
rankSpacing: 64,
|
||||
padding: 22,
|
||||
diagramPadding: 24,
|
||||
subGraphTitleMargin: { top: 12, bottom: 48 },
|
||||
wrappingWidth: 260
|
||||
},
|
||||
themeCSS: `
|
||||
.node rect { rx: 6px; ry: 6px; stroke-width: 1.4px; }
|
||||
.node polygon { stroke-width: 1.6px; }
|
||||
.nodeLabel { font-weight: 500; }
|
||||
.nodeLabel p { text-wrap: balance; line-height: 1.6; }
|
||||
.edgePath path, .flowchart-link { stroke-width: 1.35px; }
|
||||
.edgeLabel { color: #536784; font-size: 12px; }
|
||||
.edgeLabel p { padding: 3px 7px; border-radius: 4px; line-height: 1.5; }
|
||||
.cluster rect { rx: 8px; ry: 8px; stroke-width: 1px; }
|
||||
.cluster-label { font-weight: 600; font-size: 13px; }
|
||||
.cluster-label .nodeLabel { background: #F6F8FC; padding: 0 4px; }
|
||||
`
|
||||
};
|
||||
|
||||
/** 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);
|
||||
return {
|
||||
initialize: (nextConfig: MermaidConfig) => renderer.initialize(nextConfig),
|
||||
async render(id: string, source: string) {
|
||||
const result = await renderer.render(id, source);
|
||||
const svg = new DOMParser().parseFromString(result.svg, "image/svg+xml");
|
||||
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);
|
||||
});
|
||||
return { ...result, svg: new XMLSerializer().serializeToString(svg.documentElement) };
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const roleStyles = [
|
||||
"classDef tjAgent fill:#EEF3FF,stroke:#7A93CB,color:#284574,stroke-width:1.4px;",
|
||||
"classDef tjHuman fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.4px;",
|
||||
"classDef tjDecision fill:#4263C7,stroke:#4263C7,color:#FFFFFF,stroke-width:1.5px;",
|
||||
"classDef tjEvidence fill:#EDF7F4,stroke:#6B9E8B,color:#2D5E4E,stroke-width:1.6px;",
|
||||
"classDef tjAction fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.6px;"
|
||||
];
|
||||
|
||||
/** Style supplied relationships; never create nodes from a dangling display reference. */
|
||||
export function styleInvestigationDiagram(diagram: InvestigationDiagram) {
|
||||
const assignments = diagram.node_refs.flatMap((node) => {
|
||||
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const inSource = new RegExp(`\\b${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`, "m").test(
|
||||
diagram.mermaid
|
||||
);
|
||||
if (!inSource) return [];
|
||||
return node.action_refs.length
|
||||
? [`class ${node.node_id} tjAction;`]
|
||||
: node.evidence_refs.length
|
||||
? [`class ${node.node_id} tjEvidence;`]
|
||||
: [];
|
||||
});
|
||||
return [diagram.mermaid, ...roleStyles, ...assignments].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { InvestigationDiagram } from "../api/investigation-report";
|
||||
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
|
||||
|
||||
const diagramLabel = (value: string) =>
|
||||
value
|
||||
.replace(/[\r\n]/g, " ")
|
||||
.replace(/"/g, "#quot;")
|
||||
.replace(/</g, "#lt;")
|
||||
.replace(/>/g, "#gt;")
|
||||
.replace(/`/g, "#96;");
|
||||
|
||||
/** Preserve recorded relationships, without turning plan statuses into execution facts. */
|
||||
export function investigationDiagram(
|
||||
task: OperationalTask,
|
||||
snapshot: OperationalTaskReport | null
|
||||
): InvestigationDiagram | null {
|
||||
if (snapshot?.report.diagram) return snapshot.report.diagram;
|
||||
const plan = task.latest_plan;
|
||||
if (
|
||||
!plan ||
|
||||
(snapshot
|
||||
? snapshot.revision !== task.latest_report?.revision ||
|
||||
plan.execution_generation !== snapshot.execution_generation
|
||||
: plan.execution_generation !== task.execution_generation)
|
||||
)
|
||||
return null;
|
||||
const ids = new Map(plan.plan.nodes.map((node, index) => [node.id, `n${index}`]));
|
||||
const nodeDefinition = (node: (typeof plan.plan.nodes)[number]) => {
|
||||
const label = `\`${diagramLabel(node.title)}\``;
|
||||
return node.kind === "decision" || node.kind === "gate"
|
||||
? `${ids.get(node.id)}{"${label}"}:::tjDecision`
|
||||
: `${ids.get(node.id)}["${label}"]:::${node.owner === "human" ? "tjHuman" : "tjAgent"}`;
|
||||
};
|
||||
const agentNodes = plan.plan.nodes.filter((node) => node.owner === "agent");
|
||||
const humanNodes = plan.plan.nodes.filter((node) => node.owner === "human");
|
||||
const nodes =
|
||||
agentNodes.length && humanNodes.length
|
||||
? [
|
||||
'subgraph tj_agent_group["Agent · 资料核查"]',
|
||||
...agentNodes.map(nodeDefinition),
|
||||
"end",
|
||||
'subgraph tj_human_group["人工 · 现场协作"]',
|
||||
...humanNodes.map(nodeDefinition),
|
||||
"end"
|
||||
]
|
||||
: plan.plan.nodes.map(nodeDefinition);
|
||||
const edges = plan.plan.edges.map((edge) =>
|
||||
edge.condition
|
||||
? `${ids.get(edge.from)} -->|"${diagramLabel(edge.condition)}"| ${ids.get(edge.to)}`
|
||||
: `${ids.get(edge.from)} --> ${ids.get(edge.to)}`
|
||||
);
|
||||
return {
|
||||
title: "调查脉络",
|
||||
mermaid: ["flowchart TD", ...nodes, ...edges].join("\n"),
|
||||
node_refs: plan.plan.nodes
|
||||
.filter((node) => node.evidence_refs?.length || node.action_refs?.length)
|
||||
.map((node) => ({
|
||||
node_id: ids.get(node.id)!,
|
||||
label: node.title,
|
||||
evidence_refs: node.evidence_refs ?? [],
|
||||
action_refs: node.action_refs ?? []
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function investigationStatus(task: OperationalTask): string {
|
||||
if (task.active_run?.status === "queued") return "等待调查";
|
||||
if (task.active_run?.status === "claimed") return "调查启动中";
|
||||
if (task.active_run?.status === "running") return "调查中";
|
||||
if (task.lifecycle_status === "failed") return "本轮执行失败";
|
||||
if (task.lifecycle_status === "cancelled") return "本轮已取消";
|
||||
if (task.actions.some((action) => action.approval_status === "pending")) return "待人工审核";
|
||||
if (
|
||||
task.actions.some(
|
||||
(action) =>
|
||||
action.approval_status === "approved" && action.execution_status === "not_submitted"
|
||||
)
|
||||
)
|
||||
return "待执行动作";
|
||||
if (
|
||||
task.actions.some(
|
||||
(action) =>
|
||||
action.approval_status === "approved" &&
|
||||
["submitted", "unknown"].includes(action.execution_status)
|
||||
)
|
||||
)
|
||||
return "待核对回执";
|
||||
if (
|
||||
task.latest_report?.execution_generation === task.execution_generation &&
|
||||
task.latest_report.report.outcome === "insufficient_data" &&
|
||||
task.lifecycle_status === "completed"
|
||||
)
|
||||
return "已结束 · 证据不足";
|
||||
if (task.lifecycle_status === "completed") return "本轮调查完成";
|
||||
return "调查中";
|
||||
}
|
||||
|
||||
export function isPendingInvestigationAction(action: OperationalTask["actions"][number]) {
|
||||
return (
|
||||
action.approval_status === "pending" ||
|
||||
(action.approval_status === "approved" &&
|
||||
["not_submitted", "submitted", "unknown"].includes(action.execution_status))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user