diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index f1c11ddb..fa1a21dc 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { ViewModeToggle } from "@/components/workspace/view-mode-toggle"; +import { getStoredAuth } from "@/core/auth"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; @@ -29,6 +30,8 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password"; export function AgentGallery() { const { t } = useI18n(); const router = useRouter(); + // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 + const isAdmin = getStoredAuth()?.system_role === "admin"; const [viewMode, setViewMode] = useViewMode("agent"); const [activeTab, setActiveTab] = useState("mine"); const [search, setSearch] = useState(""); @@ -58,15 +61,21 @@ export function AgentGallery() { // If the active square tab disappears (admin removed that square), fall back // to "我的" so the gallery never points at a dead tab. useEffect(() => { + // 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。 + if (activeTab === "builtin" && !isAdmin) { + setActiveTab("mine"); + return; + } if ( activeTab !== "mine" && activeTab !== "private" && + activeTab !== "builtin" && squareTabs.length > 0 && !squareTabs.some((sq) => sq.id === activeTab) ) { setActiveTab("mine"); } - }, [activeTab, squareTabs]); + }, [activeTab, squareTabs, isAdmin]); const { data: privateStatus } = usePrivateSquareStatus(); const privateFeatureAvailable = !!privateStatus?.enabled; @@ -80,9 +89,16 @@ export function AgentGallery() { const [passwordInput, setPasswordInput] = useState(""); // Only the active tab fetches. "广场" maps to the server "square" scope - // (built-in/published, never private), "我的" to "mine" (owned + favorited). - const isSquareTab = activeTab !== "mine" && activeTab !== "private"; - const mainScope = activeTab === "mine" ? "mine" : "square"; + // (built-in/published, never private), "我的" to "mine" (owned + favorited), + // "内置" to "builtin" (admin-only, user_id IS NULL). + const isSquareTab = + activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin"; + const mainScope = + activeTab === "mine" + ? "mine" + : activeTab === "builtin" + ? "builtin" + : "square"; const mainPage = pages[activeTab] ?? 1; const { agents: mainAgents, @@ -393,6 +409,12 @@ export function AgentGallery() { 私密广场 )} + {isAdmin && ( + + + 内置智能体 + + )}
@@ -490,6 +512,19 @@ export function AgentGallery() { })} + {isAdmin && ( + + {renderListBody({ + list: mainAgents, + total: mainTotal, + isLoading: mainLoading, + isFetching: mainFetching, + tab: "builtin", + emptyDescription: "暂时没有内置智能体。", + })} + + )} + {privateFeatureAvailable && ( AsyncGenerator[None, None]: # re-run the heavy sync even though we just did it. _services_mark_sync_fresh() logger.info("Legacy folder-backed agents synced at startup") + # 内置功能型 agent(roundtable-* / ai-writing-*)按用途默认打标签, + # 供管理员「内置智能体」筛选按用途归类。幂等、只追加、失败不阻塞启动。 + try: + from app.gateway.routers._builtin_agent_tag_seed import ( + ensure_builtin_agent_purpose_tags, + ) + + tag_store = getattr(app.state, "tag_store", None) + if tag_store is not None: + await ensure_builtin_agent_purpose_tags(agent_store, tag_store) + except Exception: + logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)") except Exception: logger.exception("Legacy agent sync failed at startup (non-fatal)") diff --git a/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py new file mode 100644 index 00000000..58891b95 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py @@ -0,0 +1,97 @@ +"""内置智能体「用途」默认标签的种子化。 + +把**功能型内置 agent**(roundtable-* / ai-writing-*)按用途默认打上标签,使管理员在 +「内置智能体」筛选里一眼能按用途归类。没有明确用途的内置 agent(各种测试/用户残留的 +user_id 为空的 agent)**不打标签**。 + +用途映射(可继续补充): +- **多智能体规划**:roundtable-intent(任务理解)/ roundtable-recommender(智能体推荐)/ + roundtable-coordinator(总控协调)/ roundtable-report / roundtable-summary / + roundtable-execution-plan / roundtable-risk-review / roundtable-solution-design +- **AI写作**:ai-writing-researcher / ai-writing-outliner / ai-writing-writer(作家)/ + ai-writing-editor + +幂等:标签缺失则建,assignment 缺失则补;**只做并集追加**,绝不删用户/管理员后加的 +其它标签。在启动 lifespan 里 `_sync_legacy_agents` 之后调用(那时 agent 行已入库)。 +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +# 标签按用途分组(tag_type 固定为 "agent",与 target_type 对齐)。id 用稳定 slug, +# 便于幂等;name 是展示名;color 走标签库的命名色。 +_PURPOSE_TAGS: dict[str, dict[str, str]] = { + "多智能体规划": {"id": "builtin-purpose-multi-agent-planning", "color": "blue"}, + "AI写作": {"id": "builtin-purpose-ai-writing", "color": "green"}, +} + +# 内置 agent id → 用途名(必须是 _PURPOSE_TAGS 的 key)。未列出的内置 agent 不打标签。 +_AGENT_PURPOSE: dict[str, str] = { + # 多智能体规划(圆桌全链路) + "roundtable-intent": "多智能体规划", + "roundtable-recommender": "多智能体规划", + "roundtable-coordinator": "多智能体规划", + "roundtable-report": "多智能体规划", + "roundtable-summary": "多智能体规划", + "roundtable-execution-plan": "多智能体规划", + "roundtable-risk-review": "多智能体规划", + "roundtable-solution-design": "多智能体规划", + # AI 写作(杂志社协作写作四阶段) + "ai-writing-researcher": "AI写作", + "ai-writing-outliner": "AI写作", + "ai-writing-writer": "AI写作", + "ai-writing-editor": "AI写作", +} + +_TARGET_TYPE = "agent" + + +async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int: + """确保内置功能型 agent 带上默认「用途」标签。返回新增 assignment 数。 + + 错误只记录不抛——种子化失败绝不能阻塞服务启动。 + """ + try: + # 1) 确保用途标签存在,建立 name -> id 映射。 + existing = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)} + name_to_id: dict[str, str] = {} + for name, meta in _PURPOSE_TAGS.items(): + found = existing.get(name) + if found is not None: + name_to_id[name] = found["id"] + continue + try: + created = await tag_store.create_tag( + {"id": meta["id"], "name": name, "tag_type": _TARGET_TYPE, "color": meta.get("color")} + ) + name_to_id[name] = created["id"] + except ValueError: + # 并发/已存在(按 name 唯一)——重查一次取其 id。 + again = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)} + if name in again: + name_to_id[name] = again[name]["id"] + + # 2) 给映射里**实际存在**的内置 agent 追加用途标签(并集,不覆盖已有标签)。 + added = 0 + for agent_id, purpose in _AGENT_PURPOSE.items(): + tag_id = name_to_id.get(purpose) + if not tag_id: + continue + agent = await agent_store.get_any(agent_id) + if agent is None: + continue # 该部署未装此内置 agent,跳过 + current = await tag_store.get_target_tags(_TARGET_TYPE, agent_id) + cur_ids = [t["id"] for t in current] + if tag_id in cur_ids: + continue # 已打过该用途标签 + await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*cur_ids, tag_id]) + added += 1 + if added: + logger.info("Seeded %d built-in agent purpose tag assignment(s)", added) + return added + except Exception: # noqa: BLE001 + logger.exception("Failed to seed built-in agent purpose tags") + return 0 diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index f84bd5da..40e9764a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -587,8 +587,8 @@ async def list_agents( tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"), scope: str | None = Query( default=None, - pattern="^(mine|square)$", - description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.", + pattern="^(mine|square|builtin)$", + description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public; 'builtin' = 内置智能体 (user_id IS NULL, **admin only**). Omit for the default combined visibility list.", ), page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."), page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."), @@ -623,6 +623,9 @@ async def list_agents( user_id = _current_user_id(request) if admin and not _is_admin_user(request): raise HTTPException(status_code=403, detail="Only system admins can list all agents") + # 「内置智能体」筛选仅限管理员(普通用户不应看到全部内置/功能型 agent 目录)。 + if scope == "builtin" and not _is_admin_user(request): + raise HTTPException(status_code=403, detail="Only system admins can list built-in agents") paginate = scope is not None or page is not None square_id = square.strip() if square and square.strip() else None 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 51003e8b..ecabf667 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 @@ -195,6 +195,9 @@ class MemoryAgentStore(AgentStore): return owned or favorited if scope == "private": return bool(agent.get("is_private")) + if scope == "builtin": + # 内置智能体 = user_id IS NULL(仅管理员;路由层 admin 闸门)。 + return agent.get("user_id") is None return self._is_visible(agent, user_id) async def list_paginated( 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 2e662401..4eeddb1f 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 @@ -275,6 +275,9 @@ class AgentRepository(AgentStore): return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq)) if scope == "private": return AgentRow.is_private.is_(True) + if scope == "builtin": + # 内置智能体 = user_id IS NULL。仅管理员可用此 scope(路由层已 admin 闸门)。 + return AgentRow.user_id.is_(None) return self._listing_filter(user_id) @staticmethod