- 人工智能
- AI Agent
- Agent 框架
- 后端
- 工具调用
【免费下载链接】eve
The Open Framework for Building Agents
eve 官方模板eve-chat-template展示了一个非典型的聊天应用架构:浏览器不直接对接第三方模型接口,而是通过同源的/eve/v1/*路由与 eve agent 通信,把 eve 流式事件与会话游标持久化在浏览器存储或 Postgres 中,并用静态 Next.js 外壳保证侧边栏与输入框在路由切换时不抖动。读完本文,你将理解该模板的运行时模型(本地 app chat 与 eve session 双状态)、Shell-First 渲染策略、逐事件持久化与快照合并机制、刷新断点续传、连接授权流程,以及认证、就绪检查与限流的实现细节。
模板总体结构:四个层次
该模板位于 apps/templates/eve-chat-template,由四个主要层次构成:
- eve agent 层:
agent/,定义 agent、channels、connections 与 memory; - Next.js 外壳与页面层:
apps/web/app/,负责静态 Shell、根路由与会话路由; - 聊天 UI 组件层:
apps/web/components/chat/,包含 composer、message、sidebar 等; - 持久化、认证、就绪检查与限流层:
apps/web/lib/。
关键文件与职责(官方文档 how-the-chatbot-works.md 中的文件表):
| 文件 | 职责 |
|---|---|
| agent/agent.ts | 定义 eve agent 与模型 |
| agent/channels/eve.ts | 配置 eve web channel 与 auth 适配器 |
| agent/channels/slack.ts | 配置 Slack channel 与 Vercel Connect 凭据 |
| agent/connections/notion.ts | 通过 Vercel Connect 定义 Notion MCP 连接 |
| agent/connections/linear.ts | 通过 Vercel Connect 定义 Linear MCP 连接 |
| agent/connections/sentry.ts | 通过 Vercel Connect 定义 Sentry MCP 连接 |
| agent/instructions.md | 定义 agent 行为与长期记忆规则 |
| agent/memory/profile.ts | 定义按 principal 隔离的长期记忆槽位 |
| vercel.ts | 在 Vercel 上组合 peer Next.js 与 eve 服务 |
| apps/web/app/(chat)/layout.tsx/layout.tsx) | 立即渲染静态聊天外壳,再通过 Suspense 流式加载 viewer/setup/侧边栏数据 |
| apps/web/app/(chat)/page.tsx/page.tsx) | 根聊天页:创建新 chat 行并导航到/chat/[id] |
| apps/web/app/(chat)/chat/[id]/page.tsx | 会话路由:把活跃聊天数据流式传给客户端 Shell |
| apps/web/app/_components/agent-chat-shell.tsx | 客户端 Shell:侧边栏状态、历史分页、登录弹窗与共享聊天上下文 |
| apps/web/app/_components/home-chat-page.tsx | 根页 composer 与 logo 体验 |
| apps/web/app/_components/session-chat-page.tsx | 会话页 composer、活跃聊天同步与 controller 接线 |
| apps/web/app/_components/agent-chat.tsx | eve 客户端桥:发送、流式、持久化、恢复、待授权与展示状态 |
| apps/web/components/chat/composer.tsx | 受控聊天输入框 |
| apps/web/components/chat/message.tsx | 渲染 eve 消息、markdown、reasoning、工具与输入请求 |
| apps/web/components/chat/sidebar.tsx | 分页聊天历史侧边栏 |
| apps/web/components/chat/integrations-menu.tsx | 每轮连接的开关 UI |
| apps/web/app/actions/chat.ts | 服务器 action:创建 chat、持久化、pending 状态、跳过授权与限流检查 |
| apps/web/app/api/chats/route.ts | 分页聊天历史端点 |
| apps/web/lib/chat/local-store.ts | starter 模式下的版本化浏览器存储与 eve session 游标 |
| apps/web/lib/chat/persistence-client.ts | 把客户端持久化调用路由到浏览器或数据库存储 |
| apps/web/lib/db/schema.ts | Better Auth、chats、chat events 的 Drizzle 表 |
| apps/web/lib/db/queries.ts | 列表、加载、事件写入、快照保存与删除查询 |
| apps/web/lib/setup.ts | 选择 starter / 本地开发 / 生产模式并校验就绪状态 |
| apps/web/lib/auth.ts | Better Auth 配置,含 Sign in with Vercel |
| apps/web/lib/password-auth.ts | 共享密码校验与无状态签名会话 cookie |
| apps/web/lib/eve-auth.ts | 把密码或 Better Auth 会话转换为 eve channel principal |
| apps/web/lib/rate-limit.ts | 基于 Upstash Redis 的固定窗口限流 |
agent 层非常精简,例如 agent/agent.ts 仅用defineAgent({ model: "anthropic/claude-sonnet-5" })声明模型;vercel.ts 通过withEve把apps/web作为 peer 的 Next.js 服务与 eve 服务组合部署。
运行时模型:两个独立的概念
模板中必须区分两个相关但分离的状态:
- 本地 app chat:一条浏览器存储记录或
chat表中的一行; - eve session:eve 的
ClientSessionState与远端会话流。
app chat 给用户稳定的 URL(如/chat/abc)、侧边栏标题和持久化的事件历史;eve session 则是 eve 用来继续对话、等待授权、恢复流、接收后续输入的持久会话状态。生产模式下,chat 行上保存:
chat.eveSession: ClientSessionState | null流式事件按序保存为:
chat_event.eventIndex: number chat_event.event: MessageStreamEventStarter 模式把同样的两个值放在版本化的 localStorage 记录中(存储键为eve-chat-template:chats,最多保留 50 条聊天,见 local-store.ts)。
这两个索引刻意分离,不可互换:eveSession.streamIndex告诉 eve 从远端会话流的哪个位置续读;本地的chat_event.eventIndex告诉 Postgres 如何为渲染排序事件日志。
在 schema.ts 中可以确认:
export const chat = pgTable("chat", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }), title: text("title").notNull().default("New chat"), eveSession: jsonb("eve_session").$type<ClientSessionState | null>(), pendingUserMessage: text("pending_user_message"), pendingUserMessageCreatedAt: timestamp("pending_user_message_created_at"), ... }); export const chatEvent = pgTable("chat_event", { id: text("id").primaryKey(), chatId: text("chat_id").notNull().references(() => chat.id, { onDelete: "cascade" }), eventIndex: integer("event_index").notNull(), event: jsonb("event").$type<MessageStreamEvent>().notNull(), ... }, (table) => [ index("idx_chat_event_chat").on(table.chatId), uniqueIndex("idx_chat_event_chat_index").on(table.chatId, table.eventIndex), ]);chat_event上的(chatId, eventIndex)唯一索引是后面“逐事件写入 + 冲突即覆盖”策略的基础。
长期记忆是第三条独立线
长期记忆既不属于 app chat 持久化,也不属于 eve session 历史。agent/memory/profile.ts 把一个有界、由模型自行维护的文档按认证后的 eve principal 隔离:
export default defineMemory({ description: "Remember stable facts and preferences about the caller.", provider: fileMemory(), scope(context) { // Do not expose memory tools until the deployed app has durable storage. if (process.env.VERCEL && !hasMemoryBlobStorage()) { return null; } return byPrincipal(context); }, });注意源码注释说明:在 Vercel 上,只有配置了EVE_MEMORY_BLOB_STORE_ID/EVE_MEMORY_BLOB_READ_WRITE_TOKEN时才启用记忆槽,通用的BLOB_*变量被刻意忽略,避免记忆静默占用应用自己的 Blob 存储;本地开发时 eve 使用进程内记忆。agent/instructions.md 则规定记忆只保存用户提供的稳定事实与偏好,禁止保存密码、token、支付数据、密钥、一次性验证码等。
渲染策略:Shell First
模板使用开启 Cache Components 的 Next.js App Router。核心思路:聊天外壳立即渲染,动态数据在隐藏的 Suspense 边界后面流式加载。
外壳先行
apps/web/app/(chat)/layout.tsx/layout.tsx#L8-L45) 立即渲染:
<AgentChatShell initialChats={[]} initialNextCursor={null} setupStatus={getInitialSetupStatus()} viewer={null} > {children} <div className="hidden" aria-hidden> <Suspense fallback={null}> <ResolvedChatBootstrap /> </Suspense> </div> </AgentChatShell>外壳无需等待数据库或认证会话,就能画出侧边栏框架、右上角认证按钮和路由主体。随后ResolvedChatBootstrap(源码中为隐藏 div 内的 Suspense 子树)获取:
- 来自
getSetupStatus()的就绪状态; - 来自
getServerViewer(setupStatus)的密码 / 本地开发 / Better Auth viewer; - 生产模式下数据库侧聊天历史的第一页(见 layout.tsx/layout.tsx#L28-L45) 中
listChatsPageByUser(viewer.id)调用)。
Starter 模式则在 viewer 解析后由客户端从浏览器存储读取侧边栏历史。
这些数据经AgentChatBootstrapSync派发一个浏览器事件(事件名eve-chat:bootstrap-sync,定义于 agent-chat-events.ts),AgentChatShell监听该事件,把真实的 viewer、setup 状态和侧边栏历史合并进客户端状态。这种模式避免了阻塞式路由延迟整页加载。
会话路由数据
apps/web/app/(chat)/chat/[id]/page.tsx 采用同样的思路,先渲染会话页外壳:
<SessionChatPage chatId={chatId}> <Suspense fallback={null}> <ExistingChat chatId={chatId} /> </Suspense> </SessionChatPage>ExistingChat在生产模式从 Postgres 加载活跃聊天,并通过AgentChatRouteSync发出eve-chat:route-sync事件;SessionChatPage监听该同步事件后,把加载到的ActiveChat传入AgentChatSession。顶栏、侧边栏和 composer 因此可以保持稳定,只有聊天主体在等待数据。SessionChatPage同时处理“临时 chat id”:对临时 id 会走 provisional-chat 逻辑,先落库创建真实行再router.replace到正式 URL(见 session-chat-page.tsx)。
侧边栏状态与分页
侧边栏由AgentChatShell持有状态:
- 桌面侧边栏开/关
- 移动端抽屉开/关
- 当前历史页
- 下一页游标
- 活跃 chat id
- viewer
- setup 状态
- 登录弹窗状态
- 已启用的连接开关
桌面开/关状态持久化在 cookie 中:
SIDEBAR_COOKIE_NAME = "eve-chat-sidebar";(见 sidebar-state.ts,值为open/closed。)SidebarCookieScript在 React 水合前写入早期文档提示,避免侧边栏闪现一帧再收起的抖动。
聊天历史按游标分页。首页由ResolvedChatBootstrap加载,后续页通过:
GET /api/chats?cursor=<cursor>获取。queries.ts 中的listChatsPageByUser按updatedAt desc, id desc排序,一次取CHAT_HISTORY_PAGE_SIZE + 1行(页大小当前为 20),多取的一行用于判断是否存在下一页:
const CHAT_HISTORY_PAGE_SIZE = 20; function encodeChatCursor(updatedAt: Date, id: string) { return `${updatedAt.toISOString()}::${id}`; }游标编码为<updatedAt ISO string>::<chat id>,解码后再构造“updatedAt小于游标,或相等时id小于游标 id”的复合条件,保证同一时间戳的多条记录不会重复或漏读。
侧边栏在列表底部使用 IntersectionObserver 哨兵;可见时AgentChatShell.loadMoreChats()拉取下一页,并且只追加尚不存在的聊天。创建、发送或删除聊天时,侧边栏通过乐观更新同步:
touchChat(chat)removeChat(chatId)updateChatTitle(chatId, title)
根页面流程:先建 chat,再发消息
根路由由 HomeChatPage 渲染,提供:
- eve logo
- 居中的 composer
- 页脚链接
- 来自
AgentChatShell的共享侧边栏与顶部认证控件
用户提交第一条消息时:
- 输入被 trim;
- 若 setup 未完成,toast 说明缺什么;
- 若未登录,
requestSignIn(message)打开登录弹窗并把草稿存入sessionStorage(键eve-chat-draft,登录后由 session-chat-page.tsx 恢复); - 已登录时,
createChatAction({ pendingUserMessage: message })创建 chat 行; - 标题由
createFallbackTitle从首条用户消息派生; - 新 chat 经
touchChat(created)插入侧边栏; - 应用导航到
/chat/<id>。
关键点:根页面不直接调用 eve。它先创建 app chat,让会话路由在 eve client 就绪后消费 pending 消息。这保证“聊天 URL 先于首个响应流存在”。
会话页面流程与 AgentChatController
SessionChatPage 持有:
- 加载到的
ActiveChat - 受控 composer 草稿
- 指向
AgentChatController的 ref - 路由的 loading/error UI
- 从数据库加载的 pending user message
AgentChatController由AgentChatSession提供,类型定义在 agent-chat.tsx:
type AgentChatController = { readonly reset: () => void; readonly sendMessage: (text: string, draftHandlers: DraftHandlers) => Promise<void>; readonly stop: () => void; };会话页 composer 调用controller.sendMessage(text, handlers)。
若路由加载了pendingUserMessage,SessionChatPage会在满足条件时自动消费一次:
- 聊天已加载;
- controller 已就绪;
- controller 不忙;
- controller 未被 setup 或待授权禁用。
这正是根页首条消息在导航后真正发给 eve 的路径(消费逻辑见 session-chat-page.tsx,由pendingConsumedRef保证只消费一次)。
eve 客户端桥:发送、流式与持久化
聊天核心逻辑集中在 apps/web/app/_components/agent-chat.tsx。其核心是useEveAgent钩子(来自eve/react),把 eve 流式事件归约成可渲染的聊天消息,模板在其外包裹持久化与恢复逻辑:
const agent = useEveAgent({ initialEvents: activeChat?.events ?? [], initialSession: activeChat?.session, onEvent: persistStreamEvent, onSessionChange(session) { persistedSessionRef.current = attachClientSession(session); if (session) void persistSessionState(session); }, onFinish: (snapshot) => { void persistSnapshot(snapshot); }, });(见 agent-chat.tsx)
持久化的浏览器会话
浏览器侧会话对象暴露send(message, options)、respond(inputResponses, options)、stream(options)、setState(nextSession)与state。源码中可以看到恢复路径通过attachClientSession基于activeChat.session重新挂接:
function attachClientSession(session: ClientSessionState | undefined): ClientSession | null { if (!session) return null; return new Client({ host: "" }).sessions.attach(session.sessionId, { streamIndex: session.streamIndex, }); }(见 agent-chat.tsx)
当调用send/respond时,流程为:
- 构造对应的消息或输入响应 payload;
- POST 到
/eve/v1/session创建新 eve session,或 POST/eve/v1/session/:sessionId继续会话; - 读取
x-eve-session-id响应头; - 更新本地
ClientSessionState; - 把该会话状态保存到 chat 行;
- 返回浏览器兼容的 message 响应,其 async iterator 读取
/eve/v1/session/:sessionId/stream。
应用从不直接对接第三方模型端点,只与 eve 的同源 session API 通信,由 vercel.ts 把/eve/v1/*路由转发给 eve 服务。
流式读取与回合结算
流读取器打开:
GET /eve/v1/session/:sessionId/stream?startIndex=<n>逐行读取 newline-delimited JSON 事件。每个事件:
- 推入当前流缓冲;
- 递增下一个远端流索引;
- 交给
useEveAgent; - 若事件结算了回合则停止。
回合何时“结算”由 lib/chat/events.ts 判定:
export function isChatTurnSettledEvent(event: MessageStreamEvent) { return ( event.type === "authorization.required" || event.type === "session.completed" || event.type === "session.failed" || event.type === "session.waiting" ); }注意源码中除了文档提到的session.completed/session.failed/session.waiting之外,还把authorization.required也视为回合结算事件——这与“授权挂起时禁用普通输入”的语义一致。
流读取器对瞬时的开流失败(状态码 404、409、425、500、502、503、504)进行若干次重试,并且通过“从未读索引重连”容忍流中断。async iterator 退出时会调用onFinalize(events),基于实际观察到的事件推进浏览器会话状态。
发送消息:一条消息的生命周期
后续消息由AgentChatSession.sendMessage发送,预期顺序:
- 忽略空输入;
- 会话已忙则忽略;
- 若 eve 正在等待连接授权,拒绝普通文本并给出友好错误;
- 尽可能立即渲染乐观用户气泡;
- 执行
prepareSend(message); - 用
pendingUserMessage标记 chat 行; - 调用
agent.send(message, { clientContext }); - 由
useEveAgent、onEvent与onFinish完成流式与持久化。
在 agent-chat.tsx 中可以看到该顺序的完整实现:先trim并检查isTurnBlocked,再做 8000 字符长度检查,若isWaitingForAuthorization则恢复草稿并报错;随后showLocalPendingMessage()渲染本地乐观气泡,prepareSend通过后markClientChatPendingMessage落库,最后await agent.send(message, { clientContext: createConnectionClientContext(...) })。
prepareSend(见 agent-chat.tsx)完成必须在调用 eve 之前发生的事:
- 校验 setup 就绪;
- 无 viewer 时请求登录;
- 检查 Redis 限流;
- 尚无 chat 行时创建,并
router.replace到新 URL。
乐观用户气泡与持久化的 eve 事件相互独立:它由createPendingUserMessage生成;当真实的 eve 用户消息出现在归约后的消息列表里时,hasLatestUserMessage清掉本地气泡。这样 UI 保持即时感,同时让 eve 产生权威事件日志。
持久化模型:逐事件 + 快照两个时刻
应用只在两个时刻持久化:每个流式事件到达时;最终快照可用时。
逐事件持久化
persistStreamEvent(event)把每个事件写入:
appendChatEventAction({ chatId, event, eventIndex });eventIndex是当前 chat 的本地单调计数器。数据库在(chatId, eventIndex)上有唯一索引,写入使用冲突覆盖,重试同一槽位只会替换内容。对应实现见 queries.ts:
await db .insert(chatEvent) .values({ chatId, event, eventIndex, id: randomUUID() }) .onConflictDoUpdate({ set: { event }, target: [chatEvent.chatId, chatEvent.eventIndex], });浏览器模式下的对应实现在 local-store.ts,同样是“索引小于长度则覆盖,否则追加”。
逐事件持久化正是“刷新/恢复”能力的来源:即使浏览器在回合中途关闭,重启后也能从已保存的事件与会话状态重建。
快照持久化
useEveAgent完成回合后调用onFinish(snapshot),快照包含:
- eve React 侧已知的完整归约事件列表;
- 当前 eve
ClientSessionState。
模板随后调用saveChatSnapshotAction({ chatId, events, session })。saveChatSnapshot(见 queries.ts)逐索引 upsert 每个事件、删除超出新快照长度的事件行、保存 eve 会话状态、清除pendingUserMessage并更新聊天时间戳:
await db .delete(chatEvent) .where(and(eq(chatEvent.chatId, chatId), gte(chatEvent.eventIndex, events.length)));为什么需要快照合并
存在一些不来自远端流的本地事件,尤其是本地合成的授权跳过事件;也存在 hook 返回的快照起点晚于应用已从 Postgres 加载的初始事件的情况。为避免丢失或重复历史,persistSnapshot依次应用(调用点见 agent-chat.tsx):
preserveKnownInitialEvents(snapshot.events, knownInitialEvents):前缀感知——若快照与已知事件日志共享前缀,保留正确的续接而不是盲目拼接;mergeLocalEvents(snapshotEvents, localEvents):并入本地合成事件;advanceSessionWithLocalEvents(snapshot.session, localEvents):用本地事件推进会话状态。
事件比较使用areEqualJsonValues而非JSON.stringify,避免 JSON 键序差异造成误判。
Pending 消息恢复
pendingUserMessage用于两种中断:首条发送被中断、后续发送被中断。流程:
- 发送前调用
markChatPendingMessageAction,chat 行写入pendingUserMessage与pendingUserMessageCreatedAt; - 若回合完成前页面刷新,
getChatForUser会返回 pending 消息; SessionChatPage在 controller 就绪后消费它;- 结算事件持久化后,
getChatForUser隐藏过期的 pending 消息; saveChatSnapshot与skipChatAuthorization清除 pending 状态。
getChatForUser 的判定值得注意:它检查“是否存在不早于pendingUserMessageCreatedAt的结算事件”,若存在则视该回合已完成,返回pendingUserMessage: null——这就是“stale pending 自动隐藏”的实现。
刷新与恢复(Resume)
用户在回合进行中刷新页面时,应用可从已保存的 eve session 续读。AgentChatSession 的恢复 effect 检查:
- 存在 viewer;
- 存在
pendingUserMessage或存在未结算的开放回合(hasOpenChatTurn); - 加载的 chat 有
activeChat.session.sessionId; - 恢复尚未开始(
resumeStartedRef); useEveAgent已 ready。
随后从activeChat.session创建临时持久化会话并流式读取:
for await (const event of session.stream({ signal, startIndex })) { ... }其中startIndex = existingEvents.length(本地事件日志长度),每个恢复事件被追加到本地 resume overlay 状态、写入chat_event,并最终包含进快照;收到结算事件后保存完整快照并清除 pending 状态。若流在中途断开而没有结算事件,则提示 "Stream disconnected before the response completed."。
resume overlay 存在的原因:主useEveAgent实例是从已加载事件初始化的,新的恢复事件先叠在上面,直到最终快照追上。
连接的授权流程
连接授权不从助手文本里做字符串解析,eve 发出结构化授权事件。需要授权时,eve 发出authorization.required;getPendingAuthorizations(displayEvents)扫描当前事件日志——为每个authorization.required增加一个待授权项,出现匹配的authorization.completed时移除。每个待授权项在 UI 中渲染为ConnectionAuthorizationPrompt,可显示:
- 连接显示名
- 授权描述
- eve 提供的 Connect 链接
- Skip 按钮
授权挂起期间该会话的普通聊天输入被禁用——这是有意的,因为 eve 正在等待结构化结果,而非普通用户消息。
Connect
点击 Connect 打开 eve 提供的授权 URL;用户完成授权后,eve 收到回调并继续回合,浏览器流随后收到同一个 eve session 的后续事件。
Skip
Skip 是不连接服务就结束授权等待的本地方式。handleSkipAuthorization(见 agent-chat.tsx):
- 停止当前 agent 流并
agent.reset(); - 创建 outcome 为
declined的authorization.completed事件; - 创建本地
session.waiting事件; - 把这些事件应用到持久化浏览器会话;
- 经
skipChatAuthorizationAction落库(queries.ts 中同样以冲突覆盖方式写入事件并清空pendingUserMessage); - 清除 pending user message 状态。
即“skip”结束当前回合,下一条普通消息带着更新后的上下文开启新回合。失败时源码还会回滚本地事件(把未持久化成功的事件从localEventsRef中剔除并还原会话引用)。
连接菜单:Notion / Linear / Sentry
composer 页脚中的ComposerFooterControls渲染IntegrationsMenu。当前用户可见的连接开关是 Notion,状态保存在AgentChatShell中:
enabledConnections = { notion: true };发送消息时,应用向 eve 传递自然语言的clientContext:
createConnectionClientContext(enabledConnections);启用中的连接会告知 eve 本回合用户开启了哪些(Notion、Linear、Sentry 之一),被禁用的连接会被明确点名,避免模型在用户未启用前擅自使用。这个开关不会创建或撤销 Vercel Connect connector,只控制每轮的 agent 行为。
真正的 MCP connector 配置在agent/connections/*.ts,例如 notion.ts:
const notionConnector = process.env.NOTION_CONNECTOR ?? "notion"; export default defineMcpClientConnection({ url: "https://mcp.notion.com/mcp", description: "Notion workspace: search and edit pages and databases.", auth: connect(notionConnector), });生产环境应把NOTION_CONNECTOR、LINEAR_CONNECTOR、SENTRY_CONNECTOR设置为返回的 Vercel Connect connector UID;本地开发时,用--name notion、--name linear、--name sentry创建的 connector 与回退名匹配。setup.ts 中的CONNECTION_ENV_KEYS正是这三个变量。
认证:三种模式的统一 principal 边界
Starter 模式使用共享部署密码:登录路由校验EVE_CHAT_PASSWORD,签发一个签名、HTTP-only、same-site 的 cookie,不依赖数据库;session.ts 与 eve-auth.ts 对 Next.js UI 与 eve 路由边界校验同一个 cookie。
生产模式使用 Better Auth + Sign in with Vercel:
- auth-url.ts 按此顺序解析应用基址:
BETTER_AUTH_URL→VERCEL_PROJECT_PRODUCTION_URL→VERCEL_URL→http://localhost:3000; - auth.ts 配置 Better Auth:Drizzle adapter、加密 OAuth token、Vercel social provider、必需 scope
openid/email/profile、错误页/auth/error。
apps/web/lib/session.ts为服务端组件返回安全的Viewer对象:共享 starter viewer、本地开发 viewer 或 Vercel 用户,取决于所选模式。
eve-auth.ts 再把选定的应用会话适配为 eve channel principal。生产模式返回:
{ principalType: "user", principalId: session.user.id, subject: session.user.email, attributes: { email: session.user.email, name: session.user.name }, }而 agent/channels/eve.ts 允许的 auth 适配器:
export default eveChannel({ auth: [betterAuthEveAuth, passwordEveAuth, vercelOidc(), localDev()], uploadPolicy: "disabled", });同一个 channel 因此能在本地、已认证的浏览器会话以及 Vercel OIDC 上下文下工作。passwordEveAuth在密码模式下返回固定 principal(principalId: "eve-chat-user"),这也是 starter 模式所有用户共享一个会话空间的原因。
Setup 就绪检查
apps/web/lib/setup.ts 产出SetupStatus对象:
type SetupStatus = { appReady: boolean; authMode: "local-dev" | "password" | "unconfigured" | "vercel"; authReady: boolean; databaseConfigured: boolean; databaseReady: boolean; databaseSchemaReady: boolean; missing: readonly string[]; rateLimitReady: boolean; storageMode: "browser" | "database"; };源码中的选择逻辑(setup.ts):
- 生产(数据库)模式:同时满足
DATABASE_URL存在、Better Auth 三件套环境变量(BETTER_AUTH_SECRET、NEXT_PUBLIC_VERCEL_APP_CLIENT_ID、VERCEL_APP_CLIENT_SECRET)、Upstash 任一命名组(UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN或KV_REST_API_URL/KV_REST_API_TOKEN),此时storageMode为database,并在表就绪前给出missing: ["database migrations"]; - Starter(密码)模式:
EVE_CHAT_PASSWORD非空即就绪(建议 16 字符以上的强密码),storageMode为browser; - 本地开发:
NODE_ENV === "development"且VERCEL !== "1"时零配置就绪; - 否则为
unconfigured。
数据库模式还会检查这些 Postgres 表是否存在:account、chat、chat_event、session、user、verification——其中四个是 Better Auth 的标准表,两个是本模板的聊天表,与 schema.ts 的六个pgTable一一对应。
UI 在三个位置使用 setup 状态:根 composer 的禁用状态、会话 composer 的禁用状态、侧边栏的认证/登录控件。被禁用的 composer 应始终通过 tooltip 给出原因(见后文 composer 小节)。
限流:Upstash 固定窗口
生产模式限流在 apps/web/lib/rate-limit.ts,基于 Upstash Redis,兼容两套环境变量:
- 现用:
UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN - 旧版 Vercel KV:
KV_REST_API_URL/KV_REST_API_TOKEN
enforceRateLimit使用固定窗口键:
rate:<prefix>:<viewer id>:<window id>窗口 id 为Math.floor(now / windowSeconds),首次incr到 1 时设置过期。当前限制(定义在 actions/chat.ts 与 lib/chat/limits.ts):
chat:create:每小时 25 次chat:send:每小时 25 次- 消息长度:8000 字符(
MAX_CHAT_MESSAGE_CHARS)
创建与发送两个限额相互独立,使“建 chat”与“发消息”可以分别守护。长度限制在 composer 中强制执行,并在服务器 action(创建 chat、保存 pending 消息)中再次校验,形成双层防线。
消息渲染
useEveAgent把流式事件归约成消息数据,模板通过 message.tsx 渲染。支持:
- 用户气泡(右对齐、紧凑圆角)
- Streamdown 渲染的助手 markdown(左对齐全宽)
- reasoning 部件
- 动态工具部件
- 工具输入/响应 UI
- 乐观 pending 用户消息
- 独立的 "Thinking..." 存在感行
工具调用会被分组,一串动态工具部件显示为一行紧凑条目;只有存在有用细节或输入控件时工具行才可展开。reasoning 渲染为可折叠块,流式期间标签显示 "Thinking..." 并使用 shimmer 文本。独立ThinkingMessage在会话忙碌但助手文本还不足以体现进度、或响应仍在进行时出现,并以淡出而非突兀消失的方式退场。
Composer 行为
composer.tsx 完全由页面控制:
<ChatComposer disabled={composerDisabled} disabledReason={composerDisabledReason} footerStart={<ComposerFooterControls setupStatus={setupStatus} />} isBusy={controllerStatus.isBusy} onChange={setDraft} onStop={handleComposerStop} onSubmit={handleComposerSubmit} value={draft} />受控属性:value、onChange、onSubmit、disabled、disabledReason、isBusy、isPreparing。行为特性:
- 启用时自动聚焦(下一帧
focus({ preventScroll: true })); - Enter 提交、Shift+Enter 换行(见 composer.tsx);
- 请求在途时禁用输入;
- eve 忙碌时显示停止按钮(方形停止图标);
- 根页创建 chat 时显示 spinner;
- 禁用状态包裹 tooltip 说明原因。
根页与会话页渲染同一个 composer,但放置位置不同:根页居中并配 eve logo;会话页固定在聊天路由底部。这种分离避免根路由布局短暂看起来像会话路由,反之亦然。
错误处理
用户可见的请求失败渲染为ErrorToast,而非内联布局块,避免错误把 composer 或聊天主体顶开。常见 setup 错误:
- starter 模式下
EVE_CHAT_PASSWORD缺失或过短; - Neon、Better Auth/Vercel OAuth 或 Upstash 配置不完整;
- 生产迁移未运行;
- Vercel OAuth 应用缺少
emailscope。
常见流式错误:
- 结算事件到达前流断开;
- 恢复时 eve session 缺失;
- 授权回调未处于 pending 状态;
- 触发限流。
应用的一般偏好:可恢复的请求失败用 toast;setup 阻塞用带 tooltip 的禁用 composer;连接阻塞用结构化授权卡片。
事件日志不变量
这些不变量很重要,也是排查问题的第一现场:
chat_event.eventIndex必须每 chat 唯一;- 完成的快照应按索引覆盖陈旧事件行;
- 超出最终快照长度的行应被删除;
pendingUserMessage应在回合结算后清除;- 本地授权跳过事件必须并入最终快照;
- 待授权应禁用该会话的普通文本输入;
- 路由切换应重置 per-chat 引用(事件索引、本地 pending 消息等,见 agent-chat.tsx 的 reset effect);
- 侧边栏历史可以乐观更新,但事件日志始终是聊天内容的权威来源。
遇到“消息替换了旧消息”或“响应看起来完成后又更新了”时,优先检查:
- 两个事件是否写到了同一个本地
eventIndex; preserveKnownInitialEvents是否复制或丢掉了前缀;- 结算事件后
pendingUserMessage是否仍留在 chat 行上; - eve 流是否以
session.waiting结束,而 UI 尚未预期到; - 本地乐观消息是否在真实用户事件出现前被清除。
扩展指南
添加新工具
- 在
agent/tools/下创建文件(参考现有 agent/tools/get_weather.ts); - 导出
defineTool(...); - 按 eve 的 agent 约定导入/注册;
- 更新 agent/instructions.md,让 agent 知道何时使用它;
- 若 UI 需渲染特殊工具状态,更新 message.tsx。
保持工具输出为结构化 JSON:当工具部件包含可预测的 JSON 而非纯文本时,消息渲染器能做出更好的 UI 决策。
添加新连接
为另一个 Vercel Connect 支撑的 MCP 连接:
- 添加
agent/connections/<name>.ts; - 使用
defineMcpClientConnection; - 使用
connect(process.env.<ENV_NAME> ?? "<local-name>"); - 补充部署/文档中的 connector 供给说明;
- 扩展 chat-shell-context.tsx 中的
EnabledConnections; - 在
IntegrationsMenu添加开关; - 更新
createConnectionClientContext,让 eve 收到每轮意图; - 验证
authorization.required事件正确渲染。
不要通过解析助手文本来检测授权需求,一律依赖authorization.required与authorization.completed事件。
添加新 channel
模板通过 agent/channels/eve.ts 暴露 web 聊天 channel,通过 agent/channels/slack.ts 暴露 Slack channel,并在 vercel.ts 中组合 peer Next.js 服务。添加新 channel 时保持这种分离:
- channel 专属的 webhook/传输代码放在
agent/channels/; - web 聊天 UI 状态放在
apps/web/app/_components/与apps/web/components/chat/; - 跨 channel 的 agent 行为放在
agent/instructions.md、tools 与 connections 中。
web 聊天持久化代码是有意绑定浏览器体验的;在未核对其消息与会话语义前,不要把它当作通用状态适配器复用到其他 channel。
开发检查清单
修改聊天流程时,验证:
- 根页渲染无布局偏移;
- 未登录用户可以输入,随后被提示登录;
- 首条消息创建 chat,导航后立即出现;
- 后续消息乐观出现;
- 助手文本流式输出,不替换旧消息;
- 响应中途刷新能恢复,或以清晰错误结束;
- 侧边栏历史可见,活跃行状态正确;
- 侧边栏分页仍能加载更早的聊天;
- 待授权只禁用受影响的会话;
- Connect 与 Skip 都让对话回到可用状态;
- setup 阻塞显示 tooltip 或可操作的登录/setup 页面;
pnpm typecheck与pnpm build通过。
心智模型:一个小型持久化聊天运行时
把该应用看作一个小型 durable chat runtime,核心数据流如下:
浏览器随时可以被中断:Postgres(或浏览器存储)保存了足以重建 UI、并从 eve 流游标继续的状态;静态外壳让导航保持流畅;最终快照让事件日志保持权威。这就是该模板的核心设计。
- 人工智能
- AI Agent
- Agent 框架
- 后端
- 工具调用
【免费下载链接】eve
The Open Framework for Building Agents
相关推荐
eve Chat Template 深度指南:三级部署模式、持久化会话与生产级多用户聊天应用实战
eve Chat Template 深度指南:三级部署模式、持久化会话与生产级多用户聊天应用实战 eve Chat Template 是 eve(The Ope
人工智能AI AgentAgent 框架后端工具调用eve chat 模板开发指南:守住 Eve 协议与持久会话的不变量(SKILL 深度解读)
eve chat 模板开发指南:守住 Eve 协议与持久会话的不变量(SKILL 深度解读) 本篇基于 apps/templates/eve chat temp
人工智能AI AgentAgent 框架后端工具调用Upsonic 会话与运行持久化深度解析:AgentSession、RunData 与存储层的纯数据模型设计
Upsonic 会话与运行持久化深度解析:AgentSession、RunData 与存储层的纯数据模型设计 本文基于 gpt computer assista
人工智能大模型AI AgentAgent 框架自主智能体工具调用RAGAgent 记忆Agent 编排
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考