事件流
任务运行时会流式发送:生成中的回复文字、它执行的步骤、它生成的媒体,以及它的结束。有两种接收方式:
- 跟踪会话:
GET /v1/sessions/{session_id}/events会发送会话里的每一个任务,只要你保持连接。 - 在请求上流式接收:发送消息时带上
"stream": true,响应就是这一个任务的帧,以它的结束帧收尾,最长 10 分钟。
两种方式发送的帧完全相同,格式为服务器推送事件(SSE)。
帧
每一帧有一个 id、一个 event 名称和 JSON 格式的 data,后面跟一个空行:
id: 1760860912345-0:0
event: task.started
data: {"task_id": "exec-5a2c…"}
id: 1760860912400-0:0
event: text.delta
data: {"task_id": "exec-5a2c…", "delta": "Here is a vegetarian version"}
每一帧都带着所属任务的 task_id。
event | data | 何时发送 |
|---|---|---|
task.started | task_id | 任务开始。 |
text.delta | task_id、delta | 新增的回复文字。一个任务的所有增量拼起来就是它的最终文字。 |
task.progress | task_id、category、text | 正在进行的一个步骤。category 为 search、image、video、music、memory、analysis 或 other;text 是给用户看的简短说明。 |
media.updated | task_id、slot、kind、status、attachment、error | 任务生成的图片、视频、音频或文件改变了 status(见下)。 |
ui.updated | task_id、ui | 仅 a2ui 会话:任务的完整界面数据,每次变化都会发送。详见 A2UI 回复。 |
suggestions | task_id、items | 用户接下来可能发送的消息,最多三条。紧挨着结束帧之前发送。 |
task.completed | task_id、session_id、status、text、ui | 任务完成。text 是完整回复;在 a2ui 会话里,ui 是它的界面数据。 |
task.stopped | task_id、session_id、status、text、ui | 任务被停止。text 是到目前为止的回复。 |
task.failed | task_id、session_id、status、error | 任务失败。error 带 code 和 message。 |
每个任务都恰好以 task.completed、task.stopped 或 task.failed 之一结束——这个集合永远不会扩充。其他事件类型可能新增:遇到不认识的事件请忽略。
一帧可能很大:结束帧带着完整回复,在 a2ui 会话里还带着界面数据。
媒体
media.updated 按 slot 跟踪任务生成的每一张图片、每一段视频或音频、每一个文件:
- 生成过程中
status为running,之后变为最终状态之一:completed、failed或canceled。每种状态只发送一次。 kind为image、video、audio或file(尚不知道时为null)。completed的媒体带attachment:type、filename、url、content_type和expires_at。url是会过期的下载链接;会话的消息列表里总有新的链接。failed的媒体带error错误码,例如insufficient_credits或generation_failed。- 有些媒体比它的任务耗时更长:
media.updated帧可能在任务的结束帧之后才到。继续跟踪会话就能收到。
任务失败
task.failed 用 error.code 说明原因,取值来自一个可能扩充的短列表:insufficient_credits(项目余额耗尽)、timeout、input(请求或其媒体无法使用)、upstream 或 network(任务依赖的服务出错:请稍后重试),以及 provision、config、system 或 unknown(我们这边出了问题)。error.message 是给用户看的一句话。
续传:帧 id 与 Last-Event-ID
每一帧都有自己的 id——一个不透明的字符串:保存并原样传回,不要解析它。打开事件流时在 Last-Event-ID 请求头里传回其中一个,事件流就会从那一帧之后继续——不丢失,也不重复:
Last-Event-ID | 事件流发送 |
|---|---|
某一帧的 id | 那一帧之后的一切。断线重连时使用。 |
某个任务的 last_event_id | 这个任务,从它的第一帧开始。跟踪刚开启的任务时使用。 |
0 | 整个会话,从头开始。 |
| 不带,或会话已不再保存的 id | 只发送从现在起发生的内容。之前的回复在消息列表里。 |
跟踪你开启的任务
POST /v1/sessions(带第一条消息时)和 POST /v1/sessions/{session_id}/messages 都会返回 last_event_id:新任务在事件流上的起点。用它打开事件流,就会从第一帧开始收到这个任务——即使任务在你连上之前就已经开始输出。
curl -N "$NEWEYES_API_BASE/v1/sessions/$SESSION_ID/events" \
-H "Authorization: Bearer $NEWEYES_USER_TOKEN" \
-H "Last-Event-ID: $LAST_EVENT_ID"
重连
一条连接最长持续 10 分钟,网络也会中断。记住你处理过的最后一帧的 id;连接结束后,用它作为 Last-Event-ID 重新打开事件流。
心跳
没有内容可发时,事件流每 15 秒写一行注释:
: ping
如果 45 秒内什么都没有收到——没有帧,也没有 ping——就当作连接已断开,用最后一帧的 id 重连。
在请求上流式接收
在消息里加上 "stream": true,响应就是 200,内容是这个任务自己的帧,从第一帧到结束帧,然后响应结束:
curl -N "$NEWEYES_API_BASE/v1/sessions/$SESSION_ID/messages" -X POST \
-H "Authorization: Bearer $NEWEYES_USER_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"text": "Make it vegetarian.", "stream": true}'
- 被拒绝的消息仍然在任何帧之前,以错误状态码和 JSON 错误返回。
- 响应里只有这个任务的帧。结束帧之后才到的
media.updated不在其中:请跟踪会话的事件流来接收。 - 和所有事件流一样,这个响应最长持续 10 分钟,而任务可能更久,比如生成视频。
- 响应没收到结束帧就结束了,任务也不会停——不管是连接断了还是 10 分钟到了。用你收到的最后一帧的
id作为Last-Event-ID,在会话事件流上继续接收;或者用同一个Idempotency-Key重试请求,从第一帧重新接收同一个任务。 - 这个响应计入该用户的事件流数量。
在代码里读取事件流
浏览器里的 EventSource 无法发送 Authorization 请求头,所以请用 fetch(或任何能流式读取响应体的 HTTP 客户端)读取。
- JavaScript
- Python
const END = ['task.completed', 'task.failed', 'task.stopped'];
const SILENCE_MS = 45_000; // three missed pings
// Follows one task to its end, reconnecting with the last frame's id when the connection drops
// or goes silent.
async function followTask(api, token, sessionId, taskId, startAt, onFrame) {
let lastId = startAt;
for (;;) {
const silence = new AbortController();
let timer = setTimeout(() => silence.abort(), SILENCE_MS);
try {
const response = await fetch(`${api}/v1/sessions/${sessionId}/events`, {
headers: { Authorization: `Bearer ${token}`, 'Last-Event-ID': lastId },
signal: silence.signal,
});
if (!response.ok) throw new Error((await response.json()).error.code);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break; // the connection ended: reconnect from lastId
clearTimeout(timer);
timer = setTimeout(() => silence.abort(), SILENCE_MS);
buffer += value;
let end;
while ((end = buffer.indexOf('\n\n')) !== -1) {
const frame = parse(buffer.slice(0, end));
buffer = buffer.slice(end + 2);
if (!frame) continue; // a ping
lastId = frame.id;
if (frame.data.task_id !== taskId) continue;
onFrame(frame);
if (END.includes(frame.event)) {
await reader.cancel();
return frame;
}
}
}
} catch (error) {
if (error.name !== 'AbortError' && error.name !== 'TypeError') throw error;
// silent too long (AbortError) or the network dropped (TypeError): reconnect from lastId
} finally {
clearTimeout(timer);
}
}
}
function parse(block) {
const fields = {};
for (const line of block.split('\n')) {
if (!line || line.startsWith(':')) continue;
const at = line.indexOf(':');
fields[line.slice(0, at)] = line.slice(at + 1).replace(/^ /, '');
}
return fields.event ? { id: fields.id, event: fields.event, data: JSON.parse(fields.data) } : null;
}
import json
import requests
END = {"task.completed", "task.failed", "task.stopped"}
def follow_task(api, token, session_id, task_id, start_at, on_frame):
"""Follows one task to its end, reconnecting from the last frame's id when dropped."""
last_id = start_at
while True:
with requests.get(
f"{api}/v1/sessions/{session_id}/events",
headers={"Authorization": f"Bearer {token}", "Last-Event-ID": last_id},
stream=True,
timeout=(10, 45), # 45 s without a byte (not even a ping): reconnect
) as response:
response.raise_for_status()
frame = {}
try:
for line in response.iter_lines(decode_unicode=True):
if line == "":
if "event" in frame:
last_id = frame["id"]
data = json.loads(frame["data"])
if data.get("task_id") == task_id:
on_frame(frame["event"], data)
if frame["event"] in END:
return frame["event"], data
frame = {}
elif not line.startswith(":"):
name, _, value = line.partition(":")
frame[name] = value.removeprefix(" ")
except (
requests.exceptions.ConnectionError, # silent too long, or connection lost
requests.exceptions.ChunkedEncodingError, # cut off mid-frame
):
continue # reconnect from last_id
限制
每个终端用户同时最多保持 16 条事件流,每个会话最多 4 条。再打开一条时,会结束最早的一条——先结束该会话最早的一条,否则结束该终端用户最早的一条,被结束的事件流随即关闭。只有该终端用户的所有事件流都是最近 10 秒内打开的,新的事件流才会被拒绝,返回 429 rate_limited。