Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Sources/Config/DeviceCapability.swift
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions Sources/Config/ModelCatalog.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 &&
Expand DownExpand Up@@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
11 changes: 11 additions & 0 deletions Sources/Resources/zh-Hans.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" = "超出配置";
6 changes: 5 additions & 1 deletion Sources/UI/ModelManagementActions.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Sources/UI/ModelManagementRows.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ extension ModelManagementView {
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
compatibilityBadge(model.compatibility)
}
HStack(spacing: 6) {
Text(secondaryText(for: model))
Expand DownExpand Up@@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions Sources/UI/ModelManagementSections.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Sources/UI/ModelManagementView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ struct ModelManagementView: View {
SettingsPanel { activeDownloadsSection }
}

SettingsPanel { deviceInfoSection }

SettingsPanel {
enginePickerSection
Divider().padding(.vertical, 4)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Sources/Config/DeviceCapability.swift
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions Sources/Config/ModelCatalog.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 &&
Expand DownExpand Up@@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
11 changes: 11 additions & 0 deletions Sources/Resources/zh-Hans.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" = "超出配置";
6 changes: 5 additions & 1 deletion Sources/UI/ModelManagementActions.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Sources/UI/ModelManagementRows.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ extension ModelManagementView {
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
compatibilityBadge(model.compatibility)
}
HStack(spacing: 6) {
Text(secondaryText(for: model))
Expand DownExpand Up@@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions Sources/UI/ModelManagementSections.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Sources/UI/ModelManagementView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ struct ModelManagementView: View {
SettingsPanel { activeDownloadsSection }
}

SettingsPanel { deviceInfoSection }

SettingsPanel {
enginePickerSection
Divider().padding(.vertical, 4)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Sources/Config/DeviceCapability.swift
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions Sources/Config/ModelCatalog.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 &&
Expand DownExpand Up@@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
11 changes: 11 additions & 0 deletions Sources/Resources/zh-Hans.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" = "超出配置";
6 changes: 5 additions & 1 deletion Sources/UI/ModelManagementActions.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Sources/UI/ModelManagementRows.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ extension ModelManagementView {
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
compatibilityBadge(model.compatibility)
}
HStack(spacing: 6) {
Text(secondaryText(for: model))
Expand DownExpand Up@@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions Sources/UI/ModelManagementSections.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Sources/UI/ModelManagementView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ struct ModelManagementView: View {
SettingsPanel { activeDownloadsSection }
}

SettingsPanel { deviceInfoSection }

SettingsPanel {
enginePickerSection
Divider().padding(.vertical, 4)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Sources/Config/DeviceCapability.swift
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions Sources/Config/ModelCatalog.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 &&
Expand DownExpand Up@@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
11 changes: 11 additions & 0 deletions Sources/Resources/zh-Hans.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" = "超出配置";
6 changes: 5 additions & 1 deletion Sources/UI/ModelManagementActions.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Sources/UI/ModelManagementRows.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ extension ModelManagementView {
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
compatibilityBadge(model.compatibility)
}
HStack(spacing: 6) {
Text(secondaryText(for: model))
Expand DownExpand Up@@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions Sources/UI/ModelManagementSections.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Sources/UI/ModelManagementView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ struct ModelManagementView: View {
SettingsPanel { activeDownloadsSection }
}

SettingsPanel { deviceInfoSection }

SettingsPanel {
enginePickerSection
Divider().padding(.vertical, 4)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Sources/Config/DeviceCapability.swift
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions Sources/Config/ModelCatalog.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 &&
Expand DownExpand Up@@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
11 changes: 11 additions & 0 deletions Sources/Resources/zh-Hans.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" = "超出配置";
6 changes: 5 additions & 1 deletion Sources/UI/ModelManagementActions.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Sources/UI/ModelManagementRows.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ extension ModelManagementView {
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
compatibilityBadge(model.compatibility)
}
HStack(spacing: 6) {
Text(secondaryText(for: model))
Expand DownExpand Up@@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions Sources/UI/ModelManagementSections.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Sources/UI/ModelManagementView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ struct ModelManagementView: View {
SettingsPanel { activeDownloadsSection }
}

SettingsPanel { deviceInfoSection }

SettingsPanel {
enginePickerSection
Divider().padding(.vertical, 4)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Sources/Config/DeviceCapability.swift
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions Sources/Config/ModelCatalog.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 &&
Expand DownExpand Up@@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
11 changes: 11 additions & 0 deletions Sources/Resources/zh-Hans.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" = "超出配置";
6 changes: 5 additions & 1 deletion Sources/UI/ModelManagementActions.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Sources/UI/ModelManagementRows.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ extension ModelManagementView {
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
compatibilityBadge(model.compatibility)
}
HStack(spacing: 6) {
Text(secondaryText(for: model))
Expand DownExpand Up@@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions Sources/UI/ModelManagementSections.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Sources/UI/ModelManagementView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ struct ModelManagementView: View {
SettingsPanel { activeDownloadsSection }
}

SettingsPanel { deviceInfoSection }

SettingsPanel {
enginePickerSection
Divider().padding(.vertical, 4)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Sources/Config/DeviceCapability.swift
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions Sources/Config/ModelCatalog.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 &&
Expand DownExpand Up@@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
11 changes: 11 additions & 0 deletions Sources/Resources/zh-Hans.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" = "超出配置";
6 changes: 5 additions & 1 deletion Sources/UI/ModelManagementActions.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Sources/UI/ModelManagementRows.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ extension ModelManagementView {
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
compatibilityBadge(model.compatibility)
}
HStack(spacing: 6) {
Text(secondaryText(for: model))
Expand DownExpand Up@@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions Sources/UI/ModelManagementSections.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Sources/UI/ModelManagementView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ struct ModelManagementView: View {
SettingsPanel { activeDownloadsSection }
}

SettingsPanel { deviceInfoSection }

SettingsPanel {
enginePickerSection
Divider().padding(.vertical, 4)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Sources/Config/DeviceCapability.swift
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions Sources/Config/ModelCatalog.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 &&
Expand DownExpand Up@@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
11 changes: 11 additions & 0 deletions Sources/Resources/zh-Hans.lproj/Localizable.strings
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" = "超出配置";
6 changes: 5 additions & 1 deletion Sources/UI/ModelManagementActions.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Sources/UI/ModelManagementRows.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ extension ModelManagementView {
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
compatibilityBadge(model.compatibility)
}
HStack(spacing: 6) {
Text(secondaryText(for: model))
Expand DownExpand Up@@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions Sources/UI/ModelManagementSections.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Sources/UI/ModelManagementView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ struct ModelManagementView: View {
SettingsPanel { activeDownloadsSection }
}

SettingsPanel { deviceInfoSection }

SettingsPanel {
enginePickerSection
Divider().padding(.vertical, 4)
Expand Down
Loading
Loading