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,内容流向哪里
在实现之前,先理解三个关键概念:
可见度(Visibility)等级:由 intentkit/models/agent/core.py 中的
AgentVisibility枚举定义,数值越大可见范围越广:PRIVATE = 0:仅所有者可见;TEAM = 10:团队成员可见;PUBLIC = 20:所有人可见。
Discover 页面的数据门槛就是
visibility >= PUBLIC。"predefined" 归属:公开 Agent 不再属于任何真实用户或团队,而是统一挂在
owner = "predefined"、team_id = "predefined"下。这样既保证公开 Agent 由系统侧托管、不会被普通用户误编辑,又复用了现成的 Agent 所有权字段做权限判定。"public" 虚拟团队:团队订阅与 Feed 扇出是 IntentKit 既有能力(
intentkit/core/team/feed.py、intentkit/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 = OWNER、db_agent.team_id = TEAM_ID,并把visibility置为AgentVisibility.PUBLIC(即 20)。
2.2 启动前置初始化:ensure_public_agent_prerequisites
计划要求在app/api.py与app/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.yaml、trend-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.py与app/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/agents | GET | 列出所有公开 Agent(visibility >= PUBLIC且未归档),按created_at倒序 | 无 |
/public/timeline | GET | 公开 Activity 时间线 | limit(默认 20,范围 1–100)、cursor(游标字符串,可空) |
/public/posts | GET | 公开 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}/pdf | GET | 下载分享 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_activity与fan_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→ 以public为team_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_dt或created_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 --noEmit8.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.py | predefined 归属常量、前置初始化、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.py | lifespan 初始化与路由注册 |
| 入口 | app/local/public.py、app/team/public.py | 复用create_public_router() |
| 前端页面 | frontend/src/app/discover/layout.tsx 及同目录 page.tsx / timeline / posts | Discover 三 Tab |
| 前端数据层 | frontend/src/lib/api.ts | publicApi对象 |
| 前端组件 | 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),仅供参考