feat: document saving/loading
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
25
MLXServer/Documents/ChatDocumentController.swift
Normal file
25
MLXServer/Documents/ChatDocumentController.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
73
MLXServer/Documents/ChatDocumentManifest.swift
Normal file
73
MLXServer/Documents/ChatDocumentManifest.swift
Normal 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
|
||||||
|
}
|
||||||
19
MLXServer/Documents/ChatDocumentMigration.swift
Normal file
19
MLXServer/Documents/ChatDocumentMigration.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
MLXServer/Documents/ChatDocumentPackage.swift
Normal file
146
MLXServer/Documents/ChatDocumentPackage.swift
Normal 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
66
MLXServer/Info.plist
Normal 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>
|
||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
README.md
12
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
|
- **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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user