新增技能目录重新扫描接口与分页,关掉技能安全扫描的配置开关
- 后端新增 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:
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` 文件夹中。",
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
+6
-1
@@ -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 == []
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user