☰
eve Chat 模板深度解析:同源流式会话、双层持久化与可恢复聊天运行时的完整设计
2026/10/14 22:48:54 网站建设 项目流程
  • 人工智能
  • AI Agent
  • Agent 框架
  • 后端
  • 工具调用

【免费下载链接】eve

The Open Framework for Building Agents

项目地址:https://gitcode.com/gh_mirrors/eve28/eve
点击查看免费下载

eve 官方模板eve-chat-template展示了一个非典型的聊天应用架构:浏览器不直接对接第三方模型接口,而是通过同源的/eve/v1/*路由与 eve agent 通信,把 eve 流式事件与会话游标持久化在浏览器存储或 Postgres 中,并用静态 Next.js 外壳保证侧边栏与输入框在路由切换时不抖动。读完本文,你将理解该模板的运行时模型(本地 app chat 与 eve session 双状态)、Shell-First 渲染策略、逐事件持久化与快照合并机制、刷新断点续传、连接授权流程,以及认证、就绪检查与限流的实现细节。

模板总体结构:四个层次

该模板位于 apps/templates/eve-chat-template,由四个主要层次构成:

  1. eve agent 层:agent/,定义 agent、channels、connections 与 memory;
  2. Next.js 外壳与页面层:apps/web/app/,负责静态 Shell、根路由与会话路由;
  3. 聊天 UI 组件层:apps/web/components/chat/,包含 composer、message、sidebar 等;
  4. 持久化、认证、就绪检查与限流层: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.tsxeve 客户端桥:发送、流式、持久化、恢复、待授权与展示状态
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.tsstarter 模式下的版本化浏览器存储与 eve session 游标
apps/web/lib/chat/persistence-client.ts把客户端持久化调用路由到浏览器或数据库存储
apps/web/lib/db/schema.tsBetter Auth、chats、chat events 的 Drizzle 表
apps/web/lib/db/queries.ts列表、加载、事件写入、快照保存与删除查询
apps/web/lib/setup.ts选择 starter / 本地开发 / 生产模式并校验就绪状态
apps/web/lib/auth.tsBetter 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 服务组合部署。

运行时模型:两个独立的概念

模板中必须区分两个相关但分离的状态:

  1. 本地 app chat:一条浏览器存储记录或chat表中的一行;
  2. eve session:eve 的ClientSessionState与远端会话流。

app chat 给用户稳定的 URL(如/chat/abc)、侧边栏标题和持久化的事件历史;eve session 则是 eve 用来继续对话、等待授权、恢复流、接收后续输入的持久会话状态。生产模式下,chat 行上保存:

chat.eveSession: ClientSessionState | null

流式事件按序保存为:

chat_event.eventIndex: number chat_event.event: MessageStreamEvent

Starter 模式把同样的两个值放在版本化的 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的共享侧边栏与顶部认证控件

用户提交第一条消息时:

  1. 输入被 trim;
  2. 若 setup 未完成,toast 说明缺什么;
  3. 若未登录,requestSignIn(message)打开登录弹窗并把草稿存入sessionStorage(键eve-chat-draft,登录后由 session-chat-page.tsx 恢复);
  4. 已登录时,createChatAction({ pendingUserMessage: message })创建 chat 行;
  5. 标题由createFallbackTitle从首条用户消息派生;
  6. 新 chat 经touchChat(created)插入侧边栏;
  7. 应用导航到/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时,流程为:

  1. 构造对应的消息或输入响应 payload;
  2. POST 到/eve/v1/session创建新 eve session,或 POST/eve/v1/session/:sessionId继续会话;
  3. 读取x-eve-session-id响应头;
  4. 更新本地ClientSessionState;
  5. 把该会话状态保存到 chat 行;
  6. 返回浏览器兼容的 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 事件。每个事件:

  1. 推入当前流缓冲;
  2. 递增下一个远端流索引;
  3. 交给useEveAgent;
  4. 若事件结算了回合则停止。

回合何时“结算”由 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发送,预期顺序:

  1. 忽略空输入;
  2. 会话已忙则忽略;
  3. 若 eve 正在等待连接授权,拒绝普通文本并给出友好错误;
  4. 尽可能立即渲染乐观用户气泡;
  5. 执行prepareSend(message);
  6. 用pendingUserMessage标记 chat 行;
  7. 调用agent.send(message, { clientContext });
  8. 由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 侧已知的完整归约事件列表;
  • 当前 eveClientSessionState。

模板随后调用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用于两种中断:首条发送被中断、后续发送被中断。流程:

  1. 发送前调用markChatPendingMessageAction,chat 行写入pendingUserMessage与pendingUserMessageCreatedAt;
  2. 若回合完成前页面刷新,getChatForUser会返回 pending 消息;
  3. SessionChatPage在 controller 就绪后消费它;
  4. 结算事件持久化后,getChatForUser隐藏过期的 pending 消息;
  5. 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):

  1. 停止当前 agent 流并agent.reset();
  2. 创建 outcome 为declined的authorization.completed事件;
  3. 创建本地session.waiting事件;
  4. 把这些事件应用到持久化浏览器会话;
  5. 经skipChatAuthorizationAction落库(queries.ts 中同样以冲突覆盖方式写入事件并清空pendingUserMessage);
  6. 清除 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、必需 scopeopenid/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;连接阻塞用结构化授权卡片。

事件日志不变量

这些不变量很重要,也是排查问题的第一现场:

  1. chat_event.eventIndex必须每 chat 唯一;
  2. 完成的快照应按索引覆盖陈旧事件行;
  3. 超出最终快照长度的行应被删除;
  4. pendingUserMessage应在回合结算后清除;
  5. 本地授权跳过事件必须并入最终快照;
  6. 待授权应禁用该会话的普通文本输入;
  7. 路由切换应重置 per-chat 引用(事件索引、本地 pending 消息等,见 agent-chat.tsx 的 reset effect);
  8. 侧边栏历史可以乐观更新,但事件日志始终是聊天内容的权威来源。

遇到“消息替换了旧消息”或“响应看起来完成后又更新了”时,优先检查:

  • 两个事件是否写到了同一个本地eventIndex;
  • preserveKnownInitialEvents是否复制或丢掉了前缀;
  • 结算事件后pendingUserMessage是否仍留在 chat 行上;
  • eve 流是否以session.waiting结束,而 UI 尚未预期到;
  • 本地乐观消息是否在真实用户事件出现前被清除。

扩展指南

添加新工具

  1. 在agent/tools/下创建文件(参考现有 agent/tools/get_weather.ts);
  2. 导出defineTool(...);
  3. 按 eve 的 agent 约定导入/注册;
  4. 更新 agent/instructions.md,让 agent 知道何时使用它;
  5. 若 UI 需渲染特殊工具状态,更新 message.tsx。

保持工具输出为结构化 JSON:当工具部件包含可预测的 JSON 而非纯文本时,消息渲染器能做出更好的 UI 决策。

添加新连接

为另一个 Vercel Connect 支撑的 MCP 连接:

  1. 添加agent/connections/<name>.ts;
  2. 使用defineMcpClientConnection;
  3. 使用connect(process.env.<ENV_NAME> ?? "<local-name>");
  4. 补充部署/文档中的 connector 供给说明;
  5. 扩展 chat-shell-context.tsx 中的EnabledConnections;
  6. 在IntegrationsMenu添加开关;
  7. 更新createConnectionClientContext,让 eve 收到每轮意图;
  8. 验证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。

开发检查清单

修改聊天流程时,验证:

  1. 根页渲染无布局偏移;
  2. 未登录用户可以输入,随后被提示登录;
  3. 首条消息创建 chat,导航后立即出现;
  4. 后续消息乐观出现;
  5. 助手文本流式输出,不替换旧消息;
  6. 响应中途刷新能恢复,或以清晰错误结束;
  7. 侧边栏历史可见,活跃行状态正确;
  8. 侧边栏分页仍能加载更早的聊天;
  9. 待授权只禁用受影响的会话;
  10. Connect 与 Skip 都让对话回到可用状态;
  11. setup 阻塞显示 tooltip 或可操作的登录/setup 页面;
  12. pnpm typecheck与pnpm build通过。

心智模型:一个小型持久化聊天运行时

把该应用看作一个小型 durable chat runtime,核心数据流如下:

浏览器随时可以被中断:Postgres(或浏览器存储)保存了足以重建 UI、并从 eve 流游标继续的状态;静态外壳让导航保持流畅;最终快照让事件日志保持权威。这就是该模板的核心设计。

  • 人工智能
  • AI Agent
  • Agent 框架
  • 后端
  • 工具调用

【免费下载链接】eve

The Open Framework for Building Agents

项目地址:https://gitcode.com/gh_mirrors/eve28/eve
点击查看免费下载

相关推荐

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

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

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

立即咨询