This commit is contained in:
fengxiangdebaozi committed 2026-05-14 10:52:08 +08:00
commit 62292500ba
11 files changed
+152 -43

No files matched your search

+1
View File
@@ -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
@@ -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) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{(canEdit || isOwned) && (
{(canEdit || canDelete || isOwned) && (
<div className="flex gap-1">
{isOwned && (
<Button
@@ -270,7 +271,7 @@ export function AgentCard({ agent }: AgentCardProps) {
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
)}
{isOwned && (
{canDelete && (
<Button
size="icon"
variant="ghost"
@@ -2,9 +2,12 @@
import { BotIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
@@ -13,11 +16,51 @@ export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const allAgents = useMemo(
() => agents.filter((a) => a.builtin || a.published),
[agents],
);
const myAgents = useMemo(
() => agents.filter((a) => a.user_id !== null && a.user_id === currentUserId),
[agents, currentUserId],
);
const handleNewAgent = () => {
router.push("/page/workspace/agents/new");
};
const renderGrid = (list: typeof agents) => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
);
const renderEmpty = (description: string, showNewButton = false) => (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
</div>
{showNewButton && (
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
)}
</div>
);
return (
<div className="flex size-full flex-col">
{/* Page header */}
@@ -34,36 +77,41 @@ export function AgentGallery() {
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : agents.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
className="flex flex-1 flex-col overflow-hidden"
>
<TabsList className="mx-6 mt-4 w-auto self-start">
<TabsTrigger value="all">全部</TabsTrigger>
<TabsTrigger value="mine">自己的</TabsTrigger>
</TabsList>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{t.agents.emptyDescription}
</p>
) : allAgents.length === 0 ? (
renderEmpty(t.agents.emptyDescription)
) : (
renderGrid(allAgents)
)}
</TabsContent>
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
</div>
) : myAgents.length === 0 ? (
renderEmpty(t.agents.emptyDescription, true)
) : (
renderGrid(myAgents)
)}
</TabsContent>
</Tabs>
</div>
);
}
@@ -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")
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()