From f0db0c093866a53b990edc92861728ddc09f7559 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 18 Mar 2026 15:29:02 +0100 Subject: [PATCH] feat: document saving/loading --- MLXServer.xcodeproj/project.pbxproj | 36 ++- MLXServer/Commands/SaveChatCommands.swift | 44 ++- MLXServer/ContentView.swift | 196 ++++++++++++- .../Documents/ChatDocumentController.swift | 25 ++ .../Documents/ChatDocumentManifest.swift | 73 +++++ .../Documents/ChatDocumentMigration.swift | 19 ++ MLXServer/Documents/ChatDocumentPackage.swift | 146 ++++++++++ MLXServer/Info.plist | 66 +++++ MLXServer/MLXServerApp.swift | 12 + MLXServer/Models/ChatMessage.swift | 187 ++++++++++--- MLXServer/Utilities/FocusedValues.swift | 55 +++- MLXServer/ViewModels/ChatViewModel.swift | 264 +++++++++++++++++- MLXServer/Views/ChatMessagesView.swift | 16 +- README.md | 12 +- project.yml | 5 +- 15 files changed, 1086 insertions(+), 70 deletions(-) create mode 100644 MLXServer/Documents/ChatDocumentController.swift create mode 100644 MLXServer/Documents/ChatDocumentManifest.swift create mode 100644 MLXServer/Documents/ChatDocumentMigration.swift create mode 100644 MLXServer/Documents/ChatDocumentPackage.swift create mode 100644 MLXServer/Info.plist diff --git a/MLXServer.xcodeproj/project.pbxproj b/MLXServer.xcodeproj/project.pbxproj index c552992..0597bbe 100644 --- a/MLXServer.xcodeproj/project.pbxproj +++ b/MLXServer.xcodeproj/project.pbxproj @@ -11,10 +11,12 @@ 07119250A7F9D6ECE7F6B8FD /* SceneCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F03A123A8908714A89315FE /* SceneCommands.swift */; }; 165E8AB6ADAE1D59B1A86420 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145B888FBDD4F931512C5473 /* Preferences.swift */; }; 189362AAE2CDE5D4B3428334 /* ToolCallParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E73B165A1822729C907791AE /* ToolCallParser.swift */; }; + 1A8833E3CCD3289C95E282A2 /* ChatDocumentManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1607BDDE53C575627DCC6896 /* ChatDocumentManifest.swift */; }; 20FFB5DBF75AA6C359AAE31C /* SceneManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEB592E5E717F817B03151 /* SceneManagementView.swift */; }; 29879D696584B96CC56560DF /* ChatExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */; }; 2CAAF7129F7CC45200FA9F6B /* ModelPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */; }; 2D08769282BD71C170DB0943 /* InferenceStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = E35452B166893B25E765FF70 /* InferenceStats.swift */; }; + 2E3A02DF9C6A5109E532D5E2 /* ChatDocumentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1FCEFEA72B9ABB87FB20E /* ChatDocumentController.swift */; }; 4158FA884D981D73288FB74C /* SaveChatCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */; }; 4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E6AD02CDF23BDAB64700A7 /* ChatInputView.swift */; }; 4DC033E45880B2948B47DEB1 /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF518FEBF3A38E830E3CE1A5 /* FocusedValues.swift */; }; @@ -29,10 +31,12 @@ 84D32315B418B5243E017350 /* ToolPromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */; }; 85FB1EB49D76A9F21E181346 /* ChatScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */; }; 945474365D0B3E961811909A /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = D5E8E1C2DD8D8AABB4306193 /* MLXVLM */; }; + B13FFE238613BFBFC72E0CC8 /* ChatDocumentMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */; }; B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4239CFF94B819C35A8D4D617 /* MonitorView.swift */; }; B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */; }; B6D3662995B885C102876B4A /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 9090667D4134056AE66DC2F1 /* MLXLMCommon */; }; C07A377244DCD67F4FE709FE /* DownloadModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */; }; + C34F02550C584BB2547F0F6C /* ChatDocumentPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.swift */; }; CBA88529F8BE7BD0518994AD /* SceneSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B5ABDEB6F5C54856EB1A9E /* SceneSelectionView.swift */; }; CFEE79815DFB80E51FE3745A /* SceneStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C234359924C542F07ED926A2 /* SceneStore.swift */; }; D666A311788375E8A061C832 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4147321383E94E9F17A0154E /* SettingsView.swift */; }; @@ -46,15 +50,19 @@ /* Begin PBXFileReference section */ 0F03A123A8908714A89315FE /* SceneCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneCommands.swift; sourceTree = ""; }; 145B888FBDD4F931512C5473 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; + 1607BDDE53C575627DCC6896 /* ChatDocumentManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentManifest.swift; sourceTree = ""; }; 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolPromptBuilder.swift; sourceTree = ""; }; + 24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentMigration.swift; sourceTree = ""; }; 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModalView.swift; sourceTree = ""; }; 2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChatCommands.swift; sourceTree = ""; }; 37FEB592E5E717F817B03151 /* SceneManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneManagementView.swift; sourceTree = ""; }; + 386CD08DC6338F42460DFBE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelConfig.swift; sourceTree = ""; }; 3AF462805202797F61422AEE /* MLXServer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MLXServer.entitlements; sourceTree = ""; }; 3D08828E16B17EF02C14243E /* APIServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServer.swift; sourceTree = ""; }; 4147321383E94E9F17A0154E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 4239CFF94B819C35A8D4D617 /* MonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorView.swift; sourceTree = ""; }; + 6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentPackage.swift; sourceTree = ""; }; 6EE59189918D06B8D2F588FC /* MLXServer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLXServer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 922CBDC9206737BD04AF2874 /* ModelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelManager.swift; sourceTree = ""; }; 944C699FBB76C734C9DF2F2E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -68,6 +76,7 @@ C234359924C542F07ED926A2 /* SceneStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneStore.swift; sourceTree = ""; }; C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelPickerView.swift; sourceTree = ""; }; C67742651DB486871CEF1612 /* MLXServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXServerApp.swift; sourceTree = ""; }; + D5C1FCEFEA72B9ABB87FB20E /* ChatDocumentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentController.swift; sourceTree = ""; }; D733A0D1D4AC25DDDA6C8684 /* LocalModelResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalModelResolver.swift; sourceTree = ""; }; D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExporter.swift; sourceTree = ""; }; DB1A5E8B1C9F2BC4D262C53A /* ChatMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessagesView.swift; sourceTree = ""; }; @@ -104,6 +113,17 @@ path = Utilities; sourceTree = ""; }; + 08DD6175C845EFB9638A0688 /* Documents */ = { + isa = PBXGroup; + children = ( + D5C1FCEFEA72B9ABB87FB20E /* ChatDocumentController.swift */, + 1607BDDE53C575627DCC6896 /* ChatDocumentManifest.swift */, + 24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */, + 6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.swift */, + ); + path = Documents; + sourceTree = ""; + }; 652987C2A419DBFC79E32CDE /* Products */ = { isa = PBXGroup; children = ( @@ -117,9 +137,11 @@ children = ( B629DA084A9A40E54F8EA5FA /* Assets.xcassets */, 944C699FBB76C734C9DF2F2E /* ContentView.swift */, + 386CD08DC6338F42460DFBE2 /* Info.plist */, 3AF462805202797F61422AEE /* MLXServer.entitlements */, C67742651DB486871CEF1612 /* MLXServerApp.swift */, B459409ED6FD8797FDD81E94 /* Commands */, + 08DD6175C845EFB9638A0688 /* Documents */, BD0E350482D91238B4B59721 /* Models */, E13C1AAA0C49D0ED85EFD94D /* Server */, 05B1BAE308E64D2FB2E73823 /* Utilities */, @@ -273,6 +295,10 @@ files = ( D96DDE66F76FDDA642629E17 /* APIModels.swift in Sources */, 50DD129CCF2843482DEC3B96 /* APIServer.swift in Sources */, + 2E3A02DF9C6A5109E532D5E2 /* ChatDocumentController.swift in Sources */, + 1A8833E3CCD3289C95E282A2 /* ChatDocumentManifest.swift in Sources */, + B13FFE238613BFBFC72E0CC8 /* ChatDocumentMigration.swift in Sources */, + C34F02550C584BB2547F0F6C /* ChatDocumentPackage.swift in Sources */, 29879D696584B96CC56560DF /* ChatExporter.swift in Sources */, 4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */, FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */, @@ -434,9 +460,8 @@ CODE_SIGN_IDENTITY = "-"; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MLXServer/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -459,9 +484,8 @@ CODE_SIGN_IDENTITY = "-"; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MLXServer/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/MLXServer/Commands/SaveChatCommands.swift b/MLXServer/Commands/SaveChatCommands.swift index e08006a..4080741 100644 --- a/MLXServer/Commands/SaveChatCommands.swift +++ b/MLXServer/Commands/SaveChatCommands.swift @@ -1,15 +1,53 @@ import SwiftUI -/// Adds "Export Chat…" to the File menu. struct SaveChatCommands: Commands { + @FocusedValue(\.newChatAction) private var newChatAction + @FocusedValue(\.openChatAction) private var openChatAction + @FocusedValue(\.saveChatAction) private var saveChatAction + @FocusedValue(\.saveChatAsAction) private var saveChatAsAction + @FocusedValue(\.revertChatAction) private var revertChatAction @FocusedValue(\.exportChatAction) private var exportChatAction var body: some Commands { - CommandGroup(after: .saveItem) { + CommandGroup(replacing: .newItem) { + Button("New Chat") { + newChatAction?() + } + .keyboardShortcut("n", modifiers: .command) + + Button("Open Chat…") { + openChatAction?() + } + .keyboardShortcut("o", modifiers: .command) + .disabled(openChatAction == nil) + } + + CommandGroup(replacing: .saveItem) { + Button("Save Chat") { + saveChatAction?() + } + .keyboardShortcut("s", modifiers: .command) + .disabled(saveChatAction == nil) + + Button("Save Chat As…") { + saveChatAsAction?() + } + .keyboardShortcut("s", modifiers: [.command, .shift]) + .disabled(saveChatAsAction == nil) + + Divider() + + Button("Revert To Saved") { + revertChatAction?() + } + .disabled(revertChatAction == nil) + + Divider() + Button("Export Chat…") { exportChatAction?() } - .keyboardShortcut("s", modifiers: [.command, .shift]) + .keyboardShortcut("e", modifiers: [.command, .shift]) .disabled(exportChatAction == nil) } } diff --git a/MLXServer/ContentView.swift b/MLXServer/ContentView.swift index fe94943..7315ad0 100644 --- a/MLXServer/ContentView.swift +++ b/MLXServer/ContentView.swift @@ -1,7 +1,9 @@ +import AppKit import SwiftUI import UniformTypeIdentifiers struct ContentView: View { + @Environment(ChatDocumentController.self) private var documentController @Environment(ModelManager.self) private var modelManager @Environment(\.openWindow) private var openWindow @Environment(SceneStore.self) private var sceneStore @@ -10,11 +12,16 @@ struct ContentView: View { @State private var showMonitor = false @State private var showScenePicker = false @State private var exportDocument: ChatExportDocument? + @State private var documentErrorMessage: String? @State private var exportErrorMessage: String? var body: some View { - mainContent - .navigationTitle(modelManager.currentModel?.displayName ?? "MLX Server") + exportedContent + } + + private var lifecycleContent: some View { + AnyView(mainContent) + .navigationTitle(navigationTitleText) .onAppear { if chatVM == nil { chatVM = ChatViewModel(modelManager: modelManager) @@ -23,17 +30,30 @@ struct ContentView: View { chatVM?.startAPIServer() } } + + processPendingOpenRequests() } .onChange(of: modelManager.currentModel) { chatVM?.handleModelChange() + chatVM?.markDirtyIfNeeded() // Persist last used model if let id = modelManager.currentModel?.id { Preferences.lastModelId = id } } + .onChange(of: chatVM?.inputText ?? "") { + chatVM?.markDirtyIfNeeded() + } .onChange(of: modelManager.errorMessage) { showLoadError = modelManager.errorMessage != nil } + .onChange(of: documentController.openRequestNonce) { + processPendingOpenRequests() + } + } + + private var alertContent: some View { + AnyView(lifecycleContent) .alert("Model Error", isPresented: $showLoadError) { Button("Retry") { if let config = modelManager.currentModel ?? ModelConfig.availableModels.first { @@ -46,6 +66,13 @@ struct ContentView: View { } message: { Text(modelManager.errorMessage ?? "Unknown error loading model.") } + .alert("Document Error", isPresented: documentErrorBinding) { + Button("OK", role: .cancel) { + documentErrorMessage = nil + } + } message: { + Text(documentErrorMessage ?? "Unknown document error.") + } .alert("Export Failed", isPresented: exportErrorBinding) { Button("OK", role: .cancel) { exportErrorMessage = nil @@ -53,6 +80,10 @@ struct ContentView: View { } message: { Text(exportErrorMessage ?? "Unknown export error.") } + } + + private var exportedContent: some View { + AnyView(alertContent) .toolbar { ToolbarItem(placement: .principal) { ModelPickerView() @@ -65,6 +96,11 @@ struct ContentView: View { .background { modelSwitchShortcuts } + .focusedSceneValue(\.newChatAction, NewChatAction(perform: beginNewChat)) + .focusedSceneValue(\.openChatAction, OpenChatAction(perform: beginOpenDocument)) + .focusedSceneValue(\.saveChatAction, SaveChatAction(perform: saveCurrentDocument)) + .focusedSceneValue(\.saveChatAsAction, SaveChatAsAction(perform: saveCurrentDocumentAs)) + .focusedSceneValue(\.revertChatAction, RevertChatAction(perform: beginRevertToSaved)) .focusedSceneValue(\.exportChatAction, ExportChatAction(perform: beginExport)) .fileExporter( isPresented: Binding( @@ -87,6 +123,13 @@ struct ContentView: View { } } + private var navigationTitleText: String { + if let title = chatVM?.windowTitle { + return title + } + return modelManager.currentModel?.displayName ?? "MLX Server" + } + @ViewBuilder private var mainContent: some View { ZStack { @@ -145,7 +188,7 @@ struct ContentView: View { // New conversation Button { - showScenePicker = true + beginNewChat() } label: { Label("New Chat", systemImage: "plus.message") } @@ -157,11 +200,11 @@ struct ContentView: View { currentModelName: modelManager.currentModel?.displayName, onSelectNeutral: { showScenePicker = false - Task { await chatVM?.startNewConversation(scene: nil) } + startConversation(scene: nil) }, onSelectScene: { scene in showScenePicker = false - Task { await chatVM?.startNewConversation(scene: scene) } + startConversation(scene: scene) }, onManageScenes: { showScenePicker = false @@ -196,6 +239,10 @@ struct ContentView: View { } private var exportDefaultFilename: String { + if let currentDocumentURL = chatVM?.currentDocumentURL { + return currentDocumentURL.deletingPathExtension().lastPathComponent + } + let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd-HHmm" return "chat-\(formatter.string(from: .now))" @@ -208,6 +255,145 @@ struct ContentView: View { modelName: modelManager.currentModel?.displayName ) } + + private var documentDefaultFilename: String { + if let currentDocumentURL = chatVM?.currentDocumentURL { + return currentDocumentURL.deletingPathExtension().lastPathComponent + } + return exportDefaultFilename + } + + private var documentErrorBinding: Binding { + Binding( + get: { documentErrorMessage != nil }, + set: { + if !$0 { + documentErrorMessage = nil + } + } + ) + } + + private func beginNewChat() { + showScenePicker = true + } + + private func startConversation(scene: ChatScene?) { + guard confirmDiscardUnsavedChanges( + title: "Discard Unsaved Changes?", + message: "Starting a new chat will replace the current conversation." + ) else { + return + } + + Task { + await chatVM?.startNewConversation(scene: scene) + } + } + + private func beginOpenDocument() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.mlxChatDocument] + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.treatsFilePackagesAsDirectories = false + + guard panel.runModal() == .OK, let url = panel.url else { return } + Task { + await openDocument(at: url) + } + } + + private func saveCurrentDocument() { + guard let chatVM else { return } + + if let currentDocumentURL = chatVM.currentDocumentURL { + do { + try chatVM.saveDocument(to: currentDocumentURL) + } catch { + documentErrorMessage = error.localizedDescription + } + } else { + saveCurrentDocumentAs() + } + } + + private func saveCurrentDocumentAs() { + guard let chatVM else { return } + + let panel = NSSavePanel() + panel.allowedContentTypes = [.mlxChatDocument] + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.nameFieldStringValue = documentDefaultFilename + + guard panel.runModal() == .OK, let panelURL = panel.url else { return } + + let saveURL: URL + if panelURL.pathExtension.lowercased() == "mlxchat" { + saveURL = panelURL + } else { + saveURL = panelURL.appendingPathExtension("mlxchat") + } + + do { + try chatVM.saveDocument(to: saveURL) + } catch { + documentErrorMessage = error.localizedDescription + } + } + + private func beginRevertToSaved() { + guard let currentDocumentURL = chatVM?.currentDocumentURL else { return } + guard confirmDiscardUnsavedChanges( + title: "Revert To Saved Version?", + message: "All unsaved changes in the current chat will be lost." + ) else { + return + } + + Task { + await openDocument(at: currentDocumentURL, skipUnsavedCheck: true) + } + } + + private func processPendingOpenRequests() { + guard chatVM != nil else { return } + + Task { + while let url = documentController.consumeNextOpenRequest() { + await openDocument(at: url) + } + } + } + + private func openDocument(at url: URL, skipUnsavedCheck: Bool = false) async { + if !skipUnsavedCheck { + let shouldContinue = confirmDiscardUnsavedChanges( + title: "Discard Unsaved Changes?", + message: "Opening another chat will replace the current conversation." + ) + guard shouldContinue else { return } + } + + do { + try await chatVM?.loadDocument(from: url) + } catch { + documentErrorMessage = error.localizedDescription + } + } + + private func confirmDiscardUnsavedChanges(title: String, message: String) -> Bool { + guard chatVM?.hasUnsavedChanges == true else { return true } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "Discard Changes") + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + } } /// The main chat layout: messages + input area + status bar. diff --git a/MLXServer/Documents/ChatDocumentController.swift b/MLXServer/Documents/ChatDocumentController.swift new file mode 100644 index 0000000..8d41022 --- /dev/null +++ b/MLXServer/Documents/ChatDocumentController.swift @@ -0,0 +1,25 @@ +import Foundation + +@Observable +@MainActor +final class ChatDocumentController { + static let shared = ChatDocumentController() + + private(set) var pendingOpenURLs: [URL] = [] + private(set) var openRequestNonce = UUID() + + func enqueueOpenRequests(_ urls: [URL]) { + guard !urls.isEmpty else { return } + pendingOpenURLs.append(contentsOf: urls) + openRequestNonce = UUID() + } + + func consumeNextOpenRequest() -> URL? { + guard !pendingOpenURLs.isEmpty else { return nil } + return pendingOpenURLs.removeFirst() + } + + var hasPendingOpenRequests: Bool { + !pendingOpenURLs.isEmpty + } +} diff --git a/MLXServer/Documents/ChatDocumentManifest.swift b/MLXServer/Documents/ChatDocumentManifest.swift new file mode 100644 index 0000000..7a61e6d --- /dev/null +++ b/MLXServer/Documents/ChatDocumentManifest.swift @@ -0,0 +1,73 @@ +import Foundation + +struct ChatDocumentManifest: Codable { + var schemaVersion: Int + var documentId: UUID + var createdAt: Date + var updatedAt: Date + var appVersion: String + var model: StoredModelInfo? + var settings: StoredChatSettings + var messages: [StoredChatMessage] + var uiState: StoredChatUIState + + static let currentSchemaVersion = 1 + + struct StoredModelInfo: Codable, Hashable { + var id: String + var displayName: String + var repoId: String + } + + struct StoredChatSettings: Codable, Hashable { + var systemPrompt: String + var thinkingEnabled: Bool + var temperature: Double + } + + struct StoredChatUIState: Codable, Hashable { + var draftInput: String + var scrollAnchorMessageId: UUID? + } + + struct StoredChatMessage: Codable, Hashable, Identifiable { + enum Role: String, Codable { + case system + case user + case assistant + } + + enum StreamingState: String, Codable { + case streaming + case completed + } + + var id: UUID + var role: Role + var createdAt: Date + var content: String + var rawContent: String + var thinkingContent: String + var streamingState: StreamingState + var attachments: [StoredAttachment] + } + + struct StoredAttachment: Codable, Hashable, Identifiable { + var id: UUID + var type: String + var relativePath: String + var mimeType: String + var pixelWidth: Int? + var pixelHeight: Int? + var sha256: String + } +} + +struct ChatDocumentSnapshot: Codable, Hashable { + var documentId: UUID + var createdAt: Date + var model: ChatDocumentManifest.StoredModelInfo? + var settings: ChatDocumentManifest.StoredChatSettings + var messages: [ChatDocumentManifest.StoredChatMessage] + var uiState: ChatDocumentManifest.StoredChatUIState +} diff --git a/MLXServer/Documents/ChatDocumentMigration.swift b/MLXServer/Documents/ChatDocumentMigration.swift new file mode 100644 index 0000000..d7b0782 --- /dev/null +++ b/MLXServer/Documents/ChatDocumentMigration.swift @@ -0,0 +1,19 @@ +import Foundation + +enum ChatDocumentMigration { + private struct ManifestEnvelope: Decodable { + let schemaVersion: Int + } + + static func loadManifest(from data: Data) throws -> ChatDocumentManifest { + let decoder = JSONDecoder.chatDocumentDecoder + let envelope = try decoder.decode(ManifestEnvelope.self, from: data) + + switch envelope.schemaVersion { + case 1: + return try decoder.decode(ChatDocumentManifest.self, from: data) + default: + throw ChatDocumentError.unsupportedSchemaVersion(envelope.schemaVersion) + } + } +} diff --git a/MLXServer/Documents/ChatDocumentPackage.swift b/MLXServer/Documents/ChatDocumentPackage.swift new file mode 100644 index 0000000..9f16663 --- /dev/null +++ b/MLXServer/Documents/ChatDocumentPackage.swift @@ -0,0 +1,146 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +extension UTType { + static let mlxChatDocument = UTType(exportedAs: "de.rfc1437.mlxserver.chat", conformingTo: .package) +} + +enum ChatDocumentError: LocalizedError { + case invalidPackage + case missingManifest + case missingAttachment(String) + case invalidAttachmentData(String) + case unsupportedSchemaVersion(Int) + case saveWhileGenerating + + var errorDescription: String? { + switch self { + case .invalidPackage: + return "The selected file is not a valid MLX Server chat document." + case .missingManifest: + return "The chat document is missing manifest.json." + case .missingAttachment(let path): + return "The chat document is missing attachment \(path)." + case .invalidAttachmentData(let path): + return "The attachment \(path) could not be decoded as an image." + case .unsupportedSchemaVersion(let version): + return "This chat document uses unsupported schema version \(version)." + case .saveWhileGenerating: + return "Stop generation before saving this chat document." + } + } +} + +struct ChatDocumentPackage: FileDocument { + static var readableContentTypes: [UTType] { [.mlxChatDocument] } + static var writableContentTypes: [UTType] { [.mlxChatDocument] } + + let manifest: ChatDocumentManifest + let attachmentContents: [String: Data] + + init(manifest: ChatDocumentManifest, attachmentContents: [String: Data]) { + self.manifest = manifest + self.attachmentContents = attachmentContents + } + + init(contentsOf url: URL) throws { + let wrapper = try FileWrapper(url: url, options: .immediate) + try self.init(rootWrapper: wrapper) + } + + init(configuration: ReadConfiguration) throws { + try self.init(rootWrapper: configuration.file) + } + + private init(rootWrapper: FileWrapper) throws { + guard let fileWrappers = rootWrapper.fileWrappers else { + throw ChatDocumentError.invalidPackage + } + + guard let manifestWrapper = fileWrappers["manifest.json"], + let manifestData = manifestWrapper.regularFileContents else { + throw ChatDocumentError.missingManifest + } + + let manifest = try ChatDocumentMigration.loadManifest(from: manifestData) + var attachmentContents: [String: Data] = [:] + + for message in manifest.messages { + for attachment in message.attachments { + if attachmentContents[attachment.relativePath] != nil { + continue + } + + let pathComponents = attachment.relativePath.split(separator: "/").map(String.init) + guard let attachmentData = Self.data(at: pathComponents, from: fileWrappers) else { + throw ChatDocumentError.missingAttachment(attachment.relativePath) + } + attachmentContents[attachment.relativePath] = attachmentData + } + } + + self.manifest = manifest + self.attachmentContents = attachmentContents + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + try makeFileWrapper() + } + + func write(to url: URL) throws { + let wrapper = try makeFileWrapper() + let fileManager = FileManager.default + let options = FileWrapper.WritingOptions.atomic + if fileManager.fileExists(atPath: url.path) { + try wrapper.write(to: url, options: options, originalContentsURL: url) + } else { + try wrapper.write(to: url, options: options, originalContentsURL: nil) + } + } + + private func makeFileWrapper() throws -> FileWrapper { + var wrappers: [String: FileWrapper] = [:] + let manifestData = try JSONEncoder.chatDocumentEncoder.encode(manifest) + wrappers["manifest.json"] = FileWrapper(regularFileWithContents: manifestData) + + var attachmentWrappers: [String: FileWrapper] = [:] + for (relativePath, data) in attachmentContents { + let pathComponents = relativePath.split(separator: "/").map(String.init) + guard pathComponents.count == 2, pathComponents.first == "attachments" else { continue } + attachmentWrappers[pathComponents[1]] = FileWrapper(regularFileWithContents: data) + } + + wrappers["attachments"] = FileWrapper(directoryWithFileWrappers: attachmentWrappers) + return FileWrapper(directoryWithFileWrappers: wrappers) + } + + private static func data(at pathComponents: [String], from wrappers: [String: FileWrapper]) -> Data? { + guard let first = pathComponents.first else { return nil } + guard let wrapper = wrappers[first] else { return nil } + + if pathComponents.count == 1 { + return wrapper.regularFileContents + } + + guard let childWrappers = wrapper.fileWrappers else { return nil } + return data(at: Array(pathComponents.dropFirst()), from: childWrappers) + } +} + +private extension JSONEncoder { + static var chatDocumentEncoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } +} + +extension JSONDecoder { + static var chatDocumentDecoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/MLXServer/Info.plist b/MLXServer/Info.plist new file mode 100644 index 0000000..f48c807 --- /dev/null +++ b/MLXServer/Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDocumentTypes + + + CFBundleTypeName + MLX Server Chat Document + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSTypeIsPackage + + LSItemContentTypes + + de.rfc1437.mlxserver.chat + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + com.apple.package + public.data + + UTTypeDescription + MLX Server Chat Document + UTTypeIdentifier + de.rfc1437.mlxserver.chat + UTTypeTagSpecification + + public.filename-extension + + mlxchat + + + + + + diff --git a/MLXServer/MLXServerApp.swift b/MLXServer/MLXServerApp.swift index 9c1f926..87764eb 100644 --- a/MLXServer/MLXServerApp.swift +++ b/MLXServer/MLXServerApp.swift @@ -1,8 +1,18 @@ import SwiftUI import MLX +final class AppDelegate: NSObject, NSApplicationDelegate { + func application(_ application: NSApplication, open urls: [URL]) { + Task { @MainActor in + ChatDocumentController.shared.enqueueOpenRequests(urls) + } + } +} + @main struct MLXServerApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @State private var documentController = ChatDocumentController.shared @State private var modelManager = ModelManager() @State private var sceneStore = SceneStore() @@ -13,9 +23,11 @@ struct MLXServerApp: App { var body: some Scene { WindowGroup { ContentView() + .environment(documentController) .environment(modelManager) .environment(sceneStore) .task { + guard !documentController.hasPendingOpenRequests 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/ChatMessage.swift b/MLXServer/Models/ChatMessage.swift index 1ff5b28..89415a1 100644 --- a/MLXServer/Models/ChatMessage.swift +++ b/MLXServer/Models/ChatMessage.swift @@ -1,12 +1,89 @@ import AppKit +import CryptoKit import Foundation +import MLXLMCommon +import UniformTypeIdentifiers + +struct ChatAttachment: Identifiable, Hashable { + let id: UUID + let data: Data + let mimeType: String + let pixelWidth: Int? + let pixelHeight: Int? + let sha256: String + + init?( + id: UUID = UUID(), + data: Data, + mimeType: String, + pixelWidth: Int? = nil, + pixelHeight: Int? = nil, + sha256: String? = nil + ) { + guard NSImage(data: data) != nil else { return nil } + + self.id = id + self.data = data + self.mimeType = mimeType + + let dimensions = Self.resolveDimensions(from: data) + self.pixelWidth = pixelWidth ?? dimensions.width + self.pixelHeight = pixelHeight ?? dimensions.height + self.sha256 = sha256 ?? Self.sha256Hex(for: data) + } + + init?(id: UUID = UUID(), image: NSImage) { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) else { + return nil + } + + self.init( + id: id, + data: pngData, + mimeType: "image/png", + pixelWidth: bitmap.pixelsWide, + pixelHeight: bitmap.pixelsHigh + ) + } + + var fileExtension: String { + UTType(mimeType: mimeType)?.preferredFilenameExtension ?? "bin" + } + + var image: NSImage? { + NSImage(data: data) + } + + var userInputImage: UserInput.Image? { + guard let image, + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + return .ciImage(CIImage(cgImage: cgImage)) + } + + private static func resolveDimensions(from data: Data) -> (width: Int?, height: Int?) { + guard let image = NSImage(data: data), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return (nil, nil) + } + + return (cgImage.width, cgImage.height) + } + + private static func sha256Hex(for data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} /// A single message in the chat conversation. struct ChatMessage: Identifiable { - let id = UUID() + let id: UUID let role: Role var content: String - var images: [NSImage] + var attachments: [ChatAttachment] var isStreaming: Bool let timestamp: Date @@ -26,43 +103,53 @@ struct ChatMessage: Identifiable { case assistant } - init(role: Role, content: String, images: [NSImage] = [], isStreaming: Bool = false) { + init( + id: UUID = UUID(), + role: Role, + content: String, + attachments: [ChatAttachment] = [], + isStreaming: Bool = false, + timestamp: Date = Date(), + rawContent: String? = nil, + thinkingContent: String? = nil, + isThinking: Bool = false + ) { + self.id = id self.role = role self.content = content - self.rawContent = content - self.images = images + self.rawContent = rawContent ?? content + self.attachments = attachments self.isStreaming = isStreaming - self.timestamp = Date() - } -} + self.timestamp = timestamp + self.thinkingContent = thinkingContent ?? "" + self.isThinking = isThinking -/// Observable conversation state holding all messages. -@Observable -@MainActor -final class Conversation { - var messages: [ChatMessage] = [] - - func addUserMessage(_ text: String, images: [NSImage] = []) { - messages.append(ChatMessage(role: .user, content: text, images: images)) + if role == .assistant, rawContent != nil { + applyParsedContent(Self.parseAssistantContent(self.rawContent)) + } } - /// Adds an empty assistant message (to be filled via streaming) and returns its index. - func addAssistantMessage() -> Int { - let msg = ChatMessage(role: .assistant, content: "", isStreaming: true) - messages.append(msg) - return messages.count - 1 + var sessionContent: String { + role == .assistant ? rawContent : content } - /// Appends a text chunk to the assistant message at the given index. - /// Handles `...` tags by routing content to `thinkingContent` vs `content`. - func appendToMessage(at index: Int, chunk: String) { - guard index < messages.count else { return } - messages[index].rawContent += chunk + mutating func refreshAssistantContentFromRaw() { + applyParsedContent(Self.parseAssistantContent(rawContent)) + } - // Parse the full raw content to separate thinking from response. - // This is simpler and more robust than incremental parsing since - // tag boundaries can split across chunks. - let raw = messages[index].rawContent + private mutating func applyParsedContent(_ parsed: ParsedAssistantContent) { + thinkingContent = parsed.thinking + content = parsed.visible + isThinking = parsed.isInThinkingBlock + } + + private struct ParsedAssistantContent { + let visible: String + let thinking: String + let isInThinkingBlock: Bool + } + + private static func parseAssistantContent(_ raw: String) -> ParsedAssistantContent { var thinking = "" var visible = "" var isInThink = false @@ -75,7 +162,6 @@ final class Conversation { scanner = scanner[endRange.upperBound...] isInThink = false } else { - // Still inside thinking — all remaining text is thinking thinking += String(scanner) break } @@ -91,9 +177,38 @@ final class Conversation { } } - messages[index].thinkingContent = thinking.trimmingCharacters(in: .whitespacesAndNewlines) - messages[index].content = visible.trimmingCharacters(in: .whitespacesAndNewlines) - messages[index].isThinking = isInThink + return ParsedAssistantContent( + visible: visible.trimmingCharacters(in: .whitespacesAndNewlines), + thinking: thinking.trimmingCharacters(in: .whitespacesAndNewlines), + isInThinkingBlock: isInThink + ) + } +} + +/// Observable conversation state holding all messages. +@Observable +@MainActor +final class Conversation { + var messages: [ChatMessage] = [] + + func addUserMessage(_ text: String, images: [NSImage] = []) { + let attachments = images.compactMap { ChatAttachment(image: $0) } + messages.append(ChatMessage(role: .user, content: text, attachments: attachments)) + } + + /// Adds an empty assistant message (to be filled via streaming) and returns its index. + func addAssistantMessage() -> Int { + let msg = ChatMessage(role: .assistant, content: "", isStreaming: true) + messages.append(msg) + return messages.count - 1 + } + + /// Appends a text chunk to the assistant message at the given index. + /// Handles `...` tags by routing content to `thinkingContent` vs `content`. + func appendToMessage(at index: Int, chunk: String) { + guard index < messages.count else { return } + messages[index].rawContent += chunk + messages[index].refreshAssistantContentFromRaw() } /// Marks the assistant message at the given index as done streaming. @@ -106,4 +221,8 @@ final class Conversation { func clear() { messages.removeAll() } + + func replaceMessages(_ restoredMessages: [ChatMessage]) { + messages = restoredMessages + } } diff --git a/MLXServer/Utilities/FocusedValues.swift b/MLXServer/Utilities/FocusedValues.swift index 36eb15b..c396970 100644 --- a/MLXServer/Utilities/FocusedValues.swift +++ b/MLXServer/Utilities/FocusedValues.swift @@ -1,6 +1,6 @@ import SwiftUI -struct ExportChatAction { +struct ChatCommandAction { let perform: () -> Void func callAsFunction() { @@ -8,14 +8,65 @@ struct ExportChatAction { } } -/// Focused value key for triggering chat export from the menu bar. +typealias ExportChatAction = ChatCommandAction +typealias NewChatAction = ChatCommandAction +typealias OpenChatAction = ChatCommandAction +typealias SaveChatAction = ChatCommandAction +typealias SaveChatAsAction = ChatCommandAction +typealias RevertChatAction = ChatCommandAction + struct FocusedExportActionKey: FocusedValueKey { typealias Value = ExportChatAction } +struct FocusedNewChatActionKey: FocusedValueKey { + typealias Value = NewChatAction +} + +struct FocusedOpenChatActionKey: FocusedValueKey { + typealias Value = OpenChatAction +} + +struct FocusedSaveChatActionKey: FocusedValueKey { + typealias Value = SaveChatAction +} + +struct FocusedSaveChatAsActionKey: FocusedValueKey { + typealias Value = SaveChatAsAction +} + +struct FocusedRevertChatActionKey: FocusedValueKey { + typealias Value = RevertChatAction +} + extension FocusedValues { var exportChatAction: ExportChatAction? { get { self[FocusedExportActionKey.self] } set { self[FocusedExportActionKey.self] = newValue } } + + var newChatAction: NewChatAction? { + get { self[FocusedNewChatActionKey.self] } + set { self[FocusedNewChatActionKey.self] = newValue } + } + + var openChatAction: OpenChatAction? { + get { self[FocusedOpenChatActionKey.self] } + set { self[FocusedOpenChatActionKey.self] = newValue } + } + + var saveChatAction: SaveChatAction? { + get { self[FocusedSaveChatActionKey.self] } + set { self[FocusedSaveChatActionKey.self] = newValue } + } + + var saveChatAsAction: SaveChatAsAction? { + get { self[FocusedSaveChatAsActionKey.self] } + set { self[FocusedSaveChatAsActionKey.self] = newValue } + } + + var revertChatAction: RevertChatAction? { + get { self[FocusedRevertChatActionKey.self] } + set { self[FocusedRevertChatActionKey.self] = newValue } + } } diff --git a/MLXServer/ViewModels/ChatViewModel.swift b/MLXServer/ViewModels/ChatViewModel.swift index 2d304c4..f5eb0ea 100644 --- a/MLXServer/ViewModels/ChatViewModel.swift +++ b/MLXServer/ViewModels/ChatViewModel.swift @@ -1,4 +1,5 @@ import AppKit +import CryptoKit import Foundation import MLX import MLXLMCommon @@ -12,13 +13,22 @@ final class ChatViewModel { var inputText = "" var attachedImages: [NSImage] = [] var activeScene: ChatScene? + var currentDocumentURL: URL? + var hasUnsavedChanges = false var isGenerating = false var tokensPerSecond: Double = 0 var promptTokens: Int = 0 var generationTokens: Int = 0 + private(set) var lastSavedSnapshotHash: String? + private var generationTask: Task? private var chatSession: ChatSession? + private var documentId = UUID() + private var documentCreatedAt = Date() + private var documentSystemPromptOverride: String? + private var documentThinkingOverride: Bool? + private var documentTemperature = 0.7 let modelManager: ModelManager let apiServer = APIServer() @@ -31,6 +41,14 @@ final class ChatViewModel { activeScene?.displayName ?? "Neutral" } + var documentDisplayName: String { + currentDocumentURL?.deletingPathExtension().lastPathComponent ?? "Untitled Chat" + } + + var windowTitle: String { + hasUnsavedChanges ? "\(documentDisplayName) *" : documentDisplayName + } + /// Ensure a ChatSession exists for the current model. private func ensureSession() { guard let container = modelManager.modelContainer else { return } @@ -38,19 +56,35 @@ final class ChatViewModel { let systemPrompt = effectiveSystemPrompt // Pass enable_thinking to the Jinja chat template context. // Qwen3.5 and similar models use this to control reasoning mode. - let thinkingContext: [String: any Sendable]? = Preferences.enableThinking + let thinkingContext: [String: any Sendable]? = effectiveThinkingEnabled ? nil : ["enable_thinking": false] - chatSession = ChatSession( - container, - instructions: systemPrompt.isEmpty ? nil : systemPrompt, - generateParameters: GenerateParameters(temperature: 0.7), - additionalContext: thinkingContext - ) + let generateParameters = GenerateParameters(temperature: Float(documentTemperature)) + let history = conversation.messages.compactMap(historyMessage(from:)) + if history.isEmpty { + chatSession = ChatSession( + container, + instructions: systemPrompt.isEmpty ? nil : systemPrompt, + generateParameters: generateParameters, + additionalContext: thinkingContext + ) + } else { + chatSession = ChatSession( + container, + instructions: systemPrompt.isEmpty ? nil : systemPrompt, + history: history, + generateParameters: generateParameters, + additionalContext: thinkingContext + ) + } } } private var effectiveSystemPrompt: String { + if let documentSystemPromptOverride { + return documentSystemPromptOverride + } + let parts = [ Preferences.systemPrompt, activeScene?.systemPrompt ?? "" @@ -61,6 +95,10 @@ final class ChatViewModel { return parts.joined(separator: "\n\n") } + private var effectiveThinkingEnabled: Bool { + documentThinkingOverride ?? Preferences.enableThinking + } + func send() { let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, modelManager.isReady else { return } @@ -74,6 +112,7 @@ final class ChatViewModel { attachedImages = [] conversation.addUserMessage(text, images: images) + markDirtyIfNeeded() let assistantIndex = conversation.addAssistantMessage() isGenerating = true @@ -133,6 +172,7 @@ final class ChatViewModel { } conversation.finalizeMessage(at: assistantIndex) + markDirtyIfNeeded() isGenerating = false generationTask = nil modelManager.touchActivity() @@ -147,6 +187,7 @@ final class ChatViewModel { if let last = conversation.messages.indices.last, conversation.messages[last].isStreaming { conversation.finalizeMessage(at: last) + markDirtyIfNeeded() } } @@ -164,8 +205,10 @@ final class ChatViewModel { stop() conversation.clear() inputText = "" + attachedImages = [] activeScene = nil resetSession() + resetDocumentState() Preferences.lastSceneId = nil } @@ -182,8 +225,11 @@ final class ChatViewModel { inputText = scene?.starterPrompt.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" attachedImages = [] resetSession() + resetDocumentState() Preferences.lastSceneId = scene?.id + markDirtyIfNeeded() + if !inputText.isEmpty { send() } @@ -201,6 +247,210 @@ final class ChatViewModel { } } + func loadDocument(from url: URL) async throws { + let package = try ChatDocumentPackage(contentsOf: url) + let restoredMessages = try package.manifest.messages.map { storedMessage in + try restoreMessage(storedMessage, attachmentContents: package.attachmentContents) + } + + stop() + conversation.replaceMessages(restoredMessages) + inputText = package.manifest.uiState.draftInput + attachedImages = [] + activeScene = nil + currentDocumentURL = url + documentId = package.manifest.documentId + documentCreatedAt = package.manifest.createdAt + documentSystemPromptOverride = package.manifest.settings.systemPrompt + documentThinkingOverride = package.manifest.settings.thinkingEnabled + documentTemperature = package.manifest.settings.temperature + resetSession() + lastSavedSnapshotHash = try snapshotHash() + hasUnsavedChanges = false + Preferences.lastSceneId = nil + + if let storedModel = package.manifest.model, + let config = ModelConfig.resolve(storedModel.id) ?? ModelConfig.resolve(storedModel.repoId), + modelManager.currentModel?.id != config.id { + await modelManager.loadModel(config) + } + } + + func saveDocument(to url: URL) throws { + guard !isGenerating else { + throw ChatDocumentError.saveWhileGenerating + } + + let package = try makeDocumentPackage(updatedAt: Date()) + try package.write(to: url) + currentDocumentURL = url + lastSavedSnapshotHash = try snapshotHash() + hasUnsavedChanges = false + } + + func markDirtyIfNeeded() { + if let lastSavedSnapshotHash { + hasUnsavedChanges = (try? snapshotHash()) != lastSavedSnapshotHash + } else { + hasUnsavedChanges = !conversation.messages.isEmpty + || !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || activeScene != nil + } + } + + private func resetDocumentState() { + currentDocumentURL = nil + hasUnsavedChanges = false + lastSavedSnapshotHash = nil + documentId = UUID() + documentCreatedAt = Date() + documentSystemPromptOverride = nil + documentThinkingOverride = nil + documentTemperature = 0.7 + } + + private func restoreMessage( + _ storedMessage: ChatDocumentManifest.StoredChatMessage, + attachmentContents: [String: Data] + ) throws -> ChatMessage { + let attachments = try storedMessage.attachments.map { attachment in + guard let data = attachmentContents[attachment.relativePath] else { + throw ChatDocumentError.missingAttachment(attachment.relativePath) + } + guard let restoredAttachment = ChatAttachment( + id: attachment.id, + data: data, + mimeType: attachment.mimeType, + pixelWidth: attachment.pixelWidth, + pixelHeight: attachment.pixelHeight, + sha256: attachment.sha256 + ) else { + throw ChatDocumentError.invalidAttachmentData(attachment.relativePath) + } + return restoredAttachment + } + + return ChatMessage( + id: storedMessage.id, + role: ChatMessage.Role(rawValue: storedMessage.role.rawValue) ?? .assistant, + content: storedMessage.content, + attachments: attachments, + isStreaming: storedMessage.streamingState == .streaming, + timestamp: storedMessage.createdAt, + rawContent: storedMessage.rawContent, + thinkingContent: storedMessage.thinkingContent, + isThinking: storedMessage.streamingState == .streaming + ) + } + + private func makeDocumentPackage(updatedAt: Date) throws -> ChatDocumentPackage { + let manifest = makeManifest(updatedAt: updatedAt) + var attachmentContents: [String: Data] = [:] + + for message in conversation.messages { + for attachment in message.attachments { + attachmentContents[attachmentRelativePath(for: attachment)] = attachment.data + } + } + + return ChatDocumentPackage(manifest: manifest, attachmentContents: attachmentContents) + } + + private func makeManifest(updatedAt: Date) -> ChatDocumentManifest { + let messages = conversation.messages.map { message in + ChatDocumentManifest.StoredChatMessage( + id: message.id, + role: ChatDocumentManifest.StoredChatMessage.Role(rawValue: message.role.rawValue) ?? .assistant, + createdAt: message.timestamp, + content: message.content, + rawContent: message.rawContent, + thinkingContent: message.thinkingContent, + streamingState: message.isStreaming ? .streaming : .completed, + attachments: message.attachments.map { attachment in + ChatDocumentManifest.StoredAttachment( + id: attachment.id, + type: "image", + relativePath: attachmentRelativePath(for: attachment), + mimeType: attachment.mimeType, + pixelWidth: attachment.pixelWidth, + pixelHeight: attachment.pixelHeight, + sha256: attachment.sha256 + ) + } + ) + } + + return ChatDocumentManifest( + schemaVersion: ChatDocumentManifest.currentSchemaVersion, + documentId: documentId, + createdAt: documentCreatedAt, + updatedAt: updatedAt, + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0", + model: currentStoredModelInfo, + settings: .init( + systemPrompt: effectiveSystemPrompt, + thinkingEnabled: effectiveThinkingEnabled, + temperature: documentTemperature + ), + messages: messages, + uiState: .init( + draftInput: inputText, + scrollAnchorMessageId: conversation.messages.last?.id + ) + ) + } + + private var currentStoredModelInfo: ChatDocumentManifest.StoredModelInfo? { + guard let model = modelManager.currentModel else { return nil } + return .init(id: model.id, displayName: model.displayName, repoId: model.repoId) + } + + private func attachmentRelativePath(for attachment: ChatAttachment) -> String { + "attachments/\(attachment.id.uuidString).\(attachment.fileExtension)" + } + + private func historyMessage(from message: ChatMessage) -> Chat.Message? { + let role: Chat.Message.Role + switch message.role { + case .assistant: + role = .assistant + case .system: + return nil + case .user: + role = .user + } + + return Chat.Message( + role: role, + content: message.sessionContent, + images: message.attachments.compactMap(\.userInputImage) + ) + } + + private func snapshotHash() throws -> String { + let snapshot = ChatDocumentSnapshot( + documentId: documentId, + createdAt: documentCreatedAt, + model: currentStoredModelInfo, + settings: .init( + systemPrompt: effectiveSystemPrompt, + thinkingEnabled: effectiveThinkingEnabled, + temperature: documentTemperature + ), + messages: makeManifest(updatedAt: documentCreatedAt).messages, + uiState: .init(draftInput: inputText, scrollAnchorMessageId: conversation.messages.last?.id) + ) + let data = try Self.snapshotEncoder.encode(snapshot) + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static var snapshotEncoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + // MARK: - API Server func startAPIServer() { diff --git a/MLXServer/Views/ChatMessagesView.swift b/MLXServer/Views/ChatMessagesView.swift index 7346197..d381e69 100644 --- a/MLXServer/Views/ChatMessagesView.swift +++ b/MLXServer/Views/ChatMessagesView.swift @@ -65,14 +65,16 @@ struct MessageBubbleView: View { VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 6) { // Show attached images - if !message.images.isEmpty { + if !message.attachments.isEmpty { HStack(spacing: 4) { - ForEach(Array(message.images.enumerated()), id: \.offset) { _, image in - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 80, height: 80) - .clipShape(RoundedRectangle(cornerRadius: 8)) + ForEach(message.attachments) { attachment in + if let image = attachment.image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } } } } diff --git a/README.md b/README.md index c136daf..3497d1b 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,10 @@ open "build/Debug/MLX Server.app" - **Download progress modal** — shows file progress, percentage, and speed when downloading a new model - **Thinking mode** — models like Qwen3.5 can reason internally before responding; thinking content appears in a collapsible box. Toggle on/off in Settings. - **Streaming responses** with live token display -- **Export chat** — File > Export Chat (Cmd+Shift+S) saves conversations as Markdown or RTF (Pages-compatible) +- **Native chat documents** — save chats as `.mlxchat` package documents, reopen them from File > Open Chat or by double-clicking them in Finder, and continue the conversation with restored model context, thinking blocks, and images +- **Export chat** — File > Export Chat (Cmd+Shift+E) saves conversations as Markdown or RTF (Pages-compatible) - **Status bar** showing model name, context window, tokens/sec, token counts, GPU memory, API server status -- **Keyboard shortcuts**: `Cmd+N` (new chat), `Cmd+Return` (send), `Escape` (stop), `Cmd+1/2/3/4` (switch models), `Cmd+Shift+S` (export) +- **Keyboard shortcuts**: `Cmd+N` (new chat), `Cmd+O` (open chat document), `Cmd+S` (save chat document), `Cmd+Shift+S` (save chat document as), `Cmd+Shift+E` (export), `Cmd+Return` (send), `Escape` (stop), `Cmd+1/2/3/4` (switch models) - **Scene management** — create and edit reusable roleplay/task presets from the New Chat flow or Settings - **Settings** (`Cmd+,`): default model, thinking mode toggle, base system prompt, scene management, API port, API auto-start, idle unload timeout - **Idle auto-unload** — model is unloaded after configurable idle time (resets on both user input and model output), reloaded on next request @@ -109,7 +110,12 @@ MLXServer/ │ ├── MonitorView.swift — Inference statistics monitor │ └── SettingsView.swift — System prompt, thinking mode, API, idle settings ├── Commands/ -│ └── SaveChatCommands.swift — File menu export command +│ └── SaveChatCommands.swift — File menu new/open/save/revert/export commands +├── Documents/ +│ ├── ChatDocumentController.swift — Queues Finder/app open-document requests into SwiftUI +│ ├── ChatDocumentManifest.swift — Versioned `.mlxchat` manifest schema +│ ├── ChatDocumentMigration.swift — Manifest schema migration entry point +│ └── ChatDocumentPackage.swift — Package document read/write for `.mlxchat` ├── Server/ │ ├── APIServer.swift — NWListener HTTP server, SSE streaming, KV cache reuse │ ├── APIModels.swift — OpenAI-compatible Codable structs diff --git a/project.yml b/project.yml index c49ab3d..e28c9c2 100644 --- a/project.yml +++ b/project.yml @@ -28,9 +28,8 @@ targets: CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "6.0" MACOSX_DEPLOYMENT_TARGET: "15.0" - GENERATE_INFOPLIST_FILE: "YES" - INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.developer-tools" - INFOPLIST_KEY_NSHumanReadableCopyright: "" + GENERATE_INFOPLIST_FILE: "NO" + INFOPLIST_FILE: MLXServer/Info.plist CODE_SIGN_ENTITLEMENTS: MLXServer/MLXServer.entitlements CODE_SIGN_IDENTITY: "-" CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: "YES"