// // CompareView.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Compare mode: 2–4 vertical columns, one model chip each, the same prompt // broadcast to all, independent streaming, per-column copy/regenerate. // import SwiftUI struct CompareView: View { @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var appearance: AppearanceStore @Environment(\.dismiss) private var dismiss @StateObject private var session = CompareSession() @State private var prompt = "" var body: some View { VStack(spacing: 0) { header ZyquoHairline() columns ZyquoHairline() inputRow } .frame(minWidth: 900, minHeight: 560) .background(ZyquoColor.background) .onAppear { if session.columns.isEmpty { session.setup(catalog: catalog, vault: vault) } } } private var header: some View { HStack { Text("Compare Models") .font(ZyquoFont.bodyEmphasis(size: 14)) .foregroundStyle(ZyquoColor.textPrimary) Spacer() Button { session.addColumn(catalog: catalog) } label: { Label("Add column", systemImage: "plus.rectangle.on.rectangle") } .controlSize(.small) .disabled(session.columns.count >= 4) Button("Done") { dismiss() } .controlSize(.small) } .padding(ZyquoSpacing.sm) } private var columns: some View { HStack(spacing: 0) { ForEach($session.columns) { $column in VStack(spacing: 0) { columnHeader($column) ZyquoHairline() ScrollView { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { if let reasoning = column.reasoning, !reasoning.isEmpty { Text(reasoning) .font(ZyquoFont.code(size: appearance.chatFontSize - 2.5)) .foregroundStyle(ZyquoColor.textTertiary) .lineLimit(6) } if let error = column.errorText { Text(error) .font(ZyquoFont.body(size: appearance.chatFontSize - 1)) .foregroundStyle(ZyquoColor.danger) } else { MarkdownView(text: column.answer, fontSize: appearance.chatFontSize - 0.5) if column.isStreaming { StreamingCaret() } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(ZyquoSpacing.sm) } } .frame(maxWidth: .infinity) if column.id != session.columns.last?.id { Rectangle().fill(ZyquoColor.border).frame(width: ZyquoMetrics.hairline) } } } } private func columnHeader(_ column: Binding) -> some View { HStack(spacing: ZyquoSpacing.xxs) { ModelChipView(model: column.wrappedValue.model) { chosen in column.wrappedValue.model = chosen } Spacer() if let usage = column.wrappedValue.usage { Text("\(usage.outputTokens) tok") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(column.wrappedValue.answer, forType: .string) } label: { Image(systemName: "doc.on.doc").font(.system(size: 10)) } .buttonStyle(.plain) .help("Copy answer") Button { session.run(columnID: column.wrappedValue.id, prompt: session.lastPrompt, vault: vault) } label: { Image(systemName: "arrow.clockwise").font(.system(size: 10)) } .buttonStyle(.plain) .help("Regenerate") .disabled(session.lastPrompt.isEmpty) if session.columns.count > 2 { Button { session.columns.removeAll { $0.id == column.wrappedValue.id } } label: { Image(systemName: "xmark").font(.system(size: 9)) } .buttonStyle(.plain) .help("Remove column") } } .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, ZyquoSpacing.xxs) } private var inputRow: some View { HStack(spacing: ZyquoSpacing.xs) { TextField("Prompt all models…", text: $prompt, axis: .vertical) .textFieldStyle(.plain) .font(ZyquoFont.body(size: appearance.chatFontSize)) .lineLimit(1...6) .onSubmit(broadcast) Button { broadcast() } label: { Image(systemName: "arrow.up") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white) .frame(width: 26, height: 26) .background(Circle().fill(ZyquoColor.accent)) } .buttonStyle(PressableButtonStyle()) .keyboardShortcut(.return, modifiers: .command) .disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty) } .padding(ZyquoSpacing.sm) .background(ZyquoColor.surface) } private func broadcast() { let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } prompt = "" session.broadcast(prompt: text, vault: vault) } } /// State for one compare session: N independent streaming columns. @MainActor final class CompareSession: ObservableObject { struct Column: Identifiable { let id = UUID() var model: AIModel? var answer = "" var reasoning: String? var errorText: String? var usage: TokenUsage? var isStreaming = false } @Published var columns: [Column] = [] @Published private(set) var lastPrompt = "" private var tasks: [UUID: Task] = [:] func setup(catalog: ModelCatalog, vault: KeyVaultStore) { // Seed two columns with the two most relevant models from providers // that have keys, falling back to catalog defaults. let keyed = catalog.all.filter { $0.isRecommended && vault.hasKey(for: $0.provider) } let first = keyed.first ?? catalog.defaultModel let second = keyed.dropFirst().first ?? catalog.defaultModel columns = [Column(model: first), Column(model: second)] } func addColumn(catalog: ModelCatalog) { guard columns.count < 4 else { return } columns.append(Column(model: catalog.defaultModel)) } func broadcast(prompt: String, vault: KeyVaultStore) { lastPrompt = prompt for column in columns { run(columnID: column.id, prompt: prompt, vault: vault) } } func run(columnID: UUID, prompt: String, vault: KeyVaultStore) { guard !prompt.isEmpty, let index = columns.firstIndex(where: { $0.id == columnID }), let model = columns[index].model else { return } tasks[columnID]?.cancel() columns[index].answer = "" columns[index].reasoning = nil columns[index].errorText = nil columns[index].usage = nil columns[index].isStreaming = true tasks[columnID] = Task { [weak self] in guard let self else { return } do { let key = try vault.apiKey(for: model.provider) let client = ProviderRegistry.client(for: model) let request = ChatRequest( model: model, systemPrompt: nil, messages: [Message(role: .user, text: prompt)], parameters: ChatParameters() ) for try await event in client.streamChat(request, apiKey: key) { if Task.isCancelled { break } guard let i = self.columns.firstIndex(where: { $0.id == columnID }) else { break } switch event { case .textDelta(let delta): self.columns[i].answer += delta case .reasoningDelta(let delta): self.columns[i].reasoning = (self.columns[i].reasoning ?? "") + delta case .usage(let usage): self.columns[i].usage = usage default: break } } } catch { if let i = self.columns.firstIndex(where: { $0.id == columnID }) { self.columns[i].errorText = error.localizedDescription } } if let i = self.columns.firstIndex(where: { $0.id == columnID }) { self.columns[i].isStreaming = false } } } }