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:
180
docs/superpowers/specs/2026-05-29-model-download-design.md
Normal file
180
docs/superpowers/specs/2026-05-29-model-download-design.md
Normal 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-4bit(9 文件,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-4bit(11 文件,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 字节,仅作素材核对参照。
|
||||
Reference in New Issue
Block a user