From 106f1d1794c2a553cea736aa1e8aa3c3d983229a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 27 May 2026 19:02:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/core/agents/api.ts | 25 ++++ frontend-web/src/core/agents/hooks.ts | 13 ++ frontend-web/src/core/skills/hooks.ts | 8 ++ frontend-web/src/pages/AdminAgentsPage.tsx | 121 ++++++++++++++++-- .../backend/app/gateway/routers/agents.py | 84 +++++++++++- .../harness/deerflow/skills/cascade.py | 55 ++++++++ 6 files changed, 294 insertions(+), 12 deletions(-) diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts index 83eabbe8..eb0caeae 100644 --- a/frontend-web/src/core/agents/api.ts +++ b/frontend-web/src/core/agents/api.ts @@ -174,6 +174,31 @@ export async function setAgentFeatured( return res.json() as Promise; } +export interface AgentSkillsSyncRemoval { + agent_id: string; + agent_name: string | null; + user_id: string | null; + removed_skills: string[]; +} + +export interface AgentSkillsSyncResult { + checked: number; + modified: number; + removals: AgentSkillsSyncRemoval[]; +} + +export async function adminSyncAgentSkills(): Promise { + const res = await apiFetch( + `${getBackendBaseURL()}/api/agents/admin/sync-skills`, + { method: "POST" }, + ); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error(err.detail ?? `Failed to sync agent skills: ${res.statusText}`); + } + return res.json() as Promise; +} + export async function checkAgentName( name: string, ): Promise<{ available: boolean; name: string }> { diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index 9099d63c..4066ab48 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -6,6 +6,7 @@ import { } from "@tanstack/react-query"; import { + adminSyncAgentSkills, createAgent, deleteAgent, getAgent, @@ -180,3 +181,15 @@ export function useSetAgentPrivate() { }, }); } + +export function useAdminSyncAgentSkills() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: adminSyncAgentSkills, + onSuccess: (result) => { + if (result.modified > 0) { + invalidateAgentQueries(queryClient); + } + }, + }); +} diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index c869f40f..658124bd 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -162,6 +162,10 @@ export function useSetSkillPublished() { }) => setSkillPublished(skillName, published), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["skills"] }); + // Unpublishing triggers the backend cascade that strips this skill + // from every referencing agent's config.yaml, so the cached agent + // list (agent.skills) is now stale. + void queryClient.invalidateQueries({ queryKey: ["agents"] }); }, }); } @@ -194,6 +198,10 @@ export function useDeleteCustomSkill() { mutationFn: (skillName: string) => deleteCustomSkill(skillName), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["skills"] }); + // Deletion triggers the backend cascade that strips this skill from + // every referencing agent's config.yaml, so the cached agent list + // (agent.skills) is now stale. + void queryClient.invalidateQueries({ queryKey: ["agents"] }); }, }); } diff --git a/frontend-web/src/pages/AdminAgentsPage.tsx b/frontend-web/src/pages/AdminAgentsPage.tsx index 9a94f848..d33d1680 100644 --- a/frontend-web/src/pages/AdminAgentsPage.tsx +++ b/frontend-web/src/pages/AdminAgentsPage.tsx @@ -1,8 +1,18 @@ "use client"; -import { BotIcon, SearchIcon } from "lucide-react"; +import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { WorkspaceBody, @@ -10,12 +20,35 @@ import { WorkspaceHeader, } from "@/components/workspace/workspace-container"; import { AgentCard } from "@/components/workspace/agents/agent-card"; -import { useAllAgentsAdmin } from "@/core/agents"; +import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents"; +import type { AgentSkillsSyncResult } from "@/core/agents/api"; import { desensitize } from "@/lib/desensitize"; export default function AdminAgentsPage() { const [search, setSearch] = useState(""); const { agents, isLoading } = useAllAgentsAdmin(true); + const syncSkills = useAdminSyncAgentSkills(); + const [syncResult, setSyncResult] = useState( + null, + ); + const [syncDialogOpen, setSyncDialogOpen] = useState(false); + + async function handleSyncSkills() { + try { + const result = await syncSkills.mutateAsync(); + setSyncResult(result); + setSyncDialogOpen(true); + if (result.modified === 0) { + toast.success("所有智能体的技能均有效,无需清理"); + } else { + toast.success( + `已清理 ${result.modified} 个智能体中的失效技能引用`, + ); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } const filteredAgents = useMemo(() => { const needle = search.trim().toLowerCase(); @@ -37,14 +70,28 @@ export default function AdminAgentsPage() { 管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。

-
- - setSearch(e.target.value)} - placeholder="按名称搜索" - className="pl-8" - /> +
+ +
+ + setSearch(e.target.value)} + placeholder="按名称搜索" + className="pl-8" + /> +
@@ -77,6 +124,60 @@ export default function AdminAgentsPage() { + + + + + 同步技能 — 清理结果 + + {syncResult + ? `扫描 ${syncResult.checked} 个智能体配置,清理 ${syncResult.modified} 个。` + : ""} + + + {syncResult && syncResult.removals.length > 0 ? ( +
+
    + {syncResult.removals.map((entry) => ( +
  • +
    + {entry.agent_name + ? desensitize(entry.agent_name) + : entry.agent_id} +
    +
    + id: {entry.agent_id} + {entry.user_id ? ` · owner: ${entry.user_id}` : " · built-in"} +
    +
    + {entry.removed_skills.map((s) => ( + + {s} + + ))} +
    +
  • + ))} +
+
+ ) : ( +

+ 所有智能体引用的技能均存在,未做任何修改。 +

+ )} + + + +
+
); } diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 1fb66c5e..9f40d9ec 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging import shutil from datetime import datetime @@ -9,18 +10,22 @@ from typing import Any from uuid import uuid4 import yaml -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from app.gateway.auth.password import verify_password_async -from app.gateway.deps import get_agent_store, get_tag_store +from app.gateway.deps import get_agent_store, get_config, get_tag_store +from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.config.agents_api_config import get_agents_api_config from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id +from deerflow.config.app_config import AppConfig from deerflow.config.paths import get_paths from deerflow.config.system_settings import load_system_settings from deerflow.persistence.agents import AgentStore from deerflow.persistence.tags import TagStore from deerflow.runtime.user_context import get_effective_user_id +from deerflow.skills.cascade import sync_agent_skills_to_available +from deerflow.skills.storage import get_or_new_skill_storage logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["agents"]) @@ -842,3 +847,78 @@ async def reorder_featured_agents( except Exception as e: logger.error("Failed to reorder featured agents: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e + + +class AgentSkillRemovalEntry(BaseModel): + agent_id: str = Field(..., description="Agent storage id whose config.yaml was rewritten") + agent_name: str | None = Field(default=None, description="Display name from the DB, if any") + user_id: str | None = Field(default=None, description="Owner user id (null = built-in)") + removed_skills: list[str] = Field(..., description="Skill names that no longer exist on disk and were stripped") + + +class AgentSkillsSyncResponse(BaseModel): + checked: int = Field(..., description="Number of agent config.yaml files scanned") + modified: int = Field(..., description="Number of agents whose skills list was rewritten") + removals: list[AgentSkillRemovalEntry] = Field(default_factory=list, description="Per-agent removal details") + + +@router.post( + "/agents/admin/sync-skills", + response_model=AgentSkillsSyncResponse, + summary="Sync Agent Skills (admin)", + description=( + "Admin-only. Scan every agent's ``config.yaml`` and strip skill names that " + "no longer exist on disk. Repairs historical drift where a skill was " + "removed without the delete/unpublish cascade firing (e.g. deleted from " + "the filesystem manually, or removed in an older version of the code)." + ), +) +async def admin_sync_agent_skills( + request: Request, + config: AppConfig = Depends(get_config), +) -> AgentSkillsSyncResponse: + _require_agents_api_enabled() + if not _is_admin_user(request): + raise HTTPException(status_code=403, detail="Only system admins can run agent-skills sync") + + try: + storage = get_or_new_skill_storage(app_config=config) + all_skills = storage.load_skills(enabled_only=False) + available = {skill.name for skill in all_skills} + + result = await asyncio.to_thread(sync_agent_skills_to_available, available) + + # Enrich removals with display name + owner from the DB so the admin + # UI can show "我的智能体 X 移除了技能 Y" instead of just the id. + store = get_agent_store(request) + enriched: list[AgentSkillRemovalEntry] = [] + for entry in result.get("removals", []): + agent_id = entry["agent_id"] + record = await store.get_any(agent_id) + enriched.append( + AgentSkillRemovalEntry( + agent_id=agent_id, + agent_name=(record.get("name") if record else None), + user_id=(record.get("user_id") if record else None), + removed_skills=entry["removed_skills"], + ) + ) + + if result.get("modified"): + await refresh_skills_system_prompt_cache_async() + + logger.info( + "Admin agent-skills sync: checked=%d modified=%d", + result["checked"], + result["modified"], + ) + return AgentSkillsSyncResponse( + checked=result["checked"], + modified=result["modified"], + removals=enriched, + ) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to sync agent skills: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to sync agent skills: {str(e)}") from e diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/cascade.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/cascade.py index 17d3ae0f..1e753ce0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/cascade.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/cascade.py @@ -149,3 +149,58 @@ async def cascade_skill_unpublished_or_deleted( counters["notified"] += 1 return counters + + +def sync_agent_skills_to_available(available_skill_names: set[str]) -> dict[str, Any]: + """Strip skill references that no longer exist on disk from every agent. + + Walks every agent dir and rewrites its ``config.yaml`` ``skills`` list to + contain only names that appear in ``available_skill_names``. Used by the + admin "agent-skills sync" endpoint to repair historical drift — skills + deleted before the cascade existed, deleted via a path that skipped the + cascade, or removed from the filesystem manually. + + Synchronous because it only touches local files and the admin endpoint + runs it in a worker thread. + + Returns ``{checked, modified, removals: [{agent_id, removed_skills}]}``. + """ + counters: dict[str, Any] = {"checked": 0, "modified": 0, "removals": []} + + for agent_dir in _list_agent_dirs(): + config_file = agent_dir / "config.yaml" + if not config_file.exists(): + continue + counters["checked"] += 1 + + try: + with config_file.open("r", encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) or {} + except yaml.YAMLError: + logger.warning("Sync: failed to parse %s, skipping", config_file) + continue + + skills = data.get("skills") + if not isinstance(skills, list) or not skills: + continue + + kept: list[str] = [] + removed: list[str] = [] + for name in skills: + if isinstance(name, str) and name in available_skill_names: + kept.append(name) + else: + removed.append(name if isinstance(name, str) else str(name)) + + if not removed: + continue + + data["skills"] = kept + with config_file.open("w", encoding="utf-8") as f: + yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True) + + counters["modified"] += 1 + counters["removals"].append({"agent_id": agent_dir.name, "removed_skills": removed}) + logger.info("Sync: removed orphan skills %s from agent '%s'", removed, agent_dir.name) + + return counters