diff --git a/frontend-web/src/components/workspace/admin/user-management-page.tsx b/frontend-web/src/components/workspace/admin/user-management-page.tsx new file mode 100644 index 00000000..0c61d323 --- /dev/null +++ b/frontend-web/src/components/workspace/admin/user-management-page.tsx @@ -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 ( +
+ + {isLoading && ( +
+ + {t.common.loading} +
+ )} + + {error && ( +
+ {String(error)} +
+ )} + + {!isLoading && !error && users && users.length === 0 && ( +
+ {t.adminUsers.empty} +
+ )} + + {!isLoading && !error && users && users.length > 0 && ( +
+ + + + + + + + + + + + {users.map((user, i) => ( + + + + + + + + ))} + +
{t.adminUsers.email}{t.adminUsers.role}{t.adminUsers.skillCount}{t.adminUsers.agentCount}{t.adminUsers.userId}
{user.email} + {user.system_role === "admin" ? ( + {t.adminUsers.roleAdmin} + ) : ( + {t.adminUsers.roleUser} + )} + {user.skill_count}{user.agent_count} + {user.id} +
+
+ )} +
+
+ ); +} 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 20c544cb..853451c7 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -160,6 +160,7 @@ function SkillSettingsList({ setInstallOpen(false)} + isAdmin={isAdmin} /> ); @@ -358,9 +359,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { function InstallSkillDialog({ open, onClose, + isAdmin, }: { open: boolean; onClose: () => void; + isAdmin: boolean; }) { const { t } = useI18n(); const dialog = t.settings.skills.installDialog; @@ -369,6 +372,7 @@ function InstallSkillDialog({ const [validation, setValidation] = useState(null); const [validationError, setValidationError] = useState(null); const [isDragging, setIsDragging] = useState(false); + const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom"); const validateMutation = useValidateSkillUpload(); const installMutation = useInstallSkillUpload(); @@ -377,6 +381,7 @@ function InstallSkillDialog({ setValidation(null); setValidationError(null); setIsDragging(false); + setTargetCategory("custom"); if (inputRef.current) inputRef.current.value = ""; }, []); @@ -433,7 +438,7 @@ function InstallSkillDialog({ async (overwrite: boolean) => { if (!file) return; try { - const result = await installMutation.mutateAsync({ file, overwrite }); + const result = await installMutation.mutateAsync({ file, overwrite, targetCategory }); toast.success( (result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace( "{name}", @@ -446,13 +451,15 @@ function InstallSkillDialog({ 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 isCustomConflict = validation?.conflict && validation.conflict_kind === "custom"; - const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict; - const canOverwrite = !!validation && !validationError && isCustomConflict; + // Admin targeting public directory can overwrite an existing public skill. + const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict; + const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict); + const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic); return ( !o && handleClose()}> @@ -500,6 +507,27 @@ function InstallSkillDialog({ /> + {isAdmin && ( +
+ {dialog.targetCategoryLabel} +
+ {(["custom", "public"] as const).map((cat) => ( + + ))} +
+
+ )} + {validateMutation.isPending && (
@@ -557,10 +585,10 @@ function InstallSkillDialog({
)} {isPublicConflict && ( -
+
- {dialog.conflictPublic.replace("{name}", validation.skill_name)} + {(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
)} diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index b5c96f58..e0ccb498 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -1,6 +1,6 @@ "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 { usePathname } from "next/navigation"; @@ -10,11 +10,13 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; export function WorkspaceNavChatList() { const { t } = useI18n(); const pathname = usePathname(); + const isAdmin = getStoredAuth()?.system_role === "admin"; return ( @@ -70,6 +72,19 @@ export function WorkspaceNavChatList() { + {isAdmin && ( + + + + + {t.sidebar.users} + + + + )} ); diff --git a/frontend-web/src/core/auth/api.ts b/frontend-web/src/core/auth/api.ts index 4a81b8de..94a64bdb 100644 --- a/frontend-web/src/core/auth/api.ts +++ b/frontend-web/src/core/auth/api.ts @@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config"; 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 { + const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; +} + export async function loginByUsername( username: string, ): Promise { diff --git a/frontend-web/src/core/auth/hooks.ts b/frontend-web/src/core/auth/hooks.ts new file mode 100644 index 00000000..3bc5b40f --- /dev/null +++ b/frontend-web/src/core/auth/hooks.ts @@ -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, + }); +} diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index d690ae6e..e808cb97 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -172,6 +172,7 @@ export const enUS: Translations = { recentChats: "Recent chats", demoChats: "Demo chats", agents: "Agents", + users: "Users", }, // 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).", conflictPublic: "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.", installSuccessReplaced: "Skill '{name}' replaced.", rejectInvalidExt: "Only .zip or .skill files are accepted.", + targetCategoryLabel: "Install to", + categoryCustom: "Custom skill", + categoryPublic: "Built-in (admin)", }, }, 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: { title: "Notifications", bellAria: "Open notifications", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index f5d7a088..f3174e8a 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -125,6 +125,7 @@ export interface Translations { chats: string; demoChats: string; agents: string; + users: string; }; // Agents @@ -397,9 +398,13 @@ export interface Translations { warningsLabel: string; conflictCustom: string; conflictPublic: string; + conflictPublicAdminHint: string; installSuccess: string; installSuccessReplaced: string; rejectInvalidExt: string; + targetCategoryLabel: string; + categoryCustom: string; + categoryPublic: string; }; }; 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) notifications: NotificationsTranslations; } diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index dbc59dfa..6106bc0b 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -166,6 +166,7 @@ export const zhCN: Translations = { recentChats: "最近的对话", demoChats: "演示对话", agents: "智能体", + users: "用户管理", }, // Agents @@ -449,10 +450,14 @@ export const zhCN: Translations = { warningsLabel: "警告", conflictCustom: "已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。", - conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。", - installSuccess: "技能 “{name}” 已安装。", - installSuccessReplaced: "技能 “{name}” 已替换。", - rejectInvalidExt: "只接受 .zip 或 .skill 文件。", + conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`, + conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`, + installSuccess: `技能 '{name}' 已安装。`, + installSuccessReplaced: `技能 '{name}' 已替换。`, + rejectInvalidExt: `只接受 .zip 或 .skill 文件。`, + targetCategoryLabel: `安装到`, + categoryCustom: `自定义技能`, + categoryPublic: `内置技能(管理员)`, }, }, skillEvolution: { @@ -526,6 +531,19 @@ export const zhCN: Translations = { }, }, + adminUsers: { + title: "用户管理", + description: "查看所有注册用户及其关联的技能和智能体数量。", + email: "邮箱", + role: "角色", + userId: "用户 ID", + skillCount: "技能数", + agentCount: "智能体数", + roleAdmin: "管理员", + roleUser: "普通用户", + empty: "暂无用户", + }, + notifications: { title: "通知", bellAria: "打开通知", diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index 6ba58604..6930bc86 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise { const form = new FormData(); 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( `${getBackendBaseURL()}/api/skills/install-upload${query}`, { method: "POST", body: form }, diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index d40aae21..4e18f06f 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -45,8 +45,8 @@ export function useValidateSkillUpload() { export function useInstallSkillUpload() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) => - installSkillUpload(file, { overwrite }), + mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) => + installSkillUpload(file, { overwrite, targetCategory }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["skills"] }); }, diff --git a/frontend-web/src/pages/UsersPage.tsx b/frontend-web/src/pages/UsersPage.tsx new file mode 100644 index 00000000..2f325665 --- /dev/null +++ b/frontend-web/src/pages/UsersPage.tsx @@ -0,0 +1,5 @@ +import { UserManagementPage } from "@/components/workspace/admin/user-management-page"; + +export default function UsersPage() { + return ; +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index d2ef68b1..a6ba0b7a 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -18,6 +18,7 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage"; import ScheduledTasksPage from "./ScheduledTasksPage"; import SkillEvolutionPage from "./SkillEvolutionPage"; import SkillsPage from "./SkillsPage"; +import UsersPage from "./UsersPage"; function WorkspaceLayout({ children, @@ -149,6 +150,14 @@ export default function WorkspaceRoutes({ } /> + + + + } + /> ); } diff --git a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py index 9cabc91d..716b9089 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py +++ b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py @@ -99,6 +99,10 @@ class LocalAuthProvider(AuthProvider): """Update an existing 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: """Get user by email.""" return await self._repo.get_user_by_email(email) diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py index d5bd2eae..220feab6 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py +++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py @@ -88,6 +88,11 @@ class UserRepository(ABC): """Return number of users with system_role == 'admin'.""" raise NotImplementedError + @abstractmethod + async def list_users(self) -> list[User]: + """Return all users ordered by creation date.""" + raise NotImplementedError + @abstractmethod async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: """Get user by OAuth provider and ID. diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py index 92ec5f4e..209b3ff6 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py +++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py @@ -119,6 +119,12 @@ class SQLiteUserRepository(UserRepository): async with self._sf() as session: 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: stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id) async with self._sf() as session: diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index bc04de7c..5bfb2ed7 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -10,6 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.security import OAuth2PasswordRequestForm 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 ( UserResponse, 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.errors import AuthErrorCode, AuthErrorResponse 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__) @@ -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) +# ── 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) ───────────────────────────────── diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index 90b03711..a679cd69 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -114,6 +114,7 @@ class SkillInstallRequest(BaseModel): 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)") + target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'") class SkillInstallResponse(BaseModel): @@ -123,6 +124,7 @@ class SkillInstallResponse(BaseModel): skill_name: str = Field(..., description="Name of the installed skill") message: str = Field(..., description="Installation result message") 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): @@ -320,40 +322,53 @@ async def install_skill_upload( request: Request, file: UploadFile = File(..., description="The .zip or .skill archive to install"), overwrite: bool = False, + target_category: SkillCategory = SkillCategory.CUSTOM, config: AppConfig = Depends(get_config), skill_store: SkillStore = Depends(get_skill_store), ) -> SkillInstallResponse: 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) try: storage = get_or_new_skill_storage(app_config=config) # Pre-parse the archive to learn the skill name without writing anything, # 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: validation = storage.validate_skill_archive(tmp_path) preview_name = validation.get("skill_name") if isinstance(validation, dict) else None except Exception: preview_name = None - if preview_name: + if preview_name and not is_admin: existing = await skill_store.get_any(preview_name) if existing and existing.get("owner_user_id") not in (None, user_id): raise HTTPException( status_code=409, 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: from deerflow.skills.usage import mark_uploaded mark_uploaded(result["skill_name"]) except Exception: pass - # Register ownership in the SkillStore. ``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} - ) + # For public installs (admin), register as built-in with no owner. + # For custom installs, register ownership under the uploading user. + if target_category == SkillCategory.PUBLIC: + await skill_store.ensure_legacy( + {"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() return SkillInstallResponse(**result) except FileNotFoundError as e: @@ -385,16 +400,25 @@ async def install_skill( ) -> SkillInstallResponse: try: 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) - 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: from deerflow.skills.usage import mark_uploaded mark_uploaded(result["skill_name"]) except Exception: pass - await skill_store.ensure_legacy( - {"name": result["skill_name"], "owner_user_id": user_id, "published": False} - ) + if target_category == SkillCategory.PUBLIC: + 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() return SkillInstallResponse(**result) except FileNotFoundError as e: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py index 225f8d13..13b3a4d2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py @@ -56,3 +56,8 @@ class AgentStore(ABC): any user identity and returns ``False`` if the target is not a built-in. """ 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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py index 6a98d15d..2b837103 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -114,3 +114,6 @@ class MemoryAgentStore(AgentStore): return False self._agents.pop(agent_id, None) 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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 87ed7502..60e3f28d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Any -from sqlalchemy import or_, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.agents.base import AgentStore @@ -137,3 +137,8 @@ class AgentRepository(AgentStore): await session.delete(row) await session.commit() 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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py index 5a52841e..52d48a90 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py @@ -62,3 +62,8 @@ class SkillStore(ABC): async def delete_admin(self, name: str) -> bool: """Admin-side delete; bypasses ownership check (caller authorizes).""" 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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py index 09cb4266..15bf5a53 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py @@ -124,3 +124,6 @@ class MemorySkillStore(SkillStore): return False del self._skills[name] 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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py index e54fdb16..5997daca 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Any -from sqlalchemy import or_, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.skills.base import SkillStore @@ -137,3 +137,8 @@ class SkillRepository(SkillStore): await session.delete(row) await session.commit() 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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/local_skill_storage.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/local_skill_storage.py index 2bb93d43..0491c7c8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/local_skill_storage.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/local_skill_storage.py @@ -189,6 +189,7 @@ class LocalSkillStorage(SkillStorage): *, overwrite: bool = False, require_skill_suffix: bool = True, + target_category: SkillCategory = SkillCategory.CUSTOM, ) -> dict: from deerflow.skills.installer import ( SkillAlreadyExistsError, @@ -199,10 +200,10 @@ class LocalSkillStorage(SkillStorage): ) 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" - custom_dir.mkdir(parents=True, exist_ok=True) + category_dir = self._host_root / target_category.value + category_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory() as 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: 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") - target = custom_dir / skill_name + target = category_dir / skill_name replaced = False if target.exists(): if not overwrite: @@ -231,7 +234,7 @@ class LocalSkillStorage(SkillStorage): 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 shutil.copytree(skill_dir, staging_target) _move_staged_skill_into_reserved_target(staging_target, target) @@ -242,6 +245,7 @@ class LocalSkillStorage(SkillStorage): "skill_name": skill_name, "message": f"Skill '{skill_name}' installed successfully", "overwritten": replaced, + "category": target_category.value, } def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/skill_storage.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/skill_storage.py index 40870aa7..6f03eb30 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/skill_storage.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/skill_storage.py @@ -132,17 +132,28 @@ class SkillStorage(ABC): """ @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. + ``target_category`` controls the destination directory (public or custom). 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`.""" 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 def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None: