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
@@ -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;
}
}