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