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%
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