// // PlaygroundView.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Built-in tester that calls the router's OWN local endpoint (never the // upstreams directly): model picker, composer, streaming toggle, response // pane with the raw request JSON alongside. // import SwiftUI struct PlaygroundView: View { @EnvironmentObject private var server: ServerController @EnvironmentObject private var catalog: ModelCatalog @State private var modelID = "" @State private var prompt = "" @State private var streaming = true @State private var output = "" @State private var running = false @State private var errorText: String? @State private var requestJSON = "" @State private var rawResponse = "" @State private var temperature: Double = 1.0 @State private var useTemperature = false @State private var maxTokens = "" @State private var reasoningEffort = "off" var body: some View { VStack(alignment: .leading, spacing: 0) { SectionHeader(title: "Playground", subtitle: "Requests go through your local endpoint — exactly what your tools see.") .padding(ZyquoMetrics.contentInset) ZyquoHairline() if !server.isRunning { EmptyState( systemImage: "bolt.slash", title: "Server is stopped", message: "Start the server on the Dashboard to use the Playground." ) } else { content } } } private var content: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.md) { HStack(spacing: ZyquoSpacing.sm) { Picker("", selection: $modelID) { ForEach(catalog.all) { model in Text(RequestRouter.namespacedID(for: model)) .tag(RequestRouter.namespacedID(for: model)) } } .labelsHidden() .frame(maxWidth: 340) Toggle("Stream", isOn: $streaming) .toggleStyle(.checkbox) Spacer() Button(running ? "Cancel" : "Send") { running ? cancel() : send() } .keyboardShortcut(.return, modifiers: .command) .disabled(modelID.isEmpty || prompt.isEmpty && !running) } // Parameters HStack(spacing: ZyquoSpacing.md) { Toggle("temperature", isOn: $useTemperature) .toggleStyle(.checkbox) .font(ZyquoFont.mono(size: 11)) if useTemperature { Slider(value: $temperature, in: 0...2, step: 0.1) .frame(width: 120) Text(String(format: "%.1f", temperature)) .font(ZyquoFont.mono(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) } HStack(spacing: 4) { Text("max_tokens") .font(ZyquoFont.mono(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) TextField("auto", text: $maxTokens) .textFieldStyle(.roundedBorder) .font(ZyquoFont.mono(size: 11)) .frame(width: 64) } HStack(spacing: 4) { Text("reasoning") .font(ZyquoFont.mono(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) Picker("", selection: $reasoningEffort) { ForEach(["off", "low", "medium", "high"], id: \.self) { level in Text(level).tag(level) } } .labelsHidden() .frame(width: 92) } Spacer() } TextEditor(text: $prompt) .font(ZyquoFont.mono(size: 12.5)) .scrollContentBackground(.hidden) .padding(ZyquoSpacing.xs) .frame(height: 90) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) if let errorText { Label(errorText, systemImage: "xmark.octagon") .font(ZyquoFont.body()) .foregroundStyle(ZyquoColor.danger) } HSplitView { pane(title: "RESPONSE", text: output, placeholder: "Response appears here.") VStack(spacing: ZyquoSpacing.xs) { pane(title: "REQUEST JSON", text: requestJSON, placeholder: "The exact JSON sent to the router.") pane(title: "RAW RESPONSE", text: rawResponse, placeholder: streaming ? "Raw SSE chunks." : "Raw response JSON.") } .frame(minWidth: 260) } .frame(maxHeight: .infinity) } .padding(ZyquoMetrics.contentInset) .onAppear { if modelID.isEmpty, let first = catalog.defaultModel { modelID = RequestRouter.namespacedID(for: first) } } } private func pane(title: String, text: String, placeholder: String) -> some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { HStack { Text(title) .font(.system(size: 9.5, weight: .semibold)) .foregroundStyle(ZyquoColor.textTertiary) .kerning(0.4) Spacer() if !text.isEmpty { CopyButton(value: text) } } ScrollView { Text(text.isEmpty ? placeholder : text) .font(ZyquoFont.mono(size: 11.5)) .foregroundStyle(text.isEmpty ? ZyquoColor.textTertiary : ZyquoColor.textPrimary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(ZyquoSpacing.xs) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surface) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) ) } .padding(.horizontal, 1) } @State private var task: Task? private func send() { output = "" rawResponse = "" errorText = nil running = true var body: [String: Any] = [ "model": modelID, "messages": [["role": "user", "content": prompt]], "stream": streaming, ] if useTemperature { body["temperature"] = (temperature * 10).rounded() / 10 } if let limit = Int(maxTokens) { body["max_tokens"] = limit } if reasoningEffort != "off" { body["reasoning_effort"] = reasoningEffort } if let pretty = try? JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted, .sortedKeys]) { requestJSON = String(decoding: pretty, as: UTF8.self) } let url = URL(string: "http://127.0.0.1:\(server.port)/v1/chat/completions")! task = Task { defer { running = false } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: body) do { if streaming { let (bytes, _) = try await URLSession.shared.bytes(for: request) for try await line in bytes.lines { guard line.hasPrefix("data: ") else { continue } if rawResponse.count < 40_000 { rawResponse += line + "\n" } guard !line.hasSuffix("[DONE]") else { continue } guard let json = try? JSONSerialization.jsonObject(with: Data(line.dropFirst(6).utf8)) as? [String: Any] else { continue } if let error = json["error"] as? [String: Any] { errorText = error["message"] as? String continue } let delta = ((json["choices"] as? [[String: Any]])?.first?["delta"] as? [String: Any]) if let piece = delta?["content"] as? String { output += piece } } } else { let (data, _) = try await URLSession.shared.data(for: request) if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) { rawResponse = String(decoding: pretty, as: UTF8.self) } if let error = json["error"] as? [String: Any] { errorText = error["message"] as? String } else { let message = ((json["choices"] as? [[String: Any]])?.first?["message"] as? [String: Any]) output = message?["content"] as? String ?? "" } } } } catch is CancellationError { // cancelled by the user } catch { errorText = error.localizedDescription } } } private func cancel() { task?.cancel() running = false } }