diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6125fc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# 大模型素材:本地下载用于上传到 OpenList,不入库(~3GB) +/Models/ diff --git a/docs/superpowers/specs/2026-05-29-model-download-design.md b/docs/superpowers/specs/2026-05-29-model-download-design.md new file mode 100644 index 0000000..2ba9ed8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-model-download-design.md @@ -0,0 +1,180 @@ +# 模型自动下载功能设计(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 字节,仅作素材核对参照。