feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user