From 4f6ff9be8fd3b5e7f9924c39d8f96d0a072bb94e Mon Sep 17 00:00:00 2001 From: Alazar Manakelew Date: Tue, 21 Jul 2026 10:54:05 -0400 Subject: [PATCH 1/2] Detect MLX models installed by LM Studio. Scans ~/.lmstudio/models (and the legacy ~/.cache/lm-studio/models) for MLX-format model directories, lists them on the Models page with an LM Studio badge, and makes them selectable in chat by loading the model from its directory path. External models are never deleted by Nativ. --- .../Features/Models/LocalModelDiscovery.swift | 127 +++++++++++++++++- .../Nativ/Features/Models/ModelsView.swift | 13 +- 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/Sources/Nativ/Features/Models/LocalModelDiscovery.swift b/Sources/Nativ/Features/Models/LocalModelDiscovery.swift index 9e439f1..2ff2efa 100644 --- a/Sources/Nativ/Features/Models/LocalModelDiscovery.swift +++ b/Sources/Nativ/Features/Models/LocalModelDiscovery.swift @@ -42,6 +42,18 @@ enum LocalModelCapability: String, CaseIterable, Hashable, Sendable { } } +enum LocalModelSource: String, Equatable, Sendable { + case huggingFaceCache + case lmStudio + + var badgeLabel: String? { + switch self { + case .huggingFaceCache: nil + case .lmStudio: "LM Studio" + } + } +} + struct LocalModel: Identifiable, Equatable, Sendable { var id: String { repoID } @@ -55,6 +67,19 @@ struct LocalModel: Identifiable, Equatable, Sendable { let contextSize: Int? let provider: LocalModelProvider? let capabilities: Set + var source: LocalModelSource = .huggingFaceCache + + var displayName: String { + guard source != .huggingFaceCache, let snapshotURL else { + return repoID + } + let components = snapshotURL.standardizedFileURL.pathComponents.suffix(2) + return components.joined(separator: "/") + } + + var isDeletable: Bool { + source == .huggingFaceCache + } var isEligibleForLanguageModelPicker: Bool { !capabilities.contains(.speechToText) @@ -172,10 +197,23 @@ struct LocalModelConfigurationMetadata: Equatable, Sendable { } enum LocalModelDiscovery { + static let lmStudioModelRoots = [ + "~/.lmstudio/models", + "~/.cache/lm-studio/models" + ] + static func scan(path: String) async throws -> [LocalModel] { let expandedPath = Self.expandedPath(path) return try await Task.detached(priority: .userInitiated) { - try Self.scanSynchronously(path: expandedPath) + let externalModels = Self.scanLMStudioSynchronously(fileManager: FileManager.default) + do { + return Self.sortedByDisplayName(try Self.scanSynchronously(path: expandedPath) + externalModels) + } catch { + guard !externalModels.isEmpty else { + throw error + } + return Self.sortedByDisplayName(externalModels) + } }.value } @@ -274,8 +312,12 @@ enum LocalModelDiscovery { ) } - return models.sorted { lhs, rhs in - switch lhs.repoID.localizedCaseInsensitiveCompare(rhs.repoID) { + return models + } + + private static func sortedByDisplayName(_ models: [LocalModel]) -> [LocalModel] { + models.sorted { lhs, rhs in + switch lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) { case .orderedAscending: return true case .orderedDescending: @@ -286,11 +328,90 @@ enum LocalModelDiscovery { } } + private static func scanLMStudioSynchronously(fileManager: FileManager) -> [LocalModel] { + var models: [LocalModel] = [] + var seenPaths = Set() + + for root in lmStudioModelRoots { + let rootURL = URL( + fileURLWithPath: (root as NSString).expandingTildeInPath, + isDirectory: true + ) + guard isDirectoryURL(rootURL, fileManager: fileManager), + let publisherURLs = try? fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + continue + } + + for publisherURL in publisherURLs where isDirectoryURL(publisherURL, fileManager: fileManager) { + guard let modelURLs = try? fileManager.contentsOfDirectory( + at: publisherURL, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + + for modelURL in modelURLs where isDirectoryURL(modelURL, fileManager: fileManager) { + let standardizedPath = modelURL.standardizedFileURL.path + guard !seenPaths.contains(standardizedPath), + isLikelyMLXModelSnapshot(modelURL, fileManager: fileManager) + else { + continue + } + seenPaths.insert(standardizedPath) + + let hubStyleID = "\(publisherURL.lastPathComponent)/\(modelURL.lastPathComponent)" + let modifiedAt = (try? modelURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + let memoryMetadata = modelMemoryMetadata( + repoID: hubStyleID, + snapshotURL: modelURL, + fileManager: fileManager + ) + models.append(LocalModel( + repoID: standardizedPath, + snapshotURL: modelURL, + modifiedAt: modifiedAt, + sizeBytes: snapshotSize(at: modelURL, fileManager: fileManager), + parameterCount: memoryMetadata.parameterCount, + quantizationBits: memoryMetadata.quantizationBits, + quantizationGroupSize: memoryMetadata.quantizationGroupSize, + contextSize: contextSize(at: modelURL, fileManager: fileManager), + provider: modelProvider( + repoID: hubStyleID, + snapshotURL: modelURL, + fileManager: fileManager + ), + capabilities: modelCapabilities(at: modelURL, fileManager: fileManager), + source: .lmStudio + )) + } + } + } + return models + } + private static func configurationMetadataSynchronously( repoID: String, path: String ) -> LocalModelConfigurationMetadata? { let fileManager = FileManager.default + + if repoID.hasPrefix("/") { + let directURL = URL(fileURLWithPath: repoID, isDirectory: true) + guard isDirectoryURL(directURL, fileManager: fileManager) else { + return nil + } + return LocalModelConfigurationMetadata( + contextSize: contextSizeFromConfig(at: directURL, fileManager: fileManager), + defaultSystemPrompt: defaultSystemPrompt(at: directURL, fileManager: fileManager) + ) + } + let repositoryName = "models--" + repoID.replacingOccurrences(of: "/", with: "--") let repositoryURL = URL(fileURLWithPath: path, isDirectory: true) .appendingPathComponent(repositoryName, isDirectory: true) diff --git a/Sources/Nativ/Features/Models/ModelsView.swift b/Sources/Nativ/Features/Models/ModelsView.swift index 694f41e..130fe23 100644 --- a/Sources/Nativ/Features/Models/ModelsView.swift +++ b/Sources/Nativ/Features/Models/ModelsView.swift @@ -141,7 +141,7 @@ struct ModelsView: View { selectedLanguageModelID: model.settings.normalized().languageModelID, isModelSwitchInProgress: model.modelSwitchInProgress, isDeleting: localLibrary.deletingModelIDs.contains(localModel.repoID), - canDelete: !model.modelSwitchInProgress && !isModelInUse(localModel.repoID), + canDelete: localModel.isDeletable && !model.modelSwitchInProgress && !isModelInUse(localModel.repoID), onLoadModel: { model.switchLanguageModel(to: localModel.repoID) }, onDelete: { deleteInstalledModel(localModel) } ) @@ -290,6 +290,7 @@ struct ModelsView: View { } private func deleteInstalledModel(_ localModel: LocalModel) { + guard localModel.isDeletable else { return } localLibrary.delete( model: localModel, path: model.settings.modelSearchPath @@ -318,6 +319,7 @@ struct ModelsView: View { ? localLibrary.models : localLibrary.models.filter { $0.repoID.localizedCaseInsensitiveContains(query) + || $0.displayName.localizedCaseInsensitiveContains(query) || $0.provider?.displayName.localizedCaseInsensitiveContains(query) == true } @@ -641,9 +643,16 @@ private struct InstalledModelRow: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 7) { - Text(modelName(localModel.repoID)) + Text(modelName(localModel.displayName)) .font(.body.weight(.semibold)) .lineLimit(1) + if let sourceLabel = localModel.source.badgeLabel { + ModelPill( + title: sourceLabel, + systemImage: "cube", + color: .purple + ) + } if isLoading { ModelPill( title: "Loading model", From 4da60c9505df071c57b95265cb58235f8b4b0147 Mon Sep 17 00:00:00 2001 From: Alazar Manakelew Date: Tue, 21 Jul 2026 13:15:28 -0400 Subject: [PATCH 2/2] Replace hardcoded external roots with user-defined model folders. Per review: instead of scanning third-party app directories we don't own, users add custom model folders (external SSDs, LM Studio's models directory, any MLX folder) from a Sources menu on the Models page. Folders persist in settings, are scanned alongside the default Hugging Face cache, and their models appear everywhere with an External badge. Folders that are themselves a model directory, or that nest models one or two levels deep, are all recognized. --- Sources/Nativ/AppDelegate.swift | 20 ++- .../Nativ/Features/Chat/ChatComposer.swift | 18 ++- .../Dashboard/DashboardViewModel.swift | 4 +- .../Nativ/Features/Dashboard/StatsView.swift | 7 +- .../Features/Models/LocalModelDiscovery.swift | 140 ++++++++++-------- .../Nativ/Features/Models/ModelsView.swift | 68 ++++++++- Sources/Nativ/NativSettings.swift | 10 ++ 7 files changed, 189 insertions(+), 78 deletions(-) diff --git a/Sources/Nativ/AppDelegate.swift b/Sources/Nativ/AppDelegate.swift index 890115f..4997dea 100644 --- a/Sources/Nativ/AppDelegate.swift +++ b/Sources/Nativ/AppDelegate.swift @@ -852,9 +852,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return NumberFormatter.localizedString(from: NSNumber(value: value), number: .decimal) } + private var modelScanKey: String { + let settings = model.settings.normalized() + return ([settings.expandedModelSearchPath] + settings.additionalModelSearchPaths) + .joined(separator: "\u{0}") + } + private func refreshLocalModelsIfNeeded() { - let currentPath = model.settings.normalized().expandedModelSearchPath - guard lastScannedModelPath != currentPath else { + guard lastScannedModelPath != modelScanKey else { return } refreshLocalModels() @@ -862,7 +867,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func refreshLocalModels() { modelScanTask?.cancel() - let searchPath = model.settings.normalized().expandedModelSearchPath + let settings = model.settings.normalized() + let searchPath = settings.expandedModelSearchPath + let additionalPaths = settings.additionalModelSearchPaths + let scanKey = modelScanKey modelScanInProgress = true modelScanError = nil rebuildModelSubmenu() @@ -873,12 +881,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } do { - let models = try await LocalModelDiscovery.scan(path: searchPath) + let models = try await LocalModelDiscovery.scan(path: searchPath, additionalPaths: additionalPaths) guard !Task.isCancelled else { return } self.localModels = models - self.lastScannedModelPath = searchPath + self.lastScannedModelPath = scanKey } catch is CancellationError { return } catch { @@ -888,7 +896,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { self.localModels = [] self.modelScanError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - self.lastScannedModelPath = searchPath + self.lastScannedModelPath = scanKey } self.modelScanInProgress = false diff --git a/Sources/Nativ/Features/Chat/ChatComposer.swift b/Sources/Nativ/Features/Chat/ChatComposer.swift index 0809b4f..1da7a99 100644 --- a/Sources/Nativ/Features/Chat/ChatComposer.swift +++ b/Sources/Nativ/Features/Chat/ChatComposer.swift @@ -201,11 +201,17 @@ struct ChatComposer: View { .shadow(color: .black.opacity(0.08), radius: 12, x: 0, y: 4) } .padding(.vertical, 18) - .task(id: model.settings.modelSearchPath) { - localLibrary.scan(path: model.settings.modelSearchPath) + .task(id: modelScanKey) { + localLibrary.scan( + path: model.settings.modelSearchPath, + additionalPaths: model.settings.normalized().additionalModelSearchPaths + ) } .onReceive(NotificationCenter.default.publisher(for: .localModelLibraryDidChange)) { _ in - localLibrary.scan(path: model.settings.modelSearchPath) + localLibrary.scan( + path: model.settings.modelSearchPath, + additionalPaths: model.settings.normalized().additionalModelSearchPaths + ) } .onChange(of: localLibrary.models) { _, models in disableThinkingIfUnsupported(modelID: selectedModelID, models: models) @@ -219,6 +225,12 @@ struct ChatComposer: View { } } + private var modelScanKey: String { + let settings = model.settings.normalized() + return ([settings.expandedModelSearchPath] + settings.additionalModelSearchPaths) + .joined(separator: "\u{0}") + } + private var modelPicker: some View { StableChatModelPicker( models: localLibrary.models, diff --git a/Sources/Nativ/Features/Dashboard/DashboardViewModel.swift b/Sources/Nativ/Features/Dashboard/DashboardViewModel.swift index 8a61601..e58d9b4 100644 --- a/Sources/Nativ/Features/Dashboard/DashboardViewModel.swift +++ b/Sources/Nativ/Features/Dashboard/DashboardViewModel.swift @@ -213,13 +213,13 @@ final class DashboardViewModel: ObservableObject { applyPreferredSelectionIfPossible() } - func scanModels(at path: String) { + func scanModels(at path: String, additionalPaths: [String] = []) { modelScanTask?.cancel() localModelError = nil modelScanTask = Task { [path] in do { - let models = try await LocalModelDiscovery.scan(path: path) + let models = try await LocalModelDiscovery.scan(path: path, additionalPaths: additionalPaths) guard !Task.isCancelled else { return } diff --git a/Sources/Nativ/Features/Dashboard/StatsView.swift b/Sources/Nativ/Features/Dashboard/StatsView.swift index 9647bd1..d24eae1 100644 --- a/Sources/Nativ/Features/Dashboard/StatsView.swift +++ b/Sources/Nativ/Features/Dashboard/StatsView.swift @@ -82,6 +82,7 @@ struct StatsView: View { private struct DashboardModelState: Equatable { let isRunning: Bool let modelSearchPath: String + let additionalModelSearchPaths: [String] let analyticsDatabaseURL: URL let loadedModelID: String? let historicalMetricsRevision: DashboardMetricsRevision? @@ -90,6 +91,7 @@ private struct DashboardModelState: Equatable { init(model: NativModel) { isRunning = model.isRunning modelSearchPath = model.settings.modelSearchPath + additionalModelSearchPaths = model.settings.normalized().additionalModelSearchPaths analyticsDatabaseURL = model.analyticsDatabaseURL loadedModelID = model.metrics?.server.loadedModel historicalMetricsRevision = model.metrics.map { @@ -421,7 +423,10 @@ private struct DashboardContentView: View, Equatable { dashboard.updateAnalyticsDatabaseURL(modelState.analyticsDatabaseURL) dashboard.updatePreferredModelID(modelState.loadedModelID) if scanModels { - dashboard.scanModels(at: modelState.modelSearchPath) + dashboard.scanModels( + at: modelState.modelSearchPath, + additionalPaths: modelState.additionalModelSearchPaths + ) } if reloadHistory { dashboard.reloadHistorical() diff --git a/Sources/Nativ/Features/Models/LocalModelDiscovery.swift b/Sources/Nativ/Features/Models/LocalModelDiscovery.swift index 2ff2efa..2b618ae 100644 --- a/Sources/Nativ/Features/Models/LocalModelDiscovery.swift +++ b/Sources/Nativ/Features/Models/LocalModelDiscovery.swift @@ -44,12 +44,12 @@ enum LocalModelCapability: String, CaseIterable, Hashable, Sendable { enum LocalModelSource: String, Equatable, Sendable { case huggingFaceCache - case lmStudio + case external var badgeLabel: String? { switch self { case .huggingFaceCache: nil - case .lmStudio: "LM Studio" + case .external: "External" } } } @@ -197,15 +197,14 @@ struct LocalModelConfigurationMetadata: Equatable, Sendable { } enum LocalModelDiscovery { - static let lmStudioModelRoots = [ - "~/.lmstudio/models", - "~/.cache/lm-studio/models" - ] - - static func scan(path: String) async throws -> [LocalModel] { + static func scan(path: String, additionalPaths: [String] = []) async throws -> [LocalModel] { let expandedPath = Self.expandedPath(path) + let expandedAdditionalPaths = additionalPaths.map(Self.expandedPath) return try await Task.detached(priority: .userInitiated) { - let externalModels = Self.scanLMStudioSynchronously(fileManager: FileManager.default) + let externalModels = Self.scanAdditionalPathsSynchronously( + expandedAdditionalPaths, + fileManager: FileManager.default + ) do { return Self.sortedByDisplayName(try Self.scanSynchronously(path: expandedPath) + externalModels) } catch { @@ -328,73 +327,90 @@ enum LocalModelDiscovery { } } - private static func scanLMStudioSynchronously(fileManager: FileManager) -> [LocalModel] { + private static func scanAdditionalPathsSynchronously( + _ rootPaths: [String], + fileManager: FileManager + ) -> [LocalModel] { var models: [LocalModel] = [] var seenPaths = Set() - for root in lmStudioModelRoots { - let rootURL = URL( - fileURLWithPath: (root as NSString).expandingTildeInPath, - isDirectory: true - ) - guard isDirectoryURL(rootURL, fileManager: fileManager), - let publisherURLs = try? fileManager.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) - else { + for rootPath in rootPaths { + let rootURL = URL(fileURLWithPath: rootPath, isDirectory: true) + guard isDirectoryURL(rootURL, fileManager: fileManager) else { + continue + } + + if isLikelyMLXModelSnapshot(rootURL, fileManager: fileManager) { + if let model = externalModel(at: rootURL, fileManager: fileManager, seenPaths: &seenPaths) { + models.append(model) + } continue } - for publisherURL in publisherURLs where isDirectoryURL(publisherURL, fileManager: fileManager) { - guard let modelURLs = try? fileManager.contentsOfDirectory( - at: publisherURL, - includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { + for childURL in directoryContents(of: rootURL, fileManager: fileManager) { + if isLikelyMLXModelSnapshot(childURL, fileManager: fileManager) { + if let model = externalModel(at: childURL, fileManager: fileManager, seenPaths: &seenPaths) { + models.append(model) + } continue } - for modelURL in modelURLs where isDirectoryURL(modelURL, fileManager: fileManager) { - let standardizedPath = modelURL.standardizedFileURL.path - guard !seenPaths.contains(standardizedPath), - isLikelyMLXModelSnapshot(modelURL, fileManager: fileManager) - else { - continue + for grandchildURL in directoryContents(of: childURL, fileManager: fileManager) + where isLikelyMLXModelSnapshot(grandchildURL, fileManager: fileManager) { + if let model = externalModel(at: grandchildURL, fileManager: fileManager, seenPaths: &seenPaths) { + models.append(model) } - seenPaths.insert(standardizedPath) - - let hubStyleID = "\(publisherURL.lastPathComponent)/\(modelURL.lastPathComponent)" - let modifiedAt = (try? modelURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate - let memoryMetadata = modelMemoryMetadata( - repoID: hubStyleID, - snapshotURL: modelURL, - fileManager: fileManager - ) - models.append(LocalModel( - repoID: standardizedPath, - snapshotURL: modelURL, - modifiedAt: modifiedAt, - sizeBytes: snapshotSize(at: modelURL, fileManager: fileManager), - parameterCount: memoryMetadata.parameterCount, - quantizationBits: memoryMetadata.quantizationBits, - quantizationGroupSize: memoryMetadata.quantizationGroupSize, - contextSize: contextSize(at: modelURL, fileManager: fileManager), - provider: modelProvider( - repoID: hubStyleID, - snapshotURL: modelURL, - fileManager: fileManager - ), - capabilities: modelCapabilities(at: modelURL, fileManager: fileManager), - source: .lmStudio - )) } } } return models } + private static func directoryContents(of url: URL, fileManager: FileManager) -> [URL] { + let contents = (try? fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + )) ?? [] + return contents.filter { isDirectoryURL($0, fileManager: fileManager) } + } + + private static func externalModel( + at modelURL: URL, + fileManager: FileManager, + seenPaths: inout Set + ) -> LocalModel? { + let standardizedPath = modelURL.standardizedFileURL.path + guard seenPaths.insert(standardizedPath).inserted else { + return nil + } + + let hubStyleID = modelURL.standardizedFileURL.pathComponents.suffix(2).joined(separator: "/") + let modifiedAt = (try? modelURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + let memoryMetadata = modelMemoryMetadata( + repoID: hubStyleID, + snapshotURL: modelURL, + fileManager: fileManager + ) + return LocalModel( + repoID: standardizedPath, + snapshotURL: modelURL, + modifiedAt: modifiedAt, + sizeBytes: snapshotSize(at: modelURL, fileManager: fileManager), + parameterCount: memoryMetadata.parameterCount, + quantizationBits: memoryMetadata.quantizationBits, + quantizationGroupSize: memoryMetadata.quantizationGroupSize, + contextSize: contextSize(at: modelURL, fileManager: fileManager), + provider: modelProvider( + repoID: hubStyleID, + snapshotURL: modelURL, + fileManager: fileManager + ), + capabilities: modelCapabilities(at: modelURL, fileManager: fileManager), + source: .external + ) + } + private static func configurationMetadataSynchronously( repoID: String, path: String @@ -1176,14 +1192,14 @@ final class LocalModelLibrary: ObservableObject { scanTask?.cancel() } - func scan(path: String) { + func scan(path: String, additionalPaths: [String] = []) { scanTask?.cancel() isScanning = true error = nil scanTask = Task { [weak self] in do { - let models = try await LocalModelDiscovery.scan(path: path) + let models = try await LocalModelDiscovery.scan(path: path, additionalPaths: additionalPaths) guard !Task.isCancelled else { return } diff --git a/Sources/Nativ/Features/Models/ModelsView.swift b/Sources/Nativ/Features/Models/ModelsView.swift index 130fe23..1692d29 100644 --- a/Sources/Nativ/Features/Models/ModelsView.swift +++ b/Sources/Nativ/Features/Models/ModelsView.swift @@ -48,7 +48,7 @@ struct ModelsView: View { } .background(Color(nsColor: .windowBackgroundColor)) .task(id: modelScanPath) { - localLibrary.scan(path: model.settings.modelSearchPath) + rescanLocalModels() } .task(id: hubSearchTaskID) { guard section == .discover else { return } @@ -89,8 +89,10 @@ struct ModelsView: View { HStack(spacing: 10) { ModelsSearchField(prompt: "Filter installed models", text: $localQuery) + sourcesMenu + Button { - localLibrary.scan(path: model.settings.modelSearchPath) + rescanLocalModels() } label: { Label("Refresh", systemImage: "arrow.clockwise") } @@ -220,7 +222,7 @@ struct ModelsView: View { repoID: hubModel.id, cachePath: model.settings.modelSearchPath ) { - localLibrary.scan(path: model.settings.modelSearchPath) + rescanLocalModels() NotificationCenter.default.post( name: .localModelLibraryDidChange, object: nil @@ -540,7 +542,65 @@ struct ModelsView: View { } private var modelScanPath: String { - model.settings.normalized().expandedModelSearchPath + let settings = model.settings.normalized() + return ([settings.expandedModelSearchPath] + settings.additionalModelSearchPaths) + .joined(separator: "\u{0}") + } + + private var sourcesMenu: some View { + Menu { + Section("Hugging Face cache") { + Text(abbreviatedPath(model.settings.normalized().modelSearchPath)) + } + Section("Model folders") { + ForEach(model.settings.normalized().additionalModelSearchPaths, id: \.self) { path in + Menu(abbreviatedPath(path)) { + Button("Remove", role: .destructive) { + removeModelSourceFolder(path) + } + } + } + Button { + addModelSourceFolder() + } label: { + Label("Add Folder…", systemImage: "plus") + } + } + } label: { + Label("Sources", systemImage: "folder") + } + .fixedSize() + .help("Folders scanned for MLX models in addition to the Hugging Face cache") + } + + private func rescanLocalModels() { + localLibrary.scan( + path: model.settings.modelSearchPath, + additionalPaths: model.settings.normalized().additionalModelSearchPaths + ) + } + + private func addModelSourceFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Add" + panel.message = "Choose a folder containing MLX models." + guard panel.runModal() == .OK, let url = panel.url else { + return + } + model.settings.additionalModelSearchPaths.append( + (url.path as NSString).abbreviatingWithTildeInPath + ) + } + + private func removeModelSourceFolder(_ path: String) { + model.settings.additionalModelSearchPaths.removeAll { $0 == path } + } + + private func abbreviatedPath(_ path: String) -> String { + (LocalModelDiscovery.expandedPath(path) as NSString).abbreviatingWithTildeInPath } private var hubSearchTaskID: String { diff --git a/Sources/Nativ/NativSettings.swift b/Sources/Nativ/NativSettings.swift index 8ee4fe4..6ed9cd1 100644 --- a/Sources/Nativ/NativSettings.swift +++ b/Sources/Nativ/NativSettings.swift @@ -5,6 +5,7 @@ struct NativSettings: Codable, Equatable { static let defaultModelSearchPath = "~/.cache/huggingface/hub" var modelSearchPath: String + var additionalModelSearchPaths: [String] var languageModelID: String? var imageGenerationModelID: String? var textToSpeechModelID: String? @@ -42,6 +43,7 @@ struct NativSettings: Codable, Equatable { init( modelSearchPath: String = Self.defaultModelSearchPath, + additionalModelSearchPaths: [String] = [], languageModelID: String? = nil, imageGenerationModelID: String? = nil, textToSpeechModelID: String? = nil, @@ -78,6 +80,7 @@ struct NativSettings: Codable, Equatable { prefixCacheBlockSize: Int = 16 ) { self.modelSearchPath = modelSearchPath + self.additionalModelSearchPaths = additionalModelSearchPaths self.languageModelID = languageModelID self.imageGenerationModelID = imageGenerationModelID self.textToSpeechModelID = textToSpeechModelID @@ -116,6 +119,7 @@ struct NativSettings: Codable, Equatable { enum CodingKeys: String, CodingKey { case modelSearchPath + case additionalModelSearchPaths case languageModelID case imageGenerationModelID case textToSpeechModelID @@ -158,6 +162,7 @@ struct NativSettings: Codable, Equatable { let container = try decoder.container(keyedBy: CodingKeys.self) let legacySelectedModelID = try container.decodeIfPresent(String.self, forKey: .selectedModelID) modelSearchPath = try container.decodeIfPresent(String.self, forKey: .modelSearchPath) ?? defaults.modelSearchPath + additionalModelSearchPaths = try container.decodeIfPresent([String].self, forKey: .additionalModelSearchPaths) ?? defaults.additionalModelSearchPaths languageModelID = try container.decodeIfPresent(String.self, forKey: .languageModelID) ?? legacySelectedModelID ?? defaults.languageModelID imageGenerationModelID = try container.decodeIfPresent(String.self, forKey: .imageGenerationModelID) ?? defaults.imageGenerationModelID textToSpeechModelID = try container.decodeIfPresent(String.self, forKey: .textToSpeechModelID) ?? defaults.textToSpeechModelID @@ -197,6 +202,7 @@ struct NativSettings: Codable, Equatable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(modelSearchPath, forKey: .modelSearchPath) + try container.encode(additionalModelSearchPaths, forKey: .additionalModelSearchPaths) try container.encodeIfPresent(languageModelID, forKey: .languageModelID) try container.encodeIfPresent(imageGenerationModelID, forKey: .imageGenerationModelID) try container.encodeIfPresent(textToSpeechModelID, forKey: .textToSpeechModelID) @@ -258,6 +264,10 @@ struct NativSettings: Codable, Equatable { var settings = self let trimmedPath = settings.modelSearchPath.trimmingCharacters(in: .whitespacesAndNewlines) settings.modelSearchPath = trimmedPath.isEmpty ? Self.defaultModelSearchPath : trimmedPath + var seenAdditionalPaths = Set() + settings.additionalModelSearchPaths = settings.additionalModelSearchPaths + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && seenAdditionalPaths.insert($0).inserted } settings.languageModelID = Self.normalizedModelID(settings.languageModelID) settings.imageGenerationModelID = Self.normalizedModelID(settings.imageGenerationModelID) settings.textToSpeechModelID = Self.normalizedModelID(settings.textToSpeechModelID)