feat: add editable skill evaluation versions
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user