模型下载改用多个 Range 分片并发传输,提升受单连接限速时的下载吞吐。 补充分片断点恢复、阶段化组装、ETag/大小/SHA 校验及操作互斥。 包含真实 HTTP 生命周期与前端错误映射测试。
22 KiB
设计决策记录
只记「为什么这么做、否掉了什么方案」。实现细节看代码,操作步骤看 .kiro/skills/。
新决策往后追加,不改旧条目(结论变了就新开一条并注明取代哪条)。
2026-08 自动更新:静默下载 + 退出时安装
改之前是什么样:启动 → 发现新版 → 自动下载 → 停 3 秒 → app.exit(0) 装掉重启。
全程不问用户,UpdateDialog 那个 fixed inset-0 遮罩没有关闭按钮。
坏处很具体:开机自启后用户正按住说话,会被一个关不掉的遮罩糊住,接着应用自己退出。
唯一能拒绝的入口是设置里的「自动检测更新」开关。
现在的形状:后台静默下载 → 停在待安装 → 侧栏「关于」图标变绿闪烁,用户点进关于页装, 或者什么都不做,退出应用时兜底装。
为什么"强制"落在退出路径上,而不是"必须点那个图标"
只靠点击等于把更新变成完全自愿,不点的人永远留在旧版 —— 那就不是强制更新了。 安装本来就要求应用退出,所以退出那一刻是天然的安装时机:用户平时完全不用管更新, 关掉再打开就是新版。变绿的「关于」图标只是给"我现在就想更新"的人的加速通道。
代价是 install_downloaded_update 必须能选择不重新拉起应用(新增 relaunch 参数)。
退出路径上如果还把应用拉起来,表现成"这软件关不掉"。
实现落在 main.rs 的 RunEvent::Exit(因此 .run(ctx) 改成了 .build(ctx)?.run(闭包))。
两处必须让开这条路径,否则会起两个安装程序抢同一个 exe 的文件锁:
install_downloaded_update自己会app.exit(0),那次退出同样触发 Exit;restart_app(导入配置后重启)的意图是"重启回同一个版本",不是更新。 都靠INSTALLER_SPAWNED这个原子标志屏蔽。
为什么必须加周期检查
检查逻辑原来只挂在 App.tsx 的挂载 effect 上,全仓库没有任何定时检查。
SayIt 是常驻托盘 + 开机自启的应用,很多用户几周不重启 —— 只在启动时查一次,
等于绝大多数人永远发现不了新版本。删掉设置里的开关并不能解决这件事,因为压根不触发。
现在 6 小时一次(CHECK_INTERVAL_MS),检查本身只是一个几百字节的 manifest 请求。
为什么「自动检测更新」开关只删 UI、保留读取
删掉整个设置项更干净,但被否掉了。0.0.8 那次更新器把用户锁在"装不上→重开→再更新"的 死循环里(见 pitfalls #3),当时能止血靠的就是这个开关。保留 DB 读取的成本几乎为零, 换来一条不用发新版就能远程指导用户关掉更新的通道 —— 更新链路自身出故障时, 发新版这条路正好是不通的。
附带理由:这是公开仓库,把唯一能关闭的对外网络请求开关整个删掉,在开源语境下容易被质疑。
为什么更新检查不跟随「后端地址」设置
已被后续决定推翻,见本文档末尾「2026-08 更新地址跟随服务器地址」。 现在又跟随了,但补上了本节担心的那个坑的解法:拿不到 manifest 时回落到官方地址。 下面这段保留,因为它记录了「为什么不能只是简单跟随」—— 那个 404 静默陷阱是真的。
updateChecker.ts 原来用 getBackendBaseUrl(),而这个值优先取用户保存的 backendBaseUrl。
后果:任何把后端指向自建服务器的用户,检查更新会去问他自己的服务器,
拿到 404 后被当成"没配更新"静默返回(resp.status === 404 时 error = null),
于是永远收不到新版本。改用 getUpdateBaseUrl(),固定在官方地址上,与业务后端解耦。
仍然尊重 VITE_BACKEND_BASE_URL,否则开发时没法把更新通道指到测试服务器验流程。
为什么这次才需要校验 sha512、才需要下载幂等
这两件事都是"下载完立刻安装"掩盖掉的:
- 旧流程下安装包在磁盘上只存在几秒,被替换的窗口期极短;现在它要在
%TEMP%\sayit-update里躺到用户点更新,可能是几天。manifest 早就给了sha512(Base64,不是 hex),只是没人用。 - 旧流程下"下载了但没装"不存在;现在这是常态。
downloadedFilePath原来只在内存里, 进程一退就没了 —— 不落盘的话每次开机都会把整包重下一遍,服务器出网流量按用户数乘。 所以pendingUpdate(version + filePath + sha512)写进app_settings, 启动时用verify_update_package确认还能用再复用。
顺带修掉 compareVersions 的一个静默失效:非数字段会产生 NaN,而 NaN > 0 为 false,
表现成"有更新也不报"且没有任何报错。现在非数字段按 0 处理。
phase 不许兼职表示「有包等着装」
第一版给 AutoUpdatePhase 加了一个 'ready',同时又有 pending 字段,等于同一件事有
两个真相来源,立刻出了 bug:restorePending 恢复出包后设 phase='ready',紧接着
runCheckAndDownload 一进来就 setState({ phase: 'checking' }) 把它盖掉;而"包已存在、
跳过下载"那条早退分支没人负责把 phase 改回去 —— phase 永久停在 'checking',
表现成关于页一直显示「正在检查更新...」、提醒图标永远不亮。
现在 phase 只表示活动(idle | checking | downloading | installing),
「有没有包等着装」只看 pending,两者正交。phase 的复位收在 runCheckAndDownload 的
finally 一处 —— 让每条早退分支各自记得复位,就是上面那个 bug 的成因。
排查过程本身也留了个教训:当时 sayit.log 里一条更新日志都没有,而
debugLog.ts 的 shouldMirrorPayload 对 info 级只放行 recorder / websocket / backend,
source: 'update' 全被过滤掉了。整条更新链路是用户看不见的(后台检查、后台下载、
退出时安装),没有日志就等于没有诊断手段,而且"没有新版本"和"服务压根没跑起来"
在日志里长得一模一样。现在 update 源全放行,并且检查结束无条件记一条
{current, latest, hasUpdate, error}。
提醒放在哪:复用「关于」图标,不新增第四枚图标
第一版做的是侧栏底部新增一枚更新图标,点它直接弹安装确认框。否掉了,改成 让既有的「关于」图标自己变绿闪烁,点击照旧进关于页,安装动作留在关于页的 「立即安装」按钮上(那个按钮不再弹确认框,见下)。
理由:
- 底部本来就有设置 / 关于 / 引擎指示三枚图标,第四枚既挤,又需要用户先学会它是什么意思;
- 关于页正是更新所在的位置,那一屏有版本号、说明、「立即安装」和"不装也会自动装"的提示, 是个信息完整的确认现场。图标只负责"这里有事",不负责承载一个会关掉软件十几秒的动作;
- 那枚图标的既有含义是导航。在它上面挂"点了就开始装",等于让同一个控件在不同时刻 做两件轻重完全不同的事。
外观上试过给绿图标垫一层 bg-success/10 做成徽标,在那排素图标里显得脏,去掉了 ——
颜色加 animate-pulse 已经足够醒目。下载期间图标毫无变化:那会儿没有任何需要
用户知道的事,静默才是本意。
绿色用主题的 --success 令牌,不写死 emerald,深浅主题各自有合适的明度。
注意这不违反 ModeIndicator 上方那条"不给任何好颜色"的规矩 —— 那条针对的是
我们没验证过的事(配置填完了 ≠ 真能用);"包已下载完、哈希校验过、随时可装"是事实。
「立即安装」不弹确认框
一度给它加过确认框,说明"会关闭十几秒、选稍后也不会漏掉"。撤了:用户是被侧栏那枚
变绿的图标引到关于页、看完状态说明之后才按下这个按钮的,按钮上写着「立即安装」,
再叠一层"你确定吗"只是多一次点击。那句"不装的话关闭应用时也会自动完成"移到了
按钮左侧的 about.readyHint,该说的信息没丢,只是不再拦人。
验证这类改动的特殊之处
新的更新逻辑没法自己验证自己。 用户手里的旧版升级时走的还是旧代码,新交互只在 "新版 → 更新版"之间生效。所以真机必须测两段:① 旧版能不能正常把这一版带上来(不砖); ② 造一个版本号 +1 的包推到 dev 通道,验新的下载 / 提醒 / 点击安装 / 退出兜底。
2026-08 应用 Prompt 规则:删掉 priority,顺序即优先级
改之前:AppPromptRule.priority 是个数字(内置 100/95/90/88…,用户自建固定 200),
界面按它降序排列,命中时也按它降序取第一条。用户看得见顺序、却改不了顺序,
而且 9 条内置规则默认全关,启用靠底部的一条(比如 QQ)后它仍埋在一堆未启用规则中间。
现在:appPromptRules 的数组顺序就是优先级(下标 0 最高),用户拖动手柄排;
启用一条规则会把它移到最前面。
为什么把 priority 整个删掉,而不是保留它、由排序推算出新的数值
保留就有两个真相来源。只要有任何一条读取路径忘了改(getAppPromptRules 的读、
promptRouter 的匹配、"恢复默认"会把 fallback.priority 一起写回),
就会出现"界面上排在第一,实际命中的是第三条"——而且不报错,只是结果不对。
数字与可见顺序打架这件事没法靠注释防住,只能把字段去掉。
代价是 BUILTIN_APP_RULES 里的数组顺序现在有语义了(默认优先级),
所以 codex 那条从数组末尾挪到了 kiro 后面 —— 保持和原先 priority 88 一样的位置。
为什么不需要写迁移代码
老存档虽然带着 priority,但当时每条写入路径都是先按 priority 降序排好再存的,
所以存档里的数组顺序已经等于用户看到的顺序,直接沿用即可。
自建规则(priority 200)在老存档里也已经排在内置之前,迁移后照旧最先命中。
反过来,新版导出的配置被旧版导入时,旧版读到 priority 缺失全按 0 处理、
Array.prototype.sort 稳定,顺序也不会乱。
为什么开关改成即时落库
以前开关只改草稿,要再点「保存规则」才算数 —— 状态类控件按下去却没生效,
用户会以为已经开了。而且"启用置顶"必须在按下的那一刻发生,否则动作没有反馈。
副作用是每次开关都会让 rules 变化、组件重跑同步 effect,为此有两处刻意的写法:
- 同步草稿时只接受外部的
enabled,其余字段保留用户正在编辑的内容 (否则一按开关就把没保存的附加提示词冲掉); - 展开状态只在首次载入时按"已启用"推一次,不再跟着
rules重算 (否则每按一下开关或拖一次,用户手动折叠/展开的状态就被弹回去)。
「恢复默认」不改位置
fallback 里带着默认位置的信息,但恢复只覆盖内容。用户排好的顺序不该被
一个写着"恢复默认提示词"的按钮顺手打乱。
2026-08 ASR 纠错反馈:公开版收样本,音频由用户主动上传
需求:用户在历史记录里挑一条识别不准的,把识别原文改成正确文本,连同当次录音一起提交。 积累起来先做识别质量评测集,将来才谈微调。
为什么由客户端上传音频,而不是复用服务器已经存下来的录音
实测:开发/记录服务器上跑的是带录音覆盖层的版本,runtime/recordings/ 已有 164 个 wav,
usage_sessions 里也有 asr_text / llm_text。也就是说服务端本来就有 (音频, 原始ASR),
最小实现只需要客户端回传一个"修正文本 + 会话引用",几十行就完事。
这条路被否掉了,两个理由:
- 它只在开了录音覆盖层的部署上能工作。公开版
main里会留下一个"没有录音就什么都干不了" 的空壳接口,反而把「这个功能依赖静默录音」固化进公开代码。 - 同意边界会变得说不清:用户点的是"提交这一条",实际发生的是"服务端去翻它自己存的东西"。
客户端主动上传在任何部署上都成立,用户点的按钮和实际发生的事一一对应。代价是要自己 造一条公网上传通道(大小/格式/幂等/限流/配额全得自己做),这部分成本是明知故犯。
代码边界:main 禁的是"静默",不是"保存文件"
main 那条红线的实质是不许在用户不知情的情况下留存音频与识别文本,
不是"服务端不许写文件"。用户点了按钮、看过同意文案之后上传的样本,允许进公开版。
这条必须写下来,否则下次看到 main 里有存 wav 的代码,会二选一地删掉功能或删掉护栏。
.git/hooks/pre-commit 的模式(writeframes、recorder.save(all_pcm)、asr_text=result.get …)
不放宽。新代码收到的已经是完整 WAV,直接写字节即可,不用 wave 模块,天然不撞规则。
几个刻意的取舍
- 只有服务器模式给入口。 只有服务器上的模型是我们能改的;豆包/本地模型认错了, 把用户音频传到我们服务器既没用又是一次隐私意外。
- 正确文本从
asrText初始化,绝不从llmText。 用户平时看到的是 AI 整理后的文本, 如果照着它去改(补标点、分段、改书面语),拿到的标注对 ASR 是噪声。所以纠正面板不展示 AI 整理后的文本,并明确写「只把听错的字词改对,不用补标点、分段或改得更书面」。 - 音频没了就不给提交(置灰 + 说明原因),不做纯文本提交 —— 没有音频的样本对识别改进没用。
- 带上本次实际注入的热词。 一大部分"识别不准"是热词引起的(见 pitfalls 热词幻觉)。 没有热词信息,样本既没法判断成因,还可能把错误标进数据集。热词里常含人名公司名, 所以同意文案里必须点明。窗口标题、进程名、AI Prompt 一律不收 —— 与 ASR 无因果关系。
- 同意文案直说"作者会用它实验性地微调语音识别模型"。 我原来主张只写"分析识别问题、改进识别效果"、不提微调,理由是量级不够:服务器 6 周共 924 次真实使用、带录音的音频累计 0.57 小时,纠错样本只是其中愿意动手改的一小部分, 这个量先够攒一套评测集,还谈不上微调。这个说法被否掉了 —— 意图本来就是拿它试微调, 写成别的等于把真实用途藏起来,而"实验性地"这三个字已经说明不保证结果。 同一段文案里必须回答"撤回之后服务器还留着录音吗"(用户会问的第一个问题), 所以明写"撤回后服务器上的录音和文本会被删除",并写明仅作技术验证、不用于商业用途。 量级那笔账仍然成立,只是它决定的是我们自己的预期(先当评测集),不是对外口径。
- 文案里不写保留期,写"30 天内可以撤回"。 用户能自己动手的事比一个保存天数更有意义。
后端因此不按 30 天拦撤回:承诺是"30 天内可以撤回",放宽到永远可撤回不会违背承诺,
而加一道硬期限只会多出一个"点了没反应"的失败分支。常量在两处(后端
WITHDRAW_DAYS、 客户端ASR_CORRECTION_WITHDRAW_DAYS),接口返回withdraw_days,有测试钉住两者一致。 - 不做网页审核界面。 网页审核必然要一个"回放用户音频"的端点,公开版不该有; 放私有分支又要长期维护两套。改成导出脚本拉到本地听,量级也就几十条,不值得做界面。
上传上限为什么是 10 MiB
16kHz / 单声道 / 16bit 一秒 32KB,五分钟 = 9.6MB(9.16 MiB)+ 44 字节头。
服务器模式的录音长度由 PTT 五分钟硬释放和服务端 _MAX_PCM_BYTES = 10MiB 两道兜住,
所以 10 MiB 一定装得下最长的一次录音,还剩约 0.9MB 余量。
注意 _MAX_PCM_BYTES 只管 WebSocket 的 PCM 缓冲,HTTP 请求体在 backend 和 gateway
两层都没有任何上限 —— 新接口不自己数字节,就是一个任意大小的公网文件上传口。
顺带修掉的既有 bug
audioRetentionDays 前后端默认值不一致:defaults.ts 是 -1(界面显示"永久"),
而 main.rs 读不到该设置时 fallback 是 30 天并按文件 mtime 真删,删完还不清
audioFilePath。不修的话这个功能一半的入口是灰的,而用户以为音频都还在。
2026-08 更新地址跟随服务器地址(并保留回落到官方)
背景。 0.1.7 之前 getUpdateBaseUrl() 就是业务后端地址,所以「把服务器地址改成测试
服务器」顺带把更新检查也指过去了,这是当时测更新的手段。79c3f8a6(自动更新改造成后台
下载那次)把它写死成官方地址,测试手段随之失效 —— 而写死的理由是预防性推理,不是事故。
决定。 恢复跟随:getUpdateBaseUrl() 直接返回 getBackendBaseUrl(),填哪个地址就找哪个
要更新。不增加任何设置项、不增加任何界面控件。 保存服务器地址时顺带丢弃已下载的包并
重新检查一次(见下)。
理由是简洁压过了下面那条风险:多一个设置项就多一个要解释、要维护、用户要理解的东西,而这个 应用的取向是少而清楚。中间实现过一版「独立设置项 + 关于页折叠面板」,评审后认为界面复杂度 不值得,已撤掉。
这个决定的代价,以及为什么仍然可接受。
更新包没有签名(main.rs 的 updater 插件因此禁用),manifest 的 SHA-512 只证明"下载的字节与
manifest 一致"、不证明"这个包是我们发的" —— 也就是说这个地址同时决定了谁能往这台机器上装
程序。跟随之后,用户改服务器地址的动机是「让这台服务器处理我的语音」(数据权限),却顺带
交出了代码执行权限,而界面上只说了前一种。
判断是:能改这个地址的人本来就在做一个明确的技术决定(对接自建后端),不是随手点到的;配合
下面两条缓解,风险落在可接受范围。如果将来给更新包加了签名,这条风险就基本消失了 ——
那是比在界面上加开关更值得做的事。
两条必须保留的配套,缺任何一条这个设计就不成立:
- 回落到官方地址(
updateChecker.ts的checkVersionUpdate)。把服务器指向自建后端的 用户,那台机器上没有 manifest;没有回落,他们每次检查都拿到 404 并被静默当成"已是最新 版",从此永远收不到更新且毫无征兆。这一步不是优化,是前提。 只在"没拿到版本号"时回落;已经拿到就用它,哪怕比当前版本旧(那是指向旧通道测试的正常 情况,不该悄悄换成官方的答案)。 - 来源在日志里可查。
update check finished带source(实际命中的地址)与configured(配置里的地址)—— 两者不一致就说明发生了回落;地址偏离官方时启动还会记一条 info。 少了source,"回落了"和"配置的就是官方"在日志里长得一模一样,这正是排查「为什么没收到 新版」时最先要区分的两种情况。 界面上不显示更新来源:评审时试过在关于页加一行灰字,判断是它对普通用户没有意义、 而需要它的人会去看日志。所以这条约束只落在日志层,别再往界面加。
已知未处理: backendBaseUrl 在配置导入范围内,所以导入一份配置可以间接改更新来源。
没有拦,因为「导入配置时同步服务器地址」是正常预期;导入本身需要用户在预览界面二次确认,
不是无感操作。曾为独立设置项加过 NEVER_IMPORT 守卫,跟随之后那个键不存在了,已移除。
顺带否掉:只用构建期变量 SAYIT_UPDATE_BASE_URL。 多机测试要么各自重新构建、要么分发
特制包,且它只能测 SAYIT_FAKE_APP_VERSION 特制包 —— 特制包用的是新版更新器,验证不了旧包
自身的更新器有没有 bug(0.0.8 那次正是旧更新器把用户锁死)。已移除。
0.1.7 及更早的包测不了这条路。 它们解析更新地址时不读数据库,只认编译期写死的官方地址, 所以改设置对它们无效 —— 要在那些版本上验证升级,只能从 DNS 层把那个名字指到别处。 具体做法在内部运维笔记里,不在这里。
2026-08 模型并发下载:独立分片恢复,再阶段化组装
状态:已实现
背景:模型文件要用多个 HTTP Range 并发下载,绕开 CDN 的单连接限速。最初方案让多个任务
写同一个预分配 .par.part,每次调用先重新截断文件;进程中断后无法区分哪些区间已经完整
落盘,也无法安全复用进度。
为什么这样:每段改用独立的 append-only chunk,JSON 状态同时绑定源 URL、总大小、
SHA-256、强 ETag、分片计划和下载/组装阶段。网络中断保留已完成字节;Range 协议不兼容、
远端表示变化或最终校验失败才清理并回落单流。组装按 chunk 依次复制,先
flush + sync_data,再持久化边界,最后删除已组装 chunk;重启时把输出回退到最后一个已记录
边界,因此不会把未确认字节当成完成。当前 catalog 的所有模型仍在最终 rename 前校验大小和
SHA-256。
下载、删除与模型目录迁移也必须在原生命令层互斥:前端按钮置灰不能阻止重复 Tauri command。 单个模型操作持有路径 lease 到校验、rename 和元数据写完;目录迁移持有全局 storage lease 到 路径切换和设置落库完成。
被否掉的方案:
- 共享预分配文件 —— 崩溃后稀疏空洞与有效的零字节不可区分,重试只能全部重下。
- 每次失败都立刻改走单流 —— 普通网络抖动会白白丢掉并发进度,还可能短时占用两份空间。
- 等完整组装成功后再统一删除全部 chunk —— 峰值磁盘接近模型大小的两倍;阶段化删除在持久化边界后即可释放空间。
- 只靠前端禁用按钮避免并发 —— 无法覆盖直接或重复调用原生命令的路径。