From ad6fb660f08c6ce6524490cf66294b12c876c5e2 Mon Sep 17 00:00:00 2001 From: link2026 Date: Mon, 25 May 2026 15:09:51 +0800 Subject: [PATCH] feat(ai): add ModelStore with path management and bundle seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 W2 plan Task 4 落地模型路径管理: - ModelKind enum: llm (Qwen3-1.7B-MLX-4bit) / vl (Qwen2.5-VL-3B-MLX-4bit) - 用 config.json 作为 sentinel 判定模型是否就绪 - isReady / localURL / totalBytes 三个查询接口 - seedFromBundle(_:) 占位:Demo 现场预装模型旁路(W6 启用) - shared 单例用 Application Support/Models/ 测试 3 条:fresh / mark-ready / totalBytes,均用临时目录隔离 + defer cleanup。 注:.swift 文件需用户在 Xcode 拖入 target,⌘U 确认绿后 amend build commit。 Co-Authored-By: Claude Opus 4.7 (1M context) --- 体己/AI/ModelStore.swift | 76 +++++++++++++++++++++++++++++++++ 体己Tests/ModelStoreTests.swift | 46 ++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 体己/AI/ModelStore.swift create mode 100644 体己Tests/ModelStoreTests.swift diff --git a/体己/AI/ModelStore.swift b/体己/AI/ModelStore.swift new file mode 100644 index 0000000..ee13c9c --- /dev/null +++ b/体己/AI/ModelStore.swift @@ -0,0 +1,76 @@ +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) + } +} diff --git a/体己Tests/ModelStoreTests.swift b/体己Tests/ModelStoreTests.swift new file mode 100644 index 0000000..d1f95cf --- /dev/null +++ b/体己Tests/ModelStoreTests.swift @@ -0,0 +1,46 @@ +import Testing +import Foundation +@testable import 体己 + +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() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + + #expect(store.isReady(.llm) == false) + #expect(store.isReady(.vl) == false) + } + + @Test func markReadyAfterFolderCreated() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + + let llmFolder = store.localURL(for: .llm) + try FileManager.default.createDirectory(at: llmFolder, withIntermediateDirectories: true) + 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() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + + 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) + } +}