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