Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-2
This commit is contained in:
commit
62292500ba
11 files changed
+152
-43
No files matched your search
@@ -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
|
||||
+7
@@ -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
|
||||
+2
-2
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user