# 模型自动下载功能设计(2026-05-29) > 让用户在「我的 · 模型管理」页一键从自建 HTTPS 服务下载两个 MLX 模型,支持断点续传、 > 进度展示和现场重装的旁路导入兜底。对应 CLAUDE.md §4「模型分发」与 W6「首启动下载流程」的核心部分。 ## 1. 背景与现状 - 模型加载链路已通:`LLMSession`/`VLSession` 用 `ModelConfiguration(directory:)` 从沙盒 `Application Support/Models//` 读取,`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//` 已存在且 size 匹配 → 跳过(粗粒度续传)。 2. 否则下到 `Models//.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//`。 - **补上 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 字节,仅作素材核对参照。