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 @@