spb/zyquo-mlx Public MIT
The local MLX foundry for your Mac — run, fine-tune, quantize, and ship models. Nothing leaves your machine.
Swift 93.4%
Python 3.8%
Makefile 2.2%
Shell 0.5%
1//2// PlaygroundView.swift3// Zyquo MLX4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import MLXLMCommon10import SwiftUI11import UniformTypeIdentifiers1213/// Playground: interactive inference per model type — streaming chat for14/// LLM/VLM (images attach for VLM), a text→vector inspector with similarity15/// for embeddings, per-run stats, and verifiable load/unload.16struct PlaygroundView: View {17 @Bindable var model: AppModel18 @State private var session = PlaygroundSession()1920 var body: some View {21 if model.models.isEmpty {22 EmptyStateView(23 icon: "bubble.left.and.text.bubble.right",24 title: "The Playground Awaits",25 message: "Load any local model and interact with it — streaming chat for LLMs, images for vision models, a vector inspector for embeddings — with live tokens/sec, time-to-first-token, and memory stats.")26 } else {27 VStack(spacing: 0) {28 playgroundHeader29 Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)30 content31 }32 }33 }3435 private var playgroundHeader: some View {36 HStack(spacing: ZyquoTheme.spacing12) {37 Picker("Model", selection: $session.selectedModelID) {38 Text("Choose a model…").tag(String?.none)39 ForEach(model.models.filter { $0.type.isSwiftNative || $0.type == .speech }) { item in40 Text("\(item.name) (\(item.type.displayName))").tag(String?.some(item.id))41 }42 }43 .frame(maxWidth: 420)4445 if session.isLoading {46 ProgressView().controlSize(.small)47 Text("Loading…")48 .font(ZyquoTheme.captionFont)49 .foregroundStyle(ZyquoTheme.textSecondary)50 } else if session.loadedModel != nil {51 StatusPill(text: "Loaded", color: ZyquoTheme.success)52 Button("Unload") {53 Task { await session.unload() }54 }55 .controlSize(.small)56 if let freed = session.lastFreedBytes {57 Text("freed \(ByteCountFormatter.string(fromByteCount: freed, countStyle: .memory))")58 .font(ZyquoTheme.captionFont)59 .foregroundStyle(ZyquoTheme.textTertiary)60 }61 }6263 Spacer()6465 if let stats = session.lastStats {66 Text(String(format: "%.1f tok/s · TTFT %.2fs · %d tokens",67 stats.tokensPerSecond, stats.ttft, stats.generatedTokens))68 .font(ZyquoTheme.monoSmallFont)69 .foregroundStyle(ZyquoTheme.textSecondary)70 }71 }72 .padding(.horizontal, ZyquoTheme.spacing20)73 .padding(.vertical, ZyquoTheme.spacing8)74 .onChange(of: session.selectedModelID) { _, newID in75 guard let newID, let item = model.models.first(where: { $0.id == newID }) else { return }76 if item.type == .speech {77 // Out-of-process — nothing to load in the engine.78 session.speechModel = item79 } else {80 Task { await session.load(item) }81 }82 }83 }8485 @ViewBuilder86 private var content: some View {87 switch session.loadedModel?.type ?? selectedType {88 case .embedding:89 EmbeddingInspector(session: session)90 case .llm, .vlm:91 ChatPanel(session: session)92 case .speech:93 SpeechPanel(session: session)94 default:95 EmptyStateView(96 icon: "cpu",97 title: "Pick a Model to Begin",98 message: "Choose an installed model above. LLMs and vision models open a streaming chat; embedding models open the vector inspector; speech models open the transcriber.")99 }100 }101102 /// Speech models run out-of-process (no in-engine load) — panel selection103 /// falls back to the picker choice.104 private var selectedType: ModelType? {105 model.models.first { $0.id == session.selectedModelID }?.type106 }107}108109// MARK: - Session state110111@Observable112@MainActor113final class PlaygroundSession {114 var selectedModelID: String?115 var loadedModel: LocalModel?116 var isLoading = false117 var isGenerating = false118 var lastStats: InferenceStats?119 var lastFreedBytes: Int64?120 var errorMessage: String?121122 // Chat state123 var transcript: [(role: String, text: String)] = []124 var prompt = ""125 var attachedImage: URL?126 private var generationTask: Task<Void, Never>?127128 // Embedding state129 var embedInput = "The quick brown fox\nA fast auburn fox\nQuarterly revenue grew 4%"130 var embedResults: [(text: String, vector: [Float])] = []131 var similarities: [(a: String, b: String, score: Float)] = []132133 // Speech state (out-of-process — no engine load)134 var speechModel: LocalModel?135 var transcription: TranscriptionResult?136 var isTranscribing = false137138 func transcribe(audio: URL) {139 guard let speechModel else { return }140 isTranscribing = true141 transcription = nil142 Task {143 do {144 transcription = try await SpeechService.shared.transcribe(145 model: speechModel, audio: audio)146 } catch {147 errorMessage = error.localizedDescription148 }149 isTranscribing = false150 }151 }152153 func load(_ model: LocalModel) async {154 isLoading = true155 errorMessage = nil156 transcript = []157 embedResults = []158 similarities = []159 lastStats = nil160 do {161 try await InferenceEngine.shared.load(model: model)162 loadedModel = model163 } catch {164 errorMessage = error.localizedDescription165 loadedModel = nil166 }167 isLoading = false168 }169170 func unload() async {171 stop()172 lastFreedBytes = try? await InferenceEngine.shared.unload()173 loadedModel = nil174 selectedModelID = nil175 }176177 func send() {178 let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)179 guard !text.isEmpty, !isGenerating, loadedModel != nil else { return }180 prompt = ""181 let image = attachedImage182 attachedImage = nil183 transcript.append((role: "user", text: text))184 transcript.append((role: "assistant", text: ""))185 isGenerating = true186187 var messages: [Chat.Message] = transcript.dropLast(2).map {188 $0.role == "user" ? .user($0.text) : .assistant($0.text)189 }190 if let image {191 messages.append(.user(text, images: [.url(image)]))192 } else {193 messages.append(.user(text))194 }195196 generationTask = Task {197 do {198 let stream = try await InferenceEngine.shared.generate(199 messages: messages, params: GenerationParams())200 for try await event in stream {201 switch event {202 case .chunk(let piece):203 transcript[transcript.count - 1].text += piece204 case .finished(let stats):205 lastStats = stats206 }207 }208 } catch {209 errorMessage = error.localizedDescription210 }211 isGenerating = false212 }213 }214215 func stop() {216 generationTask?.cancel()217 generationTask = nil218 isGenerating = false219 }220221 func runEmbedding() {222 let texts = embedInput.split(separator: "\n").map(String.init).filter { !$0.isEmpty }223 guard !texts.isEmpty else { return }224 Task {225 do {226 let vectors = try await InferenceEngine.shared.embed(texts: texts)227 embedResults = Array(zip(texts, vectors))228 similarities = []229 for i in 0..<vectors.count {230 for j in (i + 1)..<vectors.count {231 similarities.append((232 a: texts[i], b: texts[j],233 score: InferenceEngine.cosineSimilarity(vectors[i], vectors[j])))234 }235 }236 similarities.sort { $0.score > $1.score }237 } catch {238 errorMessage = error.localizedDescription239 }240 }241 }242}243244// MARK: - Chat panel245246private struct ChatPanel: View {247 @Bindable var session: PlaygroundSession248 @State private var isPickingImage = false249250 var body: some View {251 VStack(spacing: 0) {252 if let error = session.errorMessage {253 Text(error)254 .font(ZyquoTheme.captionFont)255 .foregroundStyle(ZyquoTheme.danger)256 .padding(ZyquoTheme.spacing8)257 }258259 ScrollViewReader { proxy in260 ScrollView {261 LazyVStack(alignment: .leading, spacing: ZyquoTheme.spacing12) {262 ForEach(Array(session.transcript.enumerated()), id: \.offset) { index, entry in263 MessageBubble(role: entry.role, text: entry.text)264 .id(index)265 }266 }267 .padding(ZyquoTheme.spacing20)268 }269 .onChange(of: session.transcript.last?.text) {270 proxy.scrollTo(session.transcript.count - 1, anchor: .bottom)271 }272 }273274 Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)275276 HStack(spacing: ZyquoTheme.spacing8) {277 if session.loadedModel?.type == .vlm {278 Button {279 isPickingImage = true280 } label: {281 Image(systemName: session.attachedImage == nil ? "photo" : "photo.fill")282 .foregroundStyle(283 session.attachedImage == nil284 ? ZyquoTheme.textSecondary : ZyquoTheme.accent)285 }286 .buttonStyle(.plain)287 .help(session.attachedImage?.lastPathComponent ?? "Attach an image")288 }289290 TextField("Message the model…", text: $session.prompt, axis: .vertical)291 .textFieldStyle(.plain)292 .font(ZyquoTheme.bodyFont)293 .lineLimit(1...5)294 .onSubmit { session.send() }295296 if session.isGenerating {297 Button {298 session.stop()299 } label: {300 Image(systemName: "stop.circle.fill")301 .font(.system(size: 20))302 .foregroundStyle(ZyquoTheme.danger)303 }304 .buttonStyle(.plain)305 } else {306 Button {307 session.send()308 } label: {309 Image(systemName: "arrow.up.circle.fill")310 .font(.system(size: 20))311 .foregroundStyle(312 session.prompt.isEmpty ? ZyquoTheme.textTertiary : ZyquoTheme.accent)313 }314 .buttonStyle(.plain)315 .disabled(session.prompt.isEmpty)316 }317 }318 .padding(ZyquoTheme.spacing12)319 .background(ZyquoTheme.surface)320 }321 .fileImporter(isPresented: $isPickingImage, allowedContentTypes: [.image]) { result in322 if case .success(let url) = result { session.attachedImage = url }323 }324 }325}326327private struct MessageBubble: View {328 let role: String329 let text: String330331 var body: some View {332 HStack {333 if role == "user" { Spacer(minLength: 80) }334 Text(text.isEmpty ? "…" : text)335 .font(ZyquoTheme.bodyFont)336 .foregroundStyle(ZyquoTheme.textPrimary)337 .textSelection(.enabled)338 .padding(ZyquoTheme.spacing12)339 .background(role == "user" ? ZyquoTheme.accentSubtle : ZyquoTheme.surface)340 .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusMedium))341 .overlay(342 RoundedRectangle(cornerRadius: ZyquoTheme.radiusMedium)343 .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline))344 if role != "user" { Spacer(minLength: 80) }345 }346 }347}348349// MARK: - Speech panel350351private struct SpeechPanel: View {352 @Bindable var session: PlaygroundSession353 @State private var isPickingAudio = false354355 var body: some View {356 VStack(spacing: ZyquoTheme.spacing16) {357 if session.isTranscribing {358 ProgressView("Transcribing…")359 } else if let result = session.transcription {360 ScrollView {361 VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {362 HStack {363 if let language = result.language {364 StatusPill(text: language, color: ZyquoTheme.slate)365 }366 Text("\(result.segments) segments · \(String(format: "%.1fs", result.duration))")367 .font(ZyquoTheme.captionFont)368 .foregroundStyle(ZyquoTheme.textSecondary)369 }370 Text(result.text)371 .font(ZyquoTheme.bodyFont)372 .textSelection(.enabled)373 }374 .padding(ZyquoTheme.spacing16)375 .frame(maxWidth: .infinity, alignment: .leading)376 .zyquoCard()377 .padding(ZyquoTheme.spacing20)378 }379 } else {380 EmptyStateView(381 icon: "waveform",382 title: "Transcribe Audio",383 message: "Pick an audio file — the speech model runs locally through the Python pipeline and returns the transcript here.",384 actionLabel: "Choose Audio…",385 action: { isPickingAudio = true })386 }387388 if session.transcription != nil {389 Button("Transcribe Another…") { isPickingAudio = true }390 .padding(.bottom, ZyquoTheme.spacing16)391 }392 }393 .fileImporter(isPresented: $isPickingAudio, allowedContentTypes: [.audio]) { result in394 if case .success(let url) = result { session.transcribe(audio: url) }395 }396 }397}398399// MARK: - Embedding inspector400401private struct EmbeddingInspector: View {402 @Bindable var session: PlaygroundSession403404 var body: some View {405 ScrollView {406 VStack(alignment: .leading, spacing: ZyquoTheme.spacing16) {407 Text("One text per line — embed them and compare similarities.")408 .font(ZyquoTheme.captionFont)409 .foregroundStyle(ZyquoTheme.textSecondary)410411 TextEditor(text: $session.embedInput)412 .font(ZyquoTheme.monoFont)413 .frame(height: 120)414 .padding(ZyquoTheme.spacing8)415 .background(ZyquoTheme.surfaceSecondary)416 .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))417418 Button("Embed") { session.runEmbedding() }419 .buttonStyle(.borderedProminent)420 .tint(ZyquoTheme.accent)421422 if !session.embedResults.isEmpty {423 VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {424 Text("Vectors")425 .font(ZyquoTheme.headlineFont)426 ForEach(session.embedResults, id: \.text) { result in427 HStack {428 Text("dim \(result.vector.count)")429 .font(ZyquoTheme.monoSmallFont)430 .foregroundStyle(ZyquoTheme.chartThroughput)431 Text(result.vector.prefix(6)432 .map { String(format: "%+.3f", $0) }433 .joined(separator: " ") + " …")434 .font(ZyquoTheme.monoSmallFont)435 .foregroundStyle(ZyquoTheme.textSecondary)436 Text(result.text)437 .font(ZyquoTheme.captionFont)438 .foregroundStyle(ZyquoTheme.textPrimary)439 .lineLimit(1)440 }441 }442 }443 .padding(ZyquoTheme.spacing12)444 .frame(maxWidth: .infinity, alignment: .leading)445 .zyquoCard()446447 VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {448 Text("Cosine similarity")449 .font(ZyquoTheme.headlineFont)450 ForEach(Array(session.similarities.enumerated()), id: \.offset) { _, pair in451 HStack {452 Text(String(format: "%.4f", pair.score))453 .font(ZyquoTheme.monoFont)454 .foregroundStyle(455 pair.score > 0.8 ? ZyquoTheme.success : ZyquoTheme.textSecondary)456 Text("\(pair.a) ↔ \(pair.b)")457 .font(ZyquoTheme.captionFont)458 .foregroundStyle(ZyquoTheme.textPrimary)459 .lineLimit(1)460 }461 }462 }463 .padding(ZyquoTheme.spacing12)464 .frame(maxWidth: .infinity, alignment: .leading)465 .zyquoCard()466 }467 }468 .padding(ZyquoTheme.spacing20)469 }470 }471}472