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%
11.4 KB · 279 lines swift
Raw Blame History
1//2//  ChatController.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation10import Observation1112/// Drives one streaming generation at a time for the selected conversation:13/// send/stop/regenerate/edit-resend, live `<think>` parsing, throttled14/// tokens-per-second ticker (≤4 Hz), stats capture, and auto-titling.15@MainActor16@Observable17final class ChatController {18    private(set) var isGenerating = false19    /// Visible (non-thinking) streamed text of the in-flight response.20    private(set) var streamingText = ""21    /// Streamed `<think>` content of the in-flight response.22    private(set) var streamingThinking = ""23    /// True while the stream is inside a `<think>` block.24    private(set) var isThinking = false25    /// Live generation speed, updated at most 4 Hz.26    private(set) var liveTokensPerSecond: Double = 027    /// Conversation currently streaming (may differ from the selection).28    private(set) var streamingConversationID: UUID?2930    private weak var app: AppModel?31    private var generationTask: Task<Void, Never>?3233    func bind(to app: AppModel) {34        self.app = app35    }3637    // MARK: - Actions3839    /// Sends `prompt` in the given conversation, streaming the response.40    func send(prompt: String, in conversation: Conversation) {41        guard let app, !isGenerating else { return }42        var conversation = conversation43        let userMessage = Message(role: .user, content: prompt)44        conversation.messages.append(userMessage)45        app.update(conversation)46        stream(prompt: prompt, conversation: conversation)47    }4849    /// Regenerates the last assistant response (optionally after the user50    /// switched models).51    func regenerate(in conversation: Conversation) {52        guard !isGenerating else { return }53        var conversation = conversation54        guard let lastUser = conversation.messages.last(where: { $0.role == .user }) else { return }55        // Drop trailing assistant message(s) after the last user turn.56        while let last = conversation.messages.last, last.role == .assistant {57            conversation.messages.removeLast()58        }59        app?.update(conversation)60        stream(prompt: lastUser.content, conversation: conversation, replayingLastUser: true)61    }6263    /// Edits a previous user message and resends from that point.64    func editAndResend(messageID: UUID, newText: String, in conversation: Conversation) {65        guard !isGenerating else { return }66        var conversation = conversation67        guard let index = conversation.messages.firstIndex(where: { $0.id == messageID }) else { return }68        conversation.messages[index].content = newText69        conversation.messages.removeSubrange((index + 1)...)70        app?.update(conversation)71        stream(prompt: newText, conversation: conversation, replayingLastUser: true)72    }7374    func stop() {75        generationTask?.cancel()76        Task { await app?.engine.stopGeneration() }77    }7879    // MARK: - Streaming core8081    /// `replayingLastUser`: the prompt is already the last user message in82    /// `conversation.messages`; the engine session must be rebuilt so its83    /// history excludes it (it is re-sent as the new turn).84    private func stream(prompt: String, conversation: Conversation, replayingLastUser: Bool = false) {85        guard let app else { return }86        let conversationID = conversation.id8788        streamingText = ""89        streamingThinking = ""90        isThinking = false91        liveTokensPerSecond = 092        isGenerating = true93        streamingConversationID = conversationID9495        generationTask = Task {96            var parser = ThinkTagParser()97            var stats: GenerationStats?98            var finish: GenerationFinishReason = .stop99            let started = Date()100            var tokenCount = 0101            var lastTick = Date.distantPast102103            do {104                // The engine session's history must exclude the new prompt:105                // strip the trailing user message before (re)building.106                var sessionConversation = conversation107                if let last = sessionConversation.messages.last, last.role == .user {108                    sessionConversation.messages.removeLast()109                }110                if replayingLastUser {111                    try await app.engine.startSession(conversation: sessionConversation)112                } else {113                    try await app.engine.ensureSession(conversation: sessionConversation)114                }115116                let events = try await app.engine.generate(prompt: prompt, params: conversation.params)117                for try await event in events {118                    switch event {119                    case .token(let text):120                        tokenCount += 1121                        let (visible, thinking, inThink) = parser.consume(text)122                        if !visible.isEmpty { streamingText += visible }123                        if !thinking.isEmpty { streamingThinking += thinking }124                        isThinking = inThink125                        // ≤4 Hz ticker to avoid flicker.126                        let now = Date()127                        if now.timeIntervalSince(lastTick) >= 0.25 {128                            lastTick = now129                            let elapsed = now.timeIntervalSince(started)130                            if elapsed > 0.5 {131                                liveTokensPerSecond = Double(tokenCount) / elapsed132                            }133                        }134                    case .stats(let s):135                        stats = s136                    case .finished(let reason):137                        finish = reason138                    }139                }140            } catch {141                app.lastError = error.localizedDescription142            }143            finalize(conversationID: conversationID, stats: stats, finish: finish)144        }145    }146147    private func finalize(conversationID: UUID, stats: GenerationStats?, finish: GenerationFinishReason) {148        defer {149            isGenerating = false150            streamingConversationID = nil151            streamingText = ""152            streamingThinking = ""153            isThinking = false154            liveTokensPerSecond = 0155            generationTask = nil156        }157        guard let app, var conversation = app.conversations.first(where: { $0.id == conversationID })158        else { return }159        let content = streamingText.trimmingCharacters(in: .whitespacesAndNewlines)160        let thinking = streamingThinking.trimmingCharacters(in: .whitespacesAndNewlines)161        guard !content.isEmpty || !thinking.isEmpty else { return }162        let message = Message(163            role: .assistant,164            content: content,165            thinking: thinking.isEmpty ? nil : thinking,166            stats: stats.map {167                MessageStats(168                    timeToFirstToken: $0.timeToFirstToken,169                    tokensPerSecond: $0.tokensPerSecond,170                    promptTokenCount: $0.promptTokenCount,171                    generationTokenCount: $0.generationTokenCount,172                    peakMemoryBytes: $0.peakMemoryBytes173                )174            }175        )176        conversation.messages.append(message)177        app.update(conversation)178        _ = finish  // reason currently not surfaced beyond stats179180        if conversation.title == "New Chat" {181            autoTitle(conversation: conversation)182        }183    }184185    /// Short, cheap title generation after the first exchange, using the186    /// loaded model itself. Falls back to a truncated first prompt.187    private func autoTitle(conversation: Conversation) {188        guard let app else { return }189        guard let firstUser = conversation.messages.first(where: { $0.role == .user }) else { return }190        let fallback = String(firstUser.content.prefix(48))191192        Task {193            var title = fallback194            do {195                let prompt = """196                    Reply with a title of at most 5 words for a conversation that starts with \197                    this message, and nothing else — no quotes, no punctuation at the end:198                    \(String(firstUser.content.prefix(500)))199                    """200                let temp = Conversation(params: GenerationParams(temperature: 0.1, maxTokens: 24))201                try await app.engine.startSession(conversation: temp)202                var generated = ""203                let events = try await app.engine.generate(prompt: prompt, params: temp.params)204                for try await event in events {205                    if case .token(let t) = event { generated += t }206                }207                var parser = ThinkTagParser()208                let (visible, _, _) = parser.consume(generated)209                let cleaned = visible210                    .trimmingCharacters(in: .whitespacesAndNewlines)211                    .trimmingCharacters(in: CharacterSet(charactersIn: "\"'.“”"))212                if !cleaned.isEmpty { title = String(cleaned.prefix(60)) }213                // Rebind the engine session to the real conversation.214                try? await app.engine.startSession(conversation: conversation)215            } catch {216                // Fallback title already set.217            }218            if var c = app.conversations.first(where: { $0.id == conversation.id }) {219                c.title = title220                app.update(c, touch: false)221            }222        }223    }224}225226/// Incremental parser splitting a token stream into visible text and227/// `<think>…</think>` content, robust to tags split across chunks.228struct ThinkTagParser {229    private var inThink = false230    private var pending = ""231232    /// Returns (visibleDelta, thinkingDelta, isInsideThink).233    mutating func consume(_ chunk: String) -> (String, String, Bool) {234        pending += chunk235        var visible = ""236        var thinking = ""237238        while true {239            if inThink {240                if let range = pending.range(of: "</think>") {241                    thinking += pending[..<range.lowerBound]242                    pending = String(pending[range.upperBound...])243                    inThink = false244                } else {245                    // Keep a possible partial closing tag in the buffer.246                    let safe = safeEmitLength(of: pending, partial: "</think>")247                    thinking += pending.prefix(safe)248                    pending = String(pending.dropFirst(safe))249                    break250                }251            } else {252                if let range = pending.range(of: "<think>") {253                    visible += pending[..<range.lowerBound]254                    pending = String(pending[range.upperBound...])255                    inThink = true256                } else {257                    let safe = safeEmitLength(of: pending, partial: "<think>")258                    visible += pending.prefix(safe)259                    pending = String(pending.dropFirst(safe))260                    break261                }262            }263        }264        return (visible, thinking, inThink)265    }266267    /// Length of `text` that can be emitted without cutting a partial `tag`268    /// suffix that might complete in the next chunk.269    private func safeEmitLength(of text: String, partial tag: String) -> Int {270        let maxKeep = min(tag.count - 1, text.count)271        for keep in stride(from: maxKeep, through: 1, by: -1) {272            if text.hasSuffix(String(tag.prefix(keep))) {273                return text.count - keep274            }275        }276        return text.count277    }278}279