From 23a366ba79b381f7add1471360b274deea3b8a8a Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 25 Aug 2026 01:55:52 +0800 Subject: [PATCH] feat: add device performance check and model compatibility recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-check CPU/GPU chip, unified memory, and available disk space before downloading or selecting local models. Models that exceed the hardware are flagged with "Too Large" (incompatible) or "May Be Slow" (marginal) badges in Settings → Models. Download confirmations now include a compatibility warning when relevant. - Add DeviceCapability module (chip name, RAM, GPU cores, disk space) - Add per-model hardware requirements to ModelCatalog - Show device info summary panel in model management UI - Update website with hardware guidance section and FAQ - Bilingual localization (EN + ZH) Co-authored-by: Cursor --- Sources/Config/DeviceCapability.swift | 220 ++++++++++++++++++ Sources/Config/ModelCatalog.swift | 5 + .../Resources/en.lproj/Localizable.strings | 11 + .../zh-Hans.lproj/Localizable.strings | 11 + Sources/UI/ModelManagementActions.swift | 6 +- Sources/UI/ModelManagementRows.swift | 27 +++ Sources/UI/ModelManagementSections.swift | 49 ++++ Sources/UI/ModelManagementView.swift | 2 + docs/index.html | 25 +- 9 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 Sources/Config/DeviceCapability.swift diff --git a/Sources/Config/DeviceCapability.swift b/Sources/Config/DeviceCapability.swift new file mode 100644 index 0000000..e1ac6a9 --- /dev/null +++ b/Sources/Config/DeviceCapability.swift @@ -0,0 +1,220 @@ +import Foundation +import IOKit + +enum DeviceCapability { + struct Info: Sendable { + let chipName: String + let totalRAMGB: Double + let gpuCoreCount: Int + let neuralEngineCoreCount: Int + let availableDiskGB: Double + let totalDiskGB: Double + } + + static let current: Info = { + let ram = Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824 + let (chipName, gpuCores, neCores) = readAppleSiliconInfo() + let (availDisk, totalDisk) = diskSpace() + return Info( + chipName: chipName, + totalRAMGB: ram, + gpuCoreCount: gpuCores, + neuralEngineCoreCount: neCores, + availableDiskGB: availDisk, + totalDiskGB: totalDisk + ) + }() + + // MARK: - Model Compatibility + + enum Compatibility: Sendable { + case compatible + case marginal(String) + case incompatible(String) + } + + struct ModelRequirements: Sendable { + let minRAMGB: Double + let recommendedRAMGB: Double + let diskGB: Double + } + + static func requirements(for modelID: String, downloadSizeBytes: Int64?) -> ModelRequirements { + if let known = knownRequirements[modelID] { return known } + let diskGB = downloadSizeBytes.map { Double($0) / 1_073_741_824 } ?? 0 + let ramNeeded = max(diskGB * 1.2, 1) + return ModelRequirements( + minRAMGB: ramNeeded, + recommendedRAMGB: ramNeeded * 1.5, + diskGB: diskGB + ) + } + + static func check( + modelID: String, + downloadSizeBytes: Int64? + ) -> Compatibility { + let reqs = requirements(for: modelID, downloadSizeBytes: downloadSizeBytes) + let info = current + + if reqs.diskGB > 0, info.availableDiskGB < reqs.diskGB * 1.1 { + return .incompatible(L("device.insufficient_disk")) + } + if info.totalRAMGB < reqs.minRAMGB { + return .incompatible(L("device.insufficient_ram")) + } + if info.totalRAMGB < reqs.recommendedRAMGB { + return .marginal(L("device.marginal_ram")) + } + return .compatible + } + + static func recommendedModelID(from candidates: [String]) -> String? { + let ram = current.totalRAMGB + for id in candidates.reversed() { + if let reqs = knownRequirements[id], ram >= reqs.recommendedRAMGB { + return id + } + } + return candidates.first + } + + // MARK: - Hardware Detection + + private static func readAppleSiliconInfo() -> (chipName: String, gpuCores: Int, neCores: Int) { + var chipName = sysctlString("machdep.cpu.brand_string") ?? "Apple Silicon" + if chipName.hasPrefix("Apple ") { + chipName = String(chipName.dropFirst(6)) + } + + let gpuCores = ioRegistryInt(className: "AGXAccelerator", key: "gpu-core-count") + ?? ioRegistryInt(className: "AGXAcceleratorG13G", key: "gpu-core-count") + ?? gpuCoresFromChipName(chipName) + let neCores = neuralEngineCoresFromChipName(chipName) + return (chipName, gpuCores, neCores) + } + + private static func diskSpace() -> (available: Double, total: Double) { + guard let attrs = try? FileManager.default.attributesOfFileSystem( + forPath: NSHomeDirectory() + ) else { return (0, 0) } + let avail = (attrs[.systemFreeSize] as? NSNumber)?.doubleValue ?? 0 + let total = (attrs[.systemSize] as? NSNumber)?.doubleValue ?? 0 + return (avail / 1_073_741_824, total / 1_073_741_824) + } + + private static func sysctlString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buf = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buf, &size, nil, 0) == 0 else { return nil } + return String(cString: buf) + } + + private static func ioRegistryInt(className: String, key: String) -> Int? { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceMatching(className), + &iterator + ) == KERN_SUCCESS else { return nil } + defer { IOObjectRelease(iterator) } + + var entry = IOIteratorNext(iterator) + while entry != 0 { + defer { IOObjectRelease(entry); entry = IOIteratorNext(iterator) } + if let cfValue = IORegistryEntryCreateCFProperty( + entry, key as CFString, kCFAllocatorDefault, 0 + )?.takeRetainedValue() as? NSNumber { + return cfValue.intValue + } + } + return nil + } + + private static func gpuCoresFromChipName(_ name: String) -> Int { + let n = name.lowercased() + if n.contains("ultra") { return 76 } + if n.contains("max") { return 40 } + if n.contains("pro") { return 18 } + return 10 + } + + private static func neuralEngineCoresFromChipName(_ name: String) -> Int { + let n = name.lowercased() + if n.contains("m4") || n.contains("m3") { return 16 } + return 16 + } + + // MARK: - Known Model Requirements (RAM needed at runtime, disk for download) + + private static let knownRequirements: [String: ModelRequirements] = [ + "mlx-community/Qwen3.5-0.8B-MLX-4bit": + ModelRequirements(minRAMGB: 2, recommendedRAMGB: 4, diskGB: 0.7), + "mlx-community/Qwen3.5-2B-4bit": + ModelRequirements(minRAMGB: 4, recommendedRAMGB: 6, diskGB: 1.8), + "mlx-community/Qwen3.5-9B-5bit": + ModelRequirements(minRAMGB: 10, recommendedRAMGB: 12, diskGB: 7.1), + "mlx-community/Qwen3-30B-A3B-4bit": + ModelRequirements(minRAMGB: 12, recommendedRAMGB: 18, diskGB: 17.2), + "mlx-community/Qwen3.5-35B-A3B-4bit": + ModelRequirements(minRAMGB: 16, recommendedRAMGB: 24, diskGB: 20.5), + "mlx-community/Qwen2.5-0.5B-Instruct-4bit": + ModelRequirements(minRAMGB: 2, recommendedRAMGB: 4, diskGB: 0.3), + "mlx-community/Qwen2.5-1.5B-Instruct-4bit": + ModelRequirements(minRAMGB: 2, recommendedRAMGB: 4, diskGB: 0.9), + "mlx-community/Qwen2.5-3B-Instruct-4bit": + ModelRequirements(minRAMGB: 4, recommendedRAMGB: 6, diskGB: 1.8), + "mlx-community/Qwen3-0.6B-4bit": + ModelRequirements(minRAMGB: 2, recommendedRAMGB: 4, diskGB: 0.4), + "mlx-community/Qwen3-1.7B-4bit": + ModelRequirements(minRAMGB: 2, recommendedRAMGB: 4, diskGB: 1.0), + "mlx-community/Qwen3-4B-4bit": + ModelRequirements(minRAMGB: 4, recommendedRAMGB: 6, diskGB: 2.3), + "mlx-community/gemma-4-e2b-it-4bit": + ModelRequirements(minRAMGB: 6, recommendedRAMGB: 8, diskGB: 3.6), + "mlx-community/gemma-4-e4b-it-4bit": + ModelRequirements(minRAMGB: 8, recommendedRAMGB: 10, diskGB: 5.3), + "mlx-community/gemma-3-1b-it-4bit": + ModelRequirements(minRAMGB: 2, recommendedRAMGB: 4, diskGB: 0.8), + "mlx-community/gemma-3-4b-it-4bit": + ModelRequirements(minRAMGB: 6, recommendedRAMGB: 8, diskGB: 3.5), + "mlx-community/gemma-3-12b-it-4bit": + ModelRequirements(minRAMGB: 12, recommendedRAMGB: 16, diskGB: 8.1), + "mlx-community/Llama-4-Scout-17B-16E-Instruct-4bit": + ModelRequirements(minRAMGB: 48, recommendedRAMGB: 64, diskGB: 61.2), + "mlx-community/Llama-4-Maverick-17B-128E-Instruct-4bit": + ModelRequirements(minRAMGB: 128, recommendedRAMGB: 192, diskGB: 226.0), + ] +} + +extension DeviceCapability.Info { + var chipDisplayName: String { chipName } + var ramDisplayText: String { String(format: "%.0f GB", totalRAMGB) } + var gpuDisplayText: String { "\(gpuCoreCount)-core GPU" } + var diskAvailableText: String { String(format: "%.1f GB", availableDiskGB) } +} + +extension DeviceCapability.Compatibility { + var isCompatible: Bool { + if case .compatible = self { return true } + return false + } + + var isMarginal: Bool { + if case .marginal = self { return true } + return false + } + + var isIncompatible: Bool { + if case .incompatible = self { return true } + return false + } + + var message: String? { + switch self { + case .compatible: return nil + case .marginal(let msg), .incompatible(let msg): return msg + } + } +} diff --git a/Sources/Config/ModelCatalog.swift b/Sources/Config/ModelCatalog.swift index eb6f54e..3daab96 100644 --- a/Sources/Config/ModelCatalog.swift +++ b/Sources/Config/ModelCatalog.swift @@ -54,6 +54,7 @@ final class ModelCatalog: ObservableObject { var downloadDetail: String = "" var benchmarkTPS: Double? var isBenchmarking: Bool = false + var compatibility: DeviceCapability.Compatibility = .compatible static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id && lhs.status == rhs.status && @@ -210,6 +211,10 @@ final class ModelCatalog: ObservableObject { : (size > 0 ? .error(L("model.download_incomplete")) : .notDownloaded) } } + llmModels[i].compatibility = DeviceCapability.check( + modelID: id, + downloadSizeBytes: Self.defaultDownloadEstimateBytes(for: id) + ) } refreshASRStatus(recheckingErrors: recheckingErrors) } diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index 89bf4d3..a4b09b8 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -485,3 +485,14 @@ "error.llm_not_downloaded" = "The model files have not been downloaded. Open Settings → Models and confirm the data usage first."; "onboarding.download_notice" = "No download starts automatically. Review the size, then confirm if you want this local model."; "onboarding.download_size" = "Download (%@)"; + +/* ── Device Capability ── */ +"device.title" = "This Mac"; +"device.chip" = "Chip"; +"device.ram" = "Memory"; +"device.disk_available" = "Disk Available"; +"device.insufficient_disk" = "Not enough free disk space to download this model. Free up storage or choose a smaller model."; +"device.insufficient_ram" = "This Mac does not have enough memory to run this model. Choose a smaller model or use a remote LLM."; +"device.marginal_ram" = "This model may run slowly on this Mac due to limited memory. A smaller model is recommended for better performance."; +"device.badge_marginal" = "May Be Slow"; +"device.badge_incompatible" = "Too Large"; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index a1fc182..d0ec235 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -485,3 +485,14 @@ "error.llm_not_downloaded" = "模型文件尚未下载。请前往 设置 → 模型,确认流量后下载"; "onboarding.download_notice" = "这里不会自动下载。请先确认体积,再决定是否下载这个本地模型。"; "onboarding.download_size" = "下载(%@)"; + +/* ── 设备性能检查 ── */ +"device.title" = "本机信息"; +"device.chip" = "芯片"; +"device.ram" = "内存"; +"device.disk_available" = "可用磁盘"; +"device.insufficient_disk" = "磁盘空间不足,无法下载此模型。请清理空间或选择更小的模型。"; +"device.insufficient_ram" = "本机内存不足以运行此模型。请选择更小的模型,或使用远程 LLM。"; +"device.marginal_ram" = "本机内存有限,运行此模型可能较慢。建议选择更小的模型以获得更好的性能。"; +"device.badge_marginal" = "可能较慢"; +"device.badge_incompatible" = "超出配置"; diff --git a/Sources/UI/ModelManagementActions.swift b/Sources/UI/ModelManagementActions.swift index 8d6f272..b11b8ee 100644 --- a/Sources/UI/ModelManagementActions.swift +++ b/Sources/UI/ModelManagementActions.swift @@ -161,12 +161,16 @@ extension ModelManagementView { let estimate = estimatedDownloadBytes(for: action.model, type: action.type) let remaining = estimate.map { max($0 - action.model.cacheSize, 0) } let sizeText = remaining.map(ModelCatalog.formatBytes) ?? L("download.unknown") - return String( + var message = String( format: L("model.download_confirm_message"), action.model.displayName, sizeText, ModelStorage.root.path ) + if let warning = action.model.compatibility.message { + message += "\n\n⚠️ " + warning + } + return message } private func estimatedDownloadBytes( diff --git a/Sources/UI/ModelManagementRows.swift b/Sources/UI/ModelManagementRows.swift index aba2252..6cb9394 100644 --- a/Sources/UI/ModelManagementRows.swift +++ b/Sources/UI/ModelManagementRows.swift @@ -53,6 +53,7 @@ extension ModelManagementView { .foregroundStyle(Color.accentColor) .clipShape(Capsule()) } + compatibilityBadge(model.compatibility) } HStack(spacing: 6) { Text(secondaryText(for: model)) @@ -92,6 +93,32 @@ extension ModelManagementView { .background(isActive ? Color.accentColor.opacity(0.04) : .clear) } + @ViewBuilder + func compatibilityBadge(_ compat: DeviceCapability.Compatibility) -> some View { + switch compat { + case .compatible: + EmptyView() + case .marginal(let msg): + Text(L("device.badge_marginal")) + .font(.system(size: 9, weight: .medium)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.orange.opacity(0.15)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + .help(msg) + case .incompatible(let msg): + Text(L("device.badge_incompatible")) + .font(.system(size: 9, weight: .medium)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.red.opacity(0.15)) + .foregroundStyle(.red) + .clipShape(Capsule()) + .help(msg) + } + } + @ViewBuilder func busyModelStatus(_ model: ModelCatalog.ModelEntry) -> some View { VStack(alignment: .leading, spacing: 4) { diff --git a/Sources/UI/ModelManagementSections.swift b/Sources/UI/ModelManagementSections.swift index 51d3793..4a34ac2 100644 --- a/Sources/UI/ModelManagementSections.swift +++ b/Sources/UI/ModelManagementSections.swift @@ -2,6 +2,55 @@ import AppKit import SwiftUI extension ModelManagementView { + var deviceInfoSection: some View { + let info = DeviceCapability.current + return VStack(alignment: .leading, spacing: 10) { + Label(L("device.title"), systemImage: "cpu") + .font(.headline) + + HStack(spacing: 16) { + deviceInfoItem( + icon: "memorychip", + label: L("device.chip"), + value: info.chipDisplayName + ) + Divider().frame(height: 28) + deviceInfoItem( + icon: "memorychip.fill", + label: L("device.ram"), + value: info.ramDisplayText + ) + Divider().frame(height: 28) + deviceInfoItem( + icon: "gpu", + label: "GPU", + value: info.gpuDisplayText + ) + Divider().frame(height: 28) + deviceInfoItem( + icon: "internaldrive", + label: L("device.disk_available"), + value: info.diskAvailableText + ) + } + } + } + + private func deviceInfoItem(icon: String, label: String, value: String) -> some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + } + } + } + var storageSection: some View { VStack(alignment: .leading, spacing: 8) { Label(L("model.storage.title"), systemImage: "externaldrive") diff --git a/Sources/UI/ModelManagementView.swift b/Sources/UI/ModelManagementView.swift index 6af2ae7..322859e 100644 --- a/Sources/UI/ModelManagementView.swift +++ b/Sources/UI/ModelManagementView.swift @@ -36,6 +36,8 @@ struct ModelManagementView: View { SettingsPanel { activeDownloadsSection } } + SettingsPanel { deviceInfoSection } + SettingsPanel { enginePickerSection Divider().padding(.vertical, 4) diff --git a/docs/index.html b/docs/index.html index ee4700d..507c2d8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -37,10 +37,10 @@