feat(agents): 内置智能体筛选(仅管理员)+ 内置 agent 默认用途标签
- 后端 scope=builtin(user_id IS NULL)加入 sql/memory 的 scope 过滤; /api/agents 扩展 scope 正则并加 admin 闸门(非管理员请求 403) - 新增 _builtin_agent_tag_seed:启动时幂等给功能型内置 agent 按用途打标签 (roundtable-* → 多智能体规划,ai-writing-* → AI写作),只追加不覆盖; 无用途的内置 agent 不打标签 - 前端 agent-gallery 新增「内置智能体」Tab(仅 system_role==admin 可见), scope 类型在 api.ts/hooks.ts 同步 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
391409b6c0
commit
cd3a71baab
8 files changed
+163
-9
No files matched your search
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||||
import type { Agent, AgentSort } from "@/core/agents";
|
import type { Agent, AgentSort } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -29,6 +30,8 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
|||||||
export function AgentGallery() {
|
export function AgentGallery() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [viewMode, setViewMode] = useViewMode("agent");
|
const [viewMode, setViewMode] = useViewMode("agent");
|
||||||
const [activeTab, setActiveTab] = useState<string>("mine");
|
const [activeTab, setActiveTab] = useState<string>("mine");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -58,15 +61,21 @@ export function AgentGallery() {
|
|||||||
// If the active square tab disappears (admin removed that square), fall back
|
// If the active square tab disappears (admin removed that square), fall back
|
||||||
// to "我的" so the gallery never points at a dead tab.
|
// to "我的" so the gallery never points at a dead tab.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
|
||||||
|
if (activeTab === "builtin" && !isAdmin) {
|
||||||
|
setActiveTab("mine");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
activeTab !== "mine" &&
|
activeTab !== "mine" &&
|
||||||
activeTab !== "private" &&
|
activeTab !== "private" &&
|
||||||
|
activeTab !== "builtin" &&
|
||||||
squareTabs.length > 0 &&
|
squareTabs.length > 0 &&
|
||||||
!squareTabs.some((sq) => sq.id === activeTab)
|
!squareTabs.some((sq) => sq.id === activeTab)
|
||||||
) {
|
) {
|
||||||
setActiveTab("mine");
|
setActiveTab("mine");
|
||||||
}
|
}
|
||||||
}, [activeTab, squareTabs]);
|
}, [activeTab, squareTabs, isAdmin]);
|
||||||
|
|
||||||
const { data: privateStatus } = usePrivateSquareStatus();
|
const { data: privateStatus } = usePrivateSquareStatus();
|
||||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||||
@@ -80,9 +89,16 @@ export function AgentGallery() {
|
|||||||
const [passwordInput, setPasswordInput] = useState("");
|
const [passwordInput, setPasswordInput] = useState("");
|
||||||
|
|
||||||
// Only the active tab fetches. "广场" maps to the server "square" scope
|
// Only the active tab fetches. "广场" maps to the server "square" scope
|
||||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
|
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
|
||||||
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
|
// "内置" to "builtin" (admin-only, user_id IS NULL).
|
||||||
const mainScope = activeTab === "mine" ? "mine" : "square";
|
const isSquareTab =
|
||||||
|
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
|
||||||
|
const mainScope =
|
||||||
|
activeTab === "mine"
|
||||||
|
? "mine"
|
||||||
|
: activeTab === "builtin"
|
||||||
|
? "builtin"
|
||||||
|
: "square";
|
||||||
const mainPage = pages[activeTab] ?? 1;
|
const mainPage = pages[activeTab] ?? 1;
|
||||||
const {
|
const {
|
||||||
agents: mainAgents,
|
agents: mainAgents,
|
||||||
@@ -393,6 +409,12 @@ export function AgentGallery() {
|
|||||||
私密广场
|
私密广场
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
|
||||||
|
<BotIcon className="mr-1 h-3.5 w-3.5" />
|
||||||
|
内置智能体
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
@@ -490,6 +512,19 @@ export function AgentGallery() {
|
|||||||
})}
|
})}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||||
|
{renderListBody({
|
||||||
|
list: mainAgents,
|
||||||
|
total: mainTotal,
|
||||||
|
isLoading: mainLoading,
|
||||||
|
isFetching: mainFetching,
|
||||||
|
tab: "builtin",
|
||||||
|
emptyDescription: "暂时没有内置智能体。",
|
||||||
|
})}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{privateFeatureAvailable && (
|
{privateFeatureAvailable && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="private"
|
value="private"
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ export class AgentNameCheckError extends Error {
|
|||||||
export interface ListAgentsParams {
|
export interface ListAgentsParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
/** Pagination scope. Omit for the legacy combined-visibility list. */
|
/** Pagination scope. Omit for the legacy combined-visibility list.
|
||||||
scope?: "mine" | "square";
|
* "builtin" = 内置智能体 (user_id IS NULL), admin only — backend 403s non-admins. */
|
||||||
|
scope?: "mine" | "square" | "builtin";
|
||||||
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
|
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
|||||||
/** Server-side paginated agent listing for one gallery tab (scope). Returns
|
/** Server-side paginated agent listing for one gallery tab (scope). Returns
|
||||||
* the page of agents plus the total count for the "共 N 个" header. */
|
* the page of agents plus the total count for the "共 N 个" header. */
|
||||||
export function useAgentsPaginated(params: {
|
export function useAgentsPaginated(params: {
|
||||||
scope: "mine" | "square";
|
scope: "mine" | "square" | "builtin";
|
||||||
search?: string;
|
search?: string;
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
sort?: AgentSort;
|
sort?: AgentSort;
|
||||||
|
|||||||
@@ -980,6 +980,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# re-run the heavy sync even though we just did it.
|
# re-run the heavy sync even though we just did it.
|
||||||
_services_mark_sync_fresh()
|
_services_mark_sync_fresh()
|
||||||
logger.info("Legacy folder-backed agents synced at startup")
|
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:
|
except Exception:
|
||||||
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)"),
|
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
|
||||||
scope: str | None = Query(
|
scope: str | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
pattern="^(mine|square)$",
|
pattern="^(mine|square|builtin)$",
|
||||||
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.",
|
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: 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."),
|
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)
|
user_id = _current_user_id(request)
|
||||||
if admin and not _is_admin_user(request):
|
if admin and not _is_admin_user(request):
|
||||||
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
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
|
paginate = scope is not None or page is not None
|
||||||
square_id = square.strip() if square and square.strip() else None
|
square_id = square.strip() if square and square.strip() else None
|
||||||
|
|||||||
+3
@@ -195,6 +195,9 @@ class MemoryAgentStore(AgentStore):
|
|||||||
return owned or favorited
|
return owned or favorited
|
||||||
if scope == "private":
|
if scope == "private":
|
||||||
return bool(agent.get("is_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)
|
return self._is_visible(agent, user_id)
|
||||||
|
|
||||||
async def list_paginated(
|
async def list_paginated(
|
||||||
|
|||||||
@@ -275,6 +275,9 @@ class AgentRepository(AgentStore):
|
|||||||
return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq))
|
return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq))
|
||||||
if scope == "private":
|
if scope == "private":
|
||||||
return AgentRow.is_private.is_(True)
|
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)
|
return self._listing_filter(user_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
Reference in new issue
Block a user