diff --git a/ChatMLX.xcodeproj/project.pbxproj b/ChatMLX.xcodeproj/project.pbxproj index 03dc790..c330cc4 100644 --- a/ChatMLX.xcodeproj/project.pbxproj +++ b/ChatMLX.xcodeproj/project.pbxproj @@ -50,13 +50,11 @@ 5266765C2C85F903001EF113 /* SettingsSidebarItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5266761F2C85F903001EF113 /* SettingsSidebarItemView.swift */; }; 5266765D2C85F903001EF113 /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676202C85F903001EF113 /* SettingsSidebarView.swift */; }; 5266765E2C85F903001EF113 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676212C85F903001EF113 /* SettingsView.swift */; }; - 5266765F2C85F903001EF113 /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676242C85F903001EF113 /* Conversation.swift */; }; 526676602C85F903001EF113 /* DisplayStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676252C85F903001EF113 /* DisplayStyle.swift */; }; 526676612C85F903001EF113 /* DownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676262C85F903001EF113 /* DownloadTask.swift */; }; 526676622C85F903001EF113 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676272C85F903001EF113 /* Language.swift */; }; 526676632C85F903001EF113 /* LocalModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676282C85F903001EF113 /* LocalModel.swift */; }; 526676642C85F903001EF113 /* LocalModelGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676292C85F903001EF113 /* LocalModelGroup.swift */; }; - 526676652C85F903001EF113 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5266762A2C85F903001EF113 /* Message.swift */; }; 526676662C85F903001EF113 /* RemoteModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5266762B2C85F903001EF113 /* RemoteModel.swift */; }; 526676672C85F903001EF113 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5266762C2C85F903001EF113 /* SettingsTab.swift */; }; 526676682C85F903001EF113 /* SettingsTabGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5266762D2C85F903001EF113 /* SettingsTabGroup.swift */; }; @@ -73,7 +71,19 @@ 526676742C85F903001EF113 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5266763C2C85F903001EF113 /* View+Extensions.swift */; }; 526676782C85F9DA001EF113 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 526676772C85F9DA001EF113 /* Localizable.xcstrings */; }; 527F48152C9EFD5D006AF9FA /* LLM in Frameworks */ = {isa = PBXBuildFile; productRef = 527F48142C9EFD5D006AF9FA /* LLM */; }; + 528D82262CABE19900163AAB /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528D82252CABE19000163AAB /* Date+Extensions.swift */; }; + 528D83192CAD491900163AAB /* ChatMLX.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 528D83172CAD491900163AAB /* ChatMLX.xcdatamodeld */; }; + 528D831C2CAD49E600163AAB /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528D831B2CAD49E600163AAB /* PersistenceController.swift */; }; + 528D83292CAD5C9100163AAB /* Conversation+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528D83252CAD5C9100163AAB /* Conversation+CoreDataClass.swift */; }; + 528D832A2CAD5C9100163AAB /* Conversation+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528D83262CAD5C9100163AAB /* Conversation+CoreDataProperties.swift */; }; + 528D832B2CAD5C9100163AAB /* Message+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528D83272CAD5C9100163AAB /* Message+CoreDataClass.swift */; }; + 528D832C2CAD5C9100163AAB /* Message+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528D83282CAD5C9100163AAB /* Message+CoreDataProperties.swift */; }; + 528D83372CADB64600163AAB /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528D83362CADB64300163AAB /* ConversationViewModel.swift */; }; + 528D83392CAE51EC00163AAB /* Role.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528D83382CAE51EC00163AAB /* Role.swift */; }; 528DBE2F2C9C86FB004CDD88 /* Transformers in Frameworks */ = {isa = PBXBuildFile; productRef = 528DBE2E2C9C86FB004CDD88 /* Transformers */; }; + 52A689F62CAE8AAB0078CDF9 /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A689F52CAE8AAB0078CDF9 /* TimeInterval+Extensions.swift */; }; + 52A689F82CAE8DA30078CDF9 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A689F72CAE8DA00078CDF9 /* SettingsViewModel.swift */; }; + 52A689FA2CAECFE00078CDF9 /* ErrorAlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A689F92CAECFDE0078CDF9 /* ErrorAlertModifier.swift */; }; 52E50B1D2C8D6E81005A89DE /* LLM in Frameworks */ = {isa = PBXBuildFile; productRef = 52E50B1C2C8D6E81005A89DE /* LLM */; }; 52E50B202C8D719B005A89DE /* LLM in Frameworks */ = {isa = PBXBuildFile; productRef = 52E50B1F2C8D719B005A89DE /* LLM */; }; 52E50B222C8D719B005A89DE /* MNIST in Frameworks */ = {isa = PBXBuildFile; productRef = 52E50B212C8D719B005A89DE /* MNIST */; }; @@ -123,13 +133,11 @@ 5266761F2C85F903001EF113 /* SettingsSidebarItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSidebarItemView.swift; sourceTree = ""; }; 526676202C85F903001EF113 /* SettingsSidebarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSidebarView.swift; sourceTree = ""; }; 526676212C85F903001EF113 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 526676242C85F903001EF113 /* Conversation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = ""; }; 526676252C85F903001EF113 /* DisplayStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayStyle.swift; sourceTree = ""; }; 526676262C85F903001EF113 /* DownloadTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadTask.swift; sourceTree = ""; }; 526676272C85F903001EF113 /* Language.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; 526676282C85F903001EF113 /* LocalModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalModel.swift; sourceTree = ""; }; 526676292C85F903001EF113 /* LocalModelGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalModelGroup.swift; sourceTree = ""; }; - 5266762A2C85F903001EF113 /* Message.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; 5266762B2C85F903001EF113 /* RemoteModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteModel.swift; sourceTree = ""; }; 5266762C2C85F903001EF113 /* SettingsTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = ""; }; 5266762D2C85F903001EF113 /* SettingsTabGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsTabGroup.swift; sourceTree = ""; }; @@ -146,6 +154,18 @@ 5266763C2C85F903001EF113 /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; 526676762C85F952001EF113 /* ChatMLXRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ChatMLXRelease.entitlements; sourceTree = ""; }; 526676772C85F9DA001EF113 /* Localizable.xcstrings */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 528D82252CABE19000163AAB /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + 528D83182CAD491900163AAB /* ChatMLX.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = ChatMLX.xcdatamodel; sourceTree = ""; }; + 528D831B2CAD49E600163AAB /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + 528D83252CAD5C9100163AAB /* Conversation+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Conversation+CoreDataClass.swift"; sourceTree = ""; }; + 528D83262CAD5C9100163AAB /* Conversation+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Conversation+CoreDataProperties.swift"; sourceTree = ""; }; + 528D83272CAD5C9100163AAB /* Message+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+CoreDataClass.swift"; sourceTree = ""; }; + 528D83282CAD5C9100163AAB /* Message+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+CoreDataProperties.swift"; sourceTree = ""; }; + 528D83362CADB64300163AAB /* ConversationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewModel.swift; sourceTree = ""; }; + 528D83382CAE51EC00163AAB /* Role.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Role.swift; sourceTree = ""; }; + 52A689F52CAE8AAB0078CDF9 /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = ""; }; + 52A689F72CAE8DA00078CDF9 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 52A689F92CAECFDE0078CDF9 /* ErrorAlertModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlertModifier.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -236,6 +256,7 @@ 526676092C85F903001EF113 /* Components */ = { isa = PBXGroup; children = ( + 52A689F92CAECFDE0078CDF9 /* ErrorAlertModifier.swift */, 526676002C85F903001EF113 /* SyntaxHighlighter */, 526676012C85F903001EF113 /* EffectView.swift */, 526676022C85F903001EF113 /* UltramanMinimalistWindowModifier.swift */, @@ -252,6 +273,7 @@ 526676112C85F903001EF113 /* Conversation */ = { isa = PBXGroup; children = ( + 528D83362CADB64300163AAB /* ConversationViewModel.swift */, 5266760A2C85F903001EF113 /* ConversationDetailView.swift */, 5266760B2C85F903001EF113 /* ConversationSidebarItem.swift */, 5266760C2C85F903001EF113 /* ConversationSidebarView.swift */, @@ -293,6 +315,7 @@ 526676222C85F903001EF113 /* Settings */ = { isa = PBXGroup; children = ( + 52A689F72CAE8DA00078CDF9 /* SettingsViewModel.swift */, 526676142C85F903001EF113 /* DownloadManager */, 526676172C85F903001EF113 /* LocalModels */, 5266761A2C85F903001EF113 /* MLXCommunity */, @@ -319,17 +342,21 @@ 5266762F2C85F903001EF113 /* Models */ = { isa = PBXGroup; children = ( - 526676242C85F903001EF113 /* Conversation.swift */, 526676252C85F903001EF113 /* DisplayStyle.swift */, 526676262C85F903001EF113 /* DownloadTask.swift */, 526676272C85F903001EF113 /* Language.swift */, 526676282C85F903001EF113 /* LocalModel.swift */, 526676292C85F903001EF113 /* LocalModelGroup.swift */, - 5266762A2C85F903001EF113 /* Message.swift */, + 528D83382CAE51EC00163AAB /* Role.swift */, 5266762B2C85F903001EF113 /* RemoteModel.swift */, 5266762C2C85F903001EF113 /* SettingsTab.swift */, 5266762D2C85F903001EF113 /* SettingsTabGroup.swift */, 5266762E2C85F903001EF113 /* Styles.swift */, + 528D83252CAD5C9100163AAB /* Conversation+CoreDataClass.swift */, + 528D83262CAD5C9100163AAB /* Conversation+CoreDataProperties.swift */, + 528D83272CAD5C9100163AAB /* Message+CoreDataClass.swift */, + 528D83282CAD5C9100163AAB /* Message+CoreDataProperties.swift */, + 528D83172CAD491900163AAB /* ChatMLX.xcdatamodeld */, ); path = Models; sourceTree = ""; @@ -347,9 +374,10 @@ 526676372C85F903001EF113 /* Utilities */ = { isa = PBXGroup; children = ( - 526676332C85F903001EF113 /* Huggingface */, 526676342C85F903001EF113 /* LLMRunner.swift */, 526676352C85F903001EF113 /* Logger.swift */, + 528D831B2CAD49E600163AAB /* PersistenceController.swift */, + 526676332C85F903001EF113 /* Huggingface */, ); path = Utilities; sourceTree = ""; @@ -357,6 +385,8 @@ 5266763D2C85F903001EF113 /* Extensions */ = { isa = PBXGroup; children = ( + 528D82252CABE19000163AAB /* Date+Extensions.swift */, + 52A689F52CAE8AAB0078CDF9 /* TimeInterval+Extensions.swift */, 526676382C85F903001EF113 /* Defaults+Extensions.swift */, 526676392C85F903001EF113 /* MarkdownUI+Theme+Extensions.swift */, 5266763A2C85F903001EF113 /* NSWindow+Extensions.swift */, @@ -487,11 +517,13 @@ 526676592C85F903001EF113 /* DefaultConversationView.swift in Sources */, 5266766E2C85F903001EF113 /* Logger.swift in Sources */, 526676612C85F903001EF113 /* DownloadTask.swift in Sources */, + 52A689F62CAE8AAB0078CDF9 /* TimeInterval+Extensions.swift in Sources */, + 528D83192CAD491900163AAB /* ChatMLX.xcdatamodeld in Sources */, 526676502C85F903001EF113 /* MessageBubbleView.swift in Sources */, 526676582C85F903001EF113 /* AboutView.swift in Sources */, 526676552C85F903001EF113 /* LocalModelsView.swift in Sources */, + 52A689FA2CAECFE00078CDF9 /* ErrorAlertModifier.swift in Sources */, 526676662C85F903001EF113 /* RemoteModel.swift in Sources */, - 526676652C85F903001EF113 /* Message.swift in Sources */, 526676562C85F903001EF113 /* MLXCommunityItemView.swift in Sources */, 526676442C85F903001EF113 /* UltramanMinimalistWindowModifier.swift in Sources */, 5266764A2C85F903001EF113 /* UltramanWindow.swift in Sources */, @@ -501,14 +533,21 @@ 526676472C85F903001EF113 /* UltramanSidebarButtonStyle.swift in Sources */, 5266764F2C85F903001EF113 /* EmptyConversation.swift in Sources */, 526676572C85F903001EF113 /* MLXCommunityView.swift in Sources */, + 528D82262CABE19900163AAB /* Date+Extensions.swift in Sources */, 5266766D2C85F903001EF113 /* LLMRunner.swift in Sources */, 5266764E2C85F903001EF113 /* ConversationView.swift in Sources */, 5266766A2C85F903001EF113 /* Downloader.swift in Sources */, 526676732C85F903001EF113 /* String+Extensions.swift in Sources */, 526676742C85F903001EF113 /* View+Extensions.swift in Sources */, 526676432C85F903001EF113 /* EffectView.swift in Sources */, + 528D83292CAD5C9100163AAB /* Conversation+CoreDataClass.swift in Sources */, + 528D832A2CAD5C9100163AAB /* Conversation+CoreDataProperties.swift in Sources */, + 528D832B2CAD5C9100163AAB /* Message+CoreDataClass.swift in Sources */, + 528D83392CAE51EC00163AAB /* Role.swift in Sources */, + 528D832C2CAD5C9100163AAB /* Message+CoreDataProperties.swift in Sources */, 526676712C85F903001EF113 /* MarkdownUI+Theme+Extensions.swift in Sources */, 526676532C85F903001EF113 /* DownloadTaskView.swift in Sources */, + 52A689F82CAE8DA30078CDF9 /* SettingsViewModel.swift in Sources */, 5266764C2C85F903001EF113 /* ConversationSidebarItem.swift in Sources */, 526676622C85F903001EF113 /* Language.swift in Sources */, 526676722C85F903001EF113 /* NSWindow+Extensions.swift in Sources */, @@ -520,9 +559,10 @@ 526675462C85EDCB001EF113 /* ChatMLXApp.swift in Sources */, 526676492C85F903001EF113 /* UltramanTextField.swift in Sources */, 5266765E2C85F903001EF113 /* SettingsView.swift in Sources */, + 528D831C2CAD49E600163AAB /* PersistenceController.swift in Sources */, 5266766B2C85F903001EF113 /* Hub.swift in Sources */, 5266764D2C85F903001EF113 /* ConversationSidebarView.swift in Sources */, - 5266765F2C85F903001EF113 /* Conversation.swift in Sources */, + 528D83372CADB64600163AAB /* ConversationViewModel.swift in Sources */, 5266765D2C85F903001EF113 /* SettingsSidebarView.swift in Sources */, 5266764B2C85F903001EF113 /* ConversationDetailView.swift in Sources */, 526676602C85F903001EF113 /* DisplayStyle.swift in Sources */, @@ -662,7 +702,7 @@ CODE_SIGN_ENTITLEMENTS = ChatMLX/ChatMLX.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"ChatMLX/Preview Content\""; DEVELOPMENT_TEAM = RFGFKQEKRH; ENABLE_HARDENED_RUNTIME = YES; @@ -675,7 +715,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = johnmai.ChatMLX; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -691,7 +731,7 @@ CODE_SIGN_ENTITLEMENTS = ChatMLX/ChatMLXRelease.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"ChatMLX/Preview Content\""; DEVELOPMENT_TEAM = RFGFKQEKRH; ENABLE_HARDENED_RUNTIME = YES; @@ -704,7 +744,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = johnmai.ChatMLX; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -812,8 +852,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ml-explore/mlx-swift-examples/"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.16.0; + branch = main; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -922,6 +962,19 @@ productName = MNIST; }; /* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + 528D83172CAD491900163AAB /* ChatMLX.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 528D83182CAD491900163AAB /* ChatMLX.xcdatamodel */, + ); + currentVersion = 528D83182CAD491900163AAB /* ChatMLX.xcdatamodel */; + path = ChatMLX.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 5266753A2C85EDCB001EF113 /* Project object */; } diff --git a/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f2df0ba..3985549 100644 --- a/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ec0ae960c62380f78719a325a372403e15367161b18d5538115cf8b27ab586fb", + "originHash" : "91755e46d4857336740696612733433e7fa7ef978bc35290de8f756037756422", "pins" : [ { "identity" : "alamofire", @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maiqingqiang/Jinja", "state" : { - "revision" : "eee42768cb951fa1baa7dce0202da5b53ab49f15", - "version" : "1.0.3" + "revision" : "4ffa95ce02e013c992287e19e3bbd620b6cc233a", + "version" : "1.0.4" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ml-explore/mlx-swift-examples/", "state" : { - "revision" : "fb5ee82860107a0f3bcb83be57e8a3cb67a72c6b", - "version" : "1.16.0" + "branch" : "main", + "revision" : "caa5caf4ca64e79c3ad8f64e2a49f9b85ef1bc19" } }, { @@ -109,24 +109,6 @@ "version" : "1.5.0" } }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms", - "state" : { - "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", - "version" : "1.0.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -159,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/huggingface/swift-transformers", "state" : { - "revision" : "104e8ceb1bb714c4aa1619fa20bdfeb537433492", - "version" : "0.1.11" + "revision" : "0f2306713d48a75b862026ebb291926793773f52", + "version" : "0.1.12" } }, { diff --git a/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme b/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme new file mode 100644 index 0000000..a652935 --- /dev/null +++ b/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist b/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist index 745ea1f..e2cd285 100644 --- a/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,15 @@ ChatMLX.xcscheme_^#shared#^_ orderHint - 0 + 3 + + + SuppressBuildableAutocreation + + 526675412C85EDCB001EF113 + + primary + diff --git a/ChatMLX/Assets.xcassets/AccentColor.colorset/Contents.json b/ChatMLX/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..22c4bb0 100644 --- a/ChatMLX/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/ChatMLX/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,33 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, "idiom" : "universal" } ], diff --git a/ChatMLX/Assets.xcassets/mlx-logo-2.imageset/1028322432.png b/ChatMLX/Assets.xcassets/MLX.imageset/1028322432.png similarity index 100% rename from ChatMLX/Assets.xcassets/mlx-logo-2.imageset/1028322432.png rename to ChatMLX/Assets.xcassets/MLX.imageset/1028322432.png diff --git a/ChatMLX/Assets.xcassets/mlx-logo-2.imageset/Contents.json b/ChatMLX/Assets.xcassets/MLX.imageset/Contents.json similarity index 100% rename from ChatMLX/Assets.xcassets/mlx-logo-2.imageset/Contents.json rename to ChatMLX/Assets.xcassets/MLX.imageset/Contents.json diff --git a/ChatMLX/Assets.xcassets/clear1.imageset/Contents.json b/ChatMLX/Assets.xcassets/clear1.imageset/Contents.json deleted file mode 100644 index ebf85b3..0000000 --- a/ChatMLX/Assets.xcassets/clear1.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "clear.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ChatMLX/Assets.xcassets/clear1.imageset/clear.svg b/ChatMLX/Assets.xcassets/clear1.imageset/clear.svg deleted file mode 100644 index 3d1fc1e..0000000 --- a/ChatMLX/Assets.xcassets/clear1.imageset/clear.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ChatMLX/Assets.xcassets/hf-logo-pirate.imageset/Contents.json b/ChatMLX/Assets.xcassets/huggingface.imageset/Contents.json similarity index 100% rename from ChatMLX/Assets.xcassets/hf-logo-pirate.imageset/Contents.json rename to ChatMLX/Assets.xcassets/huggingface.imageset/Contents.json diff --git a/ChatMLX/Assets.xcassets/hf-logo-pirate.imageset/hf-logo-pirate.svg b/ChatMLX/Assets.xcassets/huggingface.imageset/hf-logo-pirate.svg similarity index 100% rename from ChatMLX/Assets.xcassets/hf-logo-pirate.imageset/hf-logo-pirate.svg rename to ChatMLX/Assets.xcassets/huggingface.imageset/hf-logo-pirate.svg diff --git a/ChatMLX/Assets.xcassets/mlx-logo.imageset/1028322422.png b/ChatMLX/Assets.xcassets/mlx-logo.imageset/1028322422.png deleted file mode 100644 index fcbe07f..0000000 Binary files a/ChatMLX/Assets.xcassets/mlx-logo.imageset/1028322422.png and /dev/null differ diff --git a/ChatMLX/Assets.xcassets/mlx-logo.imageset/Contents.json b/ChatMLX/Assets.xcassets/mlx-logo.imageset/Contents.json deleted file mode 100644 index f7778c8..0000000 --- a/ChatMLX/Assets.xcassets/mlx-logo.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "1028322422.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ChatMLX/Assets.xcassets/doc-plaintext.imageset/Contents.json b/ChatMLX/Assets.xcassets/plaintext.imageset/Contents.json similarity index 100% rename from ChatMLX/Assets.xcassets/doc-plaintext.imageset/Contents.json rename to ChatMLX/Assets.xcassets/plaintext.imageset/Contents.json diff --git a/ChatMLX/Assets.xcassets/doc-plaintext.imageset/doc-plaintext (1).svg b/ChatMLX/Assets.xcassets/plaintext.imageset/doc-plaintext (1).svg similarity index 100% rename from ChatMLX/Assets.xcassets/doc-plaintext.imageset/doc-plaintext (1).svg rename to ChatMLX/Assets.xcassets/plaintext.imageset/doc-plaintext (1).svg diff --git a/ChatMLX/ChatMLXApp.swift b/ChatMLX/ChatMLXApp.swift index 8f99133..7148dc5 100644 --- a/ChatMLX/ChatMLXApp.swift +++ b/ChatMLX/ChatMLXApp.swift @@ -6,17 +6,21 @@ // import Defaults -import SwiftData import SwiftUI @main struct ChatMLXApp: App { - @State private var conversationViewModel: ConversationView.ViewModel = .init() - @State private var settingsViewModel: SettingsView.ViewModel = .init() + @Environment(\.scenePhase) private var scenePhase + + @State private var conversationViewModel: ConversationViewModel = .init() + @State private var settingsViewModel: SettingsViewModel = .init() @Default(.language) var language + @State private var runner = LLMRunner() + let persistenceController = PersistenceController.shared + var body: some Scene { WindowGroup { ConversationView() @@ -26,8 +30,26 @@ struct ChatMLXApp: App { ) .environment(runner) .frame(minWidth: 900, minHeight: 580) + .errorAlert( + isPresented: $conversationViewModel.showErrorAlert, + title: $settingsViewModel.errorTitle, + error: $conversationViewModel.error + ) + } + .environment(\.managedObjectContext, persistenceController.container.viewContext) + .onChange(of: scenePhase) { _, newValue in + if newValue == .background { + let context = persistenceController.container.viewContext + if context.hasChanges { + do { + try context.save() + } catch { + logger.error( + "scenePhase.background save error: \(error.localizedDescription)") + } + } + } } - .modelContainer(for: [Conversation.self, Message.self]) Settings { SettingsView() @@ -38,7 +60,12 @@ struct ChatMLXApp: App { ) .environment(runner) .frame(width: 620, height: 480) + .errorAlert( + isPresented: $settingsViewModel.showErrorAlert, + title: $settingsViewModel.errorTitle, + error: $settingsViewModel.error + ) } - .modelContainer(for: [Conversation.self, Message.self]) + .environment(\.managedObjectContext, persistenceController.container.viewContext) } } diff --git a/ChatMLX/Components/ErrorAlertModifier.swift b/ChatMLX/Components/ErrorAlertModifier.swift new file mode 100644 index 0000000..8285a22 --- /dev/null +++ b/ChatMLX/Components/ErrorAlertModifier.swift @@ -0,0 +1,42 @@ +// +// ErrorAlertModifier.swift +// ChatMLX +// +// Created by John Mai on 2024/10/3. +// + +import SwiftUI + +struct ErrorAlertModifier: ViewModifier { + @Binding var showErrorAlert: Bool + @Binding var errorTitle: String? + @Binding var error: Error? + + func body(content: Content) -> some View { + content + .alert( + errorTitle ?? "Error", isPresented: $showErrorAlert, + actions: { + Button("OK") { + error = nil + } + + Button("Feedback") { + error = nil + NSWorkspace.shared.open( + URL(string: "https://github.com/maiqingqiang/ChatMLX/issues")!) + } + }, + message: { + Text(error?.localizedDescription ?? "An unknown error occurred.") + }) + } +} + +extension View { + func errorAlert(isPresented: Binding, title: Binding, error: Binding) + -> some View + { + modifier(ErrorAlertModifier(showErrorAlert: isPresented, errorTitle: title, error: error)) + } +} diff --git a/ChatMLX/Components/UltramanNavigationSplitView.swift b/ChatMLX/Components/UltramanNavigationSplitView.swift index 687e710..cf420a9 100644 --- a/ChatMLX/Components/UltramanNavigationSplitView.swift +++ b/ChatMLX/Components/UltramanNavigationSplitView.swift @@ -131,6 +131,7 @@ struct UltramanNavigationSplitView: View { } } + @MainActor @ViewBuilder func header() -> some View { VStack(spacing: 0) { diff --git a/ChatMLX/Extensions/Binding+Extensions.swift b/ChatMLX/Extensions/Binding+Extensions.swift new file mode 100644 index 0000000..3d058a1 --- /dev/null +++ b/ChatMLX/Extensions/Binding+Extensions.swift @@ -0,0 +1,15 @@ +// +// Binding+Extensions.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// + +import Foundation +import SwiftUI + +extension Binding { + func toUnwrapped(defaultValue: T) -> Binding where Value == T? { + Binding(get: { self.wrappedValue ?? defaultValue }, set: { self.wrappedValue = $0 }) + } +} diff --git a/ChatMLX/Extensions/Date+Extensions.swift b/ChatMLX/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..74676a3 --- /dev/null +++ b/ChatMLX/Extensions/Date+Extensions.swift @@ -0,0 +1,31 @@ +// +// Date+Extensions.swift +// ChatMLX +// +// Created by John Mai on 2024/10/1. +// + +import Foundation + +extension Date { + func toFormatted( + style: DateFormatter.Style = .medium, + locale: Locale = .current + ) -> String { + let formatter = DateFormatter() + formatter.dateStyle = style + formatter.timeStyle = style + formatter.locale = locale + return formatter.string(from: self) + } + + func toTimeFormatted( + style: DateFormatter.Style = .medium, + locale: Locale = .current + ) -> String { + let formatter = DateFormatter() + formatter.timeStyle = style + formatter.locale = locale + return formatter.string(from: self) + } +} diff --git a/ChatMLX/Extensions/Defaults+Extensions.swift b/ChatMLX/Extensions/Defaults+Extensions.swift index b5ed4e7..72b15d3 100644 --- a/ChatMLX/Extensions/Defaults+Extensions.swift +++ b/ChatMLX/Extensions/Defaults+Extensions.swift @@ -11,7 +11,7 @@ import SwiftUI extension Defaults.Keys { static let defaultModel = Key("defaultModel", default: "") static let language = Key("language", default: .english) - static let backgroundBlurRadius = Key("backgroundBlurRadius", default: 35) + static let backgroundBlurRadius = Key("backgroundBlurRadius", default: 35) static let backgroundColor = Key("backgroundColor", default: .black.opacity(0.4)) static let huggingFaceEndpoint = Key( "huggingFaceEndpoint", default: "https://huggingface.co") @@ -23,10 +23,11 @@ extension Defaults.Keys { static let defaultTitle = Key("defaultTitle", default: "Default Conversation") static let defaultTemperature = Key("defaultTemperature", default: 0.6) static let defaultTopP = Key("defaultTopP", default: 1.0) - static let defaultUseMaxLength = Key("defaultUseMaxLength", default: false) - static let defaultMaxLength = Key("defaultMaxLength", default: 256) - static let defaultRepetitionContextSize = Key("defaultRepetitionContextSize", default: 20) - static let defaultMaxMessagesLimit = Key("defaultMaxMessagesCount", default: 20) + static let defaultUseMaxLength = Key("defaultUseMaxLength", default: true) + static let defaultMaxLength = Key("defaultMaxLength", default: 1024) + static let defaultRepetitionContextSize = Key( + "defaultRepetitionContextSize", default: 20) + static let defaultMaxMessagesLimit = Key("defaultMaxMessagesCount", default: 20) static let defaultUseMaxMessagesLimit = Key("defaultUseMaxMessagesCount", default: false) static let defaultRepetitionPenalty = Key("defaultRepetitionPenalty", default: 0) static let defaultUseRepetitionPenalty = Key( @@ -34,6 +35,5 @@ extension Defaults.Keys { static let defaultUseSystemPrompt = Key("defaultUseSystemPrompt", default: false) static let defaultSystemPrompt = Key("defaultSystemPrompt", default: "") - static let gpuCacheLimit = Key("gpuCacheLimit", default: 128) - + static let gpuCacheLimit = Key("gpuCacheLimit", default: 128) } diff --git a/ChatMLX/Extensions/TimeInterval+Extensions.swift b/ChatMLX/Extensions/TimeInterval+Extensions.swift new file mode 100644 index 0000000..9e4a6d8 --- /dev/null +++ b/ChatMLX/Extensions/TimeInterval+Extensions.swift @@ -0,0 +1,29 @@ +// +// TimeInterval+Extensions.swift +// ChatMLX +// +// Created by John Mai on 2024/10/3. +// + +import Foundation + +extension TimeInterval { + func formatted( + allowedUnits: NSCalendar.Unit = [.hour, .minute, .second], + unitsStyle: DateComponentsFormatter.UnitsStyle = .abbreviated, + includingMilliseconds: Bool = true + ) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = allowedUnits + formatter.unitsStyle = unitsStyle + + var formattedString = formatter.string(from: self) ?? "" + + if includingMilliseconds { + let milliseconds = Int((self.truncatingRemainder(dividingBy: 1)) * 1000) + formattedString += String(format: " %03dms", milliseconds) + } + + return formattedString + } +} diff --git a/ChatMLX/Features/Conversation/ConversationDetailView.swift b/ChatMLX/Features/Conversation/ConversationDetailView.swift index 1ffaea5..8cf4778 100644 --- a/ChatMLX/Features/Conversation/ConversationDetailView.swift +++ b/ChatMLX/Features/Conversation/ConversationDetailView.swift @@ -10,32 +10,30 @@ import Defaults import Luminare import MLX import MLXLLM -import SwiftData import SwiftUI struct ConversationDetailView: View { + @ObservedObject var conversation: Conversation + @Environment(LLMRunner.self) var runner - @Binding var conversation: Conversation + @Environment(\.managedObjectContext) private var viewContext + @Environment(ConversationViewModel.self) private var vm + @State private var newMessage = "" - @Environment(\.modelContext) private var modelContext - @FocusState private var isInputFocused: Bool - @Environment(ConversationView.ViewModel.self) private - var conversationViewModel + @State private var showRightSidebar = false @State private var showInfoPopover = false - @Namespace var bottomId + @State private var localModels: [LocalModel] = [] @State private var displayStyle: DisplayStyle = .markdown @State private var isEditorFullScreen = false @State private var showToast = false @State private var toastMessage = "" @State private var toastType: AlertToast.AlertType = .regular - @State private var loading = true + @State private var scrollViewProxy: ScrollViewProxy? - var sortedMessages: [Message] { - conversation.messages.sorted { $0.timestamp < $1.timestamp } - } + @FocusState private var isInputFocused: Bool var body: some View { ZStack(alignment: .trailing) { @@ -56,7 +54,7 @@ struct ConversationDetailView: View { } } - RightSidebarView(conversation: $conversation) + RightSidebarView(conversation: conversation) } } .onAppear(perform: loadModels) @@ -79,62 +77,61 @@ struct ConversationDetailView: View { } .buttonStyle(.plain) } - } + @MainActor @ViewBuilder private func MessageBox() -> some View { ScrollViewReader { proxy in ScrollView { LazyVStack { - ForEach(sortedMessages) { message in + ForEach(conversation.messages) { message in MessageBubbleView( message: message, - displayStyle: $displayStyle, - onDelete: { - deleteMessage(message) - }, - onRegenerate: { - regenerateMessage(message) - } - ) + displayStyle: $displayStyle + ).id(message.id) } } .padding() - .id(bottomId) } .onChange( - of: sortedMessages.last, - { - proxy.scrollTo(bottomId, anchor: .bottom) + of: conversation.messages.last, + { _, _ in + scrollToBottom() } ) .onAppear { - proxy.scrollTo(bottomId, anchor: .bottom) + scrollViewProxy = proxy + scrollToBottom() } } } + private func scrollToBottom() { + guard let lastMessageId = conversation.messages.last?.id, let scrollViewProxy else { + return + } + + withAnimation { + scrollViewProxy.scrollTo(lastMessageId, anchor: .bottom) + } + } + @MainActor + @ViewBuilder private func EditorToolbar() -> some View { HStack { Button { withAnimation { - if displayStyle == .markdown { - displayStyle = .plain - } else { - displayStyle = .markdown - } + displayStyle = (displayStyle == .markdown) ? .plain : .markdown } } label: { - if displayStyle == .markdown { - Image("doc-plaintext") - } else { - Image("markdown") - } + Image(displayStyle == .markdown ? "plaintext" : "markdown") } - Button(action: conversation.clearMessages) { + Button(action: { + conversation.messages = [] + }) { Image("clear") } @@ -172,28 +169,28 @@ struct ConversationDetailView: View { .popover(isPresented: $showInfoPopover) { VStack(alignment: .leading) { LabeledContent { - Text(formatTimeInterval(conversation.promptTime)) + Text(conversation.promptTime.formatted()) } label: { Text("Prompt Time") .fontWeight(.bold) } LabeledContent { - Text("\(Int(conversation.promptTokensPerSecond ?? 0))") + Text("\(Int(conversation.promptTokensPerSecond))") } label: { Text("Prompt Tokens/second") .fontWeight(.bold) } LabeledContent { - Text(formatTimeInterval(conversation.generateTime)) + Text(conversation.generateTime.formatted()) } label: { Text("Generate Time") .fontWeight(.bold) } LabeledContent { - Text("\(Int(conversation.tokensPerSecond ?? 0))") + Text("\(Int(conversation.tokensPerSecond))") } label: { Text("Generate Tokens/second") .fontWeight(.bold) @@ -296,17 +293,27 @@ struct ConversationDetailView: View { return } - conversation.addMessage( - Message( - role: .user, - content: trimmedMessage - ) - ) newMessage = "" isInputFocused = false - Task { - await runner.generate(conversation: conversation) + Message(context: viewContext).user(content: trimmedMessage, conversation: conversation) + + runner.generate(conversation: conversation, in: viewContext) { + scrollToBottom() + } + + scrollToBottom() + + Task(priority: .background) { + do { + try await viewContext.perform { + if viewContext.hasChanges { + try viewContext.save() + } + } + } catch { + vm.throwError(error, title: "Send Message Failed") + } } } @@ -355,69 +362,13 @@ struct ConversationDetailView: View { loading = false } } catch { - showToastMessage( - "loadModels failed: \(error.localizedDescription)", - type: .error(Color.red) - ) + vm.throwError(error, title: "Load Models Failed") } } - private func formatTimeInterval(_ interval: TimeInterval?) -> String { - guard interval != nil else { - return "" - } - - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .abbreviated - return formatter.string(from: interval!) ?? "" - } - private func showToastMessage(_ message: String, type: AlertToast.AlertType) { toastMessage = message toastType = type showToast = true } - - private func deleteMessage(_ message: Message) { - guard message.role == .user else { return } - - let sortedMessages = conversation.messages.sorted { - $0.timestamp < $1.timestamp - } - - if let index = sortedMessages.firstIndex(where: { $0.id == message.id }) { - let messages = sortedMessages[index...] - for messageToDelete in messages { - conversation.messages.removeAll(where: { - $0.id == messageToDelete.id - }) - modelContext.delete(messageToDelete) - } - conversation.updatedAt = Date() - } - } - - private func regenerateMessage(_ message: Message) { - guard message.role == .assistant else { return } - - let sortedMessages = conversation.messages.sorted { - $0.timestamp < $1.timestamp - } - - if let index = sortedMessages.firstIndex(where: { $0.id == message.id }) { - let messages = sortedMessages[index...] - for messageToDelete in messages { - conversation.messages.removeAll(where: { - $0.id == messageToDelete.id - }) - modelContext.delete(messageToDelete) - } - conversation.updatedAt = Date() - } - - Task { - await runner.generate(conversation: conversation) - } - } } diff --git a/ChatMLX/Features/Conversation/ConversationSidebarItem.swift b/ChatMLX/Features/Conversation/ConversationSidebarItem.swift index c050949..c8fedfd 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarItem.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarItem.swift @@ -8,29 +8,16 @@ import SwiftUI struct ConversationSidebarItem: View { - let conversation: Conversation + @ObservedObject var conversation: Conversation + + @Environment(\.managedObjectContext) private var viewContext + @Binding var selectedConversation: Conversation? - @Environment(\.modelContext) private var modelContext @State private var isHovering: Bool = false @State private var isActive: Bool = false @State private var showIndicator: Bool = false - private var sortedMessages: [Message] { - conversation.messages.sorted { $0.timestamp < $1.timestamp } - } - - private var firstMessageContent: String { - sortedMessages.first?.content ?? "" - } - - private var lastMessageTime: String { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - return formatter.string(from: conversation.messages.last?.timestamp ?? Date()) - } - var body: some View { Button { selectedConversation = conversation @@ -40,13 +27,13 @@ struct ConversationSidebarItem: View { .font(.headline) HStack { - Text(firstMessageContent) + Text(conversation.messages.first?.content ?? "") .font(.subheadline) .lineLimit(1) Spacer() - Text(lastMessageTime) + Text(conversation.updatedAt.toFormatted()) .font(.caption) } .foregroundStyle(.white.opacity(0.7)) @@ -74,15 +61,6 @@ struct ConversationSidebarItem: View { } private func deleteConversation() { - modelContext.delete(conversation) - - do { - try modelContext.save() - if selectedConversation == conversation { - selectedConversation = nil - } - } catch { - logger.error("deleteConversation failed: \(error)") - } + try? PersistenceController.shared.delete(conversation) } } diff --git a/ChatMLX/Features/Conversation/ConversationSidebarView.swift b/ChatMLX/Features/Conversation/ConversationSidebarView.swift index 7b11ed0..3883da4 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarView.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarView.swift @@ -5,47 +5,36 @@ // Created by John Mai on 2024/8/3. // +import Defaults import Luminare -import SwiftData import SwiftUI struct ConversationSidebarView: View { - @Query private var conversations: [Conversation] + @Environment(ConversationViewModel.self) private var conversationViewModel + @Binding var selectedConversation: Conversation? - @Environment(\.modelContext) private var modelContext + + @Environment(\.managedObjectContext) private var viewContext + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Conversation.updatedAt, ascending: false)], + animation: .default + ) + private var conversations: FetchedResults + @State private var showingNewConversationAlert = false @State private var newConversationTitle = "" @State private var showingClearConfirmation = false let padding: CGFloat = 8 - var sortedConversations: [Conversation] { - conversations.sorted { $0.updatedAt > $1.updatedAt } - } - - var filteredConversations: [Conversation] { - if keyword.isEmpty { - sortedConversations - } else { - sortedConversations.filter { conversation in - conversation.title.lowercased().contains(keyword.lowercased()) - || conversation.messages.contains { message in - message.content.lowercased().contains( - keyword.lowercased()) - } - } - } - } - @State private var keyword = "" var body: some View { VStack(spacing: 0) { HStack { Spacer() - Button(action: { - createConversation() - }) { + Button(action: conversationViewModel.createConversation) { Image(systemName: "plus") } @@ -70,14 +59,16 @@ struct ConversationSidebarView: View { LuminareSection { UltramanTextField( - $keyword, placeholder: Text("Search Conversation...") + $keyword, placeholder: Text("Search Conversation..."), + onSubmit: updateSearchPredicate ) + .frame(height: 25) }.padding(.horizontal, padding) ScrollView { LazyVStack(spacing: 0) { - ForEach(filteredConversations) { conversation in + ForEach(conversations) { conversation in ConversationSidebarItem( conversation: conversation, selectedConversation: $selectedConversation @@ -90,18 +81,13 @@ struct ConversationSidebarView: View { .background(.black.opacity(0.4)) } - private func createConversation() { - let conversation = Conversation() - modelContext.insert(conversation) - selectedConversation = conversation - } - - private func clearAllConversations() { - do { - try modelContext.delete(model: Conversation.self) - selectedConversation = nil - } catch { - logger.error("Error deleting all conversations: \(error)") + private func updateSearchPredicate() { + if keyword.isEmpty { + conversations.nsPredicate = nil + } else { + conversations.nsPredicate = NSPredicate( + format: "title CONTAINS [cd] %@ OR ANY messages.content CONTAINS [cd] %@", keyword, + keyword) } } } diff --git a/ChatMLX/Features/Conversation/ConversationView.swift b/ChatMLX/Features/Conversation/ConversationView.swift index b13a135..27067d4 100644 --- a/ChatMLX/Features/Conversation/ConversationView.swift +++ b/ChatMLX/Features/Conversation/ConversationView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ConversationView: View { - @Environment(ViewModel.self) private var conversationViewModel + @Environment(ConversationViewModel.self) private var conversationViewModel var body: some View { @Bindable var conversationViewModel = conversationViewModel @@ -26,15 +26,14 @@ struct ConversationView: View { .ultramanMinimalistWindowStyle() } + @MainActor @ViewBuilder private func Detail() -> some View { Group { if let conversation = conversationViewModel.selectedConversation { ConversationDetailView( - conversation: Binding( - get: { conversation }, - set: { conversationViewModel.selectedConversation = $0 } - )) + conversation: conversation + ).id(conversation.id) } else { EmptyConversation() } @@ -42,14 +41,6 @@ struct ConversationView: View { } } -extension ConversationView { - @Observable - class ViewModel { - var detailWidth: CGFloat = 550 - var selectedConversation: Conversation? - } -} - #Preview { ConversationView() } diff --git a/ChatMLX/Features/Conversation/ConversationViewModel.swift b/ChatMLX/Features/Conversation/ConversationViewModel.swift new file mode 100644 index 0000000..b9f0296 --- /dev/null +++ b/ChatMLX/Features/Conversation/ConversationViewModel.swift @@ -0,0 +1,36 @@ +// +// ConversationViewModel.swift +// ChatMLX +// +// Created by John Mai on 2024/10/3. +// + +import SwiftUI + +@Observable +class ConversationViewModel { + var detailWidth: CGFloat = 550 + var selectedConversation: Conversation? + + var error: Error? + var errorTitle: String? + var showErrorAlert = false + + func throwError(_ error: Error, title: String? = nil) { + logger.error("\(error.localizedDescription)") + self.error = error + errorTitle = title + showErrorAlert = true + } + + func createConversation() { + do { + let context = PersistenceController.shared.container.viewContext + let conversation = Conversation(context: context) + try PersistenceController.shared.save() + selectedConversation = conversation + } catch { + throwError(error, title: "Create Conversation Failed") + } + } +} diff --git a/ChatMLX/Features/Conversation/EmptyConversation.swift b/ChatMLX/Features/Conversation/EmptyConversation.swift index e0fb9d6..172df0b 100644 --- a/ChatMLX/Features/Conversation/EmptyConversation.swift +++ b/ChatMLX/Features/Conversation/EmptyConversation.swift @@ -9,8 +9,7 @@ import Luminare import SwiftUI struct EmptyConversation: View { - @Environment(\.modelContext) private var modelContext - @Environment(ConversationView.ViewModel.self) private var conversationViewModel + @Environment(ConversationViewModel.self) private var conversationViewModel var body: some View { ContentUnavailableView { @@ -20,7 +19,7 @@ struct EmptyConversation: View { Text("Please select a new conversation") .foregroundColor(.white) Button( - action: createConversation, + action: conversationViewModel.createConversation, label: { HStack { Image(systemName: "plus") @@ -33,10 +32,4 @@ struct EmptyConversation: View { .fixedSize() } } - - private func createConversation() { - let conversation = Conversation() - modelContext.insert(conversation) - conversationViewModel.selectedConversation = conversation - } } diff --git a/ChatMLX/Features/Conversation/MessageBubbleView.swift b/ChatMLX/Features/Conversation/MessageBubbleView.swift index 368c0d4..20ac639 100644 --- a/ChatMLX/Features/Conversation/MessageBubbleView.swift +++ b/ChatMLX/Features/Conversation/MessageBubbleView.swift @@ -10,11 +10,14 @@ import MarkdownUI import SwiftUI struct MessageBubbleView: View { - let message: Message + @ObservedObject var message: Message @Binding var displayStyle: DisplayStyle @State private var showToast = false - var onDelete: () -> Void - var onRegenerate: () -> Void + + @Environment(LLMRunner.self) var runner + @Environment(ConversationViewModel.self) var vm + + @Environment(\.managedObjectContext) private var viewContext private func copyText() { let pasteboard = NSPasteboard.general @@ -32,12 +35,15 @@ struct MessageBubbleView: View { userMessageView } } + .textSelection(.enabled) .padding(.vertical, 8) .toast(isPresenting: $showToast, duration: 1.5, offsetY: 30) { AlertToast(displayMode: .hud, type: .complete(.green), title: "Copied") } } + @MainActor + @ViewBuilder private var assistantMessageView: some View { HStack(alignment: .top, spacing: 12) { Image("AppLogo") @@ -58,8 +64,6 @@ struct MessageBubbleView: View { ForegroundColor(.white) } .markdownTheme(.customGitHub) - .textSelection(.enabled) - } else { Text(message.content) } @@ -82,15 +86,15 @@ struct MessageBubbleView: View { .help("Copy") } - Button(action: onRegenerate) { + Button(action: regenerate) { Image(systemName: "arrow.clockwise") .help("Regenerate") } - Text(formatDate(message.timestamp)) + Text(message.updatedAt.toTimeFormatted()) .font(.caption) - if message.role == .assistant, !message.isComplete { + if message.role == .assistant, message.inferring { ProgressView() .controlSize(.small) .colorInvert() @@ -107,6 +111,8 @@ struct MessageBubbleView: View { } } + @MainActor + @ViewBuilder private var userMessageView: some View { VStack(alignment: .trailing) { Text(message.content) @@ -116,7 +122,7 @@ struct MessageBubbleView: View { .cornerRadius(8) HStack { - Text(formatDate(message.timestamp)) + Text(message.updatedAt.toTimeFormatted()) .font(.caption) Button(action: copyText) { @@ -124,7 +130,7 @@ struct MessageBubbleView: View { .help("Copy") } - Button(action: onDelete) { + Button(action: delete) { Image(systemName: "trash") } } @@ -135,9 +141,44 @@ struct MessageBubbleView: View { } } - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - return formatter.string(from: date) + private func delete() { + guard message.role == .user else { return } + let conversation = message.conversation + let messages = conversation.messages + if let index = messages.firstIndex(of: message) { + for message in messages[index...] { + viewContext.delete(message) + } + } + + Task(priority: .background) { + do { + try await viewContext.perform { + if viewContext.hasChanges { + try viewContext.save() + } + } + } catch { + vm.throwError(error, title: "Delete Message Failed") + } + } + } + + private func regenerate() { + guard message.role == .assistant else { return } + + Task { + let conversation = message.conversation + let messages = conversation.messages + if let index = messages.firstIndex(of: message) { + for message in messages[index...] { + viewContext.delete(message) + } + } + + await MainActor.run { + runner.generate(conversation: conversation, in: viewContext) + } + } } } diff --git a/ChatMLX/Features/Conversation/RightSidebarView.swift b/ChatMLX/Features/Conversation/RightSidebarView.swift index 644d33a..0f0c09c 100644 --- a/ChatMLX/Features/Conversation/RightSidebarView.swift +++ b/ChatMLX/Features/Conversation/RightSidebarView.swift @@ -10,7 +10,7 @@ import Luminare import SwiftUI struct RightSidebarView: View { - @Binding var conversation: Conversation + @ObservedObject var conversation: Conversation private let padding: CGFloat = 6 @@ -80,7 +80,7 @@ struct RightSidebarView: View { Double(conversation.maxLength) }, set: { - conversation.maxLength = Int($0) + conversation.maxLength = Int64($0) } ), in: 0 ... 8192, step: 1 ) { @@ -162,7 +162,7 @@ struct RightSidebarView: View { Double(conversation.maxMessagesLimit) }, set: { - conversation.maxMessagesLimit = Int($0) + conversation.maxMessagesLimit = Int32($0) } ), in: 1 ... 50, step: 1 ) { diff --git a/ChatMLX/Features/Settings/DefaultConversationView.swift b/ChatMLX/Features/Settings/DefaultConversationView.swift index 573d894..c4b8449 100644 --- a/ChatMLX/Features/Settings/DefaultConversationView.swift +++ b/ChatMLX/Features/Settings/DefaultConversationView.swift @@ -27,10 +27,11 @@ struct DefaultConversationView: View { @State private var localModels: [LocalModel] = [] + @Environment(SettingsViewModel.self) var vm + private let padding: CGFloat = 6 var body: some View { - ScrollView { VStack { LuminareSection("Title") { @@ -104,7 +105,7 @@ struct DefaultConversationView: View { CompactSlider( value: Binding( get: { Double(defaultMaxLength) }, - set: { defaultMaxLength = Int($0) } + set: { defaultMaxLength = Int64($0) } ), in: 0 ... 8192, step: 1 ) { Text("\(defaultMaxLength)") @@ -121,7 +122,7 @@ struct DefaultConversationView: View { CompactSlider( value: Binding( get: { Double(defaultRepetitionContextSize) }, - set: { defaultRepetitionContextSize = Int($0) } + set: { defaultRepetitionContextSize = Int32($0) } ), in: 0 ... 100, step: 1 ) { Text("\(defaultRepetitionContextSize)") @@ -175,7 +176,7 @@ struct DefaultConversationView: View { CompactSlider( value: Binding( get: { Double(defaultMaxMessagesLimit) }, - set: { defaultMaxMessagesLimit = Int($0) } + set: { defaultMaxMessagesLimit = Int32($0) } ), in: 1 ... 50, step: 1 ) { Text("\(defaultMaxMessagesLimit)") @@ -201,9 +202,7 @@ struct DefaultConversationView: View { UltramanTextEditor( text: $defaultSystemPrompt, placeholder: "System prompt", - onSubmit: { - - } + onSubmit: {} ) .frame(height: 100) .padding(padding) @@ -263,7 +262,7 @@ struct DefaultConversationView: View { localModels = models } } catch { - logger.error("loadModels failed: \(error)") + vm.throwError(error, title: "Load Models Failed") } } } diff --git a/ChatMLX/Features/Settings/DownloadManager/DownloadManagerView.swift b/ChatMLX/Features/Settings/DownloadManager/DownloadManagerView.swift index aee8874..178866c 100644 --- a/ChatMLX/Features/Settings/DownloadManager/DownloadManagerView.swift +++ b/ChatMLX/Features/Settings/DownloadManager/DownloadManagerView.swift @@ -8,7 +8,7 @@ import SwiftUI struct DownloadManagerView: View { - @Environment(SettingsView.ViewModel.self) private var settingsViewModel + @Environment(SettingsViewModel.self) private var settingsViewModel @State private var repoId: String = "" @State var showingAlert = false diff --git a/ChatMLX/Features/Settings/DownloadManager/DownloadTaskView.swift b/ChatMLX/Features/Settings/DownloadManager/DownloadTaskView.swift index e9ab3f0..40f230f 100644 --- a/ChatMLX/Features/Settings/DownloadManager/DownloadTaskView.swift +++ b/ChatMLX/Features/Settings/DownloadManager/DownloadTaskView.swift @@ -9,7 +9,7 @@ import SwiftUI struct DownloadTaskView: View { @Bindable var task: DownloadTask - @Environment(SettingsView.ViewModel.self) private var settingsViewModel + @Environment(SettingsViewModel.self) private var settingsViewModel var body: some View { HStack { @@ -69,7 +69,7 @@ struct DownloadTaskView: View { }) }) { Image(systemName: "trash") - .foregroundColor(.red) + .renderingMode(.original) } } } diff --git a/ChatMLX/Features/Settings/GeneralView.swift b/ChatMLX/Features/Settings/GeneralView.swift index 67a732a..83c1cdb 100644 --- a/ChatMLX/Features/Settings/GeneralView.swift +++ b/ChatMLX/Features/Settings/GeneralView.swift @@ -6,6 +6,7 @@ // import CompactSlider +import CoreData import Defaults import Luminare import SwiftUI @@ -16,11 +17,12 @@ struct GeneralView: View { @Default(.language) var language @Default(.gpuCacheLimit) var gpuCacheLimit - @Environment(ConversationView.ViewModel.self) private - var conversationViewModel + @Environment(\.managedObjectContext) private var viewContext + + @Environment(SettingsViewModel.self) private var vm + @Environment(ConversationViewModel.self) private var conversationViewModel @Environment(LLMRunner.self) var runner - @Environment(\.modelContext) private var modelContext let maxRAM = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) @@ -75,7 +77,7 @@ struct GeneralView: View { CompactSlider( value: Binding( get: { Double(gpuCacheLimit) }, - set: { gpuCacheLimit = Int($0) } + set: { gpuCacheLimit = Int32($0) } ), in: 0 ... Double(maxRAM), step: 128 ) { Text("\(Int(gpuCacheLimit))MB") @@ -131,11 +133,21 @@ struct GeneralView: View { private func clearAllConversations() { do { - try modelContext.delete(model: Conversation.self) - try modelContext.save() + let persistenceController = PersistenceController.shared + + let messageObjectIds = try persistenceController.clear("Message") + let conversationObjectIds = try persistenceController.clear("Conversation") + + NSManagedObjectContext.mergeChanges( + fromRemoteContextSave: [ + NSDeletedObjectsKey: messageObjectIds + conversationObjectIds + ], + into: [persistenceController.container.viewContext] + ) + conversationViewModel.selectedConversation = nil } catch { - logger.error("Error deleting all conversations: \(error)") + vm.throwError(error, title: "Clear All Conversations Failed") } } } diff --git a/ChatMLX/Features/Settings/LocalModels/LocalModelsView.swift b/ChatMLX/Features/Settings/LocalModels/LocalModelsView.swift index 1dd9d20..43e3159 100644 --- a/ChatMLX/Features/Settings/LocalModels/LocalModelsView.swift +++ b/ChatMLX/Features/Settings/LocalModels/LocalModelsView.swift @@ -12,6 +12,8 @@ struct LocalModelsView: View { @State private var modelGroups: [LocalModelGroup] = [] @Default(.defaultModel) var defaultModel + @Environment(SettingsViewModel.self) var vm + var body: some View { List { ForEach(modelGroups.indices, id: \.self) { groupIndex in @@ -29,8 +31,7 @@ struct LocalModelsView: View { from: groupIndex) loadModels() } - } - ) + }) } .onDelete { offsets in Task { @@ -102,7 +103,7 @@ struct LocalModelsView: View { modelGroups = groups } } catch { - logger.error("loadModels failed: \(error)") + vm.throwError(error, title: "Load Models Failed") } } @@ -118,7 +119,7 @@ struct LocalModelsView: View { defaultModel = "" } } catch { - logger.error("deleteModel failed: \(error)") + vm.throwError(error, title: "Delete Model Failed") } } } diff --git a/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityItemView.swift b/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityItemView.swift index 0b775a9..3f0801f 100644 --- a/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityItemView.swift +++ b/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityItemView.swift @@ -9,7 +9,7 @@ import SwiftUI struct MLXCommunityItemView: View { @Binding var model: RemoteModel - @Environment(SettingsView.ViewModel.self) var settingsViewModel + @Environment(SettingsViewModel.self) var settingsViewModel var body: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityView.swift b/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityView.swift index e741012..5c4127c 100644 --- a/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityView.swift +++ b/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityView.swift @@ -10,7 +10,7 @@ import Luminare import SwiftUI struct MLXCommunityView: View { - @Environment(SettingsView.ViewModel.self) var settingsViewModel + @Environment(SettingsViewModel.self) var settingsViewModel @State private var searchQuery = "" @State var isFetching = false @@ -80,6 +80,8 @@ struct MLXCommunityView: View { } } + @MainActor + @ViewBuilder var lastRowView: some View { ZStack(alignment: .center) { switch status { diff --git a/ChatMLX/Features/Settings/SettingsSidebarItemView.swift b/ChatMLX/Features/Settings/SettingsSidebarItemView.swift index c291910..1c09dd1 100644 --- a/ChatMLX/Features/Settings/SettingsSidebarItemView.swift +++ b/ChatMLX/Features/Settings/SettingsSidebarItemView.swift @@ -8,7 +8,7 @@ import SwiftUI struct SettingsSidebarItemView: View { - @Environment(SettingsView.ViewModel.self) var settingsViewModel + @Environment(SettingsViewModel.self) var settingsViewModel let tab: SettingsTab diff --git a/ChatMLX/Features/Settings/SettingsSidebarView.swift b/ChatMLX/Features/Settings/SettingsSidebarView.swift index 626b612..53944b9 100644 --- a/ChatMLX/Features/Settings/SettingsSidebarView.swift +++ b/ChatMLX/Features/Settings/SettingsSidebarView.swift @@ -8,7 +8,7 @@ import SwiftUI struct SettingsSidebarView: View { - @Environment(SettingsView.ViewModel.self) var settingsViewModel + @Environment(SettingsViewModel.self) var settingsViewModel let titlebarHeight: CGFloat = 50 let groupSpacing: CGFloat = 4 @@ -19,9 +19,9 @@ struct SettingsSidebarView: View { static let tabs: [SettingsTab] = [ .init(.general, Image(systemName: "gearshape")), .init(.defaultConversation, Image(systemName: "person.bubble")), - .init(.huggingFace, Image("hf-logo-pirate")), + .init(.huggingFace, Image("huggingface")), .init(.models, Image(systemName: "brain")), - .init(.mlxCommunity, Image("mlx-logo-2")), + .init(.mlxCommunity, Image("MLX")), .init( .downloadManager, Image(systemName: "arrow.down.circle"), showIndicator: { $0.tasks.contains { $0.isDownloading } } diff --git a/ChatMLX/Features/Settings/SettingsView.swift b/ChatMLX/Features/Settings/SettingsView.swift index 40ee254..adff8ed 100644 --- a/ChatMLX/Features/Settings/SettingsView.swift +++ b/ChatMLX/Features/Settings/SettingsView.swift @@ -8,16 +8,16 @@ import SwiftUI struct SettingsView: View { - @Environment(SettingsView.ViewModel.self) var settingsViewModel + @Environment(SettingsViewModel.self) var vm var body: some View { - @Bindable var settingsViewModel = settingsViewModel + @Bindable var vm = vm UltramanNavigationSplitView(sidebarWidth: 210) { SettingsSidebarView() } detail: { Group { - switch settingsViewModel.activeTabID { + switch vm.activeTabID { case .general: GeneralView() case .defaultConversation: @@ -39,18 +39,3 @@ struct SettingsView: View { .foregroundColor(.white) } } - -extension SettingsView { - @Observable - class ViewModel { - var tasks: [DownloadTask] = [] - var sidebarWidth: CGFloat = 250 - var activeTabID: SettingsTab.ID = .general - var remoteModels: [RemoteModel] = [] - } -} - -#Preview { - SettingsView() - .environment(SettingsView.ViewModel()) -} diff --git a/ChatMLX/Features/Settings/SettingsViewModel.swift b/ChatMLX/Features/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..ce26612 --- /dev/null +++ b/ChatMLX/Features/Settings/SettingsViewModel.swift @@ -0,0 +1,27 @@ +// +// SettingsViewModel.swift +// ChatMLX +// +// Created by John Mai on 2024/10/3. +// +import SwiftUI + +@Observable +class SettingsViewModel { + var tasks: [DownloadTask] = [] + var sidebarWidth: CGFloat = 250 + var activeTabID: SettingsTab.ID = .general + var remoteModels: [RemoteModel] = [] + + var error: Error? + var errorTitle: String? + var showErrorAlert = false + + func throwError(_ error: Error, title: String? = nil) { + logger.error("\(error.localizedDescription)") + self.error = error + errorTitle = title + showErrorAlert = true + } + +} diff --git a/ChatMLX/Localizable.xcstrings b/ChatMLX/Localizable.xcstrings index c964176..e4c4304 100644 --- a/ChatMLX/Localizable.xcstrings +++ b/ChatMLX/Localizable.xcstrings @@ -10,6 +10,9 @@ "%.2f%%" : { "shouldTranslate" : false }, + "%d" : { + "shouldTranslate" : false + }, "%lld" : { "localizations" : { "zh-Hans" : { @@ -705,6 +708,34 @@ } } }, + "Feedback" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フィードバック" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "의견 피드백" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "反馈" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "反饋" + } + } + } + }, "General" : { "extractionState" : "manual", "localizations" : { @@ -1285,6 +1316,17 @@ } } }, + "OK" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + }, + "shouldTranslate" : false + }, "Please enter Hugging Face Repo ID" : { "extractionState" : "manual", "localizations" : { diff --git a/ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents b/ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents new file mode 100644 index 0000000..d7ee4c8 --- /dev/null +++ b/ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ChatMLX/Models/Conversation+CoreDataClass.swift b/ChatMLX/Models/Conversation+CoreDataClass.swift new file mode 100644 index 0000000..6557201 --- /dev/null +++ b/ChatMLX/Models/Conversation+CoreDataClass.swift @@ -0,0 +1,15 @@ +// +// Conversation+CoreDataClass.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// +// + +import CoreData +import Foundation + +@objc(Conversation) +public class Conversation: NSManagedObject { + +} diff --git a/ChatMLX/Models/Conversation+CoreDataProperties.swift b/ChatMLX/Models/Conversation+CoreDataProperties.swift new file mode 100644 index 0000000..3de83fb --- /dev/null +++ b/ChatMLX/Models/Conversation+CoreDataProperties.swift @@ -0,0 +1,115 @@ +// +// Conversation+CoreDataProperties.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// +// + +import CoreData +import Defaults +import Foundation + +extension Conversation { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Conversation") + } + + @NSManaged public var title: String + @NSManaged public var model: String + @NSManaged public var createdAt: Date + @NSManaged public var updatedAt: Date + @NSManaged public var temperature: Float + @NSManaged public var topP: Float + @NSManaged public var useMaxLength: Bool + @NSManaged public var maxLength: Int64 + @NSManaged public var repetitionContextSize: Int + @NSManaged public var maxMessagesLimit: Int32 + @NSManaged public var useMaxMessagesLimit: Bool + @NSManaged public var useRepetitionPenalty: Bool + @NSManaged public var repetitionPenalty: Float + @NSManaged public var useSystemPrompt: Bool + @NSManaged public var systemPrompt: String + @NSManaged public var promptTime: TimeInterval + @NSManaged public var generateTime: TimeInterval + @NSManaged public var promptTokensPerSecond: Double + @NSManaged public var tokensPerSecond: Double + @NSManaged public var messages: [Message] + + public override func awakeFromInsert() { + super.awakeFromInsert() + + setPrimitiveValue(Defaults[.defaultTitle], forKey: #keyPath(Conversation.title)) + setPrimitiveValue(Defaults[.defaultModel], forKey: #keyPath(Conversation.model)) + + setPrimitiveValue(Defaults[.defaultTemperature], forKey: #keyPath(Conversation.temperature)) + setPrimitiveValue(Defaults[.defaultTopP], forKey: #keyPath(Conversation.topP)) + setPrimitiveValue( + Defaults[.defaultRepetitionContextSize], + forKey: #keyPath(Conversation.repetitionContextSize)) + + setPrimitiveValue( + Defaults[.defaultUseRepetitionPenalty], + forKey: #keyPath(Conversation.useRepetitionPenalty)) + setPrimitiveValue( + Defaults[.defaultRepetitionPenalty], forKey: #keyPath(Conversation.repetitionPenalty)) + + setPrimitiveValue( + Defaults[.defaultUseMaxLength], forKey: #keyPath(Conversation.useMaxLength)) + setPrimitiveValue(Defaults[.defaultMaxLength], forKey: #keyPath(Conversation.maxLength)) + setPrimitiveValue( + Defaults[.defaultMaxMessagesLimit], forKey: #keyPath(Conversation.maxMessagesLimit)) + setPrimitiveValue( + Defaults[.defaultUseMaxMessagesLimit], + forKey: #keyPath(Conversation.useMaxMessagesLimit)) + + setPrimitiveValue( + Defaults[.defaultUseSystemPrompt], forKey: #keyPath(Conversation.useSystemPrompt)) + setPrimitiveValue( + Defaults[.defaultSystemPrompt], forKey: #keyPath(Conversation.systemPrompt)) + + setPrimitiveValue(Date.now, forKey: #keyPath(Conversation.createdAt)) + setPrimitiveValue(Date.now, forKey: #keyPath(Conversation.updatedAt)) + } + + public override func willSave() { + super.willSave() + setPrimitiveValue(Date.now, forKey: #keyPath(Conversation.updatedAt)) + } +} + +// MARK: Generated accessors for messages + +extension Conversation { + @objc(insertObject:inMessagesAtIndex:) + @NSManaged public func insertIntoMessages(_ value: Message, at idx: Int) + + @objc(removeObjectFromMessagesAtIndex:) + @NSManaged public func removeFromMessages(at idx: Int) + + @objc(insertMessages:atIndexes:) + @NSManaged public func insertIntoMessages(_ values: [Message], at indexes: NSIndexSet) + + @objc(removeMessagesAtIndexes:) + @NSManaged public func removeFromMessages(at indexes: NSIndexSet) + + @objc(replaceObjectInMessagesAtIndex:withObject:) + @NSManaged public func replaceMessages(at idx: Int, with value: Message) + + @objc(replaceMessagesAtIndexes:withMessages:) + @NSManaged public func replaceMessages(at indexes: NSIndexSet, with values: [Message]) + + @objc(addMessagesObject:) + @NSManaged public func addToMessages(_ value: Message) + + @objc(removeMessagesObject:) + @NSManaged public func removeFromMessages(_ value: Message) + + @objc(addMessages:) + @NSManaged public func addToMessages(_ values: [Message]) + + @objc(removeMessages:) + @NSManaged public func removeFromMessages(_ values: [Message]) +} + +extension Conversation: Identifiable {} diff --git a/ChatMLX/Models/Conversation.swift b/ChatMLX/Models/Conversation.swift deleted file mode 100644 index 5e76fcf..0000000 --- a/ChatMLX/Models/Conversation.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// Conversation.swift -// ChatMLX -// -// Created by John Mai on 2024/8/4. -// - -import Defaults -import Foundation -import SwiftData - -@Model -class Conversation { - var title: String - var model: String - var createdAt: Date - var updatedAt: Date - @Relationship(deleteRule: .cascade) var messages: [Message] = [] - - var temperature: Float - var topP: Float - var useMaxLength: Bool - var maxLength: Int - var repetitionContextSize: Int - - var maxMessagesLimit: Int - var useMaxMessagesLimit: Bool - - var useRepetitionPenalty: Bool - var repetitionPenalty: Float - - var useSystemPrompt: Bool - var systemPrompt: String - - var promptTime: TimeInterval? - var generateTime: TimeInterval? - var promptTokensPerSecond: Double? - var tokensPerSecond: Double? - - init() { - title = Defaults[.defaultTitle] - model = Defaults[.defaultModel] - temperature = Defaults[.defaultTemperature] - topP = Defaults[.defaultTopP] - useMaxLength = Defaults[.defaultUseMaxLength] - maxLength = Defaults[.defaultMaxLength] - repetitionContextSize = Defaults[.defaultRepetitionContextSize] - repetitionPenalty = Defaults[.defaultRepetitionPenalty] - maxMessagesLimit = Defaults[.defaultMaxMessagesLimit] - useMaxMessagesLimit = Defaults[.defaultUseMaxMessagesLimit] - useRepetitionPenalty = Defaults[.defaultUseRepetitionPenalty] - repetitionPenalty = Defaults[.defaultRepetitionPenalty] - useSystemPrompt = Defaults[.defaultUseSystemPrompt] - systemPrompt = Defaults[.defaultSystemPrompt] - - createdAt = .init() - updatedAt = .init() - } - - func addMessage(_ message: Message) { - messages.append(message) - updatedAt = Date() - } - - func startStreamingMessage(role: Message.Role) -> Message { - let message = Message(role: role, isComplete: false) - addMessage(message) - return message - } - - func updateStreamingMessage(_ message: Message, with content: String) { - message.content = content - updatedAt = Date() - } - - func completeStreamingMessage(_ message: Message) { - message.isComplete = true - updatedAt = Date() - } - - func failedMessage(_ message: Message, with error: Error) { - message.isComplete = true - message.error = error.localizedDescription - updatedAt = Date() - } - - func clearMessages() { - messages.removeAll() - updatedAt = Date() - } -} diff --git a/ChatMLX/Models/Message+CoreDataClass.swift b/ChatMLX/Models/Message+CoreDataClass.swift new file mode 100644 index 0000000..f87083e --- /dev/null +++ b/ChatMLX/Models/Message+CoreDataClass.swift @@ -0,0 +1,41 @@ +// +// Message+CoreDataClass.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// +// + +import CoreData +import Foundation + +@objc(Message) +public class Message: NSManagedObject { + @discardableResult + func user(content: String, conversation: Conversation?) -> Self { + self.role = .user + self.content = content + if let conversation { + self.conversation = conversation + } + return self + } + + @discardableResult + func assistant(conversation: Conversation?) -> Self { + self.role = .assistant + self.inferring = true + self.content = "" + if let conversation { + self.conversation = conversation + } + return self + } + + func format() -> [String: String] { + [ + "role": self.roleRaw, + "content": self.content, + ] + } +} diff --git a/ChatMLX/Models/Message+CoreDataProperties.swift b/ChatMLX/Models/Message+CoreDataProperties.swift new file mode 100644 index 0000000..b0ef408 --- /dev/null +++ b/ChatMLX/Models/Message+CoreDataProperties.swift @@ -0,0 +1,46 @@ +// +// Message+CoreDataProperties.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// +// + +import CoreData +import Foundation + +extension Message { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Message") + } + + @NSManaged public var roleRaw: String + + public var role: Role { + set { + roleRaw = newValue.rawValue + } + get { + Role(rawValue: roleRaw) ?? .assistant + } + } + + @NSManaged public var content: String + @NSManaged public var createdAt: Date + @NSManaged public var inferring: Bool + @NSManaged public var updatedAt: Date + @NSManaged public var error: String? + @NSManaged public var conversation: Conversation + + public override func awakeFromInsert() { + setPrimitiveValue(Date.now, forKey: #keyPath(Message.createdAt)) + setPrimitiveValue(Date.now, forKey: #keyPath(Message.updatedAt)) + } + + public override func willSave() { + super.willSave() + setPrimitiveValue(Date.now, forKey: #keyPath(Message.updatedAt)) + } +} + +extension Message: Identifiable {} diff --git a/ChatMLX/Models/Message.swift b/ChatMLX/Models/Message.swift deleted file mode 100644 index d1e039f..0000000 --- a/ChatMLX/Models/Message.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Message.swift -// ChatMLX -// -// Created by John Mai on 2024/8/4. -// - -import Foundation -import SwiftData - -@Model -class Message { - enum Role: String, Codable { - case user - case assistant - case system - } - - var role: Role - var content: String - var isComplete: Bool - var timestamp: Date - var error: String? - - init( - role: Role, - content: String = "", - isComplete: Bool = false, - timestamp: Date = Date() - ) { - self.role = role - self.content = content - self.isComplete = isComplete - self.timestamp = timestamp - } -} diff --git a/ChatMLX/Models/Role.swift b/ChatMLX/Models/Role.swift new file mode 100644 index 0000000..14819da --- /dev/null +++ b/ChatMLX/Models/Role.swift @@ -0,0 +1,16 @@ +// +// Role.swift +// ChatMLX +// +// Created by John Mai on 2024/10/3. +// + +public enum Role: String, Codable { + case user + case assistant + case system + + var description: String { + "\(self)" + } +} diff --git a/ChatMLX/Models/SettingsTab.swift b/ChatMLX/Models/SettingsTab.swift index f72dff6..b103fb6 100644 --- a/ChatMLX/Models/SettingsTab.swift +++ b/ChatMLX/Models/SettingsTab.swift @@ -24,9 +24,9 @@ struct SettingsTab: Identifiable, Equatable { let id: ID let icon: Image - let showIndicator: ((SettingsView.ViewModel) -> Bool)? + let showIndicator: ((SettingsViewModel) -> Bool)? - init(_ id: ID, _ icon: Image, showIndicator: ((SettingsView.ViewModel) -> Bool)? = nil) { + init(_ id: ID, _ icon: Image, showIndicator: ((SettingsViewModel) -> Bool)? = nil) { self.id = id self.icon = icon self.showIndicator = showIndicator diff --git a/ChatMLX/Utilities/Huggingface/Downloader.swift b/ChatMLX/Utilities/Huggingface/Downloader.swift index ae74024..25a7469 100644 --- a/ChatMLX/Utilities/Huggingface/Downloader.swift +++ b/ChatMLX/Utilities/Huggingface/Downloader.swift @@ -128,10 +128,6 @@ extension Downloader: URLSessionDownloadDelegate { { if let error = error { downloadState.value = .failed(error) - // } else if let response = task.response as? HTTPURLResponse { - // print("HTTP response status code: \(response.statusCode)") - // let headers = response.allHeaderFields - // print("HTTP response headers: \(headers)") } } } diff --git a/ChatMLX/Utilities/LLMRunner.swift b/ChatMLX/Utilities/LLMRunner.swift index 0183f8a..dcb4c66 100644 --- a/ChatMLX/Utilities/LLMRunner.swift +++ b/ChatMLX/Utilities/LLMRunner.swift @@ -62,112 +62,147 @@ class LLMRunner { } } - func generate(conversation: Conversation) async { + private func switchModel(_ conversation: Conversation) { + if conversation.model != modelConfiguration?.name { + loadState = .idle + modelConfiguration = ModelConfiguration.configuration( + id: conversation.model) + } + } + + func prepare(_ conversation: Conversation) -> [[String: String]] { + var messages = conversation.messages + if conversation.useMaxMessagesLimit { + let maxCount = conversation.maxMessagesLimit + 1 + if messages.count > maxCount { + messages = Array(messages.suffix(Int(maxCount))) + if messages.first?.role != .user { + messages = Array(messages.dropFirst()) + } + } + } + + var dictionary = messages[..<(messages.count - 1)].map { + message -> [String: String] in + message.format() + } + + if conversation.useSystemPrompt, !conversation.systemPrompt.isEmpty { + dictionary.insert( + formatMessage( + role: .system, + content: conversation.systemPrompt + ), + at: 0 + ) + } + + return dictionary + } + + func formatMessage(role: Role, content: String) -> [String: String] { + [ + "role": role.rawValue, + "content": content, + ] + } + + func generate( + conversation: Conversation, in context: NSManagedObjectContext, + progressing: @escaping () -> Void = {} + ) { guard !running else { return } running = true - let message = conversation.startStreamingMessage(role: .assistant) + let assistantMessage = Message(context: context).assistant(conversation: conversation) - do { - if conversation.model != modelConfiguration?.name { - loadState = .idle - modelConfiguration = ModelConfiguration.configuration( - id: conversation.model) - } + let parameters = GenerateParameters( + temperature: conversation.temperature, + topP: conversation.topP, + repetitionPenalty: conversation.useRepetitionPenalty + ? conversation.repetitionPenalty : nil, + repetitionContextSize: Int(conversation.repetitionContextSize) + ) - if let modelConfiguration { - guard let modelContainer = try await load() else { - throw LLMRunnerError.failedToLoadModel - } + let useMaxLength = conversation.useMaxLength + let maxLength = conversation.maxLength - var messages = conversation.messages.sorted { - $0.timestamp < $1.timestamp - } + Task { + do { + switchModel(conversation) - if conversation.useMaxMessagesLimit { - let maxCount = conversation.maxMessagesLimit + 1 - if messages.count > maxCount { - messages = Array(messages.suffix(maxCount)) - if messages.first?.role != .user { - messages = Array(messages.dropFirst()) - } + if let modelConfiguration { + guard let modelContainer = try await load() else { + throw LLMRunnerError.failedToLoadModel } - } - messages.insert( - Message( - role: .system, - content: conversation.systemPrompt - ), - at: 0 - ) - - let messagesDicts = messages.map { - message -> [String: String] in - ["role": message.role.rawValue, "content": message.content] - } + let messages = prepare(conversation) - let messageTokens = try await modelContainer.perform { - _, tokenizer in - try tokenizer.applyChatTemplate(messages: messagesDicts) - } + logger.info("prepare messages -> \(messages)") + + let tokens = try await modelContainer.perform { _, tokenizer in + try tokenizer.applyChatTemplate(messages: messages) + } + + MLXRandom.seed(UInt64(Date.timeIntervalSinceReferenceDate * 1000)) + + let result = await modelContainer.perform { model, tokenizer in + MLXLLM.generate( + promptTokens: tokens, + parameters: parameters, + model: model, + tokenizer: tokenizer, + extraEOSTokens: modelConfiguration.extraEOSTokens.union([ + "<|im_end|>", "<|end|>", + ]) + ) { tokens in + if tokens.count % displayEveryNTokens == 0 { + let text = tokenizer.decode(tokens: tokens) + Task { @MainActor in + assistantMessage.content = text + progressing() + } + } - MLXRandom.seed( - UInt64(Date.timeIntervalSinceReferenceDate * 1000)) - - let result = await modelContainer.perform { - model, - tokenizer in - - MLXLLM.generate( - promptTokens: messageTokens, - parameters: GenerateParameters( - temperature: conversation.temperature, - topP: conversation.topP, - repetitionPenalty: conversation.useRepetitionPenalty - ? conversation.repetitionPenalty : nil, - repetitionContextSize: conversation.repetitionContextSize - ), - model: model, - tokenizer: tokenizer, - extraEOSTokens: modelConfiguration.extraEOSTokens.union([ - "<|im_end|>", "<|end|>", - ]) - ) { tokens in - if tokens.count % displayEveryNTokens == 0 { - let text = tokenizer.decode(tokens: tokens) - - Task { @MainActor in - message.content = text + if useMaxLength, tokens.count >= maxLength { + return .stop } + + return .more } + } + + conversation.promptTime = result.promptTime + conversation.generateTime = result.generateTime + conversation.promptTokensPerSecond = result.promptTokensPerSecond + conversation.tokensPerSecond = result.tokensPerSecond - if conversation.useMaxLength, tokens.count >= conversation.maxLength { - return .stop + await MainActor.run { + if result.output != assistantMessage.content { + assistantMessage.content = result.output } - return .more + + assistantMessage.inferring = false + running = false } } - - if result.output != message.content { - message.content = result.output + } catch { + logger.error("LLM Generate Failed: \(error.localizedDescription)") + await MainActor.run { + assistantMessage.inferring = false + assistantMessage.error = error.localizedDescription + running = false } + } - conversation.completeStreamingMessage( - message) - conversation.promptTime = result.promptTime - conversation.generateTime = result.generateTime - conversation.promptTokensPerSecond = - result.promptTokensPerSecond - conversation.tokensPerSecond = result.tokensPerSecond + Task(priority: .background) { + await context.perform { + if context.hasChanges { + try? context.save() + } + } } - } catch { - print("\(error)") - logger.error("LLM Generate Failed: \(error.localizedDescription)") - conversation.failedMessage(message, with: error) } - - running = false } } diff --git a/ChatMLX/Utilities/PersistenceController.swift b/ChatMLX/Utilities/PersistenceController.swift new file mode 100644 index 0000000..f57a70d --- /dev/null +++ b/ChatMLX/Utilities/PersistenceController.swift @@ -0,0 +1,74 @@ +// +// Persistence.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// + +import CoreData + +struct PersistenceController { + static let shared = PersistenceController() + + let container: NSPersistentContainer + + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "ChatMLX") + if inMemory { + container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + } + container.loadPersistentStores(completionHandler: { _, error in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + }) + container.viewContext.automaticallyMergesChangesFromParent = true + } + + func exisits( + _ model: T, + in context: NSManagedObjectContext + ) -> T? { + try? context.existingObject(with: model.objectID) as? T + } + + func delete(_ model: some NSManagedObject) throws { + if let existingContact = exisits(model, in: container.viewContext) { + container.viewContext.delete(existingContact) + Task(priority: .background) { + try await container.viewContext.perform { + try container.viewContext.save() + } + } + } + } + + func clear(_ entityName: String) throws -> [NSManagedObjectID] { + let fetchRequest: NSFetchRequest = NSFetchRequest( + entityName: entityName) + let batchDeteleRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + batchDeteleRequest.resultType = .resultTypeObjectIDs + + if let fetchResult = try container.viewContext.execute(batchDeteleRequest) + as? NSBatchDeleteResult, + let deletedManagedObjectIds = fetchResult.result as? [NSManagedObjectID], + !deletedManagedObjectIds.isEmpty + { + return deletedManagedObjectIds + } + + return [] + } + + func save() throws { + Task(priority: .background) { + let context = container.viewContext + + try await context.perform { + if context.hasChanges { + try context.save() + } + } + } + } +} diff --git a/README-zh_CN.md b/README-zh_CN.md index 919491a..efb9db6 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -7,7 +7,7 @@ [![分支数][forks-shield]][forks-url] [![星标数][stars-shield]][stars-url] [![问题数][issues-shield]][issues-url] -[![MIT 许可证][license-shield]][license-url] +[![Apache 许可证][license-shield]][license-url]
diff --git a/README.md b/README.md index 7de7abe..0249254 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ English | [简体中文](./README-zh_CN.md) [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] -[![MIT License][license-shield]][license-url] +[![Apache License][license-shield]][license-url]