feat: persist single evaluation history

This commit is contained in:
wuyang6
2026-07-23 19:46:15 +08:00
parent 5be9434ba3
commit 6116d10e8f
5 changed files with 398 additions and 13 deletions
+138 -10
View File
@@ -11,6 +11,7 @@ import {
LoaderCircleIcon,
PauseIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
RotateCcwIcon,
SearchIcon,
@@ -122,6 +123,8 @@ type EvaluationCase = {
skill_name?: string;
skill_version?: string;
model?: string;
created_at?: number;
updated_at?: number;
};
type Experiment = {
@@ -213,6 +216,8 @@ function EvaluationWorkspace() {
const [selectedDatasetId, setSelectedDatasetId] = useState("");
const [selectedExperimentId, setSelectedExperimentId] = useState("");
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [singleAnalyses, setSingleAnalyses] = useState<EvaluationCase[]>([]);
const [selectedSingleAnalysisId, setSelectedSingleAnalysisId] = useState("");
const [mapping, setMapping] = useState<DatasetMapping>({
fields: {},
label_map: {},
@@ -233,6 +238,7 @@ function EvaluationWorkspace() {
const uploadRef = useRef<HTMLInputElement>(null);
const selectedDatasetIdRef = useRef("");
const selectedExperimentIdRef = useRef("");
const selectedSingleAnalysisIdRef = useRef("");
useEffect(() => {
selectedDatasetIdRef.current = selectedDatasetId;
@@ -242,6 +248,10 @@ function EvaluationWorkspace() {
selectedExperimentIdRef.current = selectedExperimentId;
}, [selectedExperimentId]);
useEffect(() => {
selectedSingleAnalysisIdRef.current = selectedSingleAnalysisId;
}, [selectedSingleAnalysisId]);
const selectedDataset = useMemo(
() => datasets.find((item) => item.id === selectedDatasetId) ?? null,
[datasets, selectedDatasetId],
@@ -264,12 +274,14 @@ function EvaluationWorkspace() {
try {
const [
metadataResponse,
analysisResponse,
datasetResponse,
experimentResponse,
modelResponse,
stateResponse,
] = await Promise.all([
fetch("/api/claw/evaluations/metadata", { cache: "no-store" }),
fetch("/api/claw/evaluations/analyses", { cache: "no-store" }),
fetch("/api/claw/evaluations/datasets", { cache: "no-store" }),
fetch("/api/claw/evaluations/experiments", { cache: "no-store" }),
fetch("/api/claw/models", { cache: "no-store" }),
@@ -277,6 +289,8 @@ function EvaluationWorkspace() {
]);
const metadataPayload =
await readJson<EvaluationMetadata>(metadataResponse);
const analysisPayload =
await readJson<EvaluationCase[]>(analysisResponse);
const datasetPayload = await readJson<Dataset[]>(datasetResponse);
const experimentPayload =
await readJson<Experiment[]>(experimentResponse);
@@ -287,11 +301,21 @@ function EvaluationWorkspace() {
? ((await stateResponse.json()) as ClawState)
: {};
setMetadata(metadataPayload);
setSingleAnalyses(analysisPayload);
setDatasets(datasetPayload);
setExperiments(experimentPayload);
setSkillName(
(current) => current || metadataPayload.default_skill || "label-master",
);
const nextSingleAnalysis =
analysisPayload.find(
(item) => item.id === selectedSingleAnalysisIdRef.current,
) ?? analysisPayload[0];
if (nextSingleAnalysis) {
setSelectedSingleAnalysisId(nextSingleAnalysis.id);
setSingleResult((current) => current ?? nextSingleAnalysis);
setSingleQuery((current) => current || nextSingleAnalysis.query);
}
const nextModels = modelPayload.models ?? [];
setModels(nextModels);
setModel((current) => {
@@ -454,7 +478,13 @@ function EvaluationWorkspace() {
model: model || undefined,
}),
});
setSingleResult(await readJson<EvaluationCase>(response));
const payload = await readJson<EvaluationCase>(response);
setSingleResult(payload);
setSelectedSingleAnalysisId(payload.id);
setSingleAnalyses((current) => [
payload,
...current.filter((item) => item.id !== payload.id),
]);
} catch (reason) {
setError(messageFrom(reason));
} finally {
@@ -462,6 +492,35 @@ function EvaluationWorkspace() {
}
}
async function selectSingleAnalysis(analysisId: string) {
setSelectedSingleAnalysisId(analysisId);
setBusy("single-history");
setError("");
try {
const response = await fetch(
`/api/claw/evaluations/analyses/${encodeURIComponent(analysisId)}`,
{ cache: "no-store" },
);
const payload = await readJson<EvaluationCase>(response);
setSingleResult(payload);
setSingleQuery(payload.query);
setSingleAnalyses((current) =>
current.map((item) => (item.id === payload.id ? payload : item)),
);
} catch (reason) {
setError(messageFrom(reason));
} finally {
setBusy("");
}
}
function newSingleAnalysis() {
setSelectedSingleAnalysisId("");
setSingleResult(null);
setSingleQuery("");
setError("");
}
async function act(action: "start" | "pause" | "resume" | "cancel") {
if (!experiment) return;
setBusy(action);
@@ -617,7 +676,12 @@ function EvaluationWorkspace() {
))}
</select>
</label>
{workspaceMode === "batch" ? (
{workspaceMode === "single" ? (
<Button size="sm" onClick={newSingleAnalysis}>
<PlusIcon />
</Button>
) : (
<Button
size="sm"
onClick={() => {
@@ -627,7 +691,7 @@ function EvaluationWorkspace() {
>
</Button>
) : null}
)}
</div>
</div>
</div>
@@ -640,13 +704,60 @@ function EvaluationWorkspace() {
) : null}
{workspaceMode === "single" ? (
<SingleQueryWorkspace
query={singleQuery}
setQuery={setSingleQuery}
result={singleResult}
onAnalyze={analyzeSingleQuery}
busy={busy}
/>
<div className="grid min-h-[calc(100dvh-6.5rem)] lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="border-r bg-muted/15 p-3">
<div className="mb-2 flex items-center justify-between px-2">
<span className="font-medium text-xs"></span>
<span className="text-muted-foreground text-xs">
{singleAnalyses.length}
</span>
</div>
<div className="space-y-1">
{singleAnalyses.map((item) => (
<button
type="button"
key={item.id}
className={`w-full rounded-md px-2.5 py-2 text-left transition-colors ${
selectedSingleAnalysisId === item.id
? "bg-accent text-accent-foreground"
: "hover:bg-accent/60"
}`}
onClick={() => void selectSingleAnalysis(item.id)}
>
<div className="flex items-center gap-2">
<StatusDot status={item.status} />
<span className="min-w-0 flex-1 truncate font-medium text-sm">
{item.query}
</span>
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
</div>
<div className="mt-1 flex items-center justify-between gap-2 pl-5 text-muted-foreground text-xs">
<span className="truncate">
{item.prediction || item.skill_name || "分析失败"}
</span>
<span className="shrink-0">
{formatTimestamp(item.created_at)}
</span>
</div>
</button>
))}
{!singleAnalyses.length ? (
<div className="px-2 py-8 text-center text-muted-foreground text-xs">
</div>
) : null}
</div>
</aside>
<section className="min-w-0">
<SingleQueryWorkspace
query={singleQuery}
setQuery={setSingleQuery}
result={singleResult}
onAnalyze={analyzeSingleQuery}
busy={busy}
/>
</section>
</div>
) : (
<div className="grid min-h-[calc(100dvh-6.5rem)] lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="border-r bg-muted/15 p-3">
@@ -1752,6 +1863,23 @@ function formatDuration(value: number | null) {
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}s`;
}
function formatTimestamp(value?: number) {
if (!value) return "";
const date = new Date(value * 1000);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
return date.toLocaleDateString("zh-CN", {
month: "2-digit",
day: "2-digit",
});
}
function buildMappedPreview(
rows: Array<Record<string, unknown>>,
mapping: DatasetMapping,