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
20 changes: 14 additions & 6 deletions Sources/Nativ/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -852,17 +852,25 @@ 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()
}

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()
Expand All @@ -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 {
Expand All @@ -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
Expand Down
18 changes: 15 additions & 3 deletions Sources/Nativ/Features/Chat/ChatComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions Sources/Nativ/Features/Dashboard/DashboardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/Nativ/Features/Dashboard/StatsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
149 changes: 143 additions & 6 deletions Sources/Nativ/Features/Models/LocalModelDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ enum LocalModelCapability: String, CaseIterable, Hashable, Sendable {
}
}

enum LocalModelSource: String, Equatable, Sendable {
case huggingFaceCache
case external

var badgeLabel: String? {
switch self {
case .huggingFaceCache: nil
case .external: "External"
}
}
}

struct LocalModel: Identifiable, Equatable, Sendable {
var id: String { repoID }

Expand All @@ -55,6 +67,19 @@ struct LocalModel: Identifiable, Equatable, Sendable {
let contextSize: Int?
let provider: LocalModelProvider?
let capabilities: Set<LocalModelCapability>
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)
Expand Down Expand Up @@ -172,10 +197,22 @@ struct LocalModelConfigurationMetadata: Equatable, Sendable {
}

enum LocalModelDiscovery {
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) {
try Self.scanSynchronously(path: expandedPath)
let externalModels = Self.scanAdditionalPathsSynchronously(
expandedAdditionalPaths,
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
}

Expand Down Expand Up @@ -274,8 +311,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:
Expand All @@ -286,11 +327,107 @@ enum LocalModelDiscovery {
}
}

private static func scanAdditionalPathsSynchronously(
_ rootPaths: [String],
fileManager: FileManager
) -> [LocalModel] {
var models: [LocalModel] = []
var seenPaths = Set<String>()

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 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 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)
}
}
}
}
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<String>
) -> 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
) -> 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)
Expand Down Expand Up @@ -1055,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
}
Expand Down
Loading