Files
zk-data-agent/frontend/app/components/training/training-pipeline-panel.tsx
T
hupenglong1 f068311cac backend/frontend/runtime: 训练面板 / session 隔离 / agent runtime 调整
- backend/api/server.py:训练 step-detail / pipeline 卡片排序逻辑
- frontend:thread-list / thread / training-pipeline-panel / step-detail-sheet 配套调整,新增 metrics-chart-panel
- src/agent_*:tool spec / runtime / prompting 配套
- .gitignore 加 .logs/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:43:10 +08:00

966 lines
26 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 {
AlertTriangleIcon,
BarChart3Icon,
CheckIcon,
ChevronRightIcon,
ClipboardListIcon,
ClockIcon,
DnaIcon,
FileTextIcon,
FlaskConicalIcon,
GraduationCapIcon,
HourglassIcon,
Loader2Icon,
type LucideIcon,
Maximize2Icon,
MicroscopeIcon,
Minimize2Icon,
RefreshCwIcon,
ShieldCheckIcon,
TrendingUpIcon,
UserCheckIcon,
} from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import {
MetricsChartPanel,
type MetricsHistory,
} from "@/components/training/metrics-chart-panel";
import { StepDetailSheet } from "@/components/training/step-detail-sheet";
import { cn } from "@/lib/utils";
type CardStatus =
| "pending"
| "running"
| "waiting"
| "complete"
| "failed"
| "cancelled";
type SectionType = "baseline" | "train" | "analysis";
type GateKind = "human-check" | "human-review";
type PipelineCard = {
key: string;
step: string;
icon?: string;
title: string;
subtitle: string;
status: CardStatus;
progress?: number;
};
type RoundItem = {
type: "round";
index: number;
run_id: string;
run_index: number;
section_type: SectionType;
label: string;
description: string;
status: CardStatus;
cards: PipelineCard[];
};
type GateItem = {
type: "gate";
index: number;
key: string;
run_id: string;
run_index: number;
gate_kind: GateKind;
title: string;
description: string;
status: CardStatus;
reason?: string | null;
summary?: string | null;
proposal?: string | null;
ask?: string | null;
};
type PipelineItem = RoundItem | GateItem;
type InFlight = {
run_id: string;
section_type: SectionType;
section_label: string;
cards: PipelineCard[];
};
type Kpi = {
label: string;
value: string;
icon?: string;
};
type LogLine = {
ts: string;
iter: string | number;
text: string;
};
type PipelinePayload = {
session_id: string;
generated_at_ms: number;
available?: boolean;
status: { mode: string; state: CardStatus };
kpis: Kpi[];
items: PipelineItem[];
in_flight?: InFlight | null;
logs: LogLine[];
metrics_history?: MetricsHistory | null;
};
const ICON_MAP: Record<string, LucideIcon> = {
"alert-triangle": AlertTriangleIcon,
"bar-chart": BarChart3Icon,
microscope: MicroscopeIcon,
"trending-up": TrendingUpIcon,
"file-text": FileTextIcon,
dna: DnaIcon,
flask: FlaskConicalIcon,
shield: ShieldCheckIcon,
"graduation-cap": GraduationCapIcon,
"clipboard-list": ClipboardListIcon,
"refresh-cw": RefreshCwIcon,
clock: ClockIcon,
hourglass: HourglassIcon,
"user-check": UserCheckIcon,
};
const SECTION_PHASE_TONE: Record<SectionType, string> = {
baseline: "from-violet-500/15 to-sky-500/10 border-violet-400/40",
train: "from-emerald-500/15 to-amber-500/10 border-emerald-400/40",
analysis: "from-sky-500/15 to-violet-500/10 border-sky-400/40",
};
const SECTION_BADGE_TONE: Record<SectionType, string> = {
baseline: "bg-violet-500/10 text-violet-700 dark:text-violet-300",
train: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
analysis: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
};
export function TrainingPipelinePanel({
sessionId,
}: {
sessionId: string | null;
}) {
const [data, setData] = useState<PipelinePayload | null>(null);
const [loading, setLoading] = useState(false);
const [openCard, setOpenCard] = useState<{
key: string;
title: string;
status: string;
runId?: string;
} | null>(null);
const [bottomTab, setBottomTab] = useState<"log" | "metrics">("log");
useEffect(() => {
setLoading(true);
const url = sessionId
? `/api/claw/training/pipeline/stream?session_id=${encodeURIComponent(sessionId)}`
: "/api/claw/training/pipeline/stream";
const es = new EventSource(url);
es.onmessage = (ev) => {
try {
const payload = JSON.parse(ev.data);
if (payload && payload.available === false) {
setData(null);
} else {
setData(payload);
}
setLoading(false);
} catch {
/* ignore malformed events */
}
};
es.onerror = () => {
setLoading(false);
};
return () => {
es.close();
};
}, [sessionId]);
const stripNamespace = (key: string): string => {
// "R1:cml" → "cml" — backend uses round-namespaced keys, but the
// step-detail endpoint is keyed only by step name.
const idx = key.indexOf(":");
return idx >= 0 ? key.slice(idx + 1) : key;
};
return (
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden border-r bg-muted/20">
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<HeaderRow status={data?.status ?? null} loading={loading} />
<KpiRow kpis={data?.kpis ?? []} />
<div className="mt-5 space-y-3">
{(() => {
if (!data) {
return (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground text-xs">
pipeline
</div>
);
}
const items = data.items ?? [];
if (items.length === 0) {
const isRunning = data.status?.state === "running";
return (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground text-xs">
{isRunning
? "首个阶段进行中,卡片即将出现……"
: "等待 agent 进入第一个步骤……"}
</div>
);
}
return (
<>
{items.map((item, idx) => {
const showConnector = idx < items.length - 1;
if (item.type === "round") {
return (
<div
key={`round-${item.run_id}-${item.section_type}`}
>
<RoundSection
item={item}
onCardClick={(card) =>
setOpenCard({
key: stripNamespace(card.key),
title: card.title,
status: card.status,
runId: item.run_id,
})
}
/>
{showConnector ? <ItemConnector /> : null}
</div>
);
}
return (
<div key={`gate-${item.key}`}>
<GateCard
item={item}
onClick={() =>
setOpenCard({
key: stripNamespace(item.key),
title: item.title,
status: item.status,
runId: item.run_id,
})
}
/>
{showConnector ? <ItemConnector /> : null}
</div>
);
})}
</>
);
})()}
</div>
</div>
<BottomPanel
logs={data?.logs ?? []}
history={data?.metrics_history ?? null}
tab={bottomTab}
onTabChange={setBottomTab}
/>
<StepDetailSheet
sessionId={sessionId}
stepKey={openCard?.key ?? null}
runId={openCard?.runId ?? null}
stepTitle={openCard?.title}
stepStatus={openCard?.status}
open={openCard !== null}
onOpenChange={(next) => {
if (!next) setOpenCard(null);
}}
/>
</div>
);
}
function HeaderRow({
status,
loading,
}: {
status: PipelinePayload["status"] | null;
loading: boolean;
}) {
const statusLabel = loading
? "加载中"
: status
? `${status.mode} · ${statusText(status.state)}`
: "暂无运行";
const isRunning = status?.state === "running";
const isWaiting = status?.state === "waiting";
const isFailed = status?.state === "failed";
const isCancelled = status?.state === "cancelled";
const isErrored = isFailed || isCancelled;
return (
<header className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MicroscopeIcon className="size-5 text-muted-foreground" />
<h2 className="font-semibold text-sm uppercase tracking-[0.2em] text-foreground/90">
Training Pipeline Monitor
</h2>
</div>
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs",
isErrored
? "border-rose-500/50 bg-rose-500/10 text-rose-700 dark:text-rose-300"
: isWaiting
? "border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300"
: isRunning
? "animate-pipeline-pill-glow border-sky-400 bg-sky-500/10 text-sky-700 dark:text-sky-300"
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
<span className="relative inline-flex size-2 items-center justify-center">
<span
className={cn(
"size-2 rounded-full",
isErrored
? "bg-rose-500"
: isWaiting
? "bg-amber-500"
: isRunning
? "bg-sky-500"
: status?.state === "complete"
? "bg-emerald-500"
: "bg-muted-foreground",
)}
/>
{isRunning && !isErrored ? (
<span className="pointer-events-none absolute inset-0 animate-pipeline-dot-ping rounded-full bg-sky-400" />
) : null}
</span>
<span className="font-medium">{statusLabel}</span>
</div>
</header>
);
}
function KpiRow({ kpis }: { kpis: Kpi[] }) {
if (!kpis.length) {
return null;
}
return (
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-7">
{kpis.map((kpi) => {
const Icon = kpi.icon ? ICON_MAP[kpi.icon] : null;
return (
<div
key={kpi.label}
className="rounded-lg border bg-background px-3 py-2 min-h-[58px]"
>
<div className="text-[10px] font-medium tracking-wider text-muted-foreground truncate">
{kpi.label}
</div>
<div className="mt-0.5 flex items-start gap-1.5 font-mono text-sm leading-tight text-foreground">
<span className="break-all line-clamp-2">{kpi.value}</span>
{Icon ? (
<Icon className="size-3.5 shrink-0 text-muted-foreground mt-0.5" />
) : null}
</div>
</div>
);
})}
</div>
);
}
function ItemConnector() {
return (
<div className="flex justify-center py-1">
<div className="h-5 w-0.5 bg-gradient-to-b from-border to-muted-foreground/30" />
</div>
);
}
function StatusPill({ status }: { status: CardStatus }) {
const map: Record<
CardStatus,
{ label: string; cls: string; icon: LucideIcon | null }
> = {
complete: {
label: "COMPLETED",
cls: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
icon: CheckIcon,
},
running: {
label: "IN PROGRESS",
cls: "bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/30",
icon: Loader2Icon,
},
waiting: {
label: "WAITING FOR YOU",
cls: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/40",
icon: HourglassIcon,
},
pending: {
label: "PENDING",
cls: "bg-muted text-muted-foreground border-border",
icon: null,
},
failed: {
label: "FAILED",
cls: "bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30",
icon: AlertTriangleIcon,
},
cancelled: {
label: "CANCELLED",
cls: "bg-muted text-muted-foreground border-border",
icon: null,
},
};
const { label, cls, icon: Icon } = map[status];
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold tracking-wider",
cls,
)}
>
{Icon ? (
<Icon
className={cn(
"size-3",
status === "running" ? "animate-spin" : "",
)}
/>
) : null}
{label}
</span>
);
}
function RoundSection({
item,
onCardClick,
}: {
item: RoundItem;
onCardClick?: (card: PipelineCard) => void;
}) {
const numLabel = item.index.toString().padStart(2, "0");
const phaseTone = SECTION_PHASE_TONE[item.section_type];
const badgeTone = SECTION_BADGE_TONE[item.section_type];
return (
<section
className={cn(
"rounded-2xl border bg-gradient-to-br p-4 shadow-sm",
phaseTone,
"bg-background/80",
)}
>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full font-mono text-sm font-semibold",
badgeTone,
)}
>
{numLabel}
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground text-sm">
{item.label}
</span>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{item.description}
</p>
</div>
</div>
<StatusPill status={item.status} />
</div>
{item.cards.length > 0 ? (
<div className="flex flex-wrap items-stretch justify-center gap-2">
{item.cards.map((card, idx) => (
<div key={card.key} className="flex items-stretch gap-2">
<PipelineCardView
card={card}
onClick={onCardClick ? () => onCardClick(card) : undefined}
/>
{idx < item.cards.length - 1 ? <CardArrow /> : null}
</div>
))}
</div>
) : null}
</section>
);
}
function CardArrow() {
return (
<div className="flex items-center px-1 text-muted-foreground/50">
<ChevronRightIcon className="size-5" />
</div>
);
}
function parseGateReason(reason: string | null | undefined): {
summary: string | null;
proposal: string | null;
ask: string | null;
} {
const text = (reason ?? "").trim();
if (!text) return { summary: null, proposal: null, ask: null };
// Find the ask: greedy from any "是否/要不要/能否/还是/请..." question phrase
// to end. Fallback: the trailing sentence containing ?/.
const askPatterns = [
/(是否[^。]*$)/,
/(要不要[^。]*$)/,
/(能否[^。]*$)/,
/(请[^。]{0,40}[?][^。]*$)/,
];
let ask: string | null = null;
let body = text;
for (const pat of askPatterns) {
const m = text.match(pat);
if (m && m.index !== undefined) {
ask = m[0].trim().replace(/^[。;,\s]+/, "");
body = text.slice(0, m.index).trim().replace(/[。;,\s]+$/, "");
break;
}
}
if (!ask) {
// Fallback: split off trailing sentence(s) containing ? or
const qIdx = Math.max(text.lastIndexOf("?"), text.lastIndexOf(""));
if (qIdx >= 0) {
// expand back to previous 。 or start of string
const prevDot = Math.max(
text.lastIndexOf("。", qIdx - 1),
text.lastIndexOf(";", qIdx - 1),
text.lastIndexOf("", qIdx - 1),
);
ask = text.slice(prevDot + 1).trim().replace(/^[。;,\s]+/, "");
body = text.slice(0, prevDot + 1).trim().replace(/[。;,\s]+$/, "");
}
}
const sentences = body
.split(/。/)
.map((s) => s.trim())
.filter(Boolean);
if (sentences.length === 0) {
return { summary: null, proposal: null, ask };
}
if (sentences.length === 1) {
return { summary: sentences[0], proposal: null, ask };
}
const proposalKw =
/(H\d|H[1-3]\b|拟|要做|计划|提议|方案|增强|仿写|修改|修\s*\d|fix|patch|候选|逐条审|relabel|sft|train)/i;
let splitIdx = sentences.findIndex((s) => proposalKw.test(s));
if (splitIdx <= 0) splitIdx = Math.max(1, Math.floor(sentences.length / 2));
const summary = sentences.slice(0, splitIdx).join("。 ");
const proposal = sentences.slice(splitIdx).join("。 ");
return { summary, proposal, ask };
}
function GateBodyRow({
label,
text,
tone,
}: {
label: string;
text: string;
tone: "status" | "proposal" | "ask";
}) {
const labelTone =
tone === "ask"
? "bg-amber-500/25 text-amber-800 dark:bg-amber-400/30 dark:text-amber-100"
: tone === "proposal"
? "bg-sky-500/15 text-sky-700 dark:bg-sky-400/20 dark:text-sky-200"
: "bg-zinc-500/15 text-zinc-700 dark:bg-zinc-400/20 dark:text-zinc-200";
const textTone = tone === "ask" ? "text-foreground font-medium" : "text-foreground/85";
return (
<div className="flex items-start gap-2">
<span
className={cn(
"shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold tracking-wider",
labelTone,
)}
>
{label}
</span>
<p className={cn("text-sm leading-relaxed", textTone)}>{text}</p>
</div>
);
}
function GateCard({
item,
onClick,
}: {
item: GateItem;
onClick?: () => void;
}) {
const numLabel = item.index.toString().padStart(2, "0");
const isWaiting = item.status === "waiting";
const isComplete = item.status === "complete";
const Tag = onClick ? "button" : "div";
const hint =
item.gate_kind === "human-review"
? "在右侧对话里给出 OK / 调整后的指令,agent 才会继续。"
: "在右侧对话里回 OK / 修改建议,确认后进入下一轮训练。";
// Prefer structured fields; fall back to heuristic parsing of `reason`.
const parsed = parseGateReason(item.reason);
const summary = item.summary ?? parsed.summary;
const proposal = item.proposal ?? parsed.proposal;
const ask = item.ask ?? parsed.ask;
const hasStructured = Boolean(summary || proposal || ask);
return (
<Tag
type={onClick ? "button" : undefined}
onClick={onClick}
className={cn(
"flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-dashed bg-amber-500/5 p-5 text-left transition-colors",
isWaiting
? "border-amber-400 bg-amber-500/10 ring-2 ring-amber-300/40 dark:ring-amber-500/30 animate-pipeline-pill-glow"
: isComplete
? "border-emerald-400/60 bg-emerald-500/5"
: "border-amber-400/40",
onClick &&
"cursor-pointer hover:bg-amber-500/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400",
)}
>
<div className="flex items-center gap-3 min-w-0">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-amber-500/15 font-mono text-amber-700 text-base font-semibold dark:text-amber-300">
{numLabel}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<UserCheckIcon className="size-5 shrink-0 text-amber-700 dark:text-amber-300" />
<span className="font-semibold text-foreground text-base">
{item.title}
</span>
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 font-mono text-xs text-amber-700 dark:text-amber-300">
{item.run_id}
</span>
</div>
{hasStructured ? (
<div className="mt-2 space-y-1.5">
{summary ? (
<GateBodyRow label="现状" text={summary} tone="status" />
) : null}
{proposal ? (
<GateBodyRow label="提议" text={proposal} tone="proposal" />
) : null}
{ask ? (
<GateBodyRow label="请选" text={ask} tone="ask" />
) : null}
</div>
) : item.reason ? (
<p className="mt-1.5 text-sm text-foreground/85 leading-relaxed">
{item.reason}
</p>
) : (
<p className="mt-1.5 text-sm text-muted-foreground leading-relaxed">
{item.description}
</p>
)}
{isWaiting ? (
<p className="mt-2 text-xs tracking-wide text-amber-700/90 dark:text-amber-400/90">
{hint}
</p>
) : null}
</div>
</div>
<StatusPill status={item.status} />
</Tag>
);
}
function PipelineCardView({
card,
onClick,
}: {
card: PipelineCard;
onClick?: () => void;
}) {
const isRunning = card.status === "running";
const isComplete = card.status === "complete";
const Icon = card.icon ? ICON_MAP[card.icon] : null;
const tone = isComplete
? "border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-950/20"
: isRunning
? "border-sky-400 bg-sky-50/60 dark:bg-sky-950/30 ring-2 ring-sky-300/50 dark:ring-sky-500/30 animate-pipeline-running-blue"
: card.status === "failed"
? "border-rose-500/40 bg-rose-50/40 dark:bg-rose-950/20"
: "border-border bg-background";
const Tag = onClick ? "button" : "div";
return (
<Tag
type={onClick ? "button" : undefined}
onClick={onClick}
className={cn(
"relative flex w-48 flex-col rounded-xl border px-3 pt-3 pb-2.5 text-left transition-colors",
tone,
onClick &&
"cursor-pointer hover:brightness-105 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400",
)}
>
<div className="mb-2 flex items-start">
{isComplete ? (
<div className="flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white">
<CheckIcon className="size-3" strokeWidth={3} />
</div>
) : (
<MiniStatus status={card.status} />
)}
</div>
<div className="flex items-center gap-1.5">
{Icon ? (
<Icon
className={cn(
"size-4 shrink-0",
isComplete
? "text-emerald-600 dark:text-emerald-400"
: isRunning
? "text-sky-600 dark:text-sky-400"
: "text-muted-foreground",
)}
/>
) : null}
<span
className={cn(
"font-medium text-sm leading-tight",
isRunning ? "text-foreground" : "",
card.status === "pending" ? "text-muted-foreground" : "",
)}
>
{card.title}
</span>
</div>
<span className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{card.subtitle}
</span>
</Tag>
);
}
function MiniStatus({ status }: { status: CardStatus }) {
const map: Record<CardStatus, { label: string; cls: string }> = {
complete: {
label: "DONE",
cls: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
},
running: {
label: "RUNNING",
cls: "bg-sky-500/20 text-sky-700 dark:text-sky-200",
},
waiting: {
label: "WAITING",
cls: "bg-amber-500/20 text-amber-700 dark:text-amber-200",
},
pending: {
label: "PENDING",
cls: "bg-muted text-muted-foreground",
},
failed: {
label: "FAILED",
cls: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
},
cancelled: {
label: "CANCELLED",
cls: "bg-muted text-muted-foreground",
},
};
const { label, cls } = map[status];
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold tracking-wider",
cls,
)}
>
{status === "running" ? (
<Loader2Icon className="size-2.5 animate-spin" />
) : null}
{label}
</span>
);
}
const BOTTOM_PANEL_DEFAULT_HEIGHT = 288;
const BOTTOM_PANEL_MIN_HEIGHT = 120;
function BottomPanel({
logs,
history,
tab,
onTabChange,
}: {
logs: LogLine[];
history: MetricsHistory | null;
tab: "log" | "metrics";
onTabChange: (next: "log" | "metrics") => void;
}) {
const [height, setHeight] = useState(BOTTOM_PANEL_DEFAULT_HEIGHT);
const [isFullscreen, setIsFullscreen] = useState(false);
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
useEffect(() => {
if (!isFullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsFullscreen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isFullscreen]);
const onHandlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (isFullscreen) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { startY: e.clientY, startH: height };
};
const onHandlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
const dy = dragRef.current.startY - e.clientY;
const max =
typeof window !== "undefined"
? Math.max(BOTTOM_PANEL_MIN_HEIGHT, window.innerHeight - 100)
: 800;
setHeight(
Math.max(
BOTTOM_PANEL_MIN_HEIGHT,
Math.min(max, dragRef.current.startH + dy),
),
);
};
const onHandlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
};
return (
<div
className={cn(
"flex flex-col bg-zinc-950",
isFullscreen
? "absolute inset-0 z-50 border-t"
: "shrink-0 border-t",
)}
style={isFullscreen ? undefined : { height: `${height}px` }}
>
{!isFullscreen && (
<div
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onPointerCancel={onHandlePointerUp}
className="group h-1.5 shrink-0 cursor-ns-resize bg-zinc-900 hover:bg-sky-500/40 active:bg-sky-500/60"
title="拖动调整高度"
>
<div className="mx-auto mt-[2px] h-[2px] w-10 rounded-full bg-zinc-700 group-hover:bg-sky-400" />
</div>
)}
<div className="flex min-h-0 flex-1 flex-col px-5 py-3">
<div className="mb-2 flex shrink-0 items-center justify-between">
<div className="inline-flex rounded-full bg-zinc-900/80 p-0.5">
<PillTab active={tab === "log"} onClick={() => onTabChange("log")}>
Live Log
</PillTab>
<PillTab
active={tab === "metrics"}
onClick={() => onTabChange("metrics")}
>
Metrics
</PillTab>
</div>
<button
type="button"
onClick={() => setIsFullscreen((v) => !v)}
className="inline-flex items-center gap-1 text-[11px] text-zinc-400 transition-colors hover:text-zinc-200"
title={isFullscreen ? "退出 (Esc)" : "查看全部"}
>
{isFullscreen ? (
<Minimize2Icon className="size-3" />
) : (
<Maximize2Icon className="size-3" />
)}
</button>
</div>
<div className="min-h-0 flex-1">
{tab === "log" ? (
<LogStreamBody logs={logs} />
) : (
<MetricsChartPanel history={history} />
)}
</div>
</div>
</div>
);
}
function PillTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full px-3 py-1 font-semibold text-[11px] uppercase tracking-[0.12em] transition-colors",
active
? "bg-zinc-700 text-zinc-50"
: "text-zinc-400 hover:text-zinc-200",
)}
>
{children}
</button>
);
}
function LogStreamBody({ logs }: { logs: LogLine[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [logs]);
return (
<div
ref={ref}
className="h-full min-h-0 overflow-y-auto font-mono text-[12px] text-zinc-100 leading-6"
>
{logs.length === 0 ? (
<div className="text-zinc-500"></div>
) : (
logs.map((line, idx) => (
<div key={idx} className="flex gap-3">
<span className="text-zinc-500">{line.ts}</span>
<span className="text-emerald-400">[iter={line.iter}]</span>
<span>{line.text}</span>
</div>
))
)}
</div>
);
}
function statusText(state: CardStatus) {
const map: Record<CardStatus, string> = {
running: "Running",
waiting: "等待人工确认",
complete: "Complete",
failed: "Failed",
pending: "Idle",
cancelled: "Cancelled",
};
return map[state];
}