diff --git a/.gitignore b/.gitignore index 043f2941..4fd1ef9e 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ offline-backend-20260512/skills/ offline-backend-20260512/wheelhouse/ offline-backend-20260512/backend/.deer-flow/data/*.db offline-backend-20260512/backend/.deer-flow/data/*.db-* +offline-backend-20260512/backend/.deer-flow/* offline-backend-20260512/start_backend.sh offline-backend-20260512/stop_backend.sh offline-backend-20260512/check_backend.sh diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index cc136d61..86f32274 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -66,10 +66,11 @@ export function AgentCard({ agent }: AgentCardProps) { const isOwned = !isBuiltin && agent.user_id !== null && agent.user_id === currentUserId; const isFromOthers = !isBuiltin && !isOwned; - // System admins are allowed to edit built-in agents in place. Delete and - // publish-toggle stay owner-only — built-ins shouldn't be removable, and - // they are inherently visible to everyone so toggling published is moot. + // System admins are allowed to edit and delete built-in agents. + // Publish-toggle stays owner-only — built-ins are inherently visible to + // everyone so toggling published is moot. const canEdit = isOwned || (isBuiltin && isAdmin); + const canDelete = isOwned || (isBuiltin && isAdmin); useEffect(() => { if (!editOpen) { @@ -241,7 +242,7 @@ export function AgentCard({ agent }: AgentCardProps) { {t.agents.chat} - {(canEdit || isOwned) && ( + {(canEdit || canDelete || isOwned) && (
{isOwned && ( )} - {isOwned && ( + {canDelete && ( + )} +
+ ); + return (
{/* Page header */} @@ -34,36 +77,41 @@ export function AgentGallery() {
- {/* Content */} -
- {isLoading ? ( -
- {t.common.loading} -
- ) : agents.length === 0 ? ( -
-
- + {/* Tabs */} + setActiveTab(v as "all" | "mine")} + className="flex flex-1 flex-col overflow-hidden" + > + + 全部 + 自己的 + + + + {isLoading ? ( +
+ {t.common.loading}
-
-

{t.agents.emptyTitle}

-

- {t.agents.emptyDescription} -

+ ) : allAgents.length === 0 ? ( + renderEmpty(t.agents.emptyDescription) + ) : ( + renderGrid(allAgents) + )} + + + + {isLoading ? ( +
+ {t.common.loading}
- -
- ) : ( -
- {agents.map((agent) => ( - - ))} -
- )} -
+ ) : myAgents.length === 0 ? ( + renderEmpty(t.agents.emptyDescription, true) + ) : ( + renderGrid(myAgents) + )} + +
); } diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 11527961..4508bc0f 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -470,8 +470,15 @@ async def delete_agent(agent_id: str, request: Request) -> None: try: store = get_agent_store(request) user_id = _current_user_id(request) - await _get_owned_or_raise(store, agent_id, user_id) - deleted = await store.delete(agent_id, user_id) + is_admin = _is_admin_user(request) + _, edit_mode = await _get_editable_or_raise( + store, agent_id, user_id, is_admin=is_admin + ) + + if edit_mode == "admin": + deleted = await store.delete_builtin(agent_id) + else: + deleted = await store.delete(agent_id, user_id) if not deleted: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index 87ef2405..bc04de7c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -370,7 +370,8 @@ async def login_by_username(request: Request, response: Response, body: Username user = await provider.get_user_by_email(email) if user is None: try: - user = await provider.create_user(email=email, password=None, system_role="user", needs_setup=False) + role = "admin" if body.username == "admin" else "user" + user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False) created = True logger.info("Auto-registered passwordless user: %s", email) except ValueError: @@ -378,6 +379,13 @@ async def login_by_username(request: Request, response: Response, body: Username user = await provider.get_user_by_email(email) if user is None: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create or load user") + elif body.username == "admin" and user.system_role != "admin": + # Historical accounts created before the username-based role assignment + # may still have system_role="user" — promote the canonical admin user + # so /login/admin behaves consistently. + user.system_role = "admin" + user = await provider.update_user(user) + logger.info("Promoted historical 'admin' account to system_role=admin: %s", email) token = create_access_token(str(user.id), token_version=user.token_version) _set_session_cookie(response, token, request) 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 dce4a983..225f8d13 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 @@ -47,3 +47,12 @@ class AgentStore(ABC): @abstractmethod async def delete(self, agent_id: str, user_id: str) -> bool: raise NotImplementedError + + @abstractmethod + async def delete_builtin(self, agent_id: str) -> bool: + """Delete a built-in agent (user_id IS NULL). + + Caller is responsible for authorization — this method does NOT check + any user identity and returns ``False`` if the target is not a built-in. + """ + 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 3220b330..6a98d15d 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 @@ -107,3 +107,10 @@ class MemoryAgentStore(AgentStore): return False self._agents.pop(agent_id, None) return True + + async def delete_builtin(self, agent_id: str) -> bool: + row = self._agents.get(agent_id) + if row is None or row.get("user_id") is not None: + return False + self._agents.pop(agent_id, None) + return True 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 97b33ece..87ed7502 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 @@ -128,3 +128,12 @@ class AgentRepository(AgentStore): await session.delete(row) await session.commit() return True + + async def delete_builtin(self, agent_id: str) -> bool: + async with self._sf() as session: + row = await session.get(AgentRow, agent_id) + if row is None or row.user_id is not None: + return False + await session.delete(row) + await session.commit() + return True 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 33ef4b7b..40870aa7 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 @@ -12,7 +12,7 @@ from deerflow.skills.types import SKILL_MD_FILE, Skill, SkillCategory # noqa: F logger = logging.getLogger(__name__) -_SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:[_-][a-z0-9]+)*$") class SkillStorage(ABC): @@ -36,7 +36,7 @@ class SkillStorage(ABC): """Validate and normalise a skill name; return the normalised form.""" normalized = name.strip() if not _SKILL_NAME_PATTERN.fullmatch(normalized): - raise ValueError("Skill name must be hyphen-case using lowercase letters, digits, and hyphens only.") + raise ValueError("Skill name must use lowercase letters and digits separated by '-' or '_' only.") if len(normalized) > 64: raise ValueError("Skill name must be 64 characters or fewer.") return normalized diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/validation.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/validation.py index 8a2dd757..c95b67c0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/validation.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/validation.py @@ -65,11 +65,11 @@ def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None] if not name: return False, "Name cannot be empty", None - # Check naming convention (hyphen-case: lowercase with hyphens) - if not re.match(r"^[a-z0-9-]+$", name): - return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", None - if name.startswith("-") or name.endswith("-") or "--" in name: - return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", None + # Check naming convention (lowercase letters/digits separated by '-' or '_') + if not re.match(r"^[a-z0-9_-]+$", name): + return False, f"Name '{name}' should be lowercase letters, digits, hyphens or underscores only", None + if name[0] in "-_" or name[-1] in "-_" or re.search(r"[-_]{2,}", name): + return False, f"Name '{name}' cannot start/end with '-' or '_' or contain consecutive separators", None if len(name) > 64: return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters.", None diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py index 070be227..f53b2caf 100644 --- a/offline-backend-20260512/backend/tests/test_agents_backend.py +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -52,6 +52,25 @@ async def test_agent_store_only_owner_can_update_or_delete() -> None: assert await store.delete("private-a", "user-a") is True +@pytest.mark.asyncio +async def test_delete_builtin_only_targets_builtins() -> None: + store = MemoryAgentStore() + + await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"}) + await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"}) + + # delete_builtin refuses to touch a user-owned agent + assert await store.delete_builtin("owned-agent") is False + assert await store.get_any("owned-agent") is not None + + # delete_builtin succeeds on a built-in + assert await store.delete_builtin("builtin-agent") is True + assert await store.get_any("builtin-agent") is None + + # And no-ops on a missing id + assert await store.delete_builtin("does-not-exist") is False + + @pytest.mark.asyncio async def test_update_builtin_only_targets_builtins() -> None: store = MemoryAgentStore()