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