# 设计决策记录 只记「为什么这么做、否掉了什么方案」。实现细节看代码,操作步骤看 `.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 —— 峰值磁盘接近模型大小的两倍;阶段化删除在持久化边界后即可释放空间。 - 只靠前端禁用按钮避免并发 —— 无法覆盖直接或重复调用原生命令的路径。