这一轮从 Jasper 的一句反馈开始:
在尝试使用某些模型时,我发现会出现请求中断或翻译失败的情况。翻译失败的结果 缺乏直观展示,往往要等所有进度条读完,才提示图片失败并需要重试,这种用户 体验不太好。另外,我觉得翻译速度有点慢。抛开 API 层面,从项目本身的角度来 看,有哪些地方可以优化吗?
「翻译速度有点慢」这句话背后有好几件事,这一轮只做了其中不需要改产品 形态的那一部分。剩下的记在文末「还开着的」里,说清楚为什么没做。
本轮定了三件事:
二十张图里坏了六张的那一轮,在最后两秒之前,和「一切正常」的那一轮长得 一模一样。读者唯一能看到的失败提示,是整轮跑完之后右下角那张卡片上的 一个数字。
进度条救不了这个场:失败的图也算「走完了一张」,进度条照样往前。
失败这件事从来没有进过状态。
translatePage 里有一个 failedImages 集合和 firstErrorMessage 变量,
两者都活到整轮结束才写进 ContentState。中间的过程中,失败只体现在
「这一张没有渲染出译文」——而这件事在画面上和「翻译成功但这张图本来就没
字」长得一模一样。
于是 setState 的 translating 变体里根本没有「失败数」这个字段可填。
不是忘了填,是没有地方可填。
把失败变成页面级的事实,和「翻译到第几张」一样,随时都是真的:
ContentStateReport 加 failedCount 和 failures(每张图一条:序号 +
原因)。它们放在 ContentStateReport 而不是 complete 变体里,因为
「已经坏了几张」在翻译进行中就是真的。onError 处理完一张失败图之后,立刻再发布一次状态:进度条继续走,
但 HUD 上已经写着「已有 2 张翻译失败」。以前页面里有两个关于失败的数字:几张(failedCount)和第一张的原因
(lastError)。三张图以三种原因失败时,读者看到其中一张,于是去改 API
密钥——而真正卡住这一轮的是另一张图的限流。
每张失败图现在带自己的原因,HUD 按张列出来,最多三条,多出来的只报数量 (失败的图在画面上有角标,读者想看哪张可以自己看)。
一张图翻译失败,页面上没有任何痕迹:译文没覆盖上去,图看起来和翻译之前 一模一样。
src/content/failure-badges.ts 在图上画一个红点:
reading-anchors.ts)早就踩过这个坑。读者点了「取消当前翻译」,HUD 回到空闲,账单还在涨。
「取消」这件事只发生在页面这一侧。
页面上有 abortController,processSingleImage 每次发请求前会问一次它。
但 worker 队列里排着的 job 是另一个东西:HUD_CANCELLED 这条消息
只是被转发给弹窗,队列一个 job 都没少。队列在所有标签页之间共享,它自己
不知道「读者不想翻这一页了」。
两处具体地漏掉了:
cancelAll()(扩展
整体关掉时用)。而 cancelAll 在这里又是错的答案:读者取消的是这一页,
另一个标签页正在翻下一章。TranslatorService 有一个 abortController 字段和
cancel() 方法,从来没被赋值过。切片那一层读的是
this.abortController?.signal——所以那个信号一直是 undefined。一条
8000 像素的长条漫被切成十几片,读者点了取消,剩下的片照跑照计费。给每一轮一个身份。
pageKey 认的是一张图(队列靠它去重),它回答不了「读者取消时该丢掉
哪些」。所以另加一个 runScope:一个内容脚本一份,挂在它发出的每一个
请求上。
HUD_CANCELLED 时带上 runScope;runScope 精确丢掉这一页还没开始的 job(cancelByRunScope),
别的标签页的章节不受影响;translateImage 的 options 里
带 signal,切片池在取下一片之前看信号,裁完片再发请求之前再看一次
(裁片要花时间,画 canvas、编码——等裁完才发现已经取消,这一片的钱就白
花了)。已经在飞的 job 不动。 请求已经在路上,收不回来,那一笔钱也已经花了。 这是这条路径一直以来的诚实边界,不在这一轮改。
401(密钥无效)和 408(超时)到了读者那里是同一句话:「发生未知错误」。 这句话既不指出原因,也不指出下一步。
状态码只以文本的形式活着,而那个文本协议有个硬规矩:
const HTTP_STATUS_MARKER = /^\[HTTP (\d{3})\]/;
锚在消息开头。这不是细节 —— 唯一一段不是我们写的文本是解析失败时
模型自己的前 200 个字符;一个回答 [HTTP 408] request timed out 的模型
会让扩展读出自己的输出当状态码。
于是 worker 自己拼的句子就废了:
所有切片翻译请求均失败:[HTTP 401] Invalid API key
^^^^^^^^^^^^ 中间的标记,谁也读不到
分类器读不到状态码,就退回关键词匹配,读不到关键词,就落到 UNKNOWN。
而且 worker 主动丢掉了数字形式的状态码:它把错误拍平成字符串再跨进程 边界。
状态码以数字的形式一路走回来:
provider → provider-direct-client(.status)→ worker(errorStatus)
→ 内容脚本(errorStatus)→ 翻译器(Error.status)
→ TranslationResult.errorStatus → 内容脚本的 Error.status
→ parseTranslationError
TranslateImageJobResponse 加 errorStatus?: number。文本里的标记照旧
写(别处还在读它),但那是给更老的 worker 兜底的。TranslationResult 加 errorStatus?: number,TileTranslationResult 同理。
切片层面的失败原因以前只留一句文本,状态码更没地方放——现在切片失败带上
自己的状态码,几片失败的状态码一致时才汇总上报(401 和 408 混在一
起时报一个,就是在替其中一部分撒谎)。parseError 现在把状态码挂在 FriendlyError 上:重试助手会把原始错误
换成友好错误,不挂上的话状态码就在这一步没了。status 挂到 Error 上——分类器读的就是它。状态码优先于原因文本。一条部分失败的条漫,读者看到的是「API 密钥无效, 请检查配置」,而不是「2/4 个切片翻译失败」。切片计数这个细节被盖住了。
这是有意的:密钥错了就得改密钥,而「2/4 片失败」不改变下一步。但它确实 是取舍,不是免费得到的。
一条条漫的某些切片是纯画面(气泡之间的空白)。模型如实回答「这里没有文字」 ——这是一个答案。而质量门会再问一次,换回同一句话,代价是一次请求。
判据写成了「没有译文 → 再问一次」,而它把两件事混成了一件:
判据从「没有译文」收窄成「找到了框却没有译文」。
degrades when only some tiles answered and none produced text 这条测试
原来断言「整图必须被发出去」。它是通过意外通过的:因为「这里没有文字」
会被再问一次,而第二次问会失败,于是每一片都变成失败,整条走进
「全部失败」分支,整图兜底才被允许。那是「每一片都失败」的兜底,不是
「部分失败」的策略。
去掉那次多问之后,真正失败的只有那几片,于是这条漫画走「部分失败」分支: 报错、留在未处理状态、让读者按张重试——而不再付一次整图的钱。整图那条 路会把 800x4000 压成 205x1024 送出去,读者拿到的是一张看不清字的图。
所以这条测试的断言改了:守住「不能报成翻译成功但什么都没有」和「不能多花
一次整图的钱」,而不是「必须发整图」。测试名也改了
(reports the failure and its reason when only some tiles failed)。
| 测试 | 变异 | 结果 |
|---|---|---|
| HUD 实时失败数 | 不发布中间状态 | 红 |
| HUD 按张列原因 | 只留第一张 | 红 |
| 药丸上的失败数 | 收起时不带 | 红 |
| 图上角标 | 不画角标 | 红 |
| 角标随重试撤掉 | 重试后不清 | 红 |
| 状态码 → 原因 | 丢掉 status | 红(变成「发生未知错误」) |
| 按页取消 | 取消时丢掉全部 | 红(另一个标签页的 job 也没了) |
| 取消不碰别的标签页 | 取消时不带 runScope | 红 |
| 取消丢掉排队中的 job | 不调 cancelByRunScope | 红(job 一直排着) |
| 去重名额被释放 | 丢掉的 job 不释放去重 | 红 |
| 空切片只问一次 | 旧的判据(无译文就再问) | 红 |
| 有框无译文仍然再问 | 判据收窄过头 | 红 |
| 取消停住切片循环 | 切片层不接信号 | 红(发了 4 片而不是 1 片) |
| 状态码跨 worker 边界 | worker 不传 errorStatus | 红 |
| 请求带 runScope | 不发 runScope | 红 |
这一轮真正影响速度的只有三件事:不再为一个「这里没有文字」的答案多问一次 (一条十二片的条漫,纯画面的片各少一次请求)、取消真的停住剩下的切片、 以及失败立刻可见(读者不用等整轮结束才知道要重试哪几张)。
分析里查出来、但这一轮没做的:
| 问题 | 为什么没做 |
|---|---|
| 「5 张」这个选项是死的 | 上下都被夹住:用户改不动、代码也不看它。放开要改产品形态(这属于「以后再加」) |
| 每一片都重新下载整图 | 改成「取一次原图、切 N 片」是本轮最大的一处结构改动,风险和收益都不小,需要单独一轮 |
| 每张图都问一次 worker 拿配置 | 纯浪费,改起来安全,但收益小,留到下一轮 |
| 并发度的两道夹子 | 放开并发 = 更多并发请求 = 更容易被限流。这不是「优化」,是取舍,要读者自己定 |
| 超时与重试策略 | 现在是「失败就失败」。加策略要先定「什么算超时」,那是一条产品决策 |
| 像素级框精修、渲染在主线程 | O(像素) 的活儿压在主线程上,长条漫会卡。要搬到 Worker/OffscreenCanvas,是一次架构改动 |
「点这个角标就重试这一张」是最自然的下一步,但角标一旦能点,它就是一个 会花钱的按钮。它得先问一次「要重试这一张吗,会再花一次钱」——而卡片上 已经有「重新翻译失败的 N 张」这个入口了(那个入口会说明它要花钱)。这一轮 先不做。
已经在飞的请求收不回来,那一笔钱也已经花了。要真正掐断它,得让 provider
支持中途取消(AbortController 传到 fetch),而服务端多半还是会算这一
次。
自动化已经就位(pnpm release:publish 会打包、上传、发布),但
Chrome Web Store 的 dashboard item、PUBLISHER_ID / ITEM_ID、Google
Cloud 的 API 凭据、支持页 URL —— 这些在仓库里一份都没有,只有 Jasper
能建。