"use client"; import { ExternalLinkIcon, FileIcon, RefreshCwIcon, XIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Button } from "@/components/ui/button"; import { Sheet, SheetClose, SheetContent, SheetTitle, } from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; type TableSection = { title: string; source_path: string; kind: "csv" | "jsonl" | "text" | "markdown" | "missing" | "error"; columns: string[]; rows: string[][]; total_rows: number; shown_rows: number; error?: string; body?: string; }; type StepDetail = { step: string; run_dic: number | null; format: | "markdown" | "jsonl" | "json" | "csv" | "text" | "empty" | "tables"; content?: string; sections?: TableSection[]; source_path: string | null; fetched_at_ms: number; error?: string; tried_paths?: string[]; }; type Props = { sessionId: string | null; stepKey: string | null; stepTitle?: string; stepStatus?: string; open: boolean; onOpenChange: (open: boolean) => void; }; export function StepDetailSheet({ sessionId, stepKey, stepTitle, stepStatus, open, onOpenChange, }: Props) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const [reloadCount, setReloadCount] = useState(0); useEffect(() => { if (!open || !sessionId || !stepKey) return; let cancelled = false; setLoading(true); setDetail(null); const url = `/api/claw/training/step-detail?session_id=${encodeURIComponent(sessionId)}&step=${encodeURIComponent(stepKey)}`; fetch(url, { cache: "no-store" }) .then((res) => (res.ok ? res.json() : null)) .then((payload) => { if (cancelled) return; setDetail(payload); setLoading(false); }) .catch(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [open, sessionId, stepKey, reloadCount]); return (
{stepTitle ?? stepKey ?? "—"} {stepStatus ? : null} {detail?.run_dic ? ( runDic={detail.run_dic} ) : null}
{detail?.source_path ? (
{detail.source_path}
) : null}
{loading ? (

加载中...

) : !detail ? (

无法获取详情

) : detail.format === "empty" ? ( ) : detail.format === "tables" ? ( ) : detail.format === "markdown" ? ( ) : detail.format === "json" ? ( ) : detail.format === "jsonl" ? ( ) : ( )}
{detail?.source_path && !loading ? (
{detail.format} ·{" "} {detail.fetched_at_ms ? new Date(detail.fetched_at_ms).toLocaleTimeString() : ""}
) : null}
); } function EmptyView({ detail }: { detail: StepDetail }) { return (

{detail.error ?? "暂无产物。"}

{detail.tried_paths && detail.tried_paths.length > 0 ? (
尝试的路径
    {detail.tried_paths.map((p) => (
  • {p}
  • ))}
) : null}
); } type JsonValue = | string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; function JsonView({ text }: { text: string }) { const parsed = useMemo<{ ok: true; value: JsonValue } | { ok: false }>(() => { try { return { ok: true, value: JSON.parse(text) as JsonValue }; } catch { return { ok: false }; } }, [text]); if (!parsed.ok) { return (
				{text}
			
); } const value = parsed.value; if (Array.isArray(value)) { return (
{value.length} 项
{value.map((item, idx) => (
#{idx}
))}
); } if (value !== null && typeof value === "object") { return (
} depth={0} />
); } return (
); } function JsonObjectView({ value, depth, }: { value: Record; depth: number; }) { const keys = Object.keys(value); if (keys.length === 0) { return ( {`{}`} ); } return ( <> {keys.map((k) => ( ))} ); } function JsonValueView({ value, depth, }: { value: JsonValue; depth: number; }) { if (value === null || typeof value !== "object") { return ; } if (Array.isArray(value)) { if (value.length === 0) { return ( [] ); } const allPrimitive = value.every( (v) => v === null || typeof v !== "object", ); if (allPrimitive) { return (
{value.map((v, i) => ( ))}
); } const allObjects = value.every( (v) => v !== null && typeof v === "object" && !Array.isArray(v), ); if (allObjects) { const cols = Array.from( new Set( value.flatMap((v) => Object.keys(v as Record), ), ), ); const flat = cols.length <= 8 && depth <= 1; if (flat) { return (
{cols.map((c) => ( ))} {value.map((row, i) => ( {cols.map((c) => { const cell = (row as Record)[c]; return ( ); })} ))}
{c}
{cell === undefined ? ( ) : ( )}
); } } return (
{value.map((v, i) => (
#{i}
))}
); } const obj = value as Record; if (depth >= 1) { return (
); } return (
); } function JsonPrimitive({ value }: { value: JsonValue }) { if (value === null) { return ( null ); } if (typeof value === "boolean") { return ( {String(value)} ); } if (typeof value === "number") { return ( {value} ); } if (typeof value !== "string") return null; const str = value; if (str.includes("\n")) { return (
				{str}
			
); } return {str}; } type IterationEntry = { iteration?: number; runDic?: number; timestamp?: string; hypothesis?: string; intervention?: { type?: string; summary?: string } | string; prediction?: Record; results?: Record; verdict?: string; root_cause_findings?: Array<{ pattern?: string; class?: string; cases?: number; }>; error_delta?: { persistent?: number; new?: number; fixed?: number }; next_hypothesis?: string; [key: string]: unknown; }; const KNOWN_KEYS = new Set([ "iteration", "runDic", "timestamp", "hypothesis", "intervention", "prediction", "results", "verdict", "root_cause_findings", "error_delta", "next_hypothesis", ]); function JsonLinesView({ text }: { text: string }) { const entries = useMemo(() => { return text .split("\n") .map((l) => l.trim()) .filter(Boolean) .map((l) => { try { return { ok: true as const, data: JSON.parse(l) as IterationEntry }; } catch { return { ok: false as const, raw: l }; } }); }, [text]); if (entries.length === 0) { return (

无记录。

); } return (
{entries.length} 条记录(最新在最后)
{entries.map((entry, idx) => entry.ok ? ( ) : (
行 #{idx + 1} 无法解析为 JSON
							{entry.raw}
						
), )}
); } function IterationCard({ entry, defaultOpen, }: { entry: IterationEntry; defaultOpen: boolean; }) { const intervention = typeof entry.intervention === "string" ? { summary: entry.intervention } : (entry.intervention ?? {}); const otherKeys = Object.keys(entry).filter((k) => !KNOWN_KEYS.has(k)); return (
R{entry.iteration ?? "?"} {entry.runDic != null ? ( runDic={entry.runDic} ) : null} {entry.verdict ? : null} {entry.timestamp ?? ""}
{entry.hypothesis ? ( {entry.hypothesis} ) : null} {intervention.summary || intervention.type ? (
{intervention.type ? ( {intervention.type} ) : null} {intervention.summary ? ( {intervention.summary} ) : null}
) : null} {entry.prediction || entry.results ? ( ) : null} {entry.error_delta ? (
) : null} {entry.root_cause_findings && entry.root_cause_findings.length > 0 ? (
{entry.root_cause_findings.map((f, i) => ( ))}
pattern class cases
{f.pattern ?? "—"} {f.class ? ( {f.class} ) : ( "—" )} {f.cases ?? "—"}
) : null} {entry.next_hypothesis ? ( {entry.next_hypothesis} ) : null} {otherKeys.length > 0 ? (
其他字段({otherKeys.length})
							{JSON.stringify(
								Object.fromEntries(otherKeys.map((k) => [k, entry[k]])),
								null,
								2,
							)}
						
) : null}
); } function Field({ label, children, tone, }: { label: string; children: React.ReactNode; tone?: "primary" | "accent"; }) { return (
{label}
{children}
); } function VerdictPill({ verdict }: { verdict: string }) { const map: Record = { pass: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", partial: "bg-amber-500/15 text-amber-700 dark:text-amber-300", miss: "bg-rose-500/15 text-rose-700 dark:text-rose-300", replication: "bg-sky-500/15 text-sky-700 dark:text-sky-300", }; return ( {verdict.toUpperCase()} ); } function DeltaPill({ label, value, tone, }: { label: string; value: number | undefined; tone: "rose" | "emerald" | "muted"; }) { if (value == null) return null; const toneMap: Record = { rose: "bg-rose-500/15 text-rose-700 dark:text-rose-300", emerald: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", muted: "bg-muted text-muted-foreground", }; return ( {label} {value} ); } function MetricsCompare({ prediction, results, }: { prediction?: Record; results?: Record; }) { const keys = useMemo(() => { const set = new Set(); Object.keys(prediction ?? {}).forEach((k) => set.add(k)); Object.keys(results ?? {}).forEach((k) => set.add(k)); return Array.from(set); }, [prediction, results]); if (keys.length === 0) return null; return (
{keys.map((k) => { const p = prediction?.[k]; const r = results?.[k]; const pn = typeof p === "number" ? p : Number(p); const rn = typeof r === "number" ? r : Number(r); const delta = Number.isFinite(pn) && Number.isFinite(rn) ? rn - pn : null; const deltaTone = delta == null ? "" : delta > 0 ? "text-emerald-600 dark:text-emerald-400" : delta < 0 ? "text-rose-600 dark:text-rose-400" : "text-muted-foreground"; return ( ); })}
metric prediction result Δ
{k} {p ?? "—"} {r ?? "—"} {delta == null ? "—" : `${delta > 0 ? "+" : ""}${delta.toFixed(2)}`}
); } const PROSE_CLASSES = cn( "prose prose-sm max-w-none dark:prose-invert", "prose-headings:scroll-m-20 prose-headings:font-semibold", "prose-h1:mt-4 prose-h1:mb-2 prose-h1:text-lg", "prose-h2:mt-4 prose-h2:mb-2 prose-h2:text-base prose-h2:border-b prose-h2:border-border prose-h2:pb-1", "prose-h3:mt-3 prose-h3:mb-1.5 prose-h3:text-sm", "prose-p:my-2 prose-p:leading-relaxed", "prose-a:text-primary prose-a:no-underline hover:prose-a:underline", "prose-strong:text-foreground prose-strong:font-semibold", "prose-blockquote:my-2 prose-blockquote:border-l-4 prose-blockquote:border-muted-foreground/30 prose-blockquote:pl-3 prose-blockquote:text-muted-foreground prose-blockquote:not-italic", "prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5", "prose-hr:my-3 prose-hr:border-border", "prose-table:my-3 prose-table:text-xs", "prose-thead:bg-muted prose-th:px-2 prose-th:py-1 prose-th:font-semibold prose-th:text-left", "prose-td:px-2 prose-td:py-1 prose-tr:border-b prose-tr:border-border", "prose-code:rounded prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[0.85em] prose-code:before:content-none prose-code:after:content-none", "prose-pre:my-2 prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted/40 prose-pre:p-3 prose-pre:text-[12px] prose-pre:text-foreground", ); type MarkdownSection = { title: string | null; body: string }; const HIDDEN_MD_SECTION_PATTERNS: RegExp[] = [ /^(?:\d+(?:\.\d+)*\s+)?根因归类/, /^(?:\d+(?:\.\d+)*\s+)?下一轮假设候选/, /^(?:\d+(?:\.\d+)*\s+)?error_registry\s+baseline/i, ]; function isHiddenHeading(title: string): boolean { const trimmed = title.trim(); return HIDDEN_MD_SECTION_PATTERNS.some((re) => re.test(trimmed)); } function stripHiddenSections(text: string): string { const lines = text.split("\n"); const out: string[] = []; let skipUntilLevel: number | null = null; for (const line of lines) { const m = line.match(/^(#{1,6})\s+(.+?)\s*$/); if (m) { const level = m[1].length; const title = m[2]; if (skipUntilLevel !== null && level <= skipUntilLevel) { skipUntilLevel = null; } if (skipUntilLevel === null && isHiddenHeading(title)) { skipUntilLevel = level; continue; } } if (skipUntilLevel !== null) continue; out.push(line); } return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd(); } function splitMarkdownSections(text: string): MarkdownSection[] { const sections: MarkdownSection[] = []; let current: MarkdownSection = { title: null, body: "" }; const lines = text.split("\n"); for (const line of lines) { const m = line.match(/^##\s+(.+?)\s*$/); if (m) { if (current.title !== null || current.body.trim().length > 0) { sections.push(current); } current = { title: m[1], body: "" }; } else { current.body += (current.body ? "\n" : "") + line; } } if (current.title !== null || current.body.trim().length > 0) { sections.push(current); } return sections; } function MarkdownView({ text }: { text: string }) { const sections = useMemo(() => { const stripped = stripHiddenSections(text); return splitMarkdownSections(stripped).filter( (s) => s.title === null || !isHiddenHeading(s.title), ); }, [text]); if (sections.length === 0) { return (

空内容。

); } return (
{sections.map((section, idx) => section.title === null ? (
{section.body}
) : (
{section.title}
{section.body}
), )}
); } function TablesView({ sections }: { sections: TableSection[] }) { if (sections.length === 0) { return

空内容。

; } return (
{sections.map((section, idx) => ( ))}
); } function TableSectionCard({ section, defaultOpen, }: { section: TableSection; defaultOpen: boolean; }) { const isMissing = section.kind === "missing" || section.kind === "error"; const isMarkdown = section.kind === "markdown"; const truncated = section.shown_rows > 0 && section.shown_rows < section.total_rows; const noteParts: string[] = []; if (section.total_rows > 0) { noteParts.push(`共 ${section.total_rows} 行`); if (truncated) { noteParts.push(`仅显示前 ${section.shown_rows} 行`); } } if (section.kind === "csv") noteParts.push("CSV"); else if (section.kind === "jsonl") noteParts.push("JSONL"); else if (section.kind === "text") noteParts.push("TEXT"); else if (section.kind === "markdown") noteParts.push("MARKDOWN"); return (
{section.title} {noteParts.length > 0 ? ( {noteParts.join(" · ")} ) : null}
{section.source_path}
{isMissing ? (

{section.error ?? "产物文件不存在或读取失败"}

) : isMarkdown ? ( ) : section.rows.length === 0 ? (

空表。

) : ( )}
); } function DataTable({ columns, rows, }: { columns: string[]; rows: string[][]; }) { return (
{columns.map((c) => ( ))} {rows.map((row, rIdx) => ( {columns.map((c, cIdx) => { const cell = row[cIdx] ?? ""; return ( ); })} ))}
# {c}
{rIdx + 1} {cell === "" ? ( ) : ( )}
); } function TableCell({ text }: { text: string }) { if (text.includes("\n")) { return (
				{text}
			
); } return {text}; } function RawView({ text }: { text: string }) { return (
			{text}
		
); } function StatusPill({ status }: { status: string }) { const map: Record = { complete: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", running: "bg-sky-500/20 text-sky-700 dark:text-sky-200", pending: "bg-muted text-muted-foreground", failed: "bg-rose-500/15 text-rose-700 dark:text-rose-300", cancelled: "bg-muted text-muted-foreground", }; return ( {status.toUpperCase()} ); }