实时事件与通知
Chorus 让每个打开的浏览器与人和智能体正在做的事保持同步。每当有变化发生(任务被移动、 想法被认领、提案被批准),该变化会通过 Server-Sent Events(SSE)广播出去,受影响的页面会 自动刷新。智能体后台服务同样监听 SSE:它的通知流是可唤醒智能体得知新工作的途径。本页记录 SSE 端点及其事件结构、后台服务如何认证并找回漏掉的工作,以及未运行后台服务的智能体(和浏览器 客户端)调用的通知 REST API。
实时更新如何流转
Section titled “实时更新如何流转”每次变更,无论是通过 MCP 工具、REST API 还是 Web 界面做出的,保存后都会发布一条变更事件。SSE 端点 把匹配的事件推送给每个已连接的浏览器,浏览器稍等片刻后重新加载当前页面的数据:
变更已保存(MCP 工具 / REST API / Web 界面) → 发布变更事件 → GET /api/events 按工作区 + 项目过滤 → 浏览器 EventSource 收到该事件 → 500ms 后,页面重新加载数据这种刷新刻意做得粗粒度:单条事件只告诉页面“你正在看的某样东西变了”,随后页面会从服务器 拉取最新数据。任何事件都不携带变更后的记录本身。
SSE 端点
Section titled “SSE 端点”有两个端点推送事件。二者都是 GET,都接受浏览器会话 Cookie 或 Authorization: Bearer
API 密钥(见智能体如何接收事件),且响应都不会被缓存。
| 端点 | 推送内容 |
|---|---|
GET /api/events?projectUuid=<uuid> | 实体变更事件,限定在你所在的工作区;提供 projectUuid 时,进一步限定到该项目。 |
GET /api/events/notifications | 已认证调用者的通知事件,从而让应用内的通知指示器实时更新。智能体后台服务订阅这条流,作为它的主要唤醒通道。 |
每个响应都带有以下响应头与行为:
Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive。- 连接建立时,数据流先写入一条
: connected注释,随后以data: <json>帧的形式推送事件。 - 心跳(Heartbeat): 每 30 秒发送一条
: heartbeat注释,以便在空闲期与中间层超时的 情况下保持连接打开。 - 断开时清理: 当客户端中止请求(标签页关闭、页面跳转、网络断开)时,端点会停止推送 并清除心跳定时器。
- 多租户: 变更数据流会丢弃任何
companyUuid与调用者不匹配的事件,因此客户端不会 收到其他工作区的活动。
变更事件结构
Section titled “变更事件结构”GET /api/events 上的变更事件是结构固定的 JSON 对象:
interface RealtimeEvent { companyUuid: string; // 多租户隔离——数据流会丢弃不匹配的事件 projectUuid: string; // 用于可选的 projectUuid 过滤 entityType: "task" | "idea" | "proposal" | "document"; entityUuid: string; // 发生变更的实体 action: "created" | "updated" | "deleted";}一条事件只说明哪个实体变了、如何变的,从不携带该实体的字段。客户端仅把它当作 刷新信号。
浏览器会向 /api/events?projectUuid=<uuid> 打开一个 EventSource,并在收到任何消息时,
等待 500ms 再刷新页面数据。这段等待会把来自同一个逻辑动作的一连串事件合并为单次刷新(例如
批准一个提案会发出一条提案更新事件,外加每个被创建任务各一条事件)。标签页隐藏时连接会被
关闭,重新可见时会重新打开(并触发一次刷新);离开页面时连接彻底关闭。
智能体如何接收事件
Section titled “智能体如何接收事件”智能体通过 MCP 工具或 REST API 行动,它做出的每次变更都会发出变更事件,因此正在看同一个项目的
浏览器用户会实时看到智能体的工作出现。智能体也会消费 SSE:项目变更流(GET /api/events)
只由浏览器读取,但智能体后台服务会订阅通知流
GET /api/events/notifications,这条流是它的主要唤醒通道。
两种认证方式
Section titled “两种认证方式”两个 SSE 端点都会先检查 Bearer 令牌,再回退到 Cookie:
| 客户端 | 传输方式 | 认证 |
|---|---|---|
| 浏览器 | EventSource | 会话 Cookie,自动发送。 |
| 智能体后台服务 | 用 fetch 读取流式响应 | Authorization: Bearer cho_… 加上 Accept: text/event-stream。 |
EventSource 无法设置自定义请求头,所以浏览器依赖 Cookie。这个限制只针对 EventSource:
后台服务是一个用 fetch 读取数据流的 Node 进程,因此它像其他请求一样发送智能体 API 密钥。
待处理轮次的找回
Section titled “待处理轮次的找回”GET /api/daemon/pending-turns?connectionUuid=<uuid> 使用智能体的 Bearer 密钥调用,返回属于该
后台服务连接的会话中仍处于 pending(尚未开始)状态的轮次。它是找回路径,不是 SSE 的替代品。
每个轮次在创建时就已保存,所以即使某次唤醒的 SSE 投递丢失了,也仍能被找到并执行。
后台服务会在三个时机读取它:
- 注册时。 每当 SSE 握手确认连接(包括重连之后),后台服务会立即扫描一次待处理轮次。
- 连接期间每 30 秒一次。 周期扫描即使在从未断开的连接上,也能找回丢失的投递。
- 收到
deliver_turn提示时。 后台服务读取该连接的待处理轮次,再筛选通知指定的轮次进行派发;若周期找回同时到期,也会执行扫描。
注册和周期扫描只自动重放具有受支持的持久化唤醒上下文或操作负载的轮次。两者都没有的旧待处理轮次会被跳过,并记录 automatic historical replay blocked;等待或重连不会重放它们。实时 deliver_turn 仍可指定某个待处理轮次进行派发。
找回机制与 SSE 连接相互配合:
- 静默断连。 如果数据流 75 秒内没有收到任何内容(既没有事件,也没有服务器每 30 秒一次的 心跳),后台服务会中止连接并重连。
- 重连期间找回暂停,新连接一注册成功就以一次扫描恢复。
daemon → GET /api/events/notifications (Bearer cho_…)server → connection registereddaemon → GET /api/daemon/pending-turns (sweep on registration)daemon → GET /api/daemon/pending-turns (every 30s)server → new notification / deliver_turn (primary path) … no bytes for 75s → abort, pause recovery, reconnect …daemon → GET /api/events/notifications (reconnect)server → connection registereddaemon → GET /api/daemon/pending-turns (catch-up sweep)每个后台服务连接在内存中保留一份已接受的轮次与通知记录,由实时数据流和找回读取共用。在同一个
后台服务中,经两条路径都到达的轮次只会被分派一次,以先看到它的路径为准。这份记录能跨越 SSE 重连,
但无法跨越后台服务重启;当启动轮次被拒绝或取消时,相应记录会被释放,以便重试该轮次。另外,
服务器只允许尚未被认领的 pending 轮次开始运行。以上都不构成端到端的“恰好一次”保证。
没有后台服务的智能体
Section titled “没有后台服务的智能体”未在后台服务下运行的 MCP 智能体不持有 SSE 连接。它通过下文的
chorus_get_notifications MCP 工具与 /api/notifications REST 端点接收其工作。
通知 REST API
Section titled “通知 REST API”通知是浏览器客户端以及未在后台服务下运行的智能体使用的持久化“轮询并行动”路径。接收者由认证上下文推导 得出:以用户身份认证的请求读取该用户的通知,以智能体 API 密钥认证的请求读取该智能体的通知。 同一组路由服务两者,且没有接收者参数。
| 方法与路径 | 用途 | 关键参数 |
|---|---|---|
GET /api/notifications | 列出调用者(用户或智能体)的通知。返回 { notifications, unreadCount }。 | 查询参数:limit(1–100,默认 50)、offset(默认 0)、unreadOnly(设为 true 只返回未读)、projectUuid(限定到一个项目)。 |
GET /api/notifications/unread-count | 以 { count } 返回调用者的未读数量。 | 无 |
POST /api/notifications/read-all | 将调用者的所有通知标记为已读。 | 可选 JSON body { projectUuid },把范围限定到一个项目。 |
PATCH /api/notifications/[uuid]/read | 将一条通知标记为已读。 | 路径参数:通知 uuid。 |
PATCH /api/notifications/[uuid]/archive | 归档一条通知。 | 路径参数:通知 uuid。 |
GET /api/notifications/preferences | 读取调用者的通知偏好。 | 无 |
PUT /api/notifications/preferences | 更新调用者的通知偏好。 | 偏好字段的 JSON body。 |
limit 会在服务端被限制在 1–100 之间,offset 小于 0 时按 0 处理。对不属于调用者的通知执行
标记或归档会返回 404,而不会透露它是否存在。
每个通知 REST 路由都返回标准的 Chorus JSON 信封。成功时把载荷放在 data 之下:
{ "success": true, "data": { "notifications": [], "unreadCount": 0 } }出错时改为携带一个结构化的 error 对象:
{ "success": false, "error": { "code": "NOT_FOUND", "message": "Notification not found" } }常见失败使用错误码 NOT_FOUND、BAD_REQUEST、UNAUTHORIZED 和 FORBIDDEN,分别对应
HTTP 状态码 404、400、401、403。没有有效会话或智能体密钥的请求会被拒绝:
{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Authentication required" } }HTTP 状态码为 401。(SSE 端点不使用这一信封格式:对未认证的请求,它们返回纯文本 Unauthorized
和状态码 401。)
单实例与多实例投递
Section titled “单实例与多实例投递”默认情况下,变更事件在内存中传递,这对单实例部署已经足够:处理变更的进程正是持有每个 SSE 连接的 进程,因此在本地发出一条事件即可到达每个已连接的浏览器。
运行超过一个实例会打破这一前提:发生在实例 A 上的变更仍必须到达连接在实例 B 上的浏览器。 对于多实例部署,应通过 Redis pub/sub 投递事件:每个实例订阅一个共享频道,变更 发布到 Redis 而非仅在本地发出,每个实例的 SSE 端点再把收到的内容转发给自己已连接的浏览器。 SSE 端点与浏览器端保持不变,只是事件在实例之间的传递方式不同。
通知相关概念
Section titled “通知相关概念”本页只涵盖投递机制。关于哪些事件会生成通知(各个类别、@提及、每类活动会通知谁),以及 接收者如何选择接收或屏蔽,参阅协作参考。