feat(skills): add neo candidate and release deletion

Add backend routes to delete neo candidates and releases with optional
reason support and demo mode protection.

Expose delete actions in the Skills dashboard for candidate and release
rows, refresh data after success, and add localized success/failure
messages in en-US and zh-CN.
This commit is contained in:
RC-CHN
2026-02-26 14:48:20 +08:00
parent 8faed949c2
commit 8d5841b71f
4 changed files with 191 additions and 13 deletions
+52
View File
@@ -53,6 +53,8 @@ class SkillsRoute(Route):
"/skills/neo/promote": ("POST", self.promote_neo_candidate),
"/skills/neo/rollback": ("POST", self.rollback_neo_release),
"/skills/neo/sync": ("POST", self.sync_neo_release),
"/skills/neo/delete-candidate": ("POST", self.delete_neo_candidate),
"/skills/neo/delete-release": ("POST", self.delete_neo_release),
}
self.register_routes()
@@ -76,6 +78,14 @@ class SkillsRoute(Route):
)
return endpoint, access_token
async def _delete_neo_release(self, client: Any, release_id: str, reason: str | None):
return await client.skills.delete_release(release_id, reason=reason)
async def _delete_neo_candidate(
self, client: Any, candidate_id: str, reason: str | None
):
return await client.skills.delete_candidate(candidate_id, reason=reason)
async def _with_neo_client(
self,
operation: Callable[[Any], Awaitable[dict]],
@@ -415,3 +425,45 @@ class SkillsRoute(Route):
)
return await self._with_neo_client(_do)
async def delete_neo_candidate(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
logger.info("[Neo] POST /skills/neo/delete-candidate requested.")
data = await request.get_json()
candidate_id = data.get("candidate_id")
reason = data.get("reason")
if not candidate_id:
return Response().error("Missing candidate_id").__dict__
async def _do(client):
result = await self._delete_neo_candidate(client, candidate_id, reason)
logger.info(f"[Neo] Candidate deleted: id={candidate_id}")
return Response().ok(_to_jsonable(result)).__dict__
return await self._with_neo_client(_do)
async def delete_neo_release(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
logger.info("[Neo] POST /skills/neo/delete-release requested.")
data = await request.get_json()
release_id = data.get("release_id")
reason = data.get("reason")
if not release_id:
return Response().error("Missing release_id").__dict__
async def _do(client):
result = await self._delete_neo_release(client, release_id, reason)
logger.info(f"[Neo] Release deleted: id={release_id}")
return Response().ok(_to_jsonable(result)).__dict__
return await self._with_neo_client(_do)
@@ -63,56 +63,73 @@
</template>
<template v-else>
<v-card class="mx-3 mb-4 pa-3" variant="outlined">
<v-row>
<v-card class="mx-3 mb-4 pa-4 neo-filter-card" variant="outlined">
<div class="d-flex flex-wrap justify-space-between align-center ga-2 mb-3">
<div>
<div class="text-subtitle-1 font-weight-bold">Neo Skills</div>
<div class="text-caption text-medium-emphasis">筛选候选与发布记录</div>
</div>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="flat" @click="fetchNeoData">
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-row class="ga-md-0 ga-2">
<v-col cols="12" md="4">
<v-text-field
v-model="neoFilters.skill_key"
:label="tm('skills.neoSkillKey')"
prepend-inner-icon="mdi-key-outline"
density="comfortable"
hide-details
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<v-col cols="12" md="4">
<v-select
v-model="neoFilters.status"
:label="tm('skills.neoStatus')"
:items="candidateStatusItems"
item-title="title"
item-value="value"
prepend-inner-icon="mdi-progress-check"
density="comfortable"
hide-details
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<v-col cols="12" md="4">
<v-select
v-model="neoFilters.stage"
:label="tm('skills.neoStage')"
:items="releaseStageItems"
item-title="title"
item-value="value"
prepend-inner-icon="mdi-layers-outline"
density="comfortable"
hide-details
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2" class="d-flex align-center">
<v-btn color="primary" block @click="fetchNeoData">{{ tm("skills.refresh") }}</v-btn>
</v-col>
</v-row>
</v-card>
<v-progress-linear v-if="neoLoading" indeterminate color="primary"></v-progress-linear>
<v-card class="mx-3 mb-4" variant="outlined">
<v-card-title>{{ tm("skills.neoCandidates") }}</v-card-title>
<div class="mx-3 mb-3 d-flex flex-wrap ga-2">
<v-chip size="small" color="primary" variant="tonal">Candidates: {{ neoCandidates.length }}</v-chip>
<v-chip size="small" color="indigo" variant="tonal">Releases: {{ neoReleases.length }}</v-chip>
<v-chip size="small" color="success" variant="tonal">Active: {{ activeReleaseCount }}</v-chip>
</div>
<v-card class="mx-3 mb-4 neo-table-card" variant="outlined">
<v-card-title class="text-subtitle-1 font-weight-bold">{{ tm("skills.neoCandidates") }}</v-card-title>
<v-data-table
:headers="candidateHeaders"
:items="neoCandidates"
density="compact"
:items-per-page="10"
class="neo-data-table"
>
<template #item.latest_score="{ item }">
{{ item.latest_score ?? "-" }}
@@ -139,18 +156,27 @@
>
Payload
</v-btn>
<v-btn
size="x-small"
color="error"
variant="tonal"
@click="deleteCandidate(item)"
>
{{ tm("skills.neoDelete") }}
</v-btn>
</div>
</template>
</v-data-table>
</v-card>
<v-card class="mx-3 mb-4" variant="outlined">
<v-card-title>{{ tm("skills.neoReleases") }}</v-card-title>
<v-card class="mx-3 mb-4 neo-table-card" variant="outlined">
<v-card-title class="text-subtitle-1 font-weight-bold">{{ tm("skills.neoReleases") }}</v-card-title>
<v-data-table
:headers="releaseHeaders"
:items="neoReleases"
density="compact"
:items-per-page="10"
class="neo-data-table"
>
<template #item.is_active="{ item }">
<v-chip size="small" :color="item.is_active ? 'success' : 'default'" variant="tonal">
@@ -159,12 +185,25 @@
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1 flex-wrap">
<v-btn size="x-small" color="warning" variant="tonal" @click="rollbackRelease(item)">
{{ tm("skills.neoRollback") }}
<v-btn
size="x-small"
color="warning"
variant="tonal"
@click="handleReleaseLifecycleAction(item)"
>
{{ item.is_active ? tm("skills.neoDeactivate") : tm("skills.neoRollback") }}
</v-btn>
<v-btn size="x-small" color="primary" variant="tonal" @click="syncRelease(item)">
{{ tm("skills.neoSync") }}
</v-btn>
<v-btn
size="x-small"
color="error"
variant="tonal"
@click="deleteRelease(item)"
>
{{ tm("skills.neoDelete") }}
</v-btn>
</div>
</template>
</v-data-table>
@@ -282,6 +321,8 @@ export default {
{ title: "stable", value: "stable" },
]);
const activeReleaseCount = computed(() => neoReleases.value.filter((item) => item?.is_active).length);
const candidateHeaders = computed(() => [
{ title: "ID", key: "id", width: "180px" },
{ title: "skill_key", key: "skill_key" },
@@ -495,6 +536,32 @@ export default {
}
};
const deactivateRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/rollback", {
release_id: release.id,
});
handleApiResponse(
res,
tm("skills.neoDeactivateSuccess"),
tm("skills.neoDeactivateFailed"),
async () => {
await fetchNeoData();
},
);
} catch (_err) {
showMessage(tm("skills.neoDeactivateFailed"), "error");
}
};
const handleReleaseLifecycleAction = async (release) => {
if (release?.is_active) {
await deactivateRelease(release);
return;
}
await rollbackRelease(release);
};
const syncRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/sync", {
@@ -526,6 +593,34 @@ export default {
}
};
const deleteCandidate = async (candidate) => {
try {
const res = await axios.post("/api/skills/neo/delete-candidate", {
candidate_id: candidate.id,
reason: "deleted_from_webui",
});
handleApiResponse(res, tm("skills.neoDeleteSuccess"), tm("skills.neoDeleteFailed"), async () => {
await fetchNeoData();
});
} catch (_err) {
showMessage(tm("skills.neoDeleteFailed"), "error");
}
};
const deleteRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/delete-release", {
release_id: release.id,
reason: "deleted_from_webui",
});
handleApiResponse(res, tm("skills.neoDeleteSuccess"), tm("skills.neoDeleteFailed"), async () => {
await fetchNeoData();
});
} catch (_err) {
showMessage(tm("skills.neoDeleteFailed"), "error");
}
};
const refreshCurrentMode = async () => {
if (mode.value === "neo") {
await fetchNeoData();
@@ -565,6 +660,7 @@ export default {
neoFilters,
candidateStatusItems,
releaseStageItems,
activeReleaseCount,
candidateHeaders,
releaseHeaders,
payloadDialog,
@@ -577,8 +673,12 @@ export default {
evaluateCandidate,
promoteCandidate,
rollbackRelease,
deactivateRelease,
handleReleaseLifecycleAction,
syncRelease,
viewPayload,
deleteCandidate,
deleteRelease,
};
},
};
@@ -601,4 +701,21 @@ export default {
border-radius: 8px;
font-size: 12px;
}
.neo-filter-card {
border-radius: 14px;
border-color: rgba(var(--v-theme-primary), 0.25);
background: linear-gradient(180deg, rgba(var(--v-theme-primary), 0.03), rgba(var(--v-theme-surface), 1));
}
.neo-table-card {
border-radius: 14px;
}
.neo-data-table :deep(.v-data-table-header__content) {
font-weight: 700;
}
.neo-data-table :deep(tbody tr:hover) {
background: rgba(var(--v-theme-primary), 0.04);
}
</style>
@@ -246,6 +246,9 @@
"neoSync": "Sync",
"neoSyncSuccess": "Sync succeeded",
"neoSyncFailed": "Sync failed",
"neoDelete": "Delete",
"neoDeleteSuccess": "Deleted successfully",
"neoDeleteFailed": "Failed to delete",
"neoPayloadTitle": "Neo Payload",
"neoPayloadFailed": "Failed to load payload",
"runtimeNoneWarning": "Computer Use runtime is set to None; Skills may not run correctly because no runtime is enabled.",
@@ -243,9 +243,15 @@
"neoRollback": "回滚",
"neoRollbackSuccess": "回滚成功",
"neoRollbackFailed": "回滚失败",
"neoDeactivate": "失活",
"neoDeactivateSuccess": "失活成功",
"neoDeactivateFailed": "失活失败",
"neoSync": "同步",
"neoSyncSuccess": "同步成功",
"neoSyncFailed": "同步失败",
"neoDelete": "删除",
"neoDeleteSuccess": "删除成功",
"neoDeleteFailed": "删除失败",
"neoPayloadTitle": "Neo Payload 详情",
"neoPayloadFailed": "读取 Payload 失败",
"runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",