spb/zyquo-local Public MIT
Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.
Swift 97.2%
Shell 1.8%
Makefile 1%
1//2// AppModel.swift3// Zyquo Local4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import MLX11import Observation1213/// Root coordinator: owns the engine, stores, downloads and conversations.14@MainActor15@Observable16final class AppModel {17 let settings: AppSettings18 let store: ModelStore19 let downloads: DownloadManager20 let engine = InferenceEngine()21 let chat: ChatController2223 var conversations: [Conversation] = []24 var selectedConversationID: UUID?2526 /// Mirrors the engine actor's state for the UI.27 var engineState: EngineState = .unloaded28 /// Live MLX active memory while a model is loaded (footer chip).29 var liveMemoryBytes: Int = 030 /// Last load error to surface in the UI.31 var lastError: String?3233 var personaStore: PersonaStore34 var promptLibrary: PromptLibrary3536 /// Detail column routing.37 enum DetailRoute: Hashable {38 case chat39 case library40 }41 var route: DetailRoute = .chat4243 var selectedConversation: Conversation? {44 get { conversations.first { $0.id == selectedConversationID } }45 set {46 guard let newValue, let i = conversations.firstIndex(where: { $0.id == newValue.id }) else { return }47 conversations[i] = newValue48 }49 }5051 var loadedModelID: String? {52 switch engineState {53 case .ready(let id), .generating(let id), .loading(let id): id54 case .unloaded: nil55 }56 }5758 init() {59 let settings = AppSettings()60 self.settings = settings61 let store = ModelStore()62 self.store = store63 self.downloads = DownloadManager(hub: HubService(token: settings.hfToken), store: store)64 self.personaStore = PersonaStore()65 self.promptLibrary = PromptLibrary()66 self.chat = ChatController()67 chat.bind(to: self)6869 conversations = PersistenceService.loadConversations()70 if conversations.isEmpty {71 newConversation()72 } else {73 selectedConversationID = conversations.first?.id74 }7576 applyGPUCacheLimit()7778 // Load the default model on launch when configured.79 if let defaultID = settings.defaultModelID, store.model(for: defaultID) != nil {80 Task { await loadModel(repoID: defaultID) }81 }82 // Keep the memory readout fresh while a model is loaded.83 Task { await memoryTicker() }84 }8586 func applyGPUCacheLimit() {87 if settings.gpuCacheLimitMB > 0 {88 MLX.Memory.cacheLimit = settings.gpuCacheLimitMB * 1_048_57689 }90 }9192 /// Push the (possibly updated) HF token into services that need it.93 func refreshToken() {94 downloads.hub = HubService(token: settings.hfToken)95 }9697 // MARK: - Conversations9899 @discardableResult100 func newConversation(persona: Persona? = nil) -> Conversation {101 var conversation = Conversation(102 modelID: loadedModelID ?? settings.defaultModelID,103 systemPrompt: persona?.systemPrompt104 ?? (settings.defaultSystemPrompt.isEmpty ? nil : settings.defaultSystemPrompt),105 params: persona?.params ?? settings.defaultParams106 )107 if let persona {108 conversation.title = persona.name109 if let preferred = persona.preferredModelID { conversation.modelID = preferred }110 }111 conversations.insert(conversation, at: 0)112 selectedConversationID = conversation.id113 route = .chat114 PersistenceService.save(conversation)115 return conversation116 }117118 func delete(conversationID: UUID) {119 conversations.removeAll { $0.id == conversationID }120 PersistenceService.delete(conversationID: conversationID)121 if selectedConversationID == conversationID {122 selectedConversationID = conversations.first?.id123 }124 }125126 func update(_ conversation: Conversation, touch: Bool = true) {127 var conversation = conversation128 if touch { conversation.updatedAt = Date() }129 if let i = conversations.firstIndex(where: { $0.id == conversation.id }) {130 conversations[i] = conversation131 }132 PersistenceService.save(conversation)133 }134135 func togglePin(conversationID: UUID) {136 guard var c = conversations.first(where: { $0.id == conversationID }) else { return }137 c.pinned.toggle()138 update(c, touch: false)139 }140141 // MARK: - Model lifecycle142143 func loadModel(repoID: String) async {144 guard let model = store.model(for: repoID) else {145 lastError = "\(repoID) is not downloaded."146 return147 }148 engineState = .loading(repoID: repoID)149 do {150 try await engine.load(model: model)151 engineState = await engine.state152 store.markUsed(repoID: repoID)153 // Bind the current conversation to the newly loaded model.154 if var c = selectedConversation {155 c.modelID = repoID156 update(c, touch: false)157 }158 } catch {159 engineState = .unloaded160 lastError = error.localizedDescription161 }162 }163164 func unloadModel() async {165 await engine.unload()166 engineState = .unloaded167 liveMemoryBytes = 0168 }169170 private func memoryTicker() async {171 while !Task.isCancelled {172 try? await Task.sleep(for: .seconds(2))173 if loadedModelID != nil {174 liveMemoryBytes = MemoryAdvisor.activeMemoryBytes175 }176 }177 }178179 // MARK: - Sidebar grouping & search180181 struct SidebarGroup: Identifiable {182 var title: String183 var conversations: [Conversation]184 var id: String { title }185 }186187 func sidebarGroups(query: String) -> [SidebarGroup] {188 let filtered = query.isEmpty189 ? conversations190 : conversations.filter { c in191 c.title.localizedCaseInsensitiveContains(query)192 || c.messages.contains { $0.content.localizedCaseInsensitiveContains(query) }193 }194 var groups: [SidebarGroup] = []195 let pinned = filtered.filter(\.pinned)196 if !pinned.isEmpty {197 groups.append(SidebarGroup(title: "Pinned", conversations: pinned))198 }199 let rest = filtered.filter { !$0.pinned }200 let calendar = Calendar.current201 let now = Date()202 func bucket(_ date: Date) -> String {203 if calendar.isDateInToday(date) { return "Today" }204 if calendar.isDateInYesterday(date) { return "Yesterday" }205 if date > calendar.date(byAdding: .day, value: -7, to: now)! { return "Previous 7 Days" }206 return "Older"207 }208 for title in ["Today", "Yesterday", "Previous 7 Days", "Older"] {209 let matching = rest.filter { bucket($0.updatedAt) == title }210 if !matching.isEmpty {211 groups.append(SidebarGroup(title: title, conversations: matching))212 }213 }214 return groups215 }216}217