代码提交

This commit is contained in:
吉鑫升 committed 2026-05-27 19:02:19 +08:00
1 parent fe7cdba07e
commit 106f1d1794
6 files changed
+294 -12

No files matched your search

+25
View File
@@ -174,6 +174,31 @@ export async function setAgentFeatured(
return res.json() as Promise<Agent>;
}
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<AgentSkillsSyncResult> {
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<AgentSkillsSyncResult>;
}
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
+13
View File
@@ -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);
}
},
});
}
+8
View File
@@ -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"] });
},
});
}
+111 -10
View File
@@ -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<AgentSkillsSyncResult | null>(
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() {
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
</p>
</div>
<div className="relative w-64 shrink-0">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="按名称搜索"
className="pl-8"
/>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSyncSkills}
disabled={syncSkills.isPending}
title="扫描所有智能体的 config.yaml,移除指向已不存在技能的引用"
>
<RefreshCwIcon
className={`mr-1.5 size-4 ${syncSkills.isPending ? "animate-spin" : ""}`}
/>
{syncSkills.isPending ? "正在同步…" : "同步技能"}
</Button>
<div className="relative w-64">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="按名称搜索"
className="pl-8"
/>
</div>
</div>
</div>
@@ -77,6 +124,60 @@ export default function AdminAgentsPage() {
</div>
</div>
</WorkspaceBody>
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>同步技能 — 清理结果</DialogTitle>
<DialogDescription>
{syncResult
? `扫描 ${syncResult.checked} 个智能体配置,清理 ${syncResult.modified} 个。`
: ""}
</DialogDescription>
</DialogHeader>
{syncResult && syncResult.removals.length > 0 ? (
<div className="max-h-[60vh] overflow-y-auto">
<ul className="space-y-3 text-sm">
{syncResult.removals.map((entry) => (
<li
key={entry.agent_id}
className="rounded-md border px-3 py-2"
>
<div className="font-medium">
{entry.agent_name
? desensitize(entry.agent_name)
: entry.agent_id}
</div>
<div className="text-muted-foreground mt-0.5 text-xs">
id: {entry.agent_id}
{entry.user_id ? ` · owner: ${entry.user_id}` : " · built-in"}
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{entry.removed_skills.map((s) => (
<span
key={s}
className="bg-muted rounded px-1.5 py-0.5 text-xs"
>
{s}
</span>
))}
</div>
</li>
))}
</ul>
</div>
) : (
<p className="text-muted-foreground text-sm">
所有智能体引用的技能均存在,未做任何修改。
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSyncDialogOpen(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkspaceContainer>
);
}
@@ -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
@@ -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