你创建了一个作业组。某个用户正盯着加载动画看,“正在翻译成 14 种语言……”这句话没错,却没什么用——它根本不会动。你真正想要的是让数字在用户眼前往上跳:先是 3 个已完成,然后 4 个,接着某个语言区域失败,最后全部结束。
轮询 作业组 也能实现,但请求会很频繁,而且每次轮询拿到的都是一份全新快照;你还得和上一次结果做 diff,才能知道到底变了什么。WebSocket 则反过来。连一次就够,之后每当某个语言区域处理完成,服务器都会主动推送事件——而且每条消息都携带完整的组状态,所以你只需要渲染快照,根本不用对增量做合并。哪怕丢了一帧、断线重连,或者标签页重开,下一条消息依然会把完整状态原样带回来。
GET /jobs/localization/groups/:groupId/ws如果你刚接触异步本地化,建议先看 概览。这里的 groupId,就是你在 创建作业 时拿到的那个。
本页内容
消息类型#
Socket 上会传输四种消息。每一种都会告诉你刚刚发生了什么,同时附带整个组的当前状态。
| 类型 | 触发时机 | 关键字段 |
|---|---|---|
snapshot | 初次连接时 | 完整组状态 |
job.completed | 某个语言区域成功完成时 | jobId、locale,以及完整组状态 |
job.failed | 某个语言区域失败时 | jobId、locale、error,以及完整组状态 |
group.completed | 所有作业都已完成时 | groupId、status,以及完整组状态。发送完这条消息后,服务器会关闭连接。 |
每条消息都包含一个 snapshot 对象,用来表示当前组状态:totalJobs、completedJobs、completedWithWarningsJobs、failedJobs,以及一个以作业 ID 为键的 jobs 映射;映射中的每一项都带有各自的 locale 和 status。这些计数和 作业组端点 返回的是同一套数据——也就是说,无论是从 socket 收到快照,还是从 REST 端点轮询,看到的组进度都是一致的。
只渲染快照,不做增量合并
你不需要追踪哪些事件已经看过,也不需要补放漏掉的消息,更不用把局部更新合并进本地状态。每次收到消息时,直接读取 snapshot,然后据此渲染 UI 即可。重连后,服务端会先重新发送 snapshot,所以无论是刚加入的客户端,还是一路监听到现在的客户端,最终都会收敛到同一个状态。
消息负载#
下面就是服务器实际发送的完整数据帧。ID 使用的都是真实格式(组的 ljg_、每个作业的 ljb_);而 snapshot 只会在重复前面已展示结构的地方,才用 "..." 做简写。
建立连接后,服务器会先发送当前状态:
{
"type": "snapshot",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 1,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "processing" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "queued" }
}
}
}每当某个语言区域完成时,事件都会标明发生变化的语言区域,并附上更新后的快照:
{
"type": "job.completed",
"jobId": "ljb_B2c3D4e5F6g7H8i9",
"locale": "fr",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 2,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "completed" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "processing" }
}
}
}失败也是正常消息,不代表连接中断。job.failed 会携带对应的语言区域和一个 error,同时附带同样的完整快照——失败的语言区域会在 jobs 映射中显示为 status: "failed",其他语言区域仍会继续推送,socket 也会一直保持连接,直到 group.completed:
{
"type": "job.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"locale": "ja",
"error": "Model timeout after 30 seconds",
"snapshot": { "...": "..." }
}当所有作业都处理完毕后,服务器会发送最后一个事件并关闭连接:
{
"type": "group.completed",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"status": "completed",
"snapshot": { "...": "..." }
}最终的 status,如果所有语言区域都成功,则为 completed;如果所有语言区域都有输出结果,但其中至少一个语言区域的某些可选 pipeline 阶段失败,则为 completed_with_warnings;如果部分语言区域成功、部分失败,则为 partial;如果全部失败,则为 failed。想了解这些状态对整个组分别意味着什么,请参阅 跟踪作业组。
遇到无法识别的内容时,直接按快照渲染
已知的消息类型可以按分支处理;遇到任何不认识的情况,就直接回退到基于 snapshot 重新渲染。因为每条消息都带着完整快照,所以即使客户端收到一个自己没有专门分支处理的数据帧,只要默认按快照绘制,状态依然是正确的。
如何接入 UI#
这个组就是你的进度模型。当你 创建作业 时,返回的 202 会给你一个 groupId 和一个 jobs 数组——每个语言区域对应一项。用这个响应来初始化进度记录,你就先拿到了后续 socket 会不断填充的那份结构:要累计的总数,以及一个从零开始的计数器。
const { groupId, jobs } = await response.json();
await db.translationProgress.create({
contentId: content.id,
groupId,
totalLanguages: jobs.length,
completedLanguages: 0,
});接着,针对这个 groupId 打开 socket,并在每条消息到来时读取 snapshot 重新渲染。你会看到计数随着各个语言区域陆续完成而不断增长,并在收到 group.completed 时停止:
import WebSocket from "ws";
const groupId = "ljg_A1b2C3d4E5f6G7h8";
const ws = new WebSocket(
`wss://api.lingo.dev/jobs/localization/groups/${groupId}/ws`,
{ headers: { "X-API-Key": process.env.LINGO_API_KEY } }
);
ws.on("message", (raw) => {
const event = JSON.parse(raw);
const { snapshot } = event;
switch (event.type) {
case "snapshot":
console.log(`${snapshot.completedJobs}/${snapshot.totalJobs} complete`);
break;
case "job.completed":
console.log(`${event.locale} ready (${snapshot.completedJobs}/${snapshot.totalJobs})`);
break;
case "job.failed":
console.error(`${event.locale} failed: ${event.error}`);
break;
case "group.completed":
console.log(`All translations done: ${event.status}`);
ws.close();
break;
}
});如果运行的是一个包含三个语言区域的组,输出会像这样实时打印:
1/3 complete
fr ready (2/3)
ja failed: Model timeout after 30 seconds
All translations done: partial计数器会自己往前走,某个语言区域失败也不会让这条流中断,而 partial 会明确告诉你这次运行最终停在什么状态——这正是把加载动画变成真正进度条所需要的信息。注意,这个循环从头到尾都不会累积状态:每个分支都只读取当前消息里的 snapshot,所以同一份代码在首次连接、每次更新以及重连时都始终正确。
将 API 密钥保留在服务端#
这个 socket 使用你的 API 密钥进行认证,也就是 REST 端点所用的同一个 组织范围密钥。这也意味着,浏览器并不是打开它的地方——一旦把 API 密钥放进客户端 JavaScript,任何能查看源码的人都能借此访问你组织中的所有引擎。
从后端连接,不要直接在浏览器里连
应该由你的服务器来建立 WebSocket 连接,因为密钥本来就保存在那里;然后再通过你自己的通道——例如你可控的 WebSocket 或 server-sent events 流——把这些事件转发给浏览器。这样前端照样能拿到实时进度,而密钥始终不会离开你的基础设施。
这和 webhook 的模式是一样的:真正连接到 Lingo.dev 的一端在服务端,最终到达用户的内容,则由你自己的应用决定转发什么。
适用场景#
WebSocket 提供的是实时视图——它只绑定到一个组,并会在该组结束后关闭。若你需要一种更持久、服务器到服务器的传递方式,能够跨越标签页关闭或部署重启继续可靠运行,就把它和 webhooks 搭配起来:当运行过程展示在界面上时,socket 负责驱动 UI;而 webhook 会在每个结果落地的第一时间完成记录。两者都从同一个 create call 接起来后,用户能实时看到进度,而你的后端也能在无论是否有人盯着页面时都保留输出结果。
