新增技能目录重新扫描接口与分页,关掉技能安全扫描的配置开关

- 后端新增 POST /api/skills/reconcile-custom:扫描 custom 目录,把磁盘存在但 DB 无记录的技能登记到调用者名下(published=false),附 4 个单元测试
- 前端技能列表加"重新扫描技能目录"按钮,列表分页每页 10 条
- SkillsConfig 新增 security_scan.enabled(默认 true)用于关闭 LLM 安全扫描;scan_skill_content 在开关关闭时直接放行
- 智能体卡片/对话页长名 truncate 显示 + loading 占位

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-22 16:22:42 +08:00
1 parent b6a2c9336e
commit 80fc2a9240
14 files changed
+366 -11

No files matched your search

@@ -190,12 +190,12 @@ export function AgentCard({ agent }: AgentCardProps) {
<Card className="group flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base" title={agent.name}>
{agent.name}
</CardTitle>
{agent.model && (
@@ -4,6 +4,8 @@ import {
AlertCircle,
ArchiveIcon,
CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
FileArchive,
GlobeIcon,
@@ -11,6 +13,7 @@ import {
LockIcon,
PinIcon,
PinOffIcon,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
@@ -18,7 +21,7 @@ import {
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -57,6 +60,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
@@ -81,6 +85,8 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
);
}
const PAGE_SIZE = 10;
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const router = useRouter();
@@ -90,6 +96,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [page, setPage] = useState(1);
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds });
@@ -140,10 +148,39 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
// Filter / search / tag changes may shrink the result set — pull the page
// back into range without forcing a reset on every keystroke.
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const pagedSkills = useMemo(
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page],
);
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
const handleRescan = async () => {
try {
const result = await reconcileMutation.mutateAsync();
if (result.adopted.length > 0) {
toast.success(
t.settings.skills.rescanSkillsAdopted.replace(
"{count}",
String(result.adopted.length),
),
);
} else {
toast.info(t.settings.skills.rescanSkillsNoop);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex flex-wrap items-center justify-between gap-3">
@@ -190,6 +227,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void handleRescan()}
disabled={
reconcileMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
title={t.settings.skills.rescanSkills}
>
{reconcileMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
{reconcileMutation.isPending
? t.settings.skills.rescanSkillsRunning
: t.settings.skills.rescanSkills}
</Button>
<Button
size="sm"
variant="outline"
@@ -220,7 +276,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
)}
{!isLoading && !error &&
filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
@@ -228,6 +284,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
isAdmin={isAdmin}
/>
))}
{!isLoading && !error && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeftIcon className="size-4" />
{t.settings.skills.paginationPrev}
</Button>
<span className="text-muted-foreground text-xs">
{t.settings.skills.paginationPageOf
.replace("{page}", String(page))
.replace("{total}", String(totalPages))}
</span>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{t.settings.skills.paginationNext}
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
@@ -446,7 +446,14 @@ export const enUS: Translations = {
"Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill",
installSkill: "Install skill",
rescanSkills: "Rescan skills directory",
rescanSkillsRunning: "Scanning…",
rescanSkillsAdopted: "Adopted {count} new skill(s)",
rescanSkillsNoop: "No new skills found on disk",
goToEvolution: "Skill evolution",
paginationPrev: "Prev",
paginationNext: "Next",
paginationPageOf: "Page {page} of {total}",
emptyTitle: "No agent skill yet",
emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
@@ -381,7 +381,14 @@ export interface Translations {
description: string;
createSkill: string;
installSkill: string;
rescanSkills: string;
rescanSkillsRunning: string;
rescanSkillsAdopted: string;
rescanSkillsNoop: string;
goToEvolution: string;
paginationPrev: string;
paginationNext: string;
paginationPageOf: string;
emptyTitle: string;
emptyDescription: string;
emptyButton: string;
@@ -427,7 +427,14 @@ export const zhCN: Translations = {
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能",
installSkill: "安装技能",
rescanSkills: "重新扫描技能目录",
rescanSkillsRunning: "扫描中…",
rescanSkillsAdopted: "已收纳 {count} 个新技能",
rescanSkillsNoop: "没有发现需要收纳的新技能",
goToEvolution: "自我进化",
paginationPrev: "上一页",
paginationNext: "下一页",
paginationPageOf: "第 {page} / {total} 页",
emptyTitle: "还没有技能",
emptyDescription:
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
+16
View File
@@ -135,6 +135,22 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>;
}
export interface ReconcileCustomSkillsResponse {
adopted: string[];
already_registered: string[];
}
export async function reconcileCustomSkills(): Promise<ReconcileCustomSkillsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/reconcile-custom`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to reconcile skills directory");
}
return response.json() as Promise<ReconcileCustomSkillsResponse>;
}
export interface SetSkillPublishedResponse {
name: string;
owner_user_id: string | null;
+11
View File
@@ -11,6 +11,7 @@ import {
getSkill,
getSkillContent,
installSkillUpload,
reconcileCustomSkills,
setSkillDetail,
setSkillPinned,
setSkillPublished,
@@ -95,6 +96,16 @@ export function useInstallSkillUpload() {
});
}
export function useReconcileCustomSkills() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => reconcileCustomSkills(),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPublished() {
const queryClient = useQueryClient();
return useMutation({
+9 -4
View File
@@ -60,7 +60,9 @@ export default function AgentChatPage() {
hasPendingForThisAgent,
);
const { agent } = useAgent(agent_id);
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
const agentDisplayName =
agent?.name ?? (isAgentLoading ? t.common.loading : "");
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
@@ -171,8 +173,11 @@ export default function AgentChatPage() {
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_id}
<span
className="max-w-[200px] truncate text-xs font-medium"
title={agentDisplayName}
>
{agentDisplayName}
</span>
</div>
@@ -250,7 +255,7 @@ export default function AgentChatPage() {
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
<AgentWelcome agent={agent} agentName={agentDisplayName} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
@@ -161,6 +161,13 @@ class SkillUploadValidateResponse(BaseModel):
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
class SkillReconcileResponse(BaseModel):
"""Response model for reconciling custom skills found on disk."""
adopted: list[str] = Field(default_factory=list, description="Skills found on disk that had no DB row; now owned by the caller")
already_registered: list[str] = Field(default_factory=list, description="Skills found on disk that already had a DB row (unchanged)")
class CustomSkillContentResponse(SkillResponse):
content: str = Field(..., description="Raw SKILL.md content")
@@ -483,6 +490,54 @@ async def install_skill(
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
@router.post(
"/skills/reconcile-custom",
response_model=SkillReconcileResponse,
summary="Adopt Orphan Custom Skills",
description=(
"Scan the on-disk ``skills/custom/`` directory and register any skill that has a "
"valid SKILL.md but no DB ownership row. Adopted skills are recorded as owned by "
"the calling user with ``published=false``. Skips skills that already have a row "
"(regardless of owner). Useful after manually unzipping skills onto the filesystem."
),
)
async def reconcile_custom_skills(
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillReconcileResponse:
try:
user_id = _current_user_id(request)
storage = get_or_new_skill_storage(app_config=config)
custom_skills = [s for s in storage.load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
adopted: list[str] = []
already: list[str] = []
for skill in custom_skills:
existing = await skill_store.get_any(skill.name)
if existing is not None:
already.append(skill.name)
continue
await skill_store.ensure_legacy(
{"name": skill.name, "owner_user_id": user_id, "published": False}
)
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(skill.name)
except Exception:
pass
adopted.append(skill.name)
if adopted:
await refresh_skills_system_prompt_cache_async()
return SkillReconcileResponse(adopted=adopted, already_registered=already)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to reconcile custom skills: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to reconcile custom skills: {e}")
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
async def list_custom_skills(
request: Request,
+3 -1
View File
@@ -125,6 +125,8 @@ sandbox:
skills:
path: D:\project\bj\deerflow-server\skills
security_scan:
enabled: false
agents_api:
enabled: true
@@ -144,7 +146,7 @@ checkpointer:
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
version: v2 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
version: v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
@@ -6,6 +6,18 @@ from pydantic import BaseModel, Field
from deerflow.config.runtime_paths import project_root, resolve_path
class SecurityScanConfig(BaseModel):
"""Toggle for the LLM-based skill security scanner."""
enabled: bool = Field(
default=True,
description=(
"When false, skip the LLM security scan applied to skill installs, "
"custom-skill edits, and rollbacks. The scan is then treated as 'allow'."
),
)
class SkillsConfig(BaseModel):
"""Configuration for skills system"""
@@ -21,6 +33,10 @@ class SkillsConfig(BaseModel):
default="/mnt/skills",
description="Path where skills are mounted in the sandbox container",
)
security_scan: SecurityScanConfig = Field(
default_factory=SecurityScanConfig,
description="LLM security-scan toggle for skill content.",
)
def get_skills_path(self) -> Path:
"""
@@ -39,6 +39,12 @@ def _extract_json_object(raw: str) -> dict | None:
async def scan_skill_content(content: str, *, executable: bool = False, location: str = SKILL_MD_FILE, app_config: AppConfig | None = None) -> ScanResult:
"""Screen skill content before it is written to disk."""
config = app_config or get_app_config()
# Operator-level kill switch: when ``skills.security_scan.enabled`` is false,
# bypass the LLM round-trip entirely and let the content through.
if not config.skills.security_scan.enabled:
return ScanResult("allow", "Security scan disabled by config.")
rubric = (
"You are a security reviewer for AI agent skills. "
"Classify the content as allow, warn, or block. "
@@ -49,7 +55,6 @@ async def scan_skill_content(content: str, *, executable: bool = False, location
prompt = f"Location: {location}\nExecutable: {str(executable).lower()}\n\nReview this content:\n-----\n{content}\n-----"
try:
config = app_config or get_app_config()
# 安全审核复用 curator 的评审模型配置(skill_evolution.curator.model_name):
# 二者用同一个模型。未配置时 model_name 为 None,create_chat_model 回退主模型。
model_name = config.skill_evolution.curator.model_name
@@ -0,0 +1,138 @@
"""Tests for the ``POST /api/skills/reconcile-custom`` adoption flow.
Covers the scenario where a user drops a skill directory onto disk by hand
(bypassing the upload API) and then calls the reconcile route so it shows up
in the UI.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.gateway.routers import skills as skills_router
from deerflow.persistence.skills import MemorySkillStore
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
def _write_skill_on_disk(root: Path, name: str, description: str = "from disk") -> None:
"""Drop a valid SKILL.md into ``<root>/custom/<name>/`` like a manual unzip would."""
skill_dir = root / "custom" / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n# {name}\n",
encoding="utf-8",
)
def _fake_request(user_id: str | None = "user-a") -> SimpleNamespace:
user = SimpleNamespace(id=user_id, system_role="user") if user_id else None
return SimpleNamespace(state=SimpleNamespace(user=user))
@pytest.fixture
def skills_root(tmp_path: Path) -> Path:
root = tmp_path / "skills"
(root / "public").mkdir(parents=True)
(root / "custom").mkdir(parents=True)
return root
@pytest.fixture
def patched_storage(
skills_root: Path, monkeypatch: pytest.MonkeyPatch
) -> LocalSkillStorage:
"""Force the router to use a LocalSkillStorage rooted at ``skills_root``."""
storage = LocalSkillStorage(host_path=str(skills_root))
monkeypatch.setattr(
skills_router, "get_or_new_skill_storage", lambda **_: storage
)
async def _noop_refresh() -> None:
return None
monkeypatch.setattr(
skills_router, "refresh_skills_system_prompt_cache_async", _noop_refresh
)
return storage
@pytest.mark.asyncio
async def test_reconcile_adopts_orphan_custom_skill(
skills_root: Path, patched_storage: LocalSkillStorage
):
_write_skill_on_disk(skills_root, "manually-dropped")
store = MemorySkillStore()
result = await skills_router.reconcile_custom_skills(
request=_fake_request("user-a"), config=None, skill_store=store
)
assert result.adopted == ["manually-dropped"]
assert result.already_registered == []
record = await store.get_any("manually-dropped")
assert record is not None
assert record["owner_user_id"] == "user-a"
assert record["published"] is False
@pytest.mark.asyncio
async def test_reconcile_skips_already_registered(
skills_root: Path, patched_storage: LocalSkillStorage
):
_write_skill_on_disk(skills_root, "already-known")
store = MemorySkillStore()
# Pre-existing ownership row — should be left alone.
await store.create(
{"name": "already-known", "owner_user_id": "user-b", "published": True}
)
result = await skills_router.reconcile_custom_skills(
request=_fake_request("user-a"), config=None, skill_store=store
)
assert result.adopted == []
assert result.already_registered == ["already-known"]
# Ownership must not be overwritten.
record = await store.get_any("already-known")
assert record is not None
assert record["owner_user_id"] == "user-b"
assert record["published"] is True
@pytest.mark.asyncio
async def test_reconcile_mixed_adopts_only_orphans(
skills_root: Path, patched_storage: LocalSkillStorage
):
_write_skill_on_disk(skills_root, "orphan-one")
_write_skill_on_disk(skills_root, "orphan-two")
_write_skill_on_disk(skills_root, "registered")
store = MemorySkillStore()
await store.create(
{"name": "registered", "owner_user_id": "user-a", "published": False}
)
result = await skills_router.reconcile_custom_skills(
request=_fake_request("user-a"), config=None, skill_store=store
)
assert sorted(result.adopted) == ["orphan-one", "orphan-two"]
assert result.already_registered == ["registered"]
@pytest.mark.asyncio
async def test_reconcile_with_empty_custom_dir_is_noop(
skills_root: Path, patched_storage: LocalSkillStorage
):
store = MemorySkillStore()
result = await skills_router.reconcile_custom_skills(
request=_fake_request("user-a"), config=None, skill_store=store
)
assert result.adopted == []
assert result.already_registered == []
+3
View File
@@ -109,6 +109,8 @@ title:
Assistant: {assistant_msg}
skills:
path: /app/skills
security_scan:
enabled: false
agents_api:
enabled: true
database:
@@ -138,6 +140,7 @@ ai_writing:
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
version: v1
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
builtin: