SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%
15.3 KB · 373 lines swift
Raw Blame History
1//2//  ConversationStore.swift3//  Zyquo Cloud4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Central chat state: the conversation list, selection, and the chat engine9//  that streams provider responses into messages. All mutation happens on the10//  main actor; streaming work runs in tasks that hop back for UI updates.11//1213import Foundation1415@MainActor16final class ConversationStore: ObservableObject {17    @Published var conversations: [Conversation] = []18    @Published var selectedID: Conversation.ID?19    @Published var searchText: String = ""20    /// Live streaming task per conversation (supports parallel streams in compare mode).21    @Published private(set) var streamingConversationIDs: Set<Conversation.ID> = []2223    let catalog: ModelCatalog24    let vault: KeyVaultStore25    private let persistence: PersistenceService26    private var streamTasks: [Conversation.ID: Task<Void, Never>] = [:]2728    /// Global default system prompt (Settings → Advanced).29    @Published var defaultSystemPrompt: String {30        didSet { persistence.save(defaultSystemPrompt, to: "default-system-prompt.json") }31    }3233    init(34        catalog: ModelCatalog,35        vault: KeyVaultStore,36        persistence: PersistenceService = .shared37    ) {38        self.catalog = catalog39        self.vault = vault40        self.persistence = persistence41        self.defaultSystemPrompt = persistence.load(String.self, from: "default-system-prompt.json") ?? ""42        conversations = persistence.loadConversations()43        selectedID = conversations.first?.id44    }4546    // MARK: - Selection & lookup4748    var selected: Conversation? {49        get { conversations.first { $0.id == selectedID } }50    }5152    func binding(for id: Conversation.ID) -> Int? {53        conversations.firstIndex { $0.id == id }54    }5556    func isStreaming(_ id: Conversation.ID) -> Bool {57        streamingConversationIDs.contains(id)58    }5960    // MARK: - CRUD6162    @discardableResult63    func newConversation(model: AIModel? = nil, persona: Persona? = nil) -> Conversation {64        let chosen = model ?? catalog.defaultModel65        var conversation = Conversation(66            modelID: chosen?.id ?? "",67            provider: chosen?.provider ?? .openai,68            systemPrompt: persona?.systemPrompt ?? (defaultSystemPrompt.isEmpty ? nil : defaultSystemPrompt)69        )70        if let persona {71            conversation.personaID = persona.id72            conversation.parameters = persona.parameters73            if let modelID = persona.modelID, let provider = persona.provider {74                conversation.modelID = modelID75                conversation.provider = provider76            }77        }78        conversations.insert(conversation, at: 0)79        selectedID = conversation.id80        persistence.save(conversation)81        return conversation82    }8384    func delete(_ id: Conversation.ID) {85        stopStreaming(id)86        if let conversation = conversations.first(where: { $0.id == id }) {87            persistence.delete(conversation)88        }89        conversations.removeAll { $0.id == id }90        if selectedID == id { selectedID = conversations.first?.id }91    }9293    func update(_ conversation: Conversation) {94        guard let index = conversations.firstIndex(where: { $0.id == conversation.id }) else { return }95        var updated = conversation96        updated.updatedAt = Date()97        conversations[index] = updated98        persistence.save(updated)99    }100101    func togglePin(_ id: Conversation.ID) {102        guard var conversation = conversations.first(where: { $0.id == id }) else { return }103        conversation.isPinned.toggle()104        update(conversation)105    }106107    func rename(_ id: Conversation.ID, to title: String) {108        guard var conversation = conversations.first(where: { $0.id == id }) else { return }109        conversation.title = title110        conversation.hasAutoTitle = false111        update(conversation)112    }113114    // MARK: - Sidebar grouping115116    struct SidebarGroup: Identifiable {117        let id: String118        let title: String119        let conversations: [Conversation]120    }121122    /// Pinned / Today / Yesterday / Previous 7 Days / Older, filtered by search.123    var sidebarGroups: [SidebarGroup] {124        let filtered = searchText.isEmpty125            ? conversations126            : conversations.filter { conversation in127                conversation.title.localizedCaseInsensitiveContains(searchText)128                    || conversation.messages.contains {129                        $0.text.localizedCaseInsensitiveContains(searchText)130                    }131            }132        let calendar = Calendar.current133        let now = Date()134        var pinned: [Conversation] = []135        var today: [Conversation] = []136        var yesterday: [Conversation] = []137        var week: [Conversation] = []138        var older: [Conversation] = []139        for conversation in filtered {140            if conversation.isPinned { pinned.append(conversation); continue }141            if calendar.isDateInToday(conversation.updatedAt) { today.append(conversation) }142            else if calendar.isDateInYesterday(conversation.updatedAt) { yesterday.append(conversation) }143            else if conversation.updatedAt > now.addingTimeInterval(-7 * 86_400) { week.append(conversation) }144            else { older.append(conversation) }145        }146        return [147            SidebarGroup(id: "pinned", title: "Pinned", conversations: pinned),148            SidebarGroup(id: "today", title: "Today", conversations: today),149            SidebarGroup(id: "yesterday", title: "Yesterday", conversations: yesterday),150            SidebarGroup(id: "week", title: "Previous 7 Days", conversations: week),151            SidebarGroup(id: "older", title: "Older", conversations: older),152        ].filter { !$0.conversations.isEmpty }153    }154155    // MARK: - Chat engine156157    /// Sends the user's text (and attachments) in a conversation and streams158    /// the assistant reply. Optionally targets a different model for this159    /// message only (per-message model switch).160    func send(161        text: String,162        attachments: [Attachment] = [],163        in conversationID: Conversation.ID,164        overrideModel: AIModel? = nil165    ) {166        guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return }167        guard let model = overrideModel168            ?? catalog.model(id: conversation.modelID, provider: conversation.provider)169            ?? catalog.defaultModel170        else { return }171172        if let overrideModel {173            conversation.modelID = overrideModel.id174            conversation.provider = overrideModel.provider175        }176177        var userMessage = Message(role: .user, text: text, attachments: attachments)178        userMessage.modelID = model.id179        userMessage.provider = model.provider180        conversation.messages.append(userMessage)181        update(conversation)182183        generateReply(in: conversationID, model: model)184    }185186    /// Regenerates the last assistant message (optionally with another model).187    func regenerate(in conversationID: Conversation.ID, with model: AIModel? = nil) {188        guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return }189        if conversation.messages.last?.role == .assistant {190            conversation.messages.removeLast()191            update(conversation)192        }193        guard let target = model194            ?? catalog.model(id: conversation.modelID, provider: conversation.provider)195        else { return }196        generateReply(in: conversationID, model: target)197    }198199    /// Replaces a user message's text and regenerates from that point.200    func editAndResend(messageID: Message.ID, newText: String, in conversationID: Conversation.ID) {201        guard var conversation = conversations.first(where: { $0.id == conversationID }),202              let index = conversation.messages.firstIndex(where: { $0.id == messageID })203        else { return }204        conversation.messages[index].text = newText205        conversation.messages.removeSubrange((index + 1)...)206        update(conversation)207        guard let model = catalog.model(id: conversation.modelID, provider: conversation.provider) else { return }208        generateReply(in: conversationID, model: model)209    }210211    func deleteMessage(_ messageID: Message.ID, in conversationID: Conversation.ID) {212        guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return }213        conversation.messages.removeAll { $0.id == messageID }214        update(conversation)215    }216217    func stopStreaming(_ conversationID: Conversation.ID) {218        streamTasks[conversationID]?.cancel()219        streamTasks[conversationID] = nil220        streamingConversationIDs.remove(conversationID)221        finalizeStreamingMessage(in: conversationID)222    }223224    private func generateReply(in conversationID: Conversation.ID, model: AIModel) {225        guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return }226227        var placeholder = Message(role: .assistant, text: "")228        placeholder.modelID = model.id229        placeholder.provider = model.provider230        placeholder.isStreaming = true231        conversations[index].messages.append(placeholder)232        let messageID = placeholder.id233234        let request = ChatRequest(235            model: model,236            systemPrompt: conversations[index].systemPrompt,237            messages: conversations[index].messages.filter { $0.id != messageID && $0.errorText == nil },238            parameters: conversations[index].parameters239        )240241        streamingConversationIDs.insert(conversationID)242        let task = Task { [weak self] in243            guard let self else { return }244            do {245                let apiKey = try self.vault.apiKey(for: model.provider)246                let client = ProviderRegistry.client(for: model)247                var usage: TokenUsage?248                for try await event in client.streamChat(request, apiKey: apiKey) {249                    if Task.isCancelled { break }250                    switch event {251                    case .textDelta(let delta):252                        self.mutateMessage(messageID, in: conversationID) { $0.text += delta }253                    case .reasoningDelta(let delta):254                        self.mutateMessage(messageID, in: conversationID) {255                            $0.reasoning = ($0.reasoning ?? "") + delta256                        }257                    case .citations(let citations):258                        self.mutateMessage(messageID, in: conversationID) { $0.citations = citations }259                    case .usage(let u):260                        usage = u261                    case .finished:262                        break263                    }264                }265                let finalUsage = usage266                self.mutateMessage(messageID, in: conversationID) { message in267                    message.isStreaming = false268                    if let u = finalUsage {269                        message.usage = u270                        message.estimatedCost = model.pricing?.cost(271                            inputTokens: u.inputTokens, outputTokens: u.outputTokens272                        )273                    }274                }275            } catch let error as ProviderError {276                if case .cancelled = error {277                    self.mutateMessage(messageID, in: conversationID) { $0.isStreaming = false }278                } else {279                    self.mutateMessage(messageID, in: conversationID) {280                        $0.isStreaming = false281                        $0.errorText = error.localizedDescription282                    }283                }284            } catch {285                self.mutateMessage(messageID, in: conversationID) {286                    $0.isStreaming = false287                    $0.errorText = error.localizedDescription288                }289            }290            self.streamingConversationIDs.remove(conversationID)291            self.streamTasks[conversationID] = nil292            self.persistSnapshot(of: conversationID)293            self.autoTitleIfNeeded(conversationID)294        }295        streamTasks[conversationID] = task296    }297298    private func mutateMessage(299        _ messageID: Message.ID,300        in conversationID: Conversation.ID,301        _ mutate: (inout Message) -> Void302    ) {303        guard let ci = conversations.firstIndex(where: { $0.id == conversationID }),304              let mi = conversations[ci].messages.firstIndex(where: { $0.id == messageID })305        else { return }306        mutate(&conversations[ci].messages[mi])307    }308309    private func finalizeStreamingMessage(in conversationID: Conversation.ID) {310        guard let ci = conversations.firstIndex(where: { $0.id == conversationID }) else { return }311        for mi in conversations[ci].messages.indices where conversations[ci].messages[mi].isStreaming {312            conversations[ci].messages[mi].isStreaming = false313        }314        persistSnapshot(of: conversationID)315    }316317    private func persistSnapshot(of conversationID: Conversation.ID) {318        guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return }319        conversation.updatedAt = Date()320        if let index = conversations.firstIndex(where: { $0.id == conversationID }) {321            conversations[index] = conversation322        }323        persistence.save(conversation)324    }325326    // MARK: - Auto titles327328    /// After the first completed exchange, asks the provider's cheapest model329    /// for a short title.330    private func autoTitleIfNeeded(_ conversationID: Conversation.ID) {331        guard let conversation = conversations.first(where: { $0.id == conversationID }),332              conversation.hasAutoTitle,333              conversation.messages.filter({ $0.role == .assistant && !$0.text.isEmpty }).count == 1,334              let cheap = catalog.cheapestModel(for: conversation.provider),335              let firstUser = conversation.messages.first(where: { $0.role == .user })336        else { return }337338        let assistantText = conversation.messages.last { $0.role == .assistant }?.text ?? ""339        let prompt = """340        Write a title of at most 5 words for this conversation. Reply with the title only, no quotes.341342        User: \(firstUser.text.prefix(500))343        Assistant: \(assistantText.prefix(500))344        """345        Task { [weak self] in346            guard let self else { return }347            do {348                let apiKey = try self.vault.apiKey(for: cheap.provider)349                let client = ProviderRegistry.client(for: cheap)350                let request = ChatRequest(351                    model: cheap,352                    systemPrompt: nil,353                    messages: [Message(role: .user, text: prompt)],354                    parameters: ChatParameters(maxTokens: 24),355                    stream: false356                )357                let reply = try await client.complete(request, apiKey: apiKey)358                let title = reply.text359                    .trimmingCharacters(in: .whitespacesAndNewlines)360                    .trimmingCharacters(in: CharacterSet(charactersIn: "\"“”"))361                guard !title.isEmpty,362                      var current = self.conversations.first(where: { $0.id == conversationID }),363                      current.hasAutoTitle364                else { return }365                current.title = String(title.prefix(60))366                self.update(current)367            } catch {368                // Title generation is best-effort; keep "New Chat" on failure.369            }370        }371    }372}373