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%
3.8 KB · 111 lines swift
Raw Blame History
1//2//  ChatSession.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation10import MLXLMCommon1112/// Zyquo Local's multi-turn session: converts persisted conversation history13/// into the model's chat-template form, truncates oldest turns when the14/// context window would overflow (always keeping the system prompt), and15/// reuses the KV cache across turns via the underlying MLX session.16///17/// Not thread-safe by design — owned and accessed only by `InferenceEngine`.18final class ChatSession {19    /// Conversation this session is bound to.20    let conversationID: UUID21    private let contextWindow: Int22    private let mlxSession: MLXLMCommon.ChatSession2324    /// Rough chars-per-token estimate used for truncation budgeting.25    private static let charsPerToken = 3.526    /// Fraction of the context window budgeted for history (rest is reserved27    /// for the generated response).28    private static let historyBudgetFraction = 0.72930    /// Number of tokens estimated to be used by history + system prompt.31    private(set) var estimatedHistoryTokens: Int3233    init(34        container: ModelContainer,35        conversation: Conversation,36        contextWindow: Int,37        params: GenerationParams38    ) {39        self.conversationID = conversation.id40        self.contextWindow = contextWindow4142        let truncated = Self.truncatedHistory(43            messages: conversation.messages,44            contextWindow: contextWindow45        )46        self.estimatedHistoryTokens = Self.estimateTokens(47            truncated.map(\.content).joined() + (conversation.systemPrompt ?? "")48        )4950        let history: [Chat.Message] = truncated.map { message in51            switch message.role {52            case .system: .system(message.content)53            case .user: .user(message.content)54            case .assistant: .assistant(message.content)55            }56        }57        self.mlxSession = MLXLMCommon.ChatSession(58            container,59            instructions: conversation.systemPrompt,60            history: history,61            generateParameters: params.toMLX()62        )63    }6465    /// Streams a response to a new user prompt, honoring per-call parameters66    /// without losing the KV cache.67    func stream(prompt: String, params: GenerationParams) -> AsyncThrowingStream<Generation, Error> {68        mlxSession.generateParameters = params.toMLX()69        estimatedHistoryTokens += Self.estimateTokens(prompt)70        return mlxSession.streamDetails(to: prompt)71    }7273    /// Estimated context usage in tokens (for the UI context bar).74    var estimatedContextUsage: (used: Int, window: Int) {75        (estimatedHistoryTokens, contextWindow)76    }7778    func noteResponse(_ text: String) {79        estimatedHistoryTokens += Self.estimateTokens(text)80    }8182    // MARK: - Truncation8384    static func estimateTokens(_ text: String) -> Int {85        Int(Double(text.count) / charsPerToken) + 186    }8788    /// Drops oldest non-system turns until the estimated history fits the89    /// budgeted share of the context window. The system prompt (a leading90    /// system message, if any) is always kept.91    static func truncatedHistory(messages: [Message], contextWindow: Int) -> [Message] {92        let budget = Int(Double(contextWindow) * historyBudgetFraction)93        var system: [Message] = []94        var turns: [Message] = []95        for m in messages {96            if m.role == .system && turns.isEmpty {97                system.append(m)98            } else {99                turns.append(m)100            }101        }102        func total(_ list: [Message]) -> Int {103            list.reduce(0) { $0 + estimateTokens($1.content) }104        }105        while turns.count > 1, total(system) + total(turns) > budget {106            turns.removeFirst()107        }108        return system + turns109    }110}111