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:
hupenglong1
2026-05-25 11:43:10 +08:00
parent 57f5b60a3e
commit f068311cac
17 changed files with 1571 additions and 270 deletions
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
const step = incoming.searchParams.get("step") ?? "";
const runId = incoming.searchParams.get("run_id");
if (!sessionId || !step) {
return Response.json(
{ error: "session_id and step are required" },
@@ -20,6 +21,7 @@ export async function GET(request: Request) {
url.searchParams.set("account_id", account.id);
url.searchParams.set("session_id", sessionId);
url.searchParams.set("step", step);
if (runId) url.searchParams.set("run_id", runId);
const response = await fetch(url, { cache: "no-store" });
const payload = await response.text();
+141 -28
View File
@@ -1,7 +1,11 @@
"use client";
import type { ExportedMessageRepository } from "@assistant-ui/core";
import { AssistantRuntimeProvider, useAuiState } from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
useAui,
useAuiState,
} from "@assistant-ui/react";
import {
AssistantChatTransport,
useChatRuntime,
@@ -29,6 +33,7 @@ import {
} from "@/components/ui/sidebar";
import {
ACTIVE_SESSION_CHANGED_EVENT,
consumeFreshLocalId,
readActiveSessionId,
readPendingWorkspaceSessionId,
writeActiveSessionId,
@@ -91,6 +96,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
});
const replaySession = useCallback(
(sessionId: string, repository: ExportedMessageRepository) => {
console.log("[replay-session] called", { sessionId });
writeActiveSessionId(sessionId);
pushSessionUrl(sessionId);
// 后端已经落盘/结束后,用回放结果接管当前会话。
@@ -173,6 +179,22 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
);
};
// 共享 composer 草稿缓存里 newTask(无 sessionId)使用的 key。
const NEWTASK_KEY = "__newtask__";
// AssistantWorkspace cache effect 通知 ImeComposerInput 直接更新 textarea
// 内容(绕过 store→local 间接路径,避免 switchToNewThread 时序问题)。
export const COMPOSER_RESTORE_EVENT = "claw-composer-restore-draft";
// __LOCALID_xxx 是侧栏在 newTask 状态下点工作区时临时分配的 placeholder
// session id(让 jupyter bind 有 id 可传)。每个 LOCALID 拥有独立的 draft
// key,避免多个 LOCALID 会话在 cache 里互相覆盖。newTask → 首次生成 LOCALID
// 这一瞬的 textarea 闪空,由下面 save/restore effect 里的一次性继承处理。
function getDraftKey(activeSessionId: string | null): string {
if (!activeSessionId) return NEWTASK_KEY;
return activeSessionId;
}
// 用户在对话里表达「我要开始/进行模型训练(这一轮迭代)」时命中的关键短语。
// 要求动词或专属触发词必须存在;避免 "什么是模型训练" 之类无意义匹配。
const APPLY_INTENT_PATTERNS: RegExp[] = [
@@ -197,6 +219,7 @@ function detectApplyIntent(text: string): boolean {
}
function AssistantWorkspace() {
const aui = useAui();
const [activeSessionId, setActiveSessionId] = useState<string | null>(() =>
readActiveSessionId(),
);
@@ -209,6 +232,12 @@ function AssistantWorkspace() {
const pipelineContainerRef = useRef<HTMLDivElement>(null);
const lastPipelineYRef = useRef<number | null>(null);
const [exitButtonVisible, setExitButtonVisible] = useState(false);
// Composer 是 per-thread 的,但本应用所有 backend session 都共用同一个
// assistant-ui thread(从不调 switchToThread),因此 composer.text 跨 session
// 共享。在 activeSessionId 切换时手动 save+restore,给每个 session 一份
// 独立的 draft。null sessionIdnewTask)用 NEWTASK_KEY 落盘。
const composerCacheRef = useRef<Map<string, string>>(new Map());
const prevSessionKeyRef = useRef<string>(getDraftKey(activeSessionId));
const handlePipelineMouseMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
@@ -244,11 +273,20 @@ function AssistantWorkspace() {
}, [showPipeline]);
useEffect(() => {
const update = () => setActiveSessionId(readActiveSessionId());
const update = () => {
const next = readActiveSessionId();
console.log("[active-session] focus-update", { next });
setActiveSessionId(next);
};
const handleChanged = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string | null }>)
.detail;
setActiveSessionId(detail?.sessionId ?? readActiveSessionId());
const next = detail?.sessionId ?? readActiveSessionId();
console.log("[active-session] event-changed", {
detailSessionId: detail?.sessionId,
next,
});
setActiveSessionId(next);
};
window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
window.addEventListener("focus", update);
@@ -259,6 +297,59 @@ function AssistantWorkspace() {
};
}, []);
// 切 session 时保存当前 composer 草稿到旧 key、恢复新 key 的草稿。
useEffect(() => {
const cache = composerCacheRef.current;
const nextKey = getDraftKey(activeSessionId);
const prevKey = prevSessionKeyRef.current;
console.log("[draft-cache] effect-fire", {
prevKey,
nextKey,
activeSessionId,
willSkip: prevKey === nextKey,
});
if (prevKey === nextKey) return;
// 不能信任 aui store 的 composer.text——assistant-ui 内部 reducer 在
// 切换 / isEditing 翻转时会让 store 与 textarea 脱钩(典型现象:textarea
// 显示着 "hello" 但 store 已经是 ""),后果是这里把空串存进 cache、
// 用户的草稿被静默吞掉。直接读 DOM 拿 textarea 当前真实值。
// EditComposer 用的是 aui-edit-composer-input,不会撞 selector。
const composerEl = document.querySelector<HTMLTextAreaElement>(
"textarea.aui-composer-input",
);
const composerState = aui.composer().getState();
const savingText =
composerEl?.value ?? composerState.text ?? "";
cache.set(prevKey, savingText);
// newTask 里点工作区会**新生成**一个 LOCALID 并切过去,用户视角里
// 这是同一份草稿的延续——把 newTask 的文本继承到 LOCALID 名下,
// 避免 textarea 闪空。consumeFreshLocalId 只对前端刚生成的 LOCALID
// 命中,点击侧栏老 LOCALID 不命中,确保历史会话之间互不串。
const inheritFromNewTask =
prevKey === NEWTASK_KEY && consumeFreshLocalId(activeSessionId);
if (inheritFromNewTask) {
cache.set(nextKey, savingText);
}
const restored = cache.get(nextKey) ?? "";
console.log("[draft-cache] do-switch", {
prevKey,
nextKey,
savingText,
savingTextSource: composerEl ? "dom" : "store",
storeText: composerState.text,
restored,
inheritFromNewTask,
});
aui.composer().setText(restored);
window.dispatchEvent(
new CustomEvent(COMPOSER_RESTORE_EVENT, {
detail: { text: restored },
}),
);
console.log("[draft-cache] dispatched", { restored });
prevSessionKeyRef.current = nextKey;
}, [activeSessionId, aui]);
useEffect(() => {
closeActivityPanel();
}, [showPipeline, closeActivityPanel]);
@@ -280,36 +371,58 @@ function AssistantWorkspace() {
if (applied) return;
if (!activeSessionId) return;
let cancelled = false;
const url = `/api/claw/training/pipeline?session_id=${encodeURIComponent(activeSessionId)}`;
const pipelineUrl = `/api/claw/training/pipeline?session_id=${encodeURIComponent(activeSessionId)}`;
const runUrl = `/api/claw/runs/latest?session_id=${encodeURIComponent(activeSessionId)}`;
const tick = async () => {
try {
const res = await fetch(url, { cache: "no-store" });
const res = await fetch(pipelineUrl, { cache: "no-store" });
if (res.ok) {
const payload = await res.json();
if (cancelled) return;
if (payload) {
const items = Array.isArray(payload.items) ? payload.items : [];
const hasItemProgress = items.some(
(it: {
type?: string;
status?: string;
cards?: Array<{ status?: string }>;
}) => {
if (it.type === "gate") {
return it.status === "running" || it.status === "complete";
}
return (
Array.isArray(it.cards) &&
it.cards.some(
(c) =>
c.status === "running" || c.status === "complete",
)
);
},
);
// items[] 走 phase-complete-only 过滤;R0 只有一张卡在跑时
// items 是空的,进展信号在 in_flight 字段里——也算进展。
const hasInFlight = Boolean(payload.in_flight);
if (hasItemProgress || hasInFlight) {
setApplied(true);
return;
}
}
}
} catch {
/* ignore, fall through to runs/latest fallback */
}
// Pipeline 还没写出 items/in_flightagent 处于 prep 阶段,比如在 watcher
// 里 sleep 等远端文件)时,pipeline 端点会返回 state=pending、items=[]
// 但后端 run 其实已经在跑。直接看 run 状态兜底,免得用户起了训练但
// monitor 面板等到第一个 phase 才弹。
try {
const res = await fetch(runUrl, { cache: "no-store" });
if (!res.ok) return;
const payload = await res.json();
if (cancelled || !payload) return;
const items = Array.isArray(payload.items) ? payload.items : [];
const hasItemProgress = items.some(
(it: {
type?: string;
status?: string;
cards?: Array<{ status?: string }>;
}) => {
if (it.type === "gate") {
return it.status === "running" || it.status === "complete";
}
return (
Array.isArray(it.cards) &&
it.cards.some(
(c) =>
c.status === "running" || c.status === "complete",
)
);
},
);
// items[] 走 phase-complete-only 过滤;R0 只有一张卡在跑时
// items 是空的,进展信号在 in_flight 字段里——也算进展。
const hasInFlight = Boolean(payload.in_flight);
if (hasItemProgress || hasInFlight) setApplied(true);
if (payload.status === "running" || payload.status === "queued") {
setApplied(true);
}
} catch {
/* ignore */
}
@@ -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);
+38 -19
View File
@@ -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>
);
}
+16
View File
@@ -87,3 +87,19 @@ function normalizeSessionId(value: unknown) {
const trimmed = value.trim();
return trimmed || null;
}
// 记录"前端刚生成的 LOCALID"。AssistantWorkspace 的 draft cache effect 用它
// 区分:从 newTask 切到一个**新生成**的 LOCALID(草稿应继承)vs 切到一个
// **侧栏点击**的老 LOCALID(草稿不应继承)。
const freshLocalIds = new Set<string>();
export function markFreshLocalId(sessionId: string) {
freshLocalIds.add(sessionId);
}
export function consumeFreshLocalId(sessionId: string | null): boolean {
if (!sessionId) return false;
if (!freshLocalIds.has(sessionId)) return false;
freshLocalIds.delete(sessionId);
return true;
}
+372
View File
@@ -27,6 +27,7 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -4691,6 +4692,69 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -4963,6 +5027,127 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4979,6 +5164,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -5025,6 +5216,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
@@ -5058,6 +5259,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/eventsource-parser": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
@@ -5071,6 +5278,15 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -5137,6 +5353,15 @@
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -5197,6 +5422,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
@@ -5451,6 +5682,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -5460,6 +5697,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lucide-react": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
@@ -6394,6 +6643,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -6482,6 +6740,23 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -6687,6 +6962,12 @@
"react": "^19.2.5"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -6758,6 +7039,21 @@
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -6795,6 +7091,54 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -7051,6 +7395,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -7322,6 +7672,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+1
View File
@@ -31,6 +31,7 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",