230 lines
8.0 KiB
TypeScript
230 lines
8.0 KiB
TypeScript
import { z } from "zod";
|
||
import {
|
||
parseAnswerUiBlock,
|
||
presentationCommandSchema,
|
||
type AnswerUiBlock
|
||
} from "@/features/agent";
|
||
import { type InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||
import { INVESTIGATION_DEV_NETWORK } from "./investigation-dev-network";
|
||
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 createInvestigationDevSample(): string {
|
||
const center: [number, number] = [...INVESTIGATION_DEV_NETWORK.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: [
|
||
[INVESTIGATION_DEV_NETWORK.pipeIds[0], "1", "仅测试表格展示"],
|
||
[INVESTIGATION_DEV_NETWORK.pipeIds[1], "2", "未执行定位分析"]
|
||
]
|
||
},
|
||
children: []
|
||
}
|
||
}
|
||
}
|
||
};
|
||
const records: unknown[] = [];
|
||
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: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, 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: INVESTIGATION_DEV_NETWORK.pipeIds[0],
|
||
rank: 1,
|
||
status: "example_only"
|
||
}
|
||
);
|
||
return serializeInvestigationDevJsonl(records);
|
||
}
|