Appearance
ADR-195: 下载文件夹统一修订(三平台系统下载目录 + 消除"二扫")
状态: 已完成(代码落地 + 单测 + 全量验证通过 2026-07-27;设计偏差与已知限制见 §实施记录与已知限制) 日期: 2026-07-27(初版) 关联: ADR-181(下载管理面板,本 ADR 修订其定位与行为)、ADR-180 / ADR-183(网页 FSA 根目录授权)、ADR-176(BackendService 双实现 + 绞杀者模式)、ADR-182(纹理命名空间化)、ADR-057 / ADR-058(Shift-JIS 文件名兜底) 来源: 用户复现①「下载管理面板扫描导入全部往场景里塞」②「暂存目录语义迷惑(暂存=会被清,实际保留原地)」③「根目录扫描逻辑混乱,该扫的不扫、二扫 UI 不一致」④「安卓/网页都应请求系统下载文件夹,而非自建暂存」。 编号说明: 本修订最初误编号为 ADR-184,但
adr-184-web-zip-encoding-and-bomb-guard.md(ZIP 编码/炸弹防护)已占用该号;后顺延至 194 时与同期落地的adr-194-wind-physics-fix.md(已完成)撞号,依「已完成文档保留稳定编号、规划中文档零成本顺延」原则,最终定号为 195。
决策者: Riku(联邦首席架构师 AI)、Jieling(人类侧首席架构师)
创建日期: 2026-07-27
背景
ADR-181 的动机成立:取代脆弱的 watchDir fsnotify 机制,提供统一下载摄入入口。但其落地存在**命名误导、行为错配、键空间分裂("二扫")**三处结构性问题,用户实测后指出。
问题 1 — 命名全线误导("下载"与"暂存"语义错配)
| 当前文案 | 实际行为 | 矛盾点 |
|---|---|---|
菜单 settings.downloads = 「下载」 | 不下载,扫本地目录 | 「下载」暗示网络获取,实为本地扫描 |
| 面板「暂存目录」 | 倒入源目录,文件保留原地只加标记 | 「暂存」= 临时/会被清,实际长期保留 |
| 面板「已处理文件」 | 仅一个"清理已处理记录"按钮 | 名承诺"列表",实无列表 |
问题 2 — 行为错配:批量摄入耦合"加载到场景"
settings-downloads.ts 主循环对每个文件调用 importFileByPath(library-actions.ts,契约为单文件交互式导入:拖放/点击),其内部 loadManager.load({ kind: 'actor' }) 把模型加载为活动模型进场景。
- 后果①:N 个 PMX 被串行加载进场景、互相替换,最终仅最后一个在屏;前 N-1 个是浪费的加载/卸载抖动,潜在 dispose 竞态。
- 后果②:裸
.pmx/.vmd经此路径只进场景、不写资源库(loadManager不注册库),与 ADR-181「写入资源库」本意背离;可能闪一下即被覆盖,库里根本没它。仅.zip走ImportZip才真写库(但仍额外把主 PMXloadManager.load进场景)。
问题 3 — "二扫":两套互不连通的键空间与去重
项目存在两套扫描/入库管线,各自为政:
| 维度 | 管线 A:模型库(ScanModelDir / _listModels) | 管线 B:下载面板(runDownloadManager) |
|---|---|---|
| 扫哪 | 用户选的 resource-root(FSA 句柄 / 磁盘扫描) | 用户选的"暂存目录"(_stagingFsaIdbKey / 桌面 SelectDir) |
| 写哪 | entry: / dir: 键(列表可读) | dl:file: 旁路键 + imported:<hash> 标记(列表读不到) |
| 去重 | 基于 entry: 存在性 | 基于 imported:<hash> / .imported.json(独立账本) |
| 入库 | 写库 → 模型库可见 | 裸 pmx 走 importFileByPath → 进场景(不写库) |
_listModels(browser-adapter.ts:390)只列 entry: 前缀。下载面板写入的 dl:file: / imported: 账本对模型库完全不可见 —— 这就是用户所述「该扫的不扫、扫了 UI 二扫逻辑又不一样」的根因。
问题 4 — 平台来源定位缺失,且安卓能力误判
- 桌面:
pathmgr_desktop.go:44DownloadsDir()直接返回系统~/Downloads,桌面有 FS 权,本可直接用,却让用户"选择暂存目录"是多此一举。 - 网页:已有 FSA 授权机制(ADR-180/183 的
getFsaAuthState/restoreFsaRootHandle/reauthorizeFsaRoot),下载面板却另持一套_stagingFsaIdbKey,与模型库授权不互通。 - 安卓:
localStaging: !isAndroidPlatform()恒为false,UI 显示死提示"Android:需 SAF 授权(待实现)"。但实测安卓已有MANAGE_EXTERNAL_STORAGE权限(AndroidManifest 已声明)+ Go 侧标准os文件 IO(fileaccess_android.go:35/42/49),可直读/sdcard/Download——无需新建 SAF(推翻早期"必须 SAF"的过重判断)。pathmgr_android.go的DownloadsDir()仅返回"",是未接路径,非缺授权。
决策
决策 1 — 命名正名(纯 i18n,零逻辑风险)
| 现文案(key) | 改为 | 理由 |
|---|---|---|
settings.downloads | 下载文件夹(保留「下载」心智,用户确认对多数用户好理解) | 不下载但词易理解;面板语义对齐"系统下载文件夹" |
downloads.stagingDir | 下载文件夹 | "暂存"误导(见问题 1) |
downloads.pickStagingDir | 选择下载文件夹 | 同上 |
downloads.manageImported | 导入记录 | 该区块仅"清理"按钮,现名承诺未兑现的列表 |
新增 downloads.supportedHint | "支持 PMX / VMD / 音频 / ZIP,将递归扫描子目录" | 解决"扫啥不知道"(问题 3 用户痛点) |
| 扫描前预览 | 弹"将导入 N 个文件"清单(按扩展名分组) | 解决盲扫 |
决策 2 — 三平台下载文件夹定位(差异化复用既有能力)
| 平台 | 下载文件夹来源 | 授权机制 | 工作量 |
|---|---|---|---|
| 桌面 | 系统 ~/Downloads(pathmgr_desktop.go:44 DownloadsDir()) | 无需授权(桌面有 FS 权) | 零(直接读,移除"选暂存目录"强制步骤,保留"改用其他目录"可选) |
| 网页 | 系统 Downloads(用户 FSA 选一次 + 句柄持久化) | 复用 ADR-180/183:getFsaAuthState / restoreFsaRootHandle / reauthorizeFsaRoot;下载面板不再单独持有 _stagingFsaIdbKey,与模型库共用 FSA 授权 | 复用 |
| 安卓 | /sdcard/Download | 复用 shared 模式(MANAGE_EXTERNAL_STORAGE 已声明)+ 标准 os.ReadDir 直读;pathmgr_android.go 新增 DownloadsDir() 返回 /sdcard/Download | 低(加路径定位 + 前端绑定,无需 SAF) |
安卓代价(诚实说明):下载面板依赖 shared 模式开启(SetStorageMode("shared") + 权限已授予)。private 模式下 /sdcard/Download 不可达,UI 需提示"需开启共享存储模式"或引导切换。
约束(P3,落地前评审)— 网页下载文件夹允许独立于 resource-root 授权:本决策"与模型库共用 FSA 授权"为默认推荐,但不得强制共用单一句柄。若用户希望下载文件夹与模型库根目录为不同目录(如下载在桌面、库在移动硬盘),网页端应支持第二个独立 FSA 授权(持久化第二个 showDirectoryPicker 句柄),而非把下载文件夹句柄强制等同于 resource-root 句柄。实现上:getFsaAuthState / restoreFsaRootHandle / reauthorizeFsaRoot(ADR-180/183)应支持多句柄按用途 key 区分(如 root vs download),避免"下载文件夹"语义被"模型库根"吞并。
决策 3 — 消除"二扫":合并键空间,复用模型库入库逻辑
下载面板扫描到的文件,不再写 dl:file: 旁路 + imported:<hash> 独立账本,改为直接复用模型库的入库函数与键空间:
- 网页:裸
.pmx/.vmd→ 复用ingestModelFiles([{name,bytes}])(browser-adapter.ts,内部_buildIngestPairs计算file:+entry:键值对,经idbBatchSet单事务写入,不加载场景);.zip→ 复用ImportZip(写dir:/outfit:键)。删除_stagingFsaIdbKey+dl:file:写入。原私有_writeModelFile已重构为公开ingestModelFile(File)/ingestModelBytes(name,bytes)/ingestModelFiles(...)三入口。 - 桌面 / 安卓:扫描下载文件夹后,将文件复制到库的扫描根(resource-root),由现有的
ScanModelDir/ 磁盘扫描统一发现。删除.imported.json独立账本。 - 去重统一:以
entry:键存在性(网页)或 resource-root 内文件已存在(桌面/安卓)为唯一判据,删除imported:<hash>/.imported.json第二套账本。
结果:下载文件夹的内容直接进模型库列表可见,与"手动导入"行为完全一致,且只有一套扫描、一套键空间、一套去重。
约束(P3,落地前评审)— 批量入库须事务化:裸文件复用 _writeModelFile(逐条 idbSet)在批量扫描并发摄入场景下存在 IndexedDB 写竞态(idbSet 非事务级,多文件同时写 file:/entry: 可能交错或覆盖)。落地时批量摄入应包入 idbBatchSet 事务(一次性写入该批次所有 file:+entry: 键),或在外层加串行化队列,杜绝并发写竞态。单文件拖放/点击(原 importFileByPath 路径)维持逐条写入即可。
决策 4 — 行为解耦:批量摄入只入库、不加载到场景
- 删除
settings-downloads.ts中对importFileByPath的全部调用(:203网页裸文件 /:260桌面 zip 主 PMX /:263桌面裸文件)。 - 批量摄入语义 = 注册进资源库(写
entry:/dir:键或复制进 resource-root),不loadManager.load进场景。用户从模型库点选再加载。 importFileByPath单文件拖放/点击导入行为保持不变(仍加载进场景,符合其交互契约)。
决策 5 — 倒入源语义(移动 / 复制 / 原地注册)— 已默认落 A
下载文件夹是"倒入源",库是"目的地"。文件如何从源到目的地,三选项:
- A(推荐,已落地):复制到库根——网页写
entry:/dir:键(已是复制语义);桌面/安卓复制到 resource-root。源文件保留在下载文件夹。库立即可见,零破坏,双份空间占用可接受(模型文件通常不大)。 - B:移动到库根——源文件移出下载文件夹,释放空间;但破坏用户下载目录,且中断其他下载工具对该文件的引用。
- C:原地注册(仅网页可行)——网页写
entry:键指向原file:字节(不复制);桌面/安卓需让库扫描包含下载文件夹(又引入"二扫"风险,不推荐)。
实施期已默认落 A(见 §实施记录)。B/C 若后续用户反馈需要,再行评估。
影响面与验证
| 改动文件 | 内容 |
|---|---|
frontend/src/menus/settings-downloads.ts | 删除全部 importFileByPath 调用(批量摄入只入库、不进场景);裸文件网页改调 ingestModelFiles、桌面/安卓改调 ImportLocalFile/ImportZip(复制到资源根);删 dl:file: 写入 + imported:<hash> 账本;新增会话内去重 _ingestedStems(按完整文件名,见 §实施记录 F1);接入三平台下载文件夹来源(决策 2);扫描前预览(决策 1);网页独立"选择下载文件夹"按钮(selectFsaDownloadDir) |
frontend/src/core/backend/browser-adapter.ts | 原私有 _writeModelFile 重构为公开 ingestModelFile(File) / ingestModelBytes(name,bytes) / ingestModelFiles([{name,bytes}])(内部 _buildIngestPairs + idbBatchSet 单事务,P3 满足);新增 FSA 下载文件夹独立句柄 getFsaDownloadAuthState / reauthorizeFsaDownload / selectFsaDownloadDir / getFsaDownloadHandle(key=fsaDownloadHandle,与 fsaRootHandle 分离,P3 满足);ImportZip 保留(写库不加载场景) |
frontend/src/core/backend/idb.ts | 新增 idbBatchSet(store, entries) 单事务批量写(P3 批量摄入事务化约束落地) |
frontend/src/core/backend/go-adapter.ts | 桌面/安卓路径:扫 DownloadsDir() / pathmgr_android.DownloadDir();复制进 resource-root;删 .imported.json 账本;localStaging 改为安卓 shared 模式下可用 |
internal/app/pathmgr_android.go | 修正 DownloadsDir() string { return "/sdcard/Download" }(shared 模式下经 MANAGE_EXTERNAL_STORAGE 可访问;原返回 "" 为未接路径) |
frontend/src/core/i18n/locales/* | 决策 1 正名键(downloads.stagingDir→下载文件夹 等)+ 新增 downloads.supportedHint |
docs/adr/adr-181-download-manager-panel.md | 加修订标记(标题/状态注明"经 ADR-195 修订定位与行为");§决策 第 3 步补"批量摄入仅入库、不加载到场景";命名澄清 |
验证计划:
- 网页:
browser-adapter.test.ts扩展(ingestModelFile 入库后_listModels可见、不触发 loadManager)。 - 桌面/安卓:新增复制进 resource-root +
ScanModelDir发现的集成用例(Go 侧fileaccess_android_test.go/pathmgr_android_test.go)。 - 契约:
app.contract.test.ts确认绑定数稳定(142 methods,新增DownloadDir/SelectDownloadDir按需)。 npm run check:docs(ADR 索引同步 + 架构树完整性)通过;npm run check:funcmap函数签名无漂移。
遗留 / 注意
- 安卓 shared 模式依赖:下载面板在安卓仅在 shared 模式可用(private 模式不可达
/sdcard/Download)。UI 需明确提示,不在文档范围外强行降级。 - 倒入源语义默认 A(复制):若用户改选 B(移动),需额外处理"下载中文件被移动"的竞态(下载工具持有句柄)。
- 历史
dl:file:/imported:残留数据:迁移时旧键可保留(无害,库不读),或加一次性清理;不阻塞本 ADR 落地。 - 与 ADR-181 关系:本 ADR 是 ADR-181 的定位与行为修订,非推翻;ADR-181 的"取代 watchDir、统一摄入入口"动机与架构保留。
- 扫描前预览 UX:决策 1 的"N 个文件"清单需从递归扫描结果聚合,不增加 O(n) 之外的开销(扫描本就 O(n))。
- 落地前评审约束(来源:ADR-194 编号冲突复盘时的独立审核):已写入对应决策——① 决策 2 网页下载文件夹支持独立于 resource-root 的第二个 FSA 授权(P3);② 决策 3 批量摄入须
idbBatchSet事务化避免 IDB 写竞态(P3);③ 影响面 ADR-181 原文加修订标记(P4)。三项均不阻塞本 ADR 立项,为实施期的强约束。
实施记录与已知限制
代码于 2026-07-27 落地,全量验证通过(
vitest run2174 测试绿 /tsc --noEmit零错误 /go build ./internal/...通过 /npm run check:docs通过)。以下为实施期偏差与审计发现。
实施期偏差(已落实,非回归)
- ingest API 形态:设计稿写"提升
_writeModelFile为公开ingestModelFile"。实际落地拆为三入口——ingestModelFile(File)(拖放/点击单文件)、ingestModelBytes(name,bytes)(备用)、ingestModelFiles([{name,bytes}])(批量,下载面板用此入口)。下载面板避免new File()(TS 5.x 严格SharedArrayBuffer类型下Uint8Array不兼容BlobPart),故采用name+bytes形式。 - 去重机制:ADR-195 有意移除
imported:<hash>持久账本,改用会话内_ingestedStemsSet。 - P3 约束全部满足:① 网页下载文件夹独立
fsaDownloadHandle(showDirectoryPicker持久化第二个句柄,与fsaRootHandle键空间分离);②ingestModelFiles经idbBatchSet单事务原子写入;③ ADR-181 已加【经 ADR-195 修订】标记。
审计发现(2026-07-27 代码审计)
| 项 | 严重度 | 说明 | 处置 |
|---|---|---|---|
| F1 去重按裸 stem 误判同名不同扩展名 | 🟡 确定性缺陷(已修复) | 去重键原用 stem = name.replace(/\.[^.]+$/,''),miku.pmx 与 miku.vmd 同名词对会被判为重复而静默跳过动作文件。已改为按完整文件名去重(_ingestedStems 存 f.name/e.name),stem 仅保留给 zip 落库键 file:${stem}。 | ✅ 修复于 settings-downloads.ts 网页流 + 本地流 |
| F2 仅会话内去重,重载后重扫产生库内重复 | 🟡 已知权衡 | 移除持久账本后,重载首扫同目录时 ingestModelBytes 经 _resolveUniqueStem 对既有 entry:miku 追加 (2) 后缀,库内出现重复条目。属 ADR-195 既定权衡,非回归。 | 记录限制;后续可用 GetLibraryIndex() 现存 entry: 做跨会话去重(待办,不阻塞) |
F3 listFilesWeb 全量读入内存 | 🟡 边界提示 | new Uint8Array(await file.arrayBuffer()) 一次性载入所有文件;zip 有 500MB 单文件守卫但无总量上限。沿用 ADR-181 旧模式,用户手动触发,可接受。 | 仅提示,不改 |
F4 reauthorizeFsaDownload 手势窗口 | 🟡 边界提示 | requestPermission 须在用户手势窗口内调用,但此前已有多个 await(句柄读取、状态查询)。与现有 reauthorizeFsaRoot 行为完全一致(非新引入),若实测被浏览器拦截,需将 reauthorize 提前到首个 await 前。 | 仅提示,不改 |
验证覆盖
browser-adapter.test.ts:新增 4 用例(file:/entry: 键写入、_listModels可见、批量单事务、_resolveUniqueStem同名序号后缀)→ 24/24。download-manager.test.ts:25/25(扫描→解压→入库全链路)。- 全量
vitest run:2174 测试 / 107 文件全绿。 go build ./internal/.../tsc --noEmit/npm run check:docs:均通过。