docs(spec): 模型自动下载功能设计(2026-05-29)

新增「我的·模型管理」页模型下载功能设计:
- 独立 ModelDownloadService + ModelStore 保持纯存储(§3.1)
- HTTPS 断点续传(Range+追加写)、分模型卡片进度、大小校验
- 旁路文件导入兜底(补 VL)、AI 入口未就绪「前往下载」引导
- base URL https://file.myv0.com/,含精确 24 文件清单

并加 .gitignore 忽略本地模型素材目录 /Models/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
link2026
2026-05-29 22:12:19 +08:00
parent fe80e112af
commit 6ccbe4ac55
2 changed files with 182 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
# 模型自动下载功能设计2026-05-29
> 让用户在「我的 · 模型管理」页一键从自建 HTTPS 服务下载两个 MLX 模型,支持断点续传、
> 进度展示和现场重装的旁路导入兜底。对应 CLAUDE.md §4「模型分发」与 W6「首启动下载流程」的核心部分。
## 1. 背景与现状
- 模型加载链路已通:`LLMSession`/`VLSession``ModelConfiguration(directory:)` 从沙盒
`Application Support/Models/<repo>/` 读取,`AIRuntime.prepare()/prepareVL()`
`ModelStore.isReady()` 为假时抛 `notReady`
- **缺口**:没有任何下载实现。`ModelStore` 只有 `isReady()` 判定 + `seedFromBundle()` 占位;
唯一能装模型的路径是 DEBUG-only 的 `DebugAIRunner` 手动 `fileImporter`(且只导 LLM漏 VL
- `MeView` 已预留「模型管理」卡片(`detail="未配置"`icon `cpu`),尚未连接任何界面。
- `HealthExportService` 的未就绪文案已写「请先到『我的 · 模型管理』下载」,落点早有预期。
- 无 Onboarding / 首启动流程。
## 2. 服务器素材(已就绪并验证)
- 自建 Caddy 静态文件服务,文件根 `/srv/models/`
- base URL**`https://file.myv0.com/`**(用户自建反代,标准 HTTPS
- 备选:`http://101.132.124.52:5244/`(纯 IP需 App 端 ATS 例外;域名挂了时用)。
- 已验证:`config.json` 返回 200两个 `model.safetensors` 均支持 Range`206` + `Accept-Ranges: bytes`
反代返回的总大小与本机精确一致LLM 968080210、VL 3073720461未截断大文件。
- 服务器 24 个真实文件字节数与本机逐一匹配LLM 984015687、VL 3089713215
## 3. 范围
**做**模型管理页分模型卡片、HTTPS 断点续传下载、大小校验、蜂窝网络提示、
旁路文件导入LLM + VL、MeView 接入、AI 入口未就绪「前往下载」引导。
**不做YAGNI**:首启动 Onboarding、启动自动后台下载、哈希校验大小校验够
Live Activity 下载进度Live Activity 是推理时的 tok/s单独功能、并行多文件下载。
## 4. 架构(方案 A独立 Service + ModelStore 保持纯存储)
```
ModelManagementView (UI)
→ ModelDownloadService (@MainActor @Observable下载编排 + 进度状态)
→ ModelStore (文件路径 / 就绪判定 / 旁路导入)
→ URLSession (HTTPS 分块下载)
```
- 符合 §3.1 模块边界UI 不直接碰 `URLSession`,只观察 Service 发布的状态。
- `ModelDownloadService` 与现有 `CaptureService`/`AskService` 并列。
- `ModelStore` 继续只管「模型在哪 / 是否就绪 / 旁路拷入」,不引入网络职责。
### 4.1 下载状态模型
```swift
enum DownloadPhase: Equatable {
case idle //
case downloading //
case verifying //
case ready //
case failed(String) // ·
}
struct DownloadState: Equatable {
var phase: DownloadPhase
var receivedBytes: Int
var totalBytes: Int
var bytesPerSecond: Double
var fraction: Double { totalBytes > 0 ? Double(receivedBytes) / Double(totalBytes) : 0 }
}
```
`ModelDownloadService` 持有 `var states: [ModelKind: DownloadState]``@MainActor` 更新UI 观察。
## 5. 数据:硬编码 manifest
```swift
struct ModelFile { let path: String; let bytes: Int } // path
enum ModelManifest {
static let baseURL = URL(string: "https://file.myv0.com/")!
static func files(for kind: ModelKind) -> [ModelFile]
static func totalBytes(for kind: ModelKind) -> Int // files.reduce
}
```
- 只列**加载必需**的功能文件,排除纯文档 `README.md` / `.gitattributes`(省下载)。
- 文件 URL = `baseURL / kind.rawValue / file.path`
- `bytes` 用于总进度计算与下载后**逐文件大小校验**。
- 精确清单见附录 A。
## 6. 下载流程(断点续传,应对 3GB 单文件)
逐文件**串行**下载,单文件级续传用 **HTTP Range + 追加写**(比 `URLSession.resumeData` 更可控,
app 重启也能续):
1. 目标 `Models/<repo>/<file>` 已存在且 size 匹配 → 跳过(粗粒度续传)。
2. 否则下到 `Models/<repo>/<file>.part`:已下字节数 = `.part` 当前大小,
`Range: bytes=<已下>-` 请求,`URLSession` data delegate 流式 `FileHandle` 追加写。
3. 完成后校验 `.part` 大小 == manifest `bytes`,原子 `rename` 去掉 `.part` 后缀。
4. 该模型全部文件就位 → `ModelStore.isReady` 自然为真。
- 串行(一次一个文件):不抢 MLX 资源、进度计算清晰。
- 总进度 = 已完成字节 / `totalBytes(for:)`;速度用滑动窗口算 bytes/s。
- 支持「暂停」:取消当前 task`.part` 保留,下次从断点续。
## 7. UI
### 7.1 `ModelManagementView`(分模型卡片)
- 两张卡:
- **Qwen3-1.7B · 文本解读**(约 939 MB
- **Qwen2.5-VL-3B · 拍照识别**(约 2.9 GB
- 每张卡显示:状态 `待下载 / 下载中 xx% · x.x MB/s / 校验中 / 已就绪 ✅ / 失败 · 重试`
+ 进度条(原生 `ProgressView` + `Tj.Palette`+ 大小。
- 顶部总操作 `下载全部模型``TjPrimaryButton`);下载中切为 `暂停`
- **蜂窝网络提示**`NWPathMonitor` 检测到非 WiFi开下前弹确认"约 3.9GB,建议 WiFi 下载")。
- 底部 `从文件导入``TjGhostButton`)→ 旁路导入。
- 复用 `.tjCard` / `TjBadge` / `TjLockChip`,不新增设计 token§9
### 7.2 旁路导入(现场重装兜底)
`DebugAIRunner``fileImporter` 逻辑转正进 Service / `ModelStore`
- 选文件夹 → 校验含 `config.json` → 拷入 `Models/<repo>/`
- **补上 VL**(现在 DEBUG 只导 LLM
- 按所选文件夹名匹配 `ModelKind.rawValue` 自动识别是 LLM 还是 VL不匹配时提示选择。
## 8. 接入点
- `MeView` 「模型管理」卡片 → `NavigationLink``ModelManagementView`
`detail` 动态显示 `已就绪 / 未下载 / 下载中 xx%`
- **AI 入口未就绪引导**§4 要求):`DiaryQuickSheet``UnifiedCaptureFlow``HealthExport`
的「模型未就绪」错误态补 `前往下载` 按钮,跳 `ModelManagementView`
## 9. 错误处理
- 网络中断 → 卡片转 `失败 · 重试`,保留 `.part` 供下次续传,不卡死、不删已下数据。
- 校验失败size 不符)→ 删该文件重下。
- 旁路导入选错文件夹(无 `config.json`)→ 提示,不写入。
- base URL 不可达 → 失败态,文案提示检查网络。
## 10. 测试策略
- 单元测试(用 `URLProtocol` mock 网络,不碰真 MLX / SwiftData
- `ModelManifest.totalBytes` 计算正确。
- 续传偏移计算:`.part` 已有 N 字节时请求 `Range: bytes=N-`
- 大小校验size 不符判失败。
- `DownloadState.fraction` 边界totalBytes=0
- `ModelStore.isReady` 在文件齐全 / 缺失时的判定。
- UI 手动验证:模拟器跑下载流程(指向真实 base URL 或 mock
## 附录 A精确文件清单功能文件排除 README/.gitattributes
### Qwen3-1.7B-4bit9 文件984,013,244 字节≈939 MB
| path | bytes |
|---|---|
| config.json | 937 |
| model.safetensors | 968080210 |
| model.safetensors.index.json | 49731 |
| tokenizer.json | 11422654 |
| tokenizer_config.json | 9706 |
| vocab.json | 2776833 |
| merges.txt | 1671853 |
| special_tokens_map.json | 613 |
| added_tokens.json | 707 |
### Qwen2.5-VL-3B-Instruct-4bit11 文件3,089,710,883 字节≈2.9 GB
| path | bytes |
|---|---|
| config.json | 1659 |
| model.safetensors | 3073720461 |
| model.safetensors.index.json | 108307 |
| tokenizer.json | 11421896 |
| tokenizer_config.json | 7256 |
| vocab.json | 2776833 |
| merges.txt | 1671853 |
| special_tokens_map.json | 613 |
| added_tokens.json | 605 |
| chat_template.json | 1050 |
| preprocessor_config.json | 350 |
> 注:进度分母 = 本表功能文件 `bytes` 之和(已排除 README/.gitattributes
> 服务器上含 README/.gitattributes 的全量为 LLM 984,015,687 / VL 3,089,713,215 字节,仅作素材核对参照。