Files
zk-data-agent/frontend/app/components/training/step-detail-sheet.tsx
T
hupenglong1 5311e6d97c 修改
2026-05-22 19:48:47 +08:00

1157 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}