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%
8.9 KB · 252 lines swift
Raw Blame History
1//2//  CompareView.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Compare mode: two models side by side, same prompt broadcast, independent12/// streaming and stats. RAM-gated: both models must fit together.13struct CompareView: View {14    @Environment(AppModel.self) private var app15    @State private var controller = CompareController()16    @State private var prompt = ""17    @FocusState private var focused: Bool1819    var body: some View {20        VStack(spacing: 0) {21            HStack(spacing: ZyquoTheme.Spacing.m) {22                columnHeader(side: 0)23                Rectangle().fill(ZyquoTheme.border).frame(width: ZyquoTheme.hairline)24                columnHeader(side: 1)25            }26            .frame(height: ZyquoTheme.chatHeaderHeight)27            .padding(.horizontal, ZyquoTheme.Spacing.m)2829            if let warning = controller.ramWarning {30                Text(warning)31                    .font(ZyquoTheme.caption)32                    .foregroundStyle(ZyquoTheme.warning)33                    .padding(.bottom, ZyquoTheme.Spacing.xxs)34            }3536            Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)3738            HStack(spacing: 0) {39                column(side: 0)40                Rectangle().fill(ZyquoTheme.border).frame(width: ZyquoTheme.hairline)41                column(side: 1)42            }4344            inputRow45        }46        .background(ZyquoTheme.background)47        .frame(minWidth: 900, minHeight: 560)48        .onDisappear {49            Task { await controller.teardown() }50        }51    }5253    private func columnHeader(side: Int) -> some View {54        HStack {55            Picker(56                "Model",57                selection: Binding(58                    get: { controller.selection[side] },59                    set: { newValue in60                        controller.selection[side] = newValue61                        Task { await controller.loadSelection(app: app) }62                    }63                )64            ) {65                Text("Choose…").tag(String?.none)66                ForEach(app.store.models) { model in67                    Text(model.name).tag(String?.some(model.repoID))68                }69            }70            .labelsHidden()71            .frame(maxWidth: 280)7273            switch controller.states[side] {74            case .loading:75                ProgressView().controlSize(.mini)76            case .ready:77                Circle().fill(ZyquoTheme.success).frame(width: 7, height: 7)78            default:79                EmptyView()80            }81            Spacer()82            if let stats = controller.stats[side] {83                Text(String(format: "⚡ %.1f tok/s · %d tok · %.1fs TTFT",84                            stats.tokensPerSecond, stats.generationTokenCount, stats.timeToFirstToken))85                    .font(ZyquoTheme.caption.monospacedDigit())86                    .foregroundStyle(ZyquoTheme.textTertiary)87            }88        }89        .frame(maxWidth: .infinity)90    }9192    private func column(side: Int) -> some View {93        ScrollView {94            VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) {95                if !controller.thinking[side].isEmpty {96                    ThinkingDisclosure(thinking: controller.thinking[side], isLive: false)97                }98                MarkdownText(controller.outputs[side])99                    .textSelection(.enabled)100                if controller.generating[side] {101                    ProgressView().controlSize(.small)102                }103            }104            .padding(ZyquoTheme.Spacing.m)105            .frame(maxWidth: .infinity, alignment: .leading)106        }107        .frame(maxWidth: .infinity)108    }109110    private var inputRow: some View {111        HStack(spacing: ZyquoTheme.Spacing.xs) {112            TextField("Prompt both models…", text: $prompt, axis: .vertical)113                .textFieldStyle(.plain)114                .font(ZyquoTheme.chatBody)115                .lineLimit(1...6)116                .focused($focused)117                .onSubmit(broadcast)118            Button {119                broadcast()120            } label: {121                Image(systemName: "arrow.up")122                    .font(.system(size: 13, weight: .bold))123                    .foregroundStyle(.white)124                    .frame(width: 28, height: 28)125                    .background(canSend ? ZyquoTheme.accent : ZyquoTheme.textTertiary, in: Circle())126            }127            .buttonStyle(PressableButtonStyle())128            .keyboardShortcut(.return, modifiers: .command)129            .disabled(!canSend)130        }131        .padding(ZyquoTheme.Spacing.s)132        .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l))133        .overlay(134            RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)135                .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)136        )137        .floatingShadow()138        .padding(ZyquoTheme.Spacing.m)139    }140141    private var canSend: Bool {142        !prompt.trimmingCharacters(in: .whitespaces).isEmpty143            && controller.states.allSatisfy { if case .ready = $0 { true } else { false } }144            && !controller.generating.contains(true)145    }146147    private func broadcast() {148        guard canSend else { return }149        let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)150        prompt = ""151        controller.broadcast(prompt: text, params: app.settings.defaultParams)152    }153}154155/// Owns two independent engines for compare mode. The main app engine is156/// left untouched; RAM gating warns when both models exceed the safe budget.157@MainActor158@Observable159final class CompareController {160    enum ColumnState {161        case empty162        case loading163        case ready164    }165166    var selection: [String?] = [nil, nil]167    var states: [ColumnState] = [.empty, .empty]168    var outputs: [String] = ["", ""]169    var thinking: [String] = ["", ""]170    var generating: [Bool] = [false, false]171    var stats: [GenerationStats?] = [nil, nil]172    var ramWarning: String?173174    private var engines: [InferenceEngine?] = [nil, nil]175    private var sessions: [Conversation] = [Conversation(), Conversation()]176177    func loadSelection(app: AppModel) async {178        // RAM gate across both columns.179        let sizes = selection.compactMap { id in id.flatMap { app.store.model(for: $0)?.sizeBytes } }180        let combined = sizes.reduce(0, +)181        switch MemoryAdvisor.verdict(weightsBytes: combined) {182        case .fits:183            ramWarning = nil184        case .tight:185            ramWarning = "Both models together are a tight fit for this Mac's memory."186        case .tooLarge:187            ramWarning = "These two models do not fit in memory together — pick smaller ones."188            return189        }190191        for side in 0..<2 {192            guard let repoID = selection[side], let model = app.store.model(for: repoID) else {193                engines[side] = nil194                states[side] = .empty195                continue196            }197            if let engine = engines[side], await engine.currentModel?.repoID == repoID { continue }198            states[side] = .loading199            let engine = InferenceEngine()200            do {201                try await engine.load(model: model)202                sessions[side] = Conversation(modelID: repoID)203                try await engine.startSession(conversation: sessions[side])204                engines[side] = engine205                states[side] = .ready206            } catch {207                states[side] = .empty208                ramWarning = error.localizedDescription209            }210        }211    }212213    func broadcast(prompt: String, params: GenerationParams) {214        for side in 0..<2 {215            guard let engine = engines[side] else { continue }216            outputs[side] = ""217            thinking[side] = ""218            stats[side] = nil219            generating[side] = true220            Task {221                var parser = ThinkTagParser()222                do {223                    let events = try await engine.generate(prompt: prompt, params: params)224                    for try await event in events {225                        switch event {226                        case .token(let t):227                            let (visible, think, _) = parser.consume(t)228                            if !visible.isEmpty { outputs[side] += visible }229                            if !think.isEmpty { thinking[side] += think }230                        case .stats(let s):231                            stats[side] = s232                        case .finished:233                            break234                        }235                    }236                } catch {237                    outputs[side] += "\n*\(error.localizedDescription)*"238                }239                generating[side] = false240            }241        }242    }243244    func teardown() async {245        for engine in engines.compactMap({ $0 }) {246            await engine.unload()247        }248        engines = [nil, nil]249        states = [.empty, .empty]250    }251}252