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