From a8cc995633bad246e3eabbee5bd703d2df5983fb Mon Sep 17 00:00:00 2001 From: zenfun Date: Wed, 11 Feb 2026 17:14:55 +0800 Subject: [PATCH] feat(dashboard): add neo skills APIs and management UI --- astrbot/dashboard/routes/skills.py | 294 ++++++++++ .../components/extension/SkillsSection.vue | 501 +++++++++++++++--- .../en-US/features/config-metadata.json | 16 + .../locales/en-US/features/extension.json | 24 + .../zh-CN/features/config-metadata.json | 16 + .../locales/zh-CN/features/extension.json | 24 + tests/test_dashboard.py | 173 ++++++ 7 files changed, 966 insertions(+), 82 deletions(-) diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index 5604d3d82..4b3441d55 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -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__ diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue index becd09ea3..f8b532f88 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -1,60 +1,196 @@