diff --git a/backend/api/server.py b/backend/api/server.py index 925f451..f04a19a 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -1149,6 +1149,7 @@ class EvaluationAnalyzeRequest(BaseModel): account_id: str = Field(min_length=1) query: str = Field(min_length=1, max_length=20_000) skill_name: str = 'label-master' + snapshot_id: str | None = None model: str | None = None history: list[dict[str, Any]] = Field(default_factory=list) context: dict[str, Any] = Field(default_factory=dict) @@ -1162,10 +1163,19 @@ class EvaluationExperimentCreateRequest(BaseModel): dataset_id: str = Field(min_length=1) name: str = '' skill_name: str = 'label-master' + snapshot_id: str | None = None model: str | None = None concurrency: int = Field(default=2, ge=1, le=8) +class EvaluationSkillVersionSaveRequest(BaseModel): + account_id: str = Field(min_length=1) + base_snapshot_id: str = Field(min_length=1) + version_name: str = Field(min_length=1, max_length=80) + note: str = Field(default='', max_length=1000) + files: dict[str, str] + + class EvaluationExperimentActionRequest(BaseModel): account_id: str = Field(min_length=1) @@ -1534,6 +1544,74 @@ def create_app(state: AgentState) -> FastAPI: except EvaluationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + @app.get('/api/evaluations/skills/{skill_name}/versions') + async def list_evaluation_skill_versions( + skill_name: str, + account_id: str, + ) -> dict[str, Any]: + try: + return await asyncio.to_thread( + state.evaluation_runtime.list_skill_versions, + _safe_account_id(account_id), + skill_name, + ) + except EvaluationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get( + '/api/evaluations/skills/{skill_name}/versions/{snapshot_id}' + ) + async def get_evaluation_skill_version( + skill_name: str, + snapshot_id: str, + account_id: str, + ) -> dict[str, Any]: + try: + return state.evaluation_runtime.get_skill_version_manifest( + _safe_account_id(account_id), + skill_name, + snapshot_id, + ) + except EvaluationError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.get( + '/api/evaluations/skills/{skill_name}/versions/{snapshot_id}/file' + ) + async def get_evaluation_skill_version_file( + skill_name: str, + snapshot_id: str, + account_id: str, + path: str, + ) -> dict[str, Any]: + try: + return state.evaluation_runtime.read_skill_version_file( + _safe_account_id(account_id), + skill_name, + snapshot_id, + path, + ) + except EvaluationError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post('/api/evaluations/skills/{skill_name}/versions') + async def save_evaluation_skill_version( + skill_name: str, + payload: EvaluationSkillVersionSaveRequest, + ) -> dict[str, Any]: + try: + return await asyncio.to_thread( + state.evaluation_runtime.save_skill_version, + _safe_account_id(payload.account_id), + skill_name, + base_snapshot_id=payload.base_snapshot_id, + version_name=payload.version_name, + note=payload.note, + files=payload.files, + ) + except EvaluationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + @app.post('/api/evaluations/analyze') async def analyze_evaluation_case( payload: EvaluationAnalyzeRequest, @@ -1544,6 +1622,7 @@ def create_app(state: AgentState) -> FastAPI: account_id=_safe_account_id(payload.account_id), query=payload.query, skill_name=payload.skill_name, + snapshot_id=payload.snapshot_id, model=payload.model, history=payload.history, context=payload.context, @@ -1642,6 +1721,7 @@ def create_app(state: AgentState) -> FastAPI: dataset_id=payload.dataset_id, name=payload.name, skill_name=payload.skill_name, + snapshot_id=payload.snapshot_id, model=payload.model, concurrency=payload.concurrency, ) diff --git a/frontend/app/app/evaluations/page.tsx b/frontend/app/app/evaluations/page.tsx index cbffefc..35273e3 100644 --- a/frontend/app/app/evaluations/page.tsx +++ b/frontend/app/app/evaluations/page.tsx @@ -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([]); + 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(null); + const [selectedSkillFile, setSelectedSkillFile] = useState(""); + const [skillFileContents, setSkillFileContents] = useState< + Record + >({}); + const [dirtySkillFiles, setDirtySkillFiles] = useState([]); + const [skillVersionName, setSkillVersionName] = useState(""); + const [skillVersionNote, setSkillVersionNote] = useState(""); const uploadRef = useRef(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(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(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(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(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() { ))} + +