feat: expand intelligent investigation workspace
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { Check, Download, FlaskConical, Play, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import {
|
||||
investigationDevCases,
|
||||
type InvestigationDevCaseId
|
||||
} from "../utils/investigation-dev-cases";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import {
|
||||
investigationActionLabels,
|
||||
investigationReportJsonl,
|
||||
investigationReportUiSummary,
|
||||
parseInvestigationDevJsonl,
|
||||
type InvestigationDevPreview,
|
||||
type InvestigationPresentationActivity
|
||||
} from "../utils/investigation-dev-preview";
|
||||
|
||||
export type InvestigationDevToolsProps = {
|
||||
task: OperationalTask | null;
|
||||
mapReady: boolean;
|
||||
charts: number;
|
||||
overlay: string | null;
|
||||
activity: InvestigationPresentationActivity[];
|
||||
onSample: (caseId?: InvestigationDevCaseId) => string;
|
||||
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
export function InvestigationDevTools({
|
||||
task,
|
||||
mapReady,
|
||||
charts,
|
||||
overlay,
|
||||
activity,
|
||||
onSample,
|
||||
onPreview,
|
||||
onClear
|
||||
}: InvestigationDevToolsProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [input, setInput] = useState("");
|
||||
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
|
||||
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
|
||||
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [selectedRecord, setSelectedRecord] = useState(0);
|
||||
const previewActive = useRef(false);
|
||||
const inputId = useId();
|
||||
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
|
||||
|
||||
function replaceInput(value: string, nextCaseId?: InvestigationDevCaseId) {
|
||||
setInput(value);
|
||||
setCaseId(nextCaseId);
|
||||
setChecked(null);
|
||||
setPreview(null);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setSelectedRecord(0);
|
||||
}
|
||||
function clearLoadedPreview() {
|
||||
if (!previewActive.current) return;
|
||||
onClear();
|
||||
previewActive.current = false;
|
||||
}
|
||||
function loadCase(nextCaseId: InvestigationDevCaseId) {
|
||||
if (running || !mapReady) return;
|
||||
clearLoadedPreview();
|
||||
replaceInput(onSample(nextCaseId), nextCaseId);
|
||||
}
|
||||
const selectedCase = investigationDevCases.find((item) => item.id === caseId);
|
||||
|
||||
function validate() {
|
||||
try {
|
||||
const parsed = parseInvestigationDevJsonl(input);
|
||||
setChecked(parsed);
|
||||
setError(null);
|
||||
setMessage(
|
||||
`已识别 ${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
|
||||
);
|
||||
return parsed;
|
||||
} catch (cause) {
|
||||
setChecked(null);
|
||||
setError(cause instanceof Error ? cause.message : "JSONL 校验失败。");
|
||||
setMessage(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
if (running || !input.trim()) return;
|
||||
const parsed = validate();
|
||||
if (!parsed) return;
|
||||
if (parsed.commands.length > 0 && !mapReady) {
|
||||
setError("地图尚未就绪,请稍后再执行本地预览。");
|
||||
setMessage(null);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setPreview(parsed);
|
||||
previewActive.current = true;
|
||||
try {
|
||||
await onPreview(parsed, selectedCase?.label ?? "JSONL");
|
||||
setMessage(
|
||||
`本地预览完成:${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
|
||||
);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError"))
|
||||
setError(cause instanceof Error ? cause.message : "本地展示失败。");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
function download() {
|
||||
if (!checked) return;
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([`${input.trim()}\n`], { type: "application/x-ndjson;charset=utf-8" })
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `investigation-preview-${task?.execution_generation ?? "local"}.jsonl`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4" data-testid="investigation-dev-tools">
|
||||
<div className="space-y-1 border-b border-border pb-4 text-xs leading-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">当前调查</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-8 px-0 text-xs"
|
||||
aria-expanded={open}
|
||||
aria-controls="investigation-dev-lab"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<FlaskConical size={14} />
|
||||
{open ? "收起动作测试" : "测试地图、图表与 JSONL"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="break-words text-sm font-semibold text-foreground">
|
||||
{task ? task.title : "尚未选择调查"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{task
|
||||
? `第 ${task.execution_generation} 轮 · 实时图表 ${charts} · 报告图表 ${summary.charts} · 表格 ${summary.tables} · 地图入口 ${summary.maps}`
|
||||
: "可直接载入、校验和执行本地案例;选择调查后可观察实时展示。"}
|
||||
</p>
|
||||
{task?.latest_report ? (
|
||||
<p className="text-muted-foreground">
|
||||
已保存报告第 {task.latest_report.revision} 版 · 第{" "}
|
||||
{task.latest_report.execution_generation ?? "未知"} 轮
|
||||
{task.latest_report.execution_generation !== task.execution_generation
|
||||
? ",上一轮内容"
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
{overlay ? <p className="break-words text-muted-foreground">覆盖层:{overlay}</p> : null}
|
||||
{summary.invalid ? (
|
||||
<p className="text-[var(--status-warning-foreground)]">
|
||||
{summary.invalid} 个报告界面块校验未通过。
|
||||
</p>
|
||||
) : null}
|
||||
{task && !task.latest_report ? (
|
||||
<p className="text-muted-foreground">本轮尚无报告,实时联动只处理选中后的新指令。</p>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
|
||||
<h3 className="text-xs font-semibold">01 准备数据</h3>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
|
||||
</p>
|
||||
<FieldSelect
|
||||
label="JSONL 测试案例"
|
||||
placeholder="选择案例,立即载入 JSONL"
|
||||
value={caseId ?? ""}
|
||||
onValueChange={(value) => {
|
||||
const nextCase = investigationDevCases.find((item) => item.id === value);
|
||||
if (nextCase) loadCase(nextCase.id);
|
||||
}}
|
||||
options={investigationDevCases.map((item) => ({ value: item.id, label: item.label }))}
|
||||
disabled={!mapReady || running}
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
/>
|
||||
{!mapReady ? (
|
||||
<p className="text-[11px] text-muted-foreground">地图正在初始化,就绪后可载入案例。</p>
|
||||
) : null}
|
||||
{selectedCase ? (
|
||||
<p aria-label="案例预期结果" className="text-xs leading-5 text-muted-foreground">
|
||||
预期:{selectedCase.expected}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
切换即载入,执行时自动校验;已执行的本地预览会清除。
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!mapReady || running}
|
||||
onClick={() => loadCase(caseId ?? "combined")}
|
||||
>
|
||||
载入合成样例
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!task || !summary.blocks || running}
|
||||
onClick={() => {
|
||||
if (task) {
|
||||
clearLoadedPreview();
|
||||
replaceInput(investigationReportJsonl(task));
|
||||
}
|
||||
}}
|
||||
>
|
||||
载入当前报告
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 border-t border-border pt-4">
|
||||
<h3 className="pb-2 text-xs font-semibold">02 校验与执行</h3>
|
||||
<label htmlFor={inputId} className="text-xs font-medium">
|
||||
调查展示 JSONL
|
||||
</label>
|
||||
<Textarea
|
||||
id={inputId}
|
||||
value={input}
|
||||
disabled={running}
|
||||
onChange={(event) => replaceInput(event.target.value)}
|
||||
rows={5}
|
||||
maxLength={200_000}
|
||||
spellCheck={false}
|
||||
className="min-h-28 resize-y font-mono text-[11px] leading-5"
|
||||
placeholder="每行一条展示指令、界面块或带来源的调查数据"
|
||||
/>
|
||||
</div>
|
||||
<Disclosure
|
||||
label="JSONL 格式说明"
|
||||
className="[&>summary]:min-h-8 [&>summary]:text-muted-foreground"
|
||||
>
|
||||
<p className="pb-2 text-[11px] leading-5 text-muted-foreground">
|
||||
presentation 执行受控展示,ui_block 预览界面;candidate
|
||||
等调查数据仅展示,不自动操作地图。
|
||||
</p>
|
||||
</Disclosure>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!input.trim() || running}
|
||||
onClick={validate}
|
||||
>
|
||||
<Check size={14} />
|
||||
校验 JSONL
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="text-xs"
|
||||
disabled={!input.trim() || running}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
<Play size={14} />
|
||||
{running ? "正在预览" : "执行本地预览"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
点击执行会自动校验当前 JSONL,整批校验通过后才预览;也可单独校验。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">03 检查结果</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
disabled={!checked || running}
|
||||
onClick={download}
|
||||
>
|
||||
<Download size={14} />
|
||||
下载测试 JSONL
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
onClear();
|
||||
previewActive.current = false;
|
||||
setPreview(null);
|
||||
setMessage("已清除当前浏览器的临时展示。");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
清除临时展示
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{message ? (
|
||||
<p role="status" className="break-words text-xs leading-5 text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="break-words text-xs leading-5 text-[var(--status-danger-foreground)]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{preview ? (
|
||||
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
业务结果已在展示面板打开,可切换动作历史回看。
|
||||
</p>
|
||||
<Disclosure label="原始数据检查">
|
||||
{preview.records.length ? (
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<table className="w-full text-left text-[11px] leading-5">
|
||||
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-1 pr-3 font-medium">类型</th>
|
||||
<th className="py-1 pr-3 font-medium">对象</th>
|
||||
<th className="py-1 pr-3 font-medium">来源</th>
|
||||
<th className="py-1 text-right font-medium">证据</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.records.map((record, index) => (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
|
||||
aria-label={`查看第 ${index + 1} 条调查数据`}
|
||||
aria-pressed={selectedRecord === index}
|
||||
onClick={() => setSelectedRecord(index)}
|
||||
>
|
||||
{String(
|
||||
record.element_id ??
|
||||
record.device_id ??
|
||||
record.action_id ??
|
||||
record.item ??
|
||||
`记录 ${index + 1}`
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">{String(record.origin)}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{(record.source_evidence_refs as string[]).length}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<pre
|
||||
aria-label="调查数据详情"
|
||||
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
|
||||
>
|
||||
{JSON.stringify(preview.records[selectedRecord], null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</Disclosure>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{activity.length ? (
|
||||
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">最近展示动作</h3>
|
||||
<ol
|
||||
aria-label="动作识别记录"
|
||||
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
|
||||
>
|
||||
{activity.slice(-3).map((item) => (
|
||||
<li key={`${item.source}-${item.id}`} className="break-words">
|
||||
<span className="text-muted-foreground">
|
||||
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
|
||||
</span>
|
||||
{investigationActionLabels[item.action]} ·{" "}
|
||||
{item.status === "completed"
|
||||
? "已应用"
|
||||
: item.status === "error"
|
||||
? "未完成"
|
||||
: "展示中"}
|
||||
{item.message ? (
|
||||
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user