feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -481,7 +481,7 @@ export function getFeaturePanelModel(
return {
id,
badge: badgeEntry ? getFeaturePanelBadge(layer, badgeEntry.value) : undefined,
badge: badgeEntry ? getFeaturePanelBadge(badgeEntry.value) : undefined,
attributes: entries.filter((entry) => entry.key !== idKey && entry.key !== badgeKey)
};
}
@@ -625,10 +625,7 @@ function isStatusLikeKey(normalizedKey: string) {
return normalizedKey === "status" || normalizedKey === "setting";
}
function getFeaturePanelBadge(
layer: WaterNetworkSourceId,
value: string
): FeaturePanelBadge {
function getFeaturePanelBadge(value: string): FeaturePanelBadge {
const normalizedValue = value.toLowerCase();
if (["运行", "运行中", "在线", "开启", "正常"].includes(value)) {
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases";
import { parseInvestigationDevJsonl } from "./investigation-dev-preview";
const center: [number, number] = [121.48, 31.1];
const target = { source_id: "scada", feature_id: "P1" } as const;
describe("switchable investigation Dev cases", () => {
it.each(investigationDevCases.filter((item) => !item.id.startsWith("invalid_")))(
"$label produces a valid, nonempty batch within the existing presentation contract",
({ id }) => {
const input = createInvestigationDevCase(id, center, target);
const preview = parseInvestigationDevJsonl(input);
const records = input.split("\n").map((line) => JSON.parse(line));
expect(records.every((record) => typeof record.record_type === "string")).toBe(true);
expect(
records
.filter((record) => record.record_type === "presentation")
.every((record) => !record.name && !record.params && record.command.action)
).toBe(true);
expect(preview.blocks.every((block) => block.phase === "final")).toBe(true);
expect(
preview.commands.length + preview.blocks.length + preview.records.length
).toBeGreaterThan(0);
}
);
it.each(investigationDevCases.filter((item) => item.id.startsWith("invalid_")))(
"$label cannot produce an executable batch",
({ id }) => {
expect(() => parseInvestigationDevJsonl(createInvestigationDevCase(id, center))).toThrow(
id === "invalid_json" ? "第 2 行不是合法 JSON" : "第 1 行展示指令不合法"
);
}
);
it("only queries an existing task target and falls back to a synthetic map point without one", () => {
const withTarget = parseInvestigationDevJsonl(
createInvestigationDevCase("map_targets", center, target)
);
expect(withTarget.commands[0]).toMatchObject({
action: "highlight_features",
layer: "scada",
feature_ids: ["P1"]
});
const withoutTarget = parseInvestigationDevJsonl(
createInvestigationDevCase("map_targets", center)
);
expect(withoutTarget.commands).toHaveLength(1);
expect(withoutTarget.commands[0]).toMatchObject({ action: "set_overlay", mode: "geometry" });
});
it.each(["data_only", "candidates_table", "valves_table"] as const)(
"%s never turns synthetic asset IDs or embedded action fields into map queries or writes",
(id) => {
const preview = parseInvestigationDevJsonl(createInvestigationDevCase(id, center));
expect(preview.commands).toEqual([]);
expect(preview.records).toHaveLength(2);
expect(preview.records.every((record) => record.origin === "synthetic")).toBe(true);
}
);
it.each([
[180, 90],
[-180, -90],
[540, 85]
] as Array<[number, number]>)(
"keeps synthetic geometry within schema bounds near longitude %s, latitude %s",
(longitude, latitude) => {
for (const id of ["combined", "map_targets", "impact_area", "complaint_heatmap"] as const) {
expect(() =>
parseInvestigationDevJsonl(createInvestigationDevCase(id, [longitude, latitude]))
).not.toThrow();
}
}
);
});
@@ -0,0 +1,284 @@
import type { AnswerUiBlock } from "@/features/agent";
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
import type { InvestigationMapTarget } from "../api/investigation-report";
import {
createInvestigationDevSample,
createPresentationRecord,
createUiRecord,
normalizeDevCenter,
serializeInvestigationDevJsonl
} from "./investigation-dev-preview";
export const investigationDevCases = [
{
id: "combined",
label: "组合联动",
expected: "合成空间点、压力曲线、候选表格与数据一同展示;有调查地图目标时同时高亮。"
},
{
id: "map_targets",
label: "地图定位与高亮",
expected: "显示地图中心附近的合成点,并高亮所选调查已有的地图目标;无目标时仅显示合成点。"
},
{
id: "pressure_line",
label: "压力趋势对比",
expected: "打开两条合成压力曲线,核对时间轴、单位、图例和数据表。"
},
{
id: "impact_bar",
label: "影响用户统计",
expected: "打开合成用户数柱状图;统计仅供展示测试,不是实际停水户数。"
},
{
id: "impact_area",
label: "合成影响范围",
expected: "在当前地图中心附近显示一个合成面;不代表真实隔离边界。"
},
{
id: "complaint_heatmap",
label: "投诉热力分布",
expected: "显示三个带非负权重的合成投诉点,按热力模式渲染。"
},
{
id: "candidates_table",
label: "候选管段对比",
expected: "展示两条合成候选数据和对比表格,不查询或高亮虚拟管段。"
},
{
id: "valves_table",
label: "隔离阀门清单",
expected: "展示两条合成阀门数据和核查表格,不执行关阀或发布处置方案。"
},
{
id: "data_only",
label: "纯 JSONL 数据",
expected: "仅展示候选与影响数据;数据行中出现 action 字段也不会触发地图动作。"
},
{
id: "invalid_json",
label: "错误:JSON 语法",
expected: "第 2 行报语法错误,整批不执行;可点击执行验证拦截结果。"
},
{
id: "invalid_command",
label: "错误:不支持的动作",
expected: "第 1 行展示指令被拒绝,不执行任意命令。"
},
{
id: "invalid_chart",
label: "错误:图表数据不齐",
expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。"
}
] as const;
export type InvestigationDevCaseId = (typeof investigationDevCases)[number]["id"];
const jsonl = serializeInvestigationDevJsonl;
const presentation = createPresentationRecord;
// Invalid cases deliberately bypass the validated writer to exercise rejection.
const invalidJsonl = (records: unknown[]) =>
records.map((record) => JSON.stringify(record)).join("\n");
const data = (record_type: string, fields: Record<string, unknown>) => ({
record_type,
origin: "synthetic",
source_evidence_refs: [],
...fields
});
function table(id: string, title: string, columns: string[], rows: string[][]): unknown {
const block: AnswerUiBlock = {
version: "answer-ui@1",
block_id: `dev-case-${id}`,
fallback_text: `${title},仅供本地展示测试。`,
spec: {
root: "table",
elements: {
table: { type: "Table", props: { title, columns, rows }, children: [] }
}
}
};
return createUiRecord(block);
}
/** Fixtures use current map coordinates and existing task targets, never invented query IDs. */
export function createInvestigationDevCase(
caseId: InvestigationDevCaseId,
center: [number, number],
target?: InvestigationMapTarget
): string {
const [longitude, latitude] = normalizeDevCenter(center);
if (caseId === "combined") return createInvestigationDevSample([longitude, latitude], target);
const point = (dx = 0, dy = 0): [number, number] => [
Math.max(-180, Math.min(180, longitude + dx)),
latitude + dy
];
const overlay = (
mode: "geometry" | "heatmap",
title: string,
features: Extract<
InvestigationPresentationCommand,
{ action: "set_overlay" }
>["data"]["features"]
) =>
presentation({
action: "set_overlay",
overlay_id: `dev-case-${caseId}`,
title,
mode,
evidence_id: "dev:synthetic",
data: { type: "FeatureCollection", features }
});
switch (caseId) {
case "map_targets":
return jsonl([
...(target
? [
presentation({
action: "highlight_features",
layer: target.source_id,
feature_ids: [target.feature_id],
effect: "pulse"
})
]
: []),
overlay("geometry", "本地合成定位点", [
{
type: "Feature",
properties: { origin: "synthetic" },
geometry: { type: "Point", coordinates: point() }
}
])
]);
case "pressure_line":
return jsonl([
presentation({
action: "open_chart",
chart_id: "dev-case-pressure-line",
evidence_refs: ["dev:synthetic"],
chart: {
title: "本地合成压力对比",
chart_type: "line",
x_data: ["08:00", "08:15", "08:30", "08:45"],
series: [
{ name: "异常日压力水头(m)", data: [32, 18, 22, 30] },
{ name: "基线压力水头(m)", data: [33, 32, 32, 33] }
]
}
})
]);
case "impact_bar":
return jsonl([
presentation({
action: "open_chart",
chart_id: "dev-case-impact-bar",
evidence_refs: ["dev:synthetic"],
chart: {
title: "本地合成影响用户统计",
chart_type: "bar",
x_data: ["合成片区 A", "合成片区 B", "合成片区 C"],
series: [{ name: "合成用户数(户)", data: [120, 80, 45] }]
}
})
]);
case "impact_area":
return jsonl([
overlay("geometry", "本地合成影响范围", [
{
type: "Feature",
properties: { origin: "synthetic", purpose: "display_test_only" },
geometry: {
type: "Polygon",
coordinates: [
[
point(-0.002, -0.0015),
point(0.002, -0.0015),
point(0.002, 0.0015),
point(-0.002, 0.0015),
point(-0.002, -0.0015)
]
]
}
}
])
]);
case "complaint_heatmap":
return jsonl([
overlay(
"heatmap",
"本地合成投诉热力",
[-0.0015, 0, 0.0015].map((offset, index) => ({
type: "Feature",
properties: { origin: "synthetic", weight: [1, 3, 2][index] },
geometry: { type: "Point", coordinates: point(offset, offset / 2) }
}))
)
]);
case "candidates_table":
return jsonl([
table(
"candidates",
"合成候选管段对比",
["候选", "优先级", "核查依据"],
[
["DEMO-PIPE-01", "1", "合成压力降幅较大,待核查"],
["DEMO-PIPE-02", "2", "合成相关性较弱,待排除"]
]
),
data("candidate", { element_id: "DEMO-PIPE-01", rank: 1, status: "unverified" }),
data("candidate", { element_id: "DEMO-PIPE-02", rank: 2, status: "unverified" })
]);
case "valves_table":
return jsonl([
table(
"valves",
"合成隔离阀门清单",
["阀门", "操作建议", "核查要求"],
[
["DEMO-VALVE-01", "待评估", "位置与可操作性未核实"],
["DEMO-VALVE-02", "待评估", "连通性与影响范围未核实"]
]
),
data("valve", { element_id: "DEMO-VALVE-01", status: "unverified", executed: false }),
data("valve", { element_id: "DEMO-VALVE-02", status: "unverified", executed: false })
]);
case "data_only":
return jsonl([
data("candidate", {
element_id: "DEMO-PIPE-03",
action: "clear_highlights",
status: "data_only"
}),
data("impact", {
item: "合成片区 A",
estimated_users: 120,
unit: "户",
status: "example_only"
})
]);
case "invalid_json":
return `${jsonl([presentation({ action: "clear_highlights" })])}\n{"record_type":"candidate",`;
case "invalid_command":
return invalidJsonl([
{ record_type: "presentation", command: { action: "unsupported_test_action" } }
]);
case "invalid_chart":
return invalidJsonl([
{
record_type: "presentation",
command: {
action: "open_chart",
chart_id: "dev-invalid-chart",
evidence_refs: ["dev:synthetic"],
chart: {
title: "错误案例:数据长度不齐",
chart_type: "line",
x_data: ["08:00", "08:15"],
series: [{ name: "合成压力水头(m)", data: [32] }]
}
}
}
]);
}
}
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
createInvestigationDevSample,
investigationReportUiSummary,
parseInvestigationDevJsonl
} from "./investigation-dev-preview";
import type { OperationalTask } from "../api/operational-task-client";
describe("investigation Dev JSONL", () => {
it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => {
const result = parseInvestigationDevJsonl(
createInvestigationDevSample([121.48, 31.1], { source_id: "scada", feature_id: "P1" })
);
expect(result.commands.map((item) => item.action)).toEqual([
"highlight_features",
"set_overlay",
"open_chart"
]);
expect(result.blocks).toHaveLength(1);
expect(result.records).toEqual([
expect.objectContaining({
record_type: "candidate",
origin: "synthetic",
status: "example_only"
})
]);
});
it("never turns candidate data or an embedded command into an action", () => {
const result = parseInvestigationDevJsonl(
JSON.stringify({
record_type: "candidate",
origin: "system_data",
source_evidence_refs: ["saved-evidence"],
action: "highlight_features",
command: { action: "clear_highlights" },
element_id: "P1"
})
);
expect(result.commands).toEqual([]);
expect(result.records).toHaveLength(1);
});
it.each([
{ action: "clear_highlights" },
{ action: "execute", command: "rm files" },
{
action: "highlight_features",
layer: "pipes",
feature_ids: ["P1"],
effect: "pulse",
project_id: "other-project"
},
{ record_type: "presentation", run_id: "foreign-run", command: { action: "clear_highlights" } },
{
action: "open_chart",
chart_id: "bad-chart",
evidence_refs: ["e"],
chart: {
title: "bad",
chart_type: "line",
x_data: ["08:00", "08:15"],
series: [{ name: "pressure", data: [1] }]
}
}
])(
"rejects unsupported, scope-changing or inconsistent commands before execution: %j",
(value) => {
expect(() => parseInvestigationDevJsonl(JSON.stringify(value))).toThrow("第 1 行");
}
);
it("reports the physical line and returns no partially accepted batch", () => {
expect(() =>
parseInvestigationDevJsonl(
'\ufeff{"record_type":"presentation","command":{"action":"clear_highlights"}}\n\ninvalid'
)
).toThrow("第 3 行");
});
it("rejects malformed UI, repeated blocks and oversized batches", () => {
expect(() =>
parseInvestigationDevJsonl(
JSON.stringify({ record_type: "ui_block", block: { version: "answer-ui@1" } })
)
).toThrow("界面块不合法");
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
expect(() =>
parseInvestigationDevJsonl(
[block, block]
.map((item) => JSON.stringify({ record_type: "ui_block", block: item }))
.join("\n")
)
).toThrow("block_id 重复");
expect(() =>
parseInvestigationDevJsonl(Array(41).fill('{"action":"clear_highlights"}').join("\n"))
).toThrow("40 行");
expect(() => parseInvestigationDevJsonl("中".repeat(70_000))).toThrow("200 KB");
});
it("rejects bare UI blocks instead of guessing a record type", () => {
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
expect(() => parseInvestigationDevJsonl(JSON.stringify(block))).toThrow("第 1 行");
});
it("counts only registered valid report components", () => {
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
const task = {
latest_report: {
report: {
ui_blocks: [
{ block, evidence_refs: ["e"] },
{ block: { ...block, version: "future-ui" }, evidence_refs: ["e"] }
]
}
}
} as unknown as OperationalTask;
expect(investigationReportUiSummary(task)).toEqual({
blocks: 1,
charts: 0,
tables: 1,
maps: 0,
invalid: 1
});
});
});
describe("migrated example data", () => {
it("validates the portable fixture against all ten current commands and explicit UI phases", async () => {
const { default: input } =
await import("../../../../tests/fixtures/unified-presentation.jsonl?raw");
const { PRESENTATION_ACTIONS } = await import("@/features/agent");
const preview = parseInvestigationDevJsonl(input);
expect(preview.commands.map((command) => command.action).sort()).toEqual(
[...PRESENTATION_ACTIONS].sort()
);
expect(preview.blocks).toHaveLength(1);
expect(preview.blocks[0].phase).toBe("final");
expect(preview.records).toEqual([
expect.objectContaining({ origin: "synthetic", element_id: "DEMO-PIPE-01" })
]);
});
it("rejects invalid generated examples instead of exporting a partial or legacy batch", async () => {
const { serializeInvestigationDevJsonl } = await import("./investigation-dev-preview");
expect(() => serializeInvestigationDevJsonl([{ action: "clear_highlights" }])).toThrow(
"第 1 行"
);
expect(() =>
serializeInvestigationDevJsonl([
{ record_type: "presentation", command: { action: "clear_highlights" } },
{
record_type: "presentation",
command: { action: "zoom_to_map", center: [181, 31], zoom: 14 }
}
])
).toThrow("第 2 行");
});
});
@@ -0,0 +1,246 @@
import { z } from "zod";
import {
parseAnswerUiBlock,
presentationCommandSchema,
type AnswerUiBlock
} from "@/features/agent";
import { type InvestigationPresentationCommand } from "../api/investigation-presentation";
import type { InvestigationMapTarget } from "../api/investigation-report";
import type { OperationalTask } from "../api/operational-task-client";
export type InvestigationDevPreview = {
commands: InvestigationPresentationCommand[];
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
};
export type InvestigationPresentationActivity = {
id: number;
action: InvestigationPresentationCommand["action"];
source: "live" | "preview" | "chat";
status: "running" | "completed" | "error";
message?: string;
};
export const investigationActionLabels: Record<InvestigationPresentationCommand["action"], string> =
{
locate_features: "地图定位",
zoom_to_map: "调整视角",
render_scada_analysis: "SCADA 分析",
clear_scada_analysis: "清除 SCADA 分析",
highlight_features: "地图高亮",
clear_highlights: "清除高亮",
open_chart: "打开图表",
close_chart: "关闭图表",
set_overlay: "显示覆盖层",
clear_overlay: "清除覆盖层"
};
const references = z.array(z.string().trim().min(1).max(128)).max(100);
const command = presentationCommandSchema;
const presentationRecord = z.object({ record_type: z.literal("presentation"), command }).strict();
const uiRecord = z
.object({
record_type: z.literal("ui_block"),
block: z.unknown(),
evidence_refs: references.optional()
})
.strict();
const dataRecord = z
.object({
record_type: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
origin: z.enum(["system_data", "synthetic", "derived", "human_input"]),
source_evidence_refs: references
})
.passthrough();
/** Only explicit presentation records execute. Skill data records remain data. */
export function parseInvestigationDevJsonl(input: string): InvestigationDevPreview {
if (new TextEncoder().encode(input).byteLength > 200_000)
throw new Error("JSONL 超过 200 KB,请缩小数据范围。");
const lines = input
.replace(/^\uFEFF/, "")
.split(/\r?\n/)
.map((text, index) => ({ text: text.trim(), line: index + 1 }))
.filter((item) => item.text);
if (!lines.length) throw new Error("请先载入样例、当前报告,或粘贴 JSONL。");
if (lines.length > 40) throw new Error("一次最多校验 40 行,请分批预览。");
const result: InvestigationDevPreview = { commands: [], blocks: [], records: [] };
const blockIds = new Set<string>();
for (const { text, line } of lines) {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(`第 ${line} 行不是合法 JSON。`);
}
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error(`第 ${line} 行必须是 JSON 对象。`);
const record = value as Record<string, unknown>;
if (record.record_type === "presentation") {
const parsed = presentationRecord.safeParse(value);
if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法,请检查动作、参数和范围。`);
const data = parsed.data;
result.commands.push(data.command);
} else if (record.record_type === "ui_block") {
const wrapped = uiRecord.safeParse(value);
const block = parseAnswerUiBlock(wrapped.success ? wrapped.data.block : null);
if (!block || blockIds.has(block.block_id))
throw new Error(`第 ${line} 行界面块不合法或 block_id 重复。`);
blockIds.add(block.block_id);
result.blocks.push(block);
if (result.blocks.length > 8) throw new Error("一次最多预览 8 个界面块。");
} else {
const parsed = dataRecord.safeParse(value);
if (!parsed.success)
throw new Error(`第 ${line} 行不是已支持的展示指令、界面块或带来源的调查数据。`);
result.records.push(parsed.data);
}
}
return result;
}
/** Explicit records are the only export format; this never interprets legacy inputs. */
export function createPresentationRecord(command: InvestigationPresentationCommand) {
return presentationRecord.parse({ record_type: "presentation", command });
}
export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []) {
const normalized = parseAnswerUiBlock({ ...block, phase: block.phase ?? "final" });
if (!normalized) throw new Error("界面块未通过当前组件协议校验。");
return uiRecord.parse({
record_type: "ui_block",
block: normalized,
evidence_refs: evidenceRefs
});
}
export function serializeInvestigationDevJsonl(records: unknown[]) {
const input = records.map((record) => JSON.stringify(record)).join("\n");
parseInvestigationDevJsonl(input);
return input;
}
export function investigationReportJsonl(task: OperationalTask) {
return (task.latest_report?.report.ui_blocks ?? [])
.map((item) =>
JSON.stringify({
record_type: "ui_block",
block: item.block,
evidence_refs: item.evidence_refs
})
)
.join("\n");
}
export function investigationReportUiSummary(task: OperationalTask | null) {
const counts = { blocks: 0, charts: 0, tables: 0, maps: 0, invalid: 0 };
for (const item of task?.latest_report?.report.ui_blocks ?? []) {
const block = parseAnswerUiBlock(item.block);
if (!block) {
counts.invalid += 1;
continue;
}
counts.blocks += 1;
for (const node of Object.values(block.spec.elements)) {
if (node.type === "Chart") counts.charts += 1;
if (node.type === "Table") counts.tables += 1;
if (node.type === "MapLocation") counts.maps += 1;
}
}
return counts;
}
export function normalizeDevCenter([longitude, latitude]: [number, number]): [number, number] {
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("地图中心坐标必须为有限数值。");
return [((((longitude + 180) % 360) + 360) % 360) - 180, Math.max(-89.99, Math.min(89.99, latitude))];
}
export function createInvestigationDevSample(
center: [number, number],
target?: InvestigationMapTarget
): string {
center = normalizeDevCenter(center);
const chart = {
title: "本地合成压力趋势",
chart_type: "line" as const,
x_data: ["08:00", "08:15", "08:30"],
series: [{ name: "压力水头(m)", data: [32, 28, 30] }]
};
const block: AnswerUiBlock = {
version: "answer-ui@1",
block_id: "dev-investigation-summary",
fallback_text: "仅供展示测试的合成候选对比,不是调查成果。",
spec: {
root: "summary",
elements: {
summary: {
type: "Stack",
props: { columns: "one", gap: "compact" },
children: ["notice", "candidates"]
},
notice: {
type: "Text",
props: { text: "本地合成样例,不代表真实故障、阀门或现场结果。", tone: "warning" },
children: []
},
candidates: {
type: "Table",
props: {
title: "合成候选对比",
columns: ["候选", "排序", "说明"],
rows: [
["DEMO-PIPE-01", "1", "仅测试表格展示"],
["DEMO-PIPE-02", "2", "未执行定位分析"]
]
},
children: []
}
}
}
};
const records: unknown[] = [];
if (target)
records.push(
createPresentationRecord({
action: "highlight_features",
layer: target.source_id,
feature_ids: [target.feature_id],
effect: "pulse"
})
);
records.push(
createPresentationRecord({
action: "set_overlay",
overlay_id: "dev-synthetic-area",
title: "本地合成空间样例",
mode: "geometry",
evidence_id: "dev:synthetic",
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { origin: "synthetic" },
geometry: { type: "Point", coordinates: center }
}
]
}
}),
createPresentationRecord({
action: "open_chart",
chart_id: "dev-synthetic-pressure",
chart,
evidence_refs: ["dev:synthetic"]
}),
createUiRecord(block),
{
record_type: "candidate",
origin: "synthetic",
source_evidence_refs: [],
element_id: "DEMO-PIPE-01",
rank: 1,
status: "example_only"
}
);
return serializeInvestigationDevJsonl(records);
}
@@ -81,9 +81,10 @@ describe("investigation diagram presentation", () => {
]
});
expect(styled.startsWith(source)).toBe(true);
expect(styled).toContain("class B tjEvidence;");
expect(styled).toContain("class B tjUnknown;");
expect(styled).not.toContain("class missing ");
expect(styled).not.toMatch(/completed|running|succeeded/);
expect(styled).not.toContain("class B tjCompleted;");
expect(styled).not.toContain("class B tjRunning;");
});
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",
@@ -92,6 +93,22 @@ describe("investigation diagram presentation", () => {
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.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) => {
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);
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;");
});
});
@@ -11,7 +11,7 @@ export const investigationDiagramConfig: MermaidConfig = {
fontFamily: INVESTIGATION_DIAGRAM_FONT,
themeVariables: {
fontFamily: INVESTIGATION_DIAGRAM_FONT,
fontSize: "15px",
fontSize: "18px",
background: "#FFFFFF",
primaryColor: "#FFFFFF",
primaryTextColor: "#26334D",
@@ -28,6 +28,7 @@ export const investigationDiagramConfig: MermaidConfig = {
clusterBorder: "#CCD6E5"
},
flowchart: {
useMaxWidth: false,
curve: "stepAfter",
nodeSpacing: 56,
rankSpacing: 64,
@@ -42,10 +43,10 @@ export const investigationDiagramConfig: MermaidConfig = {
.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 { color: #536784; font-size: 15px; }
.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 { font-weight: 600; font-size: 16px; }
.cluster-label .nodeLabel { background: #F6F8FC; padding: 0 4px; }
`
};
@@ -108,15 +109,60 @@ export const investigationDiagramPlugins = {
}
};
const roleStyles = [
"classDef tjAgent fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.4px;",
"classDef tjHuman fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.4px;",
"classDef tjDecision fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,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;"
];
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" }
} as const;
/** Style supplied relationships; never create nodes from a dangling display reference. */
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";
}
function nodeStyle(state: typeof investigationDiagramStates[keyof typeof investigationDiagramStates]) {
return `fill:${state.fill},stroke:${state.stroke},color:${state.color},stroke-width:${state.width}px,stroke-dasharray:${state.dash}`;
}
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) {
const refs = new Map(diagram.node_refs.map((node) => [node.node_id, node]));
const classes = Object.values(investigationDiagramStates).map((state) => state.className);
root.querySelectorAll<SVGGElement>("svg g.node").forEach((node) => {
const ref = refs.get(investigationDiagramNodeId(node) ?? "");
const state = investigationDiagramStates[ref ? investigationNodeState(ref, running) : "unknown"];
node.classList.remove(...classes);
node.classList.add(state.className);
node.setAttribute("aria-label", `${ref?.label ?? node.textContent?.trim() ?? "节点"}:${state.label}`);
for (const shape of Array.from(node.children)) {
if (!shape.matches("rect, polygon, path, circle, ellipse")) continue;
const style = (shape as SVGElement).style;
style.setProperty("fill", state.fill, "important");
style.setProperty("stroke", state.stroke, "important");
style.setProperty("stroke-width", `${state.width}px`, "important");
style.setProperty("stroke-dasharray", state.dash, "important");
}
node.querySelectorAll<SVGElement | HTMLElement>(".nodeLabel, .nodeLabel p").forEach((label) => {
label.style.setProperty("color", state.color, "important");
label.style.setProperty("fill", state.color, "important");
});
});
}
/** Color explicit recorded states; relationships and evidence never imply completion. */
export function styleInvestigationDiagram(diagram: InvestigationDiagram, running = false) {
const assignments = diagram.node_refs.flatMap((node) => {
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -124,14 +170,15 @@ export function styleInvestigationDiagram(diagram: InvestigationDiagram, running
diagram.mermaid
);
if (!inSource) return [];
if (running && node.status === "running") return [`class ${node.node_id} tjRunning;`];
return node.action_refs.length
? [`class ${node.node_id} tjAction;`]
: node.evidence_refs.length
? [`class ${node.node_id} tjEvidence;`]
: [];
const state = investigationDiagramStates[investigationNodeState(node, running)];
return [`class ${node.node_id} ${state.className};`, `style ${node.node_id} ${nodeStyle(state)};`];
});
return [diagram.mermaid, ...roleStyles,
running ? "classDef tjRunning fill:#E8F0FF,stroke:#4263C7,color:#284574,stroke-width:2.5px;" : "classDef tjRunning fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.4px;",
...assignments].join("\n");
// Keep role/reference classes neutral. Their presence conveys association,
// while only node_refs.status supplies an execution fact.
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");
}
@@ -0,0 +1,204 @@
import { describe, expect, it, vi } from "vitest";
import type { InvestigationPresentationHistoryEvent } from "../api/investigation-presentation";
import type { InvestigationMapPresentation } from "../map/investigation-map-presentation";
import { presentationHistoryPorts } from "../map/presentation-history-ports";
import { hydrateInvestigationPresentationArchive } from "./investigation-presentation-archive";
import { PresentationViewHistory } from "./presentation-view-history";
const task = {
task_id: "50000000-0000-4000-8000-000000000001",
title: "已保存调查",
execution_generation: 2,
evidence: [],
latest_report: null
};
const chart = (title: string) => ({
action: "open_chart",
chart_id: "pressure",
evidence_refs: ["reading"],
chart: { title, chart_type: "line", x_data: ["08:00"], series: [{ name: "压力", data: [20] }] }
});
const event = (
event_seq: number,
command: unknown,
execution_generation = 2
): InvestigationPresentationHistoryEvent => ({
task_id: task.task_id,
event_seq,
run_id: "55000000-0000-4000-8000-000000000001",
execution_generation,
occurred_at: "2026-10-27T08:00:00Z",
command
});
function setup() {
const history = new PresentationViewHistory();
const scene = {
highlight: vi.fn(),
clearHighlights: vi.fn(),
setOverlay: vi.fn(),
clearOverlay: vi.fn(),
renderScadaAnalysis: vi.fn(),
clearScadaAnalysis: vi.fn()
};
const controller = {
queryPresentationFeatures: vi.fn(async () => ({
type: "FeatureCollection" as const,
features: [
{
type: "Feature" as const,
properties: { device_id: "P1" },
geometry: { type: "Point" as const, coordinates: [121.4, 31.1] }
}
]
}))
};
const createPorts = (staging: PresentationViewHistory) =>
presentationHistoryPorts({
controller,
scene: scene as unknown as InvestigationMapPresentation,
map: () => null,
padding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
update: (updater) => staging.update(updater(staging.current)),
rendering: () => false,
cameraAllowed: () => true,
waitForChart: async () => undefined,
frame: async () => undefined
});
const load = (
events: InvestigationPresentationHistoryEvent[],
signal = new AbortController().signal
) => hydrateInvestigationPresentationArchive({ events, task, history, createPorts, signal });
return { history, scene, controller, load, createPorts };
}
describe("saved investigation presentation", () => {
it("hydrates sequential snapshots, applies cleanup without history rows and deduplicates task reselection", async () => {
const test = setup();
const events = [
event(1, {
action: "highlight_features",
layer: "scada",
feature_ids: ["P1"],
effect: "pulse"
}),
event(2, chart("原始压力")),
event(3, { action: "close_chart", chart_id: "pressure" }),
event(4, { action: "clear_highlights" }),
event(5, chart("最终压力"))
];
const current = await test.load(events);
expect(current.charts[0].chart.title).toBe("最终压力");
expect(current.highlights.features).toHaveLength(0);
expect(test.history.entries).toHaveLength(4);
expect(test.history.entries[1].snapshot.charts[0].chart.title).toBe("原始压力");
expect(test.history.entries[1].snapshot.highlights.features).toHaveLength(1);
const again = await test.load(events);
expect(again.charts[0].chart.title).toBe("最终压力");
expect(test.history.entries).toHaveLength(4);
expect(test.controller.queryPresentationFeatures).toHaveBeenCalledTimes(1);
expect(Object.values(test.scene).every((fn) => fn.mock.calls.length === 0)).toBe(true);
});
it("retains prior generations as history without leaking their charts into the current generation", async () => {
const test = setup();
const next = await test.load([event(1, chart("上一轮"), 1), event(2, chart("本轮"))]);
expect(next.charts.map((item) => item.chart.title)).toEqual(["本轮"]);
expect(test.history.entries[0].taskTitle).toContain("第 1 轮");
const previousOnly = await test.load([event(1, chart("上一轮"), 1)]);
expect(previousOnly.charts).toEqual([]);
});
it("rejects unregistered commands individually and refuses another task's records", async () => {
const test = setup();
const current = await test.load([
event(1, { action: "execute", command: "unsafe" }),
event(2, chart("有效曲线"))
]);
expect(test.history.entries.map((item) => item.status)).toEqual(["error", "completed"]);
expect(current.charts[0].chart.title).toBe("有效曲线");
await expect(
test.load([
{ ...event(3, chart("错误任务")), task_id: "50000000-0000-4000-8000-000000000002" }
])
).rejects.toThrow("不属于所选调查");
});
it("does not append late results after a task switch aborts a feature lookup", async () => {
const test = setup();
let done!: () => void;
const pending = new Promise<void>((resolve) => {
done = resolve;
});
test.controller.queryPresentationFeatures.mockImplementationOnce(async () => {
await pending;
return { type: "FeatureCollection", features: [] };
});
const controller = new AbortController();
const loading = test.load(
[event(1, { action: "locate_features", layer: "scada", feature_ids: ["P1"] })],
controller.signal
);
controller.abort();
done();
await expect(loading).rejects.toThrow();
expect(test.history.entries).toHaveLength(0);
});
});
it("loads saved report blocks without display events and does not duplicate them on reselection", async () => {
const test = setup();
const reportTask = {
...task,
latest_report: {
revision: 3,
execution_generation: 2,
source_session_id: "saved",
published_by: "agent",
published_at: "2026-10-27T08:00:00Z",
report: {
title: "保存的调查报告",
current_conclusion: "待现场核查",
conclusion_boundary: "合成测试",
evidence_refs: [],
recommendations: [],
pending_items: [],
ui_blocks: [
{
evidence_refs: [],
block: {
version: "answer-ui@1" as const,
block_id: "saved-valves",
phase: "final" as const,
fallback_text: "阀门数据",
spec: {
root: "note",
elements: {
note: {
type: "Text" as const,
props: { text: "已保存阀门核查结果", tone: "neutral" as const },
children: []
}
}
}
}
}
]
}
}
};
const first = await hydrateInvestigationPresentationArchive({
events: [],
task: reportTask,
history: test.history,
createPorts: () => ({}) as never,
signal: new AbortController().signal
});
expect(first.blocks[0].block_id).toBe("saved-valves");
const second = await hydrateInvestigationPresentationArchive({
events: [],
task: reportTask,
history: test.history,
createPorts: () => ({}) as never,
signal: new AbortController().signal
});
expect(second.blocks).toBe(first.blocks);
expect(test.history.entries).toHaveLength(1);
expect(test.history.entries[0].label).toContain("报告第 3 版");
});
@@ -0,0 +1,102 @@
import { parseAnswerUiBlock } from "@/features/agent";
import type { OperationalTask } from "../api/operational-task-client";
import {
investigationPresentationEventSchema,
presentationEventKey,
type InvestigationPresentationHistoryEvent
} from "../api/investigation-presentation";
import {
executePresentationCommand,
type PresentationPorts
} from "../map/presentation-command-executor";
import { presentationCommandLabel } from "./presentation-command-label";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
/** Hydrate saved display data offscreen. No business actions, SSE publication or map mutations. */
export async function hydrateInvestigationPresentationArchive({
events,
task,
history,
createPorts,
signal
}: {
events: InvestigationPresentationHistoryEvent[];
task: Pick<
OperationalTask,
"task_id" | "title" | "execution_generation" | "evidence" | "latest_report"
>;
history: PresentationViewHistory;
createPorts: (staging: PresentationViewHistory) => PresentationPorts;
signal: AbortSignal;
}) {
const staging = new PresentationViewHistory();
const ports = createPorts(staging);
let generation: number | null = null;
for (const event of events) {
signal.throwIfAborted();
if (event.task_id !== task.task_id) throw new Error("历史展示不属于所选调查");
if (event.execution_generation !== generation) {
generation = event.execution_generation;
staging.update({
...emptyPresentationView(),
evidence: task.evidence.map(({ evidence_id, summary }) => ({ evidence_id, summary }))
});
}
const key = presentationEventKey(task.task_id, event.event_seq);
const cached = history.entries.find((entry) => entry.key === key);
if (cached) {
staging.update(cached.snapshot);
continue;
}
const parsed = investigationPresentationEventSchema.safeParse({
task_id: event.task_id,
run_id: event.run_id,
execution_generation: event.execution_generation,
command: event.command
});
let message: string | undefined;
try {
if (!parsed.success) throw new Error("已保存的展示指令不符合当前格式,未执行。");
await executePresentationCommand(parsed.data.command, signal, ports);
} catch (cause) {
signal.throwIfAborted();
message = cause instanceof Error ? cause.message : "历史展示暂不可用";
}
signal.throwIfAborted();
if (parsed.success && parsed.data.command.action.startsWith("clear_")) continue;
const id = history.begin(
parsed.success
? presentationCommandLabel(parsed.data.command, staging.current)
: "历史展示参数无效",
"live",
`${task.title} · 第 ${generation} 轮`,
{ key, taskId: task.task_id, createdAt: Date.parse(event.occurred_at) }
);
history.finish(id, message ? "error" : "completed", message, staging.current);
}
let current =
generation === task.execution_generation ? staging.current : emptyPresentationView();
const report = task.latest_report;
if (report?.execution_generation === task.execution_generation) {
const key = `task:${task.task_id}:report:${report.revision}`;
const cached = history.entries.find((entry) => entry.key === key);
const blocks =
cached?.snapshot.blocks ??
(report.report.ui_blocks ?? []).flatMap((item) => {
const block = parseAnswerUiBlock(item.block);
return block ? [block] : [];
});
if (blocks.length) {
current = { ...current, blocks };
if (!cached) {
const id = history.begin(`报告第 ${report.revision} 版 · 数据与分析`, "live", task.title, {
key,
taskId: task.task_id,
createdAt: Date.parse(report.published_at)
});
history.finish(id, "completed", undefined, current);
}
}
}
return current;
}
@@ -0,0 +1,11 @@
const ease = [0.22, 1, 0.36, 1] as const;
export const presentationTransition = (immediate: boolean) => ({
duration: immediate ? 0 : 0.18,
ease
});
export const presentationFade = (immediate: boolean) => ({
initial: immediate ? (false as const) : { opacity: 0, y: 6 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: immediate ? 0 : -4 },
transition: presentationTransition(immediate)
});
@@ -69,6 +69,7 @@ export function investigationStatus(task: OperationalTask): string {
if (task.active_run?.status === "running") return "调查中";
if (task.lifecycle_status === "failed") return "本轮执行失败";
if (task.lifecycle_status === "cancelled") return "本轮已取消";
if (task.questions?.some((question) => question.status === "pending" && question.execution_generation === task.execution_generation)) return "等待回答";
if (task.actions.some((action) => action.approval_status === "pending")) return "待人工审核";
if (
task.actions.some(
@@ -0,0 +1,20 @@
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
import { investigationActionLabels } from "./investigation-dev-preview";
import type { PresentationView } from "./presentation-view-history";
export function presentationCommandLabel(
command: InvestigationPresentationCommand,
view: PresentationView
) {
const detail =
command.action === "open_chart"
? command.chart.title
: command.action === "set_overlay"
? command.title
: command.action === "close_chart"
? view.charts.find((chart) => chart.chart_id === command.chart_id)?.chart.title
: "feature_ids" in command
? command.feature_ids.slice(0, 3).join("、")
: null;
return `${investigationActionLabels[command.action]}${detail ? ` · ${detail}` : ""}`;
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
describe("presentation history", () => {
it("retains every step across clear and keeps a past view pinned while current advances", () => {
const history = new PresentationViewHistory();
for (let index = 0; index < 15; index++) {
history.update({
...history.current,
records: [{ record_type: "candidate", element_id: `pipe-${index}` }]
});
history.record(`step-${index}`, "live", "调查");
}
expect(history.entries).toHaveLength(15);
const first = history.entries[0];
expect(history.select(first.id)).toBe(true);
history.update(emptyPresentationView());
expect(history.visible.records[0].element_id).toBe("pipe-0");
expect(history.current.records).toEqual([]);
history.select(null);
expect(history.visible.records).toEqual([]);
expect(history.entries).toHaveLength(15);
history.select(first.id);
expect(history.visible.records[0].element_id).toBe("pipe-0");
});
it("merges only the matching latest result and restores a separate history option after cleanup", () => {
const history = new PresentationViewHistory();
expect(history.currentEntryId).toBeNull();
const first = history.record("曲线", "live", null);
expect(history.currentEntryId).toBe(first);
history.update({
...history.current,
camera: { center: [121, 31], zoom: 15, bearing: 0, pitch: 0 }
});
expect(history.currentEntryId).toBe(first);
const pending = history.begin("阀门列表", "live", null);
expect(history.currentEntryId).toBeNull();
history.update({ ...history.current, records: [{ element_id: "V1" }] });
history.finish(pending, "completed");
expect(history.currentEntryId).toBe(pending);
history.update(emptyPresentationView());
expect(history.currentEntryId).toBeNull();
expect(history.entries).toHaveLength(2);
expect(history.select(pending)).toBe(true);
expect(history.visible.records).toEqual([{ element_id: "V1" }]);
});
it("reserves action order, retains errors and cancellation, and prevents stale completions after project reset", () => {
const history = new PresentationViewHistory();
const pending = history.begin("query", "live", null);
expect(history.select(pending)).toBe(false);
const captured = { ...history.current, records: [{ element_id: "captured" }] };
history.update({ ...history.current, records: [{ element_id: "later action" }] });
history.finish(pending, "error", "not found", captured);
expect(history.select(pending)).toBe(true);
expect(history.visible.records).toEqual([{ element_id: "captured" }]);
expect(history.selected?.message).toBe("not found");
const cancelled = history.begin("cancel", "preview", null);
history.finish(cancelled, "cancelled");
expect(history.entries.map((item) => item.status)).toEqual(["error", "cancelled"]);
history.reset();
const fresh = history.begin("new project", "live", null);
history.finish(pending, "completed");
expect(fresh).not.toBe(pending);
expect(history.entries).toHaveLength(1);
expect(history.entries[0].status).toBe("running");
expect(history.selectedId).toBeNull();
expect(history.current.records).toEqual([]);
});
});
@@ -0,0 +1,141 @@
import type { FeatureCollection } from "geojson";
import type { AnswerUiBlock } from "@/features/agent";
import type {
InvestigationOverlay,
InvestigationPresentationCommand
} from "../api/investigation-presentation";
import type { ScadaAnalysisItem } from "../map/scada-analysis";
import type { InvestigationPresentationActivity } from "./investigation-dev-preview";
export type PresentationCamera = {
center: [number, number];
zoom: number;
bearing: number;
pitch: number;
};
export type PresentationView = {
charts: Array<Extract<InvestigationPresentationCommand, { action: "open_chart" }>>;
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
evidence: Array<{ evidence_id: string; summary: string }>;
highlights: FeatureCollection;
highlightLayers: string[];
overlay: InvestigationOverlay | null;
scada: { data: FeatureCollection; items: ScadaAnalysisItem[] } | null;
camera: PresentationCamera | null;
};
export type PresentationHistoryEntry = {
id: number;
key?: string;
taskId?: string;
label: string;
source: InvestigationPresentationActivity["source"];
taskTitle: string | null;
createdAt: number;
status: "running" | "completed" | "error" | "cancelled";
message?: string;
snapshot: PresentationView;
};
export type PresentationHistoryControls = {
currentEntryId: number | null;
entries: PresentationHistoryEntry[];
selectedId: number | null;
onSelect: (id: number | null) => void;
};
export const emptyPresentationView = (): PresentationView => ({
charts: [],
blocks: [],
records: [],
evidence: [],
highlights: { type: "FeatureCollection", features: [] },
highlightLayers: [],
overlay: null,
scada: null,
camera: null
});
/** Page-local immutable snapshots. Updating current never changes a pinned historical view. */
export class PresentationViewHistory {
current = emptyPresentationView();
entries: PresentationHistoryEntry[] = [];
selectedId: number | null = null;
private sequence = 0;
get currentEntryId() {
if (this.entries.at(-1)?.status === "running") return null;
// Results share immutable values with their snapshot. Capturing a manually moved
// camera for navigation does not turn the latest result into an older result.
const keys = Object.keys(this.current) as Array<keyof PresentationView>;
return (
this.entries
.slice()
.reverse()
.find(
(entry) =>
entry.status === "completed" &&
keys.every((key) => key === "camera" || entry.snapshot[key] === this.current[key])
)?.id ?? null
);
}
get selected() {
return this.entries.find((entry) => entry.id === this.selectedId) ?? null;
}
get visible() {
return this.selected?.snapshot ?? this.current;
}
update(next: PresentationView) {
this.current = next;
}
begin(
label: string,
source: PresentationHistoryEntry["source"],
taskTitle: string | null,
metadata: { key?: string; taskId?: string; createdAt?: number } = {}
) {
const id = ++this.sequence;
this.entries = [
...this.entries,
{
id,
label,
source,
taskTitle,
createdAt: Date.now(),
...metadata,
status: "running",
snapshot: this.current
}
];
return id;
}
finish(
id: number,
status: Exclude<PresentationHistoryEntry["status"], "running">,
message?: string,
snapshot = this.current
) {
this.entries = this.entries.map((entry) =>
entry.id === id ? { ...entry, status, message, snapshot } : entry
);
}
record(
label: string,
source: PresentationHistoryEntry["source"],
taskTitle: string | null,
metadata: { key?: string; taskId?: string; createdAt?: number } = {}
) {
const id = this.begin(label, source, taskTitle, metadata);
this.finish(id, "completed");
return id;
}
select(id: number | null) {
if (id !== null && !this.entries.some((entry) => entry.id === id && entry.status !== "running"))
return false;
this.selectedId = id;
return true;
}
reset() {
this.current = emptyPresentationView();
this.entries = [];
this.selectedId = null;
}
}