代码提交
This commit is contained in:
1 parent
fe7cdba07e
commit
106f1d1794
6 files changed
+294
-12
No files matched your search
@@ -174,6 +174,31 @@ export async function setAgentFeatured(
|
|||||||
return res.json() as Promise<Agent>;
|
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(
|
export async function checkAgentName(
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<{ available: boolean; name: string }> {
|
): Promise<{ available: boolean; name: string }> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
adminSyncAgentSkills,
|
||||||
createAgent,
|
createAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
getAgent,
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -162,6 +162,10 @@ export function useSetSkillPublished() {
|
|||||||
}) => setSkillPublished(skillName, published),
|
}) => setSkillPublished(skillName, published),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
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),
|
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
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"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BotIcon, SearchIcon } from "lucide-react";
|
import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "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 { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
WorkspaceBody,
|
WorkspaceBody,
|
||||||
@@ -10,12 +20,35 @@ import {
|
|||||||
WorkspaceHeader,
|
WorkspaceHeader,
|
||||||
} from "@/components/workspace/workspace-container";
|
} from "@/components/workspace/workspace-container";
|
||||||
import { AgentCard } from "@/components/workspace/agents/agent-card";
|
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";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
|
||||||
export default function AdminAgentsPage() {
|
export default function AdminAgentsPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { agents, isLoading } = useAllAgentsAdmin(true);
|
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 filteredAgents = useMemo(() => {
|
||||||
const needle = search.trim().toLowerCase();
|
const needle = search.trim().toLowerCase();
|
||||||
@@ -37,14 +70,28 @@ export default function AdminAgentsPage() {
|
|||||||
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
|
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-64 shrink-0">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
<Button
|
||||||
<Input
|
variant="outline"
|
||||||
value={search}
|
size="sm"
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onClick={handleSyncSkills}
|
||||||
placeholder="按名称搜索"
|
disabled={syncSkills.isPending}
|
||||||
className="pl-8"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,6 +124,60 @@ export default function AdminAgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</WorkspaceBody>
|
</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>
|
</WorkspaceContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -9,18 +10,22 @@ from typing import Any
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.auth.password import verify_password_async
|
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_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.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.paths import get_paths
|
||||||
from deerflow.config.system_settings import load_system_settings
|
from deerflow.config.system_settings import load_system_settings
|
||||||
from deerflow.persistence.agents import AgentStore
|
from deerflow.persistence.agents import AgentStore
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(prefix="/api", tags=["agents"])
|
||||||
@@ -842,3 +847,78 @@ async def reorder_featured_agents(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
|
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
|
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
|
counters["notified"] += 1
|
||||||
|
|
||||||
return counters
|
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
|
||||||
Reference in new issue
Block a user