SPB Git

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%
6.9 KB · 217 lines swift
Raw Blame History
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