feat(dashboard): add neo skills APIs and management UI

This commit is contained in:
zenfun
2026-02-11 17:14:55 +08:00
parent 73251db1da
commit a8cc995633
7 changed files with 966 additions and 82 deletions
+294
View File
@@ -1,15 +1,38 @@
import os
import traceback
from typing import Any
from quart import request
from astrbot.core import DEMO_MODE, logger
from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
from astrbot.core.skills.skill_manager import SkillManager
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from .route import Response, Route, RouteContext
def _to_jsonable(value: Any) -> Any:
if isinstance(value, dict):
return {k: _to_jsonable(v) for k, v in value.items()}
if isinstance(value, list):
return [_to_jsonable(v) for v in value]
if hasattr(value, "model_dump"):
return _to_jsonable(value.model_dump())
return value
def _to_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
return bool(value)
class SkillsRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle) -> None:
super().__init__(context)
@@ -19,9 +42,32 @@ class SkillsRoute(Route):
"/skills/upload": ("POST", self.upload_skill),
"/skills/update": ("POST", self.update_skill),
"/skills/delete": ("POST", self.delete_skill),
"/skills/neo/candidates": ("GET", self.get_neo_candidates),
"/skills/neo/releases": ("GET", self.get_neo_releases),
"/skills/neo/payload": ("GET", self.get_neo_payload),
"/skills/neo/evaluate": ("POST", self.evaluate_neo_candidate),
"/skills/neo/promote": ("POST", self.promote_neo_candidate),
"/skills/neo/rollback": ("POST", self.rollback_neo_release),
"/skills/neo/sync": ("POST", self.sync_neo_release),
}
self.register_routes()
def _get_neo_client_config(self) -> tuple[str, str]:
provider_settings = self.core_lifecycle.astrbot_config.get(
"provider_settings",
{},
)
sandbox = provider_settings.get("sandbox", {})
endpoint = sandbox.get("shipyard_neo_endpoint", "")
access_token = sandbox.get("shipyard_neo_access_token", "")
if not endpoint or not access_token:
raise ValueError(
"Shipyard Neo configuration is incomplete. "
"Please set provider_settings.sandbox.shipyard_neo_endpoint "
"and shipyard_neo_access_token."
)
return endpoint, access_token
async def get_skills(self):
try:
provider_settings = self.core_lifecycle.astrbot_config.get(
@@ -121,3 +167,251 @@ class SkillsRoute(Route):
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def get_neo_candidates(self):
try:
endpoint, access_token = self._get_neo_client_config()
status = request.args.get("status")
skill_key = request.args.get("skill_key")
limit = int(request.args.get("limit", 100))
offset = int(request.args.get("offset", 0))
from shipyard_neo import BayClient
async with BayClient(
endpoint_url=endpoint,
access_token=access_token,
) as client:
candidates = await client.skills.list_candidates(
status=status,
skill_key=skill_key,
limit=limit,
offset=offset,
)
return Response().ok(_to_jsonable(candidates)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def get_neo_releases(self):
try:
endpoint, access_token = self._get_neo_client_config()
skill_key = request.args.get("skill_key")
stage = request.args.get("stage")
active_only = _to_bool(request.args.get("active_only"), False)
limit = int(request.args.get("limit", 100))
offset = int(request.args.get("offset", 0))
from shipyard_neo import BayClient
async with BayClient(
endpoint_url=endpoint,
access_token=access_token,
) as client:
releases = await client.skills.list_releases(
skill_key=skill_key,
active_only=active_only,
stage=stage,
limit=limit,
offset=offset,
)
return Response().ok(_to_jsonable(releases)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def get_neo_payload(self):
try:
endpoint, access_token = self._get_neo_client_config()
payload_ref = request.args.get("payload_ref", "")
if not payload_ref:
return Response().error("Missing payload_ref").__dict__
from shipyard_neo import BayClient
async with BayClient(
endpoint_url=endpoint,
access_token=access_token,
) as client:
payload = await client.skills.get_payload(payload_ref)
return Response().ok(_to_jsonable(payload)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def evaluate_neo_candidate(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
endpoint, access_token = self._get_neo_client_config()
data = await request.get_json()
candidate_id = data.get("candidate_id")
passed = data.get("passed")
if not candidate_id or passed is None:
return Response().error("Missing candidate_id or passed").__dict__
from shipyard_neo import BayClient
async with BayClient(
endpoint_url=endpoint,
access_token=access_token,
) as client:
result = await client.skills.evaluate_candidate(
candidate_id,
passed=bool(passed),
score=data.get("score"),
benchmark_id=data.get("benchmark_id"),
report=data.get("report"),
)
return Response().ok(_to_jsonable(result)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def promote_neo_candidate(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
endpoint, access_token = self._get_neo_client_config()
data = await request.get_json()
candidate_id = data.get("candidate_id")
stage = data.get("stage", "canary")
sync_to_local = _to_bool(data.get("sync_to_local"), True)
if not candidate_id:
return Response().error("Missing candidate_id").__dict__
if stage not in {"canary", "stable"}:
return Response().error("Invalid stage, must be canary/stable").__dict__
from shipyard_neo import BayClient
async with BayClient(
endpoint_url=endpoint,
access_token=access_token,
) as client:
release = await client.skills.promote_candidate(candidate_id, stage=stage)
release_json = _to_jsonable(release)
sync_json = None
if stage == "stable" and sync_to_local:
sync_mgr = NeoSkillSyncManager()
try:
sync_result = await sync_mgr.sync_release(
client,
release_id=str(release_json.get("id", "")),
require_stable=True,
)
sync_json = {
"skill_key": sync_result.skill_key,
"local_skill_name": sync_result.local_skill_name,
"release_id": sync_result.release_id,
"candidate_id": sync_result.candidate_id,
"payload_ref": sync_result.payload_ref,
"map_path": sync_result.map_path,
"synced_at": sync_result.synced_at,
}
except Exception as sync_err:
rollback_result = await client.skills.rollback_release(
str(release_json.get("id", ""))
)
resp = Response().error(
"Stable promote synced failed and has been rolled back. "
f"sync_error={sync_err}"
)
resp.data = {
"release": release_json,
"rollback": _to_jsonable(rollback_result),
}
return resp.__dict__
# Try to push latest local skills to all active sandboxes.
try:
await sync_skills_to_active_sandboxes()
except Exception:
logger.warning("Failed to sync skills to active sandboxes.")
return Response().ok({"release": release_json, "sync": sync_json}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def rollback_neo_release(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
endpoint, access_token = self._get_neo_client_config()
data = await request.get_json()
release_id = data.get("release_id")
if not release_id:
return Response().error("Missing release_id").__dict__
from shipyard_neo import BayClient
async with BayClient(
endpoint_url=endpoint,
access_token=access_token,
) as client:
result = await client.skills.rollback_release(release_id)
return Response().ok(_to_jsonable(result)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def sync_neo_release(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
endpoint, access_token = self._get_neo_client_config()
data = await request.get_json()
release_id = data.get("release_id")
skill_key = data.get("skill_key")
require_stable = _to_bool(data.get("require_stable"), True)
if not release_id and not skill_key:
return Response().error("Missing release_id or skill_key").__dict__
from shipyard_neo import BayClient
async with BayClient(
endpoint_url=endpoint,
access_token=access_token,
) as client:
sync_mgr = NeoSkillSyncManager()
result = await sync_mgr.sync_release(
client,
release_id=release_id,
skill_key=skill_key,
require_stable=require_stable,
)
return (
Response()
.ok(
{
"skill_key": result.skill_key,
"local_skill_name": result.local_skill_name,
"release_id": result.release_id,
"candidate_id": result.candidate_id,
"payload_ref": result.payload_ref,
"map_path": result.map_path,
"synced_at": result.synced_at,
}
)
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
@@ -1,60 +1,196 @@
<template>
<div class="skills-page">
<v-container fluid class="pa-0" elevation="0">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
<div>
<v-btn color="success" prepend-icon="mdi-upload" class="me-2" variant="tonal" @click="uploadDialog = true">
{{ tm('skills.upload') }}
<v-btn
v-if="mode === 'local'"
color="success"
prepend-icon="mdi-upload"
class="me-2"
variant="tonal"
@click="uploadDialog = true"
>
{{ tm("skills.upload") }}
</v-btn>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="fetchSkills">
{{ tm('skills.refresh') }}
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="refreshCurrentMode">
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-btn-toggle v-model="mode" mandatory divided density="comfortable">
<v-btn value="local">{{ tm("skills.modeLocal") }}</v-btn>
<v-btn value="neo">{{ tm("skills.modeNeo") }}</v-btn>
</v-btn-toggle>
</v-row>
<div class="px-2 pb-2">
<small style="color: grey;">{{ tm('skills.runtimeHint') }}</small>
<div v-if="mode === 'local'" class="px-2 pb-2">
<small style="color: grey;">{{ tm("skills.runtimeHint") }}</small>
</div>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<template v-if="mode === 'local'">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<div v-else-if="skills.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-folder-open</v-icon>
<p class="text-grey mt-4">{{ tm('skills.empty') }}</p>
<small class="text-grey">{{ tm('skills.emptyHint') }}</small>
</div>
<div v-else-if="skills.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-folder-open</v-icon>
<p class="text-grey mt-4">{{ tm("skills.empty") }}</p>
<small class="text-grey">{{ tm("skills.emptyHint") }}</small>
</div>
<v-row v-else>
<v-col v-for="skill in skills" :key="skill.name" cols="12" md="6" lg="4" xl="3">
<item-card :item="skill" title-field="name" enabled-field="active" :loading="itemLoading[skill.name] || false"
:show-edit-button="false" @toggle-enabled="toggleSkill" @delete="confirmDelete">
<template v-slot:item-details="{ item }">
<div class="text-caption text-medium-emphasis mb-2 skill-description">
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm('skills.noDescription') }}
</div>
<div class="text-caption text-medium-emphasis">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm('skills.path') }}: {{ item.path }}
<v-row v-else>
<v-col v-for="skill in skills" :key="skill.name" cols="12" md="6" lg="4" xl="3">
<item-card
:item="skill"
title-field="name"
enabled-field="active"
:loading="itemLoading[skill.name] || false"
:show-edit-button="false"
@toggle-enabled="toggleSkill"
@delete="confirmDelete"
>
<template #item-details="{ item }">
<div class="text-caption text-medium-emphasis mb-2 skill-description">
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm("skills.noDescription") }}
</div>
<div class="text-caption text-medium-emphasis">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ item.path }}
</div>
</template>
</item-card>
</v-col>
</v-row>
</template>
<template v-else>
<v-card class="mx-3 mb-4 pa-3" variant="outlined">
<v-row>
<v-col cols="12" md="4">
<v-text-field
v-model="neoFilters.skill_key"
:label="tm('skills.neoSkillKey')"
density="comfortable"
hide-details
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="neoFilters.status"
:label="tm('skills.neoStatus')"
:items="candidateStatusItems"
item-title="title"
item-value="value"
density="comfortable"
hide-details
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="neoFilters.stage"
:label="tm('skills.neoStage')"
:items="releaseStageItems"
item-title="title"
item-value="value"
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>
<v-data-table
:headers="candidateHeaders"
:items="neoCandidates"
density="compact"
:items-per-page="10"
>
<template #item.latest_score="{ item }">
{{ item.latest_score ?? "-" }}
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1 flex-wrap">
<v-btn size="x-small" color="success" variant="tonal" @click="evaluateCandidate(item, true)">
{{ tm("skills.neoPass") }}
</v-btn>
<v-btn size="x-small" color="warning" variant="tonal" @click="evaluateCandidate(item, false)">
{{ tm("skills.neoReject") }}
</v-btn>
<v-btn size="x-small" color="primary" variant="tonal" @click="promoteCandidate(item, 'canary')">
Canary
</v-btn>
<v-btn size="x-small" color="primary" variant="tonal" @click="promoteCandidate(item, 'stable')">
Stable
</v-btn>
<v-btn
size="x-small"
variant="tonal"
@click="viewPayload(item.payload_ref)"
:disabled="!item.payload_ref"
>
Payload
</v-btn>
</div>
</template>
</item-card>
</v-col>
</v-row>
</v-data-table>
</v-card>
<v-card class="mx-3 mb-4" variant="outlined">
<v-card-title>{{ tm("skills.neoReleases") }}</v-card-title>
<v-data-table
:headers="releaseHeaders"
:items="neoReleases"
density="compact"
:items-per-page="10"
>
<template #item.is_active="{ item }">
<v-chip size="small" :color="item.is_active ? 'success' : 'default'" variant="tonal">
{{ item.is_active ? "active" : "inactive" }}
</v-chip>
</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>
<v-btn size="x-small" color="primary" variant="tonal" @click="syncRelease(item)">
{{ tm("skills.neoSync") }}
</v-btn>
</div>
</template>
</v-data-table>
</v-card>
</template>
</v-container>
<v-dialog v-model="uploadDialog" max-width="520px">
<v-card>
<v-card-title class="text-h3 pa-4 pb-0 pl-6">{{ tm('skills.uploadDialogTitle') }}</v-card-title>
<v-card-title class="text-h3 pa-4 pb-0 pl-6">{{ tm("skills.uploadDialogTitle") }}</v-card-title>
<v-card-text>
<small class="text-grey">{{ tm('skills.uploadHint') }}</small>
<v-file-input v-model="uploadFile" accept=".zip" :label="tm('skills.selectFile')"
prepend-icon="mdi-folder-zip-outline" variant="outlined" class="mt-4" :multiple="false" />
<small class="text-grey">{{ tm("skills.uploadHint") }}</small>
<v-file-input
v-model="uploadFile"
accept=".zip"
:label="tm('skills.selectFile')"
prepend-icon="mdi-folder-zip-outline"
variant="outlined"
class="mt-4"
:multiple="false"
/>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="uploadDialog = false">{{ tm('skills.cancel') }}</v-btn>
<v-btn variant="text" @click="uploadDialog = false">{{ tm("skills.cancel") }}</v-btn>
<v-btn color="primary" :loading="uploading" :disabled="!uploadFile" @click="uploadSkill">
{{ tm('skills.confirmUpload') }}
{{ tm("skills.confirmUpload") }}
</v-btn>
</v-card-actions>
</v-card>
@@ -62,18 +198,30 @@
<v-dialog v-model="deleteDialog" max-width="400px">
<v-card>
<v-card-title>{{ tm('skills.deleteTitle') }}</v-card-title>
<v-card-text>{{ tm('skills.deleteMessage') }}</v-card-text>
<v-card-title>{{ tm("skills.deleteTitle") }}</v-card-title>
<v-card-text>{{ tm("skills.deleteMessage") }}</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="deleteDialog = false">{{ tm('skills.cancel') }}</v-btn>
<v-btn variant="text" @click="deleteDialog = false">{{ tm("skills.cancel") }}</v-btn>
<v-btn color="error" :loading="deleting" @click="deleteSkill">
{{ t('core.common.itemCard.delete') }}
{{ t("core.common.itemCard.delete") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :timeout="3000" :color="snackbar.color" elevation="24">
<v-dialog v-model="payloadDialog.show" max-width="820px">
<v-card>
<v-card-title>{{ tm("skills.neoPayloadTitle") }}</v-card-title>
<v-card-text>
<pre class="payload-preview">{{ payloadDialog.content }}</pre>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="payloadDialog.show = false">{{ tm("skills.cancel") }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :timeout="3500" :color="snackbar.color" elevation="24">
{{ snackbar.message }}
</v-snackbar>
</div>
@@ -81,7 +229,7 @@
<script>
import axios from "axios";
import { ref, reactive, onMounted } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import ItemCard from "@/components/shared/ItemCard.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
@@ -92,6 +240,7 @@ export default {
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const mode = ref("local");
const skills = ref([]);
const loading = ref(false);
const uploading = ref(false);
@@ -103,23 +252,71 @@ export default {
const skillToDelete = ref(null);
const snackbar = reactive({ show: false, message: "", color: "success" });
const neoLoading = ref(false);
const neoCandidates = ref([]);
const neoReleases = ref([]);
const neoFilters = reactive({
skill_key: "",
status: "",
stage: "",
});
const payloadDialog = reactive({
show: false,
content: "",
});
const candidateStatusItems = computed(() => [
{ title: tm("skills.neoAll"), value: "" },
{ title: "draft", value: "draft" },
{ title: "evaluating", value: "evaluating" },
{ title: "promoted", value: "promoted" },
{ title: "promoted_canary", value: "promoted_canary" },
{ title: "promoted_stable", value: "promoted_stable" },
{ title: "rejected", value: "rejected" },
{ title: "rolled_back", value: "rolled_back" },
]);
const releaseStageItems = computed(() => [
{ title: tm("skills.neoAll"), value: "" },
{ title: "canary", value: "canary" },
{ title: "stable", value: "stable" },
]);
const candidateHeaders = computed(() => [
{ title: "ID", key: "id", width: "180px" },
{ title: "skill_key", key: "skill_key" },
{ title: "status", key: "status", width: "130px" },
{ title: "score", key: "latest_score", width: "90px" },
{ title: tm("skills.actions"), key: "actions", sortable: false, width: "420px" },
]);
const releaseHeaders = computed(() => [
{ title: "ID", key: "id", width: "180px" },
{ title: "skill_key", key: "skill_key" },
{ title: "stage", key: "stage", width: "100px" },
{ title: "version", key: "version", width: "90px" },
{ title: "active", key: "is_active", width: "110px" },
{ title: tm("skills.actions"), key: "actions", sortable: false, width: "220px" },
]);
const showMessage = (message, color = "success") => {
snackbar.message = message;
snackbar.color = color;
snackbar.show = true;
};
const normalizeSkillsPayload = (res) => {
const payload = res?.data?.data || [];
if (Array.isArray(payload)) return payload;
return payload.skills || [];
};
const fetchSkills = async () => {
loading.value = true;
try {
const res = await axios.get("/api/skills");
const payload = res.data?.data || [];
if (Array.isArray(payload)) {
skills.value = payload;
} else {
skills.value = payload.skills || [];
}
} catch (err) {
skills.value = normalizeSkillsPayload(res);
} catch (_err) {
showMessage(tm("skills.loadFailed"), "error");
} finally {
loading.value = false;
@@ -141,9 +338,7 @@ export default {
uploading.value = true;
try {
const formData = new FormData();
const file = Array.isArray(uploadFile.value)
? uploadFile.value[0]
: uploadFile.value;
const file = Array.isArray(uploadFile.value) ? uploadFile.value[0] : uploadFile.value;
if (!file) {
uploading.value = false;
return;
@@ -152,17 +347,12 @@ export default {
const res = await axios.post("/api/skills/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
handleApiResponse(
res,
tm("skills.uploadSuccess"),
tm("skills.uploadFailed"),
async () => {
uploadDialog.value = false;
uploadFile.value = null;
await fetchSkills();
}
);
} catch (err) {
handleApiResponse(res, tm("skills.uploadSuccess"), tm("skills.uploadFailed"), async () => {
uploadDialog.value = false;
uploadFile.value = null;
await fetchSkills();
});
} catch (_err) {
showMessage(tm("skills.uploadFailed"), "error");
} finally {
uploading.value = false;
@@ -177,15 +367,10 @@ export default {
name: skill.name,
active: nextActive,
});
handleApiResponse(
res,
tm("skills.updateSuccess"),
tm("skills.updateFailed"),
() => {
skill.active = nextActive;
}
);
} catch (err) {
handleApiResponse(res, tm("skills.updateSuccess"), tm("skills.updateFailed"), () => {
skill.active = nextActive;
});
} catch (_err) {
showMessage(tm("skills.updateFailed"), "error");
} finally {
itemLoading[skill.name] = false;
@@ -204,27 +389,154 @@ export default {
const res = await axios.post("/api/skills/delete", {
name: skillToDelete.value.name,
});
handleApiResponse(
res,
tm("skills.deleteSuccess"),
tm("skills.deleteFailed"),
async () => {
deleteDialog.value = false;
await fetchSkills();
}
);
} catch (err) {
handleApiResponse(res, tm("skills.deleteSuccess"), tm("skills.deleteFailed"), async () => {
deleteDialog.value = false;
await fetchSkills();
});
} catch (_err) {
showMessage(tm("skills.deleteFailed"), "error");
} finally {
deleting.value = false;
}
};
onMounted(fetchSkills);
const fetchNeoCandidates = async () => {
const params = {
skill_key: neoFilters.skill_key || undefined,
status: neoFilters.status || undefined,
};
const res = await axios.get("/api/skills/neo/candidates", { params });
const payload = res?.data?.data || {};
neoCandidates.value = payload.items || [];
};
const fetchNeoReleases = async () => {
const params = {
skill_key: neoFilters.skill_key || undefined,
stage: neoFilters.stage || undefined,
};
const res = await axios.get("/api/skills/neo/releases", { params });
const payload = res?.data?.data || {};
neoReleases.value = payload.items || [];
};
const fetchNeoData = async () => {
neoLoading.value = true;
try {
await Promise.all([fetchNeoCandidates(), fetchNeoReleases()]);
} catch (_err) {
showMessage(tm("skills.neoLoadFailed"), "error");
} finally {
neoLoading.value = false;
}
};
const evaluateCandidate = async (candidate, passed) => {
try {
const res = await axios.post("/api/skills/neo/evaluate", {
candidate_id: candidate.id,
passed,
score: passed ? 1.0 : 0.0,
report: passed ? "approved_from_webui" : "rejected_from_webui",
});
handleApiResponse(res, tm("skills.neoEvaluateSuccess"), tm("skills.neoEvaluateFailed"), async () => {
await fetchNeoCandidates();
});
} catch (_err) {
showMessage(tm("skills.neoEvaluateFailed"), "error");
}
};
const promoteCandidate = async (candidate, stage) => {
try {
const res = await axios.post("/api/skills/neo/promote", {
candidate_id: candidate.id,
stage,
sync_to_local: true,
});
const ok = res?.data?.status === "ok";
if (!ok) {
showMessage(res?.data?.message || tm("skills.neoPromoteFailed"), "error");
} else {
showMessage(tm("skills.neoPromoteSuccess"), "success");
}
await fetchNeoData();
if (stage === "stable") {
await fetchSkills();
}
} catch (_err) {
showMessage(tm("skills.neoPromoteFailed"), "error");
}
};
const rollbackRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/rollback", {
release_id: release.id,
});
handleApiResponse(res, tm("skills.neoRollbackSuccess"), tm("skills.neoRollbackFailed"), async () => {
await fetchNeoData();
});
} catch (_err) {
showMessage(tm("skills.neoRollbackFailed"), "error");
}
};
const syncRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/sync", {
release_id: release.id,
});
handleApiResponse(res, tm("skills.neoSyncSuccess"), tm("skills.neoSyncFailed"), async () => {
await fetchSkills();
});
} catch (_err) {
showMessage(tm("skills.neoSyncFailed"), "error");
}
};
const viewPayload = async (payloadRef) => {
if (!payloadRef) return;
try {
const res = await axios.get("/api/skills/neo/payload", {
params: { payload_ref: payloadRef },
});
if (res?.data?.status !== "ok") {
showMessage(res?.data?.message || tm("skills.neoPayloadFailed"), "error");
return;
}
const payload = res?.data?.data || {};
payloadDialog.content = JSON.stringify(payload, null, 2);
payloadDialog.show = true;
} catch (_err) {
showMessage(tm("skills.neoPayloadFailed"), "error");
}
};
const refreshCurrentMode = async () => {
if (mode.value === "neo") {
await fetchNeoData();
} else {
await fetchSkills();
}
};
watch(mode, async (nextMode) => {
if (nextMode === "neo") {
await fetchNeoData();
} else {
await fetchSkills();
}
});
onMounted(async () => {
await Promise.all([fetchSkills(), fetchNeoData()]);
});
return {
t,
tm,
mode,
skills,
loading,
uploadDialog,
@@ -234,11 +546,26 @@ export default {
deleteDialog,
deleting,
snackbar,
fetchSkills,
neoLoading,
neoCandidates,
neoReleases,
neoFilters,
candidateStatusItems,
releaseStageItems,
candidateHeaders,
releaseHeaders,
payloadDialog,
refreshCurrentMode,
fetchNeoData,
uploadSkill,
toggleSkill,
confirmDelete,
deleteSkill,
evaluateCandidate,
promoteCandidate,
rollbackRelease,
syncRelease,
viewPayload,
};
},
};
@@ -251,4 +578,14 @@ export default {
-webkit-box-orient: vertical;
overflow: hidden;
}
.payload-preview {
max-height: 480px;
overflow: auto;
background: #111;
color: #ececec;
padding: 12px;
border-radius: 8px;
font-size: 12px;
}
</style>
@@ -149,6 +149,22 @@
"booter": {
"description": "Sandbox Environment Driver"
},
"shipyard_neo_endpoint": {
"description": "Shipyard Neo API Endpoint",
"hint": "API access address for Shipyard Neo(Bay) service."
},
"shipyard_neo_access_token": {
"description": "Shipyard Neo Access Token",
"hint": "Access token for Shipyard Neo(Bay) service."
},
"shipyard_neo_profile": {
"description": "Shipyard Neo Profile",
"hint": "Sandbox profile for Shipyard Neo, e.g. python-default."
},
"shipyard_neo_ttl": {
"description": "Shipyard Neo Sandbox TTL",
"hint": "Sandbox time-to-live in seconds."
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "API access address for Shipyard service."
@@ -191,6 +191,9 @@
"enterUrl": "Enter extension repository URL"
},
"skills": {
"modeLocal": "Local Skills",
"modeNeo": "Neo Skills",
"actions": "Actions",
"upload": "Upload Skills",
"refresh": "Refresh",
"empty": "No Skills found",
@@ -211,6 +214,27 @@
"deleteMessage": "Are you sure you want to delete this Skill?",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Delete failed",
"neoSkillKey": "Filter by skill_key",
"neoStatus": "Candidate Status",
"neoStage": "Release Stage",
"neoAll": "All",
"neoCandidates": "Neo Candidates",
"neoReleases": "Neo Releases",
"neoLoadFailed": "Failed to load Neo skills data",
"neoPass": "Pass",
"neoReject": "Reject",
"neoEvaluateSuccess": "Evaluation updated",
"neoEvaluateFailed": "Failed to update evaluation",
"neoPromoteSuccess": "Promoted successfully",
"neoPromoteFailed": "Failed to promote",
"neoRollback": "Rollback",
"neoRollbackSuccess": "Rollback succeeded",
"neoRollbackFailed": "Rollback failed",
"neoSync": "Sync",
"neoSyncSuccess": "Sync succeeded",
"neoSyncFailed": "Sync failed",
"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.",
"runtimeHint": "Set the Computer Use runtime to Local or Sandbox in settings so AstrBot can use your Skills."
},
@@ -152,6 +152,22 @@
"booter": {
"description": "沙箱环境驱动器"
},
"shipyard_neo_endpoint": {
"description": "Shipyard Neo API Endpoint",
"hint": "Shipyard Neo(Bay) 服务的 API 访问地址。"
},
"shipyard_neo_access_token": {
"description": "Shipyard Neo 访问令牌",
"hint": "用于访问 Shipyard Neo(Bay) 服务的访问令牌。"
},
"shipyard_neo_profile": {
"description": "Shipyard Neo Profile",
"hint": "Shipyard Neo 沙箱 profile,例如 python-default。"
},
"shipyard_neo_ttl": {
"description": "Shipyard Neo Sandbox 存活时间(秒)",
"hint": "Shipyard Neo 沙箱的生存时间(秒)。"
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "Shipyard 服务的 API 访问地址。"
@@ -191,6 +191,9 @@
"enterUrl": "输入插件仓库链接"
},
"skills": {
"modeLocal": "本地 Skills",
"modeNeo": "Neo Skills",
"actions": "操作",
"upload": "上传 Skills",
"refresh": "刷新",
"empty": "暂无 Skills",
@@ -211,6 +214,27 @@
"deleteMessage": "确定要删除该 Skill 吗?",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"neoSkillKey": "skill_key 过滤",
"neoStatus": "候选状态",
"neoStage": "发布阶段",
"neoAll": "全部",
"neoCandidates": "Neo Candidates",
"neoReleases": "Neo Releases",
"neoLoadFailed": "加载 Neo Skills 数据失败",
"neoPass": "通过",
"neoReject": "拒绝",
"neoEvaluateSuccess": "评测更新成功",
"neoEvaluateFailed": "评测更新失败",
"neoPromoteSuccess": "发布成功",
"neoPromoteFailed": "发布失败",
"neoRollback": "回滚",
"neoRollbackSuccess": "回滚成功",
"neoRollbackFailed": "回滚失败",
"neoSync": "同步",
"neoSyncSuccess": "同步成功",
"neoSyncFailed": "同步失败",
"neoPayloadTitle": "Neo Payload 详情",
"neoPayloadFailed": "读取 Payload 失败",
"runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。"
},
+173
View File
@@ -1,5 +1,7 @@
import asyncio
import os
import sys
from types import SimpleNamespace
import pytest
import pytest_asyncio
@@ -242,3 +244,174 @@ async def test_do_update(
data = await response.get_json()
assert data["status"] == "ok"
assert os.path.exists(release_path)
class _FakeNeoSkills:
async def list_candidates(self, **kwargs):
_ = kwargs
return [
{
"id": "cand-1",
"skill_key": "neo.demo",
"status": "evaluated_pass",
"payload_ref": "pref-1",
}
]
async def list_releases(self, **kwargs):
_ = kwargs
return [
{
"id": "rel-1",
"skill_key": "neo.demo",
"candidate_id": "cand-1",
"stage": "stable",
"active": True,
}
]
async def get_payload(self, payload_ref: str):
return {
"payload_ref": payload_ref,
"payload": {"skill_markdown": "# Demo"},
}
async def evaluate_candidate(self, candidate_id: str, **kwargs):
return {"candidate_id": candidate_id, **kwargs}
async def promote_candidate(self, candidate_id: str, stage: str = "canary"):
return {
"id": "rel-2",
"skill_key": "neo.demo",
"candidate_id": candidate_id,
"stage": stage,
}
async def rollback_release(self, release_id: str):
return {"id": "rb-1", "rolled_back_release_id": release_id}
class _FakeNeoBayClient:
def __init__(self, endpoint_url: str, access_token: str):
self.endpoint_url = endpoint_url
self.access_token = access_token
self.skills = _FakeNeoSkills()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
_ = exc_type, exc, tb
return False
@pytest.mark.asyncio
async def test_neo_skills_routes(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
provider_settings = core_lifecycle_td.astrbot_config.setdefault(
"provider_settings", {}
)
sandbox = provider_settings.setdefault("sandbox", {})
sandbox["shipyard_neo_endpoint"] = "http://neo.test"
sandbox["shipyard_neo_access_token"] = "neo-token"
fake_shipyard_neo_module = SimpleNamespace(BayClient=_FakeNeoBayClient)
monkeypatch.setitem(sys.modules, "shipyard_neo", fake_shipyard_neo_module)
async def _fake_sync_release(self, client, **kwargs):
_ = self, client, kwargs
return SimpleNamespace(
skill_key="neo.demo",
local_skill_name="neo_demo",
release_id="rel-2",
candidate_id="cand-1",
payload_ref="pref-1",
map_path="data/skills/neo_skill_map.json",
synced_at="2026-01-01T00:00:00Z",
)
async def _fake_sync_skills_to_active_sandboxes():
return
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.NeoSkillSyncManager.sync_release",
_fake_sync_release,
)
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
_fake_sync_skills_to_active_sandboxes,
)
test_client = app.test_client()
response = await test_client.get(
"/api/skills/neo/candidates", headers=authenticated_header
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert isinstance(data["data"], list)
assert data["data"][0]["id"] == "cand-1"
response = await test_client.get(
"/api/skills/neo/releases", headers=authenticated_header
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert isinstance(data["data"], list)
assert data["data"][0]["id"] == "rel-1"
response = await test_client.get(
"/api/skills/neo/payload?payload_ref=pref-1", headers=authenticated_header
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["payload_ref"] == "pref-1"
response = await test_client.post(
"/api/skills/neo/evaluate",
json={"candidate_id": "cand-1", "passed": True, "score": 0.95},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["candidate_id"] == "cand-1"
assert data["data"]["passed"] is True
response = await test_client.post(
"/api/skills/neo/promote",
json={"candidate_id": "cand-1", "stage": "stable"},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["release"]["id"] == "rel-2"
assert data["data"]["sync"]["local_skill_name"] == "neo_demo"
response = await test_client.post(
"/api/skills/neo/rollback",
json={"release_id": "rel-2"},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["rolled_back_release_id"] == "rel-2"
response = await test_client.post(
"/api/skills/neo/sync",
json={"release_id": "rel-2"},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["skill_key"] == "neo.demo"