From f0e56895eb4433183ff29979eaec6fbe36503c29 Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Tue, 1 Oct 2024 11:12:13 +0800 Subject: [PATCH 1/9] :recycle: Refactor code --- .../xcshareddata/swiftpm/Package.resolved | 6 +-- .../xcschemes/xcschememanagement.plist | 8 +++ ChatMLX/ChatMLXApp.swift | 15 +++++- .../Conversation/ConversationDetailView.swift | 50 +---------------- .../ConversationSidebarView.swift | 10 ++-- .../Conversation/MessageBubbleView.swift | 54 +++++++++++++++++-- ChatMLX/Models/Conversation.swift | 2 +- ChatMLX/Models/Message.swift | 2 + ChatMLX/Utilities/LLMRunner.swift | 36 ++++++++----- 9 files changed, 103 insertions(+), 80 deletions(-) diff --git a/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f2df0ba..b2d82df 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", @@ -159,8 +159,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/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist b/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist index 745ea1f..f4bd9dd 100644 --- a/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,13 @@ 0 + SuppressBuildableAutocreation + + 526675412C85EDCB001EF113 + + primary + + + diff --git a/ChatMLX/ChatMLXApp.swift b/ChatMLX/ChatMLXApp.swift index 8f99133..9bc4b85 100644 --- a/ChatMLX/ChatMLXApp.swift +++ b/ChatMLX/ChatMLXApp.swift @@ -17,6 +17,17 @@ struct ChatMLXApp: App { @Default(.language) var language @State private var runner = LLMRunner() + var sharedModelContainer: ModelContainer = { + let schema = Schema([Conversation.self, Message.self]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + var body: some Scene { WindowGroup { ConversationView() @@ -27,7 +38,7 @@ struct ChatMLXApp: App { .environment(runner) .frame(minWidth: 900, minHeight: 580) } - .modelContainer(for: [Conversation.self, Message.self]) + .modelContainer(sharedModelContainer) Settings { SettingsView() @@ -39,6 +50,6 @@ struct ChatMLXApp: App { .environment(runner) .frame(width: 620, height: 480) } - .modelContainer(for: [Conversation.self, Message.self]) + .modelContainer(sharedModelContainer) } } diff --git a/ChatMLX/Features/Conversation/ConversationDetailView.swift b/ChatMLX/Features/Conversation/ConversationDetailView.swift index 1ffaea5..8e946f9 100644 --- a/ChatMLX/Features/Conversation/ConversationDetailView.swift +++ b/ChatMLX/Features/Conversation/ConversationDetailView.swift @@ -90,13 +90,7 @@ struct ConversationDetailView: View { ForEach(sortedMessages) { message in MessageBubbleView( message: message, - displayStyle: $displayStyle, - onDelete: { - deleteMessage(message) - }, - onRegenerate: { - regenerateMessage(message) - } + displayStyle: $displayStyle ) } } @@ -378,46 +372,4 @@ struct ConversationDetailView: View { 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/ConversationSidebarView.swift b/ChatMLX/Features/Conversation/ConversationSidebarView.swift index 7b11ed0..6fab894 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarView.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarView.swift @@ -10,7 +10,7 @@ import SwiftData import SwiftUI struct ConversationSidebarView: View { - @Query private var conversations: [Conversation] + @Query(sort: \Conversation.updatedAt, order: .reverse) private var conversations: [Conversation] @Binding var selectedConversation: Conversation? @Environment(\.modelContext) private var modelContext @State private var showingNewConversationAlert = false @@ -19,15 +19,11 @@ struct ConversationSidebarView: View { let padding: CGFloat = 8 - var sortedConversations: [Conversation] { - conversations.sorted { $0.updatedAt > $1.updatedAt } - } - var filteredConversations: [Conversation] { if keyword.isEmpty { - sortedConversations + conversations } else { - sortedConversations.filter { conversation in + conversations.filter { conversation in conversation.title.lowercased().contains(keyword.lowercased()) || conversation.messages.contains { message in message.content.lowercased().contains( diff --git a/ChatMLX/Features/Conversation/MessageBubbleView.swift b/ChatMLX/Features/Conversation/MessageBubbleView.swift index 368c0d4..71cfb94 100644 --- a/ChatMLX/Features/Conversation/MessageBubbleView.swift +++ b/ChatMLX/Features/Conversation/MessageBubbleView.swift @@ -13,8 +13,8 @@ struct MessageBubbleView: View { let message: Message @Binding var displayStyle: DisplayStyle @State private var showToast = false - var onDelete: () -> Void - var onRegenerate: () -> Void + @Environment(\.modelContext) private var modelContext + @Environment(LLMRunner.self) var runner private func copyText() { let pasteboard = NSPasteboard.general @@ -82,7 +82,7 @@ struct MessageBubbleView: View { .help("Copy") } - Button(action: onRegenerate) { + Button(action: regenerate) { Image(systemName: "arrow.clockwise") .help("Regenerate") } @@ -124,7 +124,7 @@ struct MessageBubbleView: View { .help("Copy") } - Button(action: onDelete) { + Button(action: delete) { Image(systemName: "trash") } } @@ -140,4 +140,50 @@ struct MessageBubbleView: View { formatter.dateFormat = "HH:mm:ss" return formatter.string(from: date) } + + private func delete() { + guard message.role == .user else { return } + + if let conversation = message.conversation { + 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 regenerate() { + guard message.role == .assistant else { return } + + if let conversation = message.conversation { + 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/Models/Conversation.swift b/ChatMLX/Models/Conversation.swift index 5e76fcf..578283b 100644 --- a/ChatMLX/Models/Conversation.swift +++ b/ChatMLX/Models/Conversation.swift @@ -15,7 +15,7 @@ class Conversation { var model: String var createdAt: Date var updatedAt: Date - @Relationship(deleteRule: .cascade) var messages: [Message] = [] + @Relationship(deleteRule: .cascade, inverse: \Message.conversation) var messages: [Message] = [] var temperature: Float var topP: Float diff --git a/ChatMLX/Models/Message.swift b/ChatMLX/Models/Message.swift index d1e039f..13e567d 100644 --- a/ChatMLX/Models/Message.swift +++ b/ChatMLX/Models/Message.swift @@ -22,6 +22,8 @@ class Message { var timestamp: Date var error: String? + @Relationship var conversation: Conversation? + init( role: Role, content: String = "", diff --git a/ChatMLX/Utilities/LLMRunner.swift b/ChatMLX/Utilities/LLMRunner.swift index 0183f8a..7a28ac4 100644 --- a/ChatMLX/Utilities/LLMRunner.swift +++ b/ChatMLX/Utilities/LLMRunner.swift @@ -64,7 +64,10 @@ class LLMRunner { func generate(conversation: Conversation) async { guard !running else { return } - running = true + + await MainActor.run { + running = true + } let message = conversation.startStreamingMessage(role: .assistant) @@ -149,25 +152,30 @@ class LLMRunner { } } - if result.output != message.content { - message.content = result.output - } + await MainActor.run { + if result.output != message.content { + message.content = result.output + } - conversation.completeStreamingMessage( - message) - conversation.promptTime = result.promptTime - conversation.generateTime = result.generateTime - conversation.promptTokensPerSecond = - result.promptTokensPerSecond - conversation.tokensPerSecond = result.tokensPerSecond + conversation.completeStreamingMessage( + message) + conversation.promptTime = result.promptTime + conversation.generateTime = result.generateTime + conversation.promptTokensPerSecond = + result.promptTokensPerSecond + conversation.tokensPerSecond = result.tokensPerSecond + } } } catch { print("\(error)") logger.error("LLM Generate Failed: \(error.localizedDescription)") - conversation.failedMessage(message, with: error) + await MainActor.run { + conversation.failedMessage(message, with: error) + } + } + await MainActor.run { + running = false } - - running = false } } From a0986f5266876c81d7bd58fb09cc66e76cf1c7a4 Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Tue, 1 Oct 2024 17:37:02 +0800 Subject: [PATCH 2/9] :bug: Fix multiple issues --- ChatMLX.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/swiftpm/Package.resolved | 22 +----- .../xcshareddata/xcschemes/ChatMLX.xcscheme | 78 +++++++++++++++++++ .../xcschemes/xcschememanagement.plist | 2 +- ChatMLX/ChatMLXApp.swift | 7 +- .../UltramanNavigationSplitView.swift | 1 + ChatMLX/Extensions/Date+Extensions.swift | 20 +++++ .../Conversation/ConversationDetailView.swift | 10 +-- .../ConversationSidebarItem.swift | 15 ++-- .../ConversationSidebarView.swift | 11 +-- .../Conversation/ConversationView.swift | 1 + .../Conversation/MessageBubbleView.swift | 26 +++---- ChatMLX/Features/Settings/GeneralView.swift | 6 +- .../MLXCommunity/MLXCommunityView.swift | 2 + ChatMLX/Models/Conversation.swift | 21 +++-- ChatMLX/Models/Message.swift | 20 ++--- ChatMLX/Utilities/LLMRunner.swift | 26 ++++--- 17 files changed, 184 insertions(+), 92 deletions(-) create mode 100644 ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme create mode 100644 ChatMLX/Extensions/Date+Extensions.swift diff --git a/ChatMLX.xcodeproj/project.pbxproj b/ChatMLX.xcodeproj/project.pbxproj index 03dc790..d9a9bd5 100644 --- a/ChatMLX.xcodeproj/project.pbxproj +++ b/ChatMLX.xcodeproj/project.pbxproj @@ -73,6 +73,7 @@ 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 */; }; 528DBE2F2C9C86FB004CDD88 /* Transformers in Frameworks */ = {isa = PBXBuildFile; productRef = 528DBE2E2C9C86FB004CDD88 /* Transformers */; }; 52E50B1D2C8D6E81005A89DE /* LLM in Frameworks */ = {isa = PBXBuildFile; productRef = 52E50B1C2C8D6E81005A89DE /* LLM */; }; 52E50B202C8D719B005A89DE /* LLM in Frameworks */ = {isa = PBXBuildFile; productRef = 52E50B1F2C8D719B005A89DE /* LLM */; }; @@ -146,6 +147,7 @@ 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -357,6 +359,7 @@ 5266763D2C85F903001EF113 /* Extensions */ = { isa = PBXGroup; children = ( + 528D82252CABE19000163AAB /* Date+Extensions.swift */, 526676382C85F903001EF113 /* Defaults+Extensions.swift */, 526676392C85F903001EF113 /* MarkdownUI+Theme+Extensions.swift */, 5266763A2C85F903001EF113 /* NSWindow+Extensions.swift */, @@ -501,6 +504,7 @@ 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 */, @@ -812,8 +816,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 */ diff --git a/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b2d82df..324b064 100644 --- a/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme b/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme new file mode 100644 index 0000000..f58682c --- /dev/null +++ b/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist b/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist index f4bd9dd..e2cd285 100644 --- a/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ChatMLX.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ ChatMLX.xcscheme_^#shared#^_ orderHint - 0 + 3 SuppressBuildableAutocreation diff --git a/ChatMLX/ChatMLXApp.swift b/ChatMLX/ChatMLXApp.swift index 9bc4b85..c1b27fe 100644 --- a/ChatMLX/ChatMLXApp.swift +++ b/ChatMLX/ChatMLXApp.swift @@ -19,10 +19,13 @@ struct ChatMLXApp: App { var sharedModelContainer: ModelContainer = { let schema = Schema([Conversation.self, Message.self]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + let url = URL.applicationSupportDirectory.appending(path: "ChatMLX/Store.sqlite") + + let modelConfiguration = ModelConfiguration(url: url) do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) + return try ModelContainer(for: schema, configurations: modelConfiguration) } catch { fatalError("Could not create ModelContainer: \(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/Date+Extensions.swift b/ChatMLX/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..90b20d2 --- /dev/null +++ b/ChatMLX/Extensions/Date+Extensions.swift @@ -0,0 +1,20 @@ +// +// Date+Extensions.swift +// ChatMLX +// +// Created by John Mai on 2024/10/1. +// + +import Foundation + +extension Date { + func toFormattedString(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) + } +} diff --git a/ChatMLX/Features/Conversation/ConversationDetailView.swift b/ChatMLX/Features/Conversation/ConversationDetailView.swift index 8e946f9..6d470d1 100644 --- a/ChatMLX/Features/Conversation/ConversationDetailView.swift +++ b/ChatMLX/Features/Conversation/ConversationDetailView.swift @@ -33,10 +33,6 @@ struct ConversationDetailView: View { @State private var loading = true - var sortedMessages: [Message] { - conversation.messages.sorted { $0.timestamp < $1.timestamp } - } - var body: some View { ZStack(alignment: .trailing) { VStack(spacing: 0) { @@ -82,12 +78,13 @@ struct ConversationDetailView: View { } + @MainActor @ViewBuilder private func MessageBox() -> some View { ScrollViewReader { proxy in ScrollView { LazyVStack { - ForEach(sortedMessages) { message in + ForEach(conversation.sortedMessages) { message in MessageBubbleView( message: message, displayStyle: $displayStyle @@ -98,7 +95,7 @@ struct ConversationDetailView: View { .id(bottomId) } .onChange( - of: sortedMessages.last, + of: conversation.sortedMessages.last, { proxy.scrollTo(bottomId, anchor: .bottom) } @@ -110,6 +107,7 @@ struct ConversationDetailView: View { } @MainActor + @ViewBuilder private func EditorToolbar() -> some View { HStack { Button { diff --git a/ChatMLX/Features/Conversation/ConversationSidebarItem.swift b/ChatMLX/Features/Conversation/ConversationSidebarItem.swift index c050949..80a7024 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarItem.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarItem.swift @@ -16,19 +16,16 @@ struct ConversationSidebarItem: View { @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 ?? "" + conversation.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()) + if let message = conversation.messages.last { + return message.updatedAt.toFormattedString() + } + + return "" } var body: some View { diff --git a/ChatMLX/Features/Conversation/ConversationSidebarView.swift b/ChatMLX/Features/Conversation/ConversationSidebarView.swift index 6fab894..4283e11 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarView.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarView.swift @@ -10,7 +10,7 @@ import SwiftData import SwiftUI struct ConversationSidebarView: View { - @Query(sort: \Conversation.updatedAt, order: .reverse) private var conversations: [Conversation] + @Query(Conversation.all, animation: .bouncy) private var conversations: [Conversation] @Binding var selectedConversation: Conversation? @Environment(\.modelContext) private var modelContext @State private var showingNewConversationAlert = false @@ -91,13 +91,4 @@ struct ConversationSidebarView: View { 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)") - } - } } diff --git a/ChatMLX/Features/Conversation/ConversationView.swift b/ChatMLX/Features/Conversation/ConversationView.swift index b13a135..8dbe1c1 100644 --- a/ChatMLX/Features/Conversation/ConversationView.swift +++ b/ChatMLX/Features/Conversation/ConversationView.swift @@ -26,6 +26,7 @@ struct ConversationView: View { .ultramanMinimalistWindowStyle() } + @MainActor @ViewBuilder private func Detail() -> some View { Group { diff --git a/ChatMLX/Features/Conversation/MessageBubbleView.swift b/ChatMLX/Features/Conversation/MessageBubbleView.swift index 71cfb94..86c40dc 100644 --- a/ChatMLX/Features/Conversation/MessageBubbleView.swift +++ b/ChatMLX/Features/Conversation/MessageBubbleView.swift @@ -38,6 +38,8 @@ struct MessageBubbleView: View { } } + @MainActor + @ViewBuilder private var assistantMessageView: some View { HStack(alignment: .top, spacing: 12) { Image("AppLogo") @@ -87,10 +89,10 @@ struct MessageBubbleView: View { .help("Regenerate") } - Text(formatDate(message.timestamp)) + Text(formatDate(message.updatedAt)) .font(.caption) - if message.role == .assistant, !message.isComplete { + if message.role == .assistant, message.inferring { ProgressView() .controlSize(.small) .colorInvert() @@ -107,6 +109,8 @@ struct MessageBubbleView: View { } } + @MainActor + @ViewBuilder private var userMessageView: some View { VStack(alignment: .trailing) { Text(message.content) @@ -116,7 +120,7 @@ struct MessageBubbleView: View { .cornerRadius(8) HStack { - Text(formatDate(message.timestamp)) + Text(formatDate(message.updatedAt)) .font(.caption) Button(action: copyText) { @@ -145,12 +149,8 @@ struct MessageBubbleView: View { guard message.role == .user else { return } if let conversation = message.conversation { - let sortedMessages = conversation.messages.sorted { - $0.timestamp < $1.timestamp - } - - if let index = sortedMessages.firstIndex(where: { $0.id == message.id }) { - let messages = sortedMessages[index...] + if let index = conversation.sortedMessages.firstIndex(where: { $0.id == message.id }) { + let messages = conversation.sortedMessages[index...] for messageToDelete in messages { conversation.messages.removeAll(where: { $0.id == messageToDelete.id @@ -166,12 +166,8 @@ struct MessageBubbleView: View { guard message.role == .assistant else { return } if let conversation = message.conversation { - let sortedMessages = conversation.messages.sorted { - $0.timestamp < $1.timestamp - } - - if let index = sortedMessages.firstIndex(where: { $0.id == message.id }) { - let messages = sortedMessages[index...] + if let index = conversation.sortedMessages.firstIndex(where: { $0.id == message.id }) { + let messages = conversation.sortedMessages[index...] for messageToDelete in messages { conversation.messages.removeAll(where: { $0.id == messageToDelete.id diff --git a/ChatMLX/Features/Settings/GeneralView.swift b/ChatMLX/Features/Settings/GeneralView.swift index 67a732a..67e24b0 100644 --- a/ChatMLX/Features/Settings/GeneralView.swift +++ b/ChatMLX/Features/Settings/GeneralView.swift @@ -8,6 +8,7 @@ import CompactSlider import Defaults import Luminare +import SwiftData import SwiftUI struct GeneralView: View { @@ -131,7 +132,10 @@ struct GeneralView: View { private func clearAllConversations() { do { - try modelContext.delete(model: Conversation.self) + let conversations = try modelContext.fetch(FetchDescriptor()) + for conversation in conversations { + modelContext.delete(conversation) + } try modelContext.save() conversationViewModel.selectedConversation = nil } catch { diff --git a/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityView.swift b/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityView.swift index e741012..942a9c1 100644 --- a/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityView.swift +++ b/ChatMLX/Features/Settings/MLXCommunity/MLXCommunityView.swift @@ -80,6 +80,8 @@ struct MLXCommunityView: View { } } + @MainActor + @ViewBuilder var lastRowView: some View { ZStack(alignment: .center) { switch status { diff --git a/ChatMLX/Models/Conversation.swift b/ChatMLX/Models/Conversation.swift index 578283b..4f33896 100644 --- a/ChatMLX/Models/Conversation.swift +++ b/ChatMLX/Models/Conversation.swift @@ -10,12 +10,16 @@ import Foundation import SwiftData @Model -class Conversation { +final class Conversation { var title: String var model: String var createdAt: Date var updatedAt: Date - @Relationship(deleteRule: .cascade, inverse: \Message.conversation) var messages: [Message] = [] + @Relationship(deleteRule: .cascade) var messages: [Message] = [] + + var sortedMessages: [Message] { + return messages.sorted { $0.updatedAt < $1.updatedAt } + } var temperature: Float var topP: Float @@ -37,6 +41,12 @@ class Conversation { var promptTokensPerSecond: Double? var tokensPerSecond: Double? + static var all: FetchDescriptor { + FetchDescriptor( + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + } + init() { title = Defaults[.defaultTitle] model = Defaults[.defaultModel] @@ -63,7 +73,8 @@ class Conversation { } func startStreamingMessage(role: Message.Role) -> Message { - let message = Message(role: role, isComplete: false) + let message = Message(role: role) + message.inferring = true addMessage(message) return message } @@ -74,12 +85,12 @@ class Conversation { } func completeStreamingMessage(_ message: Message) { - message.isComplete = true + message.inferring = false updatedAt = Date() } func failedMessage(_ message: Message, with error: Error) { - message.isComplete = true + message.inferring = false message.error = error.localizedDescription updatedAt = Date() } diff --git a/ChatMLX/Models/Message.swift b/ChatMLX/Models/Message.swift index 13e567d..be4ed53 100644 --- a/ChatMLX/Models/Message.swift +++ b/ChatMLX/Models/Message.swift @@ -9,7 +9,7 @@ import Foundation import SwiftData @Model -class Message { +final class Message { enum Role: String, Codable { case user case assistant @@ -18,21 +18,23 @@ class Message { var role: Role var content: String - var isComplete: Bool - var timestamp: Date + + @Transient var inferring: Bool = false + + var createdAt: Date + var updatedAt: Date + var error: String? - @Relationship var conversation: Conversation? + var conversation: Conversation? init( role: Role, - content: String = "", - isComplete: Bool = false, - timestamp: Date = Date() + content: String = "" ) { self.role = role self.content = content - self.isComplete = isComplete - self.timestamp = timestamp + self.createdAt = Date() + self.updatedAt = Date() } } diff --git a/ChatMLX/Utilities/LLMRunner.swift b/ChatMLX/Utilities/LLMRunner.swift index 7a28ac4..0a91e1b 100644 --- a/ChatMLX/Utilities/LLMRunner.swift +++ b/ChatMLX/Utilities/LLMRunner.swift @@ -30,7 +30,7 @@ class LLMRunner { var gpuActiveMemory: Int = 0 - let displayEveryNTokens = 4 + let displayEveryNTokens = 10 init() {} @@ -83,9 +83,7 @@ class LLMRunner { throw LLMRunnerError.failedToLoadModel } - var messages = conversation.messages.sorted { - $0.timestamp < $1.timestamp - } + var messages = conversation.sortedMessages if conversation.useMaxMessagesLimit { let maxCount = conversation.maxMessagesLimit + 1 @@ -97,19 +95,23 @@ class LLMRunner { } } - messages.insert( - Message( - role: .system, - content: conversation.systemPrompt - ), - at: 0 - ) + if conversation.useSystemPrompt, !conversation.systemPrompt.isEmpty { + messages.insert( + Message( + role: .system, + content: conversation.systemPrompt + ), + at: 0 + ) + } - let messagesDicts = messages.map { + let messagesDicts = messages[..<(messages.count - 1)].map { message -> [String: String] in ["role": message.role.rawValue, "content": message.content] } + print("messagesDicts", messagesDicts) + let messageTokens = try await modelContainer.perform { _, tokenizer in try tokenizer.applyChatTemplate(messages: messagesDicts) From 7c743dd2501ebc7e206278f2b14786b36dc52341 Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Tue, 1 Oct 2024 17:41:22 +0800 Subject: [PATCH 3/9] :memo: Update README --- README-zh_CN.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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]
From 5fb4f9735a21bcf85961ac120689a45704130619 Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Tue, 1 Oct 2024 23:00:31 +0800 Subject: [PATCH 4/9] :arrow_up: Bump Jinja --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 324b064..3985549 100644 --- a/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ChatMLX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } }, { From 142bab567d6632ca845504f4ca6e97a78d6c65ee Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Wed, 2 Oct 2024 23:32:36 +0800 Subject: [PATCH 5/9] :sparkles: Switch Core Data --- ChatMLX.xcodeproj/project.pbxproj | 59 ++++++++-- ChatMLX/ChatMLXApp.swift | 38 ++++--- ChatMLX/Extensions/Binding+Extensions.swift | 15 +++ ChatMLX/Extensions/Defaults+Extensions.swift | 10 +- .../Conversation/ConversationDetailView.swift | 45 ++++---- .../ConversationSidebarItem.swift | 35 ++---- .../ConversationSidebarView.swift | 48 ++++---- .../Conversation/ConversationView.swift | 21 +--- .../Conversation/ConversationViewModel.swift | 32 ++++++ .../Conversation/EmptyConversation.swift | 10 +- .../Conversation/MessageBubbleView.swift | 70 ++++++------ .../Conversation/RightSidebarView.swift | 8 +- .../Settings/DefaultConversationView.swift | 6 +- ChatMLX/Features/Settings/GeneralView.swift | 22 ++-- ChatMLX/Localizable.xcstrings | 12 ++ .../ChatMLX.xcdatamodel/contents | 34 ++++++ .../Models/Conversation+CoreDataClass.swift | 15 +++ .../Conversation+CoreDataProperties.swift | 104 ++++++++++++++++++ ...onversation.swift => ConversationSW.swift} | 28 +++-- ChatMLX/Models/Message+CoreDataClass.swift | 15 +++ .../Models/Message+CoreDataProperties.swift | 36 ++++++ .../Models/{Message.swift => MessageSW.swift} | 4 +- ChatMLX/Utilities/LLMRunner.swift | 67 ++++++----- ChatMLX/Utilities/PersistenceController.swift | 79 +++++++++++++ 24 files changed, 588 insertions(+), 225 deletions(-) create mode 100644 ChatMLX/Extensions/Binding+Extensions.swift create mode 100644 ChatMLX/Features/Conversation/ConversationViewModel.swift create mode 100644 ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents create mode 100644 ChatMLX/Models/Conversation+CoreDataClass.swift create mode 100644 ChatMLX/Models/Conversation+CoreDataProperties.swift rename ChatMLX/Models/{Conversation.swift => ConversationSW.swift} (75%) create mode 100644 ChatMLX/Models/Message+CoreDataClass.swift create mode 100644 ChatMLX/Models/Message+CoreDataProperties.swift rename ChatMLX/Models/{Message.swift => MessageSW.swift} (90%) create mode 100644 ChatMLX/Utilities/PersistenceController.swift diff --git a/ChatMLX.xcodeproj/project.pbxproj b/ChatMLX.xcodeproj/project.pbxproj index d9a9bd5..156c842 100644 --- a/ChatMLX.xcodeproj/project.pbxproj +++ b/ChatMLX.xcodeproj/project.pbxproj @@ -50,13 +50,13 @@ 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 */; }; + 5266765F2C85F903001EF113 /* ConversationSW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676242C85F903001EF113 /* ConversationSW.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 */; }; + 526676652C85F903001EF113 /* MessageSW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5266762A2C85F903001EF113 /* MessageSW.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 */; }; @@ -74,6 +74,13 @@ 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 */; }; 528DBE2F2C9C86FB004CDD88 /* Transformers in Frameworks */ = {isa = PBXBuildFile; productRef = 528DBE2E2C9C86FB004CDD88 /* Transformers */; }; 52E50B1D2C8D6E81005A89DE /* LLM in Frameworks */ = {isa = PBXBuildFile; productRef = 52E50B1C2C8D6E81005A89DE /* LLM */; }; 52E50B202C8D719B005A89DE /* LLM in Frameworks */ = {isa = PBXBuildFile; productRef = 52E50B1F2C8D719B005A89DE /* LLM */; }; @@ -124,13 +131,13 @@ 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 = ""; }; + 526676242C85F903001EF113 /* ConversationSW.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationSW.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 = ""; }; + 5266762A2C85F903001EF113 /* MessageSW.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageSW.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 = ""; }; @@ -148,6 +155,13 @@ 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -254,6 +268,7 @@ 526676112C85F903001EF113 /* Conversation */ = { isa = PBXGroup; children = ( + 528D83362CADB64300163AAB /* ConversationViewModel.swift */, 5266760A2C85F903001EF113 /* ConversationDetailView.swift */, 5266760B2C85F903001EF113 /* ConversationSidebarItem.swift */, 5266760C2C85F903001EF113 /* ConversationSidebarView.swift */, @@ -321,17 +336,22 @@ 5266762F2C85F903001EF113 /* Models */ = { isa = PBXGroup; children = ( - 526676242C85F903001EF113 /* Conversation.swift */, + 526676242C85F903001EF113 /* ConversationSW.swift */, 526676252C85F903001EF113 /* DisplayStyle.swift */, 526676262C85F903001EF113 /* DownloadTask.swift */, 526676272C85F903001EF113 /* Language.swift */, 526676282C85F903001EF113 /* LocalModel.swift */, 526676292C85F903001EF113 /* LocalModelGroup.swift */, - 5266762A2C85F903001EF113 /* Message.swift */, + 5266762A2C85F903001EF113 /* MessageSW.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 = ""; @@ -349,9 +369,10 @@ 526676372C85F903001EF113 /* Utilities */ = { isa = PBXGroup; children = ( - 526676332C85F903001EF113 /* Huggingface */, 526676342C85F903001EF113 /* LLMRunner.swift */, 526676352C85F903001EF113 /* Logger.swift */, + 528D831B2CAD49E600163AAB /* PersistenceController.swift */, + 526676332C85F903001EF113 /* Huggingface */, ); path = Utilities; sourceTree = ""; @@ -490,11 +511,12 @@ 526676592C85F903001EF113 /* DefaultConversationView.swift in Sources */, 5266766E2C85F903001EF113 /* Logger.swift in Sources */, 526676612C85F903001EF113 /* DownloadTask.swift in Sources */, + 528D83192CAD491900163AAB /* ChatMLX.xcdatamodeld in Sources */, 526676502C85F903001EF113 /* MessageBubbleView.swift in Sources */, 526676582C85F903001EF113 /* AboutView.swift in Sources */, 526676552C85F903001EF113 /* LocalModelsView.swift in Sources */, 526676662C85F903001EF113 /* RemoteModel.swift in Sources */, - 526676652C85F903001EF113 /* Message.swift in Sources */, + 526676652C85F903001EF113 /* MessageSW.swift in Sources */, 526676562C85F903001EF113 /* MLXCommunityItemView.swift in Sources */, 526676442C85F903001EF113 /* UltramanMinimalistWindowModifier.swift in Sources */, 5266764A2C85F903001EF113 /* UltramanWindow.swift in Sources */, @@ -511,6 +533,10 @@ 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 */, + 528D832C2CAD5C9100163AAB /* Message+CoreDataProperties.swift in Sources */, 526676712C85F903001EF113 /* MarkdownUI+Theme+Extensions.swift in Sources */, 526676532C85F903001EF113 /* DownloadTaskView.swift in Sources */, 5266764C2C85F903001EF113 /* ConversationSidebarItem.swift in Sources */, @@ -524,9 +550,11 @@ 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 */, + 5266765F2C85F903001EF113 /* ConversationSW.swift in Sources */, + 528D83372CADB64600163AAB /* ConversationViewModel.swift in Sources */, 5266765D2C85F903001EF113 /* SettingsSidebarView.swift in Sources */, 5266764B2C85F903001EF113 /* ConversationDetailView.swift in Sources */, 526676602C85F903001EF113 /* DisplayStyle.swift in Sources */, @@ -926,6 +954,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/ChatMLXApp.swift b/ChatMLX/ChatMLXApp.swift index c1b27fe..3686bde 100644 --- a/ChatMLX/ChatMLXApp.swift +++ b/ChatMLX/ChatMLXApp.swift @@ -11,25 +11,16 @@ import SwiftUI @main struct ChatMLXApp: App { - @State private var conversationViewModel: ConversationView.ViewModel = .init() + @Environment(\.scenePhase) private var scenePhase + + @State private var conversationViewModel: ConversationViewModel = .init() @State private var settingsViewModel: SettingsView.ViewModel = .init() @Default(.language) var language - @State private var runner = LLMRunner() - - var sharedModelContainer: ModelContainer = { - let schema = Schema([Conversation.self, Message.self]) - - let url = URL.applicationSupportDirectory.appending(path: "ChatMLX/Store.sqlite") - let modelConfiguration = ModelConfiguration(url: url) + @State private var runner = LLMRunner() - do { - return try ModelContainer(for: schema, configurations: modelConfiguration) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() + let persistenceController = PersistenceController.shared var body: some Scene { WindowGroup { @@ -40,8 +31,23 @@ struct ChatMLXApp: App { ) .environment(runner) .frame(minWidth: 900, minHeight: 580) + .alert("Error", isPresented: $conversationViewModel.showErrorAlert, actions: { + Button("OK") { + conversationViewModel.error = nil + } + + Button("Feedback") { + conversationViewModel.error = nil + NSWorkspace.shared.open(URL(string: "https://github.com/maiqingqiang/ChatMLX/issues")!) + } + }, message: { + Text(conversationViewModel.error?.localizedDescription ?? "An unknown error occurred.") + }) + } + .environment(\.managedObjectContext, persistenceController.container.viewContext) + .onChange(of: scenePhase) { _, _ in + try? persistenceController.save() } - .modelContainer(sharedModelContainer) Settings { SettingsView() @@ -53,6 +59,6 @@ struct ChatMLXApp: App { .environment(runner) .frame(width: 620, height: 480) } - .modelContainer(sharedModelContainer) + .environment(\.managedObjectContext, persistenceController.container.viewContext) } } diff --git a/ChatMLX/Extensions/Binding+Extensions.swift b/ChatMLX/Extensions/Binding+Extensions.swift new file mode 100644 index 0000000..a94957c --- /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 == Optional { + Binding(get: { self.wrappedValue ?? defaultValue }, set: { self.wrappedValue = $0 }) + } +} diff --git a/ChatMLX/Extensions/Defaults+Extensions.swift b/ChatMLX/Extensions/Defaults+Extensions.swift index b5ed4e7..cd07c31 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") @@ -24,9 +24,9 @@ extension Defaults.Keys { 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 defaultMaxLength = Key("defaultMaxLength", default: 256) + 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 +34,6 @@ 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/Features/Conversation/ConversationDetailView.swift b/ChatMLX/Features/Conversation/ConversationDetailView.swift index 6d470d1..94dc9e2 100644 --- a/ChatMLX/Features/Conversation/ConversationDetailView.swift +++ b/ChatMLX/Features/Conversation/ConversationDetailView.swift @@ -15,12 +15,15 @@ import SwiftUI struct ConversationDetailView: View { @Environment(LLMRunner.self) var runner - @Binding var conversation: Conversation - @State private var newMessage = "" @Environment(\.modelContext) private var modelContext + + @ObservedObject var conversation: Conversation + @Environment(\.managedObjectContext) private var viewContext + + @State private var newMessage = "" @FocusState private var isInputFocused: Bool - @Environment(ConversationView.ViewModel.self) private - var conversationViewModel + @Environment(ConversationViewModel.self) private + var conversationViewModel @State private var showRightSidebar = false @State private var showInfoPopover = false @Namespace var bottomId @@ -52,7 +55,7 @@ struct ConversationDetailView: View { } } - RightSidebarView(conversation: $conversation) + RightSidebarView(conversation: conversation) } } .onAppear(perform: loadModels) @@ -75,7 +78,6 @@ struct ConversationDetailView: View { } .buttonStyle(.plain) } - } @MainActor @@ -84,7 +86,7 @@ struct ConversationDetailView: View { ScrollViewReader { proxy in ScrollView { LazyVStack { - ForEach(conversation.sortedMessages) { message in + ForEach(conversation.messages) { message in MessageBubbleView( message: message, displayStyle: $displayStyle @@ -95,7 +97,7 @@ struct ConversationDetailView: View { .id(bottomId) } .onChange( - of: conversation.sortedMessages.last, + of: conversation.messages.last, { proxy.scrollTo(bottomId, anchor: .bottom) } @@ -126,7 +128,9 @@ struct ConversationDetailView: View { } } - Button(action: conversation.clearMessages) { + Button(action: { +// conversation.clearMessages() + }) { Image("clear") } @@ -171,7 +175,7 @@ struct ConversationDetailView: View { } LabeledContent { - Text("\(Int(conversation.promptTokensPerSecond ?? 0))") + Text("\(Int(conversation.promptTokensPerSecond))") } label: { Text("Prompt Tokens/second") .fontWeight(.bold) @@ -185,7 +189,7 @@ struct ConversationDetailView: View { } LabeledContent { - Text("\(Int(conversation.tokensPerSecond ?? 0))") + Text("\(Int(conversation.tokensPerSecond))") } label: { Text("Generate Tokens/second") .fontWeight(.bold) @@ -288,17 +292,20 @@ struct ConversationDetailView: View { return } - conversation.addMessage( - Message( - role: .user, - content: trimmedMessage - ) - ) newMessage = "" isInputFocused = false - Task { - await runner.generate(conversation: conversation) + do { + await runner.generate( + message: trimmedMessage, + conversation: conversation, + in: viewContext + ) + + try PersistenceController.shared.save() + } catch { + conversationViewModel.throwError(error: error) + } } } diff --git a/ChatMLX/Features/Conversation/ConversationSidebarItem.swift b/ChatMLX/Features/Conversation/ConversationSidebarItem.swift index 80a7024..69a3860 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarItem.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarItem.swift @@ -5,29 +5,21 @@ // Created by John Mai on 2024/8/4. // +import SwiftData 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 firstMessageContent: String { - conversation.sortedMessages.first?.content ?? "" - } - - private var lastMessageTime: String { - if let message = conversation.messages.last { - return message.updatedAt.toFormattedString() - } - - return "" - } - var body: some View { Button { selectedConversation = conversation @@ -37,13 +29,13 @@ struct ConversationSidebarItem: View { .font(.headline) HStack { - Text(firstMessageContent) + Text(conversation.messages.first?.content ?? "") .font(.subheadline) .lineLimit(1) Spacer() - Text(lastMessageTime) + Text(conversation.messages.last?.updatedAt.toFormattedString() ?? "") .font(.caption) } .foregroundStyle(.white.opacity(0.7)) @@ -71,15 +63,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, in: viewContext) } } diff --git a/ChatMLX/Features/Conversation/ConversationSidebarView.swift b/ChatMLX/Features/Conversation/ConversationSidebarView.swift index 4283e11..05c7608 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarView.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarView.swift @@ -5,43 +5,36 @@ // Created by John Mai on 2024/8/3. // +import Defaults import Luminare -import SwiftData import SwiftUI struct ConversationSidebarView: View { - @Query(Conversation.all, animation: .bouncy) 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 filteredConversations: [Conversation] { - if keyword.isEmpty { - conversations - } else { - conversations.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") } @@ -66,14 +59,15 @@ 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 @@ -86,9 +80,11 @@ struct ConversationSidebarView: View { .background(.black.opacity(0.4)) } - private func createConversation() { - let conversation = Conversation() - modelContext.insert(conversation) - selectedConversation = conversation + 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 8dbe1c1..b3edfd0 100644 --- a/ChatMLX/Features/Conversation/ConversationView.swift +++ b/ChatMLX/Features/Conversation/ConversationView.swift @@ -8,11 +8,11 @@ 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 - + UltramanNavigationSplitView( sidebar: { ConversationSidebarView( @@ -25,17 +25,14 @@ struct ConversationView: View { .foregroundColor(.white) .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() } @@ -43,14 +40,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..7d5ff99 --- /dev/null +++ b/ChatMLX/Features/Conversation/ConversationViewModel.swift @@ -0,0 +1,32 @@ +// +// 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 showErrorAlert = false + + func throwError(error: Error) { + self.error = error + showErrorAlert = true + } + + func createConversation() { + do { + let conversation = try PersistenceController.shared.createConversation() + selectedConversation = conversation + } catch { + throwError(error: error) + } + } +} diff --git a/ChatMLX/Features/Conversation/EmptyConversation.swift b/ChatMLX/Features/Conversation/EmptyConversation.swift index e0fb9d6..2d92c7d 100644 --- a/ChatMLX/Features/Conversation/EmptyConversation.swift +++ b/ChatMLX/Features/Conversation/EmptyConversation.swift @@ -10,7 +10,7 @@ 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 +20,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 +33,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 86c40dc..1f2a6ad 100644 --- a/ChatMLX/Features/Conversation/MessageBubbleView.swift +++ b/ChatMLX/Features/Conversation/MessageBubbleView.swift @@ -10,7 +10,7 @@ import MarkdownUI import SwiftUI struct MessageBubbleView: View { - let message: Message + @ObservedObject var message: Message @Binding var displayStyle: DisplayStyle @State private var showToast = false @Environment(\.modelContext) private var modelContext @@ -25,7 +25,7 @@ struct MessageBubbleView: View { var body: some View { HStack { - if message.role == .assistant { + if message.role == MessageSW.Role.assistant.rawValue { assistantMessageView } else { Spacer() @@ -92,7 +92,7 @@ struct MessageBubbleView: View { Text(formatDate(message.updatedAt)) .font(.caption) - if message.role == .assistant, message.inferring { + if message.role == MessageSW.Role.assistant.rawValue, message.inferring { ProgressView() .controlSize(.small) .colorInvert() @@ -146,40 +146,40 @@ struct MessageBubbleView: View { } private func delete() { - guard message.role == .user else { return } - - if let conversation = message.conversation { - if let index = conversation.sortedMessages.firstIndex(where: { $0.id == message.id }) { - let messages = conversation.sortedMessages[index...] - for messageToDelete in messages { - conversation.messages.removeAll(where: { - $0.id == messageToDelete.id - }) - modelContext.delete(messageToDelete) - } - conversation.updatedAt = Date() - } - } +// guard message.role == .user else { return } +// +// if let conversation = message.conversation { +// if let index = conversation.sortedMessages.firstIndex(where: { $0.id == message.id }) { +// let messages = conversation.sortedMessages[index...] +// for messageToDelete in messages { +// conversation.messages.removeAll(where: { +// $0.id == messageToDelete.id +// }) +// modelContext.delete(messageToDelete) +// } +// conversation.updatedAt = Date() +// } +// } } private func regenerate() { - guard message.role == .assistant else { return } - - if let conversation = message.conversation { - if let index = conversation.sortedMessages.firstIndex(where: { $0.id == message.id }) { - let messages = conversation.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) - } - } +// guard message.role == .assistant else { return } +// +// if let conversation = message.conversation { +// if let index = conversation.sortedMessages.firstIndex(where: { $0.id == message.id }) { +// let messages = conversation.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/RightSidebarView.swift b/ChatMLX/Features/Conversation/RightSidebarView.swift index 644d33a..279dea4 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 ) { @@ -101,7 +101,7 @@ struct RightSidebarView: View { Double(conversation.repetitionContextSize) }, set: { - conversation.repetitionContextSize = Int($0) + conversation.repetitionContextSize = Int32($0) } ), in: 0 ... 100, 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..a47f62c 100644 --- a/ChatMLX/Features/Settings/DefaultConversationView.swift +++ b/ChatMLX/Features/Settings/DefaultConversationView.swift @@ -104,7 +104,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 +121,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 +175,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)") diff --git a/ChatMLX/Features/Settings/GeneralView.swift b/ChatMLX/Features/Settings/GeneralView.swift index 67e24b0..f986499 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 SwiftData @@ -17,8 +18,10 @@ struct GeneralView: View { @Default(.language) var language @Default(.gpuCacheLimit) var gpuCacheLimit - @Environment(ConversationView.ViewModel.self) private - var conversationViewModel + @Environment(\.managedObjectContext) private var viewContext + + @Environment(ConversationViewModel.self) private + var conversationViewModel @Environment(LLMRunner.self) var runner @Environment(\.modelContext) private var modelContext @@ -76,7 +79,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,16 +134,9 @@ struct GeneralView: View { } private func clearAllConversations() { - do { - let conversations = try modelContext.fetch(FetchDescriptor()) - for conversation in conversations { - modelContext.delete(conversation) - } - try modelContext.save() - conversationViewModel.selectedConversation = nil - } catch { - logger.error("Error deleting all conversations: \(error)") - } + try? PersistenceController.shared.clearMessage() + try? PersistenceController.shared.clearConversation() + conversationViewModel.selectedConversation = nil } } diff --git a/ChatMLX/Localizable.xcstrings b/ChatMLX/Localizable.xcstrings index c964176..4e5a2aa 100644 --- a/ChatMLX/Localizable.xcstrings +++ b/ChatMLX/Localizable.xcstrings @@ -9,6 +9,9 @@ }, "%.2f%%" : { "shouldTranslate" : false + }, + "%d" : { + }, "%lld" : { "localizations" : { @@ -676,6 +679,9 @@ } } } + }, + "Error" : { + }, "Exit Full Screen" : { "localizations" : { @@ -704,6 +710,9 @@ } } } + }, + "Feedback" : { + }, "General" : { "extractionState" : "manual", @@ -1284,6 +1293,9 @@ } } } + }, + "OK" : { + }, "Please enter Hugging Face Repo ID" : { "extractionState" : "manual", diff --git a/ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents b/ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents new file mode 100644 index 0000000..0e97e07 --- /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..6dbc786 --- /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 Foundation +import CoreData + +@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..0f12d1c --- /dev/null +++ b/ChatMLX/Models/Conversation+CoreDataProperties.swift @@ -0,0 +1,104 @@ +// +// Conversation+CoreDataProperties.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// +// + +import CoreData +import Defaults +import Foundation + +public extension Conversation { + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Conversation") + } + + @NSManaged var title: String + @NSManaged var model: String + @NSManaged var createdAt: Date + @NSManaged var updatedAt: Date + @NSManaged var temperature: Float + @NSManaged var topP: Float + @NSManaged var useMaxLength: Bool + @NSManaged var maxLength: Int64 + @NSManaged var repetitionContextSize: Int32 + @NSManaged var maxMessagesLimit: Int32 + @NSManaged var useMaxMessagesLimit: Bool + @NSManaged var useRepetitionPenalty: Bool + @NSManaged var repetitionPenalty: Float + @NSManaged var useSystemPrompt: Bool + @NSManaged var systemPrompt: String + @NSManaged var promptTime: Double + @NSManaged var generateTime: Double + @NSManaged var promptTokensPerSecond: Double + @NSManaged var tokensPerSecond: Double + @NSManaged var messages: [Message] + + 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)) + } + + override func willSave() { + super.willSave() + setPrimitiveValue(Date.now, forKey: #keyPath(Conversation.updatedAt)) + } +} + +// MARK: Generated accessors for messages + +public extension Conversation { + @objc(insertObject:inMessagesAtIndex:) + @NSManaged func insertIntoMessages(_ value: Message, at idx: Int) + + @objc(removeObjectFromMessagesAtIndex:) + @NSManaged func removeFromMessages(at idx: Int) + + @objc(insertMessages:atIndexes:) + @NSManaged func insertIntoMessages(_ values: [Message], at indexes: NSIndexSet) + + @objc(removeMessagesAtIndexes:) + @NSManaged func removeFromMessages(at indexes: NSIndexSet) + + @objc(replaceObjectInMessagesAtIndex:withObject:) + @NSManaged func replaceMessages(at idx: Int, with value: Message) + + @objc(replaceMessagesAtIndexes:withMessages:) + @NSManaged func replaceMessages(at indexes: NSIndexSet, with values: [Message]) + + @objc(addMessagesObject:) + @NSManaged func addToMessages(_ value: Message) + + @objc(removeMessagesObject:) + @NSManaged func removeFromMessages(_ value: Message) + + @objc(addMessages:) + @NSManaged func addToMessages(_ values: [Message]) + + @objc(removeMessages:) + @NSManaged func removeFromMessages(_ values: [Message]) +} + +extension Conversation: Identifiable {} diff --git a/ChatMLX/Models/Conversation.swift b/ChatMLX/Models/ConversationSW.swift similarity index 75% rename from ChatMLX/Models/Conversation.swift rename to ChatMLX/Models/ConversationSW.swift index 4f33896..feae010 100644 --- a/ChatMLX/Models/Conversation.swift +++ b/ChatMLX/Models/ConversationSW.swift @@ -10,24 +10,22 @@ import Foundation import SwiftData @Model -final class Conversation { +final class ConversationSW { var title: String var model: String var createdAt: Date var updatedAt: Date - @Relationship(deleteRule: .cascade) var messages: [Message] = [] + @Relationship(deleteRule: .cascade) var messages: [MessageSW] = [] - var sortedMessages: [Message] { - return messages.sorted { $0.updatedAt < $1.updatedAt } - } + var sortedMessages: [MessageSW] = [] var temperature: Float var topP: Float var useMaxLength: Bool - var maxLength: Int - var repetitionContextSize: Int + var maxLength: Int64 + var repetitionContextSize: Int32 - var maxMessagesLimit: Int + var maxMessagesLimit: Int32 var useMaxMessagesLimit: Bool var useRepetitionPenalty: Bool @@ -41,7 +39,7 @@ final class Conversation { var promptTokensPerSecond: Double? var tokensPerSecond: Double? - static var all: FetchDescriptor { + static var all: FetchDescriptor { FetchDescriptor( sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] ) @@ -67,29 +65,29 @@ final class Conversation { updatedAt = .init() } - func addMessage(_ message: Message) { + func addMessage(_ message: MessageSW) { messages.append(message) updatedAt = Date() } - func startStreamingMessage(role: Message.Role) -> Message { - let message = Message(role: role) + func startStreamingMessage(role: MessageSW.Role) -> MessageSW { + let message = MessageSW(role: role) message.inferring = true addMessage(message) return message } - func updateStreamingMessage(_ message: Message, with content: String) { + func updateStreamingMessage(_ message: MessageSW, with content: String) { message.content = content updatedAt = Date() } - func completeStreamingMessage(_ message: Message) { + func completeStreamingMessage(_ message: MessageSW) { message.inferring = false updatedAt = Date() } - func failedMessage(_ message: Message, with error: Error) { + func failedMessage(_ message: MessageSW, with error: Error) { message.inferring = false message.error = error.localizedDescription updatedAt = Date() diff --git a/ChatMLX/Models/Message+CoreDataClass.swift b/ChatMLX/Models/Message+CoreDataClass.swift new file mode 100644 index 0000000..0644f12 --- /dev/null +++ b/ChatMLX/Models/Message+CoreDataClass.swift @@ -0,0 +1,15 @@ +// +// Message+CoreDataClass.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// +// + +import Foundation +import CoreData + +@objc(Message) +public class Message: NSManagedObject { + +} diff --git a/ChatMLX/Models/Message+CoreDataProperties.swift b/ChatMLX/Models/Message+CoreDataProperties.swift new file mode 100644 index 0000000..c651d86 --- /dev/null +++ b/ChatMLX/Models/Message+CoreDataProperties.swift @@ -0,0 +1,36 @@ +// +// Message+CoreDataProperties.swift +// ChatMLX +// +// Created by John Mai on 2024/10/2. +// +// + +import CoreData +import Foundation + +public extension Message { + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Message") + } + + @NSManaged var role: String + @NSManaged var content: String + @NSManaged var createdAt: Date + @NSManaged var inferring: Bool + @NSManaged var updatedAt: Date + @NSManaged var error: String? + @NSManaged var conversation: Conversation + + override func awakeFromInsert() { + setPrimitiveValue(Date.now, forKey: #keyPath(Message.createdAt)) + setPrimitiveValue(Date.now, forKey: #keyPath(Message.updatedAt)) + } + + 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/MessageSW.swift similarity index 90% rename from ChatMLX/Models/Message.swift rename to ChatMLX/Models/MessageSW.swift index be4ed53..201c8ef 100644 --- a/ChatMLX/Models/Message.swift +++ b/ChatMLX/Models/MessageSW.swift @@ -9,7 +9,7 @@ import Foundation import SwiftData @Model -final class Message { +final class MessageSW { enum Role: String, Codable { case user case assistant @@ -26,7 +26,7 @@ final class Message { var error: String? - var conversation: Conversation? + var conversation: ConversationSW? init( role: Role, diff --git a/ChatMLX/Utilities/LLMRunner.swift b/ChatMLX/Utilities/LLMRunner.swift index 0a91e1b..5927ad7 100644 --- a/ChatMLX/Utilities/LLMRunner.swift +++ b/ChatMLX/Utilities/LLMRunner.swift @@ -6,10 +6,10 @@ // import Defaults +import Metal import MLX import MLXLLM import MLXRandom -import Metal import SwiftUI import Tokenizers @@ -30,7 +30,7 @@ class LLMRunner { var gpuActiveMemory: Int = 0 - let displayEveryNTokens = 10 + let displayEveryNTokens = 4 init() {} @@ -62,14 +62,25 @@ class LLMRunner { } } - func generate(conversation: Conversation) async { + func generate(message: String, conversation: Conversation, in context: NSManagedObjectContext) async { guard !running else { return } await MainActor.run { running = true } - let message = conversation.startStreamingMessage(role: .assistant) + let userMessage = Message(context: context) + userMessage.role = MessageSW.Role.user.rawValue + userMessage.content = message + userMessage.conversation = conversation + +// let message = conversation.startStreamingMessage(role: .assistant) + + let assistantMessage = Message(context: context) + assistantMessage.role = MessageSW.Role.assistant.rawValue + assistantMessage.inferring = true + assistantMessage.content = "" + assistantMessage.conversation = conversation do { if conversation.model != modelConfiguration?.name { @@ -83,31 +94,31 @@ class LLMRunner { throw LLMRunnerError.failedToLoadModel } - var messages = conversation.sortedMessages + var messages = conversation.messages 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.suffix(Int(maxCount))) + if messages.first?.role != MessageSW.Role.user.rawValue { messages = Array(messages.dropFirst()) } } } - if conversation.useSystemPrompt, !conversation.systemPrompt.isEmpty { - messages.insert( - Message( - role: .system, - content: conversation.systemPrompt - ), - at: 0 - ) - } +// if conversation.useSystemPrompt, !conversation.systemPrompt.isEmpty { +// messages.insert( +// Message( +// role: .system, +// content: conversation.systemPrompt +// ), +// at: 0 +// ) +// } let messagesDicts = messages[..<(messages.count - 1)].map { message -> [String: String] in - ["role": message.role.rawValue, "content": message.content] + ["role": message.role, "content": message.content] } print("messagesDicts", messagesDicts) @@ -122,7 +133,7 @@ class LLMRunner { let result = await modelContainer.perform { model, - tokenizer in + tokenizer in MLXLLM.generate( promptTokens: messageTokens, @@ -131,7 +142,8 @@ class LLMRunner { topP: conversation.topP, repetitionPenalty: conversation.useRepetitionPenalty ? conversation.repetitionPenalty : nil, - repetitionContextSize: conversation.repetitionContextSize +// repetitionContextSize: Int(conversation.repetitionContextSize) + repetitionContextSize: 20 ), model: model, tokenizer: tokenizer, @@ -141,9 +153,9 @@ class LLMRunner { ) { tokens in if tokens.count % displayEveryNTokens == 0 { let text = tokenizer.decode(tokens: tokens) - + print("assistantMessage.content ->", text) Task { @MainActor in - message.content = text + assistantMessage.content = text } } @@ -155,12 +167,11 @@ class LLMRunner { } await MainActor.run { - if result.output != message.content { - message.content = result.output + if result.output != assistantMessage.content { + assistantMessage.content = result.output } - conversation.completeStreamingMessage( - message) + assistantMessage.inferring = false conversation.promptTime = result.promptTime conversation.generateTime = result.generateTime conversation.promptTokensPerSecond = @@ -171,9 +182,9 @@ class LLMRunner { } catch { print("\(error)") logger.error("LLM Generate Failed: \(error.localizedDescription)") - await MainActor.run { - conversation.failedMessage(message, with: error) - } +// await MainActor.run { +// conversation.failedMessage(message, with: error) +// } } await MainActor.run { running = false diff --git a/ChatMLX/Utilities/PersistenceController.swift b/ChatMLX/Utilities/PersistenceController.swift new file mode 100644 index 0000000..fe4a9d8 --- /dev/null +++ b/ChatMLX/Utilities/PersistenceController.swift @@ -0,0 +1,79 @@ +// +// 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, + in context: NSManagedObjectContext) throws + { + if let existingContact = exisits(model, in: context) { + context.delete(existingContact) + Task(priority: .background) { + try await context.perform { + try context.save() + } + } + } + } + + func clear(_ entityName: String) throws { + 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 + { + let changes = [NSDeletedObjectsKey: deletedManagedObjectIds] + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [container.viewContext]) + } + } + + func save() throws { + if container.viewContext.hasChanges { + try container.viewContext.save() + } + } + + func createConversation() throws -> Conversation { + let conversation = Conversation(context: container.viewContext) + try save() + return conversation + } + + func clearConversation() throws { + try clear("Conversation") + } + + func clearMessage() throws { + try clear("Message") + } +} From f65263c41269913bfced8421649b845753bc541b Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Thu, 3 Oct 2024 22:44:58 +0800 Subject: [PATCH 6/9] :sparkles: Switch Core Data --- ChatMLX.xcodeproj/project.pbxproj | 24 +- .../xcshareddata/xcschemes/ChatMLX.xcscheme | 13 ++ .../AccentColor.colorset/Contents.json | 27 +++ .../1028322432.png | Bin .../Contents.json | 0 .../clear1.imageset/Contents.json | 21 -- .../Assets.xcassets/clear1.imageset/clear.svg | 1 - .../Contents.json | 0 .../hf-logo-pirate.svg | 0 .../mlx-logo.imageset/1028322422.png | Bin 11673 -> 0 bytes .../mlx-logo.imageset/Contents.json | 21 -- .../Contents.json | 0 .../doc-plaintext (1).svg | 0 ChatMLX/ChatMLXApp.swift | 39 ++-- ChatMLX/Components/ErrorAlertModifier.swift | 42 ++++ ChatMLX/Extensions/Binding+Extensions.swift | 2 +- ChatMLX/Extensions/Date+Extensions.swift | 17 +- ChatMLX/Extensions/Defaults+Extensions.swift | 8 +- .../Extensions/TimeInterval+Extensions.swift | 29 +++ .../Conversation/ConversationDetailView.swift | 64 +++--- .../ConversationSidebarItem.swift | 8 +- .../ConversationSidebarView.swift | 7 +- .../Conversation/ConversationView.swift | 9 +- .../Conversation/ConversationViewModel.swift | 16 +- .../Conversation/EmptyConversation.swift | 1 - .../Conversation/MessageBubbleView.swift | 89 ++++---- .../Conversation/RightSidebarView.swift | 2 +- .../Settings/DefaultConversationView.swift | 9 +- .../DownloadManager/DownloadManagerView.swift | 2 +- .../DownloadManager/DownloadTaskView.swift | 4 +- ChatMLX/Features/Settings/GeneralView.swift | 26 ++- .../LocalModels/LocalModelsView.swift | 9 +- .../MLXCommunity/MLXCommunityItemView.swift | 2 +- .../MLXCommunity/MLXCommunityView.swift | 2 +- .../Settings/SettingsSidebarItemView.swift | 2 +- .../Settings/SettingsSidebarView.swift | 6 +- ChatMLX/Features/Settings/SettingsView.swift | 21 +- .../Features/Settings/SettingsViewModel.swift | 27 +++ .../ChatMLX.xcdatamodel/contents | 4 +- .../Models/Conversation+CoreDataClass.swift | 2 +- .../Conversation+CoreDataProperties.swift | 103 +++++---- ChatMLX/Models/ConversationSW.swift | 100 -------- ChatMLX/Models/Message+CoreDataClass.swift | 28 ++- .../Models/Message+CoreDataProperties.swift | 34 ++- ChatMLX/Models/MessageSW.swift | 40 ---- ChatMLX/Models/Role.swift | 16 ++ ChatMLX/Models/SettingsTab.swift | 4 +- .../Utilities/Huggingface/Downloader.swift | 4 - ChatMLX/Utilities/LLMRunner.swift | 216 +++++++++--------- ChatMLX/Utilities/PersistenceController.swift | 61 +++-- 50 files changed, 598 insertions(+), 564 deletions(-) rename ChatMLX/Assets.xcassets/{mlx-logo-2.imageset => MLX.imageset}/1028322432.png (100%) rename ChatMLX/Assets.xcassets/{mlx-logo-2.imageset => MLX.imageset}/Contents.json (100%) delete mode 100644 ChatMLX/Assets.xcassets/clear1.imageset/Contents.json delete mode 100644 ChatMLX/Assets.xcassets/clear1.imageset/clear.svg rename ChatMLX/Assets.xcassets/{hf-logo-pirate.imageset => huggingface.imageset}/Contents.json (100%) rename ChatMLX/Assets.xcassets/{hf-logo-pirate.imageset => huggingface.imageset}/hf-logo-pirate.svg (100%) delete mode 100644 ChatMLX/Assets.xcassets/mlx-logo.imageset/1028322422.png delete mode 100644 ChatMLX/Assets.xcassets/mlx-logo.imageset/Contents.json rename ChatMLX/Assets.xcassets/{doc-plaintext.imageset => plaintext.imageset}/Contents.json (100%) rename ChatMLX/Assets.xcassets/{doc-plaintext.imageset => plaintext.imageset}/doc-plaintext (1).svg (100%) create mode 100644 ChatMLX/Components/ErrorAlertModifier.swift create mode 100644 ChatMLX/Extensions/TimeInterval+Extensions.swift create mode 100644 ChatMLX/Features/Settings/SettingsViewModel.swift delete mode 100644 ChatMLX/Models/ConversationSW.swift delete mode 100644 ChatMLX/Models/MessageSW.swift create mode 100644 ChatMLX/Models/Role.swift diff --git a/ChatMLX.xcodeproj/project.pbxproj b/ChatMLX.xcodeproj/project.pbxproj index 156c842..25ce09d 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 /* ConversationSW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526676242C85F903001EF113 /* ConversationSW.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 /* MessageSW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5266762A2C85F903001EF113 /* MessageSW.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 */; }; @@ -81,7 +79,11 @@ 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 */; }; @@ -131,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 /* ConversationSW.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationSW.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 /* MessageSW.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageSW.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 = ""; }; @@ -162,6 +162,10 @@ 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 */ @@ -252,6 +256,7 @@ 526676092C85F903001EF113 /* Components */ = { isa = PBXGroup; children = ( + 52A689F92CAECFDE0078CDF9 /* ErrorAlertModifier.swift */, 526676002C85F903001EF113 /* SyntaxHighlighter */, 526676012C85F903001EF113 /* EffectView.swift */, 526676022C85F903001EF113 /* UltramanMinimalistWindowModifier.swift */, @@ -310,6 +315,7 @@ 526676222C85F903001EF113 /* Settings */ = { isa = PBXGroup; children = ( + 52A689F72CAE8DA00078CDF9 /* SettingsViewModel.swift */, 526676142C85F903001EF113 /* DownloadManager */, 526676172C85F903001EF113 /* LocalModels */, 5266761A2C85F903001EF113 /* MLXCommunity */, @@ -336,13 +342,12 @@ 5266762F2C85F903001EF113 /* Models */ = { isa = PBXGroup; children = ( - 526676242C85F903001EF113 /* ConversationSW.swift */, 526676252C85F903001EF113 /* DisplayStyle.swift */, 526676262C85F903001EF113 /* DownloadTask.swift */, 526676272C85F903001EF113 /* Language.swift */, 526676282C85F903001EF113 /* LocalModel.swift */, 526676292C85F903001EF113 /* LocalModelGroup.swift */, - 5266762A2C85F903001EF113 /* MessageSW.swift */, + 528D83382CAE51EC00163AAB /* Role.swift */, 5266762B2C85F903001EF113 /* RemoteModel.swift */, 5266762C2C85F903001EF113 /* SettingsTab.swift */, 5266762D2C85F903001EF113 /* SettingsTabGroup.swift */, @@ -381,6 +386,7 @@ isa = PBXGroup; children = ( 528D82252CABE19000163AAB /* Date+Extensions.swift */, + 52A689F52CAE8AAB0078CDF9 /* TimeInterval+Extensions.swift */, 526676382C85F903001EF113 /* Defaults+Extensions.swift */, 526676392C85F903001EF113 /* MarkdownUI+Theme+Extensions.swift */, 5266763A2C85F903001EF113 /* NSWindow+Extensions.swift */, @@ -511,12 +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 /* MessageSW.swift in Sources */, 526676562C85F903001EF113 /* MLXCommunityItemView.swift in Sources */, 526676442C85F903001EF113 /* UltramanMinimalistWindowModifier.swift in Sources */, 5266764A2C85F903001EF113 /* UltramanWindow.swift in Sources */, @@ -536,9 +543,11 @@ 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 */, @@ -553,7 +562,6 @@ 528D831C2CAD49E600163AAB /* PersistenceController.swift in Sources */, 5266766B2C85F903001EF113 /* Hub.swift in Sources */, 5266764D2C85F903001EF113 /* ConversationSidebarView.swift in Sources */, - 5266765F2C85F903001EF113 /* ConversationSW.swift in Sources */, 528D83372CADB64600163AAB /* ConversationViewModel.swift in Sources */, 5266765D2C85F903001EF113 /* SettingsSidebarView.swift in Sources */, 5266764B2C85F903001EF113 /* ConversationDetailView.swift in Sources */, diff --git a/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme b/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme index f58682c..a652935 100644 --- a/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme +++ b/ChatMLX.xcodeproj/xcshareddata/xcschemes/ChatMLX.xcscheme @@ -50,6 +50,19 @@ ReferencedContainer = "container:ChatMLX.xcodeproj"> + + + + + + + + \ 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 fcbe07f4195d67dee9ca5e6f2fea4a2604d439b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11673 zcmeHt_dnZh`#yDd7gbtnwzQN|Rl7#oQmRU7Z;I9)HDe{YJFJ$d)?T?)iBUm~*r{4Y zt=fdt2(^-m5fMo~@7&MxCw#xZJwN1y_sezVdY{*M9p`Zz=N13hSfBHh;3+mXHco>F zx~6Pw?1`+;iDST%O_ba)@Z;q32R1=$Y^QlyA9l8^904{qIYR^8yXIjzo3oKo!UQO3 z3;*Nc7ukX*FSt&Awz9<-oD3H+`*r^TM~JlNWp${|8s1~;@lNK`eQNdK`}@$9!z$W_eT}zPlg8k{ zkF{-Bi;F=S)GhCPHhnA>!h!UE(DW)@g;XtK#Aav#8Je7C0yK>+)$R zpRI7eaQGBAl8ge|H~O`oTy_c#oUPg|!Zz(@Ly#t`q!p&W*@1049==CrF!$0O{!oRq zceWJeeY(~eR7Ef{lz7x>KQ?PFGO-n6NWca&$aY}n(wFo7VYvnt*OtsoChk^knrpA! z&fwd{H~LbD`B%YOyXGk&*CLlQ-4WQ}*_h%o9&e;W$kxTh#YGb<9bMf#rjwZ3o{rJ& z3IA9p+AInQYcz1*ruB%?=Oc}imVWdos4EPoBD07B!h(W(+Qc580O4eE$iu61QWG}X zGwG{5JgaNOxdJEJl7N_q!!_|+@A?w?#H8ubGKZVJIY0UPXnYp`)REsRX69KdgU_Ej z+Q1wT!RR_9)gR2abJIiYkGS3l_=vu9Bsf$~pw>sj>Cn3Yu7HnCAy6G(;>+-=mat10 z?+$4`m!JvWqMrEp8_5gavf2yOPZ%7wJ%Lh2*ym{D!Ps*Ws#k;k_1#y(KHt;PeL8lK zxN_eiw%)b@HnM9n!CqVxba%o79$`{(a4Arec|nN&P`Hm$Pa9)ew-DrJ2Fk9JEpKTl zk0}7ludl#o|5ey6+_#;6?dadGsmS?LdhLupcg0N6zPfGS&CL&0rxVj1BMbuN56P3o z>pZHwi>|X^ZL03**ojFeySt||CFHA3>T-MS>T(C&-Wchot-R`o_&*8H);mHuk04M> zTFIrWZV_Ik5-I!_IOfjgY#sE$*)ViZY6AJCLU}Qwfy`Z-F7}GY{OKT06v>My`M$s)ZGjCq!gf#vA?G z^}+P?j1+BT)scrMAm?Z72ZE<6KM!TNx7TPRF%>BwwvJv=?&#V7{resej#NkH5A5HI zR8zt8zZ+gXmhC4`J#tEVHbX@W3Eh1lRY!!1tu z`ku&C@jCmcm!lPsIk#uM)rlPj0m6g#{VFHMw)Q$Nz~|L|KA^sE=)7ES0*))|?%21+ zm|go++RC`&l)Xp~N|7u|qrH>`hf{1U`rK&;5gRF3n}_kf9&vF|0b`}`#6~WYnZR8O z<}EEPkr3s-vubNGVB5M}l2Zy%u0s2lw;vgn`t`A`Gzed%D<}=RsidT-ppsYLns~4E zogi=?(FX@|q}(+B(&)I9Z=I&nYjRGf5HbYT)}bCh2muaMxp{?kY!`;P+SWmj<(f&$ zt{b2>$z8Fsf>81nK7l$l!hcA|ygV4D%`fxhI6d~KUQsp+U$>?2rU(8!rCGHDDSj?* za--sQ+x1k8j|TSFR}QBNON@1_UjE~E@MaoXeFn4j0}CTH4-da_=DwmA@YBgtDfO=` zq%J3Wxf1TuP{ZsUAVOXjLJmu)SBXFTOAZcN+8*cCE|?$K)n9EHp}hQexf;(%kWI98 zERgcOAwtU=*GetcRjWRoIyVSf9RzjXsgem9kLadbGy3Ku`lROlTmxM`mZ{w}VeU3@ zBzT)nUC&z)ZM#`Jix$dcSpzA?wN?z zt%hakzx*KCl1P5>;_frf6yWH%;HA;Gv9*p^xV`Vz#0wOSbmd{cSz)q8s|~c!KjcD0 z0+h9J(3v`Q)ukO0#0NWD5|JJ6mNli45K#l{+@8Gc=sFinMzy&3_$MW|agccL#KflO z`;vkvoJQxi*@(!irr4NTM`l9Y^TTasFL#rxw@6iQ?%Euxw9qN|inx*rJcJl>%&FOV zQvHb-3DprFW=LuFfSgkeIxBDN)IfN^kL%c^6f+e8$0z4faJ zzKki_*w-^-Q2!=kG0`OaV3^dOIp%*n4(`Ufv7vXbA>0d}4vw4sdUN}ag|1t8 z)9SyNu~46p59IBr%iDtrd@h{F95HG-y}$C8?VOm`G`z#O*J8AES7_xC>Q6rgm-TMB z{!?@0S*g(8NX&d@noo%Vho7o{xbchqiucdDD7EQHW~h3*bX!O4Oq0n5^0^bXvF{He zIWT({xA;^MI^A#jP&^Bzt1BA0fw%Q4S)^0fd@l0gCBk!TV zu2c4b!m^v~-+D1b`PcpH_P>`QVwL*+vyWf>BRsgVB#t>*sAgNbQ%t}ft7wYKfA^++ zwySk(i}=Fc@6-5!m9Y5o`uJnRg2<#*6^mWeNK5YNMz6dRX&FOjc^ZNji@>@L$44y7 z@!yG20qros{Oy6+YlC+xJ496M{9c$C7HH&MuI)sYlh21k31hHj6Rx&g05dHWm%YoG z2@QgCFA)dbuYZvV~VD z=Q-WTb$1MF-GzTtD7n=jG7p=2RD}AuR@?ZVUvQ=s7B(7klRz#*Q$0pQ*s@B>TcAIs z`Aog|l4f?Ve?)(j>u%ZE!_YMHilCH%f^jlO)5nW1*ZJO*O{$o~x6;pBwAP%Vv{4jDK(CUq)50l z&?^H^Xyvu8BU&Le?R5{qImsm znm(4!?JX%K)XK(o&eyFvo+5*zYlrohANyy(+%8=CP0!L<9EOQF4ln~;-Pa3*U~kuXjzSgPJ_gj%%Jqt_ zzxc8XWJ&G$%7)VMoQYOWPz%O3lYVWhtqKyhU+K=IPZgK3ciamn#=!YX%1+V()S~b8 zcAT}ZwbJw0r7-F=apV30aX@%ofZDq>E#&{mekEXN7IITnf+!#{Zol2ws8-R4v{?a~ zV=qa;rN1!Cw0EYxw9G?F;FlsA(@8u0HCUU*U2PT>6&%chsYjz&Tbo>Z>w0n=U8yuP zE9*UzMxk5@-QOHUhOgDk&(9Z^4gY-w8+q1!ZEcc&k_K%$ivG^w8r#2X5!)?+SK4uZT_r(Qlsc@J%vHD z^Fp6IE2#`|4UimJq-AhAgE(`^uXLJX0m`X*>n{jE0fA&`rVLzdJ~$qUmE-At&q`49 z$m}YG!+35WQ<_3?0d}Jy#bY4+c6tBE$OwQ5w>_fYCI}i?2>Y5x$=1-7lzO?B0ww$r ztyPtC2Vmnz!JJE>i5bQ4!poLZ+CjjM8NTl7T1SJV*KUt`0k7!2>DMJeFQy^w@}KESZr4dbRO7c4HZvEz;(V=F@T6$NRccXoVM^M` zM3Uyt;}KPpk_jOHeWqrQv-6JDeWC-q5(e_WDHZ5ENw1ZKHE z?KJyHj+hO?@Vg$FL)Z9Dk-s`X=J-Oi+_pDdLHiwZEs-D5de)Ebh;7(tK%|{cT(5&| z78kES?(h9e3CV8o&dWc01B{9s8+88nfL=-$%ACgd!~RTBOIEp<4lz^oJ6W@`g-Xgw zb@1IT+FtXXZDVq9q)Siks*^ar919lCoQ>qFKI;&JU*@Ptv=BfqqUlsvQ|aS~bJ z0<66UYy^1(q$i$E7)*-vP`fh}OUD}eL2_AFG(uB#~n5!KA{i%IWo*Arr+9rS# z@tdUr+!Uc&`6Lw22-yTkr28rnYm4s4ZO;$g_?Ada^Zp1j=P6nEb`gN?0F4SLSW$y_ zhU+oY?&Orz(S3?4Q2WEBSNRa{nJSj8|2jSxi|No=2x*8JS)Npd5dMLcwwrx z5M{249x7FN-ZkUP)aR#oDq1Qbm#ya8)xW191%Z)|=`_!dk|=W>pI73!DCfrQy|hgY zar`jpJ!f{pjVah>*7dqfP3@=4Lk~JkOIv)uz)d`fdJSg(Y|)h#9=C#WiMe>_*Su+l zxSOt^pdpz4D~-DC{v-Khe=C%>L}TtGr3-1a$vVzas|y?K2PU_CXVQ}7G}4Qt7k@Vy z92rx$$5oc>JUK!0)OFC#Hl_gb$bbCh>+1L@y?y>cr!BJ&-A)a#c0C|6$Z}P@SMXmG zG_ZCpRtE$ay~uMU~1 zYc~>wFA~xN)7k&BuFI7V6|^g~yiED2wL_OZlTHGw!tZ{Y(iIhi#$vZuJKypPX?4%e zU|VN@pOgvP$R|a4y10Cs+~U7sf2~_qYfGrh_@5VarL+2svOxWWO?T(|D3uTKPyaK8 zxOnyix^--6Yr?UKYW|_SV##n_#YZLlaE#w!2l}CVq75b(SH=>?0EW2+gWgJ}B$-vG zoO?T@M*q-fIa$|G+PpXIP2P6T)9#-Z!lo|OLe-C7W=y=YipX>Jv;jq zjCS1MH+w2D5idsL%+(9|m&M5pGh>B=jXzA<`RJ6XdHh{) zYgsS{%_c*pLUQYV?WK7i7hb{a8%-S^D54f+)+q#2URmC{+^0wKs{8EF(zCSn!qOS_ zFn8hDUiD2IZ3IwleC{p&E|h|~Iwv*Qkb#Ot9;8I|d4OKUecxs>7R}(>*?0jBn0{nz zPT)Z|`ex{nf1-;Uga}vFzDMf8?0xGVFqLIt%x5?p;1V0hD6P{D3{wfL8a?7KDhCu2>Gen5$7m z&&L1A&}2j-nZiMeSqTa*J?^NDC_vV)W7+=5FPgx9lpY2F_1#>Cgv=2DCvTuk`tSN{ z1V8C{e-()A3SzVCb_bL2f;M4fWSn`sSvcZ%J~>ifWZS`2s@NuHPU}ucH3`I1FL5I3 ze6r0PF)@_{0Br$|g-4sfTp{b@Xw~pTBUN1e=xCJ{dZw~#wCcVty7BN!`pEV6?Ct3(LAr{Y_N#%lnwk@O*Q3b8Jeple$B6arZ4aLx0A_! zTWYA`QxoTas9hDWngvTpyId6HhY_pwDvHJEa|I=pN#TTAyE(Bvg%-3!P$&N*Q88L_ z4b#dsrm7`UYrE!+)j_}v*cG5<&aM?-)Pd1h$fyEdYooF~K?M8w+IKpHc*56X1B{Fy z7gS*V{D#_Ja&Wy{1XI5P`_Jx{T)D@w|0p3T1aQg30Q?RW+pS=;xG)S8Ufm3qrn z$epTUk>#=*O`{$tQtz(N!0C1C>^gh*8qc+l+g6(H`+y>F2H-p92M#%VBLaX2 zc8YrMcljmfx4s@ZRX$TFBb8Ilhd=w?N6x2r#{_()wbD1EorO_ZgzE#RTNXt#PU9qz zKT}&O@NGPlZCndEDHxOpWD>=#bTUTIed6WtB3P~Mx4K^wy)K4yR`hUuc4B3FImQZ0 z-}F!4AvM$0U8OwOXr7k_Sdj<9sjQ}?l#@bNGUv6;$u0prrGHO6HK%9M3A?*oAgI*^ z$Z6*7*QMsQgkI&U&Aulf&W@?24&8}gLqnAk?~4KGoO^VBeJG67gYwb(^*2X?2s|pv z{$CeuO^%t})&|jH9&S5Z_DH2Z?9m_4d~0HMC$ZT`lTpTex`E<};tV*q&Qhy?6jwk% zkjln=>#OFzXoUJGPO~$Tx#4l)7w_ao<>F6HgA1%FxHy1TOG-&L$_T5OR&gg1Rx&sL zV$Ab`M`vOCgo>!${mS*l+E7Az20Pd2_ZjM@V`t=T)U)NdqWVqLW$|0U>uT2e;}1{C zE3k&|kPeAKq_A!cpvL5B5R0jZ`EJlY<4a24$ah4eDut!Eg7=I!$DJOiK-&8sXUTx(V ze&2DwCkSq9n5i$u370-sQ9Yp0ggLRJpeK8gg{44=p`e;+?6?H_%||J$%*+!IO6ot0 zv))8iflwETe2iWryIPVk5gXJk7k#hI+5w?`;GtfLp`3S>I$>lwr{xg>NPAmE!MDH1 zZRXG~G+qj%B^fGOY_McTf2oCDMKfkL+{%*DP&V#=#`om=hv=q2qkDU7;NGU=GN1y{N%Hv$6FCJ0@Vv*{KQ1AM!PMl`!ZjQky9TMD5H&s;J zHgK7b^k9&lWV~wVkOPuksux<9<$R~rx@p8u_@i4n@SQ>` zvmRvJ#OKySA;~1}n)jW9=;+r_4oDyl~& zx15AT(~w%bOZu;pSfWUavA@_g;BAw3*g3UQ>2u;A=l?27+@KkycXkIR0H_Lofby*Z zz+DZ^%yUCJr6!e{X$AmQGmxR)mk@fvznK_wF?W`NtD5yVz8^1@ca7B!kR0Su*&DgT zH@fi~Ad${aW{3q6Jx~{3WWkhR7*>a8@5hd9~+cs>_|y*a&D@GwqlwIa%AF%ISfD- z{=T4~pgq3n=&V8Dw&0P5W(_o3Qb0c^VFD~ccjZq|gSL?x784b4%XK^Um*g#0$}}w8 zD#-1c6QTMw64GWK<*hHA)~3|-EOL~KgOq3U>vTh;+ANyZcO5&M@W(5rLV78HYc{r~ zR8i(#LngIrz-s}`tJB^u`Z9ZRXn%5QC2Bgd??T-Q67C37RNKG7lWbQ<3Tvw3jsLEW zv>jM@{Z+O(=vG#XSPald_~j7u6Odb;Q=u@o=Suz{gqw=lRTU*xj{R(el7Xdu-a(43zFU8t#JCHov#aobjM zP6t+;ZNB3-;PIS5mGQR9-qf6H2tsKB#+xMgGCpv733)qin9oP2^;$71- zfUu{*|KniXf0~9_|3N0?*C_wCA5zZhe;V;k4eLn#sVIzUjyl*GHIIcQdZ%Xqwum~# z3~B5mMecMubXiz-+rLVmeN`_s(?ofq{HfNoI)T=5HJ{{ea_v(v=@#FNG2y=Mh4h#0 z8!+L#%omnb9e4aDzPAQcHyvJW41cJ*^zIQ}9-+1nS6pUNt;40Ri%{E}yp_e=fniXdo~`;J@4uK zOV4!Z6o33Z$MPpLIJ&HPwYfy!0n0;Y>7LyjCk*3!thG%c!QzZp{Lp^2qIT<-@^U#K zs7pVB1{^Mgi2M^zSIR<16$%f-F4U?R9xWNRWLW|w2Ud<+7(QLVxEjSJrk&tSei|Yp z2EheVEMILvw2yKIiPRU4frr62k8>s6W)$d!OuNB zCV>a>SM>lB;QeSx^RrE_n5iQc8$x0v%YIXFQ^K3ZYa4V~Kx;!n<%-MX3(f&%5pCpc zgwYrt@-C2Hzbe-oXk1n@ zl!?`F(O$s`K)&D7%00cdeu(bkyri?s54SNPh)@QuY#48)u_&=ox+D3{Xh=r?(Y|!d zkN1~1Q0%->Gnh9LlC05Ixp=SdJ{irY1!HWPKo9EtVVgAi^*YP4$}X{$QDTsDf?PrF zi;621aZr(vD&(SoNZUd{R_n=m6u*?NIj}>-Q3l#6W75wa?q8p;GxrD% zfsn!nBjbBwmLR}ue`Dr5eEI0@*#1K@hvBk z)C8Yzq0BO)Mt5&+Cd2Ff={>02*1F7?3|3> z5e+z50`seE;1Ou2Gf{uIx#>)2of$O{6$Us*#mMqsI^ucDGKR+Z$gb`|c?d^?27|;PIu)u41T)=E=Jijlp zFC$*vJiGI6ZyabjW{N7*f;x|Kw$MUGX$OXb#Y0pF#3!Jcw9Kbc>1?(DaIeqc%V7Ij8>HHh7FxO|XYg^Tzw{8b}PFYXPg2 zE#MZ5)h={BEL?X6XF~~l8P6#NCxY&N)0>jkz+;QUFg)Wh!L*g*h9Pb#4Mr9m4Gt3G zo$;y4V-0CiQvJh*;6ptycyI%(^r@gg$5@^2YNcz*e#s4Y^khFV)}$ng`7zjk^7J{C z(rWLvV%vWde$bWPI6a(S+x+q8uwS_9DS#iJ#sLJnt)%;L^R?rimHYC?+p1?(y)xVz zY#z$&yiu+f)2sb&!#$jrHB&)#P%}jkVAF~$#Sr>&$N?iZXp z#1CTxC95W}2Ph~M?e#p5Zeh&MFO zNEGb1*j~39+@?Q`x9u4+g$s@85yFo2DCT(m*L-X>b^w+Jw5jYH+@RHi;bYIzXGb=I zRD@`rchYU&4UNJCXxGK|{Gy5`r8TgVst}#0*a|0CP$HSU>a|%)E^VAMaW8c8L=g|m|b?0OyF1o z&N(TUx(WNNajpv{FxH!mOB_t0&K}?BIYv||#llKFL7e^|{RUpm$1JBZv)tcx;B}XU zkSZftwcKcWtF|H!m%_`gpvp=ypz(O6UhFmIvH|C%+@5WZM8cTqrS?~bBI~_F#~v=y&rCQpxRtL4u?3*CpO1caO<&A}ShlL%kSRwl6*rt>;JpBM zj8#TMU9vvOZ257ouY}X*@TtHb_@`m|HmQu(Hs|ISWJQQcaDT~=8o5OZ7X&!F7d05o zh;XtLoPo{OHa#6zkm+rBKHCC^j?FZZ6}R+stDyz;6L@dgdZ~o=4!8rnpt=QC@wH!P z%c+~Lp}t1Cp4y#7?d3mJ@{smskX2%An#kg=M*7IxG+vG(1m~4#j7_^@^v7?ov+U>5 z@4e-a{ZG#+0@HaZS=3cbDmYSiHCp8zTOU|R_-hfO3Wj9<)p~V48e;a@UIWoxHY*v~ zml@H6up&b2)DhaEQ`@pnNI7rlPWvHG+6tQ>P+Bf&V>MXXP_g}3vjN+ zdzSdJaG^QU^^)4CG8IE4GX}uf0qCiO<^!ipICTIr{yN~ZS_`_Ry*(C6K{5gy6bC{^74-2FpHrjY zv}`ALEM-V1U3JkuXiN+lMNZSX1;xfSipHt-mE0x>v$0{-r%>!{#oYh9L;AnlwEw>^ d5$`eCq313w9mT4p0C%m~4DK82qVGL<`F})dBlG|O 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 3686bde..7148dc5 100644 --- a/ChatMLX/ChatMLXApp.swift +++ b/ChatMLX/ChatMLXApp.swift @@ -6,7 +6,6 @@ // import Defaults -import SwiftData import SwiftUI @main @@ -14,7 +13,7 @@ struct ChatMLXApp: App { @Environment(\.scenePhase) private var scenePhase @State private var conversationViewModel: ConversationViewModel = .init() - @State private var settingsViewModel: SettingsView.ViewModel = .init() + @State private var settingsViewModel: SettingsViewModel = .init() @Default(.language) var language @@ -31,22 +30,25 @@ struct ChatMLXApp: App { ) .environment(runner) .frame(minWidth: 900, minHeight: 580) - .alert("Error", isPresented: $conversationViewModel.showErrorAlert, actions: { - Button("OK") { - conversationViewModel.error = nil - } - - Button("Feedback") { - conversationViewModel.error = nil - NSWorkspace.shared.open(URL(string: "https://github.com/maiqingqiang/ChatMLX/issues")!) - } - }, message: { - Text(conversationViewModel.error?.localizedDescription ?? "An unknown error occurred.") - }) + .errorAlert( + isPresented: $conversationViewModel.showErrorAlert, + title: $settingsViewModel.errorTitle, + error: $conversationViewModel.error + ) } .environment(\.managedObjectContext, persistenceController.container.viewContext) - .onChange(of: scenePhase) { _, _ in - try? persistenceController.save() + .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)") + } + } + } } Settings { @@ -58,6 +60,11 @@ struct ChatMLXApp: App { ) .environment(runner) .frame(width: 620, height: 480) + .errorAlert( + isPresented: $settingsViewModel.showErrorAlert, + title: $settingsViewModel.errorTitle, + error: $settingsViewModel.error + ) } .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/Extensions/Binding+Extensions.swift b/ChatMLX/Extensions/Binding+Extensions.swift index a94957c..3d058a1 100644 --- a/ChatMLX/Extensions/Binding+Extensions.swift +++ b/ChatMLX/Extensions/Binding+Extensions.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI extension Binding { - func toUnwrapped(defaultValue: T) -> Binding where Value == Optional { + 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 index 90b20d2..74676a3 100644 --- a/ChatMLX/Extensions/Date+Extensions.swift +++ b/ChatMLX/Extensions/Date+Extensions.swift @@ -8,13 +8,24 @@ import Foundation extension Date { - func toFormattedString(style: DateFormatter.Style = .medium, locale: Locale = .current) - -> String - { + 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 cd07c31..72b15d3 100644 --- a/ChatMLX/Extensions/Defaults+Extensions.swift +++ b/ChatMLX/Extensions/Defaults+Extensions.swift @@ -23,9 +23,10 @@ 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 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) @@ -35,5 +36,4 @@ extension Defaults.Keys { static let defaultSystemPrompt = Key("defaultSystemPrompt", default: "") 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 94dc9e2..6e0f7c8 100644 --- a/ChatMLX/Features/Conversation/ConversationDetailView.swift +++ b/ChatMLX/Features/Conversation/ConversationDetailView.swift @@ -10,32 +10,31 @@ import Defaults import Luminare import MLX import MLXLLM -import SwiftData import SwiftUI struct ConversationDetailView: View { - @Environment(LLMRunner.self) var runner - @Environment(\.modelContext) private var modelContext - @ObservedObject var conversation: Conversation + + @Environment(LLMRunner.self) var runner @Environment(\.managedObjectContext) private var viewContext + @Environment(ConversationViewModel.self) private var vm @State private var newMessage = "" - @FocusState private var isInputFocused: Bool - @Environment(ConversationViewModel.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 + @Namespace var bottomId + @FocusState private var isInputFocused: Bool + var body: some View { ZStack(alignment: .trailing) { VStack(spacing: 0) { @@ -90,7 +89,7 @@ struct ConversationDetailView: View { MessageBubbleView( message: message, displayStyle: $displayStyle - ) + ).id(message.id) } } .padding() @@ -122,14 +121,14 @@ struct ConversationDetailView: View { } } label: { if displayStyle == .markdown { - Image("doc-plaintext") + Image("plaintext") } else { Image("markdown") } } Button(action: { -// conversation.clearMessages() + conversation.messages = [] }) { Image("clear") } @@ -168,7 +167,7 @@ struct ConversationDetailView: View { .popover(isPresented: $showInfoPopover) { VStack(alignment: .leading) { LabeledContent { - Text(formatTimeInterval(conversation.promptTime)) + Text(conversation.promptTime.formatted()) } label: { Text("Prompt Time") .fontWeight(.bold) @@ -182,7 +181,7 @@ struct ConversationDetailView: View { } LabeledContent { - Text(formatTimeInterval(conversation.generateTime)) + Text(conversation.generateTime.formatted()) } label: { Text("Generate Time") .fontWeight(.bold) @@ -294,17 +293,20 @@ struct ConversationDetailView: View { newMessage = "" isInputFocused = false - Task { - do { - await runner.generate( - message: trimmedMessage, - conversation: conversation, - in: viewContext - ) - try PersistenceController.shared.save() + Message(context: viewContext).user(content: trimmedMessage, conversation: conversation) + + runner.generate(conversation: conversation, in: viewContext) + + Task(priority: .background) { + do { + try await viewContext.perform { + if viewContext.hasChanges { + try viewContext.save() + } + } } catch { - conversationViewModel.throwError(error: error) + vm.throwError(error, title: "Send Message Failed") } } } @@ -354,22 +356,8 @@ struct ConversationDetailView: View { loading = false } } catch { - showToastMessage( - "loadModels failed: \(error.localizedDescription)", - type: .error(Color.red) - ) - } - } - - private func formatTimeInterval(_ interval: TimeInterval?) -> String { - guard interval != nil else { - return "" + vm.throwError(error, title: "Load Models Failed") } - - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .abbreviated - return formatter.string(from: interval!) ?? "" } private func showToastMessage(_ message: String, type: AlertToast.AlertType) { diff --git a/ChatMLX/Features/Conversation/ConversationSidebarItem.swift b/ChatMLX/Features/Conversation/ConversationSidebarItem.swift index 69a3860..c8fedfd 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarItem.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarItem.swift @@ -5,14 +5,12 @@ // Created by John Mai on 2024/8/4. // -import SwiftData import SwiftUI struct ConversationSidebarItem: View { @ObservedObject var conversation: Conversation - - @Environment(\.managedObjectContext) private var viewContext + @Environment(\.managedObjectContext) private var viewContext @Binding var selectedConversation: Conversation? @@ -35,7 +33,7 @@ struct ConversationSidebarItem: View { Spacer() - Text(conversation.messages.last?.updatedAt.toFormattedString() ?? "") + Text(conversation.updatedAt.toFormatted()) .font(.caption) } .foregroundStyle(.white.opacity(0.7)) @@ -63,6 +61,6 @@ struct ConversationSidebarItem: View { } private func deleteConversation() { - try? PersistenceController.shared.delete(conversation, in: viewContext) + try? PersistenceController.shared.delete(conversation) } } diff --git a/ChatMLX/Features/Conversation/ConversationSidebarView.swift b/ChatMLX/Features/Conversation/ConversationSidebarView.swift index 05c7608..3883da4 100644 --- a/ChatMLX/Features/Conversation/ConversationSidebarView.swift +++ b/ChatMLX/Features/Conversation/ConversationSidebarView.swift @@ -59,7 +59,8 @@ struct ConversationSidebarView: View { LuminareSection { UltramanTextField( - $keyword, placeholder: Text("Search Conversation..."), onSubmit: updateSearchPredicate + $keyword, placeholder: Text("Search Conversation..."), + onSubmit: updateSearchPredicate ) .frame(height: 25) @@ -84,7 +85,9 @@ struct ConversationSidebarView: View { if keyword.isEmpty { conversations.nsPredicate = nil } else { - conversations.nsPredicate = NSPredicate(format: "title CONTAINS [cd] %@ OR ANY messages.content CONTAINS [cd] %@", keyword, keyword) + 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 b3edfd0..27067d4 100644 --- a/ChatMLX/Features/Conversation/ConversationView.swift +++ b/ChatMLX/Features/Conversation/ConversationView.swift @@ -9,10 +9,10 @@ import SwiftUI struct ConversationView: View { @Environment(ConversationViewModel.self) private var conversationViewModel - + var body: some View { @Bindable var conversationViewModel = conversationViewModel - + UltramanNavigationSplitView( sidebar: { ConversationSidebarView( @@ -25,14 +25,15 @@ struct ConversationView: View { .foregroundColor(.white) .ultramanMinimalistWindowStyle() } - + @MainActor @ViewBuilder private func Detail() -> some View { Group { if let conversation = conversationViewModel.selectedConversation { ConversationDetailView( - conversation: conversation).id(conversation.id) + conversation: conversation + ).id(conversation.id) } else { EmptyConversation() } diff --git a/ChatMLX/Features/Conversation/ConversationViewModel.swift b/ChatMLX/Features/Conversation/ConversationViewModel.swift index 7d5ff99..b9f0296 100644 --- a/ChatMLX/Features/Conversation/ConversationViewModel.swift +++ b/ChatMLX/Features/Conversation/ConversationViewModel.swift @@ -7,26 +7,30 @@ 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) { + func throwError(_ error: Error, title: String? = nil) { + logger.error("\(error.localizedDescription)") self.error = error + errorTitle = title showErrorAlert = true } - + func createConversation() { do { - let conversation = try PersistenceController.shared.createConversation() + let context = PersistenceController.shared.container.viewContext + let conversation = Conversation(context: context) + try PersistenceController.shared.save() selectedConversation = conversation } catch { - throwError(error: error) + throwError(error, title: "Create Conversation Failed") } } } diff --git a/ChatMLX/Features/Conversation/EmptyConversation.swift b/ChatMLX/Features/Conversation/EmptyConversation.swift index 2d92c7d..172df0b 100644 --- a/ChatMLX/Features/Conversation/EmptyConversation.swift +++ b/ChatMLX/Features/Conversation/EmptyConversation.swift @@ -9,7 +9,6 @@ import Luminare import SwiftUI struct EmptyConversation: View { - @Environment(\.modelContext) private var modelContext @Environment(ConversationViewModel.self) private var conversationViewModel var body: some View { diff --git a/ChatMLX/Features/Conversation/MessageBubbleView.swift b/ChatMLX/Features/Conversation/MessageBubbleView.swift index 1f2a6ad..20ac639 100644 --- a/ChatMLX/Features/Conversation/MessageBubbleView.swift +++ b/ChatMLX/Features/Conversation/MessageBubbleView.swift @@ -13,8 +13,11 @@ struct MessageBubbleView: View { @ObservedObject var message: Message @Binding var displayStyle: DisplayStyle @State private var showToast = false - @Environment(\.modelContext) private var modelContext + @Environment(LLMRunner.self) var runner + @Environment(ConversationViewModel.self) var vm + + @Environment(\.managedObjectContext) private var viewContext private func copyText() { let pasteboard = NSPasteboard.general @@ -25,13 +28,14 @@ struct MessageBubbleView: View { var body: some View { HStack { - if message.role == MessageSW.Role.assistant.rawValue { + if message.role == .assistant { assistantMessageView } else { Spacer() userMessageView } } + .textSelection(.enabled) .padding(.vertical, 8) .toast(isPresenting: $showToast, duration: 1.5, offsetY: 30) { AlertToast(displayMode: .hud, type: .complete(.green), title: "Copied") @@ -60,8 +64,6 @@ struct MessageBubbleView: View { ForegroundColor(.white) } .markdownTheme(.customGitHub) - .textSelection(.enabled) - } else { Text(message.content) } @@ -89,10 +91,10 @@ struct MessageBubbleView: View { .help("Regenerate") } - Text(formatDate(message.updatedAt)) + Text(message.updatedAt.toTimeFormatted()) .font(.caption) - if message.role == MessageSW.Role.assistant.rawValue, message.inferring { + if message.role == .assistant, message.inferring { ProgressView() .controlSize(.small) .colorInvert() @@ -120,7 +122,7 @@ struct MessageBubbleView: View { .cornerRadius(8) HStack { - Text(formatDate(message.updatedAt)) + Text(message.updatedAt.toTimeFormatted()) .font(.caption) Button(action: copyText) { @@ -139,47 +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 } -// -// if let conversation = message.conversation { -// if let index = conversation.sortedMessages.firstIndex(where: { $0.id == message.id }) { -// let messages = conversation.sortedMessages[index...] -// for messageToDelete in messages { -// conversation.messages.removeAll(where: { -// $0.id == messageToDelete.id -// }) -// modelContext.delete(messageToDelete) -// } -// conversation.updatedAt = Date() -// } -// } + 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 } -// -// if let conversation = message.conversation { -// if let index = conversation.sortedMessages.firstIndex(where: { $0.id == message.id }) { -// let messages = conversation.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) -// } -// } + 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 279dea4..0f0c09c 100644 --- a/ChatMLX/Features/Conversation/RightSidebarView.swift +++ b/ChatMLX/Features/Conversation/RightSidebarView.swift @@ -101,7 +101,7 @@ struct RightSidebarView: View { Double(conversation.repetitionContextSize) }, set: { - conversation.repetitionContextSize = Int32($0) + conversation.repetitionContextSize = Int($0) } ), in: 0 ... 100, step: 1 ) { diff --git a/ChatMLX/Features/Settings/DefaultConversationView.swift b/ChatMLX/Features/Settings/DefaultConversationView.swift index a47f62c..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") { @@ -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 f986499..83c1cdb 100644 --- a/ChatMLX/Features/Settings/GeneralView.swift +++ b/ChatMLX/Features/Settings/GeneralView.swift @@ -9,7 +9,6 @@ import CompactSlider import CoreData import Defaults import Luminare -import SwiftData import SwiftUI struct GeneralView: View { @@ -20,11 +19,10 @@ struct GeneralView: View { @Environment(\.managedObjectContext) private var viewContext - @Environment(ConversationViewModel.self) private - var conversationViewModel + @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) @@ -134,9 +132,23 @@ struct GeneralView: View { } private func clearAllConversations() { - try? PersistenceController.shared.clearMessage() - try? PersistenceController.shared.clearConversation() - conversationViewModel.selectedConversation = nil + do { + 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 { + 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 942a9c1..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 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/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents b/ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents index 0e97e07..d7ee4c8 100644 --- a/ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents +++ b/ChatMLX/Models/ChatMLX.xcdatamodeld/ChatMLX.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -27,7 +27,7 @@ - + diff --git a/ChatMLX/Models/Conversation+CoreDataClass.swift b/ChatMLX/Models/Conversation+CoreDataClass.swift index 6dbc786..6557201 100644 --- a/ChatMLX/Models/Conversation+CoreDataClass.swift +++ b/ChatMLX/Models/Conversation+CoreDataClass.swift @@ -6,8 +6,8 @@ // // -import Foundation import CoreData +import Foundation @objc(Conversation) public class Conversation: NSManagedObject { diff --git a/ChatMLX/Models/Conversation+CoreDataProperties.swift b/ChatMLX/Models/Conversation+CoreDataProperties.swift index 0f12d1c..3de83fb 100644 --- a/ChatMLX/Models/Conversation+CoreDataProperties.swift +++ b/ChatMLX/Models/Conversation+CoreDataProperties.swift @@ -10,33 +10,33 @@ import CoreData import Defaults import Foundation -public extension Conversation { - @nonobjc class func fetchRequest() -> NSFetchRequest { +extension Conversation { + @nonobjc public class func fetchRequest() -> NSFetchRequest { NSFetchRequest(entityName: "Conversation") } - @NSManaged var title: String - @NSManaged var model: String - @NSManaged var createdAt: Date - @NSManaged var updatedAt: Date - @NSManaged var temperature: Float - @NSManaged var topP: Float - @NSManaged var useMaxLength: Bool - @NSManaged var maxLength: Int64 - @NSManaged var repetitionContextSize: Int32 - @NSManaged var maxMessagesLimit: Int32 - @NSManaged var useMaxMessagesLimit: Bool - @NSManaged var useRepetitionPenalty: Bool - @NSManaged var repetitionPenalty: Float - @NSManaged var useSystemPrompt: Bool - @NSManaged var systemPrompt: String - @NSManaged var promptTime: Double - @NSManaged var generateTime: Double - @NSManaged var promptTokensPerSecond: Double - @NSManaged var tokensPerSecond: Double - @NSManaged var messages: [Message] - - override func awakeFromInsert() { + @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)) @@ -44,24 +44,35 @@ public extension Conversation { 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[.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[.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( + 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)) } - override func willSave() { + public override func willSave() { super.willSave() setPrimitiveValue(Date.now, forKey: #keyPath(Conversation.updatedAt)) } @@ -69,36 +80,36 @@ public extension Conversation { // MARK: Generated accessors for messages -public extension Conversation { +extension Conversation { @objc(insertObject:inMessagesAtIndex:) - @NSManaged func insertIntoMessages(_ value: Message, at idx: Int) + @NSManaged public func insertIntoMessages(_ value: Message, at idx: Int) @objc(removeObjectFromMessagesAtIndex:) - @NSManaged func removeFromMessages(at idx: Int) + @NSManaged public func removeFromMessages(at idx: Int) @objc(insertMessages:atIndexes:) - @NSManaged func insertIntoMessages(_ values: [Message], at indexes: NSIndexSet) + @NSManaged public func insertIntoMessages(_ values: [Message], at indexes: NSIndexSet) @objc(removeMessagesAtIndexes:) - @NSManaged func removeFromMessages(at indexes: NSIndexSet) + @NSManaged public func removeFromMessages(at indexes: NSIndexSet) @objc(replaceObjectInMessagesAtIndex:withObject:) - @NSManaged func replaceMessages(at idx: Int, with value: Message) + @NSManaged public func replaceMessages(at idx: Int, with value: Message) @objc(replaceMessagesAtIndexes:withMessages:) - @NSManaged func replaceMessages(at indexes: NSIndexSet, with values: [Message]) + @NSManaged public func replaceMessages(at indexes: NSIndexSet, with values: [Message]) @objc(addMessagesObject:) - @NSManaged func addToMessages(_ value: Message) + @NSManaged public func addToMessages(_ value: Message) @objc(removeMessagesObject:) - @NSManaged func removeFromMessages(_ value: Message) + @NSManaged public func removeFromMessages(_ value: Message) @objc(addMessages:) - @NSManaged func addToMessages(_ values: [Message]) + @NSManaged public func addToMessages(_ values: [Message]) @objc(removeMessages:) - @NSManaged func removeFromMessages(_ values: [Message]) + @NSManaged public func removeFromMessages(_ values: [Message]) } extension Conversation: Identifiable {} diff --git a/ChatMLX/Models/ConversationSW.swift b/ChatMLX/Models/ConversationSW.swift deleted file mode 100644 index feae010..0000000 --- a/ChatMLX/Models/ConversationSW.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// Conversation.swift -// ChatMLX -// -// Created by John Mai on 2024/8/4. -// - -import Defaults -import Foundation -import SwiftData - -@Model -final class ConversationSW { - var title: String - var model: String - var createdAt: Date - var updatedAt: Date - @Relationship(deleteRule: .cascade) var messages: [MessageSW] = [] - - var sortedMessages: [MessageSW] = [] - - var temperature: Float - var topP: Float - var useMaxLength: Bool - var maxLength: Int64 - var repetitionContextSize: Int32 - - var maxMessagesLimit: Int32 - 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? - - static var all: FetchDescriptor { - FetchDescriptor( - sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] - ) - } - - 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: MessageSW) { - messages.append(message) - updatedAt = Date() - } - - func startStreamingMessage(role: MessageSW.Role) -> MessageSW { - let message = MessageSW(role: role) - message.inferring = true - addMessage(message) - return message - } - - func updateStreamingMessage(_ message: MessageSW, with content: String) { - message.content = content - updatedAt = Date() - } - - func completeStreamingMessage(_ message: MessageSW) { - message.inferring = false - updatedAt = Date() - } - - func failedMessage(_ message: MessageSW, with error: Error) { - message.inferring = false - 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 index 0644f12..f87083e 100644 --- a/ChatMLX/Models/Message+CoreDataClass.swift +++ b/ChatMLX/Models/Message+CoreDataClass.swift @@ -6,10 +6,36 @@ // // -import Foundation 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 index c651d86..b0ef408 100644 --- a/ChatMLX/Models/Message+CoreDataProperties.swift +++ b/ChatMLX/Models/Message+CoreDataProperties.swift @@ -9,25 +9,35 @@ import CoreData import Foundation -public extension Message { - @nonobjc class func fetchRequest() -> NSFetchRequest { +extension Message { + @nonobjc public class func fetchRequest() -> NSFetchRequest { NSFetchRequest(entityName: "Message") } - @NSManaged var role: String - @NSManaged var content: String - @NSManaged var createdAt: Date - @NSManaged var inferring: Bool - @NSManaged var updatedAt: Date - @NSManaged var error: String? - @NSManaged var conversation: Conversation - - override func awakeFromInsert() { + @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)) } - override func willSave() { + public override func willSave() { super.willSave() setPrimitiveValue(Date.now, forKey: #keyPath(Message.updatedAt)) } diff --git a/ChatMLX/Models/MessageSW.swift b/ChatMLX/Models/MessageSW.swift deleted file mode 100644 index 201c8ef..0000000 --- a/ChatMLX/Models/MessageSW.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Message.swift -// ChatMLX -// -// Created by John Mai on 2024/8/4. -// - -import Foundation -import SwiftData - -@Model -final class MessageSW { - enum Role: String, Codable { - case user - case assistant - case system - } - - var role: Role - var content: String - - @Transient var inferring: Bool = false - - var createdAt: Date - var updatedAt: Date - - var error: String? - - var conversation: ConversationSW? - - init( - role: Role, - content: String = "" - ) { - self.role = role - self.content = content - self.createdAt = Date() - self.updatedAt = Date() - } -} 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 5927ad7..1810940 100644 --- a/ChatMLX/Utilities/LLMRunner.swift +++ b/ChatMLX/Utilities/LLMRunner.swift @@ -6,10 +6,10 @@ // import Defaults -import Metal import MLX import MLXLLM import MLXRandom +import Metal import SwiftUI import Tokenizers @@ -62,132 +62,142 @@ class LLMRunner { } } - func generate(message: String, conversation: Conversation, in context: NSManagedObjectContext) async { - guard !running else { return } + 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() + } - await MainActor.run { - running = true + if conversation.useSystemPrompt, !conversation.systemPrompt.isEmpty { + dictionary.insert( + formatMessage( + role: .system, + content: conversation.systemPrompt + ), + at: 0 + ) } - let userMessage = Message(context: context) - userMessage.role = MessageSW.Role.user.rawValue - userMessage.content = message - userMessage.conversation = conversation + return dictionary + } -// let message = conversation.startStreamingMessage(role: .assistant) + func formatMessage(role: Role, content: String) -> [String: String] { + [ + "role": role.rawValue, + "content": content, + ] + } - let assistantMessage = Message(context: context) - assistantMessage.role = MessageSW.Role.assistant.rawValue - assistantMessage.inferring = true - assistantMessage.content = "" - assistantMessage.conversation = conversation + func generate(conversation: Conversation, in context: NSManagedObjectContext) { + guard !running else { return } + running = true - do { - if conversation.model != modelConfiguration?.name { - loadState = .idle - modelConfiguration = ModelConfiguration.configuration( - id: conversation.model) - } + let assistantMessage = Message(context: context).assistant(conversation: conversation) - if let modelConfiguration { - guard let modelContainer = try await load() else { - throw LLMRunnerError.failedToLoadModel - } + let parameters = GenerateParameters( + temperature: conversation.temperature, + topP: conversation.topP, + repetitionPenalty: conversation.useRepetitionPenalty + ? conversation.repetitionPenalty : nil, + repetitionContextSize: Int(conversation.repetitionContextSize) + ) - var messages = conversation.messages + let useMaxLength = conversation.useMaxLength + let maxLength = conversation.maxLength - if conversation.useMaxMessagesLimit { - let maxCount = conversation.maxMessagesLimit + 1 - if messages.count > maxCount { - messages = Array(messages.suffix(Int(maxCount))) - if messages.first?.role != MessageSW.Role.user.rawValue { - messages = Array(messages.dropFirst()) - } + Task { + do { + switchModel(conversation) + + if let modelConfiguration { + guard let modelContainer = try await load() else { + throw LLMRunnerError.failedToLoadModel } - } -// if conversation.useSystemPrompt, !conversation.systemPrompt.isEmpty { -// messages.insert( -// Message( -// role: .system, -// content: conversation.systemPrompt -// ), -// at: 0 -// ) -// } - - let messagesDicts = messages[..<(messages.count - 1)].map { - message -> [String: String] in - ["role": message.role, "content": message.content] - } + let messages = prepare(conversation) - print("messagesDicts", messagesDicts) + logger.info("prepare messages -> \(messages)") - let messageTokens = try await modelContainer.perform { - _, tokenizer in - try tokenizer.applyChatTemplate(messages: messagesDicts) - } + 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: messageTokens, - parameters: GenerateParameters( - temperature: conversation.temperature, - topP: conversation.topP, - repetitionPenalty: conversation.useRepetitionPenalty - ? conversation.repetitionPenalty : nil, -// repetitionContextSize: Int(conversation.repetitionContextSize) - repetitionContextSize: 20 - ), - model: model, - tokenizer: tokenizer, - extraEOSTokens: modelConfiguration.extraEOSTokens.union([ - "<|im_end|>", "<|end|>", - ]) - ) { tokens in - if tokens.count % displayEveryNTokens == 0 { - let text = tokenizer.decode(tokens: tokens) - print("assistantMessage.content ->", text) - Task { @MainActor in - assistantMessage.content = text + 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 + } } - } - if conversation.useMaxLength, tokens.count >= conversation.maxLength { - return .stop - } - return .more - } - } + if useMaxLength, tokens.count >= maxLength { + return .stop + } - await MainActor.run { - if result.output != assistantMessage.content { - assistantMessage.content = result.output + return .more + } } - assistantMessage.inferring = false conversation.promptTime = result.promptTime conversation.generateTime = result.generateTime - conversation.promptTokensPerSecond = - result.promptTokensPerSecond + conversation.promptTokensPerSecond = result.promptTokensPerSecond conversation.tokensPerSecond = result.tokensPerSecond + + await MainActor.run { + if result.output != assistantMessage.content { + assistantMessage.content = result.output + } + + assistantMessage.inferring = false + running = false + } + } + } catch { + logger.error("LLM Generate Failed: \(error.localizedDescription)") + await MainActor.run { + assistantMessage.inferring = false + assistantMessage.error = error.localizedDescription + running = false + } + } + + Task(priority: .background) { + await context.perform { + if context.hasChanges { + try? context.save() + } } } - } catch { - print("\(error)") - logger.error("LLM Generate Failed: \(error.localizedDescription)") -// await MainActor.run { -// conversation.failedMessage(message, with: error) -// } - } - await MainActor.run { - running = false } } } diff --git a/ChatMLX/Utilities/PersistenceController.swift b/ChatMLX/Utilities/PersistenceController.swift index fe4a9d8..f57a70d 100644 --- a/ChatMLX/Utilities/PersistenceController.swift +++ b/ChatMLX/Utilities/PersistenceController.swift @@ -25,55 +25,50 @@ struct PersistenceController { container.viewContext.automaticallyMergesChangesFromParent = true } - func exisits(_ model: T, - in context: NSManagedObjectContext) -> T? - { + func exisits( + _ model: T, + in context: NSManagedObjectContext + ) -> T? { try? context.existingObject(with: model.objectID) as? T } - func delete(_ model: some NSManagedObject, - in context: NSManagedObjectContext) throws - { - if let existingContact = exisits(model, in: context) { - context.delete(existingContact) + func delete(_ model: some NSManagedObject) throws { + if let existingContact = exisits(model, in: container.viewContext) { + container.viewContext.delete(existingContact) Task(priority: .background) { - try await context.perform { - try context.save() + try await container.viewContext.perform { + try container.viewContext.save() } } } } - func clear(_ entityName: String) throws { - let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: entityName) + 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 + if let fetchResult = try container.viewContext.execute(batchDeteleRequest) + as? NSBatchDeleteResult, + let deletedManagedObjectIds = fetchResult.result as? [NSManagedObjectID], + !deletedManagedObjectIds.isEmpty { - let changes = [NSDeletedObjectsKey: deletedManagedObjectIds] - NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [container.viewContext]) + return deletedManagedObjectIds } + + return [] } - + func save() throws { - if container.viewContext.hasChanges { - try container.viewContext.save() + Task(priority: .background) { + let context = container.viewContext + + try await context.perform { + if context.hasChanges { + try context.save() + } + } } } - - func createConversation() throws -> Conversation { - let conversation = Conversation(context: container.viewContext) - try save() - return conversation - } - - func clearConversation() throws { - try clear("Conversation") - } - - func clearMessage() throws { - try clear("Message") - } } From 72ba4d45fdd39501dfd1ef08808cd02838cfd139 Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Thu, 3 Oct 2024 22:49:06 +0800 Subject: [PATCH 7/9] :bookmark: Update Version --- ChatMLX.xcodeproj/project.pbxproj | 4 ++-- ChatMLX/Localizable.xcstrings | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ChatMLX.xcodeproj/project.pbxproj b/ChatMLX.xcodeproj/project.pbxproj index 25ce09d..bfdf27b 100644 --- a/ChatMLX.xcodeproj/project.pbxproj +++ b/ChatMLX.xcodeproj/project.pbxproj @@ -715,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; @@ -744,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; diff --git a/ChatMLX/Localizable.xcstrings b/ChatMLX/Localizable.xcstrings index 4e5a2aa..d82e037 100644 --- a/ChatMLX/Localizable.xcstrings +++ b/ChatMLX/Localizable.xcstrings @@ -679,9 +679,6 @@ } } } - }, - "Error" : { - }, "Exit Full Screen" : { "localizations" : { From ba25a8c088bbb3e18b6aa5651cd7a87c6442e1a3 Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Thu, 3 Oct 2024 22:52:45 +0800 Subject: [PATCH 8/9] :bookmark: Update Localizable --- ChatMLX.xcodeproj/project.pbxproj | 4 ++-- ChatMLX/Localizable.xcstrings | 39 ++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/ChatMLX.xcodeproj/project.pbxproj b/ChatMLX.xcodeproj/project.pbxproj index bfdf27b..c330cc4 100644 --- a/ChatMLX.xcodeproj/project.pbxproj +++ b/ChatMLX.xcodeproj/project.pbxproj @@ -702,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; @@ -731,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; diff --git a/ChatMLX/Localizable.xcstrings b/ChatMLX/Localizable.xcstrings index d82e037..e4c4304 100644 --- a/ChatMLX/Localizable.xcstrings +++ b/ChatMLX/Localizable.xcstrings @@ -11,7 +11,7 @@ "shouldTranslate" : false }, "%d" : { - + "shouldTranslate" : false }, "%lld" : { "localizations" : { @@ -709,7 +709,32 @@ } }, "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", @@ -1292,7 +1317,15 @@ } }, "OK" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + }, + "shouldTranslate" : false }, "Please enter Hugging Face Repo ID" : { "extractionState" : "manual", From e2b133e8ac7b8dd9df036b55143b1a9421445c0f Mon Sep 17 00:00:00 2001 From: maiqingqiang <867409182@qq.com> Date: Fri, 4 Oct 2024 00:44:11 +0800 Subject: [PATCH 9/9] :bug: Fix Message scrollToBottom --- .../Conversation/ConversationDetailView.swift | 38 +++++++++++-------- ChatMLX/Utilities/LLMRunner.swift | 6 ++- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/ChatMLX/Features/Conversation/ConversationDetailView.swift b/ChatMLX/Features/Conversation/ConversationDetailView.swift index 6e0f7c8..8cf4778 100644 --- a/ChatMLX/Features/Conversation/ConversationDetailView.swift +++ b/ChatMLX/Features/Conversation/ConversationDetailView.swift @@ -31,8 +31,8 @@ struct ConversationDetailView: View { @State private var toastMessage = "" @State private var toastType: AlertToast.AlertType = .regular @State private var loading = true + @State private var scrollViewProxy: ScrollViewProxy? - @Namespace var bottomId @FocusState private var isInputFocused: Bool var body: some View { @@ -93,38 +93,40 @@ struct ConversationDetailView: View { } } .padding() - .id(bottomId) } .onChange( of: conversation.messages.last, - { - proxy.scrollTo(bottomId, anchor: .bottom) + { _, _ 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("plaintext") - } else { - Image("markdown") - } + Image(displayStyle == .markdown ? "plaintext" : "markdown") } Button(action: { @@ -296,7 +298,11 @@ struct ConversationDetailView: View { Message(context: viewContext).user(content: trimmedMessage, conversation: conversation) - runner.generate(conversation: conversation, in: viewContext) + runner.generate(conversation: conversation, in: viewContext) { + scrollToBottom() + } + + scrollToBottom() Task(priority: .background) { do { diff --git a/ChatMLX/Utilities/LLMRunner.swift b/ChatMLX/Utilities/LLMRunner.swift index 1810940..dcb4c66 100644 --- a/ChatMLX/Utilities/LLMRunner.swift +++ b/ChatMLX/Utilities/LLMRunner.swift @@ -107,7 +107,10 @@ class LLMRunner { ] } - func generate(conversation: Conversation, in context: NSManagedObjectContext) { + func generate( + conversation: Conversation, in context: NSManagedObjectContext, + progressing: @escaping () -> Void = {} + ) { guard !running else { return } running = true @@ -157,6 +160,7 @@ class LLMRunner { let text = tokenizer.decode(tokens: tokens) Task { @MainActor in assistantMessage.content = text + progressing() } }