diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index af123c30..069b901d 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -190,12 +190,12 @@ export function AgentCard({ agent }: AgentCardProps) {
-
+
-
- +
+ {agent.name} {agent.model && ( diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index a22e8c99..8db2b342 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -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([]); + 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 (
@@ -190,6 +227,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { {t.settings.skills.goToEvolution} + + + {t.settings.skills.paginationPageOf + .replace("{page}", String(page)) + .replace("{total}", String(totalPages))} + + +
+ )} setInstallOpen(false)} diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index f306c8d5..210dd65d 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -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.", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index ce2fc467..532692d4 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 3b18d3d7..5e1e0688 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -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` 文件夹中。", diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index 39eab291..25428dcb 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -135,6 +135,22 @@ export async function installSkillUpload( return response.json() as Promise; } +export interface ReconcileCustomSkillsResponse { + adopted: string[]; + already_registered: string[]; +} + +export async function reconcileCustomSkills(): Promise { + 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; +} + export interface SetSkillPublishedResponse { name: string; owner_user_id: string | null; diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index 2f73a061..1fa8f85e 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -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({ diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index bd144454..e8f7b9a4 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -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 */}
- - {agent?.name ?? agent_id} + + {agentDisplayName}
@@ -250,7 +255,7 @@ export default function AgentChatPage() { context={settings.context} extraHeader={ isLandingState && ( - + ) } disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index b9089ad3..4a2bc3ad 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -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, diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 459a1a62..3208a97c 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -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 选哪个 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/skills_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/skills_config.py index c8ba681e..de2f5112 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/skills_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/skills_config.py @@ -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: """ diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/security_scanner.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/security_scanner.py index a1ee6af8..d0b02272 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/security_scanner.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/security_scanner.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_skills_reconcile.py b/offline-backend-20260512/backend/tests/test_skills_reconcile.py new file mode 100644 index 00000000..1dcbd1ab --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_skills_reconcile.py @@ -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 ``/custom//`` 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 == [] diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index a275b847..7df8818e 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -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: