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) + } +}