Rename @testable imports across all test/UI test files after the Tiji→Kangkang
project rename in 44ed01a. Add shared scheme. Sync CLAUDE.md / W2 plan / spec
v1.0 to current scope (Symptom feature noted, C1/C2 flow lockdown).
37 KiB
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 §1-§5;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 修改,不要手编) -
Step 1:打开 Xcode 项目
open /Users/xuhuayong/apps/康康/康康.xcodeproj
- 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 -
Step 3:加入 mlx-swift-examples(含 LLM 工具)
继续 Add Package Dependencies,URL:
https://github.com/ml-explore/mlx-swift-examples
勾选 MLXLLM 和 MLXLMCommon 加到 康康 target。
- Step 4:确认 Build Settings
Xcode → 康康 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
康康 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
- Step 5:Build 验证
Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。
Expected:Build Succeeded,无依赖错误。
- Step 6:提交
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(全文重写) -
Step 1:把 Models.swift 替换为新内容
打开 康康/Models/Models.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
}
}
- Step 2:更新 KangkangApp.swift Schema
打开 康康/App/KangkangApp.swift,替换 Schema 数组:
let schema = Schema([
Indicator.self,
Report.self,
DiaryEntry.self,
Asset.self,
ChatTurn.self,
])
- Step 3:删模拟器沙盒(破坏性迁移)
在 Mac 上:
xcrun simctl shutdown all
xcrun simctl erase all
(也可以在 Simulator → Device → Erase All Content and Settings)
- Step 4:Build & Run 验证
Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。
Expected:App 启动到 RootView,无 fatalError。
- Step 5:提交
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 -
Step 1:写失败的测试
创建 康康Tests/FileVaultTests.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) }
}
}
- Step 2:运行测试,确认 fail
Xcode ⌘U 跑测试(在模拟器上跑)。
Expected:FileVaultTests 编译错误 "Cannot find 'FileVault' in scope"。
- Step 3:写最小 FileVault 实现
创建 康康/Persistence/FileVault.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)
}
}
}
- Step 4:把 FileVault.swift 加入 康康 target
Xcode 右键 康康/ 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "康康"。
把 FileVaultTests.swift 拖进 康康Tests target,确认 Target Membership 勾选 "康康Tests"。
- Step 5:跑测试,确认全 pass
Xcode ⌘U。
Expected:writeAndReadJPEGRoundtrip / removeMakesFileGone / wipeRemovesAllFiles 全绿。
- Step 6:提交
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 -
Step 1:写失败的测试
创建 康康Tests/ModelStoreTests.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)
}
}
- Step 2:运行测试,确认 fail
⌘U → expect Cannot find 'ModelStore'.
- Step 3:写 ModelStore 实现
创建 康康/AI/ModelStore.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)
}
}
- Step 4:Xcode 中把文件加入 target
右键 康康/ → New Group "AI" → 拖入 ModelStore.swift,勾 "康康" target。
ModelStoreTests.swift 拖入 康康Tests target。
- Step 5:跑测试,全绿
⌘U。
Expected:3 个测试全 pass。
- Step 6:提交
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 才接真模型。
- Step 1:创建 TokenChunk.swift
import Foundation
struct TokenChunk: Sendable {
let text: String
let decodeRate: Double // tokens / second, 估算值
}
- Step 2:创建 AIRuntime.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 }
}
}
- Step 3:确认 Build 失败原因合理
⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。
这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。
- Step 4:把文件加入 target
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "康康" target。
(此时 Build 还是失败,正常)
- 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。
- Step 1:在终端下载模型(脚本一次性)
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 等。
- Step 2:写 LLMSession 实现
创建 康康/AI/LLMSession.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的最新示例,以仓库示例为准小幅调整。
- Step 3:把 LLMSession.swift 加入 康康 target
拖入 AI group,确认 Target Membership。
- Step 4:Build,期望成功
⌘B。
Expected:Build Succeeded。
若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 Libraries/MLXLLM 的最新 LLM 示例修正。
- Step 5:在 KangkangApp 启动时打印沙盒路径(临时调试)
打开 康康/App/KangkangApp.swift,在 WindowGroup { RootView() } 内加一个 .onAppear:
.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
- Step 6:把模型拷到沙盒
APP_SUPPORT="<上面控制台打印的路径>"
mkdir -p "$APP_SUPPORT/Models"
cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/"
- Step 7:提交(本任务 + Task 5 一起)
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 -
Step 1:创建 DebugAIRunner
康康/Debug/DebugAIRunner.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
- Step 2:在 MeView 末尾挂上(仅 DEBUG)
打开 康康/Features/Me/MeView.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() }
- Step 3:在 Xcode 中加入文件
右键 康康/ → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "康康" target。
- Step 4:Build,确认 OK
⌘B → Expected: Build Succeeded。
- Step 5:提交
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:提交里程碑标记
git tag w2-llm-runs
git commit --allow-empty -m "milestone: W2 LLM 自检通过 (simulator)"
Task 9:加一组 schema 重建烟测(防回归)
Files:
-
Create:
康康Tests/ModelsSchemaTests.swift -
Step 1:写 schema 烟测
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"])
}
}
- Step 2:加入 康康Tests target,跑测试
⌘U。
Expected:3 个测试全 pass。
若 cascade 删除测试失败 → 检查 Indicator.report 反向关系是否声明正确(参考 Task 2)。
- Step 3:提交
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 文件:
mkdir -p docs/superpowers/retros
创建 docs/superpowers/retros/2026-05-31-w2.md:
# 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:提交收尾
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
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。