chrome-plugin-manga-translator

Round 14 audit — 失败要当场看得见,取消要真的停得住

这一轮从 Jasper 的一句反馈开始:

在尝试使用某些模型时,我发现会出现请求中断或翻译失败的情况。翻译失败的结果 缺乏直观展示,往往要等所有进度条读完,才提示图片失败并需要重试,这种用户 体验不太好。另外,我觉得翻译速度有点慢。抛开 API 层面,从项目本身的角度来 看,有哪些地方可以优化吗?

「翻译速度有点慢」这句话背后有好几件事,这一轮只做了其中不需要改产品 形态的那一部分。剩下的记在文末「还开着的」里,说清楚为什么没做。

本轮定了三件事:

  1. 失败要当场看得见:实时计数、图上标出、按张列原因;
  2. 取消要真的停住队列:worker 丢掉这一页还没发出去的请求;
  3. 状态码要活着走到读者面前:401 说「密钥无效」,408 说「超时」。

一、失败为什么「要等所有进度条读完」

现象

二十张图里坏了六张的那一轮,在最后两秒之前,和「一切正常」的那一轮长得 一模一样。读者唯一能看到的失败提示,是整轮跑完之后右下角那张卡片上的 一个数字。

进度条救不了这个场:失败的图也算「走完了一张」,进度条照样往前。

根因

失败这件事从来没有进过状态。

translatePage 里有一个 failedImages 集合和 firstErrorMessage 变量, 两者都活到整轮结束才写进 ContentState。中间的过程中,失败只体现在 「这一张没有渲染出译文」——而这件事在画面上和「翻译成功但这张图本来就没 字」长得一模一样。

于是 setState 的 translating 变体里根本没有「失败数」这个字段可填。 不是忘了填,是没有地方可填。

修法

把失败变成页面级的事实,和「翻译到第几张」一样,随时都是真的:

为什么按张列原因,而不是只给一个数字

以前页面里有两个关于失败的数字:几张(failedCount)和第一张的原因 (lastError)。三张图以三种原因失败时,读者看到其中一张,于是去改 API 密钥——而真正卡住这一轮的是另一张图的限流。

每张失败图现在带自己的原因,HUD 按张列出来,最多三条,多出来的只报数量 (失败的图在画面上有角标,读者想看哪张可以自己看)。

图上的角标

一张图翻译失败,页面上没有任何痕迹:译文没覆盖上去,图看起来和翻译之前 一模一样。

src/content/failure-badges.ts 在图上画一个红点:


二、取消为什么没有停住队列

现象

读者点了「取消当前翻译」,HUD 回到空闲,账单还在涨。

根因

「取消」这件事只发生在页面这一侧。

页面上有 abortController,processSingleImage 每次发请求前会问一次它。 但 worker 队列里排着的 job 是另一个东西:HUD_CANCELLED 这条消息 只是被转发给弹窗,队列一个 job 都没少。队列在所有标签页之间共享,它自己 不知道「读者不想翻这一页了」。

两处具体地漏掉了:

修法

给每一轮一个身份。

pageKey 认的是一张图(队列靠它去重),它回答不了「读者取消时该丢掉 哪些」。所以另加一个 runScope:一个内容脚本一份,挂在它发出的每一个 请求上。

已经在飞的 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

一处必须诚实说明的取舍

状态码优先于原因文本。一条部分失败的条漫,读者看到的是「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),而服务端多半还是会算这一 次。

上架仍然是 Jasper 一个人的事

自动化已经就位(pnpm release:publish 会打包、上传、发布),但 Chrome Web Store 的 dashboard item、PUBLISHER_ID / ITEM_ID、Google Cloud 的 API 凭据、支持页 URL —— 这些在仓库里一份都没有,只有 Jasper 能建。