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%
1//2// CompareView.swift3// Zyquo Cloud4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Compare mode: 2–4 vertical columns, one model chip each, the same prompt9// broadcast to all, independent streaming, per-column copy/regenerate.10//1112import SwiftUI1314struct CompareView: View {15 @EnvironmentObject private var catalog: ModelCatalog16 @EnvironmentObject private var vault: KeyVaultStore17 @EnvironmentObject private var appearance: AppearanceStore18 @Environment(\.dismiss) private var dismiss1920 @StateObject private var session = CompareSession()21 @State private var prompt = ""2223 var body: some View {24 VStack(spacing: 0) {25 header26 ZyquoHairline()27 columns28 ZyquoHairline()29 inputRow30 }31 .frame(minWidth: 900, minHeight: 560)32 .background(ZyquoColor.background)33 .onAppear {34 if session.columns.isEmpty {35 session.setup(catalog: catalog, vault: vault)36 }37 }38 }3940 private var header: some View {41 HStack {42 Text("Compare Models")43 .font(ZyquoFont.bodyEmphasis(size: 14))44 .foregroundStyle(ZyquoColor.textPrimary)45 Spacer()46 Button {47 session.addColumn(catalog: catalog)48 } label: {49 Label("Add column", systemImage: "plus.rectangle.on.rectangle")50 }51 .controlSize(.small)52 .disabled(session.columns.count >= 4)53 Button("Done") { dismiss() }54 .controlSize(.small)55 }56 .padding(ZyquoSpacing.sm)57 }5859 private var columns: some View {60 HStack(spacing: 0) {61 ForEach($session.columns) { $column in62 VStack(spacing: 0) {63 columnHeader($column)64 ZyquoHairline()65 ScrollView {66 VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {67 if let reasoning = column.reasoning, !reasoning.isEmpty {68 Text(reasoning)69 .font(ZyquoFont.code(size: appearance.chatFontSize - 2.5))70 .foregroundStyle(ZyquoColor.textTertiary)71 .lineLimit(6)72 }73 if let error = column.errorText {74 Text(error)75 .font(ZyquoFont.body(size: appearance.chatFontSize - 1))76 .foregroundStyle(ZyquoColor.danger)77 } else {78 MarkdownView(text: column.answer, fontSize: appearance.chatFontSize - 0.5)79 if column.isStreaming { StreamingCaret() }80 }81 }82 .frame(maxWidth: .infinity, alignment: .leading)83 .padding(ZyquoSpacing.sm)84 }85 }86 .frame(maxWidth: .infinity)87 if column.id != session.columns.last?.id {88 Rectangle().fill(ZyquoColor.border).frame(width: ZyquoMetrics.hairline)89 }90 }91 }92 }9394 private func columnHeader(_ column: Binding<CompareSession.Column>) -> some View {95 HStack(spacing: ZyquoSpacing.xxs) {96 ModelChipView(model: column.wrappedValue.model) { chosen in97 column.wrappedValue.model = chosen98 }99 Spacer()100 if let usage = column.wrappedValue.usage {101 Text("\(usage.outputTokens) tok")102 .font(ZyquoFont.caption)103 .foregroundStyle(ZyquoColor.textTertiary)104 }105 Button {106 NSPasteboard.general.clearContents()107 NSPasteboard.general.setString(column.wrappedValue.answer, forType: .string)108 } label: {109 Image(systemName: "doc.on.doc").font(.system(size: 10))110 }111 .buttonStyle(.plain)112 .help("Copy answer")113 Button {114 session.run(columnID: column.wrappedValue.id, prompt: session.lastPrompt, vault: vault)115 } label: {116 Image(systemName: "arrow.clockwise").font(.system(size: 10))117 }118 .buttonStyle(.plain)119 .help("Regenerate")120 .disabled(session.lastPrompt.isEmpty)121 if session.columns.count > 2 {122 Button {123 session.columns.removeAll { $0.id == column.wrappedValue.id }124 } label: {125 Image(systemName: "xmark").font(.system(size: 9))126 }127 .buttonStyle(.plain)128 .help("Remove column")129 }130 }131 .padding(.horizontal, ZyquoSpacing.xs)132 .padding(.vertical, ZyquoSpacing.xxs)133 }134135 private var inputRow: some View {136 HStack(spacing: ZyquoSpacing.xs) {137 TextField("Prompt all models…", text: $prompt, axis: .vertical)138 .textFieldStyle(.plain)139 .font(ZyquoFont.body(size: appearance.chatFontSize))140 .lineLimit(1...6)141 .onSubmit(broadcast)142 Button {143 broadcast()144 } label: {145 Image(systemName: "arrow.up")146 .font(.system(size: 12, weight: .semibold))147 .foregroundStyle(.white)148 .frame(width: 26, height: 26)149 .background(Circle().fill(ZyquoColor.accent))150 }151 .buttonStyle(PressableButtonStyle())152 .keyboardShortcut(.return, modifiers: .command)153 .disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty)154 }155 .padding(ZyquoSpacing.sm)156 .background(ZyquoColor.surface)157 }158159 private func broadcast() {160 let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)161 guard !text.isEmpty else { return }162 prompt = ""163 session.broadcast(prompt: text, vault: vault)164 }165}166167/// State for one compare session: N independent streaming columns.168@MainActor169final class CompareSession: ObservableObject {170 struct Column: Identifiable {171 let id = UUID()172 var model: AIModel?173 var answer = ""174 var reasoning: String?175 var errorText: String?176 var usage: TokenUsage?177 var isStreaming = false178 }179180 @Published var columns: [Column] = []181 @Published private(set) var lastPrompt = ""182 private var tasks: [UUID: Task<Void, Never>] = [:]183184 func setup(catalog: ModelCatalog, vault: KeyVaultStore) {185 // Seed two columns with the two most relevant models from providers186 // that have keys, falling back to catalog defaults.187 let keyed = catalog.all.filter {188 $0.isRecommended && vault.hasKey(for: $0.provider)189 }190 let first = keyed.first ?? catalog.defaultModel191 let second = keyed.dropFirst().first ?? catalog.defaultModel192 columns = [Column(model: first), Column(model: second)]193 }194195 func addColumn(catalog: ModelCatalog) {196 guard columns.count < 4 else { return }197 columns.append(Column(model: catalog.defaultModel))198 }199200 func broadcast(prompt: String, vault: KeyVaultStore) {201 lastPrompt = prompt202 for column in columns {203 run(columnID: column.id, prompt: prompt, vault: vault)204 }205 }206207 func run(columnID: UUID, prompt: String, vault: KeyVaultStore) {208 guard !prompt.isEmpty,209 let index = columns.firstIndex(where: { $0.id == columnID }),210 let model = columns[index].model211 else { return }212 tasks[columnID]?.cancel()213 columns[index].answer = ""214 columns[index].reasoning = nil215 columns[index].errorText = nil216 columns[index].usage = nil217 columns[index].isStreaming = true218219 tasks[columnID] = Task { [weak self] in220 guard let self else { return }221 do {222 let key = try vault.apiKey(for: model.provider)223 let client = ProviderRegistry.client(for: model)224 let request = ChatRequest(225 model: model,226 systemPrompt: nil,227 messages: [Message(role: .user, text: prompt)],228 parameters: ChatParameters()229 )230 for try await event in client.streamChat(request, apiKey: key) {231 if Task.isCancelled { break }232 guard let i = self.columns.firstIndex(where: { $0.id == columnID }) else { break }233 switch event {234 case .textDelta(let delta): self.columns[i].answer += delta235 case .reasoningDelta(let delta):236 self.columns[i].reasoning = (self.columns[i].reasoning ?? "") + delta237 case .usage(let usage): self.columns[i].usage = usage238 default: break239 }240 }241 } catch {242 if let i = self.columns.firstIndex(where: { $0.id == columnID }) {243 self.columns[i].errorText = error.localizedDescription244 }245 }246 if let i = self.columns.firstIndex(where: { $0.id == columnID }) {247 self.columns[i].isStreaming = false248 }249 }250 }251}252