SPB Git

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%
18.0 KB · 472 lines swift
Raw Blame History
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