# 一键装配(provisioning)设计依据 > 状态:P1 已落地并在真机验证;P2 设置卡 UI 未做。发布挂起。 > 本文档记录**为什么这么设计**,模块内注释记录**怎么实现**。 ## 要解决的问题 官方「语音输入」插件把引擎和模型打包在插件里,用户点几下就能用。我们的 `Qwen3-TTS` GGUF 是 1.2 GB,不可能进 npm 包,所以只能首运行时下载。 下载这件事在国内链路上不是"慢一点",而是**默认地址根本连不上**——所以 人性化与否,取决于来源策略,而不是按钮数量。 ## 实测数据(2026-09-25,本机链路,可用 `node scripts/net-probe.mjs` 复跑) | 来源 | 结果 | | --- | --- | | `huggingface.co` | 建连超时,不可用 | | `hf-mirror.com` | 0.5–2.7 MB/s,支持 Range(206)→ 可断点续传 | | `github.com`(release 文件) | **间歇**:一小时内 0/6 全超时,另一时段 0.6 MB/s | | `api.github.com` | 9/9 约 0.5s —— 元数据通,字节不通 | | `gh-proxy.com` | 0.11–0.19 MB/s | | `ghproxy.net` | 0.08–0.16 MB/s | | `gh-proxy.org` | 0.05–0.09 MB/s | | `ghproxy.link` | 对 release 资源返回 200 + 空正文 | | ghfast.top / mirror.ghproxy.com / gh.llkk.cc / github.moeyy.xyz / hub.gitmirror.com / kkgithub.com / gitclone.com | 无可用响应 | 真机验证:引擎 8.66 MB 经 gh-proxy 下载→sha256 相符→解压→`crispasr.exe --version` 退出 0; 模型 1.26 GB 经 hf-mirror 54 秒完成并校验;装出来的引擎+装出来的模型合成 4.24 秒语音, CPU 版 `rtf≈1.0`。 **同一份 8.66 MB 引擎,三次从头装配:** | 版本 | 胜出来源 | 用时 | 折算速率 | | --- | --- | --- | --- | | 无竞速(固定顺序) | gh-proxy.com | 66 s | 131 KB/s | | 首字节竞速 | ghproxy.net | 148 s | 58 KB/s | | 吞吐竞速 | gh-proxy.com | 217 s | 40 KB/s | 这张表必须连着读:**同一条链路在分钟尺度上就有 3 倍波动**,所以后两次并不是"改坏了", 但也确实**没有证据表明吞吐竞速赢过了固定顺序**。它站得住的理由是另外两条:把五个来源的 死等变成一次有界采样(否则首个死源要吃满 15 秒建连超时),以及慢车道中途让位+断点续传 把"选错一次"的代价从"整个文件重来"降到"一段采样"。来源选择的净收益要等样本多了再判。 ## 由数据得到的四条决定 1. **摘要钉在清单里,不靠网络取。** 校验不能依赖一个用户可能连不上的主机。 引擎摘要来自 `api.github.com` 的 `assets[].digest`,模型摘要来自 HF 的 `siblings[].lfs.sha256`,两者都已在真机上与本地文件逐位相符。 2. **"自动"是测速,不是按顺序试。** 首字节竞速(`orderByFirstByte`)之后还要看 持续吞吐:实测最快应答的加速站反而是最慢的下载道(首字节第一、全程 171s vs 66s)。 所以下载器有"慢车道让位",并且**最后一个来源绝不因慢让位**——否则全网慢的时候 会把一次能完成的安装主动弄失败。 3. **引擎无国内通道,模型有。** `crispasr` 的 PyPI wheel 只含 DLL 和 Python 绑定, **没有 `crispasr.exe`**;引擎二进制只有 GitHub Releases。同时 `crispasr.exe` 里没有 `HF_ENDPOINT` 字符串,引擎自带的 `--auto-download` 写死 `huggingface.co`, 在国内不可用——所以我们必须自己接管下载。 4. **必须留手动放置这条路。** CUDA 版 693 MB 走任何可用代理都是小时级;把它设成 默认就是"拿不出手"。所以:默认 CPU 版(实测已接近实时),大文件明确标注建议手动, 投放目录 + 校验让手动路径变成一步。 ## 安全边界 - 前端只能**点名**(variant id / quant / policy),URL、体积、摘要一律来自清单。 有测试专门盯这条:请求体里塞 `url`/`sha256` 不会改变实际抓取地址。 - 手动放置**不接受路径**:只在本插件自己的 `downloads/`、`models/` 里按文件名解析。 否则任何能访问 loopback 端口的页面就有了"读某个文件并改名"的能力。 - 校验不过的文件不会变成产物:写到 `.part`,摘要相符才 `rename`;不符就删除并换源。 ## 磁盘布局 ``` / 默认 ~/.dsh/voice,随 audioDir 移动 engine// 解压后的引擎;可执行文件与它的 DLL 同级 models/.gguf 与上游同名 downloads/ 在途(.part)与手动投放区;解压成功后归档被删除 provision.json 装了什么、摘要、来自哪个来源(路径相对根目录存储) ``` 配置的 `tts.crispasr.{bin,model,codec}` **永远优先**于上面的一切:已经手动装好的人, 升级插件不会被换掉文件。空档才由装配填。 ## 模型档位(2026-09-25 增) `cstr` 在 HF 上的 Qwen3-TTS 端口,实测与探测后纳入三档 talker: | talker | 体积 | sha256 来源 | `--backend` | | --- | --- | --- | --- | | 0.6B customvoice q8_0 | 923 MiB | HF `lfs.sha256`(与本地文件逐位相符) | `qwen3-tts-customvoice` | | 1.7B customvoice q8_0 | 1.90 GiB | 同上 | `qwen3-tts-1.7b-customvoice` | | 1.7B customvoice f16 | 3.58 GiB | 同上 | `qwen3-tts-1.7b-customvoice` | **后端必须跟模型成对。** `crispasr --list-backends-json` 显示 1.7B 是**独立的后端名**, 不是同一个后端换个文件;而引擎对显式 `-m` 并不做架构校验(实测用 1.7B 后端加载 0.6B 文件照样跑通,`talker 28L/1024` vs `28L/2048`)。也就是说配错**不会报错**,只会静默用错 架构——所以 `backend` 是 `ModelAsset` 的字段,从记录里已装的 talker 反查,不单独配置。 真机验证:1.95 GB 经 hf-mirror 106 秒完成(20.4 MB/s,摘要相符);1.7B 与 0.6B 在 RTX 4090 上同句对比 `rtf 0.195 vs 0.188`——**大模型没有变慢**,代价只是下载翻倍; 两者共用同一套 9 个音色(`Available: aiden, dylan, eric, ono_anna, ryan, serena, sohee, uncle_fu, vivian`),所以音色选择器不需要跟着模型档位变。 未纳入的:`*-base`(需要参考音频做音色克隆)与 `*-voicedesign`(用文字描述造声)是 **不同后端、不同输入**,不是"换个 gguf 文件",没接线就不放进选择器。 顺带发现:1.7B 后端每次合成都会往 stderr 打三行 AI 披露警告,因为它不记录预设音色 是否真人;`--speaker-identity synthetic` 可静音、`real_person` 会前置披露语音。 **这等于替用户声明音色来源**,所以没有默认加上,留作显式配置项。 ## 多路下载(2026-09-25 增) 按"下载软件那样多路并发 + 断点续传"做了,但**测下来车道数不是那根杠杆**。同一条 GitHub 路线、同一个 8.3 MiB 文件,每种车道各跑三次: | 车道 | 三次耗时 | 中位 | | --- | --- | --- | | 1 路 | 150.7s / 45.9s / 39.2s | 45.9s | | 4 路 | 16.2s / **322.5s** / 37.3s | 37.3s | hf-mirror 更夸张:单流在不同时刻量到 **4.75 / 18–20 / 30–33 / 63 MiB/s**;8 路有一次 比单流快 6 倍(30.9 vs 4.75),另一次**慢 2.5 倍**(24.8 vs 63)。 结论:**方差是"每次尝试"级别的,不是"每条路线"级别的。** 所以 - 多路的价值是**不被单次坏尝试拖死**(一条道返回 200 空正文时,其他道照跑), 不是提速;默认 4 路按这个理由定,不宣称更快。 - 曾写过一版"跑 3 秒看速率、快就不加分片"的判据,**已删除**:那是拿一个样本赌整个 文件,而样本恰恰是最不可信的东西。 - 真正经得住方差的是**分段位图续传**(`.part.json`)+ **每段独立换源**:崩在 322 秒的那次尝试,已完成的分段不作废。 - hf-mirror 是否按累计流量收口(先快后慢):**今天既没证实也没推翻**——样本都在同一 个时间窗里。设计按"会发生"处理:随时可取消,稍后再点接着下。 ## 默认引擎档的规则(2026-09-25 定) `recommendedVariant` 的默认上限就是 `MANUAL_HINT_BYTES = 100 MB`,也就是"建议手动下载" 的那条线。规则因此只有一句话:**默认装的,永远不是我们会让他手动下的那一档。** 对一台探测到 CUDA 的新机器:下拉里 CUDA 仍排第一(能力优先,不藏),但默认选 **Vulkan 35.5 MB**,CUDA 的说明里写实测代价。真机验证这个默认是对的: ``` ./.verify-backup/prov/root/engine/win-vulkan/crispasr.exe 已探测到 Vulkan · RTX 4090 · 24 GB(0.8.36) D:/crispasr/crispasr.exe 已探测到 CUDA · RTX 4090 · 24 GB(0.8.28) ``` 即 35 MB 的包就能让 4090 走 GPU,不必下 693 MB。 ## 一个装完跑不起来的 bug 解压后记录下来的"引擎可执行文件"曾经是 **`crispasr.dll`**:候选名列表里把 `.exe` 和 `.dll` 平级,而 `walkForFile` 返回**第一个**命中,于是 readdir 顺序替我们决定了引擎是谁。 CPU 包恰好没有 `crispasr.dll`,所以一直没暴露;换 Vulkan 默认档立刻踩中。 修法两处:`walkForFile` 改为**收集全部命中、按偏好序取最优**;`binaryNames()` 只接受 清单点名的可执行文件,**不再接受库文件**——返回一个错的路径比返回"没找到"更糟,后者 卡片还能解释。同时补了 `test/provision-unpack.test.ts`:这个模块此前**完全没有测试**, 所以摘要校验、断点、换源全绿而装出来的东西跑不起来。 ## 设置卡的减负(2026-09-25 定) 装配能跑起来之后,卡片本身成了压力来源:一屏里同时出现引擎、量化、下载源、加速前缀、 手动放置,而绝大多数用户只需要按一次按钮。所以布局改成一句话的规则 — **默认值由设备探测决定,探测已经做完的事不再问用户**: - 「运行环境」区的第一行是**一键部署 · <总字节>**,点它不需要先理解任何一项。 字节数留在按钮上是有意的:693 MB 不该是一次没有标签的点击。 - 模型 / 引擎版本 / Codec 量化 / 下载源 / 加速前缀 / 手动放置全部收进 `高级选项` 折叠, 默认收起。**下载失败时自动展开**——那是手动放置唯一真正被需要的时刻,不该指望用户 猜得到逃生口在折叠里。 - 按钮文案区分 `一键部署` 与 `安装选定的 N 项`:判据是 `isDefaultSelection`,即用户是否 动过任何一项。没动过就是"我替你选了",动过就不能再假装是。 - 语速从 1–600 的数字框改成慢/中/快 三档——数字是引擎的参数,不是人能做的决定; `tts.rate` 仍可直接写进配置。 - 设置卡一度只留四行,其余折进 `外观与高级选项`。**这个折叠第二天就被否掉了**:振铃超时、 卡片主题、配色三项都是"看一眼就会选"的东西,藏起来只会让人以为插件没有这些能力。 现在七行平铺;折叠只保留在 `运行环境 › 高级选项`(那里放的是四个下拉和手动放置, 确实是另一回事)。 ## 默认值必须来自磁盘(2026-09-25 修) 真机上出现的谎报:引擎探测到 CUDA + RTX 4090,voice 目录里装着 CUDA 引擎和 1.7B talker, 卡片却每次都显示 `Talker 模型 923 MB · 未安装`、`还需下载 923 MB`,引擎下拉还固定在 Vulkan 上。三处同一个根因——**默认值是从清单算的,不是从磁盘读的**: - `provisionRequest()` 用 `defaultModels()` 拼请求,于是 plan 找的是 0.6B 那个文件, 而盘上躺的是 1.7B。`readProvisionedEngine()` 早就按记录解析出了真正的文件(否则 TTS 根本跑不起来),但那份结果只用于跑语音,没用于装配计划。现在 `readInstalledModels()` 把"root 里到底有哪两个 gguf"单独报出来,plan 以它为准,`defaultModels()` 只当空目录 时的兜底。半装的 root(有权重没引擎)也算——它不是一台能跑的引擎,但那两个文件 确实已经在磁盘上了。 - `catalogue.recommended`(`recommendedVariant(device)` 的结果,受 100 MB 传输上限约束) 被客户端拿去填下拉,等于把"没装东西时该装哪个"当成"这台机器装的是哪个"。现在 catalogue 直接带 `selection`——**服务端此刻会执行的那一份请求**,客户端照它初始化, `isDefaultSelection` 也拿它当基线。`recommended` 这个字段删掉了:同一件事不该有两个真相。 Vulkan 仍然是空 root 的默认档(小白点一键部署不该撞上 693 MB),但它出现在 `selection` 里,而不是被 UI 自己猜一遍。 - `engineFromConfig` 这个字段定义了却从来没人赋值,于是"配置里已经写了 D:\crispasr" 的人,默认按钮上仍然背着一次引擎下载。现在由 `probeCrispasr(current())` 决定。 - 探测是异步的,路由挂载时 `deviceReport` 还是 `unknown`,第一次 `inspect` 就按 CPU 档 算了一遍,之后没人纠正——除非用户重新打开页面。现在 `refreshProvisioned()` 在探测 回来后补一次 `inspect`,但**只在 phase 还是 `unknown`/`unprepared` 时**:跑失败之后再 重读会把 `failed` 覆盖成 `未安装`,把失败原因和逃生口一起抹掉。 - 动了下拉,行状态却还是旧 plan 的——`POST /voice/provision/preview` 用选中的那一份 请求做一次 `inspect`(只 stat,不下载),行和按钮上的字节数跟着下拉走。下载进行中 直接回实时视图,不让一次预览把进度条擦掉。 ## 铃声 卡片弹起时要有声音,但**不能用微信的铃声**(那是别人的版权物),所以 `assets/ringtone.wav` 由 `scripts/gen-ringtone.mjs` 自己合成。 第一版是 A4/C#5/E5 大三和弦 + 2.01x / 2.98x 非谐泛音,"像敲了一下玻璃杯"——被当场否掉: **那是高铁和飞机登机广播的音色**,温柔是温柔,但会让人 PTSD。公共广播铃声的配方是 "上行大三和弦 + 金属非谐泛音",新版三条全部避开:**下行**、**小调五声**(C5·A4·G4·E4)、 **泛音全是基音的整数倍**(1/2/3/4 次,各按 1.7/3.1/4.6/6.2 的速率衰减)。最后一条是 "拨弦"和"风琴"的区别,也是"房间里的乐器"和"天花板上的喇叭"的区别。 - 每音符比上一个轻一点(diminuendo),尾音互相叠上,所以是"往下说"而不是"喊你"。 - 1.4 s 声音 + 1.8 s 休止:填满循环的铃声只会被关掉。首尾各做 5 ms / 40 ms 淡入淡出, 否则循环接缝处 3 % 的幅度跳变会"咔"一声。 - 3.2 s / 22.05 kHz / 单声道 / 16-bit = 141 KB,归一化到 **−3.3 dBFS**,播放音量 0.45, 整体 RMS 约 −16 dBFS:隔着房间听得到,不至于吓到人。 - 走 `/voice/call/ringtone` 同源路由,不是 base64 内联(内联会让客户端 bundle 胖三倍), 也不是让浏览器去读包目录(读不到)。`package.json` 的 `files` 必须含 `assets`,否则装出来的 包没铃声——路由对这种情况返回 404,卡片安静地不响,通话本身不受影响。 - 三层开关:`callCard.ringtone`(配置)、卡片上的「静音」(只关当次来电,不落配置)、 接听/拒接/超时(停)。 - 浏览器的自动播放策略会拒绝第一次 `play()`,而且是以 rejected promise 的形式—— `play().catch(()=>{})` 会把这一环永久吞掉。所以 `ringtone.ts` 在被拒后挂一次性的 `pointerdown`/`keydown` 重试,并且**在真正开始播之后把监听器摘掉**。这条规则连同 "play() 晚于卡片消失才 resolve" 的竞态,是 `test/ringtone.test.ts` 存在的全部理由。 ### 11 条可选,和一次做过头的响度对齐(2026-09-25 补) `src/client/tones.ts` 是唯一的 id→文件表,四个地方读它:配置的 union、路由的白名单、 设置卡的「铃声」下拉框、以及 `test/tones.test.ts` 与磁盘的双向对账(表里有而盘上没有 → 测试红;盘上有而表里没有 → 白占包体积,也红)。生成器 `gen-ringtone-candidates.mjs` 在 写文件之前也要两张表对上,对不上直接抛,所以"加了配方忘了加选项"这种错到不了运行时。 **id 永远不是路径**:`GET /voice/call/ringtone?tone=kalimba` 只做一次表查找,取出的 `file` 是代码里的常量;再加一道"解析后必须仍在 `assets/` 下"的兜底。于是 `?tone=../../package.json` 和它的百分号编码版本都返回默认那条铃声,而不是包清单—— 测试把三种恶意写法都跑了一遍。响应的 `cache-control` 是 `max-age=86400`,tone 走 query 正因为 query 是缓存键的一部分,换铃声不会被旧缓存答复。 试听按钮和卡片共用 `RINGTONE_VOLUME = 0.45`:选的时候听到的就是接下去每天听到的响度, 而不是文件满电平。它循环播、再点一次停、切走这一行(选到「静音」)时自动停下——一个 停在播着的预览,和一条停不下来的铃声,是同一种讨厌。 那段响度对齐是**先做过头、再改回来**的:为了"别让人因为更响而选中某条",我把 11 条统一 压到最安静那条的 300 ms 响度窗,而且只压不抬。后果是 7 条的峰值掉到 0.35–0.54,再乘 0.45 的播放增益就在笔电喇叭上消失了——试听页 11 条只响出 4 条,而响出来的那 4 条正好是 峰值高于 0.62 的 4 条。"公平比较"如果让一半选项听不见,它就不是公平,是缺功能。现在 11 条混到同一个峰值(−3.3 dBFS),响度窗之间保留约 5 dB 差异:那是渐强与敲击声的本性, 要改得改配方里的力度,不该在最后挂一级增益。`ringtone-kit.mjs` 的 `TARGET_LOUD` 把这个 结论写在了常量上,0 就是不做对齐。 ## 磁盘占用与一键清理(2026-09-25 补) 插件能安静地放下 1.2 GB,就得能让人拿回来。`src/provision/cleanup.ts` 是这句话的全部: **三条规则** 1. **根目录就是边界。** 每个路径先 `resolve` 再和 `layout.root` 比,`treeBytes` 用 `lstat` 且不跟随符号链接——所以根目录里放一个指向别处的链接,也不会把删除带出去。 用户在配置里手写的 `D:\crispasr` 天然在边界外:保护它的规则和保护整块磁盘的规则是同一条。 2. **按组,不是一把 `rm -r`。** 引擎 / 模型 / 下载缓存分别计量、分别删除; `provision.json` 只在**三组都请求且全部成功**时才跟着走,否则留着——一次没删干净的 清理仍然需要记录来解释"哪一组还在"。 3. **拒绝也是一种结果。** Windows 不会删正在运行的 exe,半截的 `.part` 可能还被握着。 这些以 `failed` + 操作系统原话返回,而不是"成功"但实际少释放了 35 MB。 **两道确认** `POST /voice/provision/cleanup` 必须带 `confirm: true`。这个端点和本机任何进程一样能发 请求,"卡片发了个 body" 不等于 "有人按了第二个按钮"。没带 confirm 的请求返回**占用报告** 而不是执行删除;下载进行中返回 409(删掉正在写的 `.part` 会毁掉断点续传,然后整个文件 重新下一遍)。 界面上是两段。第一版把入口做成一个灰色下划线小链接(「清理本地文件 · 1.5 GB」), 结果**没人看得见它**——一个用来回答"我能不能把这些东西拿回来"的按钮,藏在它自己要解决 的那个焦虑下面。现在它是独立一栏,左边一句 `voice 目录当前占用 3.1 GB`,右边一个带边框 的按钮「清理本地文件」;数字挪到左边是因为**它是别人来找这个按钮的理由,不该写在按钮 标签里**。点开后才出现勾选框、「将删除 引擎 35.5 MB · 模型 1.2 GB,共 1.2 GB。删除后需要 重新下载才能用语音来电。」、说明作用域的那句(只删 `~/.dsh/voice`,你手写的 `D:\crispasr` 不在里面)和红色的「确认清理」。预勾选有个判断:**引擎已经能从配置路径正常工作时,默认 只勾下载缓存**——一个正在用着语音的人按这个按钮,多半是想清垃圾而不是想把自己的声音删掉。 **为什么按钮平时看不见**:`totalBytes === 0` 时整个入口不渲染。"清理 0 B" 放在一张本来 就不该给人压力的卡片上是纯噪音;但下载中它是**禁用而不是隐藏**,因为"为什么现在不能删" 这句话必须能被读到。 ## 已知边界 - 清理按组做,不按文件做:能单独留下某个引擎版本、只删某个模型的能力没有, 目录粒度已经覆盖"我不想要了"和"清缓存"两种实际需求。 - 铃声没有音量旋钮:文件峰值与播放音量都是定死的,要更响/更轻只能改配置里的系统音量。 `callCard.ringtone: false` 是完全关掉。 - 设备探测已接上(`--diagnostics` → `ggml backends` / 显存),但它只在装好一个引擎之后 才能问:第一个引擎靠的是 `os.platform()` / `os.arch()` / 是否有 NVIDIA 显卡。 - 换模型的现实:`cstr` 另有 base / voicedesign 端口,但那是**不同 backend、不同输入**, 不是换个 gguf 就能跑,所以没放进选择器。可选项目前是量化档位。 ## 装配的三处后端修复(2026-09-25 补) 前面几节讲的是「做了什么」,这一节讲的是「曾经谎报过什么」——三条都是代码写完、 测试全绿、界面也好看之后,回头逐行查后端才发现的。共同点是:**功能没坏,承诺坏了**。 **断点续传的位图从来没在中断路径上落过盘。** `segmented.ts` 把 `saveState` 写在 `try/finally` *之后*,于是只有「所有 lane 都干净返回」的那一条路会写 sidecar—— 而那条路接下来就把 `.part` 改名、把 sidecar 删掉。换句话说:**位图存在的唯一目的 (中途失败后接着用)永远走不到**。更糟的是 `.part` 在下载开始前就被 `truncate` 成 目标全长,所以下一次运行看到的是「文件大小对、位图不存在」→ 判定为全新开始 → 1.95 GB 从头再下一遍。而 `adviceFor('aborted')` 那句文案一直写着 「已下载的字节留在 .part 里,再点一次从断点继续」。测试也是绿的,因为 `provision-segmented.test.ts` 里那份 sidecar 是**测试自己手写**的,绕开了唯一值得测的路径。 现在 `saveState` 进了 `finally`,新测试用「传两段就断」的服务器**制造**一次中断, 再验证第二次运行只请求剩下的洞。 **读一次状态就等于把正在跑的进度擦掉。** `GET /state` 无条件 `runner.inspect()`, 而 `inspect` 是从计划表重建整个视图的:phase 变成 `unprepared`、`receivedBytes` 归零。 所以「下载中途打开设置卡看一眼」这个动作本身,就会把进度条抹平,还会顺着 `index.ts` 里的相位订阅去 `detect()`——在下载正在写同一个目录的时候再起一个引擎进程。 `/preview` 早就知道要 `if (runner.busy)`,`/state` 忘了。同一个规则写在两个端点上, 一个记得一个不记得,这就是它一直活到今天的原因。`/adopt` 同理:它既不看 `busy`, 也不把自己标成忙,能和 `run()` 交错写同一份 `provision.json`——记录被写坏就是 `readRecord` 的 catch 分支,也就是「插件忘记自己装了 1.2 GB,然后再次提供下载」。 现在 adopt 和 prepare 抢同一把 `running` 锁,`writeRecord` 的临时文件名带上 pid。 **「只删 voice 目录里的东西」这句话里,voice 目录本身没人检查。** 清理用的判定是 `insideRoot(root, groupDir(root, group))`,而 `groupDir` 永远是 `join(root, …)`—— 这个表达式对任何 root 都为真,它什么也没挡住;测试还把这句话原样断言了一遍 (`provision-cleanup.test.ts`:「每个 group 都在 root 内」✅)。真正的入口是配置里的 `audioDir: z.string()`,一个字面量类型都没有:写成 `.` 就是当前工作目录, 于是二次确认之后的 `rm -r` 删掉的是 `\models`、`\engine`、`\downloads`。 现在这些锚点被明确拒绝(盘根、cwd、home、DSH 配置目录、临时目录),判定放在 `cleanRoot` 内部而不是路由里,因为忘记调它的调用者会真的删东西。相对路径直接拒绝, `D:\voice` 这种一层目录仍然允许——它是人真的会放的合法位置,砍掉它就是缺功能。