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>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
clearActiveSessionId,
|
||||
markFreshLocalId,
|
||||
readActiveSessionId,
|
||||
writeActiveSessionId,
|
||||
writePendingWorkspaceSessionId,
|
||||
@@ -163,6 +164,7 @@ function ensureSidebarWorkspaceSessionId(): {
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
||||
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
||||
markFreshLocalId(generated);
|
||||
writeActiveSessionId(generated);
|
||||
return { sessionId: generated, created: true };
|
||||
}
|
||||
@@ -438,6 +440,10 @@ const ClawSessionList: FC = () => {
|
||||
type="button"
|
||||
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
|
||||
onClick={async () => {
|
||||
console.log("[session-list] click", {
|
||||
sessionId: session.session_id,
|
||||
preview: session.preview,
|
||||
});
|
||||
setOpenMenuSessionId(null);
|
||||
writeActiveSessionId(session.session_id);
|
||||
pushSessionUrl(session.session_id);
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
ACTIVE_SESSION_CHANGED_EVENT,
|
||||
clearActiveSessionId,
|
||||
clearPendingWorkspaceSessionId,
|
||||
markFreshLocalId,
|
||||
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
|
||||
readActiveSessionId,
|
||||
readPendingWorkspaceSessionId,
|
||||
@@ -261,8 +262,14 @@ function useRefreshCurrentRun() {
|
||||
includePending: true,
|
||||
includeActive: true,
|
||||
});
|
||||
const activeRunRef = useRef<string | null>(null);
|
||||
const appliedTerminalRef = useRef<string | null>(null);
|
||||
// Per-session bookkeeping: switching sessions (sidebar history) and coming
|
||||
// back must NOT re-trigger replaySession on a session that has already been
|
||||
// applied — replaySession rebuilds the thread tree, which causes a layout
|
||||
// shift / scroll jitter in the chat UI. Single-value refs would get
|
||||
// overwritten when visiting another session and forget that this session
|
||||
// was already settled.
|
||||
const activeRunRefMap = useRef<Map<string, string>>(new Map());
|
||||
const appliedTerminalRefMap = useRef<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
@@ -274,8 +281,8 @@ function useRefreshCurrentRun() {
|
||||
if (cancelled) return;
|
||||
if (runStatus?.status === "queued" || runStatus?.status === "running") {
|
||||
const runKey = runStatus.run_id ?? "active";
|
||||
const previousRunKey = activeRunRef.current;
|
||||
activeRunRef.current = runKey;
|
||||
const previousRunKey = activeRunRefMap.current.get(sessionId) ?? null;
|
||||
activeRunRefMap.current.set(sessionId, runKey);
|
||||
// Watcher auto-resume: backend started a new run via _maybe_auto_resume
|
||||
// without a local SSE stream driving the UI. We need to replay once so
|
||||
// the active-run placeholder (pending prompt + 思考中 bubble) appears.
|
||||
@@ -294,14 +301,15 @@ function useRefreshCurrentRun() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!runtimeRunning && !activeRunRef.current) return;
|
||||
const sessionActiveRun = activeRunRefMap.current.get(sessionId) ?? null;
|
||||
if (!runtimeRunning && !sessionActiveRun) return;
|
||||
const terminalKey = [
|
||||
runStatus?.run_id ?? activeRunRef.current ?? "unknown",
|
||||
runStatus?.run_id ?? sessionActiveRun ?? "unknown",
|
||||
runStatus?.status ?? "idle",
|
||||
runStatus?.finished_at ?? "",
|
||||
runStatus?.elapsed_ms ?? "",
|
||||
].join(":");
|
||||
if (appliedTerminalRef.current === terminalKey) return;
|
||||
if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
|
||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
@@ -311,8 +319,8 @@ function useRefreshCurrentRun() {
|
||||
sessionId,
|
||||
toReplayRepository(payload, sessionId, runStatus),
|
||||
);
|
||||
activeRunRef.current = null;
|
||||
appliedTerminalRef.current = terminalKey;
|
||||
activeRunRefMap.current.delete(sessionId);
|
||||
appliedTerminalRefMap.current.set(sessionId, terminalKey);
|
||||
scheduleSessionListRefresh();
|
||||
}
|
||||
|
||||
@@ -775,6 +783,7 @@ function ensureWorkspaceSessionId(current: string | null) {
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
||||
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
||||
markFreshLocalId(generated);
|
||||
writeActiveSessionId(generated);
|
||||
scheduleSessionListRefresh();
|
||||
return generated;
|
||||
@@ -2393,6 +2402,11 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
const justSubmittedRef = useRef(false);
|
||||
const isDisabled = runtimeDisabled || disabled;
|
||||
|
||||
// Pull store→local on external store changes (session switch, paste-from-attachment,
|
||||
// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
|
||||
// onChange/handleInsert/handleKeyDown/onCompositionEnd via syncComposerText, so we
|
||||
// don't need a symmetric effect — and adding one creates a feedback loop that
|
||||
// re-renders the composer at ~1ms intervals (visible as shake).
|
||||
useEffect(() => {
|
||||
if (!isComposingRef.current) {
|
||||
if (justSubmittedRef.current && storeText) return;
|
||||
@@ -2401,16 +2415,6 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
}
|
||||
}, [storeText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isComposingRef.current &&
|
||||
localText !== storeText &&
|
||||
aui.composer().getState().isEditing
|
||||
) {
|
||||
aui.composer().setText(localText);
|
||||
}
|
||||
}, [aui, localText, storeText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || isDisabled) return;
|
||||
const textarea = textareaRef.current;
|
||||
@@ -2433,6 +2437,21 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
};
|
||||
}, [aui]);
|
||||
|
||||
// AssistantWorkspace 切 session 时通过事件通知我们恢复 draft——直接
|
||||
// 控制 localText,绕过 store→local 路径上的 justSubmittedRef 屏蔽和
|
||||
// switchToNewThread 引起的异步覆盖。
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ text?: string }>).detail;
|
||||
const text = detail?.text ?? "";
|
||||
justSubmittedRef.current = false;
|
||||
setLocalText(text);
|
||||
};
|
||||
window.addEventListener("claw-composer-restore-draft", handler);
|
||||
return () =>
|
||||
window.removeEventListener("claw-composer-restore-draft", handler);
|
||||
}, []);
|
||||
|
||||
const syncComposerText = useCallback(
|
||||
(value: string) => {
|
||||
if (!aui.composer().getState().isEditing) return;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Round = {
|
||||
iteration: number;
|
||||
runDic: number;
|
||||
label: string;
|
||||
timestamp: string;
|
||||
values: Record<string, number | null>;
|
||||
};
|
||||
|
||||
export type MetricsHistory = {
|
||||
metrics: string[];
|
||||
rounds: Round[];
|
||||
};
|
||||
|
||||
const METRIC_LABELS: Record<string, string> = {
|
||||
req_set_car: "需求集合(车载)",
|
||||
dapan_car: "大盘车载",
|
||||
specific_test: "Specific Test",
|
||||
triage_err_rate: "Triage 错误率",
|
||||
bvt_nav: "导航BVT",
|
||||
talkable_controllable: "可聊可控",
|
||||
target_subset_count: "目标子集数量",
|
||||
target_subset_wrong: "目标子集错误数",
|
||||
target_subset_correct: "目标子集正确数",
|
||||
target_subset_acc: "目标子集准确率",
|
||||
icl_test_overall: "ICL 测试整体",
|
||||
specific_test_overall: "专项测试整体",
|
||||
overrall: "大盘集",
|
||||
overrall_che_zai: "大盘集车载",
|
||||
overrall_shou_ji: "大盘集手机",
|
||||
overrall_dian_shi: "大盘集电视",
|
||||
overrall_yan_jing: "大盘集眼镜",
|
||||
overrall_you_ping: "大盘集有屏",
|
||||
overrall_wu_ping: "大盘集无屏",
|
||||
};
|
||||
|
||||
const LINE_COLORS = [
|
||||
"#34d399", // emerald
|
||||
"#38bdf8", // sky
|
||||
"#a78bfa", // violet
|
||||
"#fbbf24", // amber
|
||||
"#f472b6", // rose
|
||||
"#22d3ee", // cyan
|
||||
"#a3e635", // lime
|
||||
"#fb923c", // orange
|
||||
];
|
||||
|
||||
function labelOf(key: string): string {
|
||||
return METRIC_LABELS[key] ?? key;
|
||||
}
|
||||
|
||||
function formatValue(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "—";
|
||||
return v.toFixed(4);
|
||||
}
|
||||
|
||||
function formatDelta(curr: number | null | undefined, base: number | null | undefined): {
|
||||
text: string;
|
||||
tone: "up" | "down" | "flat";
|
||||
} {
|
||||
if (
|
||||
curr === null ||
|
||||
curr === undefined ||
|
||||
base === null ||
|
||||
base === undefined ||
|
||||
Number.isNaN(curr) ||
|
||||
Number.isNaN(base)
|
||||
) {
|
||||
return { text: "—", tone: "flat" };
|
||||
}
|
||||
const d = curr - base;
|
||||
if (Math.abs(d) < 1e-6) return { text: "±0", tone: "flat" };
|
||||
const sign = d > 0 ? "+" : "";
|
||||
return { text: `${sign}${d.toFixed(4)}`, tone: d > 0 ? "up" : "down" };
|
||||
}
|
||||
|
||||
function chartRowsFor(history: MetricsHistory, key: string): Array<Record<string, unknown>> {
|
||||
return history.rounds.map((r) => {
|
||||
const v = r.values[key];
|
||||
return {
|
||||
label: r.label,
|
||||
[key]: v === undefined || v === null ? null : v,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function MetricsChartPanel({ history }: { history: MetricsHistory | null }) {
|
||||
if (!history || history.rounds.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-zinc-500 text-xs">
|
||||
暂无指标数据,等首轮分析完成
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latest = history.rounds[history.rounds.length - 1];
|
||||
const baseline = history.rounds[0];
|
||||
const prev =
|
||||
history.rounds.length >= 2 ? history.rounds[history.rounds.length - 2] : baseline;
|
||||
|
||||
const colorFor = (key: string): string => {
|
||||
const idx = history.metrics.indexOf(key);
|
||||
return LINE_COLORS[idx >= 0 ? idx % LINE_COLORS.length : 0];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
|
||||
{history.metrics.map((k) => {
|
||||
const curr = latest.values[k];
|
||||
const dBase = formatDelta(curr, baseline.values[k]);
|
||||
const dPrev = formatDelta(curr, prev.values[k]);
|
||||
const color = colorFor(k);
|
||||
const rows = chartRowsFor(history, k);
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
className="flex flex-col rounded-md border border-zinc-800 bg-zinc-900/40 p-2"
|
||||
>
|
||||
<div className="flex shrink-0 items-baseline justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="truncate font-medium text-[11px] text-zinc-200">
|
||||
{labelOf(k)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono font-semibold text-[14px] text-zinc-50 leading-none">
|
||||
{formatValue(curr)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex shrink-0 gap-2 font-mono text-[10px]">
|
||||
<span className={deltaClass(dBase.tone)}>
|
||||
Δbase {dBase.text}
|
||||
</span>
|
||||
<span className={deltaClass(dPrev.tone)}>
|
||||
Δprev {dPrev.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-32">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={rows}
|
||||
margin={{ top: 4, right: 8, left: -12, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
stroke="#a1a1aa"
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#a1a1aa"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v: number) =>
|
||||
typeof v === "number" ? v.toFixed(4) : v
|
||||
}
|
||||
domain={["auto", "auto"]}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "#18181b",
|
||||
border: "1px solid #3f3f46",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
}}
|
||||
labelStyle={{ color: "#e4e4e7" }}
|
||||
itemStyle={{ color: "#e4e4e7" }}
|
||||
formatter={(value: number, name: string) => [
|
||||
typeof value === "number" ? value.toFixed(4) : value,
|
||||
labelOf(name),
|
||||
]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={k}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
dot={{ r: 2.5 }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deltaClass(tone: "up" | "down" | "flat"): string {
|
||||
if (tone === "up") return "text-emerald-400";
|
||||
if (tone === "down") return "text-rose-400";
|
||||
return "text-zinc-500";
|
||||
}
|
||||
@@ -3,10 +3,12 @@
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
Maximize2Icon,
|
||||
Minimize2Icon,
|
||||
RefreshCwIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -52,6 +54,7 @@ type StepDetail = {
|
||||
type Props = {
|
||||
sessionId: string | null;
|
||||
stepKey: string | null;
|
||||
runId?: string | null;
|
||||
stepTitle?: string;
|
||||
stepStatus?: string;
|
||||
open: boolean;
|
||||
@@ -61,6 +64,7 @@ type Props = {
|
||||
export function StepDetailSheet({
|
||||
sessionId,
|
||||
stepKey,
|
||||
runId,
|
||||
stepTitle,
|
||||
stepStatus,
|
||||
open,
|
||||
@@ -69,13 +73,41 @@ export function StepDetailSheet({
|
||||
const [detail, setDetail] = useState<StepDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reloadCount, setReloadCount] = useState(0);
|
||||
const [width, setWidth] = useState(672);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const dragRef = useRef<{ startX: number; startW: number } | null>(null);
|
||||
|
||||
const onResizePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isFullscreen) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragRef.current = { startX: e.clientX, startW: width };
|
||||
};
|
||||
const onResizePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
const dx = dragRef.current.startX - e.clientX;
|
||||
const max =
|
||||
typeof window !== "undefined"
|
||||
? Math.max(360, window.innerWidth - 32)
|
||||
: 1200;
|
||||
setWidth(Math.max(360, Math.min(max, dragRef.current.startW + dx)));
|
||||
};
|
||||
const onResizePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
dragRef.current = null;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
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)}`;
|
||||
const params = new URLSearchParams({
|
||||
session_id: sessionId,
|
||||
step: stepKey,
|
||||
});
|
||||
if (runId) params.set("run_id", runId);
|
||||
const url = `/api/claw/training/step-detail?${params.toString()}`;
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((payload) => {
|
||||
@@ -89,15 +121,39 @@ export function StepDetailSheet({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, sessionId, stepKey, reloadCount]);
|
||||
}, [open, sessionId, stepKey, runId, 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"
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isFullscreen) {
|
||||
e.preventDefault();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
}}
|
||||
className="flex max-w-none flex-col gap-0 p-0 sm:max-w-none"
|
||||
style={
|
||||
isFullscreen
|
||||
? { width: "100vw", maxWidth: "100vw" }
|
||||
: { width: `${width}px`, maxWidth: "none" }
|
||||
}
|
||||
>
|
||||
{!isFullscreen ? (
|
||||
<div
|
||||
onPointerDown={onResizePointerDown}
|
||||
onPointerMove={onResizePointerMove}
|
||||
onPointerUp={onResizePointerUp}
|
||||
onPointerCancel={onResizePointerUp}
|
||||
className="group absolute inset-y-0 left-0 z-50 w-1.5 cursor-ew-resize hover:bg-sky-500/40 active:bg-sky-500/60"
|
||||
title="拖动调整宽度"
|
||||
aria-label="拖动调整宽度"
|
||||
>
|
||||
<div className="-translate-y-1/2 absolute top-1/2 left-[2px] h-10 w-[2px] rounded-full bg-muted-foreground/30 group-hover:bg-sky-400" />
|
||||
</div>
|
||||
) : null}
|
||||
<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">
|
||||
@@ -119,6 +175,21 @@ export function StepDetailSheet({
|
||||
) : 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={() => setIsFullscreen((v) => !v)}
|
||||
title={isFullscreen ? "退出全屏 (Esc)" : "全屏"}
|
||||
aria-label={isFullscreen ? "退出全屏" : "全屏"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2Icon className="size-3.5" />
|
||||
) : (
|
||||
<Maximize2Icon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -8,20 +8,25 @@ import {
|
||||
ClipboardListIcon,
|
||||
ClockIcon,
|
||||
DnaIcon,
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
GraduationCapIcon,
|
||||
HourglassIcon,
|
||||
Loader2Icon,
|
||||
type LucideIcon,
|
||||
Maximize2Icon,
|
||||
MicroscopeIcon,
|
||||
Minimize2Icon,
|
||||
RefreshCwIcon,
|
||||
ShieldCheckIcon,
|
||||
TrendingUpIcon,
|
||||
UserCheckIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "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";
|
||||
|
||||
@@ -103,6 +108,7 @@ type PipelinePayload = {
|
||||
items: PipelineItem[];
|
||||
in_flight?: InFlight | null;
|
||||
logs: LogLine[];
|
||||
metrics_history?: MetricsHistory | null;
|
||||
};
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
@@ -145,7 +151,9 @@ export function TrainingPipelinePanel({
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
runId?: string;
|
||||
} | null>(null);
|
||||
const [bottomTab, setBottomTab] = useState<"log" | "metrics">("log");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -182,7 +190,7 @@ export function TrainingPipelinePanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden border-r bg-muted/20">
|
||||
<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 ?? []} />
|
||||
@@ -222,6 +230,7 @@ export function TrainingPipelinePanel({
|
||||
key: stripNamespace(card.key),
|
||||
title: card.title,
|
||||
status: card.status,
|
||||
runId: item.run_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -238,6 +247,7 @@ export function TrainingPipelinePanel({
|
||||
key: stripNamespace(item.key),
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
runId: item.run_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -250,11 +260,16 @@ export function TrainingPipelinePanel({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<LegendBar />
|
||||
<LogStream logs={data?.logs ?? []} />
|
||||
<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}
|
||||
@@ -774,96 +789,165 @@ function MiniStatus({ status }: { status: CardStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LegendBar() {
|
||||
const items = [
|
||||
{
|
||||
icon: BarChart3Icon,
|
||||
label: "Measure",
|
||||
sub: "Quantify performance",
|
||||
color:
|
||||
"bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
|
||||
},
|
||||
{
|
||||
icon: DnaIcon,
|
||||
label: "Improve",
|
||||
sub: "Data, training, quality",
|
||||
color:
|
||||
"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
|
||||
},
|
||||
{
|
||||
icon: MicroscopeIcon,
|
||||
label: "Understand",
|
||||
sub: "Analyze & diagnose",
|
||||
color:
|
||||
"bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30",
|
||||
},
|
||||
{
|
||||
icon: RefreshCwIcon,
|
||||
label: "Iterate",
|
||||
sub: "Repeat & grow",
|
||||
color:
|
||||
"bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30",
|
||||
},
|
||||
];
|
||||
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="flex shrink-0 items-center gap-4 border-t bg-background/80 px-6 py-2.5 overflow-x-auto">
|
||||
{items.map(({ icon: Icon, label, sub, color }) => (
|
||||
<div key={label} className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full border",
|
||||
color,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<div className="leading-tight">
|
||||
<div className="text-[11px] font-semibold text-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{sub}</div>
|
||||
</div>
|
||||
<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 LogStream({ logs }: { logs: LogLine[] }) {
|
||||
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 className="flex h-72 shrink-0 flex-col border-t bg-zinc-950 px-5 py-3">
|
||||
<div className="mb-2 flex shrink-0 items-center justify-between">
|
||||
<span className="font-semibold text-xs uppercase tracking-[0.18em] text-zinc-300">
|
||||
Live Log
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-zinc-400 transition-colors hover:text-zinc-200"
|
||||
>
|
||||
查看全部
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className="min-h-0 flex-1 overflow-y-auto font-mono text-[12px] leading-6 text-zinc-100"
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user