eve 中文教程 · 第 17 章 前端与部署
前端只需要一个 hook;部署只需要两条命令——但鉴权和模型凭据必须自己配,因为默认策略在生产环境一律拒绝。
17.1 三种交付形态
| 形态 | 谁托管谁 | 适合 |
|---|---|---|
| 框架集成(Next.js / Nuxt / SvelteKit / TanStack) | 前端应用托管 agent,同源路由 | 已有 Web 应用,要一个聊天面板 |
| 平级服务 | 前端和 agent 各自独立部署,反向代理或平台路由 | 多前端、多 agent、独立伸缩 |
| 无前端 | 只有 HTTP / Slack / 定时 | 内部工具、后台任务 |
框架集成的好处是同源:没有 CORS,不用配 URL 环境变量,浏览器 cookie 直接带上。
部署后的组件关系(以框架集成为例):
flowchart TB
B["浏览器"] -->|"同源请求"| N["Next.js 应用<br/>聊天 UI"]
N -->|"/eve/v1/*"| E["eve 服务<br/>Nitro"]
E --> W[("Workflow world<br/>Vercel / 本地目录 / Postgres")]
E --> S["沙箱<br/>Vercel Sandbox / Docker"]
E --> M["模型<br/>AI Gateway / provider"]
E --> C["通道<br/>Slack · 定时任务"]
C -->|"webhook"| E
四个依赖各自独立:world 存执行状态,沙箱跑命令,模型是外部 API,通道是入站口。自托管要自己负责 world 和沙箱这两块,Vercel 上它们由平台提供。
17.2 用脚手架生成
eve add channel/web
选 Next.js 或 TanStack Start,它会生成:
next.config.ts
app/page.tsx
app/_components/agent-chat.tsx
app/_components/agent-message.tsx
// next.config.ts
import type { NextConfig } from "next";
import { withEve } from "eve/next";
const nextConfig: NextConfig = {};
export default withEve(nextConfig);
withEve 在开发时启动 agent 进程并代理请求,部署时把 agent 作为独立服务贡献出去。
withEve 的几个选项
| 选项 | 默认 | 用途 |
|---|---|---|
eveRoot |
Next.js 应用根 | eve 项目根(可含一个 agent/ 或一个 agents/ workspace) |
agents |
workspace 自动推断 | 挂到 /eve/<name>/v1/* 的具名 agent |
eveBuildCommand |
自动生成 | 生成服务的构建命令 |
servicePrefix |
/_eve_internal/eve |
内部路由命名空间 |
devServerTimeoutMs |
180000 |
等 dev server 就绪的上限,冷启动慢时调大 |
前后端平级时:
// apps/web/next.config.ts
export default withEve(nextConfig, { eveRoot: "../.." });
保留生成的聊天组件。 它渲染了工具结果、错误、授权链接、审批按钮、问题表单。自己写一个只渲染文本的组件,第 11 章的审批就没法在 UI 上完成——turn 会一直挂着。
17.3 自己写 UI
"use client";
import { useEveAgent } from "eve/react";
export function Chat() {
const agent = useEveAgent();
const busy = agent.status === "submitted" || agent.status === "streaming";
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const text = String(form.get("message") ?? "").trim();
if (text) void agent.send(text, busy ? { turnPolicy: "steer" } : undefined);
}}
>
{agent.data.messages.map((m) => (
<article key={m.id}>
<header>{m.role}</header>
{m.parts.map((p, i) =>
p.type === "text" ? <p key={p.id ?? i}>{p.text}</p> : null
)}
</article>
))}
<input name="message" disabled={agent.status === "resuming"} />
<button type="submit" disabled={agent.status === "resuming"}>发送</button>
</form>
);
}
useEveAgent() 的选项:
| 选项 | 类型 | 默认 | 说明 |
|---|---|---|---|
agent |
string |
无 | 具名 agent,路由到 /eve/<name>/v1/*;不能和 host 同时用 |
host |
string |
""(同源) |
基址或同源代理前缀 |
auth |
同 Client 的 auth |
无 | 浏览器端的凭据解析(bearer / basic / vercelOidc) |
headers |
Record<string, string> \| 回调 |
无 | 每个请求都带的头 |
session |
ClientSession |
无 | 用已有会话句柄 |
initialSession |
{ sessionId, streamIndex } |
无 | 用保存的游标恢复 |
initialEvents |
MessageStreamEvent[] |
无 | 先渲染历史,必须是同一会话流的有序前缀 |
resume |
boolean |
false |
挂载后重放并跟随进行中的 turn,需要 session 或 initialSession |
prewarm |
boolean |
false |
只建会话不发起 turn |
reducer |
EveAgentReducer<TData> |
默认 reducer | 自定义投影 |
optimistic |
boolean |
true |
提交时先本地显示用户消息 |
followIdle |
boolean |
true |
会话空闲时是否继续挂着流;false 在空闲时关流,下次 send / respond / resume 从游标重开 |
followSubagents |
boolean |
false |
跟随每个子代理的会话到 conversation.agents |
onError / onEvent / onFinish / onSessionChange / prepareSend |
回调 | 无 | 生命周期钩子 |
useEveAgent() 返回的东西:
| 字段 | 用途 |
|---|---|
data |
选定的投影,默认是 ConversationState(含 messages、inputs) |
conversation |
规范会话状态,含子代理状态 |
status |
ready / resuming / submitted / streaming / error |
error |
最近一次失败 |
events |
原始事件流,要自己投影时用 |
session |
可序列化游标(sessionId、streamIndex) |
prewarm |
只建会话不发起 turn |
send |
发消息,可带每轮选项 |
respond |
回答待处理的人工输入 |
resume |
接回已有会话并跟随进行中的 turn |
cancel |
取消当前 turn |
reset |
清本地状态,开新会话 |
刷新后接回
把 { sessionId, streamIndex } 存进 URL 或 localStorage,刷新后用 resume 接回去,进行中的 turn 也能继续跟:
const agent = useEveAgent({
session: savedSession, // 从存储里读
initialEvents: savedEvents, // 可选:先渲染历史
});
多个聊天线程时,每个线程存一份 session + 事件日志;切换线程要重挂组件(key={chat.id}),因为 session、auth、reducer、followIdle 这些只在 store 创建时读一次。
长期挂载的面板要关掉空闲跟随。 只要组件还挂着,流就一直重连,哪怕什么都没发生。像常驻侧边栏这种只在用户主动问话后才需要回复的界面,传 followIdle: false:会话进入空闲(没有 turn 在跑或停泊、没有任务在跑、没有待登录)时关流,下一次 send / respond / resume 从会话游标重开,中间到达的事件那时补上。prewarm 的会话在第一条消息之前不开流。反过来,如果定时任务、通道或另一个标签页可能在你空闲时给同一会话开新 turn,就别关。
流式
useEveAgent 默认已经把 message.appended 累加进 data.messages。要自己处理原始流,用 events:
useEffect(() => {
for (const event of agent.events) {
if (event.type === "action.result") console.log(event.data.result.toolName);
}
}, [agent.events]);
17.4 在 UI 里处理审批
默认 reducer 把待处理请求记在 data.inputs 里,每条带 requestId、kind、prompt、options。不要自己过滤状态,用 eve/react 导出的 openConversationInputs()——它知道哪些请求已经 settle、哪些属于子代理:
import { openConversationInputs } from "eve/react";
const pending = openConversationInputs(agent.data).map((input) => input.request);
return pending.map((request) => (
<fieldset key={request.requestId} disabled={agent.status === "resuming"}>
<legend>
{request.kind === "tool-approval"
? "需要审批"
: request.kind === "question"
? "需要回答"
: "额度提示"}
</legend>
<p>{request.prompt}</p>
{request.options?.map((option) => (
<button
key={option.id}
type="button"
onClick={() => void agent.respond([{ requestId: request.requestId, optionId: option.id }])}
>
{option.label}
</button>
))}
</fieldset>
));
要点:
- 提交不等于完成。
respond()先把请求标成responded,等approval.settled或input.resolved到达才算解决。 - 一个 turn 可能有多个待答请求。全渲染出来,别只显示第一个。
allowFreeform为真的问题要渲染文本输入,发{ requestId, text }。- 被拒绝的审批会重新打开。
17.5 鉴权
默认通道在生产环境返回 401。三种做法:
做法一:应用会话(推荐给已有 Web 应用)
// agent/channels/eve.ts
import { eveChannel } from "eve/channels/eve";
import { localDev, vercelOidc } from "eve/channels/auth";
import { verifyAppSession } from "../lib/auth";
export default eveChannel({
auth: [verifyAppSession, vercelOidc(), localDev()],
});
verifyAppSession 从 cookie 或 bearer 里验证你的应用会话,返回 principalType: "user" 的 principal。同源集成会自动带 cookie。
eveChannel 除 auth 外还有 audience、uploadPolicy、fetchFile、cors、turnPolicy、onMessage、events、trustedForwarders,字段表在第 14 章。
做法二:HTTP Basic(单用户内部工具)
见第 14 章。注意要配 proxy.ts 让浏览器弹原生登录框,并且必须走 HTTPS。
// proxy.ts
import { routeAuth } from "eve/channels/auth";
import { NextResponse } from "next/server";
import { appAuth } from "./agent/lib/auth";
export async function proxy(request: Request) {
if (process.env.NODE_ENV === "development") return NextResponse.next();
const result = await routeAuth(request, appAuth);
return result instanceof Response ? result : NextResponse.next();
}
export const config = { matcher: ["/", "/s/:path*"] };
做法三:匿名(只用于公开演示)
import { none } from "eve/channels/auth";
export default eveChannel({ auth: [none()] });
none() 是显式放行所有请求。处理私有或生产数据的 agent 绝不能用。
17.6 部署到 Vercel
npx vercel@latest link
npx vercel@latest env add ANTHROPIC_API_KEY preview
npx vercel@latest env add OPS_USERNAME preview
npx vercel@latest env add OPS_PASSWORD preview
npx vercel@latest deploy
或者用 eve 自己的命令:
eve link
eve deploy
要点:
- 本地
.env不会随部署上传。 每个环境(Preview / Production)都要单独配。 - 如果你本地用的是 ChatGPT 订阅登录,部署要换成 AI Gateway 模型 +
AI_GATEWAY_API_KEY。订阅凭据只在你笔记本上有效。 - 部署在 Vercel 上时,沙箱自动用 Vercel Sandbox,workflow 自动用 Vercel Workflow,cron 自动变成 Vercel Cron Job。
- 生产要发
--prod。
部署后必须验证:
# 无凭据必须 401
curl -i -X POST https://your-app.vercel.app/eve/v1/session
# 健康检查(公开)
curl https://your-app.vercel.app/eve/v1/health
# 带凭据应该 202
curl -i -X POST https://your-app.vercel.app/eve/v1/session \
-u "$USER:$PASS" -H 'content-type: application/json' \
-d '{"message":"hi"}'
第一条如果返回 202,你的 agent 是公开的,任何人都在花你的钱。
17.7 自托管
目标:Node 服务 + 你自己的持久化 + 你自己的沙箱。
1. 构建与启动
eve build
PORT=3000 eve start --host 0.0.0.0
构建产物在 .output/(Nitro server)。eve start 服务这份产物,接受 PORT 或 --port。TLS、伸缩、重启、日志收集交给你的进程管理器或容器平台。
2. 模型与鉴权
- 非 Vercel 主机用 Gateway 字符串模型:设
AI_GATEWAY_API_KEY。 - 直连 provider:装对应 AI SDK provider 包,在
agent.ts里传模型对象,设它自己的 API key。 - 别把
vercelOidc()当成唯一的生产鉴权——它只在 Vercel 环境有意义。用 Basic / JWT / OIDC / 自定义 verifier。
3. 持久化 workflow 状态
默认的本地 world 把 run 状态写在 .eve/.workflow-data。两种做法:
把目录挂到持久卷(最简单):
/app/.eve/.workflow-data → 持久卷
换一个 world 包:
// agent/agent.ts
export default defineAgent({
model: "anthropic/claude-sonnet-5",
experimental: {
workflow: { world: "@workflow/world-postgres" },
},
});
换 world 的完整步骤(建表、环境变量、自定义包)在 10.14。有一个部署时必须处理的点:Postgres world 自带的 worker 会把编排投递到 POST /.well-known/workflow/v1/flow,这个路由没有鉴权,要在网关层挡住。
- 版本钉在 eve 自带的那一代
@workflow/*上。eve@0.81.1依赖@workflow/world@5.0.3、world-local@5.1.0、world-vercel@5.2.0,world-postgres当前最新5.0.5。装latest可能拿到协议不兼容的版本,初始化时会被拒。官方文档里还写着5.0.0-beta.x,那行已经过时,以包里的依赖为准。 - 连接串等配置放环境变量,由 world 包自己读,不要写进
agent.ts。 - 要在构建产物里保持 external,加到
build.externalDependencies。
4. 沙箱
// agent/sandbox/sandbox.ts
import { defineSandbox } from "eve/sandbox";
import { DockerSandbox } from "eve/sandbox/docker";
export const environment = DockerSandbox.environment();
export default defineSandbox(() => environment.open({ networkPolicy: "deny-all" }));
构建机要能构建镜像,运行时要能启动容器(挂 docker socket 或用远程 Docker)。
5. 反向代理
- 请求体上限放开(附件内联 base64)。
- 关闭响应缓冲(NDJSON 要逐行下发)。
- 超时给够(长 turn 可能跑几分钟)。
6. 定时任务
eve start 会启动 Nitro 的 scheduled task runner。如果你用自己的进程管理器只服务 HTTP、不启动 runner,cron 定义还在但不会触发。要么用 eve start,要么用外部调度器通过鉴权路由触发。
7. 版本钉死
非 Vercel Workflow 的世界,一个 eve 版本同时只能服务一个 world。升级后老会话全部 stranded(第 10 章)。所以:
{ "dependencies": { "eve": "0.81.1" } }
不要用 ^。升级是需要计划的操作。
17.8 环境变量清单
先记住三条,细节见附录 C:.env.local 不会随部署上传;eve build 不读 .env*,编译期求值的定义要用构建机的环境;沙箱里没有 process.env。
| 变量 | 用途 | 必填 |
|---|---|---|
AI_GATEWAY_API_KEY |
走 AI Gateway 的模型 | 用 Gateway 时 |
ANTHROPIC_API_KEY / OPENAI_API_KEY |
直连 provider | 直连时 |
DEEPSEEK_API_KEY / DASHSCOPE_API_KEY / ZHIPU_API_KEY 等 |
直连国产 provider(第 2 章) | 用哪个填哪个 |
| 业务工具自己的密钥 | 日志 API、OPS API 等 | 按需 |
| 通道鉴权凭据 | Basic 用户名密码、应用会话密钥 | 生产必填 |
WORKFLOW_POSTGRES_URL(没有则退回 DATABASE_URL) |
Postgres world 的连接串 | 自托管时 |
一个原则:所有密钥只在应用运行时。不要放进沙箱、不要放进技能正文、不要放进给模型的工具输出。
17.9 常见坑
坑:部署后 401,以为部署坏了。
默认鉴权策略就是拒绝生产流量。配 agent/channels/eve.ts。
坑:部署后能用,但任何人扫到 URL 都能用。 同上,反过来说:没有鉴权就是公开的。用无凭据 curl 验证。
坑:本地能跑,线上模型报错。
本地 .env 没上传,或用了订阅登录。换成 Gateway/API key 并配到对应环境。
坑:自己写聊天 UI,审批按钮没有。
turn 会一直挂着。保留生成的组件,或自己实现 respond。
坑:自托管 cron 不触发。
托管方式没启动 Nitro 任务 runner。用 eve start。
坑:自托管升级后所有会话 409。 stranded session。钉版本,升级前接受中断。
坑:反向代理缓冲了流式响应。 前端看起来「一次性吐出全部」。关掉代理缓冲。
坑:withEve 的 eveRoot 写错。
前后端平级时忘了 eveRoot: "../..",dev 起来找不到 agent。
17.10 练习
eve add channel/web生成 Next.js 前端,跑通一轮对话和一个审批。- 把
{ sessionId, streamIndex }存进 URL,刷新后resume接回。 - 用 HTTP Basic 保护部署,验证无凭据 401、有凭据 202。
- 本地
eve build && eve start,用curl验证/eve/v1/health和一次完整对话。 - 用 Docker 做沙箱 + Postgres 做 world,跑一次自托管。
延伸阅读
上一章:16 评测 · 下一章:18 进阶与上线检查