feat: auto-save on close and auto-load on reopen
This commit is contained in:
@@ -24,10 +24,20 @@ struct ContentView: View {
|
|||||||
.navigationTitle(navigationTitleText)
|
.navigationTitle(navigationTitleText)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if chatVM == nil {
|
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
|
// Auto-start API server if configured
|
||||||
if Preferences.apiAutoStart {
|
if Preferences.apiAutoStart {
|
||||||
chatVM?.startAPIServer()
|
vm.startAPIServer()
|
||||||
|
}
|
||||||
|
// Restore autosaved session if no document is being opened
|
||||||
|
if !documentController.hasPendingOpenRequests {
|
||||||
|
Task {
|
||||||
|
await vm.restoreFromAutosave()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MLX
|
import MLX
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func application(_ application: NSApplication, open urls: [URL]) {
|
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)
|
.environment(sceneStore)
|
||||||
.task {
|
.task {
|
||||||
guard !documentController.hasPendingOpenRequests else { return }
|
guard !documentController.hasPendingOpenRequests else { return }
|
||||||
|
guard !ChatViewModel.hasAutosavedSession else { return }
|
||||||
// Auto-load: configured default → last used → built-in default
|
// Auto-load: configured default → last used → built-in default
|
||||||
let modelId = Preferences.defaultModelId ?? Preferences.lastModelId ?? ModelConfig.default.id
|
let modelId = Preferences.defaultModelId ?? Preferences.lastModelId ?? ModelConfig.default.id
|
||||||
if let config = ModelConfig.availableModels.first(where: { $0.id == modelId }) {
|
if let config = ModelConfig.availableModels.first(where: { $0.id == modelId }) {
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ struct ModelConfig: Identifiable, Hashable {
|
|||||||
supportsImages: false,
|
supportsImages: false,
|
||||||
supportsTools: 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]
|
static let `default` = availableModels[0]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ final class ChatViewModel {
|
|||||||
private(set) var lastSavedSnapshotHash: String?
|
private(set) var lastSavedSnapshotHash: String?
|
||||||
|
|
||||||
private var generationTask: Task<Void, Never>?
|
private var generationTask: Task<Void, Never>?
|
||||||
|
private var autosaveTask: Task<Void, Never>?
|
||||||
private var chatSession: ChatSession?
|
private var chatSession: ChatSession?
|
||||||
private var documentId = UUID()
|
private var documentId = UUID()
|
||||||
private var documentCreatedAt = Date()
|
private var documentCreatedAt = Date()
|
||||||
@@ -210,6 +211,7 @@ final class ChatViewModel {
|
|||||||
resetSession()
|
resetSession()
|
||||||
resetDocumentState()
|
resetDocumentState()
|
||||||
Preferences.lastSceneId = nil
|
Preferences.lastSceneId = nil
|
||||||
|
scheduleAutosaveIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startNewConversation(scene: ChatScene?) async {
|
func startNewConversation(scene: ChatScene?) async {
|
||||||
@@ -248,6 +250,8 @@ final class ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadDocument(from url: URL) async throws {
|
func loadDocument(from url: URL) async throws {
|
||||||
|
autosaveTask?.cancel()
|
||||||
|
|
||||||
let package = try ChatDocumentPackage(contentsOf: url)
|
let package = try ChatDocumentPackage(contentsOf: url)
|
||||||
let restoredMessages = try package.manifest.messages.map { storedMessage in
|
let restoredMessages = try package.manifest.messages.map { storedMessage in
|
||||||
try restoreMessage(storedMessage, attachmentContents: package.attachmentContents)
|
try restoreMessage(storedMessage, attachmentContents: package.attachmentContents)
|
||||||
@@ -281,11 +285,13 @@ final class ChatViewModel {
|
|||||||
throw ChatDocumentError.saveWhileGenerating
|
throw ChatDocumentError.saveWhileGenerating
|
||||||
}
|
}
|
||||||
|
|
||||||
|
autosaveTask?.cancel()
|
||||||
let package = try makeDocumentPackage(updatedAt: Date())
|
let package = try makeDocumentPackage(updatedAt: Date())
|
||||||
try package.write(to: url)
|
try package.write(to: url)
|
||||||
currentDocumentURL = url
|
currentDocumentURL = url
|
||||||
lastSavedSnapshotHash = try snapshotHash()
|
lastSavedSnapshotHash = try snapshotHash()
|
||||||
hasUnsavedChanges = false
|
hasUnsavedChanges = false
|
||||||
|
Self.removeAutosave()
|
||||||
}
|
}
|
||||||
|
|
||||||
func markDirtyIfNeeded() {
|
func markDirtyIfNeeded() {
|
||||||
@@ -296,6 +302,8 @@ final class ChatViewModel {
|
|||||||
|| !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|| !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|| activeScene != nil
|
|| activeScene != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleAutosaveIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetDocumentState() {
|
private func resetDocumentState() {
|
||||||
@@ -451,6 +459,102 @@ final class ChatViewModel {
|
|||||||
return encoder
|
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
|
// MARK: - API Server
|
||||||
|
|
||||||
func startAPIServer() {
|
func startAPIServer() {
|
||||||
|
|||||||
Reference in New Issue
Block a user