feat: add editable skill evaluation versions

This commit is contained in:
wuyang6
2026-07-24 10:11:51 +08:00
parent ecc02c06b7
commit 6630e82069
5 changed files with 1070 additions and 3 deletions
+461
View File
@@ -6,14 +6,19 @@ import {
ChevronRightIcon,
CircleAlertIcon,
DownloadIcon,
FileCode2Icon,
FilePenLineIcon,
FileSpreadsheetIcon,
FileTextIcon,
FlaskConicalIcon,
GitBranchIcon,
LoaderCircleIcon,
PauseIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
RotateCcwIcon,
SaveIcon,
SearchIcon,
SquareIcon,
UploadIcon,
@@ -50,6 +55,45 @@ type EvaluationMetadata = {
};
};
type SkillVersion = {
id: string;
skill_name: string;
version_name: string;
version_note: string;
source_type: "git" | "local";
parent_snapshot_id: string;
content_hash: string;
git_commit: string;
git_status: string;
file_count: number;
created_at: number;
is_current: boolean;
};
type SkillVersionFile = {
path: string;
size: number;
editable: boolean;
previewable: boolean;
};
type SkillVersionManifest = SkillVersion & {
files: SkillVersionFile[];
};
type SkillVersionsResponse = {
skill_name: string;
current_snapshot_id: string;
versions: SkillVersion[];
};
type SkillFileContent = {
path: string;
content: string;
size: number;
editable: boolean;
};
type ModelOption = {
id: string;
name?: string;
@@ -223,6 +267,8 @@ function EvaluationWorkspace() {
label_map: {},
});
const [skillName, setSkillName] = useState("");
const [skillVersions, setSkillVersions] = useState<SkillVersion[]>([]);
const [snapshotId, setSnapshotId] = useState("");
const [model, setModel] = useState("");
const [concurrency, setConcurrency] = useState(2);
const [experimentName, setExperimentName] = useState("");
@@ -235,6 +281,18 @@ function EvaluationWorkspace() {
const [queryFilter, setQueryFilter] = useState("");
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [skillEditorOpen, setSkillEditorOpen] = useState(false);
const [skillEditorBusy, setSkillEditorBusy] = useState("");
const [skillEditorError, setSkillEditorError] = useState("");
const [skillManifest, setSkillManifest] =
useState<SkillVersionManifest | null>(null);
const [selectedSkillFile, setSelectedSkillFile] = useState("");
const [skillFileContents, setSkillFileContents] = useState<
Record<string, SkillFileContent>
>({});
const [dirtySkillFiles, setDirtySkillFiles] = useState<string[]>([]);
const [skillVersionName, setSkillVersionName] = useState("");
const [skillVersionNote, setSkillVersionNote] = useState("");
const uploadRef = useRef<HTMLInputElement>(null);
const selectedDatasetIdRef = useRef("");
const selectedExperimentIdRef = useRef("");
@@ -256,6 +314,33 @@ function EvaluationWorkspace() {
() => datasets.find((item) => item.id === selectedDatasetId) ?? null,
[datasets, selectedDatasetId],
);
const selectedSkillVersion = useMemo(
() => skillVersions.find((item) => item.id === snapshotId) ?? null,
[skillVersions, snapshotId],
);
const loadSkillVersions = useCallback(
async (nextSkillName: string, preferredSnapshotId = "") => {
if (!nextSkillName) return null;
const response = await fetch(
`/api/claw/evaluations/skills/${encodeURIComponent(nextSkillName)}/versions`,
{ cache: "no-store" },
);
const payload = await readJson<SkillVersionsResponse>(response);
setSkillVersions(payload.versions);
const preferred = payload.versions.find(
(item) => item.id === preferredSnapshotId,
);
setSnapshotId(
preferred?.id ||
payload.current_snapshot_id ||
payload.versions[0]?.id ||
"",
);
return payload;
},
[],
);
const refreshExperiment = useCallback(async (experimentId: string) => {
const response = await fetch(
@@ -358,6 +443,15 @@ function EvaluationWorkspace() {
void loadWorkspace();
}, [loadWorkspace]);
useEffect(() => {
if (!skillName) return;
setSkillVersions([]);
setSnapshotId("");
void loadSkillVersions(skillName).catch((reason) =>
setError(messageFrom(reason)),
);
}, [loadSkillVersions, skillName]);
useEffect(() => {
if (!experiment || !ACTIVE_STATUSES.has(experiment.status)) return;
const timer = window.setInterval(() => {
@@ -368,6 +462,131 @@ function EvaluationWorkspace() {
return () => window.clearInterval(timer);
}, [experiment, refreshExperiment]);
async function fetchSkillFile(
versionId: string,
filePath: string,
force = false,
) {
setSelectedSkillFile(filePath);
const cached = skillFileContents[filePath];
if (cached && !force) return cached;
setSkillEditorBusy("file");
setSkillEditorError("");
try {
const response = await fetch(
`/api/claw/evaluations/skills/${encodeURIComponent(skillName)}/versions/${encodeURIComponent(versionId)}/file?path=${encodeURIComponent(filePath)}`,
{ cache: "no-store" },
);
const payload = await readJson<SkillFileContent>(response);
setSkillFileContents((current) => ({
...current,
[filePath]: payload,
}));
return payload;
} catch (reason) {
setSkillEditorError(messageFrom(reason));
return null;
} finally {
setSkillEditorBusy("");
}
}
async function openSkillEditor() {
if (!skillName) return;
setSkillEditorOpen(true);
setSkillEditorBusy("manifest");
setSkillEditorError("");
setSkillManifest(null);
setSelectedSkillFile("");
setSkillFileContents({});
setDirtySkillFiles([]);
setSkillVersionName("");
setSkillVersionNote("");
try {
let versionId = snapshotId;
if (!versionId) {
const versions = await loadSkillVersions(skillName);
versionId = versions?.current_snapshot_id ?? "";
}
if (!versionId) throw new Error("当前 Skill 没有可用版本");
const response = await fetch(
`/api/claw/evaluations/skills/${encodeURIComponent(skillName)}/versions/${encodeURIComponent(versionId)}`,
{ cache: "no-store" },
);
const manifest = await readJson<SkillVersionManifest>(response);
setSkillManifest(manifest);
const firstFile =
manifest.files.find((item) => item.path === "SKILL.md") ??
manifest.files.find((item) => item.previewable);
if (firstFile) {
await fetchSkillFile(manifest.id, firstFile.path, true);
}
} catch (reason) {
setSkillEditorError(messageFrom(reason));
} finally {
setSkillEditorBusy("");
}
}
function updateSkillFile(content: string) {
if (!selectedSkillFile) return;
setSkillFileContents((current) => {
const existing = current[selectedSkillFile];
if (!existing) return current;
return {
...current,
[selectedSkillFile]: { ...existing, content },
};
});
setDirtySkillFiles((current) =>
current.includes(selectedSkillFile)
? current
: [...current, selectedSkillFile],
);
}
async function saveSkillVersion() {
if (!skillManifest) return;
if (!skillVersionName.trim()) {
setSkillEditorError("请填写新版本名称");
return;
}
if (!dirtySkillFiles.length) {
setSkillEditorError("当前没有文件修改");
return;
}
setSkillEditorBusy("save");
setSkillEditorError("");
try {
const files = Object.fromEntries(
dirtySkillFiles.map((path) => [
path,
skillFileContents[path]?.content ?? "",
]),
);
const response = await fetch(
`/api/claw/evaluations/skills/${encodeURIComponent(skillName)}/versions`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
base_snapshot_id: skillManifest.id,
version_name: skillVersionName.trim(),
note: skillVersionNote.trim(),
files,
}),
},
);
const saved = await readJson<SkillVersion>(response);
await loadSkillVersions(skillName, saved.id);
setSkillEditorOpen(false);
} catch (reason) {
setSkillEditorError(messageFrom(reason));
} finally {
setSkillEditorBusy("");
}
}
async function uploadDataset(file: File) {
setBusy("upload");
setError("");
@@ -434,6 +653,7 @@ function EvaluationWorkspace() {
name:
experimentName.trim() || `${skillName} · ${selectedDataset.name}`,
skill_name: skillName,
snapshot_id: snapshotId || undefined,
model: model || undefined,
concurrency,
}),
@@ -475,6 +695,7 @@ function EvaluationWorkspace() {
body: JSON.stringify({
query,
skill_name: skillName,
snapshot_id: snapshotId || undefined,
model: model || undefined,
}),
});
@@ -661,6 +882,34 @@ function EvaluationWorkspace() {
))}
</select>
</label>
<label className="flex h-8 items-center gap-2 rounded-md border bg-background px-2">
<span className="text-muted-foreground text-xs"></span>
<select
aria-label="全局 Skill 版本"
value={snapshotId}
onChange={(event) => setSnapshotId(event.target.value)}
disabled={!skillVersions.length}
className="h-7 min-w-36 max-w-56 bg-transparent font-medium text-xs outline-none disabled:opacity-60"
>
{skillVersions.map((version) => (
<option key={version.id} value={version.id}>
{version.version_name}
{version.source_type === "local" ? " · 本地" : ""}
{` · ${version.content_hash.slice(0, 8)}`}
</option>
))}
</select>
</label>
<Button
variant="outline"
size="sm"
onClick={() => void openSkillEditor()}
disabled={!snapshotId}
title="查看并编辑当前 Skill 版本"
>
<FilePenLineIcon />
</Button>
<label className="flex h-8 min-w-0 items-center gap-2 rounded-md border bg-background px-2">
<span className="text-muted-foreground text-xs"></span>
<select
@@ -871,10 +1120,222 @@ function EvaluationWorkspace() {
);
}}
/>
<SkillEditorSheet
open={skillEditorOpen}
onOpenChange={setSkillEditorOpen}
skillName={skillName}
version={selectedSkillVersion}
manifest={skillManifest}
selectedFile={selectedSkillFile}
fileContent={
selectedSkillFile
? (skillFileContents[selectedSkillFile] ?? null)
: null
}
dirtyFiles={dirtySkillFiles}
busy={skillEditorBusy}
error={skillEditorError}
versionName={skillVersionName}
setVersionName={setSkillVersionName}
versionNote={skillVersionNote}
setVersionNote={setSkillVersionNote}
onSelectFile={(filePath) => {
if (skillManifest) {
void fetchSkillFile(skillManifest.id, filePath);
}
}}
onChangeContent={updateSkillFile}
onSave={() => void saveSkillVersion()}
/>
</main>
);
}
function SkillEditorSheet(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
skillName: string;
version: SkillVersion | null;
manifest: SkillVersionManifest | null;
selectedFile: string;
fileContent: SkillFileContent | null;
dirtyFiles: string[];
busy: string;
error: string;
versionName: string;
setVersionName: (value: string) => void;
versionNote: string;
setVersionNote: (value: string) => void;
onSelectFile: (path: string) => void;
onChangeContent: (content: string) => void;
onSave: () => void;
}) {
const {
open,
onOpenChange,
skillName,
version,
manifest,
selectedFile,
fileContent,
dirtyFiles,
busy,
error,
versionName,
setVersionName,
versionNote,
setVersionNote,
onSelectFile,
onChangeContent,
onSave,
} = props;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[min(96vw,1100px)] gap-0 sm:max-w-[1100px]">
<SheetHeader className="border-b px-5 py-4">
<SheetTitle className="flex items-center gap-2">
<FilePenLineIcon className="size-4" />
Skill
</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span>{skillName}</span>
<span className="inline-flex items-center gap-1">
<GitBranchIcon className="size-3.5" />
{version?.version_name || "正在读取版本"}
</span>
{version?.content_hash ? (
<code>{version.content_hash.slice(0, 12)}</code>
) : null}
</SheetDescription>
</SheetHeader>
{error ? (
<div className="mx-5 mt-3 flex items-start gap-2 border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm">
<CircleAlertIcon className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
<div className="grid min-h-0 flex-1 grid-cols-[230px_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-r bg-muted/15 p-3">
<div className="mb-2 flex items-center justify-between px-2 text-xs">
<span className="font-medium">Skill </span>
<span className="text-muted-foreground">
{manifest?.files.length ?? 0}
</span>
</div>
<div className="space-y-0.5">
{manifest?.files.map((file) => {
const dirty = dirtyFiles.includes(file.path);
return (
<button
type="button"
key={file.path}
onClick={() => onSelectFile(file.path)}
disabled={!file.previewable}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
selectedFile === file.path
? "bg-accent text-accent-foreground"
: "hover:bg-accent/60"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
{file.path.endsWith(".md") ? (
<FileTextIcon className="size-3.5 shrink-0" />
) : (
<FileCode2Icon className="size-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">{file.path}</span>
{dirty ? (
<span
className="size-1.5 shrink-0 rounded-full bg-sky-500"
title="已修改"
/>
) : null}
</button>
);
})}
</div>
</aside>
<section className="flex min-h-0 min-w-0 flex-col">
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4">
<span className="min-w-0 flex-1 truncate font-medium text-sm">
{selectedFile || "选择一个文件"}
</span>
{fileContent ? (
<span className="text-muted-foreground text-xs">
{fileContent.editable ? "可编辑" : "只读"}
</span>
) : null}
{busy === "file" ? (
<LoaderCircleIcon className="size-4 animate-spin text-muted-foreground" />
) : null}
</div>
<div className="min-h-0 flex-1 p-4">
{fileContent ? (
<textarea
value={fileContent.content}
onChange={(event) => onChangeContent(event.target.value)}
readOnly={!fileContent.editable}
spellCheck={false}
className="h-full min-h-72 w-full resize-none border bg-muted/10 p-4 font-mono text-[13px] leading-6 outline-none focus:ring-2 focus:ring-ring/40 read-only:cursor-default read-only:opacity-80"
/>
) : (
<div className="grid h-full min-h-72 place-items-center text-muted-foreground text-sm">
{busy === "manifest" ? (
<LoaderCircleIcon className="size-5 animate-spin" />
) : (
"选择左侧文件查看内容"
)}
</div>
)}
</div>
<div className="shrink-0 border-t bg-muted/10 px-4 py-3">
<div className="grid gap-3 md:grid-cols-[minmax(0,220px)_minmax(0,1fr)_auto] md:items-end">
<Field label="新版本名称">
<Input
value={versionName}
onChange={(event) => setVersionName(event.target.value)}
placeholder="例如:导航边界调整 v1"
maxLength={80}
/>
</Field>
<Field label="版本备注">
<Input
value={versionNote}
onChange={(event) => setVersionNote(event.target.value)}
placeholder="记录本次调整内容"
maxLength={1000}
/>
</Field>
<Button
onClick={onSave}
disabled={busy === "save" || !dirtyFiles.length}
>
{busy === "save" ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<SaveIcon />
)}
</Button>
</div>
<p className="mt-2 text-muted-foreground text-xs">
Markdown JSON
{dirtyFiles.length
? ` 当前修改 ${dirtyFiles.length} 个文件。`
: ""}
</p>
</div>
</section>
</div>
</SheetContent>
</Sheet>
);
}
function SingleQueryWorkspace(props: {
query: string;
setQuery: (value: string) => void;