前十轮都在问一句话:这句话是真的吗? 钱花在哪、有没有人授权过、界面上说的话和代码做的事对不对得上。
这一轮换了个方向,问的是一个更朴素的问题:
这个答案,是什么时候取的?用到它的时候,它还成立吗?
派了三位审查员,同时自己把「排队 → 执行 → 出素材」这条链从头走了一遍。
结果:链上有四处,答案取早了,或取到之后没人再看它一眼。
worker 拒绝一个任务时(主开关关了、服务商没配好),把理由压成一个字符串 交回页面。页面拿这个字符串去过错误分类器,而分类器的类别里全是 “服务商出了问题”:401、429、超时、内容审核……
没有”扩展自己还没准备好”这一类。
于是它回答 UNKNOWN_ERROR,再加上原话:
发生未知错误:当前服务商还没有配置好,请先在设置页完成配置
后半句是对的,前半句把它框成了”一个我们自己也说不清的故障”。 一个用户自己五分钟就能改好的设置,被渲染成需要去找客服的样子。
worker 和页面各写了一份那句话的文案,页面靠关键词去猜它属于哪一类。 两份文案会漂移,而猜的那个分类器根本没有这一类可猜。
拒绝语搬进共享协议(src/shared/runtime-contracts.ts)。worker 说的是
协议里的句子,页面认的也是协议里的句子 —— 不再猜,直接查表。
命中时页面渲染:worker 的原话(不再套「发生未知错误」)+ 一句下一步 + 那颗「打开设置」按钮。
并且整个翻译在这一张就停下来。 没配好的服务商对每一张图的失败都一样, 旧路径会把剩下的十九张挨个跑一遍、挨个报一次”失败”。用户会看到二十条 同一条错误,而答案在第一行。
src/background/background.ts 在入队那一刻解析一次服务商,然后把这个结果
交给队列里的任务:
const active = resolveActiveProvider(currentConfig); // ← 入队时取一次
...
return translationJobQueue.enqueue({
job,
run: async () => {
const configAtRunTime = await getConfig(); // ← 运行时会再读一次
if (!isTranslationEnabled(configAtRunTime)) { ... } // 但只用来看主开关
const response = await translateImageViaProviderDirect({
provider: active.provider, // ← 用的是十分钟前那个
apiKey: active.apiKey,
...
主开关在运行时会再查一次,服务商不会。
同一段代码里,主开关那段的注释写着理由:
The check above only covers the moment the job was queued. A chapter sits in the queue for minutes — one job per tile, a vision model that takes 10-30s each — and the user can turn the extension off while it waits. “Off” has to mean off all the way down, or the extension bills the user for a switch they turned off.
同一个理由,对服务商成立得更强。
用户换服务商,最常见的原因就是不想再花钱 —— 从云端换到本机。 换完之后,队列里剩下的那十几张图还是往刚离开的那个云端接口发, 扩展于是替用户继续付他刚刚决定不付的钱。
另一个方向同样坏:密钥过期了,用户去设置页换了一把新密钥。 队列里的任务拿的还是那把旧的,于是对着一个已经不存在的密钥失败 —— 用户看到的是「认证失败」,而他明明刚换过密钥。
服务商和主开关一样,在任务真正开始的那一刻再解析一次:
const activeAtRunTime = resolveActiveProvider(configAtRunTime);
再补一道闸:运行时的服务商如果还没配好(没有地址,云端的还没有密钥), 任务停下来,并说清楚该去做什么 —— 而不是带着一个空密钥去请求, 然后报一个跟这张图毫无关系的「认证失败」。
这条闸只在公开版里可能触发:个人版在构建时预置了密钥,
normalizeProviderSettings 会用它补上空的字段。
scripts/capture-store-assets.mjs 以前只等「翻译流程结束」,
不检查结果。模型连不上时,那次「翻译」也是结束的 ——
于是录下来的三十秒里全是「请求超时,请稍后重试」,截图里是一张没翻的漫画。
商店素材是一次失败的录像。
三道检查,任何一道不过就停下来,不碰已有的文件:
count + cachedCount > 0)?.manga-translator-overlay 的数量)?失败时的报错直接写清楚原因和下一步:
「Chrome 从这台机器上到不了服务商 —— 看上面那行 worker -> provider,
如果服务商本身能到,试 --forward。」
--forward 改的是没人读的那份配置--forward 的意思是:本机跑一个转发器,把扩展的请求转到服务商。
它要改扩展里的服务商地址。
它改的是 providers[provider].baseUrl。而 worker 解析服务商时,
顶层那份 state.openaiCompatible 优先 —— 于是这次修改落在了
一个没人读的字段里,扩展照旧去请求那个到不了的地址。
这个模式一直是坏的,而且坏得很安静:脚本不报错,只是翻译一直失败。
两份都改,并且把最终生效的服务商和地址打印出来:
[capture] config patched: provider=openai-compatible baseUrl=http://127.0.0.1:62993
脚本知道页面能不能连上(它自己开了个本地服务器), 但真正发请求的是 worker,而这两者在这台机器上可以是两个世界。
没有这一行的时候,失败只有一句话:「翻译超时」。
脚本现在从 worker 内部探一次服务商的地址,把结果和耗时打印出来:
[capture] worker -> provider: unreachable after 5003ms — The operation timed out
并且把 worker 的 console 输出和网络请求转发进采集日志。 这一行是「脚本坏了」和「这台机器到不了」之间的全部区别 —— 而后者才是真相。
store/listing/demo.mp4 里录的是「成功 0 张,失败 2 张 / 请求超时」。
商店的视频是选填的。一段拍失败的视频比没有视频更糟: 它会被挂到商品页上,成为这个产品给人的第一印象。
删掉了。 采集脚本仍然能在能连上服务商的机器上重新生成一个 —— 流程是文档里写好的那一条命令。
自动续翻有一个预算:一页最多二十轮自动扫描(防止一个无限滚动的页面 一直触发新的计费请求)。预算用完时,观察器停了。
但页面上没有一个人知道这件事。
读者滚动、等待,什么也不会发生。而这两句话都是当时取的答案, 它们在预算用完的那一刻就过期了,没有人再去看一眼。
applyAutoTranslateMode() 每次拿到新的配置快照都会重新武装观察器 ——
用的是配置上的开关,不是实际的状态。于是预算用完之后,
下一次配置刷新又把观察器立了起来:它每一轮都会被触发,每一轮都被
预算挡住,什么都不做。
一个不会发生任何事情的观察器,和一个根本不存在的观察器, 在读者眼里是一样的。
把”这一页现在还会不会自动翻译新图片”收成一个函数, 配置和预算都要看:
function isAutoContinueArmed(): boolean {
return isAutoTranslateEnabled && autoTranslateRunsRemaining > 0;
}
上报给 HUD 和弹窗的 autoContinueArmed 用的是它,
观察器武装用的也是它。
另外多报一个字段 autoContinueBudgetExhausted:光说”不会自动翻译了”
不够,读者分不清是自己没开开关还是这一页翻够了次数 ——
弹窗以前把它说成「「自动续翻」已打开,但本站不在名单里,本页也没有手动
翻译过」,每一句都是假的(开关是开着的,本页也翻过)。
现在弹窗说的是:
这一页的自动续翻已经用完次数,新出现的图片不会再自动翻译(不会产生 费用)。再点一次「翻译当前页面」可以重新开始。
顺带把”再来一次”的路径确认了一遍:用户手动翻一次,预算就补满
(origin === 'user' 时重置)。读者不会被卡死。
观察器去抖 800 毫秒,而一次视觉模型请求要十到三十秒。
一张图在这轮翻译进行当中加载出来 → 观察器去抖 → 800 毫秒后触发 →
shouldAutoTranslateFollowUp() 看到页面状态是 translating,
拒绝(翻译中不能再来一轮)→ 这一次续翻被丢掉。
然后没有任何东西重新排程。
这一轮跑完之后,这一页就停在”有一张图没翻”的状态上 —— 而弹窗还在说「滚动到它们出现后会自动继续翻译」。
这一轮结束的时候(互斥锁释放之后)再排一次:
if (!translationCancelled && !fatalError && !wasAborted && isAutoContinueArmed()) {
autoTranslateScheduler.schedule();
}
三个”不”是刻意的:
页面可以在翻译之后把图片的地址换掉:懒加载站点把缩略图换成大图是 常规做法,阅读器里点一下”看大图”也是。
换完之后,屏幕上已经是另一张画面,而旧译文还盖在上面 —— 它描述的是别的像素。框的位置也未必对。
而且新地址那张图再也不会被翻译了:第九轮定的规矩是 “观察器只问从来没问过的图”,而这张图问过了(问的是缩略图)。
于是这一页停在”盖着旧译文的原图”上。
译完一张图就记住它当时是哪个地址,并听它自己的 load 事件 ——
属性可以先改、字节后到,只有 load 才意味着”新画面已经到位”。
画面变了就做三件事:
removeTranslation,面板和锚点也跟着撤)。地址只差一个版本号(page.webp?v=2)不算换画面:那一层译文还好着,
不该被白白撤掉。
「清除本页译文」取消正在跑的那一轮,但那一轮还在收尾: 它发出去的请求还没回来。等它们回来的时候,它把已经画好的译文又画回 刚被清空的画面上,然后宣布「翻译完成」。
页面先是空的,然后又不是了。
clearAll() 不再把 translationCancelled 复位。那一轮读这个标记来决定
“要不要报告结果” —— 复位了它就会以为自己是正常跑完的。下一轮开始时
它自己会重新武装这个标记。
单页阅读器换章不刷新页面,只换 URL。旧章的翻译还在跑的时候, 新章已经出现了。
旧章那一轮跑完了,报的是「翻译完成:成功 8 张」—— 而新章一张图都没有。
换章时那一轮被明确标记为”被取代”,它不再报结果。
用户在翻译途中改了设置。worker 推来新的配置快照,页面把手里的 translator 置空(好让下一张图用新的服务商)。
这一轮里排队的图接着就发现没有 translator 了,抛出一句
Services not initialized —— 一句跟读者的图片毫无关系的失败。
下一张图自己把需要的东西重建起来。配置快照带的就是用户刚要的配置, 重建正是对的做法,不是错误。
两个标签页打开同一页漫画,或者观察器和读者自己的按钮同时要同一张图:
worker 会把它们合并成一个 job(pageKey 相同)。两个调用方拿到同一份
结果,服务商只被问了一次。
而记账发生在调用方那边(内容脚本里的翻译器)—— 于是两个调用方各记一笔。
用户没花的钱,在「本月 N 次 API 调用」上出现了两次。这个数字是读者拿来 对账的;对不上账的账本比没有账本更糟。
账记在了”谁收到了答案”,而不是”谁问了服务商”。
合并发生的时候,这两件事就分家了:一次请求,两个收到答案的人。
记账挪到 worker 那一层(provider-direct-client.ts)—— 只有它知道
“真的问了一次”。这一次请求记一次,两个调用方拿到的是同一个数字。
翻译器自己只留一条记录:缓存命中。那不是一次请求,是一次没有发生的 请求,它要留在”这次翻译没有花钱”的那一栏里。
provider 那一层记的”2xx 但答案读不懂”不变:那一层之后错误就被拍扁成一句 话了,usage 再也拿不到,只能由它记。
| 测试 | 去掉修复后 |
|---|---|
| 排队中的任务用运行时的服务商 | 用入队时的服务商 —— 用户换了服务商,钱照旧花 |
| 服务商没配好时任务停下来 | 带着空密钥去请求,报「认证失败」 |
| worker 的拒绝语原样出现在卡片上 | 「发生未知错误:当前服务商还没有配置好」 |
| 第一张被拒就停掉整轮 | 十九张图各失败一次,各报一条同样的错误 |
| 预热没翻出东西就不写素材 | 把一次失败录成商店素材 |
--forward 改两份服务商配置 |
只改没人读的那份 —— 模式一直是坏的 |
| 打印 worker 到服务商的连通性 | 失败只有一句「翻译超时」 |
| 预算用完后不再说”会自动继续翻译” | 页面继续承诺一件不会发生的事 |
| 预算用完后说清楚为什么 | 「「自动续翻」已打开,但本站不在名单里」—— 每一句都是假的 |
| 用户再点一次就能重新开始 | 读者被卡住,只能刷新页面 |
| 运行途中出现的图会被翻 | 那张图永远停在原图上 |
| 画面换了,旧译文被撤掉 | 旧译文盖在新画面上 |
| 画面换了,新图回到候选池 | 弹窗说”已经翻过了”,其实没有 |
| 观察器不为换图重花一次钱 | 同一页漫画付两次钱 |
| 清除之后那一轮不再画回来 | 刚清空的画面又出现译文 |
| 换章之后不再报上一章的成绩 | 新章报「成功 8 张」 |
| 改设置之后剩下半页不失败 | 剩下的图报「Services not initialized」 |
| 一次请求只记一笔 | 一次请求在账单上出现两次 |
| 翻译器不再自己记账 | 内容脚本里再记一次 —— 两个调用方就是两笔 |
前十轮的题目是「这句话对不对」。这一轮的题目是:
这个答案是什么时候取的?
一个答案在被取出来的那一刻是真的。它躺在队列里、被写进文件、 被贴上商店页面,中间隔着十分钟、一次失败、一份没人读的字段 —— 它就过期了,而没有人检查。
这一轮改的每一处,都是在使用的那一刻重新取一次答案, 并且在答案不成立时说清楚为什么。
而”重新取一次”不总是意味着多取一次。有时候正确的做法是 承认它过期了:预算用完了,就说预算用完了;画面换了, 就把不再描述画面的译文撤掉。