1157 lines
31 KiB
TypeScript
1157 lines
31 KiB
TypeScript
"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<StepDetail | null>(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 (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent
|
||
side="right"
|
||
hideClose
|
||
className="flex w-[min(50rem,calc(100vw-2rem))] flex-col gap-0 p-0 sm:max-w-2xl"
|
||
>
|
||
<header className="flex shrink-0 items-start justify-between gap-3 border-b px-5 py-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<SheetTitle className="font-semibold text-base text-foreground">
|
||
{stepTitle ?? stepKey ?? "—"}
|
||
</SheetTitle>
|
||
{stepStatus ? <StatusPill status={stepStatus} /> : null}
|
||
{detail?.run_dic ? (
|
||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||
runDic={detail.run_dic}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{detail?.source_path ? (
|
||
<div className="mt-1 flex items-center gap-1 truncate font-mono text-[11px] text-muted-foreground">
|
||
<FileIcon className="size-3 shrink-0" />
|
||
<span className="truncate">{detail.source_path}</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-0.5 flex shrink-0 items-center gap-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-7"
|
||
onClick={() => setReloadCount((c) => c + 1)}
|
||
title="刷新"
|
||
aria-label="刷新"
|
||
>
|
||
<RefreshCwIcon className="size-3.5" />
|
||
</Button>
|
||
<SheetClose
|
||
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||
aria-label="关闭"
|
||
>
|
||
<XIcon className="size-3.5" />
|
||
</SheetClose>
|
||
</div>
|
||
</header>
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 text-sm">
|
||
{loading ? (
|
||
<p className="text-muted-foreground text-xs">加载中...</p>
|
||
) : !detail ? (
|
||
<p className="text-muted-foreground text-xs">无法获取详情</p>
|
||
) : detail.format === "empty" ? (
|
||
<EmptyView detail={detail} />
|
||
) : detail.format === "tables" ? (
|
||
<TablesView sections={detail.sections ?? []} />
|
||
) : detail.format === "markdown" ? (
|
||
<MarkdownView text={detail.content ?? ""} />
|
||
) : detail.format === "json" ? (
|
||
<JsonView text={detail.content ?? ""} />
|
||
) : detail.format === "jsonl" ? (
|
||
<JsonLinesView text={detail.content ?? ""} />
|
||
) : (
|
||
<RawView text={detail.content ?? ""} />
|
||
)}
|
||
</div>
|
||
{detail?.source_path && !loading ? (
|
||
<footer className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-5 py-2 text-[11px] text-muted-foreground">
|
||
<span>
|
||
{detail.format} ·{" "}
|
||
{detail.fetched_at_ms
|
||
? new Date(detail.fetched_at_ms).toLocaleTimeString()
|
||
: ""}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||
onClick={() => {
|
||
if (detail.content) {
|
||
navigator.clipboard?.writeText(detail.content);
|
||
}
|
||
}}
|
||
>
|
||
<ExternalLinkIcon className="size-3" />
|
||
复制全文
|
||
</button>
|
||
</footer>
|
||
) : null}
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function EmptyView({ detail }: { detail: StepDetail }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<p className="rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 p-3 text-muted-foreground text-xs">
|
||
{detail.error ?? "暂无产物。"}
|
||
</p>
|
||
{detail.tried_paths && detail.tried_paths.length > 0 ? (
|
||
<div>
|
||
<div className="mb-1 text-[10px] font-medium tracking-wider text-muted-foreground uppercase">
|
||
尝试的路径
|
||
</div>
|
||
<ul className="space-y-1 font-mono text-[11px]">
|
||
{detail.tried_paths.map((p) => (
|
||
<li key={p} className="text-muted-foreground">
|
||
{p}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<pre className="overflow-x-auto rounded-md border bg-muted/30 p-3 font-mono text-[11px] leading-relaxed">
|
||
{text}
|
||
</pre>
|
||
);
|
||
}
|
||
|
||
const value = parsed.value;
|
||
if (Array.isArray(value)) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{value.length} 项
|
||
</div>
|
||
{value.map((item, idx) => (
|
||
<details
|
||
key={idx}
|
||
open={idx === 0}
|
||
className="rounded-lg border bg-card shadow-sm"
|
||
>
|
||
<summary className="cursor-pointer border-b px-4 py-2 font-mono text-[11px] text-muted-foreground hover:bg-muted/30">
|
||
#{idx}
|
||
</summary>
|
||
<div className="px-4 py-3">
|
||
<JsonValueView value={item} depth={0} />
|
||
</div>
|
||
</details>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
if (value !== null && typeof value === "object") {
|
||
return (
|
||
<div className="rounded-lg border bg-card shadow-sm">
|
||
<div className="space-y-3 px-4 py-3">
|
||
<JsonObjectView value={value as Record<string, JsonValue>} depth={0} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="rounded-md border bg-muted/20 px-3 py-2 font-mono text-[12px]">
|
||
<JsonPrimitive value={value} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function JsonObjectView({
|
||
value,
|
||
depth,
|
||
}: {
|
||
value: Record<string, JsonValue>;
|
||
depth: number;
|
||
}) {
|
||
const keys = Object.keys(value);
|
||
if (keys.length === 0) {
|
||
return (
|
||
<span className="font-mono text-[11px] text-muted-foreground">
|
||
{`{}`}
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
{keys.map((k) => (
|
||
<Field key={k} label={k}>
|
||
<JsonValueView value={value[k]} depth={depth + 1} />
|
||
</Field>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function JsonValueView({
|
||
value,
|
||
depth,
|
||
}: {
|
||
value: JsonValue;
|
||
depth: number;
|
||
}) {
|
||
if (value === null || typeof value !== "object") {
|
||
return <JsonPrimitive value={value} />;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
if (value.length === 0) {
|
||
return (
|
||
<span className="font-mono text-[11px] text-muted-foreground">
|
||
[]
|
||
</span>
|
||
);
|
||
}
|
||
const allPrimitive = value.every(
|
||
(v) => v === null || typeof v !== "object",
|
||
);
|
||
if (allPrimitive) {
|
||
return (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{value.map((v, i) => (
|
||
<span
|
||
key={i}
|
||
className="inline-flex rounded border bg-muted/30 px-1.5 py-0.5 font-mono text-[11px]"
|
||
>
|
||
<JsonPrimitive value={v} />
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
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<string, JsonValue>),
|
||
),
|
||
),
|
||
);
|
||
const flat = cols.length <= 8 && depth <= 1;
|
||
if (flat) {
|
||
return (
|
||
<div className="overflow-x-auto rounded-md border">
|
||
<table className="w-full border-collapse text-[12px]">
|
||
<thead className="bg-muted/50">
|
||
<tr>
|
||
{cols.map((c) => (
|
||
<th
|
||
key={c}
|
||
className="border-b px-2 py-1 text-left font-medium"
|
||
>
|
||
{c}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{value.map((row, i) => (
|
||
<tr
|
||
key={i}
|
||
className="border-b last:border-b-0 hover:bg-muted/20"
|
||
>
|
||
{cols.map((c) => {
|
||
const cell = (row as Record<string, JsonValue>)[c];
|
||
return (
|
||
<td
|
||
key={c}
|
||
className="px-2 py-1 align-top font-mono text-[11px]"
|
||
>
|
||
{cell === undefined ? (
|
||
<span className="text-muted-foreground">—</span>
|
||
) : (
|
||
<JsonValueView value={cell} depth={depth + 2} />
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
return (
|
||
<div className="space-y-1.5">
|
||
{value.map((v, i) => (
|
||
<details key={i} className="rounded-md border bg-muted/20">
|
||
<summary className="cursor-pointer px-3 py-1 font-mono text-[10px] text-muted-foreground hover:bg-muted/40">
|
||
#{i}
|
||
</summary>
|
||
<div className="space-y-2 px-3 pt-1 pb-2">
|
||
<JsonValueView value={v} depth={depth + 1} />
|
||
</div>
|
||
</details>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
const obj = value as Record<string, JsonValue>;
|
||
if (depth >= 1) {
|
||
return (
|
||
<div className="space-y-2 border-l-2 border-muted pl-3">
|
||
<JsonObjectView value={obj} depth={depth} />
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="space-y-2">
|
||
<JsonObjectView value={obj} depth={depth} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function JsonPrimitive({ value }: { value: JsonValue }) {
|
||
if (value === null) {
|
||
return (
|
||
<span className="font-mono text-[11px] text-muted-foreground italic">
|
||
null
|
||
</span>
|
||
);
|
||
}
|
||
if (typeof value === "boolean") {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"font-mono text-[11px]",
|
||
value
|
||
? "text-emerald-600 dark:text-emerald-400"
|
||
: "text-rose-600 dark:text-rose-400",
|
||
)}
|
||
>
|
||
{String(value)}
|
||
</span>
|
||
);
|
||
}
|
||
if (typeof value === "number") {
|
||
return (
|
||
<span className="font-mono text-[12px] text-foreground">{value}</span>
|
||
);
|
||
}
|
||
if (typeof value !== "string") return null;
|
||
const str = value;
|
||
if (str.includes("\n")) {
|
||
return (
|
||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
|
||
{str}
|
||
</pre>
|
||
);
|
||
}
|
||
return <span className="break-words text-foreground">{str}</span>;
|
||
}
|
||
|
||
type IterationEntry = {
|
||
iteration?: number;
|
||
runDic?: number;
|
||
timestamp?: string;
|
||
hypothesis?: string;
|
||
intervention?: { type?: string; summary?: string } | string;
|
||
prediction?: Record<string, number | string>;
|
||
results?: Record<string, number | string>;
|
||
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 (
|
||
<p className="text-muted-foreground text-xs">无记录。</p>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{entries.length} 条记录(最新在最后)
|
||
</div>
|
||
{entries.map((entry, idx) =>
|
||
entry.ok ? (
|
||
<IterationCard
|
||
key={idx}
|
||
entry={entry.data}
|
||
defaultOpen={idx === entries.length - 1}
|
||
/>
|
||
) : (
|
||
<div
|
||
key={idx}
|
||
className="rounded-md border border-destructive/40 bg-destructive/5 p-3 font-mono text-[11px] text-destructive"
|
||
>
|
||
行 #{idx + 1} 无法解析为 JSON
|
||
<pre className="mt-1 whitespace-pre-wrap break-all text-muted-foreground">
|
||
{entry.raw}
|
||
</pre>
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<details
|
||
open={defaultOpen}
|
||
className="group rounded-lg border bg-card shadow-sm"
|
||
>
|
||
<summary className="flex cursor-pointer items-center gap-2 border-b px-4 py-2.5 hover:bg-muted/30">
|
||
<span className="rounded bg-primary/10 px-2 py-0.5 font-mono text-[11px] font-semibold text-primary">
|
||
R{entry.iteration ?? "?"}
|
||
</span>
|
||
{entry.runDic != null ? (
|
||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||
runDic={entry.runDic}
|
||
</span>
|
||
) : null}
|
||
{entry.verdict ? <VerdictPill verdict={entry.verdict} /> : null}
|
||
<span className="ml-auto truncate font-mono text-[10px] text-muted-foreground">
|
||
{entry.timestamp ?? ""}
|
||
</span>
|
||
</summary>
|
||
<div className="space-y-3 px-4 py-3 text-[13px]">
|
||
{entry.hypothesis ? (
|
||
<Field label="假设" tone="primary">
|
||
{entry.hypothesis}
|
||
</Field>
|
||
) : null}
|
||
{intervention.summary || intervention.type ? (
|
||
<Field label="干预">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{intervention.type ? (
|
||
<span className="rounded bg-amber-500/15 px-2 py-0.5 font-mono text-[11px] text-amber-700 dark:text-amber-300">
|
||
{intervention.type}
|
||
</span>
|
||
) : null}
|
||
{intervention.summary ? (
|
||
<span className="text-foreground">{intervention.summary}</span>
|
||
) : null}
|
||
</div>
|
||
</Field>
|
||
) : null}
|
||
{entry.prediction || entry.results ? (
|
||
<MetricsCompare
|
||
prediction={entry.prediction}
|
||
results={entry.results}
|
||
/>
|
||
) : null}
|
||
{entry.error_delta ? (
|
||
<Field label="错误增量">
|
||
<div className="flex flex-wrap gap-2">
|
||
<DeltaPill
|
||
label="persistent"
|
||
value={entry.error_delta.persistent}
|
||
tone="muted"
|
||
/>
|
||
<DeltaPill
|
||
label="new"
|
||
value={entry.error_delta.new}
|
||
tone="rose"
|
||
/>
|
||
<DeltaPill
|
||
label="fixed"
|
||
value={entry.error_delta.fixed}
|
||
tone="emerald"
|
||
/>
|
||
</div>
|
||
</Field>
|
||
) : null}
|
||
{entry.root_cause_findings && entry.root_cause_findings.length > 0 ? (
|
||
<Field label="根因发现">
|
||
<div className="overflow-x-auto rounded-md border">
|
||
<table className="w-full border-collapse text-[12px]">
|
||
<thead className="bg-muted/50">
|
||
<tr>
|
||
<th className="border-b px-2 py-1 text-left font-medium">
|
||
pattern
|
||
</th>
|
||
<th className="border-b px-2 py-1 text-left font-medium">
|
||
class
|
||
</th>
|
||
<th className="border-b px-2 py-1 text-right font-medium">
|
||
cases
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{entry.root_cause_findings.map((f, i) => (
|
||
<tr
|
||
key={i}
|
||
className="border-b last:border-b-0 hover:bg-muted/20"
|
||
>
|
||
<td className="px-2 py-1">{f.pattern ?? "—"}</td>
|
||
<td className="px-2 py-1">
|
||
{f.class ? (
|
||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
|
||
{f.class}
|
||
</span>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</td>
|
||
<td className="px-2 py-1 text-right font-mono">
|
||
{f.cases ?? "—"}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Field>
|
||
) : null}
|
||
{entry.next_hypothesis ? (
|
||
<Field label="下一步假设" tone="accent">
|
||
{entry.next_hypothesis}
|
||
</Field>
|
||
) : null}
|
||
{otherKeys.length > 0 ? (
|
||
<details className="rounded-md border bg-muted/20">
|
||
<summary className="cursor-pointer px-3 py-1.5 text-[11px] text-muted-foreground hover:bg-muted/40">
|
||
其他字段({otherKeys.length})
|
||
</summary>
|
||
<pre className="overflow-x-auto px-3 pt-0 pb-2 font-mono text-[11px] leading-relaxed">
|
||
{JSON.stringify(
|
||
Object.fromEntries(otherKeys.map((k) => [k, entry[k]])),
|
||
null,
|
||
2,
|
||
)}
|
||
</pre>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
children,
|
||
tone,
|
||
}: {
|
||
label: string;
|
||
children: React.ReactNode;
|
||
tone?: "primary" | "accent";
|
||
}) {
|
||
return (
|
||
<div>
|
||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||
{label}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"rounded-md border bg-muted/20 px-3 py-2 leading-relaxed",
|
||
tone === "primary" && "border-primary/30 bg-primary/5",
|
||
tone === "accent" && "border-amber-500/30 bg-amber-500/5",
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function VerdictPill({ verdict }: { verdict: string }) {
|
||
const map: Record<string, string> = {
|
||
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 (
|
||
<span
|
||
className={cn(
|
||
"rounded-full px-2 py-0.5 font-medium text-[10px] tracking-wider",
|
||
map[verdict.toLowerCase()] ?? "bg-muted text-muted-foreground",
|
||
)}
|
||
>
|
||
{verdict.toUpperCase()}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function DeltaPill({
|
||
label,
|
||
value,
|
||
tone,
|
||
}: {
|
||
label: string;
|
||
value: number | undefined;
|
||
tone: "rose" | "emerald" | "muted";
|
||
}) {
|
||
if (value == null) return null;
|
||
const toneMap: Record<typeof tone, string> = {
|
||
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 (
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-[11px]",
|
||
toneMap[tone],
|
||
)}
|
||
>
|
||
<span className="opacity-70">{label}</span>
|
||
<span className="font-semibold">{value}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function MetricsCompare({
|
||
prediction,
|
||
results,
|
||
}: {
|
||
prediction?: Record<string, number | string>;
|
||
results?: Record<string, number | string>;
|
||
}) {
|
||
const keys = useMemo(() => {
|
||
const set = new Set<string>();
|
||
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 (
|
||
<Field label="指标">
|
||
<div className="overflow-x-auto rounded-md border">
|
||
<table className="w-full border-collapse text-[12px]">
|
||
<thead className="bg-muted/50">
|
||
<tr>
|
||
<th className="border-b px-2 py-1 text-left font-medium">
|
||
metric
|
||
</th>
|
||
<th className="border-b px-2 py-1 text-right font-medium">
|
||
prediction
|
||
</th>
|
||
<th className="border-b px-2 py-1 text-right font-medium">
|
||
result
|
||
</th>
|
||
<th className="border-b px-2 py-1 text-right font-medium">
|
||
Δ
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 (
|
||
<tr
|
||
key={k}
|
||
className="border-b last:border-b-0 hover:bg-muted/20"
|
||
>
|
||
<td className="px-2 py-1 font-mono text-[11px]">{k}</td>
|
||
<td className="px-2 py-1 text-right font-mono">
|
||
{p ?? "—"}
|
||
</td>
|
||
<td className="px-2 py-1 text-right font-mono font-semibold">
|
||
{r ?? "—"}
|
||
</td>
|
||
<td
|
||
className={cn(
|
||
"px-2 py-1 text-right font-mono",
|
||
deltaTone,
|
||
)}
|
||
>
|
||
{delta == null
|
||
? "—"
|
||
: `${delta > 0 ? "+" : ""}${delta.toFixed(2)}`}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<p className="text-muted-foreground text-xs">空内容。</p>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{sections.map((section, idx) =>
|
||
section.title === null ? (
|
||
<div
|
||
key={idx}
|
||
className="rounded-lg border bg-card px-4 py-3 shadow-sm"
|
||
>
|
||
<article className={PROSE_CLASSES}>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||
{section.body}
|
||
</ReactMarkdown>
|
||
</article>
|
||
</div>
|
||
) : (
|
||
<details
|
||
key={idx}
|
||
open={idx <= 1}
|
||
className="group rounded-lg border bg-card shadow-sm"
|
||
>
|
||
<summary className="flex cursor-pointer items-center gap-2 border-b px-4 py-2.5 hover:bg-muted/30">
|
||
<span className="font-semibold text-foreground text-sm">
|
||
{section.title}
|
||
</span>
|
||
<span className="ml-auto text-[10px] text-muted-foreground transition-transform group-open:rotate-180">
|
||
▾
|
||
</span>
|
||
</summary>
|
||
<div className="px-4 py-3">
|
||
<article className={PROSE_CLASSES}>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||
{section.body}
|
||
</ReactMarkdown>
|
||
</article>
|
||
</div>
|
||
</details>
|
||
),
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TablesView({ sections }: { sections: TableSection[] }) {
|
||
if (sections.length === 0) {
|
||
return <p className="text-muted-foreground text-xs">空内容。</p>;
|
||
}
|
||
return (
|
||
<div className="space-y-3">
|
||
{sections.map((section, idx) => (
|
||
<TableSectionCard
|
||
key={`${section.title}-${idx}`}
|
||
section={section}
|
||
defaultOpen={idx <= 1}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<details
|
||
open={defaultOpen && !isMissing}
|
||
className="group rounded-lg border bg-card shadow-sm"
|
||
>
|
||
<summary className="flex cursor-pointer items-center gap-2 border-b px-4 py-2.5 hover:bg-muted/30">
|
||
<span className="font-semibold text-foreground text-sm">
|
||
{section.title}
|
||
</span>
|
||
{noteParts.length > 0 ? (
|
||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||
{noteParts.join(" · ")}
|
||
</span>
|
||
) : null}
|
||
<span className="ml-auto text-[10px] text-muted-foreground transition-transform group-open:rotate-180">
|
||
▾
|
||
</span>
|
||
</summary>
|
||
<div className="space-y-2 px-4 py-3">
|
||
<div className="flex items-center gap-1 truncate font-mono text-[11px] text-muted-foreground">
|
||
<FileIcon className="size-3 shrink-0" />
|
||
<span className="truncate">{section.source_path}</span>
|
||
</div>
|
||
{isMissing ? (
|
||
<p className="rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 px-3 py-2 text-muted-foreground text-xs">
|
||
{section.error ?? "产物文件不存在或读取失败"}
|
||
</p>
|
||
) : isMarkdown ? (
|
||
<MarkdownView text={section.body ?? ""} />
|
||
) : section.rows.length === 0 ? (
|
||
<p className="text-muted-foreground text-xs">空表。</p>
|
||
) : (
|
||
<DataTable columns={section.columns} rows={section.rows} />
|
||
)}
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
function DataTable({
|
||
columns,
|
||
rows,
|
||
}: {
|
||
columns: string[];
|
||
rows: string[][];
|
||
}) {
|
||
return (
|
||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||
<table className="w-full border-collapse text-[12px]">
|
||
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur supports-[backdrop-filter]:bg-muted/60">
|
||
<tr>
|
||
<th className="border-b border-r px-2 py-1 text-left font-medium text-[10px] text-muted-foreground tracking-wider w-10">
|
||
#
|
||
</th>
|
||
{columns.map((c) => (
|
||
<th
|
||
key={c}
|
||
className="border-b border-r last:border-r-0 px-2 py-1 text-left font-medium whitespace-nowrap"
|
||
>
|
||
{c}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, rIdx) => (
|
||
<tr
|
||
key={rIdx}
|
||
className="border-b last:border-b-0 hover:bg-muted/20"
|
||
>
|
||
<td className="border-r px-2 py-1 align-top font-mono text-[10px] text-muted-foreground tabular-nums">
|
||
{rIdx + 1}
|
||
</td>
|
||
{columns.map((c, cIdx) => {
|
||
const cell = row[cIdx] ?? "";
|
||
return (
|
||
<td
|
||
key={c}
|
||
className="border-r last:border-r-0 px-2 py-1 align-top font-mono text-[11px]"
|
||
>
|
||
{cell === "" ? (
|
||
<span className="text-muted-foreground">—</span>
|
||
) : (
|
||
<TableCell text={cell} />
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TableCell({ text }: { text: string }) {
|
||
if (text.includes("\n")) {
|
||
return (
|
||
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-snug">
|
||
{text}
|
||
</pre>
|
||
);
|
||
}
|
||
return <span className="break-words whitespace-pre-wrap">{text}</span>;
|
||
}
|
||
|
||
function RawView({ text }: { text: string }) {
|
||
return (
|
||
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
||
{text}
|
||
</pre>
|
||
);
|
||
}
|
||
|
||
function StatusPill({ status }: { status: string }) {
|
||
const map: Record<string, string> = {
|
||
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 (
|
||
<span
|
||
className={cn(
|
||
"rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wider",
|
||
map[status] ?? "bg-muted text-muted-foreground",
|
||
)}
|
||
>
|
||
{status.toUpperCase()}
|
||
</span>
|
||
);
|
||
}
|