# 模型自动下载功能设计（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 字节，仅作素材核对参照。
