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%
10.0 KB · 245 lines swift
Raw Blame History
1//2//  PlaygroundView.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Built-in tester that calls the router's OWN local endpoint (never the9//  upstreams directly): model picker, composer, streaming toggle, response10//  pane with the raw request JSON alongside.11//1213import SwiftUI1415struct PlaygroundView: View {16    @EnvironmentObject private var server: ServerController17    @EnvironmentObject private var catalog: ModelCatalog1819    @State private var modelID = ""20    @State private var prompt = ""21    @State private var streaming = true22    @State private var output = ""23    @State private var running = false24    @State private var errorText: String?25    @State private var requestJSON = ""26    @State private var rawResponse = ""27    @State private var temperature: Double = 1.028    @State private var useTemperature = false29    @State private var maxTokens = ""30    @State private var reasoningEffort = "off"3132    var body: some View {33        VStack(alignment: .leading, spacing: 0) {34            SectionHeader(title: "Playground", subtitle: "Requests go through your local endpoint — exactly what your tools see.")35                .padding(ZyquoMetrics.contentInset)3637            ZyquoHairline()3839            if !server.isRunning {40                EmptyState(41                    systemImage: "bolt.slash",42                    title: "Server is stopped",43                    message: "Start the server on the Dashboard to use the Playground."44                )45            } else {46                content47            }48        }49    }5051    private var content: some View {52        VStack(alignment: .leading, spacing: ZyquoSpacing.md) {53            HStack(spacing: ZyquoSpacing.sm) {54                Picker("", selection: $modelID) {55                    ForEach(catalog.all) { model in56                        Text(RequestRouter.namespacedID(for: model))57                            .tag(RequestRouter.namespacedID(for: model))58                    }59                }60                .labelsHidden()61                .frame(maxWidth: 340)6263                Toggle("Stream", isOn: $streaming)64                    .toggleStyle(.checkbox)6566                Spacer()6768                Button(running ? "Cancel" : "Send") {69                    running ? cancel() : send()70                }71                .keyboardShortcut(.return, modifiers: .command)72                .disabled(modelID.isEmpty || prompt.isEmpty && !running)73            }7475            // Parameters76            HStack(spacing: ZyquoSpacing.md) {77                Toggle("temperature", isOn: $useTemperature)78                    .toggleStyle(.checkbox)79                    .font(ZyquoFont.mono(size: 11))80                if useTemperature {81                    Slider(value: $temperature, in: 0...2, step: 0.1)82                        .frame(width: 120)83                    Text(String(format: "%.1f", temperature))84                        .font(ZyquoFont.mono(size: 11))85                        .foregroundStyle(ZyquoColor.textSecondary)86                }87                HStack(spacing: 4) {88                    Text("max_tokens")89                        .font(ZyquoFont.mono(size: 11))90                        .foregroundStyle(ZyquoColor.textSecondary)91                    TextField("auto", text: $maxTokens)92                        .textFieldStyle(.roundedBorder)93                        .font(ZyquoFont.mono(size: 11))94                        .frame(width: 64)95                }96                HStack(spacing: 4) {97                    Text("reasoning")98                        .font(ZyquoFont.mono(size: 11))99                        .foregroundStyle(ZyquoColor.textSecondary)100                    Picker("", selection: $reasoningEffort) {101                        ForEach(["off", "low", "medium", "high"], id: \.self) { level in102                            Text(level).tag(level)103                        }104                    }105                    .labelsHidden()106                    .frame(width: 92)107                }108                Spacer()109            }110111            TextEditor(text: $prompt)112                .font(ZyquoFont.mono(size: 12.5))113                .scrollContentBackground(.hidden)114                .padding(ZyquoSpacing.xs)115                .frame(height: 90)116                .background(117                    RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)118                        .fill(ZyquoColor.surfaceSecondary)119                )120121            if let errorText {122                Label(errorText, systemImage: "xmark.octagon")123                    .font(ZyquoFont.body())124                    .foregroundStyle(ZyquoColor.danger)125            }126127            HSplitView {128                pane(title: "RESPONSE", text: output, placeholder: "Response appears here.")129                VStack(spacing: ZyquoSpacing.xs) {130                    pane(title: "REQUEST JSON", text: requestJSON, placeholder: "The exact JSON sent to the router.")131                    pane(title: "RAW RESPONSE", text: rawResponse, placeholder: streaming ? "Raw SSE chunks." : "Raw response JSON.")132                }133                .frame(minWidth: 260)134            }135            .frame(maxHeight: .infinity)136        }137        .padding(ZyquoMetrics.contentInset)138        .onAppear {139            if modelID.isEmpty, let first = catalog.defaultModel {140                modelID = RequestRouter.namespacedID(for: first)141            }142        }143    }144145    private func pane(title: String, text: String, placeholder: String) -> some View {146        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {147            HStack {148                Text(title)149                    .font(.system(size: 9.5, weight: .semibold))150                    .foregroundStyle(ZyquoColor.textTertiary)151                    .kerning(0.4)152                Spacer()153                if !text.isEmpty { CopyButton(value: text) }154            }155            ScrollView {156                Text(text.isEmpty ? placeholder : text)157                    .font(ZyquoFont.mono(size: 11.5))158                    .foregroundStyle(text.isEmpty ? ZyquoColor.textTertiary : ZyquoColor.textPrimary)159                    .textSelection(.enabled)160                    .frame(maxWidth: .infinity, alignment: .leading)161                    .padding(ZyquoSpacing.xs)162            }163            .frame(maxWidth: .infinity, maxHeight: .infinity)164            .background(165                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)166                    .fill(ZyquoColor.surface)167                    .overlay(168                        RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)169                            .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)170                    )171            )172        }173        .padding(.horizontal, 1)174    }175176    @State private var task: Task<Void, Never>?177178    private func send() {179        output = ""180        rawResponse = ""181        errorText = nil182        running = true183        var body: [String: Any] = [184            "model": modelID,185            "messages": [["role": "user", "content": prompt]],186            "stream": streaming,187        ]188        if useTemperature { body["temperature"] = (temperature * 10).rounded() / 10 }189        if let limit = Int(maxTokens) { body["max_tokens"] = limit }190        if reasoningEffort != "off" { body["reasoning_effort"] = reasoningEffort }191        if let pretty = try? JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted, .sortedKeys]) {192            requestJSON = String(decoding: pretty, as: UTF8.self)193        }194        let url = URL(string: "http://127.0.0.1:\(server.port)/v1/chat/completions")!195        task = Task {196            defer { running = false }197            var request = URLRequest(url: url)198            request.httpMethod = "POST"199            request.setValue("application/json", forHTTPHeaderField: "Content-Type")200            request.httpBody = try? JSONSerialization.data(withJSONObject: body)201            do {202                if streaming {203                    let (bytes, _) = try await URLSession.shared.bytes(for: request)204                    for try await line in bytes.lines {205                        guard line.hasPrefix("data: ") else { continue }206                        if rawResponse.count < 40_000 { rawResponse += line + "\n" }207                        guard !line.hasSuffix("[DONE]") else { continue }208                        guard let json = try? JSONSerialization.jsonObject(with: Data(line.dropFirst(6).utf8)) as? [String: Any] else { continue }209                        if let error = json["error"] as? [String: Any] {210                            errorText = error["message"] as? String211                            continue212                        }213                        let delta = ((json["choices"] as? [[String: Any]])?.first?["delta"] as? [String: Any])214                        if let piece = delta?["content"] as? String {215                            output += piece216                        }217                    }218                } else {219                    let (data, _) = try await URLSession.shared.data(for: request)220                    if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {221                        if let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) {222                            rawResponse = String(decoding: pretty, as: UTF8.self)223                        }224                        if let error = json["error"] as? [String: Any] {225                            errorText = error["message"] as? String226                        } else {227                            let message = ((json["choices"] as? [[String: Any]])?.first?["message"] as? [String: Any])228                            output = message?["content"] as? String ?? ""229                        }230                    }231                }232            } catch is CancellationError {233                // cancelled by the user234            } catch {235                errorText = error.localizedDescription236            }237        }238    }239240    private func cancel() {241        task?.cancel()242        running = false243    }244}245