diff --git a/MLXServer/ContentView.swift b/MLXServer/ContentView.swift index 7315ad0..94b5a42 100644 --- a/MLXServer/ContentView.swift +++ b/MLXServer/ContentView.swift @@ -24,10 +24,20 @@ struct ContentView: View { .navigationTitle(navigationTitleText) .onAppear { if chatVM == nil { - chatVM = ChatViewModel(modelManager: modelManager) + let vm = ChatViewModel(modelManager: modelManager) + chatVM = vm + if let delegate = NSApp.delegate as? AppDelegate { + delegate.chatViewModel = vm + } // Auto-start API server if configured if Preferences.apiAutoStart { - chatVM?.startAPIServer() + vm.startAPIServer() + } + // Restore autosaved session if no document is being opened + if !documentController.hasPendingOpenRequests { + Task { + await vm.restoreFromAutosave() + } } } diff --git a/MLXServer/MLXServerApp.swift b/MLXServer/MLXServerApp.swift index 87764eb..54b351f 100644 --- a/MLXServer/MLXServerApp.swift +++ b/MLXServer/MLXServerApp.swift @@ -1,11 +1,16 @@ import SwiftUI import MLX +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + var chatViewModel: ChatViewModel? + func application(_ application: NSApplication, open urls: [URL]) { - Task { @MainActor in - ChatDocumentController.shared.enqueueOpenRequests(urls) - } + ChatDocumentController.shared.enqueueOpenRequests(urls) + } + + func applicationWillTerminate(_ notification: Notification) { + chatViewModel?.autosaveToSandbox() } } @@ -28,6 +33,7 @@ struct MLXServerApp: App { .environment(sceneStore) .task { guard !documentController.hasPendingOpenRequests else { return } + guard !ChatViewModel.hasAutosavedSession else { return } // Auto-load: configured default → last used → built-in default let modelId = Preferences.defaultModelId ?? Preferences.lastModelId ?? ModelConfig.default.id if let config = ModelConfig.availableModels.first(where: { $0.id == modelId }) { diff --git a/MLXServer/Models/ModelConfig.swift b/MLXServer/Models/ModelConfig.swift index b64955a..c502ca1 100644 --- a/MLXServer/Models/ModelConfig.swift +++ b/MLXServer/Models/ModelConfig.swift @@ -54,6 +54,15 @@ struct ModelConfig: Identifiable, Hashable { supportsImages: false, supportsTools: false ), + ModelConfig( + id: "unslopnemo", + repoId: "mlx-community/UnslopNemo-12B-v4.1-4bit", + displayName: "UnslopNemo 12B", + contextLength: 131_072, + loaderKind: .llm, + supportsImages: false, + supportsTools: false + ), ] static let `default` = availableModels[0] diff --git a/MLXServer/ViewModels/ChatViewModel.swift b/MLXServer/ViewModels/ChatViewModel.swift index f5eb0ea..41fe770 100644 --- a/MLXServer/ViewModels/ChatViewModel.swift +++ b/MLXServer/ViewModels/ChatViewModel.swift @@ -23,6 +23,7 @@ final class ChatViewModel { private(set) var lastSavedSnapshotHash: String? private var generationTask: Task? + private var autosaveTask: Task? private var chatSession: ChatSession? private var documentId = UUID() private var documentCreatedAt = Date() @@ -210,6 +211,7 @@ final class ChatViewModel { resetSession() resetDocumentState() Preferences.lastSceneId = nil + scheduleAutosaveIfNeeded() } func startNewConversation(scene: ChatScene?) async { @@ -248,6 +250,8 @@ final class ChatViewModel { } func loadDocument(from url: URL) async throws { + autosaveTask?.cancel() + let package = try ChatDocumentPackage(contentsOf: url) let restoredMessages = try package.manifest.messages.map { storedMessage in try restoreMessage(storedMessage, attachmentContents: package.attachmentContents) @@ -281,11 +285,13 @@ final class ChatViewModel { throw ChatDocumentError.saveWhileGenerating } + autosaveTask?.cancel() let package = try makeDocumentPackage(updatedAt: Date()) try package.write(to: url) currentDocumentURL = url lastSavedSnapshotHash = try snapshotHash() hasUnsavedChanges = false + Self.removeAutosave() } func markDirtyIfNeeded() { @@ -296,6 +302,8 @@ final class ChatViewModel { || !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || activeScene != nil } + + scheduleAutosaveIfNeeded() } private func resetDocumentState() { @@ -451,6 +459,102 @@ final class ChatViewModel { return encoder } + // MARK: - Autosave / Restore + + /// Location for the automatic session save inside the sandbox container. + static var autosaveURL: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dir = appSupport.appendingPathComponent("MLXServer", isDirectory: true) + return dir.appendingPathComponent("autosave.mlxchat") + } + + static var hasAutosavedSession: Bool { + FileManager.default.fileExists(atPath: autosaveURL.path) + } + + /// Persist the current session so it survives a quit. + func autosaveToSandbox() { + autosaveTask?.cancel() + + guard currentDocumentURL == nil else { + Self.removeAutosave() + return + } + + // Nothing to save if conversation is empty and no draft text + let hasDraft = !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard !conversation.messages.isEmpty || hasDraft else { + // Remove stale autosave if conversation was cleared + Self.removeAutosave() + return + } + + do { + let url = Self.autosaveURL + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + let package = try makeDocumentPackage(updatedAt: Date()) + try package.write(to: url) + } catch { + print("[Autosave] Failed: \(error.localizedDescription)") + } + } + + /// Restore a previously autosaved session. Returns true if restored. + func restoreFromAutosave() async -> Bool { + let url = Self.autosaveURL + guard FileManager.default.fileExists(atPath: url.path) else { return false } + + do { + try await loadDocument(from: url) + // Clear document URL so this doesn't look like a user-saved file + currentDocumentURL = nil + hasUnsavedChanges = false + lastSavedSnapshotHash = nil + + if modelManager.currentModel == nil { + let modelId = Preferences.defaultModelId ?? Preferences.lastModelId ?? ModelConfig.default.id + if let config = ModelConfig.availableModels.first(where: { $0.id == modelId }) { + await modelManager.loadModel(config) + } + } + + return true + } catch { + print("[Autosave] Restore failed: \(error.localizedDescription)") + return false + } + } + + static func removeAutosave() { + let url = autosaveURL + try? FileManager.default.removeItem(at: url) + } + + private func scheduleAutosaveIfNeeded() { + autosaveTask?.cancel() + + guard currentDocumentURL == nil else { + Self.removeAutosave() + return + } + + let hasDraft = !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard !conversation.messages.isEmpty || hasDraft || activeScene != nil else { + Self.removeAutosave() + return + } + + autosaveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(800)) + guard !Task.isCancelled else { return } + await self?.autosaveToSandbox() + } + } + // MARK: - API Server func startAPIServer() {