SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%
8.2 KB · 242 lines swift
Raw Blame History
1//2//  KeysView.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Two tabs: Provider Keys (masked vault fields with per-provider Test —9//  identical UX to Zyquo Cloud) and Local API Keys (create/reveal-once/10//  revoke zyquo-sk tokens).11//1213import SwiftUI1415struct KeysView: View {16    @State private var tab: Tab = .provider1718    enum Tab: String, CaseIterable, Identifiable {19        case provider = "Provider Keys"20        case local = "Local API Keys"21        var id: String { rawValue }22    }2324    var body: some View {25        VStack(alignment: .leading, spacing: 0) {26            HStack {27                SectionHeader(title: "Keys")28                Spacer()29                Picker("", selection: $tab) {30                    ForEach(Tab.allCases) { tab in31                        Text(tab.rawValue).tag(tab)32                    }33                }34                .labelsHidden()35                .pickerStyle(.segmented)36                .frame(width: 280)37            }38            .padding(ZyquoMetrics.contentInset)3940            ZyquoHairline()4142            switch tab {43            case .provider: ProviderKeysTab()44            case .local: LocalKeysTab()45            }46        }47    }48}4950// MARK: - Provider keys5152private struct ProviderKeysTab: View {53    @EnvironmentObject private var vault: KeyVaultStore54    @EnvironmentObject private var catalog: ModelCatalog5556    var body: some View {57        ScrollView {58            VStack(spacing: 0) {59                ForEach(ProviderID.builtIn) { provider in60                    ProviderKeyRow(provider: provider)61                    ZyquoHairline()62                        .padding(.leading, ZyquoMetrics.contentInset)63                }64            }65        }66    }67}6869private struct ProviderKeyRow: View {70    let provider: ProviderID71    @EnvironmentObject private var vault: KeyVaultStore72    @EnvironmentObject private var catalog: ModelCatalog73    @State private var draft = ""74    @State private var editing = false7576    var body: some View {77        HStack(spacing: ZyquoSpacing.md) {78            HStack(spacing: ZyquoSpacing.xs) {79                Circle()80                    .fill(ZyquoColor.providerHue(provider))81                    .frame(width: 8, height: 8)82                Text(provider.displayName)83                    .font(ZyquoFont.bodyEmphasis())84                    .foregroundStyle(ZyquoColor.textPrimary)85            }86            .frame(width: 150, alignment: .leading)8788            if editing || !vault.hasKey(for: provider) {89                SecureField("Paste API key…", text: $draft)90                    .textFieldStyle(.roundedBorder)91                    .font(ZyquoFont.mono(size: 12))92                    .frame(maxWidth: 320)93                Button("Save") {94                    vault.setKey(draft, for: provider)95                    draft = ""96                    editing = false97                }98                .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty)99                if editing {100                    Button("Cancel") {101                        draft = ""102                        editing = false103                    }104                }105            } else {106                Text(vault.redactedKeys[provider] ?? "••••")107                    .font(ZyquoFont.mono(size: 12))108                    .foregroundStyle(ZyquoColor.textSecondary)109                    .frame(maxWidth: 320, alignment: .leading)110                Button("Replace") { editing = true }111                Button("Remove") { vault.deleteKey(for: provider) }112                Button("Test") {113                    Task { await vault.testKey(for: provider, catalog: catalog) }114                }115            }116117            Spacer()118            statusView119        }120        .padding(.horizontal, ZyquoMetrics.contentInset)121        .padding(.vertical, ZyquoSpacing.xs)122    }123124    @ViewBuilder125    private var statusView: some View {126        switch vault.statuses[provider] ?? .unset {127        case .unset:128            Text("No key")129                .font(ZyquoFont.caption)130                .foregroundStyle(ZyquoColor.textTertiary)131        case .saved:132            statusDot(ZyquoColor.textTertiary, "Saved")133        case .testing:134            ProgressView()135                .controlSize(.small)136        case .verified(let latency):137            statusDot(ZyquoColor.success, String(format: "OK · %.0f ms", latency * 1000))138        case .failed(let message):139            statusDot(ZyquoColor.danger, message)140                .help(message)141        }142    }143144    private func statusDot(_ color: Color, _ text: String) -> some View {145        HStack(spacing: 5) {146            Circle().fill(color).frame(width: 7, height: 7)147            Text(text)148                .font(ZyquoFont.caption)149                .foregroundStyle(ZyquoColor.textSecondary)150                .lineLimit(1)151                .frame(maxWidth: 220, alignment: .trailing)152        }153    }154}155156// MARK: - Local keys157158private struct LocalKeysTab: View {159    @EnvironmentObject private var localKeys: LocalKeysStore160    @State private var newName = ""161    @State private var revealedToken: String?162163    var body: some View {164        ScrollView {165            VStack(alignment: .leading, spacing: ZyquoSpacing.md) {166                // Create167                HStack(spacing: ZyquoSpacing.xs) {168                    TextField("Key name (e.g. \"CLI\", \"VS Code\")", text: $newName)169                        .textFieldStyle(.roundedBorder)170                        .frame(maxWidth: 280)171                    Button("Create key") {172                        revealedToken = localKeys.create(name: newName)173                        newName = ""174                    }175                }176177                if let token = revealedToken {178                    VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {179                        Label("Copy this token now — it is shown only once.", systemImage: "exclamationmark.triangle")180                            .font(ZyquoFont.caption)181                            .foregroundStyle(ZyquoColor.warning)182                        HStack {183                            CopyField(value: token, size: 12.5)184                            Button("Done") { revealedToken = nil }185                        }186                    }187                    .padding(ZyquoSpacing.sm)188                    .background(189                        RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)190                            .fill(ZyquoColor.accentSubtle)191                    )192                }193194                if localKeys.keys.isEmpty {195                    Text("No local keys. Without keys, the router is open on localhost; LAN mode requires at least one enabled key.")196                        .font(ZyquoFont.body())197                        .foregroundStyle(ZyquoColor.textSecondary)198                } else {199                    VStack(spacing: 0) {200                        ForEach(localKeys.keys) { key in201                            LocalKeyRow(record: key)202                            ZyquoHairline()203                        }204                    }205                    .zyquoCard()206                }207            }208            .padding(ZyquoMetrics.contentInset)209        }210    }211}212213private struct LocalKeyRow: View {214    let record: APIKeyRecord215    @EnvironmentObject private var localKeys: LocalKeysStore216217    var body: some View {218        HStack(spacing: ZyquoSpacing.md) {219            VStack(alignment: .leading, spacing: 2) {220                Text(record.name)221                    .font(ZyquoFont.bodyEmphasis())222                    .foregroundStyle(ZyquoColor.textPrimary)223                Text("\(record.tokenPrefix)…  ·  created \(record.createdAt.formatted(date: .abbreviated, time: .omitted))")224                    .font(ZyquoFont.mono(size: 11))225                    .foregroundStyle(ZyquoColor.textTertiary)226            }227            Spacer()228            Toggle("", isOn: Binding(229                get: { record.enabled },230                set: { localKeys.setEnabled($0, for: record.id) }231            ))232            .toggleStyle(.switch)233            .controlSize(.small)234            .labelsHidden()235            Button("Revoke", role: .destructive) {236                localKeys.revoke(record.id)237            }238        }239        .padding(ZyquoSpacing.sm)240    }241}242