Files
kangkang/docs/superpowers/plans/2026-05-25-w2-ai-foundation.md
link2026 53da442424 chore: rename Tiji→Kangkang test imports + scheme + sync docs
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).
2026-05-25 23:18:00 +08:00

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

勾选 MLXLLMMLXLMCommon 加到 康康 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-examplesLibraries/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。