feat: admin skill public upload + user management page

- Admin users can choose to install skills to public (built-in) or custom category via UI radio selector in InstallSkillDialog
- Backend: SkillStorage.ainstall_skill_from_archive accepts target_category param; non-admin 403 on public install
- Backend: AgentStore/SkillStore add count_owned(user_id); UserRepository/LocalAuthProvider add list_users()
- Backend: GET /api/v1/auth/users endpoint (admin-only) returns all users with skill_count and agent_count
- Frontend: admin-only "用户管理" nav item in sidebar; /page/workspace/admin/users route with user table
- i18n: added sidebar.users and adminUsers section in zh-CN/en-US

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-05-14 20:40:23 +08:00
1 parent 3e6e3c08d5
commit 40cd8a24af
25 files changed
+380 -40

No files matched your search

@@ -0,0 +1,79 @@
"use client";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
export function UserManagementPage() {
const { t } = useI18n();
const { data: users, isLoading, error } = useAllUsers(true);
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminUsers.title}
description={t.adminUsers.description}
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive text-sm py-4">
{String(error)}
</div>
)}
{!isLoading && !error && users && users.length === 0 && (
<div className="text-muted-foreground text-sm py-8 text-center">
{t.adminUsers.empty}
</div>
)}
{!isLoading && !error && users && users.length > 0 && (
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.email}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.role}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.skillCount}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.agentCount}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.userId}</th>
</tr>
</thead>
<tbody>
{users.map((user, i) => (
<tr
key={user.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">{user.email}</td>
<td className="px-4 py-3">
{user.system_role === "admin" ? (
<Badge variant="default">{t.adminUsers.roleAdmin}</Badge>
) : (
<Badge variant="secondary">{t.adminUsers.roleUser}</Badge>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">{user.skill_count}</td>
<td className="px-4 py-3 text-right tabular-nums">{user.agent_count}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[160px]">
{user.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SettingsSection>
</div>
);
}
@@ -160,6 +160,7 @@ function SkillSettingsList({
<InstallSkillDialog <InstallSkillDialog
open={installOpen} open={installOpen}
onClose={() => setInstallOpen(false)} onClose={() => setInstallOpen(false)}
isAdmin={isAdmin}
/> />
</div> </div>
); );
@@ -358,9 +359,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
function InstallSkillDialog({ function InstallSkillDialog({
open, open,
onClose, onClose,
isAdmin,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
isAdmin: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const dialog = t.settings.skills.installDialog; const dialog = t.settings.skills.installDialog;
@@ -369,6 +372,7 @@ function InstallSkillDialog({
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null); const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
const validateMutation = useValidateSkillUpload(); const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload(); const installMutation = useInstallSkillUpload();
@@ -377,6 +381,7 @@ function InstallSkillDialog({
setValidation(null); setValidation(null);
setValidationError(null); setValidationError(null);
setIsDragging(false); setIsDragging(false);
setTargetCategory("custom");
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
}, []); }, []);
@@ -433,7 +438,7 @@ function InstallSkillDialog({
async (overwrite: boolean) => { async (overwrite: boolean) => {
if (!file) return; if (!file) return;
try { try {
const result = await installMutation.mutateAsync({ file, overwrite }); const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
toast.success( toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace( (result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}", "{name}",
@@ -446,13 +451,15 @@ function InstallSkillDialog({
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
}, },
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset], [dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
); );
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public"; const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom"; const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict; // Admin targeting public directory can overwrite an existing public skill.
const canOverwrite = !!validation && !validationError && isCustomConflict; const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}> <Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -500,6 +507,27 @@ function InstallSkillDialog({
/> />
</div> </div>
{isAdmin && (
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
<div className="flex gap-4">
{(["custom", "public"] as const).map((cat) => (
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => setTargetCategory(cat)}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
</label>
))}
</div>
</div>
)}
{validateMutation.isPending && ( {validateMutation.isPending && (
<div className="text-muted-foreground flex items-center gap-2 text-sm"> <div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@@ -557,10 +585,10 @@ function InstallSkillDialog({
</div> </div>
)} )}
{isPublicConflict && ( {isPublicConflict && (
<div className="text-destructive mt-1 flex items-start gap-2 text-xs"> <div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" /> <AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span> <span>
{dialog.conflictPublic.replace("{name}", validation.skill_name)} {(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
</span> </span>
</div> </div>
)} )}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react"; import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -10,11 +10,13 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavChatList() { export function WorkspaceNavChatList() {
const { t } = useI18n(); const { t } = useI18n();
const pathname = usePathname(); const pathname = usePathname();
const isAdmin = getStoredAuth()?.system_role === "admin";
return ( return (
<SidebarGroup className="px-2 pt-2"> <SidebarGroup className="px-2 pt-2">
<SidebarMenu> <SidebarMenu>
@@ -70,6 +72,19 @@ export function WorkspaceNavChatList() {
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/admin")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
<UsersIcon />
<span>{t.sidebar.users}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
); );
+14
View File
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index"; import type { LoginByUsernameResponse } from "./index";
export interface UserListItem {
id: string;
email: string;
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
export async function loginByUsername( export async function loginByUsername(
username: string, username: string,
): Promise<LoginByUsernameResponse> { ): Promise<LoginByUsernameResponse> {
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { listAllUsers } from "./api";
export function useAllUsers(enabled: boolean) {
return useQuery({
queryKey: ["admin", "users"],
queryFn: listAllUsers,
enabled,
});
}
@@ -172,6 +172,7 @@ export const enUS: Translations = {
recentChats: "Recent chats", recentChats: "Recent chats",
demoChats: "Demo chats", demoChats: "Demo chats",
agents: "Agents", agents: "Agents",
users: "Users",
}, },
// Agents // Agents
@@ -471,9 +472,14 @@ export const enUS: Translations = {
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).", "A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
conflictPublic: conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.", "A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
installSuccess: "Skill '{name}' installed.", installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.", installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.", rejectInvalidExt: "Only .zip or .skill files are accepted.",
targetCategoryLabel: "Install to",
categoryCustom: "Custom skill",
categoryPublic: "Built-in (admin)",
}, },
}, },
skillEvolution: { skillEvolution: {
@@ -554,6 +560,19 @@ export const enUS: Translations = {
}, },
}, },
adminUsers: {
title: "User Management",
description: "View all registered users and their associated skill and agent counts.",
email: "Email",
role: "Role",
userId: "User ID",
skillCount: "Skills",
agentCount: "Agents",
roleAdmin: "Admin",
roleUser: "User",
empty: "No users found",
},
notifications: { notifications: {
title: "Notifications", title: "Notifications",
bellAria: "Open notifications", bellAria: "Open notifications",
@@ -125,6 +125,7 @@ export interface Translations {
chats: string; chats: string;
demoChats: string; demoChats: string;
agents: string; agents: string;
users: string;
}; };
// Agents // Agents
@@ -397,9 +398,13 @@ export interface Translations {
warningsLabel: string; warningsLabel: string;
conflictCustom: string; conflictCustom: string;
conflictPublic: string; conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string; installSuccess: string;
installSuccessReplaced: string; installSuccessReplaced: string;
rejectInvalidExt: string; rejectInvalidExt: string;
targetCategoryLabel: string;
categoryCustom: string;
categoryPublic: string;
}; };
}; };
skillEvolution: { skillEvolution: {
@@ -469,6 +474,20 @@ export interface Translations {
}; };
}; };
// Admin user management page
adminUsers: {
title: string;
description: string;
email: string;
role: string;
userId: string;
skillCount: string;
agentCount: string;
roleAdmin: string;
roleUser: string;
empty: string;
};
// In-app notification inbox (bell in the workspace sidebar) // In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations; notifications: NotificationsTranslations;
} }
+22 -4
View File
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
recentChats: "最近的对话", recentChats: "最近的对话",
demoChats: "演示对话", demoChats: "演示对话",
agents: "智能体", agents: "智能体",
users: "用户管理",
}, },
// Agents // Agents
@@ -449,10 +450,14 @@ export const zhCN: Translations = {
warningsLabel: "警告", warningsLabel: "警告",
conflictCustom: conflictCustom:
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。", "已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。", conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
installSuccess: "技能 “{name}” 已安装。", conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
installSuccessReplaced: "技能 “{name}” 已替换。", installSuccess: `技能 '{name}' 已安装。`,
rejectInvalidExt: "只接受 .zip 或 .skill 文件。", installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
targetCategoryLabel: `安装到`,
categoryCustom: `自定义技能`,
categoryPublic: `内置技能(管理员)`,
}, },
}, },
skillEvolution: { skillEvolution: {
@@ -526,6 +531,19 @@ export const zhCN: Translations = {
}, },
}, },
adminUsers: {
title: "用户管理",
description: "查看所有注册用户及其关联的技能和智能体数量。",
email: "邮箱",
role: "角色",
userId: "用户 ID",
skillCount: "技能数",
agentCount: "智能体数",
roleAdmin: "管理员",
roleUser: "普通用户",
empty: "暂无用户",
},
notifications: { notifications: {
title: "通知", title: "通知",
bellAria: "打开通知", bellAria: "打开通知",
+5 -2
View File
@@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise<ValidateSkillUplo
export async function installSkillUpload( export async function installSkillUpload(
file: File, file: File,
options: { overwrite?: boolean } = {}, options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
): Promise<InstallSkillResponse> { ): Promise<InstallSkillResponse> {
const form = new FormData(); const form = new FormData();
form.append("file", file, file.name); form.append("file", file, file.name);
const query = options.overwrite ? "?overwrite=true" : ""; const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`, `${getBackendBaseURL()}/api/skills/install-upload${query}`,
{ method: "POST", body: form }, { method: "POST", body: form },
+2 -2
View File
@@ -45,8 +45,8 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() { export function useInstallSkillUpload() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) => mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite }), installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] }); void queryClient.invalidateQueries({ queryKey: ["skills"] });
}, },
+5
View File
@@ -0,0 +1,5 @@
import { UserManagementPage } from "@/components/workspace/admin/user-management-page";
export default function UsersPage() {
return <UserManagementPage />;
}
@@ -18,6 +18,7 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage"; import ScheduledTasksPage from "./ScheduledTasksPage";
import SkillEvolutionPage from "./SkillEvolutionPage"; import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage"; import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
function WorkspaceLayout({ function WorkspaceLayout({
children, children,
@@ -149,6 +150,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="admin/users"
element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
<UsersPage />
</WorkspaceLayout>
}
/>
</Routes> </Routes>
); );
} }
@@ -99,6 +99,10 @@ class LocalAuthProvider(AuthProvider):
"""Update an existing user.""" """Update an existing user."""
return await self._repo.update_user(user) return await self._repo.update_user(user)
async def list_users(self) -> list[User]:
"""Return all registered users ordered by creation date."""
return await self._repo.list_users()
async def get_user_by_email(self, email: str) -> User | None: async def get_user_by_email(self, email: str) -> User | None:
"""Get user by email.""" """Get user by email."""
return await self._repo.get_user_by_email(email) return await self._repo.get_user_by_email(email)
@@ -88,6 +88,11 @@ class UserRepository(ABC):
"""Return number of users with system_role == 'admin'.""" """Return number of users with system_role == 'admin'."""
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def list_users(self) -> list[User]:
"""Return all users ordered by creation date."""
raise NotImplementedError
@abstractmethod @abstractmethod
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
"""Get user by OAuth provider and ID. """Get user by OAuth provider and ID.
@@ -119,6 +119,12 @@ class SQLiteUserRepository(UserRepository):
async with self._sf() as session: async with self._sf() as session:
return await session.scalar(stmt) or 0 return await session.scalar(stmt) or 0
async def list_users(self) -> list[User]:
stmt = select(UserRow).order_by(UserRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_user(row) for row in result.scalars()]
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id) stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
async with self._sf() as session: async with self._sf() as session:
@@ -10,6 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator from pydantic import BaseModel, EmailStr, Field, field_validator
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
from app.gateway.auth import ( from app.gateway.auth import (
UserResponse, UserResponse,
create_access_token, create_access_token,
@@ -17,7 +20,7 @@ from app.gateway.auth import (
from app.gateway.auth.config import get_auth_config from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.csrf_middleware import is_secure_request from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_current_user_from_request, get_local_provider from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -559,6 +562,43 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role) return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
# ── Admin Endpoints ───────────────────────────────────────────────────────
class UserListItem(BaseModel):
id: str
email: str
system_role: Literal["admin", "user"]
skill_count: int
agent_count: int
@router.get("/users", response_model=list[UserListItem])
async def list_all_users(
request: Request,
skill_store: SkillStore = Depends(get_skill_store),
agent_store: AgentStore = Depends(get_agent_store),
):
"""List all users with their skill and agent counts. Admin only."""
user = await get_current_user_from_request(request)
if user.system_role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
users = await get_local_provider().list_users()
result = []
for u in users:
uid = str(u.id)
result.append(
UserListItem(
id=uid,
email=u.email,
system_role=u.system_role,
skill_count=await skill_store.count_owned(uid),
agent_count=await agent_store.count_owned(uid),
)
)
return result
# ── OAuth Endpoints (Future/Placeholder) ───────────────────────────────── # ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
@@ -114,6 +114,7 @@ class SkillInstallRequest(BaseModel):
thread_id: str = Field(..., description="The thread ID where the .skill file is located") thread_id: str = Field(..., description="The thread ID where the .skill file is located")
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)") path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
class SkillInstallResponse(BaseModel): class SkillInstallResponse(BaseModel):
@@ -123,6 +124,7 @@ class SkillInstallResponse(BaseModel):
skill_name: str = Field(..., description="Name of the installed skill") skill_name: str = Field(..., description="Name of the installed skill")
message: str = Field(..., description="Installation result message") message: str = Field(..., description="Installation result message")
overwritten: bool = Field(default=False, description="True if an existing skill was replaced") overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
class SkillUploadValidateResponse(BaseModel): class SkillUploadValidateResponse(BaseModel):
@@ -320,40 +322,53 @@ async def install_skill_upload(
request: Request, request: Request,
file: UploadFile = File(..., description="The .zip or .skill archive to install"), file: UploadFile = File(..., description="The .zip or .skill archive to install"),
overwrite: bool = False, overwrite: bool = False,
target_category: SkillCategory = SkillCategory.CUSTOM,
config: AppConfig = Depends(get_config), config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store), skill_store: SkillStore = Depends(get_skill_store),
) -> SkillInstallResponse: ) -> SkillInstallResponse:
user_id = _current_user_id(request) user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
# Only admins may install to the public (built-in) directory.
if target_category == SkillCategory.PUBLIC and not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
tmp_path = await _persist_uploaded_skill(file) tmp_path = await _persist_uploaded_skill(file)
try: try:
storage = get_or_new_skill_storage(app_config=config) storage = get_or_new_skill_storage(app_config=config)
# Pre-parse the archive to learn the skill name without writing anything, # Pre-parse the archive to learn the skill name without writing anything,
# so we can reject the upload before touching the filesystem if another # so we can reject the upload before touching the filesystem if another
# user already owns this name. # user already owns this name. Admins bypass the ownership check since
# they install to the shared public directory.
try: try:
validation = storage.validate_skill_archive(tmp_path) validation = storage.validate_skill_archive(tmp_path)
preview_name = validation.get("skill_name") if isinstance(validation, dict) else None preview_name = validation.get("skill_name") if isinstance(validation, dict) else None
except Exception: except Exception:
preview_name = None preview_name = None
if preview_name: if preview_name and not is_admin:
existing = await skill_store.get_any(preview_name) existing = await skill_store.get_any(preview_name)
if existing and existing.get("owner_user_id") not in (None, user_id): if existing and existing.get("owner_user_id") not in (None, user_id):
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"Skill '{preview_name}' already exists and is owned by another user", detail=f"Skill '{preview_name}' already exists and is owned by another user",
) )
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False) result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
try: try:
from deerflow.skills.usage import mark_uploaded from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"]) mark_uploaded(result["skill_name"])
except Exception: except Exception:
pass pass
# Register ownership in the SkillStore. ``ensure_legacy`` is idempotent # For public installs (admin), register as built-in with no owner.
# and only patches a NULL owner; it never overwrites an existing owner, # For custom installs, register ownership under the uploading user.
# so the pre-parse check above is what really guards cross-user steals. if target_category == SkillCategory.PUBLIC:
await skill_store.ensure_legacy( await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False} {"name": result["skill_name"], "owner_user_id": None, "published": True}
) )
else:
# ``ensure_legacy`` is idempotent and only patches a NULL owner; it
# never overwrites an existing owner, so the pre-parse check above
# is what really guards cross-user steals.
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
)
await refresh_skills_system_prompt_cache_async() await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result) return SkillInstallResponse(**result)
except FileNotFoundError as e: except FileNotFoundError as e:
@@ -385,16 +400,25 @@ async def install_skill(
) -> SkillInstallResponse: ) -> SkillInstallResponse:
try: try:
user_id = _current_user_id(request) user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
target_category = body.target_category
if target_category == SkillCategory.PUBLIC and not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path) skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path) result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
try: try:
from deerflow.skills.usage import mark_uploaded from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"]) mark_uploaded(result["skill_name"])
except Exception: except Exception:
pass pass
await skill_store.ensure_legacy( if target_category == SkillCategory.PUBLIC:
{"name": result["skill_name"], "owner_user_id": user_id, "published": False} await skill_store.ensure_legacy(
) {"name": result["skill_name"], "owner_user_id": None, "published": True}
)
else:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
)
await refresh_skills_system_prompt_cache_async() await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result) return SkillInstallResponse(**result)
except FileNotFoundError as e: except FileNotFoundError as e:
@@ -56,3 +56,8 @@ class AgentStore(ABC):
any user identity and returns ``False`` if the target is not a built-in. any user identity and returns ``False`` if the target is not a built-in.
""" """
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def count_owned(self, user_id: str) -> int:
"""Return the number of agents owned by ``user_id`` (user_id IS NOT NULL)."""
raise NotImplementedError
@@ -114,3 +114,6 @@ class MemoryAgentStore(AgentStore):
return False return False
self._agents.pop(agent_id, None) self._agents.pop(agent_id, None)
return True return True
async def count_owned(self, user_id: str) -> int:
return sum(1 for a in self._agents.values() if a.get("user_id") == user_id)
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
@@ -137,3 +137,8 @@ class AgentRepository(AgentStore):
await session.delete(row) await session.delete(row)
await session.commit() await session.commit()
return True return True
async def count_owned(self, user_id: str) -> int:
stmt = select(func.count()).select_from(AgentRow).where(AgentRow.user_id == user_id)
async with self._sf() as session:
return await session.scalar(stmt) or 0
@@ -62,3 +62,8 @@ class SkillStore(ABC):
async def delete_admin(self, name: str) -> bool: async def delete_admin(self, name: str) -> bool:
"""Admin-side delete; bypasses ownership check (caller authorizes).""" """Admin-side delete; bypasses ownership check (caller authorizes)."""
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def count_owned(self, user_id: str) -> int:
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
raise NotImplementedError
@@ -124,3 +124,6 @@ class MemorySkillStore(SkillStore):
return False return False
del self._skills[name] del self._skills[name]
return True return True
async def count_owned(self, user_id: str) -> int:
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.skills.base import SkillStore from deerflow.persistence.skills.base import SkillStore
@@ -137,3 +137,8 @@ class SkillRepository(SkillStore):
await session.delete(row) await session.delete(row)
await session.commit() await session.commit()
return True return True
async def count_owned(self, user_id: str) -> int:
stmt = select(func.count()).select_from(SkillRow).where(SkillRow.owner_user_id == user_id)
async with self._sf() as session:
return await session.scalar(stmt) or 0
@@ -189,6 +189,7 @@ class LocalSkillStorage(SkillStorage):
*, *,
overwrite: bool = False, overwrite: bool = False,
require_skill_suffix: bool = True, require_skill_suffix: bool = True,
target_category: SkillCategory = SkillCategory.CUSTOM,
) -> dict: ) -> dict:
from deerflow.skills.installer import ( from deerflow.skills.installer import (
SkillAlreadyExistsError, SkillAlreadyExistsError,
@@ -199,10 +200,10 @@ class LocalSkillStorage(SkillStorage):
) )
from deerflow.skills.validation import _validate_skill_frontmatter from deerflow.skills.validation import _validate_skill_frontmatter
logger.info("Installing skill from %s (overwrite=%s)", archive_path, overwrite) logger.info("Installing skill from %s (overwrite=%s, target_category=%s)", archive_path, overwrite, target_category)
custom_dir = self._host_root / "custom" category_dir = self._host_root / target_category.value
custom_dir.mkdir(parents=True, exist_ok=True) category_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp) tmp_path = Path(tmp)
@@ -218,10 +219,12 @@ class LocalSkillStorage(SkillStorage):
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name: if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
raise ValueError(f"Invalid skill name: {skill_name}") raise ValueError(f"Invalid skill name: {skill_name}")
if self.public_skill_exists(skill_name): # Custom installs cannot overwrite built-in public skills.
# Public installs (admin-only) are allowed to replace existing public skills when overwrite=True.
if target_category == SkillCategory.CUSTOM and self.public_skill_exists(skill_name):
raise SkillAlreadyExistsError(f"Skill '{skill_name}' conflicts with a built-in public skill and cannot be overwritten") raise SkillAlreadyExistsError(f"Skill '{skill_name}' conflicts with a built-in public skill and cannot be overwritten")
target = custom_dir / skill_name target = category_dir / skill_name
replaced = False replaced = False
if target.exists(): if target.exists():
if not overwrite: if not overwrite:
@@ -231,7 +234,7 @@ class LocalSkillStorage(SkillStorage):
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name) await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=custom_dir) as staging_root: with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=category_dir) as staging_root:
staging_target = Path(staging_root) / skill_name staging_target = Path(staging_root) / skill_name
shutil.copytree(skill_dir, staging_target) shutil.copytree(skill_dir, staging_target)
_move_staged_skill_into_reserved_target(staging_target, target) _move_staged_skill_into_reserved_target(staging_target, target)
@@ -242,6 +245,7 @@ class LocalSkillStorage(SkillStorage):
"skill_name": skill_name, "skill_name": skill_name,
"message": f"Skill '{skill_name}' installed successfully", "message": f"Skill '{skill_name}' installed successfully",
"overwritten": replaced, "overwritten": replaced,
"category": target_category.value,
} }
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None: def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
@@ -132,17 +132,28 @@ class SkillStorage(ABC):
""" """
@abstractmethod @abstractmethod
async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict: async def ainstall_skill_from_archive(
self,
archive_path: str | Path,
*,
target_category: SkillCategory = SkillCategory.CUSTOM,
) -> dict:
"""Async install of a skill from a ``.skill`` ZIP archive. """Async install of a skill from a ``.skill`` ZIP archive.
``target_category`` controls the destination directory (public or custom).
Origin: ``deerflow.skills.installer.ainstall_skill_from_archive``. Origin: ``deerflow.skills.installer.ainstall_skill_from_archive``.
""" """
def install_skill_from_archive(self, archive_path: str | Path) -> dict: def install_skill_from_archive(
self,
archive_path: str | Path,
*,
target_category: SkillCategory = SkillCategory.CUSTOM,
) -> dict:
"""Sync wrapper — delegates to :meth:`ainstall_skill_from_archive`.""" """Sync wrapper — delegates to :meth:`ainstall_skill_from_archive`."""
from deerflow.skills.installer import _run_async_install from deerflow.skills.installer import _run_async_install
return _run_async_install(self.ainstall_skill_from_archive(archive_path)) return _run_async_install(self.ainstall_skill_from_archive(archive_path, target_category=target_category))
@abstractmethod @abstractmethod
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None: def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None: