IntentKit Discover 页面与公开 Agent 系统实现指南:从虚拟公共团队到权限感知详情页
2026/9/17 8:30:25 网站建设 项目流程

IntentKit Discover 页面与公开 Agent 系统实现指南:从虚拟公共团队到权限感知详情页

【免费下载链接】intentkitIntentKit is an open-source, self-hosted cloud agent cluster that manages a collaborative team of AI agents for you.项目地址: https://gitcode.com/GitHub_Trending/int/intentkit

导读

本文基于 IntentKit 仓库的 Discover Page 实现计划,完整讲解如何构建一个面向未登录访客的公开内容发现体系:将public_agents/目录下 YAML 定义的公开 Agent(可见度 ≥ 20)统一归属到predefined所有者与团队,通过自动订阅让一个名为public的虚拟团队聚合全部公开 Agent 的 Activity 与 Post 流,并新增无鉴权的/public/*API 与前端 Discover 三 Tab 页面。读完本文,你将掌握公开 Agent 的数据归属模型、团队订阅与扇出(fan-out)机制、游标分页 Feed 查询、前端 Discover 页面搭建,以及如何让 Agent 详情页根据可见度条件化展示编辑入口。

一、整体架构:谁拥有公开 Agent,内容流向哪里

在实现之前,先理解三个关键概念:

  1. 可见度(Visibility)等级:由 intentkit/models/agent/core.py 中的AgentVisibility枚举定义,数值越大可见范围越广:

    • PRIVATE = 0:仅所有者可见;
    • TEAM = 10:团队成员可见;
    • PUBLIC = 20:所有人可见。

    Discover 页面的数据门槛就是visibility >= PUBLIC

  2. "predefined" 归属:公开 Agent 不再属于任何真实用户或团队,而是统一挂在owner = "predefined"team_id = "predefined"下。这样既保证公开 Agent 由系统侧托管、不会被普通用户误编辑,又复用了现成的 Agent 所有权字段做权限判定。

  3. "public" 虚拟团队:团队订阅与 Feed 扇出是 IntentKit 既有能力(intentkit/core/team/feed.pyintentkit/core/team/subscription.py)。设计上不另起炉灶,而是把"公开内容聚合"建模成一个 id 为public的虚拟团队——所有公开 Agent 启动时被自动订阅进该团队,随后其 Activity / Post 通过既有的扇出管线写入public团队的 Feed 表,前端再通过/public/*接口读取,全链路复用、无鉴权成本。

后端技术栈为 Python/FastAPI + SQLAlchemy 2.0 + PostgreSQL,前端为 Next.js 14 App Router + TanStack Query。

二、后端第一步:将公开 Agent 归属改为 "predefined"

2.1 常量定义

在 intentkit/core/public_agents.py 中,计划要求将归属常量改为:

OWNER = "predefined" TEAM_ID = "predefined"

仓库当前实现正是如此——新建公开 Agent 时(同文件sync_public_agents()内)会显式设置db_agent.owner = OWNERdb_agent.team_id = TEAM_ID,并把visibility置为AgentVisibility.PUBLIC(即 20)。

2.2 启动前置初始化:ensure_public_agent_prerequisites

计划要求在app/api.pyapp/team/api.py的 lifespan 中、调用sync_public_agents()之前,先确保以下记录存在,并将该逻辑抽取为共享函数。当前仓库已在 intentkit/core/public_agents.py 中实现:

async def ensure_public_agent_prerequisites() -> None: """Ensure the predefined user/team and public virtual team exist.""" try: async with get_session() as session: # Create "predefined" user predefined_user = await session.get(UserTable, "predefined") if not predefined_user: session.add(UserTable(id="predefined")) # Create "predefined" team predefined_team = await session.get(TeamTable, "predefined") if not predefined_team: session.add(TeamTable(id="predefined", name="predefined")) # Create "predefined" team membership predefined_member = await session.get( TeamMemberTable, {"team_id": "predefined", "user_id": "predefined"} ) if not predefined_member: session.add( TeamMemberTable( team_id="predefined", user_id="predefined", role=TeamRole.OWNER, ) ) # Create "public" virtual team public_team = await session.get(TeamTable, "public") if not public_team: session.add(TeamTable(id="public", name="public")) await session.commit() except Exception as e: logger.error("Failed to create public agent prerequisites: %s", e)

要点说明:

  • predefined用户、predefined团队及其 OWNER 成员关系是公开 Agent 的宿主;public团队则是"公开内容聚合"这个虚拟团队的载体;
  • 全部采用"先查后建"的幂等写法,重复启动不会产生重复记录;
  • 函数内捕获所有异常并打日志,避免初始化失败拖垮整个应用启动。

从 app/api.py 与 app/team/api.py 的 lifespan 可以看出,两个入口都已按计划顺序调用:

await ensure_public_agent_prerequisites() await sync_public_agents()

2.3 同步后自动订阅 public 团队

sync_public_agents()会扫描 public_agents/base/ 目录下的 YAML 文件(如blog-writer.yamltrend-spotter.yaml等),按"内容哈希(AgentUpdate.hash(),SHA-256)变化才更新"的策略 upsert 到数据库。计划要求在同步完成后,对每个同步到的 Agent 调用auto_subscribe_team("public", agent_id)。当前仓库实现在同步循环结束后统一处理:

# Auto-subscribe the "public" team to each synced agent from intentkit.core.team.subscription import auto_subscribe_team for agent_id in synced_agent_ids: try: await auto_subscribe_team("public", agent_id) except Exception: logger.exception("Failed to subscribe public team to %s", agent_id)

auto_subscribe_team定义在 intentkit/core/team/subscription.py,本质是一条insert ... on_conflict_do_nothing(),保证(team_id, agent_id)订阅关系幂等写入TeamSubscriptionTable

三、后端公开 API:无鉴权的 /public/* 端点

计划原方案是在app/local/public.pyapp/team/public.py各写一份重复端点;实际实现更优——抽取出共享的工厂函数create_public_router()(见 intentkit/core/public_api.py),两个入口文件只需一行:

# app/local/public.py 与 app/team/public.py from intentkit.core.public_api import create_public_router public_router = create_public_router()

随后在 app/api.py 与 app/team/api.py 中分别app.include_router(public_router),并在 app/local/init.py 与 app/team/init.py 中导出public_router/team_public_router

3.1 端点清单与参数说明

端点方法说明关键参数
/public/agentsGET列出所有公开 Agent(visibility >= PUBLIC且未归档),按created_at倒序
/public/timelineGET公开 Activity 时间线limit(默认 20,范围 1–100)、cursor(游标字符串,可空)
/public/postsGET公开 Post 列表limit(默认 20,范围 1–100)、cursor
/public/posts/{post_id}GET单个公开 Post 详情路径参数post_id
/public/share-links/{share_link_id}GET解析分享链接(含计数自增)路径参数
/public/share-links/{share_link_id}/pdfGET下载分享 Post 的 PDF路径参数

其中/public/agents的实现要点(与计划代码一致):

@router.get("/agents", operation_id="public_list_agents") async def list_public_agents() -> list[AgentResponse]: """List all public agents (visibility >= PUBLIC).""" async with get_session() as session: result = await session.execute( select(AgentTable) .where(AgentTable.visibility >= AgentVisibility.PUBLIC) .where(AgentTable.archived_at.is_(None)) .order_by(AgentTable.created_at.desc()) ) agents = result.scalars().all() responses = [] for agent_row in agents: agent = Agent.model_validate(agent_row) resp = await AgentResponse.from_agent(agent) responses.append(resp) return responses

值得注意的细节:

  • 过滤条件包含archived_at.is_(None)——被归档(如模型不可用)的公开 Agent 不会出现在发现页;
  • AgentResponse.from_agent()负责把数据库行转成面向 API 的响应模型;
  • /public/posts/{post_id}在返回前还会二次校验 Post 所属 Agent 的可见度,非公开 Agent 的 Post 一律返回 404,防止越权暴露;
  • 额外两个share-links端点(get_shared_view+increment_share_link_view_count,见 intentkit/core/share_link.py)是计划之后扩展进来的,与公开内容体系同属"无鉴权只读"语义。

四、Feed 扇出与游标分页:public 虚拟团队如何"被动"收获内容

4.1 扇出时自动附加 public 团队

计划要求同时修改fan_out_activityfan_out_post:当 Agent 可见度 ≥ 20 时,除已订阅团队外,额外把public团队加入扇出目标。当前仓库把该判定收敛进共享函数_resolve_target_teams()(见 intentkit/core/team/feed.py):

async def _resolve_target_teams(session: AsyncSession, agent_id: str) -> list[str]: """Get all teams that should receive fan-out for an agent's content.""" result = await session.execute( select(TeamSubscriptionTable.team_id).where( TeamSubscriptionTable.agent_id == agent_id ) ) team_ids = list(result.scalars().all()) # Ensure public agents fan out to the "public" virtual team if PUBLIC_TEAM_ID not in team_ids: agent_row = await session.get(AgentTable, agent_id) if ( agent_row and agent_row.visibility is not None and agent_row.visibility >= AgentVisibility.PUBLIC ): team_ids.append(PUBLIC_TEAM_ID) return team_ids

随后fan_out_activity(同文件 L48-L69)与fan_out_post(L72-L89)以同样的批量insert ... on_conflict_do_nothing()写入TeamActivityFeedTable/TeamPostFeedTable。也就是说:

  • 写入路径:Agent 产生 Activity / Post → 调用方触发扇出 → 目标团队 = 订阅团队 ∪ (公开则含public) → 批量落 Feed 表;
  • 读取路径/public/timeline/public/posts→ 以publicteam_id查询 Feed 表。

该行为已有测试覆盖,见 tests/core/test_team_feed.py:其中明确断言了"订阅查询返回结果不含 public 时,公开 Agent 的扇出仍会把public追加进目标团队",以及已含public时跳过追加。

4.2 游标分页实现

query_activity_feed/query_post_feed均实现了基于(created_at, id)的复合游标分页(intentkit/core/team/feed.py):

  • 游标格式:{created_at.isoformat()}|{item_id}
  • 解析失败抛IntentKitAPIError(400, "InvalidCursor", "Malformed cursor")
  • 翻页条件:created_at < cursor_dtcreated_at == cursor_dt and id < cursor_id,保证同一时刻产生的多条记录也不重不漏;
  • 查询时取limit + 1条判断是否has_more,再截断为limit条,并返回next_cursor
  • 返回前统一调用attach_agent_info(items)(intentkit/core/agent/info.py)为每条内容附带 Agent 名称、头像等展示信息,前端无需二次请求。

五、前端数据层:Agent 类型与 publicApi

5.1 Agent 类型补充可见度字段

计划要求在 frontend/src/types/agent.ts 的 Agent 接口中增加:

owner: string | null; team_id: string | null; visibility: number | null;

这三个字段是前端做权限判定与 Public 徽标展示的前提。

5.2 publicApi 对象

计划给出了publicApi的完整设计,仓库在 frontend/src/lib/api.ts 中实现,四个方法直接使用fetch请求无鉴权端点:

export const publicApi = { async getAgents(): Promise<AgentResponse[]> { const response = await fetch(`${API_BASE}/public/agents`); if (!response.ok) { throw new Error(`Failed to fetch public agents: ${response.statusText}`); } return response.json(); }, async getTimeline(limit = 20, cursor?: string | null) { const params = new URLSearchParams({ limit: String(limit) }); if (cursor) params.set("cursor", cursor); const response = await fetch(`${API_BASE}/public/timeline?${params}`); if (!response.ok) { throw new Error(`Failed to fetch public timeline: ${response.statusText}`); } return response.json(); }, async getPosts(limit = 20, cursor?: string | null) { const params = new URLSearchParams({ limit: String(limit) }); if (cursor) params.set("cursor", cursor); const response = await fetch(`${API_BASE}/public/posts?${params}`); if (!response.ok) { throw new Error(`Failed to fetch public posts: ${response.statusText}`); } return response.json(); }, async getPost(postId: string) { const response = await fetch(`${API_BASE}/public/posts/${postId}`); if (!response.ok) { throw new Error(`Failed to fetch public post: ${response.statusText}`); } return response.json(); }, };

六、前端 Discover 页面:三 Tab 布局与数据渲染

6.1 共享布局与 Tab 高亮

frontend/src/app/discover/layout.tsx 实现了计划中的共享 Tab 布局——使用usePathname()感知当前路由,Tab 高亮逻辑用startsWith匹配子路径:

"use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; export default function DiscoverLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const tabs = [ { href: "/discover", label: "Agents", match: (p: string) => p === "/discover" || p.startsWith("/discover/agents"), }, { href: "/discover/timeline", label: "Timeline", match: (p: string) => p.startsWith("/discover/timeline"), }, { href: "/discover/posts", label: "Posts", match: (p: string) => p.startsWith("/discover/posts"), }, ]; return ( <div className="container py-10"> <div className="mb-8"> <h1 className="text-3xl font-bold tracking-tight">Discover</h1> <p className="text-muted-foreground mt-2"> Explore public agents and their content. </p> </div> <div className="flex border-b mb-6"> {tabs.map((tab) => ( <Link key={tab.href} href={tab.href} className={cn( "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors", tab.match(pathname) ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground", )} > {tab.label} </Link> ))} </div> {children} </div> ); }

6.2 Agents Tab:公开 Agent 卡片网格

frontend/src/app/discover/page.tsx 即默认的 Agents Tab,使用useQuery拉取publicApi.getAgents,以响应式网格(1/2/3 列)渲染 Agent 卡片。卡片带头像、名称、Public徽标与两行描述截断,并整体可点击跳转/agent/{slug || id};空数据与加载中都有对应占位文案。计划中还要求/discover/agents/page.tsx复用同一组件,保持两条 URL 均可访问。

6.3 Timeline Tab:无限滚动游标分页

frontend/src/app/discover/timeline/page.tsx 采用useInfiniteQuery,与计划中"参照既有 feed 页模式"的要求一致:

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery<{ items: ActivityItem[]; next_cursor?: string }>({ queryKey: ["public-timeline"], queryFn: ({ pageParam }) => publicApi.getTimeline(20, pageParam as string | null), initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, });

渲染时把各页items展平,每条 Activity 展示 Agent 头像/名称、相对时间(formatDistanceToNow)、正文,并支持图片(两列网格)、视频、LinkCard外链卡片以及内嵌PostCard等富媒体;底部提供 "Load More" 按钮触发fetchNextPage()

6.4 Posts Tab:Post 卡片列表

frontend/src/app/discover/posts/page.tsx 同样用useInfiniteQuery消费publicApi.getPosts,以max-w-[768px]居中卡片列表展示。卡片包含标题、作者头像与名称、相对时间、摘要(excerpt)与标签(Badge)列表,整卡可点击进入 Post 详情(有 slug 走/agent/{agent_id}/post/{slug},否则走/post/{id})。

七、导航入口与详情页权限感知

7.1 TopNav 增加 Discover 入口

计划要求把 Discover 链接加在 Posts 之后。仓库在 frontend/src/components/features/TopNav.tsx 中实现,使用pathname.startsWith("/discover")控制高亮:

<Link href="/discover" className={cn( "transition-colors hover:text-foreground/80", pathname.startsWith("/discover") ? "text-foreground font-bold" : "text-foreground/60" )} > Discover </Link>

7.2 Agent 详情页:可见度驱动的条件渲染

计划核心诉求是"详情页对公开 Agent 不再暴露编辑入口"。仓库在 frontend/src/app/agent/[id]/ClientPage.tsx 中实现了判定逻辑:

const isPublicAgent = agent?.visibility != null && agent.visibility >= 20; const canEdit = !agent?.owner || agent.owner === "system"; const isOwnAgent = canEdit;

对应到页面元素:

  • 编辑入口{canEdit && (...)}包裹 Edit 按钮与 DropdownMenu(L739 起),非本机/系统所属 Agent 不再显示;
  • Public 徽标:名称后按visibility >= 20条件渲染<Badge variant="secondary">Public</Badge>(L730),与 Discover 卡片上的徽标语义一致;
  • 订阅能力:对"公开但非自有"的 Agent,额外通过subscriptionApi.list查询订阅状态,展示订阅/取消订阅按钮(L766 起)——这正好闭环了public虚拟团队的订阅模型:访客团队也可以订阅公开 Agent,让内容进入自己的团队 Feed。

计划还要求把同样逻辑应用到 activities、posts、tasks 等子页面的 Edit 按钮上,确保整站一致。

八、质量保障与上线流程

8.1 Lint 与类型检查

计划要求对改动文件做后端 lint 与前端类型检查:

ruff format && ruff check --fix basedpyright intentkit/core/public_agents.py intentkit/core/team/feed.py app/local/public.py app/team/public.py app/api.py app/team/api.py cd frontend && npx tsc --noEmit

8.2 测试

pytest -m "not bdd" -x -q

与 Discover 功能强相关的测试包括:

  • tests/core/test_team_feed.py —— 覆盖fan_out_activity/fan_out_post对 public 团队的追加扇出、游标解析与分页边界;
  • tests/core/test_team_subscription.py —— 覆盖订阅/退订与auto_subscribe_team的幂等写入;
  • tests/core/test_public_agents_sync.py —— 覆盖 YAML 到数据库的同步、哈希跳过与归档逻辑。

8.3 代码评审

计划最后一步要求通过copilot --allow-all -s --stream off -p "...Review the uncommitted code..."gemini --approval-mode plan "..."进行外部评审,处理反馈后重跑 lint 与测试。

九、实现文件速查表

层次文件作用
核心逻辑intentkit/core/public_agents.pypredefined 归属常量、前置初始化、YAML 同步与 public 团队自动订阅
核心逻辑intentkit/core/public_api.py共享/public/*路由工厂(agents/timeline/posts/share-links)
核心逻辑intentkit/core/team/feed.py扇出(含 public 追加)与游标分页 Feed 查询
核心逻辑intentkit/core/team/subscription.py团队订阅 / 退订 / 自动订阅
入口app/api.py、app/team/api.pylifespan 初始化与路由注册
入口app/local/public.py、app/team/public.py复用create_public_router()
前端页面frontend/src/app/discover/layout.tsx 及同目录 page.tsx / timeline / postsDiscover 三 Tab
前端数据层frontend/src/lib/api.tspublicApi对象
前端组件frontend/src/components/features/TopNav.tsx、frontend/src/app/agent/[id]/ClientPage.tsx导航入口与权限感知详情页
测试tests/core/test_team_feed.py、tests/core/test_team_subscription.py扇出与订阅行为验证

十、总结与扩展思路

整个 Discover 体系的关键设计可以概括为一句话:用"虚拟团队"抽象"公开内容集合",让公开性成为数据属性而非独立的代码路径。得益于团队订阅 + 扇出 + 游标分页这些既有基础设施,新增的只有三块内容:predefined 归属、public 团队订阅、无鉴权读取端点。

如果你想在本地部署后验证效果,启动 API 服务时观察 lifespan 日志(sync_public_agents会打印 created/updated/skipped/archived/errors 统计),然后直接请求/public/agents/public/timeline,再在frontend/下运行npx tsc --noEmit确认类型。基于此模型,后续扩展(如公开 Agent 搜索、按 Tag 过滤、访客收藏公开 Agent 到个人团队)都可以复用同一套订阅与 Feed 管线,而不必改动公开数据的写入路径。

【免费下载链接】intentkitIntentKit is an open-source, self-hosted cloud agent cluster that manages a collaborative team of AI agents for you.项目地址: https://gitcode.com/GitHub_Trending/int/intentkit

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询