feat: document saving/loading

This commit is contained in:
2026-03-18 15:29:02 +01:00
parent 09b94b32d0
commit f0db0c0938
15 changed files with 1086 additions and 70 deletions

View File

@@ -11,10 +11,12 @@
07119250A7F9D6ECE7F6B8FD /* SceneCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F03A123A8908714A89315FE /* SceneCommands.swift */; }; 07119250A7F9D6ECE7F6B8FD /* SceneCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F03A123A8908714A89315FE /* SceneCommands.swift */; };
165E8AB6ADAE1D59B1A86420 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145B888FBDD4F931512C5473 /* Preferences.swift */; }; 165E8AB6ADAE1D59B1A86420 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145B888FBDD4F931512C5473 /* Preferences.swift */; };
189362AAE2CDE5D4B3428334 /* ToolCallParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E73B165A1822729C907791AE /* ToolCallParser.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 */; }; 20FFB5DBF75AA6C359AAE31C /* SceneManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEB592E5E717F817B03151 /* SceneManagementView.swift */; };
29879D696584B96CC56560DF /* ChatExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */; }; 29879D696584B96CC56560DF /* ChatExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */; };
2CAAF7129F7CC45200FA9F6B /* ModelPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */; }; 2CAAF7129F7CC45200FA9F6B /* ModelPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */; };
2D08769282BD71C170DB0943 /* InferenceStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = E35452B166893B25E765FF70 /* InferenceStats.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 */; }; 4158FA884D981D73288FB74C /* SaveChatCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */; };
4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E6AD02CDF23BDAB64700A7 /* ChatInputView.swift */; }; 4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E6AD02CDF23BDAB64700A7 /* ChatInputView.swift */; };
4DC033E45880B2948B47DEB1 /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF518FEBF3A38E830E3CE1A5 /* FocusedValues.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 */; }; 84D32315B418B5243E017350 /* ToolPromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */; };
85FB1EB49D76A9F21E181346 /* ChatScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */; }; 85FB1EB49D76A9F21E181346 /* ChatScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */; };
945474365D0B3E961811909A /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = D5E8E1C2DD8D8AABB4306193 /* MLXVLM */; }; 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 */; }; B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4239CFF94B819C35A8D4D617 /* MonitorView.swift */; };
B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */; }; B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */; };
B6D3662995B885C102876B4A /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 9090667D4134056AE66DC2F1 /* MLXLMCommon */; }; B6D3662995B885C102876B4A /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 9090667D4134056AE66DC2F1 /* MLXLMCommon */; };
C07A377244DCD67F4FE709FE /* DownloadModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */; }; 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 */; }; CBA88529F8BE7BD0518994AD /* SceneSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B5ABDEB6F5C54856EB1A9E /* SceneSelectionView.swift */; };
CFEE79815DFB80E51FE3745A /* SceneStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C234359924C542F07ED926A2 /* SceneStore.swift */; }; CFEE79815DFB80E51FE3745A /* SceneStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C234359924C542F07ED926A2 /* SceneStore.swift */; };
D666A311788375E8A061C832 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4147321383E94E9F17A0154E /* SettingsView.swift */; }; D666A311788375E8A061C832 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4147321383E94E9F17A0154E /* SettingsView.swift */; };
@@ -46,15 +50,19 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0F03A123A8908714A89315FE /* SceneCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneCommands.swift; sourceTree = "<group>"; }; 0F03A123A8908714A89315FE /* SceneCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneCommands.swift; sourceTree = "<group>"; };
145B888FBDD4F931512C5473 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; }; 145B888FBDD4F931512C5473 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
1607BDDE53C575627DCC6896 /* ChatDocumentManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentManifest.swift; sourceTree = "<group>"; };
16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolPromptBuilder.swift; sourceTree = "<group>"; }; 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolPromptBuilder.swift; sourceTree = "<group>"; };
24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentMigration.swift; sourceTree = "<group>"; };
2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModalView.swift; sourceTree = "<group>"; }; 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModalView.swift; sourceTree = "<group>"; };
2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChatCommands.swift; sourceTree = "<group>"; }; 2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChatCommands.swift; sourceTree = "<group>"; };
37FEB592E5E717F817B03151 /* SceneManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneManagementView.swift; sourceTree = "<group>"; }; 37FEB592E5E717F817B03151 /* SceneManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneManagementView.swift; sourceTree = "<group>"; };
386CD08DC6338F42460DFBE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
38DFC212AF4359A45FBE22BA /* ModelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelConfig.swift; sourceTree = "<group>"; }; 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelConfig.swift; sourceTree = "<group>"; };
3AF462805202797F61422AEE /* MLXServer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MLXServer.entitlements; sourceTree = "<group>"; }; 3AF462805202797F61422AEE /* MLXServer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MLXServer.entitlements; sourceTree = "<group>"; };
3D08828E16B17EF02C14243E /* APIServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServer.swift; sourceTree = "<group>"; }; 3D08828E16B17EF02C14243E /* APIServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServer.swift; sourceTree = "<group>"; };
4147321383E94E9F17A0154E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 4147321383E94E9F17A0154E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
4239CFF94B819C35A8D4D617 /* MonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorView.swift; sourceTree = "<group>"; }; 4239CFF94B819C35A8D4D617 /* MonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorView.swift; sourceTree = "<group>"; };
6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentPackage.swift; sourceTree = "<group>"; };
6EE59189918D06B8D2F588FC /* MLXServer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLXServer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 922CBDC9206737BD04AF2874 /* ModelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelManager.swift; sourceTree = "<group>"; };
944C699FBB76C734C9DF2F2E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 944C699FBB76C734C9DF2F2E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -68,6 +76,7 @@
C234359924C542F07ED926A2 /* SceneStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneStore.swift; sourceTree = "<group>"; }; C234359924C542F07ED926A2 /* SceneStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneStore.swift; sourceTree = "<group>"; };
C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelPickerView.swift; sourceTree = "<group>"; }; C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelPickerView.swift; sourceTree = "<group>"; };
C67742651DB486871CEF1612 /* MLXServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXServerApp.swift; sourceTree = "<group>"; }; C67742651DB486871CEF1612 /* MLXServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXServerApp.swift; sourceTree = "<group>"; };
D5C1FCEFEA72B9ABB87FB20E /* ChatDocumentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentController.swift; sourceTree = "<group>"; };
D733A0D1D4AC25DDDA6C8684 /* LocalModelResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalModelResolver.swift; sourceTree = "<group>"; }; D733A0D1D4AC25DDDA6C8684 /* LocalModelResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalModelResolver.swift; sourceTree = "<group>"; };
D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExporter.swift; sourceTree = "<group>"; }; D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExporter.swift; sourceTree = "<group>"; };
DB1A5E8B1C9F2BC4D262C53A /* ChatMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessagesView.swift; sourceTree = "<group>"; }; DB1A5E8B1C9F2BC4D262C53A /* ChatMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessagesView.swift; sourceTree = "<group>"; };
@@ -104,6 +113,17 @@
path = Utilities; path = Utilities;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
08DD6175C845EFB9638A0688 /* Documents */ = {
isa = PBXGroup;
children = (
D5C1FCEFEA72B9ABB87FB20E /* ChatDocumentController.swift */,
1607BDDE53C575627DCC6896 /* ChatDocumentManifest.swift */,
24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */,
6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.swift */,
);
path = Documents;
sourceTree = "<group>";
};
652987C2A419DBFC79E32CDE /* Products */ = { 652987C2A419DBFC79E32CDE /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -117,9 +137,11 @@
children = ( children = (
B629DA084A9A40E54F8EA5FA /* Assets.xcassets */, B629DA084A9A40E54F8EA5FA /* Assets.xcassets */,
944C699FBB76C734C9DF2F2E /* ContentView.swift */, 944C699FBB76C734C9DF2F2E /* ContentView.swift */,
386CD08DC6338F42460DFBE2 /* Info.plist */,
3AF462805202797F61422AEE /* MLXServer.entitlements */, 3AF462805202797F61422AEE /* MLXServer.entitlements */,
C67742651DB486871CEF1612 /* MLXServerApp.swift */, C67742651DB486871CEF1612 /* MLXServerApp.swift */,
B459409ED6FD8797FDD81E94 /* Commands */, B459409ED6FD8797FDD81E94 /* Commands */,
08DD6175C845EFB9638A0688 /* Documents */,
BD0E350482D91238B4B59721 /* Models */, BD0E350482D91238B4B59721 /* Models */,
E13C1AAA0C49D0ED85EFD94D /* Server */, E13C1AAA0C49D0ED85EFD94D /* Server */,
05B1BAE308E64D2FB2E73823 /* Utilities */, 05B1BAE308E64D2FB2E73823 /* Utilities */,
@@ -273,6 +295,10 @@
files = ( files = (
D96DDE66F76FDDA642629E17 /* APIModels.swift in Sources */, D96DDE66F76FDDA642629E17 /* APIModels.swift in Sources */,
50DD129CCF2843482DEC3B96 /* APIServer.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 */, 29879D696584B96CC56560DF /* ChatExporter.swift in Sources */,
4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */, 4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */,
FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */, FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */,
@@ -434,9 +460,8 @@
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_FILE = MLXServer/Info.plist;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -459,9 +484,8 @@
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_FILE = MLXServer/Info.plist;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",

View File

@@ -1,15 +1,53 @@
import SwiftUI import SwiftUI
/// Adds "Export Chat" to the File menu.
struct SaveChatCommands: Commands { 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 @FocusedValue(\.exportChatAction) private var exportChatAction
var body: some Commands { 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…") { Button("Export Chat…") {
exportChatAction?() exportChatAction?()
} }
.keyboardShortcut("s", modifiers: [.command, .shift]) .keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(exportChatAction == nil) .disabled(exportChatAction == nil)
} }
} }

View File

@@ -1,7 +1,9 @@
import AppKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
struct ContentView: View { struct ContentView: View {
@Environment(ChatDocumentController.self) private var documentController
@Environment(ModelManager.self) private var modelManager @Environment(ModelManager.self) private var modelManager
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@Environment(SceneStore.self) private var sceneStore @Environment(SceneStore.self) private var sceneStore
@@ -10,11 +12,16 @@ struct ContentView: View {
@State private var showMonitor = false @State private var showMonitor = false
@State private var showScenePicker = false @State private var showScenePicker = false
@State private var exportDocument: ChatExportDocument? @State private var exportDocument: ChatExportDocument?
@State private var documentErrorMessage: String?
@State private var exportErrorMessage: String? @State private var exportErrorMessage: String?
var body: some View { var body: some View {
mainContent exportedContent
.navigationTitle(modelManager.currentModel?.displayName ?? "MLX Server") }
private var lifecycleContent: some View {
AnyView(mainContent)
.navigationTitle(navigationTitleText)
.onAppear { .onAppear {
if chatVM == nil { if chatVM == nil {
chatVM = ChatViewModel(modelManager: modelManager) chatVM = ChatViewModel(modelManager: modelManager)
@@ -23,17 +30,30 @@ struct ContentView: View {
chatVM?.startAPIServer() chatVM?.startAPIServer()
} }
} }
processPendingOpenRequests()
} }
.onChange(of: modelManager.currentModel) { .onChange(of: modelManager.currentModel) {
chatVM?.handleModelChange() chatVM?.handleModelChange()
chatVM?.markDirtyIfNeeded()
// Persist last used model // Persist last used model
if let id = modelManager.currentModel?.id { if let id = modelManager.currentModel?.id {
Preferences.lastModelId = id Preferences.lastModelId = id
} }
} }
.onChange(of: chatVM?.inputText ?? "") {
chatVM?.markDirtyIfNeeded()
}
.onChange(of: modelManager.errorMessage) { .onChange(of: modelManager.errorMessage) {
showLoadError = modelManager.errorMessage != nil showLoadError = modelManager.errorMessage != nil
} }
.onChange(of: documentController.openRequestNonce) {
processPendingOpenRequests()
}
}
private var alertContent: some View {
AnyView(lifecycleContent)
.alert("Model Error", isPresented: $showLoadError) { .alert("Model Error", isPresented: $showLoadError) {
Button("Retry") { Button("Retry") {
if let config = modelManager.currentModel ?? ModelConfig.availableModels.first { if let config = modelManager.currentModel ?? ModelConfig.availableModels.first {
@@ -46,6 +66,13 @@ struct ContentView: View {
} message: { } message: {
Text(modelManager.errorMessage ?? "Unknown error loading model.") 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) { .alert("Export Failed", isPresented: exportErrorBinding) {
Button("OK", role: .cancel) { Button("OK", role: .cancel) {
exportErrorMessage = nil exportErrorMessage = nil
@@ -53,6 +80,10 @@ struct ContentView: View {
} message: { } message: {
Text(exportErrorMessage ?? "Unknown export error.") Text(exportErrorMessage ?? "Unknown export error.")
} }
}
private var exportedContent: some View {
AnyView(alertContent)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
ModelPickerView() ModelPickerView()
@@ -65,6 +96,11 @@ struct ContentView: View {
.background { .background {
modelSwitchShortcuts 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)) .focusedSceneValue(\.exportChatAction, ExportChatAction(perform: beginExport))
.fileExporter( .fileExporter(
isPresented: Binding( 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 @ViewBuilder
private var mainContent: some View { private var mainContent: some View {
ZStack { ZStack {
@@ -145,7 +188,7 @@ struct ContentView: View {
// New conversation // New conversation
Button { Button {
showScenePicker = true beginNewChat()
} label: { } label: {
Label("New Chat", systemImage: "plus.message") Label("New Chat", systemImage: "plus.message")
} }
@@ -157,11 +200,11 @@ struct ContentView: View {
currentModelName: modelManager.currentModel?.displayName, currentModelName: modelManager.currentModel?.displayName,
onSelectNeutral: { onSelectNeutral: {
showScenePicker = false showScenePicker = false
Task { await chatVM?.startNewConversation(scene: nil) } startConversation(scene: nil)
}, },
onSelectScene: { scene in onSelectScene: { scene in
showScenePicker = false showScenePicker = false
Task { await chatVM?.startNewConversation(scene: scene) } startConversation(scene: scene)
}, },
onManageScenes: { onManageScenes: {
showScenePicker = false showScenePicker = false
@@ -196,6 +239,10 @@ struct ContentView: View {
} }
private var exportDefaultFilename: String { private var exportDefaultFilename: String {
if let currentDocumentURL = chatVM?.currentDocumentURL {
return currentDocumentURL.deletingPathExtension().lastPathComponent
}
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmm" formatter.dateFormat = "yyyy-MM-dd-HHmm"
return "chat-\(formatter.string(from: .now))" return "chat-\(formatter.string(from: .now))"
@@ -208,6 +255,145 @@ struct ContentView: View {
modelName: modelManager.currentModel?.displayName modelName: modelManager.currentModel?.displayName
) )
} }
private var documentDefaultFilename: String {
if let currentDocumentURL = chatVM?.currentDocumentURL {
return currentDocumentURL.deletingPathExtension().lastPathComponent
}
return exportDefaultFilename
}
private var documentErrorBinding: Binding<Bool> {
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. /// The main chat layout: messages + input area + status bar.

View File

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

View File

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

View File

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

View File

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

66
MLXServer/Info.plist Normal file
View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>MLX Server Chat Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSTypeIsPackage</key>
<true/>
<key>LSItemContentTypes</key>
<array>
<string>de.rfc1437.mlxserver.chat</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>com.apple.package</string>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>MLX Server Chat Document</string>
<key>UTTypeIdentifier</key>
<string>de.rfc1437.mlxserver.chat</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mlxchat</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@@ -1,8 +1,18 @@
import SwiftUI import SwiftUI
import MLX import MLX
final class AppDelegate: NSObject, NSApplicationDelegate {
func application(_ application: NSApplication, open urls: [URL]) {
Task { @MainActor in
ChatDocumentController.shared.enqueueOpenRequests(urls)
}
}
}
@main @main
struct MLXServerApp: App { struct MLXServerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var documentController = ChatDocumentController.shared
@State private var modelManager = ModelManager() @State private var modelManager = ModelManager()
@State private var sceneStore = SceneStore() @State private var sceneStore = SceneStore()
@@ -13,9 +23,11 @@ struct MLXServerApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(documentController)
.environment(modelManager) .environment(modelManager)
.environment(sceneStore) .environment(sceneStore)
.task { .task {
guard !documentController.hasPendingOpenRequests 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 }) {

View File

@@ -1,12 +1,89 @@
import AppKit import AppKit
import CryptoKit
import Foundation 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. /// A single message in the chat conversation.
struct ChatMessage: Identifiable { struct ChatMessage: Identifiable {
let id = UUID() let id: UUID
let role: Role let role: Role
var content: String var content: String
var images: [NSImage] var attachments: [ChatAttachment]
var isStreaming: Bool var isStreaming: Bool
let timestamp: Date let timestamp: Date
@@ -26,43 +103,53 @@ struct ChatMessage: Identifiable {
case assistant 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.role = role
self.content = content self.content = content
self.rawContent = content self.rawContent = rawContent ?? content
self.images = images self.attachments = attachments
self.isStreaming = isStreaming self.isStreaming = isStreaming
self.timestamp = Date() self.timestamp = timestamp
} self.thinkingContent = thinkingContent ?? ""
} self.isThinking = isThinking
/// Observable conversation state holding all messages. if role == .assistant, rawContent != nil {
@Observable applyParsedContent(Self.parseAssistantContent(self.rawContent))
@MainActor }
final class Conversation {
var messages: [ChatMessage] = []
func addUserMessage(_ text: String, images: [NSImage] = []) {
messages.append(ChatMessage(role: .user, content: text, images: images))
} }
/// Adds an empty assistant message (to be filled via streaming) and returns its index. var sessionContent: String {
func addAssistantMessage() -> Int { role == .assistant ? rawContent : content
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. mutating func refreshAssistantContentFromRaw() {
/// Handles `<think>...</think>` tags by routing content to `thinkingContent` vs `content`. applyParsedContent(Self.parseAssistantContent(rawContent))
func appendToMessage(at index: Int, chunk: String) { }
guard index < messages.count else { return }
messages[index].rawContent += chunk
// Parse the full raw content to separate thinking from response. private mutating func applyParsedContent(_ parsed: ParsedAssistantContent) {
// This is simpler and more robust than incremental parsing since thinkingContent = parsed.thinking
// tag boundaries can split across chunks. content = parsed.visible
let raw = messages[index].rawContent 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 thinking = ""
var visible = "" var visible = ""
var isInThink = false var isInThink = false
@@ -75,7 +162,6 @@ final class Conversation {
scanner = scanner[endRange.upperBound...] scanner = scanner[endRange.upperBound...]
isInThink = false isInThink = false
} else { } else {
// Still inside thinking all remaining text is thinking
thinking += String(scanner) thinking += String(scanner)
break break
} }
@@ -91,9 +177,38 @@ final class Conversation {
} }
} }
messages[index].thinkingContent = thinking.trimmingCharacters(in: .whitespacesAndNewlines) return ParsedAssistantContent(
messages[index].content = visible.trimmingCharacters(in: .whitespacesAndNewlines) visible: visible.trimmingCharacters(in: .whitespacesAndNewlines),
messages[index].isThinking = isInThink 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 `<think>...</think>` 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. /// Marks the assistant message at the given index as done streaming.
@@ -106,4 +221,8 @@ final class Conversation {
func clear() { func clear() {
messages.removeAll() messages.removeAll()
} }
func replaceMessages(_ restoredMessages: [ChatMessage]) {
messages = restoredMessages
}
} }

View File

@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
struct ExportChatAction { struct ChatCommandAction {
let perform: () -> Void let perform: () -> Void
func callAsFunction() { 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 { struct FocusedExportActionKey: FocusedValueKey {
typealias Value = ExportChatAction 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 { extension FocusedValues {
var exportChatAction: ExportChatAction? { var exportChatAction: ExportChatAction? {
get { self[FocusedExportActionKey.self] } get { self[FocusedExportActionKey.self] }
set { self[FocusedExportActionKey.self] = newValue } 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 }
}
} }

View File

@@ -1,4 +1,5 @@
import AppKit import AppKit
import CryptoKit
import Foundation import Foundation
import MLX import MLX
import MLXLMCommon import MLXLMCommon
@@ -12,13 +13,22 @@ final class ChatViewModel {
var inputText = "" var inputText = ""
var attachedImages: [NSImage] = [] var attachedImages: [NSImage] = []
var activeScene: ChatScene? var activeScene: ChatScene?
var currentDocumentURL: URL?
var hasUnsavedChanges = false
var isGenerating = false var isGenerating = false
var tokensPerSecond: Double = 0 var tokensPerSecond: Double = 0
var promptTokens: Int = 0 var promptTokens: Int = 0
var generationTokens: Int = 0 var generationTokens: Int = 0
private(set) var lastSavedSnapshotHash: String?
private var generationTask: Task<Void, Never>? private var generationTask: Task<Void, Never>?
private var chatSession: ChatSession? 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 modelManager: ModelManager
let apiServer = APIServer() let apiServer = APIServer()
@@ -31,6 +41,14 @@ final class ChatViewModel {
activeScene?.displayName ?? "Neutral" 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. /// Ensure a ChatSession exists for the current model.
private func ensureSession() { private func ensureSession() {
guard let container = modelManager.modelContainer else { return } guard let container = modelManager.modelContainer else { return }
@@ -38,19 +56,35 @@ final class ChatViewModel {
let systemPrompt = effectiveSystemPrompt let systemPrompt = effectiveSystemPrompt
// Pass enable_thinking to the Jinja chat template context. // Pass enable_thinking to the Jinja chat template context.
// Qwen3.5 and similar models use this to control reasoning mode. // 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 ? nil
: ["enable_thinking": false] : ["enable_thinking": false]
chatSession = ChatSession( let generateParameters = GenerateParameters(temperature: Float(documentTemperature))
container, let history = conversation.messages.compactMap(historyMessage(from:))
instructions: systemPrompt.isEmpty ? nil : systemPrompt, if history.isEmpty {
generateParameters: GenerateParameters(temperature: 0.7), chatSession = ChatSession(
additionalContext: thinkingContext 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 { private var effectiveSystemPrompt: String {
if let documentSystemPromptOverride {
return documentSystemPromptOverride
}
let parts = [ let parts = [
Preferences.systemPrompt, Preferences.systemPrompt,
activeScene?.systemPrompt ?? "" activeScene?.systemPrompt ?? ""
@@ -61,6 +95,10 @@ final class ChatViewModel {
return parts.joined(separator: "\n\n") return parts.joined(separator: "\n\n")
} }
private var effectiveThinkingEnabled: Bool {
documentThinkingOverride ?? Preferences.enableThinking
}
func send() { func send() {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, modelManager.isReady else { return } guard !text.isEmpty, modelManager.isReady else { return }
@@ -74,6 +112,7 @@ final class ChatViewModel {
attachedImages = [] attachedImages = []
conversation.addUserMessage(text, images: images) conversation.addUserMessage(text, images: images)
markDirtyIfNeeded()
let assistantIndex = conversation.addAssistantMessage() let assistantIndex = conversation.addAssistantMessage()
isGenerating = true isGenerating = true
@@ -133,6 +172,7 @@ final class ChatViewModel {
} }
conversation.finalizeMessage(at: assistantIndex) conversation.finalizeMessage(at: assistantIndex)
markDirtyIfNeeded()
isGenerating = false isGenerating = false
generationTask = nil generationTask = nil
modelManager.touchActivity() modelManager.touchActivity()
@@ -147,6 +187,7 @@ final class ChatViewModel {
if let last = conversation.messages.indices.last, if let last = conversation.messages.indices.last,
conversation.messages[last].isStreaming { conversation.messages[last].isStreaming {
conversation.finalizeMessage(at: last) conversation.finalizeMessage(at: last)
markDirtyIfNeeded()
} }
} }
@@ -164,8 +205,10 @@ final class ChatViewModel {
stop() stop()
conversation.clear() conversation.clear()
inputText = "" inputText = ""
attachedImages = []
activeScene = nil activeScene = nil
resetSession() resetSession()
resetDocumentState()
Preferences.lastSceneId = nil Preferences.lastSceneId = nil
} }
@@ -182,8 +225,11 @@ final class ChatViewModel {
inputText = scene?.starterPrompt.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" inputText = scene?.starterPrompt.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
attachedImages = [] attachedImages = []
resetSession() resetSession()
resetDocumentState()
Preferences.lastSceneId = scene?.id Preferences.lastSceneId = scene?.id
markDirtyIfNeeded()
if !inputText.isEmpty { if !inputText.isEmpty {
send() 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 // MARK: - API Server
func startAPIServer() { func startAPIServer() {

View File

@@ -65,14 +65,16 @@ struct MessageBubbleView: View {
VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 6) { VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 6) {
// Show attached images // Show attached images
if !message.images.isEmpty { if !message.attachments.isEmpty {
HStack(spacing: 4) { HStack(spacing: 4) {
ForEach(Array(message.images.enumerated()), id: \.offset) { _, image in ForEach(message.attachments) { attachment in
Image(nsImage: image) if let image = attachment.image {
.resizable() Image(nsImage: image)
.aspectRatio(contentMode: .fill) .resizable()
.frame(width: 80, height: 80) .aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8)) .frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
} }
} }
} }

View File

@@ -32,9 +32,10 @@ open "build/Debug/MLX Server.app"
- **Download progress modal** — shows file progress, percentage, and speed when downloading a new model - **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. - **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 - **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 - **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 - **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 - **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 - **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 │ ├── MonitorView.swift — Inference statistics monitor
│ └── SettingsView.swift — System prompt, thinking mode, API, idle settings │ └── SettingsView.swift — System prompt, thinking mode, API, idle settings
├── Commands/ ├── 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/ ├── Server/
│ ├── APIServer.swift — NWListener HTTP server, SSE streaming, KV cache reuse │ ├── APIServer.swift — NWListener HTTP server, SSE streaming, KV cache reuse
│ ├── APIModels.swift — OpenAI-compatible Codable structs │ ├── APIModels.swift — OpenAI-compatible Codable structs

View File

@@ -28,9 +28,8 @@ targets:
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "15.0" MACOSX_DEPLOYMENT_TARGET: "15.0"
GENERATE_INFOPLIST_FILE: "YES" GENERATE_INFOPLIST_FILE: "NO"
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.developer-tools" INFOPLIST_FILE: MLXServer/Info.plist
INFOPLIST_KEY_NSHumanReadableCopyright: ""
CODE_SIGN_ENTITLEMENTS: MLXServer/MLXServer.entitlements CODE_SIGN_ENTITLEMENTS: MLXServer/MLXServer.entitlements
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: "YES" CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: "YES"