feat: auto-save on close and auto-load on reopen

This commit is contained in:
2026-03-19 09:44:35 +01:00
parent b52633a301
commit 25df02daa1
4 changed files with 134 additions and 5 deletions

View File

@@ -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()
}
}
}

View File

@@ -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 }) {

View File

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

View File

@@ -23,6 +23,7 @@ final class ChatViewModel {
private(set) var lastSavedSnapshotHash: String?
private var generationTask: Task<Void, Never>?
private var autosaveTask: Task<Void, Never>?
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() {