- plan: flip 43 checkboxes done across Task 1-7/9; Task 8 (manual speed baseline) and Task 10 (this retro) intentionally left open - CLAUDE.md §8: AI/ ⚠️ partial (AIRuntime/LLMSession/ModelStore/TokenChunk done, VLSession/Prompts/ pending); FileVault ✅; add Debug/DebugAIRunner ✅; drop bold from "W2 当前" and tag W2-W3 row 进行中 - new retros/2026-05-31-w2.md: status table, TBD speed baseline, off-plan Symptom/Timeline/ArchiveListView/AppIcon/Swift6 cleanup, Swift 6 + Simulator sandbox learnings, W3 prep checklist
1295 lines
37 KiB
Markdown
1295 lines
37 KiB
Markdown
# W2:AI 基座 + Schema 重建 Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** 在真机上跑通 Qwen3-1.7B(MLX 4bit)首个 token 输出,落地 SwiftData 全量数据模型(含新增 Asset/ChatTurn 与字段),交付 `AIRuntime` actor + `FileVault` 加密目录,本周末通过 ≥ 15 tok/s 验收。
|
|
|
|
**Architecture:** 严格按 spec §1 分层——UI 永远不直接调 AIRuntime,所有推理走 actor 串行化;模型路径用 `Application Support/Models`,JPEG 用 `Application Support/Vault/` 且打开 `.completeFileProtection`;schema 改动用破坏性迁移(W2-W4 不写 VersionedSchema)。
|
|
|
|
**Tech Stack:** SwiftUI · SwiftData · MLX Swift (SPM) · Swift Testing 框架(`import Testing`) · iOS 17+
|
|
|
|
**关联文档:** [Spec v1.0](../specs/2026-05-25-kangji-features-design.md) §1-§5;[CLAUDE.md](../../../CLAUDE.md) §3 §5 §10 红线
|
|
|
|
---
|
|
|
|
## 文件结构(本周变更)
|
|
|
|
### 新建
|
|
|
|
| 路径 | 职责 |
|
|
|---|---|
|
|
| `康康/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate |
|
|
| `康康/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 |
|
|
| `康康/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 |
|
|
| `康康/AI/TokenChunk.swift` | 流式数据结构 |
|
|
| `康康/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 |
|
|
| `康康/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 |
|
|
| `康康Tests/FileVaultTests.swift` | FileVault 单元测试 |
|
|
| `康康Tests/ModelStoreTests.swift` | ModelStore 单元测试 |
|
|
|
|
### 修改
|
|
|
|
| 路径 | 改什么 |
|
|
|---|---|
|
|
| `康康/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags |
|
|
| `康康/App/KangkangApp.swift` | Schema 加入两个新 @Model |
|
|
| `康康/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner |
|
|
| `康康.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples |
|
|
|
|
### 不动(W2 不碰)
|
|
|
|
`RootView` · `HomeView` · `Quick/*` · `Archive/*` · `RecordSheet` · `Trends/TrendsView` · `DesignSystem/*`
|
|
|
|
---
|
|
|
|
## Task 1:Xcode 项目加入 MLX Swift SPM 依赖
|
|
|
|
**Files:**
|
|
- Modify: `康康.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编)
|
|
|
|
- [x] **Step 1:打开 Xcode 项目**
|
|
|
|
```bash
|
|
open /Users/xuhuayong/apps/康康/康康.xcodeproj
|
|
```
|
|
|
|
- [x] **Step 2:加入 MLX Swift 依赖**
|
|
|
|
在 Xcode → File → Add Package Dependencies → 输入 URL:
|
|
|
|
```
|
|
https://github.com/ml-explore/mlx-swift
|
|
```
|
|
|
|
选 "Up to Next Major" → 添加,勾选这些 product 加到 **康康** target:
|
|
- `MLX`
|
|
- `MLXFast`
|
|
- `MLXNN`
|
|
- `MLXOptimizers`
|
|
- `MLXRandom`
|
|
|
|
- [x] **Step 3:加入 mlx-swift-examples(含 LLM 工具)**
|
|
|
|
继续 Add Package Dependencies,URL:
|
|
|
|
```
|
|
https://github.com/ml-explore/mlx-swift-examples
|
|
```
|
|
|
|
勾选 `MLXLLM` 和 `MLXLMCommon` 加到 **康康** target。
|
|
|
|
- [x] **Step 4:确认 Build Settings**
|
|
|
|
Xcode → 康康 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
|
|
|
|
康康 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
|
|
|
|
- [x] **Step 5:Build 验证**
|
|
|
|
Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。
|
|
|
|
Expected:Build Succeeded,无依赖错误。
|
|
|
|
- [x] **Step 6:提交**
|
|
|
|
```bash
|
|
cd /Users/xuhuayong/apps/康康
|
|
git add 康康.xcodeproj
|
|
git commit -m "build: add MLX Swift SPM dependencies"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2:扩展 Models.swift —— Asset 与 ChatTurn
|
|
|
|
**Files:**
|
|
- Modify: `康康/Models/Models.swift`(全文重写)
|
|
|
|
- [x] **Step 1:把 Models.swift 替换为新内容**
|
|
|
|
打开 `康康/Models/Models.swift`,**整文件替换**为:
|
|
|
|
```swift
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
enum IndicatorStatus: String, Codable, CaseIterable {
|
|
case high, low, normal
|
|
}
|
|
|
|
enum ReportType: String, Codable, CaseIterable {
|
|
case checkup, lab, imaging, prescription, other
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .checkup: return "体检报告"
|
|
case .lab: return "化验单"
|
|
case .imaging: return "影像报告"
|
|
case .prescription: return "处方"
|
|
case .other: return "其他"
|
|
}
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class Indicator {
|
|
var name: String
|
|
var value: String
|
|
var unit: String
|
|
var range: String
|
|
var statusRaw: String
|
|
var note: String?
|
|
var capturedAt: Date
|
|
|
|
var report: Report?
|
|
var asset: Asset?
|
|
var pinned: Bool = false
|
|
|
|
init(name: String,
|
|
value: String,
|
|
unit: String,
|
|
range: String,
|
|
status: IndicatorStatus,
|
|
note: String? = nil,
|
|
capturedAt: Date = .now,
|
|
report: Report? = nil,
|
|
asset: Asset? = nil,
|
|
pinned: Bool = false) {
|
|
self.name = name
|
|
self.value = value
|
|
self.unit = unit
|
|
self.range = range
|
|
self.statusRaw = status.rawValue
|
|
self.note = note
|
|
self.capturedAt = capturedAt
|
|
self.report = report
|
|
self.asset = asset
|
|
self.pinned = pinned
|
|
}
|
|
|
|
var status: IndicatorStatus {
|
|
IndicatorStatus(rawValue: statusRaw) ?? .normal
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class Report {
|
|
var title: String
|
|
var typeRaw: String
|
|
var reportDate: Date
|
|
var institution: String?
|
|
var note: String?
|
|
var summary: String?
|
|
var pageCount: Int
|
|
var createdAt: Date
|
|
|
|
@Relationship(deleteRule: .cascade, inverse: \Indicator.report)
|
|
var indicators: [Indicator] = []
|
|
|
|
@Relationship(deleteRule: .cascade)
|
|
var assets: [Asset] = []
|
|
|
|
init(title: String,
|
|
type: ReportType,
|
|
reportDate: Date,
|
|
institution: String? = nil,
|
|
note: String? = nil,
|
|
summary: String? = nil,
|
|
pageCount: Int = 1,
|
|
createdAt: Date = .now) {
|
|
self.title = title
|
|
self.typeRaw = type.rawValue
|
|
self.reportDate = reportDate
|
|
self.institution = institution
|
|
self.note = note
|
|
self.summary = summary
|
|
self.pageCount = pageCount
|
|
self.createdAt = createdAt
|
|
}
|
|
|
|
var type: ReportType {
|
|
ReportType(rawValue: typeRaw) ?? .other
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class DiaryEntry {
|
|
var content: String
|
|
var createdAt: Date
|
|
var tags: [String]
|
|
|
|
init(content: String, createdAt: Date = .now, tags: [String] = []) {
|
|
self.content = content
|
|
self.createdAt = createdAt
|
|
self.tags = tags
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class Asset {
|
|
var relativePath: String
|
|
var mimeType: String
|
|
var bytes: Int
|
|
var createdAt: Date
|
|
|
|
init(relativePath: String,
|
|
mimeType: String = "image/jpeg",
|
|
bytes: Int = 0,
|
|
createdAt: Date = .now) {
|
|
self.relativePath = relativePath
|
|
self.mimeType = mimeType
|
|
self.bytes = bytes
|
|
self.createdAt = createdAt
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class ChatTurn {
|
|
var question: String
|
|
var answer: String
|
|
var referencedIndicatorIDs: [String]
|
|
var referencedReportIDs: [String]
|
|
var createdAt: Date
|
|
var decodeRate: Double
|
|
|
|
init(question: String,
|
|
answer: String,
|
|
referencedIndicatorIDs: [String] = [],
|
|
referencedReportIDs: [String] = [],
|
|
createdAt: Date = .now,
|
|
decodeRate: Double = 0) {
|
|
self.question = question
|
|
self.answer = answer
|
|
self.referencedIndicatorIDs = referencedIndicatorIDs
|
|
self.referencedReportIDs = referencedReportIDs
|
|
self.createdAt = createdAt
|
|
self.decodeRate = decodeRate
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 2:更新 KangkangApp.swift Schema**
|
|
|
|
打开 `康康/App/KangkangApp.swift`,替换 Schema 数组:
|
|
|
|
```swift
|
|
let schema = Schema([
|
|
Indicator.self,
|
|
Report.self,
|
|
DiaryEntry.self,
|
|
Asset.self,
|
|
ChatTurn.self,
|
|
])
|
|
```
|
|
|
|
- [x] **Step 3:删模拟器沙盒(破坏性迁移)**
|
|
|
|
在 Mac 上:
|
|
|
|
```bash
|
|
xcrun simctl shutdown all
|
|
xcrun simctl erase all
|
|
```
|
|
|
|
(也可以在 Simulator → Device → Erase All Content and Settings)
|
|
|
|
- [x] **Step 4:Build & Run 验证**
|
|
|
|
Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。
|
|
|
|
Expected:App 启动到 RootView,无 fatalError。
|
|
|
|
- [x] **Step 5:提交**
|
|
|
|
```bash
|
|
git add 康康/Models/Models.swift 康康/App/KangkangApp.swift
|
|
git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3:FileVault —— 加密目录读写(TDD)
|
|
|
|
**Files:**
|
|
- Create: `康康/Persistence/FileVault.swift`
|
|
- Test: `康康Tests/FileVaultTests.swift`
|
|
|
|
- [x] **Step 1:写失败的测试**
|
|
|
|
创建 `康康Tests/FileVaultTests.swift`:
|
|
|
|
```swift
|
|
import Testing
|
|
import UIKit
|
|
@testable import 康康
|
|
|
|
@MainActor
|
|
struct FileVaultTests {
|
|
|
|
private func makeIsolatedVault() throws -> FileVault {
|
|
let temp = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
return try FileVault(rootURL: temp)
|
|
}
|
|
|
|
@Test func writeAndReadJPEGRoundtrip() throws {
|
|
let vault = try makeIsolatedVault()
|
|
let image = UIImage(systemName: "doc")!
|
|
|
|
let saved = try vault.writeJPEG(image, quality: 0.8)
|
|
|
|
#expect(saved.bytes > 0)
|
|
#expect(saved.relativePath.hasSuffix(".jpg"))
|
|
|
|
let loaded = try vault.loadImage(relativePath: saved.relativePath)
|
|
#expect(loaded.size != .zero)
|
|
}
|
|
|
|
@Test func removeMakesFileGone() throws {
|
|
let vault = try makeIsolatedVault()
|
|
let saved = try vault.writeJPEG(UIImage(systemName: "doc")!)
|
|
|
|
try vault.remove(relativePath: saved.relativePath)
|
|
|
|
#expect(throws: (any Error).self) {
|
|
_ = try vault.loadImage(relativePath: saved.relativePath)
|
|
}
|
|
}
|
|
|
|
@Test func wipeRemovesAllFiles() throws {
|
|
let vault = try makeIsolatedVault()
|
|
let a = try vault.writeJPEG(UIImage(systemName: "doc")!)
|
|
let b = try vault.writeJPEG(UIImage(systemName: "folder")!)
|
|
|
|
try vault.wipe()
|
|
|
|
#expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: a.relativePath) }
|
|
#expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: b.relativePath) }
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 2:运行测试,确认 fail**
|
|
|
|
Xcode ⌘U 跑测试(在模拟器上跑)。
|
|
|
|
Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。
|
|
|
|
- [x] **Step 3:写最小 FileVault 实现**
|
|
|
|
创建 `康康/Persistence/FileVault.swift`:
|
|
|
|
```swift
|
|
import Foundation
|
|
import UIKit
|
|
|
|
enum FileVaultError: Error {
|
|
case readFailed
|
|
case writeFailed
|
|
case removeFailed
|
|
case decodeFailed
|
|
}
|
|
|
|
final class FileVault {
|
|
static let shared: FileVault = {
|
|
do {
|
|
let appSupport = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let vaultURL = appSupport.appendingPathComponent("Vault", isDirectory: true)
|
|
return try FileVault(rootURL: vaultURL)
|
|
} catch {
|
|
fatalError("FileVault.shared init failed: \(error)")
|
|
}
|
|
}()
|
|
|
|
let rootURL: URL
|
|
|
|
init(rootURL: URL) throws {
|
|
self.rootURL = rootURL
|
|
try FileManager.default.createDirectory(
|
|
at: rootURL,
|
|
withIntermediateDirectories: true,
|
|
attributes: [.protectionKey: FileProtectionType.complete]
|
|
)
|
|
}
|
|
|
|
struct SavedAsset {
|
|
let relativePath: String
|
|
let bytes: Int
|
|
}
|
|
|
|
func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset {
|
|
guard let data = image.jpegData(compressionQuality: quality) else {
|
|
throw FileVaultError.writeFailed
|
|
}
|
|
let filename = "\(UUID().uuidString).jpg"
|
|
let url = rootURL.appendingPathComponent(filename)
|
|
try data.write(to: url, options: [.atomic, .completeFileProtection])
|
|
return SavedAsset(relativePath: filename, bytes: data.count)
|
|
}
|
|
|
|
func loadImage(relativePath: String) throws -> UIImage {
|
|
let url = rootURL.appendingPathComponent(relativePath)
|
|
let data = try Data(contentsOf: url)
|
|
guard let image = UIImage(data: data) else { throw FileVaultError.decodeFailed }
|
|
return image
|
|
}
|
|
|
|
func remove(relativePath: String) throws {
|
|
let url = rootURL.appendingPathComponent(relativePath)
|
|
try FileManager.default.removeItem(at: url)
|
|
}
|
|
|
|
func wipe() throws {
|
|
let fm = FileManager.default
|
|
let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)
|
|
for url in contents {
|
|
try fm.removeItem(at: url)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 4:把 FileVault.swift 加入 康康 target**
|
|
|
|
Xcode 右键 `康康/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "康康"。
|
|
|
|
把 FileVaultTests.swift 拖进 康康Tests target,确认 Target Membership 勾选 "康康Tests"。
|
|
|
|
- [x] **Step 5:跑测试,确认全 pass**
|
|
|
|
Xcode ⌘U。
|
|
|
|
Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。
|
|
|
|
- [x] **Step 6:提交**
|
|
|
|
```bash
|
|
git add 康康/Persistence/FileVault.swift 康康Tests/FileVaultTests.swift 康康.xcodeproj
|
|
git commit -m "feat(persistence): add FileVault with complete file protection"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD)
|
|
|
|
**Files:**
|
|
- Create: `康康/AI/ModelStore.swift`
|
|
- Test: `康康Tests/ModelStoreTests.swift`
|
|
|
|
- [x] **Step 1:写失败的测试**
|
|
|
|
创建 `康康Tests/ModelStoreTests.swift`:
|
|
|
|
```swift
|
|
import Testing
|
|
import Foundation
|
|
@testable import 康康
|
|
|
|
@MainActor
|
|
struct ModelStoreTests {
|
|
|
|
private func isolatedStore() throws -> ModelStore {
|
|
let temp = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
return try ModelStore(rootURL: temp)
|
|
}
|
|
|
|
@Test func freshStoreReportsBothModelsMissing() throws {
|
|
let store = try isolatedStore()
|
|
#expect(store.isReady(.llm) == false)
|
|
#expect(store.isReady(.vl) == false)
|
|
}
|
|
|
|
@Test func markReadyAfterFolderCreated() throws {
|
|
let store = try isolatedStore()
|
|
let llmFolder = store.localURL(for: .llm)
|
|
try FileManager.default.createDirectory(at: llmFolder, withIntermediateDirectories: true)
|
|
|
|
// dummy config file
|
|
let configURL = llmFolder.appendingPathComponent("config.json")
|
|
try "{}".write(to: configURL, atomically: true, encoding: .utf8)
|
|
|
|
#expect(store.isReady(.llm) == true)
|
|
#expect(store.isReady(.vl) == false)
|
|
}
|
|
|
|
@Test func totalBytesSumsExistingFiles() throws {
|
|
let store = try isolatedStore()
|
|
let folder = store.localURL(for: .llm)
|
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
let data = Data(repeating: 0, count: 1024)
|
|
try data.write(to: folder.appendingPathComponent("a.bin"))
|
|
try data.write(to: folder.appendingPathComponent("b.bin"))
|
|
|
|
#expect(store.totalBytes(for: .llm) == 2048)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 2:运行测试,确认 fail**
|
|
|
|
⌘U → expect `Cannot find 'ModelStore'`.
|
|
|
|
- [x] **Step 3:写 ModelStore 实现**
|
|
|
|
创建 `康康/AI/ModelStore.swift`:
|
|
|
|
```swift
|
|
import Foundation
|
|
|
|
enum ModelKind: String, CaseIterable {
|
|
case llm = "Qwen3-1.7B-MLX-4bit"
|
|
case vl = "Qwen2.5-VL-3B-MLX-4bit"
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .llm: return "Qwen3-1.7B"
|
|
case .vl: return "Qwen2.5-VL-3B"
|
|
}
|
|
}
|
|
|
|
/// 用于判定该模型是否已就绪的最小标志文件
|
|
var sentinelFilename: String { "config.json" }
|
|
}
|
|
|
|
final class ModelStore {
|
|
static let shared: ModelStore = {
|
|
do {
|
|
let appSupport = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let root = appSupport.appendingPathComponent("Models", isDirectory: true)
|
|
return try ModelStore(rootURL: root)
|
|
} catch {
|
|
fatalError("ModelStore.shared init failed: \(error)")
|
|
}
|
|
}()
|
|
|
|
let rootURL: URL
|
|
|
|
init(rootURL: URL) throws {
|
|
self.rootURL = rootURL
|
|
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
|
}
|
|
|
|
func localURL(for kind: ModelKind) -> URL {
|
|
rootURL.appendingPathComponent(kind.rawValue, isDirectory: true)
|
|
}
|
|
|
|
func isReady(_ kind: ModelKind) -> Bool {
|
|
let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename)
|
|
return FileManager.default.fileExists(atPath: sentinel.path)
|
|
}
|
|
|
|
func totalBytes(for kind: ModelKind) -> Int {
|
|
let folder = localURL(for: kind)
|
|
guard let enumerator = FileManager.default.enumerator(
|
|
at: folder,
|
|
includingPropertiesForKeys: [.fileSizeKey]
|
|
) else { return 0 }
|
|
var sum = 0
|
|
for case let url as URL in enumerator {
|
|
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
|
sum += size
|
|
}
|
|
}
|
|
return sum
|
|
}
|
|
|
|
/// Demo 现场旁路:从 Bundle 拷贝预装模型
|
|
/// (W6 才真正使用,本周只占位)
|
|
func seedFromBundle(_ kind: ModelKind) throws {
|
|
guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else {
|
|
return
|
|
}
|
|
let target = localURL(for: kind)
|
|
if FileManager.default.fileExists(atPath: target.path) {
|
|
try FileManager.default.removeItem(at: target)
|
|
}
|
|
try FileManager.default.copyItem(at: bundleURL, to: target)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 4:Xcode 中把文件加入 target**
|
|
|
|
右键 `康康/` → New Group "AI" → 拖入 ModelStore.swift,勾 "康康" target。
|
|
ModelStoreTests.swift 拖入 康康Tests target。
|
|
|
|
- [x] **Step 5:跑测试,全绿**
|
|
|
|
⌘U。
|
|
|
|
Expected:3 个测试全 pass。
|
|
|
|
- [x] **Step 6:提交**
|
|
|
|
```bash
|
|
git add 康康/AI/ModelStore.swift 康康Tests/ModelStoreTests.swift 康康.xcodeproj
|
|
git commit -m "feat(ai): add ModelStore with path management and bundle seed"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5:TokenChunk + AIRuntime actor 骨架
|
|
|
|
**Files:**
|
|
- Create: `康康/AI/TokenChunk.swift`
|
|
- Create: `康康/AI/AIRuntime.swift`
|
|
|
|
本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。
|
|
|
|
- [x] **Step 1:创建 TokenChunk.swift**
|
|
|
|
```swift
|
|
import Foundation
|
|
|
|
struct TokenChunk: Sendable {
|
|
let text: String
|
|
let decodeRate: Double // tokens / second, 估算值
|
|
}
|
|
```
|
|
|
|
- [x] **Step 2:创建 AIRuntime.swift 骨架**
|
|
|
|
```swift
|
|
import Foundation
|
|
|
|
enum AIRuntimeError: Error, LocalizedError {
|
|
case notReady
|
|
case modelLoadFailed(String)
|
|
case inferenceFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notReady: return "AI 模型尚未准备好"
|
|
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
|
|
case .inferenceFailed(let m): return "推理失败:\(m)"
|
|
}
|
|
}
|
|
}
|
|
|
|
actor AIRuntime {
|
|
static let shared = AIRuntime()
|
|
|
|
enum Status: Sendable, Equatable {
|
|
case notReady
|
|
case loading
|
|
case ready
|
|
case error(String)
|
|
}
|
|
|
|
private(set) var status: Status = .notReady
|
|
private(set) var lastDecodeRate: Double = 0
|
|
|
|
private var llmSession: LLMSession?
|
|
|
|
private init() {}
|
|
|
|
/// 加载模型。首次调用会真正加载,后续幂等。
|
|
func prepare() async throws {
|
|
switch status {
|
|
case .ready: return
|
|
case .loading: return // 已经在加载
|
|
case .error, .notReady: break
|
|
}
|
|
|
|
guard ModelStore.shared.isReady(.llm) else {
|
|
status = .error("LLM 模型未就绪")
|
|
throw AIRuntimeError.notReady
|
|
}
|
|
|
|
status = .loading
|
|
do {
|
|
let session = try await LLMSession.load(
|
|
folderURL: ModelStore.shared.localURL(for: .llm)
|
|
)
|
|
self.llmSession = session
|
|
status = .ready
|
|
} catch {
|
|
status = .error("\(error)")
|
|
throw AIRuntimeError.modelLoadFailed("\(error)")
|
|
}
|
|
}
|
|
|
|
/// 流式生成。调用前应先 await prepare()。
|
|
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
|
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
|
|
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
|
let snapshotStatus = status
|
|
let snapshotSession = llmSession
|
|
|
|
return AsyncThrowingStream { continuation in
|
|
Task { [weak self] in
|
|
guard snapshotStatus == .ready, let session = snapshotSession else {
|
|
continuation.finish(throwing: AIRuntimeError.notReady)
|
|
return
|
|
}
|
|
do {
|
|
// session.generate 跨 actor 边界,需要 await
|
|
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
|
for try await chunk in stream {
|
|
await self?.recordRate(chunk.decodeRate)
|
|
continuation.yield(chunk)
|
|
}
|
|
continuation.finish()
|
|
} catch {
|
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func recordRate(_ rate: Double) {
|
|
if rate > 0 { lastDecodeRate = rate }
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 3:确认 Build 失败原因合理**
|
|
|
|
⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。
|
|
|
|
这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。
|
|
|
|
- [x] **Step 4:把文件加入 target**
|
|
|
|
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "康康" target。
|
|
|
|
(此时 Build 还是失败,正常)
|
|
|
|
- [x] **Step 5:暂不提交**
|
|
|
|
等 Task 6 完成、Build 通过后一起提交。
|
|
|
|
---
|
|
|
|
## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B
|
|
|
|
**Files:**
|
|
- Create: `康康/AI/LLMSession.swift`
|
|
|
|
**预先准备(开发者手动一次)**:
|
|
|
|
在终端执行,把 Qwen3-1.7B-MLX-4bit 拉到本地某个目录,稍后通过 Xcode 的"Run with arguments / file copy"或者直接放进模拟器沙盒。本周临时方案:**写一个 DEBUG-only 的 import 入口,把 Mac 上的模型目录通过 share sheet 导进 App**。但更简单:
|
|
|
|
**最简方案**:把模型文件夹放进 `~/Library/Developer/CoreSimulator/Devices/<DeviceUUID>/data/Containers/Data/Application/<AppUUID>/Library/Application Support/Models/Qwen3-1.7B-MLX-4bit/`,App 启动后能直接读到。
|
|
|
|
具体路径在 App 启动时打印,见 Step 5。
|
|
|
|
- [x] **Step 1:在终端下载模型(脚本一次性)**
|
|
|
|
```bash
|
|
mkdir -p ~/tiji-models && cd ~/tiji-models
|
|
# 用 huggingface-cli;若没装:pip install -U "huggingface_hub[cli]"
|
|
huggingface-cli download mlx-community/Qwen3-1.7B-MLX-4bit \
|
|
--local-dir Qwen3-1.7B-MLX-4bit
|
|
```
|
|
|
|
Expected:目录里有 `config.json` / `model.safetensors` / `tokenizer.json` 等。
|
|
|
|
- [x] **Step 2:写 LLMSession 实现**
|
|
|
|
创建 `康康/AI/LLMSession.swift`:
|
|
|
|
```swift
|
|
import Foundation
|
|
import MLX
|
|
import MLXLLM
|
|
import MLXLMCommon
|
|
|
|
actor LLMSession {
|
|
|
|
let container: ModelContainer
|
|
|
|
private init(container: ModelContainer) {
|
|
self.container = container
|
|
}
|
|
|
|
static func load(folderURL: URL) async throws -> LLMSession {
|
|
let configuration = ModelConfiguration(directory: folderURL)
|
|
let container = try await LLMModelFactory.shared.loadContainer(
|
|
configuration: configuration
|
|
)
|
|
return LLMSession(container: container)
|
|
}
|
|
|
|
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
|
AsyncThrowingStream { continuation in
|
|
Task {
|
|
do {
|
|
let parameters = GenerateParameters(temperature: 0.6, topP: 0.9)
|
|
let start = Date()
|
|
var produced = 0
|
|
|
|
try await container.perform { context in
|
|
let lmInput = try await context.processor.prepare(
|
|
input: .init(prompt: prompt)
|
|
)
|
|
|
|
let stream = try MLXLMCommon.generate(
|
|
input: lmInput,
|
|
parameters: parameters,
|
|
context: context
|
|
)
|
|
|
|
for await event in stream {
|
|
switch event {
|
|
case .chunk(let text):
|
|
produced += 1
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
|
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
|
if produced >= maxTokens { break }
|
|
case .info:
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
continuation.finish()
|
|
} catch {
|
|
continuation.finish(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
> **注**:`MLXLMCommon` 的具体 API 版本可能在 GenerateParameters/stream 处略有差异。如果 Step 4 编译报错,查看 mlx-swift-examples 仓库 `Libraries/MLXLLM` 的最新示例,以仓库示例为准小幅调整。
|
|
|
|
- [x] **Step 3:把 LLMSession.swift 加入 康康 target**
|
|
|
|
拖入 AI group,确认 Target Membership。
|
|
|
|
- [x] **Step 4:Build,期望成功**
|
|
|
|
⌘B。
|
|
|
|
Expected:Build Succeeded。
|
|
|
|
若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。
|
|
|
|
- [x] **Step 5:在 KangkangApp 启动时打印沙盒路径(临时调试)**
|
|
|
|
打开 `康康/App/KangkangApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`:
|
|
|
|
```swift
|
|
.onAppear {
|
|
#if DEBUG
|
|
let appSupport = try? FileManager.default.url(
|
|
for: .applicationSupportDirectory, in: .userDomainMask,
|
|
appropriateFor: nil, create: false
|
|
)
|
|
print("📁 App Support: \(appSupport?.path ?? "?")")
|
|
print("📁 把 Qwen3-1.7B-MLX-4bit/ 拷到上面路径 + /Models/ 下面")
|
|
#endif
|
|
}
|
|
```
|
|
|
|
⌘R 启动到模拟器,在 Xcode console 看到路径,例如:
|
|
|
|
```
|
|
📁 App Support: /Users/.../data/Containers/Data/Application/<UUID>/Library/Application Support
|
|
```
|
|
|
|
- [x] **Step 6:把模型拷到沙盒**
|
|
|
|
```bash
|
|
APP_SUPPORT="<上面控制台打印的路径>"
|
|
mkdir -p "$APP_SUPPORT/Models"
|
|
cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/"
|
|
```
|
|
|
|
- [x] **Step 7:提交(本任务 + Task 5 一起)**
|
|
|
|
```bash
|
|
git add 康康/AI/ 康康/App/KangkangApp.swift 康康.xcodeproj
|
|
git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7:DebugAIRunner —— DEBUG 测试入口
|
|
|
|
**Files:**
|
|
- Create: `康康/Debug/DebugAIRunner.swift`
|
|
- Modify: `康康/Features/Me/MeView.swift`
|
|
|
|
- [x] **Step 1:创建 DebugAIRunner**
|
|
|
|
`康康/Debug/DebugAIRunner.swift`:
|
|
|
|
```swift
|
|
#if DEBUG
|
|
import SwiftUI
|
|
|
|
struct DebugAIRunner: View {
|
|
@State private var output: String = ""
|
|
@State private var status: String = "未开始"
|
|
@State private var rate: Double = 0
|
|
@State private var running = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("DEBUG · AI 自检")
|
|
.font(.headline)
|
|
|
|
HStack {
|
|
Text("状态:\(status)")
|
|
Spacer()
|
|
Text(String(format: "%.1f tok/s", rate))
|
|
.foregroundStyle(.secondary)
|
|
.monospaced()
|
|
}
|
|
.font(.footnote)
|
|
|
|
Button(running ? "推理中..." : "跑一段 prompt") {
|
|
Task { await run() }
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(running)
|
|
|
|
ScrollView {
|
|
Text(output.isEmpty ? "(暂无输出)" : output)
|
|
.font(.system(.footnote, design: .monospaced))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxHeight: 280)
|
|
.padding(8)
|
|
.background(Color.black.opacity(0.04))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
.padding()
|
|
.background(Color.yellow.opacity(0.08))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
@MainActor
|
|
private func run() async {
|
|
running = true
|
|
output = ""
|
|
status = "加载模型..."
|
|
do {
|
|
try await AIRuntime.shared.prepare()
|
|
status = "推理中..."
|
|
|
|
let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。"
|
|
for try await chunk in await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200) {
|
|
output += chunk.text
|
|
rate = chunk.decodeRate
|
|
}
|
|
status = "完成 ✓"
|
|
} catch {
|
|
status = "失败:\(error.localizedDescription)"
|
|
}
|
|
running = false
|
|
}
|
|
}
|
|
#endif
|
|
```
|
|
|
|
- [x] **Step 2:在 MeView 末尾挂上(仅 DEBUG)**
|
|
|
|
打开 `康康/Features/Me/MeView.swift`,把现有内容整体替换为:
|
|
|
|
```swift
|
|
import SwiftUI
|
|
|
|
struct MeView: View {
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 12) {
|
|
TjPlaceholder(label: "我的 · 模型管理 / Face ID / 关于\n(W6 实现)")
|
|
.frame(width: 280, height: 180)
|
|
|
|
#if DEBUG
|
|
DebugAIRunner()
|
|
#endif
|
|
}
|
|
.padding(.vertical, 24)
|
|
}
|
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
}
|
|
}
|
|
|
|
#Preview { MeView() }
|
|
```
|
|
|
|
- [x] **Step 3:在 Xcode 中加入文件**
|
|
|
|
右键 `康康/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "康康" target。
|
|
|
|
- [x] **Step 4:Build,确认 OK**
|
|
|
|
⌘B → Expected: Build Succeeded。
|
|
|
|
- [x] **Step 5:提交**
|
|
|
|
```bash
|
|
git add 康康/Debug/ 康康/Features/Me/MeView.swift 康康.xcodeproj
|
|
git commit -m "chore(debug): add AI self-test runner in MeView (DEBUG only)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8:模拟器跑通自检(里程碑)
|
|
|
|
**Files:**(无,运行验收)
|
|
|
|
- [ ] **Step 1:在模拟器跑一次自检**
|
|
|
|
⌘R 启动到 iPhone 15 Pro 模拟器。底部 Tab → "我的" → 看到黄色"DEBUG · AI 自检"卡片。
|
|
|
|
点击"跑一段 prompt"。
|
|
|
|
Expected:
|
|
- 状态变 "加载模型..." → "推理中..." → 输出区开始流式吐字 → "完成 ✓"
|
|
- 顶部 tok/s 显示数字 > 0
|
|
|
|
若加载模型卡 `LLM 模型未就绪`:回到 Task 6 Step 6 检查模型是否真的拷到沙盒(注意每次 App reinstall 后 UUID 变化,要重新拷)。
|
|
|
|
- [ ] **Step 2:记录模拟器速度基线**
|
|
|
|
在 spec 笔记里记下:模拟器 (M1/M2 Mac) decode 速度 ≈ ?? tok/s。
|
|
|
|
(模拟器跑 MLX 走 CPU,期望 ≥ 8 tok/s,真机才是 ≥ 15)
|
|
|
|
- [ ] **Step 3:连真机跑一次**
|
|
|
|
iPhone 15 Pro / 16 Pro 用线连 Mac → Xcode 选这台真机 → ⌘R。
|
|
|
|
**注意**:真机沙盒和模拟器不同,需要把模型用 Xcode → Devices and Simulators → 选 App → Download Container 拿到 Documents,再用 Finder → 拖入。
|
|
|
|
更简单的真机路径:把模型放进 App Bundle 资源,运行时一次拷到沙盒。本周临时方案——在 Step 1 之前,如果你打算走真机,跟我说一声,我加一个"从 Bundle 旁路 seed"的步骤。
|
|
|
|
W2 验收最低要求:**模拟器跑通**即可。真机验证延后到 W3 一起做。
|
|
|
|
- [ ] **Step 4:决断**
|
|
|
|
回顾 spec §6 R1:
|
|
- 若模拟器 decode < 5 tok/s 或频繁崩溃 → 周五前换 llama.cpp,W2 plan 重写
|
|
- 若 ≥ 8 tok/s 且无 OOM → 进 W3,真机延后
|
|
|
|
- [ ] **Step 5:提交里程碑标记**
|
|
|
|
```bash
|
|
git tag w2-llm-runs
|
|
git commit --allow-empty -m "milestone: W2 LLM 自检通过 (simulator)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9:加一组 schema 重建烟测(防回归)
|
|
|
|
**Files:**
|
|
- Create: `康康Tests/ModelsSchemaTests.swift`
|
|
|
|
- [x] **Step 1:写 schema 烟测**
|
|
|
|
```swift
|
|
import Testing
|
|
import SwiftData
|
|
import Foundation
|
|
@testable import 康康
|
|
|
|
@MainActor
|
|
struct ModelsSchemaTests {
|
|
|
|
private func makeContainer() throws -> ModelContainer {
|
|
let schema = Schema([
|
|
Indicator.self,
|
|
Report.self,
|
|
DiaryEntry.self,
|
|
Asset.self,
|
|
ChatTurn.self,
|
|
])
|
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
|
return try ModelContainer(for: schema, configurations: [config])
|
|
}
|
|
|
|
@Test func insertIndicatorWithReportRelationship() throws {
|
|
let container = try makeContainer()
|
|
let ctx = ModelContext(container)
|
|
|
|
let report = Report(title: "春检", type: .checkup, reportDate: .now)
|
|
let indicator = Indicator(
|
|
name: "ALT",
|
|
value: "32",
|
|
unit: "U/L",
|
|
range: "9-50",
|
|
status: .normal,
|
|
report: report
|
|
)
|
|
ctx.insert(report)
|
|
ctx.insert(indicator)
|
|
try ctx.save()
|
|
|
|
#expect(report.indicators.count == 1)
|
|
#expect(indicator.report?.title == "春检")
|
|
}
|
|
|
|
@Test func cascadeDeleteReportRemovesIndicators() throws {
|
|
let container = try makeContainer()
|
|
let ctx = ModelContext(container)
|
|
|
|
let report = Report(title: "春检", type: .checkup, reportDate: .now)
|
|
let indicator = Indicator(
|
|
name: "ALT", value: "32", unit: "U/L", range: "9-50",
|
|
status: .normal, report: report
|
|
)
|
|
ctx.insert(report)
|
|
ctx.insert(indicator)
|
|
try ctx.save()
|
|
|
|
ctx.delete(report)
|
|
try ctx.save()
|
|
|
|
let remaining = try ctx.fetch(FetchDescriptor<Indicator>())
|
|
#expect(remaining.isEmpty)
|
|
}
|
|
|
|
@Test func chatTurnPersistsReferencedIDs() throws {
|
|
let container = try makeContainer()
|
|
let ctx = ModelContext(container)
|
|
|
|
let turn = ChatTurn(
|
|
question: "我的 LDL 怎么样?",
|
|
answer: "近 3 个月 LDL 偏高 [1]",
|
|
referencedIndicatorIDs: ["abc"],
|
|
referencedReportIDs: [],
|
|
decodeRate: 24.3
|
|
)
|
|
ctx.insert(turn)
|
|
try ctx.save()
|
|
|
|
let all = try ctx.fetch(FetchDescriptor<ChatTurn>())
|
|
#expect(all.count == 1)
|
|
#expect(all.first?.referencedIndicatorIDs == ["abc"])
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 2:加入 康康Tests target,跑测试**
|
|
|
|
⌘U。
|
|
|
|
Expected:3 个测试全 pass。
|
|
|
|
若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。
|
|
|
|
- [x] **Step 3:提交**
|
|
|
|
```bash
|
|
git add 康康Tests/ModelsSchemaTests.swift 康康.xcodeproj
|
|
git commit -m "test(models): add schema smoke tests for relationships and cascade"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10:周末 retro 与 W2 收尾
|
|
|
|
**Files:**(无)
|
|
|
|
- [ ] **Step 1:汇总本周成果**
|
|
|
|
确认这些都 ✓:
|
|
- [ ] MLX SPM 接入
|
|
- [ ] AIRuntime + LLMSession + ModelStore 三个文件存在并跑通
|
|
- [ ] FileVault 单元测试 3 个全绿
|
|
- [ ] ModelStore 单元测试 3 个全绿
|
|
- [ ] Schema 单元测试 3 个全绿
|
|
- [ ] DebugAIRunner 在 MeView 能看到,模拟器点击能流式吐字
|
|
- [ ] decode 速度记录在某处(README 或 spec 注释)
|
|
|
|
- [ ] **Step 2:更新 CLAUDE.md §8 文件状态**
|
|
|
|
把 `AI/` 和 `Persistence/FileVault.swift` 从 ❌ 改为 ✅。
|
|
|
|
打开 CLAUDE.md,找到 §8,把:
|
|
|
|
```
|
|
├── AI/ ❌ AIRuntime, LLMSession, VLSession, Prompts/
|
|
```
|
|
|
|
改为:
|
|
|
|
```
|
|
├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore ✅;VLSession + Prompts/ ❌
|
|
```
|
|
|
|
`Persistence/FileVault.swift` 从 ❌ 改为 ✅。
|
|
|
|
- [ ] **Step 3:风险仪表盘检查**
|
|
|
|
按 spec §6 仪表盘:
|
|
- R1 MLX:本周关键 → **通过 / 需补救 / 触发回退** 选一个
|
|
- R4 Migration:🟡 → 实际表现?
|
|
|
|
把决断写进一个 retro 文件:
|
|
|
|
```bash
|
|
mkdir -p docs/superpowers/retros
|
|
```
|
|
|
|
创建 `docs/superpowers/retros/2026-05-31-w2.md`:
|
|
|
|
```markdown
|
|
# W2 Retro · 2026-05-31
|
|
|
|
## Status
|
|
- R1 MLX: [通过 / 需补救 / 触发回退]
|
|
- R4 Migration: [实际]
|
|
- 本周里程碑: [是否达成]
|
|
|
|
## 速度基线
|
|
- 模拟器(M1/M2 Mac, iPhone 15 Pro sim) decode: ?? tok/s
|
|
- 真机 iPhone 15 Pro decode: ?? tok/s (若已测)
|
|
|
|
## 学到的
|
|
- ...
|
|
|
|
## 下周(W3)前置准备
|
|
- [ ] 准备 5-10 张真实化验单照片(W4 VL 回归测用)
|
|
- [ ] 准备 20 条危险问句(W3 末医疗话术安全测试)
|
|
```
|
|
|
|
- [ ] **Step 4:提交收尾**
|
|
|
|
```bash
|
|
git add CLAUDE.md docs/superpowers/retros/2026-05-31-w2.md
|
|
git commit -m "chore: W2 retro and CLAUDE.md status update"
|
|
```
|
|
|
|
- [ ] **Step 5:打 tag**
|
|
|
|
```bash
|
|
git tag w2-done -m "W2 complete: AI foundation, Schema, FileVault"
|
|
```
|
|
|
|
W3 plan 在下周一开始时再写,反映本周实际进度。
|
|
|
|
---
|
|
|
|
## 本周交付清单
|
|
|
|
| 产物 | 验收 |
|
|
|---|---|
|
|
| MLX SPM 依赖 | Build 通过 |
|
|
| 5 个 @Model + 关系 | 3 个 schema 测试全绿 |
|
|
| FileVault | 3 个单元测试全绿,文件落盘且不可被普通查看 |
|
|
| ModelStore | 3 个单元测试全绿 |
|
|
| AIRuntime + LLMSession | DebugAIRunner 点击后流式输出 |
|
|
| 速度基线 | 记录在 retro 文档 |
|
|
|
|
**唯一红线触发条件**:模拟器 decode < 5 tok/s 或频繁 OOM → 立即换 llama.cpp,本 plan 全部 revert,重写 W2。
|