所有页面
本页内容

预览

预览会把一个带网页界面的东西放到公开链接上:一个监听中继机器上某个端口的开发服务器,或一个装普通文件的文件夹。你可以从任何设备打开该链接,也可以把它发给任何人。中继不会被暴露:预览由一个单独的监听器在单独的主机名上提供。

典型用途:

  • 在手机上打开应用。 智能体启动开发服务器,你轻点链接,在真实设备上试用布局。
  • 把链接发给别人。 同事无需访问你的机器或中继,就能打开一个原型或已构建的网站。
  • 把 webhook 指向开发服务器。 需要公开地址的服务可以调用你正在构建的应用。

本页是给所有者和运维人员的指南。智能体的工具及其参数、结果和限制,在 preview 中有描述。

预览链接是公开的

链接中的令牌(128 位)是唯一的保护。任何持有该链接的人无需登录就能访问该应用。对含有真实数据的内容做预览,就是真正把它暴露出去。用完预览后请关闭它。

预览如何与中继分开 #

预览由第二个 HTTP 监听器在它自己的端口上提供,并在它自己的主机名上发布。由此得出两点:

  • 中继不在那个端口上。 它的 API、网页应用、实时连接和分享链接都没有挂载在那里。预览流量无法到达它们,因为它们并不存在于那个监听器上。创建或关闭预览需要中继的访问令牌,并且只发生在中继自己的端口上。
  • 主机名不同。 被预览的应用运行在与中继不同的另一个浏览器源中。它的 JavaScript 无法发送你的中继登录 cookie,也无法读取中继网页应用的存储。

主机名必须不同,而不仅仅是端口不同。cookie 会忽略端口,因此 relay.example.com:3334 会共享 relay.example.com 的登录 cookie。当主机名相同时,中继会拒绝启动该通道。

除此之外,该通道绝不会把中继的登录 cookie(copilot_auth)或携带中继访问令牌的 Authorization 头转发给被预览的应用,即使手工构造的请求携带它们。应用自己的 Authorization 头会照常通过。

预览彼此之间确实共享一个主机名,每个都在自己的路径下。参见需要记住的几点。

设置 #

在运维人员设置之前,预览是关闭的。共有三个步骤。

1. 一个主机名 #

挑选一个你控制的、名称上与中继不同的主机名,例如 preview.example.com 与 relay.example.com 并列。把它的 DNS 指向你的前置代理或隧道。预览按路径而非子域划分命名空间,因此在你已有的区域下一个单标签主机名就足够了。在 Cloudflare 下,该名称由 Universal 证书覆盖。像 *.preview.example.com 这样的两标签通配符则不会,这就是为什么没有为每个预览分配子域。

2. 一个前置代理 #

代理会把发往预览主机名的请求转发给预览监听器,后者默认使用中继端口加一(当中继使用 3333 时是 3334)。使用你通过 previews.port 固定的端口;代理需要一个稳定的目标。

Caddy

preview.example.com {
    reverse_proxy 127.0.0.1:3334
}

Cloudflare Tunnel

在你已经使用的隧道上添加第二个公开主机名,可以在 Cloudflare 控制台的 Public Hostnames → Add(公共主机名 → 添加)下添加,也可以写进隧道的 config.yml:

ingress:
  - hostname: preview.example.com
    service: http://127.0.0.1:3334
  - hostname: relay.example.com
    service: http://127.0.0.1:3333
  - service: http_status:404

3. previews 配置块 #

把该配置块添加到中继的 config.json 并重启中继(参见 oar restart):

{
  "publicHostnames": ["relay.example.com"],
  "previews": {
    "enabled": true,
    "port": 3334,
    "bindHost": "127.0.0.1",
    "publicBaseUrl": "https://preview.example.com",
    "allowedTargetHosts": [],
    "maxLive": 8
  }
}
键 那里放什么
publicHostnames 中继自身应答的每个主机名。针对 publicBaseUrl 的检查会使用这个列表(并且总是包括 localhost 和 127.0.0.1),因此你漏掉的主机名不会被检查。
previews.enabled 设为 true 以开启该通道。默认是关闭的。
previews.port 监听器的端口。默认:中继端口加一。0 会在每次启动时挑选一个空闲端口,前置代理无法跟上它。
previews.bindHost 监听器绑定到哪里。默认 127.0.0.1。让它保持环回:前置代理运行在同一台机器上。
previews.allowPublicBind 只有当 bindHost 不是环回时才需要。那样每个预览就能不经代理直接从网络访问。
previews.publicBaseUrl 该通道的公开地址:https://preview.example.com。链接由它构建。
previews.allowedTargetHosts 除本机之外,端口预览可以指向的主机,例如容器或虚拟机。
previews.maxLive 整个中继同时最多有多少预览。默认 8。

每个键、默认值和边界情况都在 config.json 的表格中。

环境变量覆盖 #

COPILOT_PREVIEWS_ENABLED、COPILOT_PREVIEWS_PORT、COPILOT_PREVIEWS_BIND_HOST、COPILOT_PREVIEWS_PUBLIC_BASE_URL 和 COPILOT_PREVIEWS_ALLOWED_TARGET_HOSTS 会覆盖相应的键。它们的值和谁覆盖谁,见环境变量。

当通道拒绝启动时 #

配置错误的通道绝不会拖垮中继。中继会继续运行,通道保持关闭。这些检查都不会变成警告,因为每一项都在保护上面描述的隔离。

原因 消息
publicBaseUrl 缺失或不是 http(s) 地址 previews.publicBaseUrl is required (an http(s) URL on a hostname of your own)
publicBaseUrl 使用了中继的主机名 previews.publicBaseUrl must use a different hostname than the relay (got <hostname>)
端口低于 1024 previews.port must be >= 1024 (got <port>)
端口是中继的端口或其 CLI 端口 previews.port <port> collides with a relay port
无法确定端口 previews.port could not be resolved (set previews.port or config.port)
bindHost 不是环回 previews.bindHost <address> is not loopback; set previews.allowPublicBind to override

你会在两个地方看到它:

  • 中继日志,在启动时:每项失败的检查一行 PREVIEW LANE disabled: <message>。当检查通过时,日志会显示 [preview] listener on http://127.0.0.1:3334 → https://preview.example.com。日志在哪里:文件与文件夹。
  • 发布请求的应答。 API 会以 503 应答,并带上 Preview lane failed its startup checks 和 details 中的消息。智能体的 preview 工具会转达这一点,并让智能体告诉你。当没有设置 previews.enabled 时,应答是 Preview lane is disabled (set previews.enabled and previews.publicBaseUrl),日志中没有那一行。

Settings → Previews(设置 → 预览)和 /preview 的提示不会显示原因。设置选项卡会列出存在的预览,而提示只会说发布失败(参见 /preview)。请查看中继日志。

如果端口被另一个程序占用,检查会通过,但监听器无法绑定。日志随后会显示 PREVIEW LANE failed to bind :<port> — <reason>。就 API 而言,通道是启动的,但预览端口上没有任何应答,因此链接打不开。释放该端口,或设置另一个 previews.port。

三种发布方式 #

让智能体来做 #

说出你想要什么:“启动开发服务器,给我一个链接”“给我看看构建好的网站”。智能体会使用 preview 工具,或在某个提供商没有该工具时使用中继的预览 API。它会告诉你链接,以及该链接是公开的。哪些提供商有该工具,见参考页上的表格。

输入区中的 /preview #

该命令在你的浏览器中针对中继运行。它适用于每个提供商,且不消耗智能体的轮次。当消息没有附加任何内容时,它就会运行。提示会出现在对话中。

你输入 会发生什么
/preview 5173 web app 以标签“web app”发布端口 5173。打印 Preview published: <url>。
/preview ./dist built site 把文件夹 ./dist 作为静态预览发布,标签为“built site”。第一个词是文件夹,因此它不能包含空格。打印 Preview published: <url>。
/preview list 列出中继的每个预览,每行一个:令牌的前 8 个字符、标签、端口或文件夹、如果离线则是 — offline,以及链接。如果没有,则打印 No live previews.;当它关闭时,打印 The preview lane is disabled on this relay.。
/preview close 关闭此对话的预览。没有时,打印 No live previews in this conversation.。有多个时,打印匹配项并要求更长的前缀。
/preview close 3f9c 关闭令牌以这 4 到 32 个十六进制字符开头的预览,可在任何对话中。打印 Preview closed: <label>。没有匹配时:No preview matches "3f9c".
/preview 打印用法行,任何它读不懂的输入也一样。

由数字组成的第一个词总是一个端口,超出 1 到 65535 的端口会打印用法行。对于名为 8080 的文件夹,请输入 ./8080。

如果发布失败,无论什么原因(端口错误、文件夹在工作目录之外、通道已满、通道被禁用),提示都是一样的:

Preview failed — check the port/path, or whether the preview lane is enabled (Settings → Live previews).

确切原因不会显示。列表在 Settings → Previews 上,位于标题 Live previews(实时预览)下。要查看原因,请调用 API(下文),或查看疑难解答中的表格。如果无法访问中继,提示是 Preview command failed — could not reach the relay. 命令菜单及其按键见输入区。

API #

上面的一切都使用中继端口上的同一个 API。它需要中继的访问令牌(config.json 中的 authToken,也就是你登录所用的同一个令牌)作为 bearer 令牌。请先注册,这样你就知道基础路径,然后在它下面启动开发服务器:

curl -sX POST http://127.0.0.1:3333/api/previews \
  -H "authorization: Bearer $RELAY_TOKEN" -H 'content-type: application/json' \
  -d '{"conversationId":"<conversation id>","port":5173,"label":"web app"}'
{
  "ok": true,
  "url": "https://preview.example.com/test_0123456789abcdef0123456789abcdef/",
  "basePath": "/test_0123456789abcdef0123456789abcdef/",
  "preview": { "token": "0123456789abcdef0123456789abcdef" },
  "existing": false
}

当对话已经有一个针对同一端口或文件夹的实时预览、并且你取回的是那一个时,existing 为 true。

请求 用途
POST /api/previews port 或 dir(不能两者都有),可选 conversationId、host、label。
GET /api/previews 所有预览。?conversationId=,或会话 worker 发送的对话头,会把它缩小到一个对话。
DELETE /api/previews/<token> 关闭一个。开发服务器不受影响。0.9.14 新增 像 GET 那样指定对话的请求,只能关闭该对话的预览;其他对话的会被拒绝(403)。

让应用在预览路径下工作 #

在共享的预览主机名上,每个预览都位于 /test_<token>/ 下。由代理的行为得出的规则是:

  • 浏览器请求 /test_<token>/…。
  • 应用收到的是不含该前缀的路径。
  • 因此应用必须在其页面中放入要么带前缀、要么是相对地址的链接和资源 URL,并且它还必须应答不带前缀的请求。

代理在途中会做什么:

代理
去掉前缀 应用收到 /test_<token> 之后的路径,带查询参数。访问裸的 /test_<token> 会被重定向(301)到带尾斜杠的同一地址,因此相对 URL 会在预览内解析。
发送 X-Forwarded-Prefix 不带尾斜杠的前缀。
发送 X-Forwarded-Host 预览监听器收到的 Host。
发送 X-Forwarded-Proto 前置代理发送的那个;如果它没发送,则是 https。
发送 X-Forwarded-For 连接到监听器的一方的地址。在同一台机器上的前置代理之后,那就是代理。
设置 Host 为应用自己的地址(127.0.0.1:5173),供会检查它的开发服务器使用。
重写 Location 到 /x 的重定向会变成 /test_<token>/x,到应用自己地址的绝对重定向也一样。已经带前缀的重定向、以 // 开头的重定向、相对重定向或指向其他站点的重定向则保持不变。
限定 Set-Cookie cookie 会得到 Path=/test_<token>/ 并去掉 Domain,这样某个预览的 cookie 不会到达另一个预览。名为 copilot_auth 的 cookie 会被丢弃。
标记响应 X-Robots-Tag: noindex, nofollow, noarchive,并且它会丢弃 Service-Worker-Allowed。

它不会重写页面、样式表或脚本的主体。一个把 /assets/app.js 写进其 HTML 的应用,请求的是预览主机名的根,那里没有预览,浏览器会得到 No such preview 页面(404)。这表明该应用不知道自己的前缀。

哪些能直接工作 #

  • 相对 URL。 页面使用 ./assets/app.js 这类地址的应用能直接工作:页面位于 /test_<token>/,因此它们会在预览内解析。这由代理的工作方式推出;并未按框架逐一测试。
  • 读取前缀。 在不带前缀的路径上路由、并从 X-Forwarded-Prefix 构建链接的应用,正好符合该规则。
  • 构建好的网站。 用相对路径或等于前缀的 base 构建,然后把输出文件夹发布为静态预览。对 /test_<token>/assets/… 的请求会从该文件夹提供,因此两种 URL 都能工作。

哪些不能直接工作 #

一种让应用只应答带前缀请求的工具设置不适合,因为代理会把它去掉。

  • 把 base 设为前缀的 Vite 开发服务器(--base)。 已对照中继机器上的 Vite 8.3.1 源代码检查:该开发服务器只处理以 base 开头的路径。它对 / 的请求会以到 base 的重定向应答,而代理对此保持不动,因此浏览器会循环。其他不带前缀的请求会得到 404,说该服务器有一个公开的基础 URL。热重载只在 base 下的路径监听,被去掉前缀的请求无法匹配。请改用构建好的网站方式,或用一个从根路径以相对 URL 提供服务的 Vite 设置。
  • 把路由挂载在前缀处的 Express(app.use(prefix, router))。应用收到的是 /x,而不是 /test_<token>/x,因此挂载无法匹配。把路由挂载在 / 并从 X-Forwarded-Prefix 构建链接。

Next.js(basePath、assetPrefix)和 SvelteKit(kit.paths.base)在 OAR 自己给智能体的说明中被提及,但这些说明并未对照代理的行为检查,而且这里没有这两种工具可供测试。如果它们的 base 设置让应用期望每个请求都带前缀,那它就和 Vite 一样不适合。未经验证:请在依赖之前先试一下。

当应用需要前缀时,先发布

智能体在启动开发服务器之前会先调用 create,这样应用在需要前缀时就能拿到它。对于用相对 URL 工作的应用,顺序无所谓。

静态预览 #

用 dir 而不是 port,中继会自己提供一个文件夹。不需要运行任何东西,这适合构建输出、原型、单个 HTML 文件或覆盖率报告。

  • 该文件夹取自对话的工作目录(绝对路径,或相对于它)并且必须位于其中。没有对话时发布的预览会使用中继自己的工作目录。这会在你发布时以及每次请求时检查,因此之后创建的符号链接无法指向外面。
  • 对于文件夹路径,会提供 index.html。没有任何缓存。
  • 绝不提供:名称以点开头的文件和文件夹(.git、.env)以及密钥文件(*.pem、*.key,以 id_rsa、id_ed25519、id_ecdsa 或 id_dsa 开头的名称)。
  • 相对资源路径能工作。以预览前缀开头的也能工作。根绝对路径(/assets/app.js)不能。
  • 没有 index.html 的文件夹,以及任何缺失的文件,会得到 Not found 页面(404)。
  • 静态预览始终是 online,并拒绝 WebSocket 连接。

管理预览 #

  • 后台任务面板中的卡片。 对话的每个预览都有一张卡片,显示其标签、端口、一条说明链接是公开的提示、中继检查过后的 online 或 offline 徽标,以及 Open(打开)、Copy link(复制链接)和 Close(关闭)按钮。该面板在后台任务中有描述。
  • 对话记录中的链接卡片。 当预览在对话的一轮运行期间发布时,回复会带一张链接卡片,无论是智能体还是你用 /preview 发布的。在轮次之间发布的预览没有对话记录卡片。一旦预览消失,卡片会显示一个 closed 徽标而没有链接。分享的对话只会显示带标签的卡片,其文本中引用的每个预览链接都会显示为 [preview link removed]。
  • Settings → Previews。 Live previews 列表会显示中继的每个预览、来自所有对话,带对话 id 的前 8 个字符,以及 0.9.11 新增 它何时被发布和上次打开。当你离开它的对话后,可以在这里关闭它。参见 Settings(设置)。

offline 和 closed 是不同的。中继每 15 秒检查一次每个端口预览,方式是尝试连接到该端口(并在你发布时立刻检查一次)。当没有任何应答时,卡片会显示一个 offline 徽标和说明“offline — dev server not responding”。链接仍然有效:在同一端口再次启动服务器,同一个链接就能工作。closed 意味着预览已消失,链接不再解析。

关闭预览只会结束该链接。开发服务器会继续作为它本来的后台任务运行。

预览不会自行过期。它会在你关闭它时结束,或者 0.9.11 新增 在你删除其对话时结束(包括通过重建中继缓存而删除)。归档对话后,它的预览仍然有效,并显示在 Settings → Previews 中。

在同一个对话中再次发布同一端口或文件夹,会返回已经生效的那个预览,链接相同、标签不变,而不是一个新的。

整个中继同时最多有 8 个预览生效(previews.maxLive)。拒绝消息会列出最早的三个预览,包括各自的发布时间和上次打开时间,以及如何关闭它们。

中继重启之后 0.9.11 新增 #

预览在中继重启、更新或崩溃后依然存在:中继把它们保存在其数据库中,并在启动时恢复它们,因此你发出去的链接会继续工作。每个预览都会先被再次检查,当检查发现它不再有效时就结束:

  • 一个文件夹预览,其文件夹已消失,或不再位于其对话的工作目录内;
  • 一个端口预览,在机器重启之后:开发服务器已消失,该端口稍后可能属于别的东西;
  • 一个其对话已不存在的预览。

Linux 和 macOS 会可靠地报告重启。在 Windows 上,Restart(重启)算作一次重启;但在 fast startup(快速启动)开启时(这是 Windows 的默认设置),Shut down(关机)后再开机则不算:之前的端口预览会恢复,并连到届时监听该端口的任何程序。请关闭你不再需要的预览。

由于数据库忙、或文件夹因为消失之外的原因无法读取,而无法应答的检查,不会结束该预览。在此期间它不会被提供:Settings → Previews 和工具的 list 会把它显示为未提供(unverified),它会计入上限,也可以被关闭。中继每 15 秒再次检查它,一旦检查有应答,就会在其链接下再次提供它;在其对话中再次发布同一端口或文件夹会立即做到这一点。十分钟没有应答后,该预览会结束,日志会说明这一点。

端口预览在启动后也会立刻探测一次,因此它的 online 或 offline 徽标是最新的。中继的日志用短哈希来命名预览,从不用它的令牌。另见重启保留什么、丢失什么。

WebSocket #

端口预览会在同一前缀下透传 WebSocket 连接,前缀像任何请求一样被去掉,因此当浏览器通过预览链接连接时,应用自己的 socket 能工作。(热重载是应用自己的事:参见上面的 Vite 说明。)静态预览,以及未知或已关闭的链接,会用 404 应答 WebSocket 请求。如果应用以普通响应应答升级,浏览器会得到那个状态。如果应用挂了,则是 502。

需要记住的几点 #

  • 预览彼此共享一个源。 cookie 被限定到每个预览的路径,令牌也无法被猜到,但两个预览彼此之间的隔离,不像每个预览与中继之间那样彻底。
  • 默认只能指向你自己的机器。 端口预览只能指向环回地址,加上 previews.allowedTargetHosts 中的主机。中继自己的端口总是被拒绝,因为发布它会把自己的登录页放到公开主机名上。
  • 中继不会运行该应用。 它绝不会启动或重启开发服务器。如果服务器停止,卡片就会变成 offline。

按提供商 #

预览在每个提供商上看起来都一样;只有智能体发布的方式不同,正如参考页所示。/preview 和 Settings → Previews 对它们全部适用。

疑难解答 #

你看到什么 原因和解决办法
智能体或 API 以 Preview lane is disabled 或 Preview lane failed its startup checks(503)应答 通道关闭了,或拒绝启动。阅读中继日志中的 PREVIEW LANE disabled 行,修正 previews,重启中继。参见当通道拒绝启动时。
链接显示 Preview upstream unreachable 页面(502) 端口上没有监听,或连接失败。在同一端口再次启动开发服务器并重新加载;链接仍然有效。
资源缺失或页面空白,且对 /assets/... 的请求得到 No such preview(404) 应用写出了根绝对路径。让它生成相对或带前缀的 URL(规则)。
页面循环加载,或应用以 404 应答并提到“public base URL” 应用期望每个请求都带前缀,但代理把它去掉了(哪些不能直接工作)。
整个链接都是 No such preview 链接未知或已关闭,其对话被删除,或重启后发现它不再有效(中继重启之后)。请重新发布。
预览显示为未提供(unverified) 中继在启动时无法检查它。它每 15 秒会再次检查;在其对话中再次发布同一目标会立刻把它带回来。
链接完全打不开 前置代理或 DNS 未设置,或监听器无法绑定其端口(日志中的 PREVIEW LANE failed to bind)。
卡片显示 offline 开发服务器已停止应答。在同一端口再次启动它。
Too many live previews (max 8); previews stay until closed. Oldest: …(429) 在 Settings → Previews 中、用 /preview close、或用 DELETE /api/previews/<token> 关闭一个;智能体可以用工具的 close 操作关闭它自己对话的;或提高 previews.maxLive。
Port <n> belongs to the relay and cannot be previewed(400) 你给了中继的端口、它的 CLI 端口或预览端口。请发布开发服务器的端口。
Target port must be between 1024 and 65535(400) 使用更高的端口。
Target host ... is not loopback and not in previews.allowedTargetHosts(400) 把该主机添加到 previews.allowedTargetHosts。
dir must live inside the workspace root (...)、Directory not found: ... 或 Not a directory: ...(400) 文件夹必须存在,并且位于对话的工作目录内。