SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%
6.4 KB · 169 lines swift
Raw Blame History
1//2//  ModelChipView.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Centered header chip: model name + quant badge. Click → switcher popover12/// listing downloaded models with RAM verdicts; switching shows inline13/// progress in the chip. Morphs smoothly between unloaded/loading/ready.14struct ModelChipView: View {15    let conversation: Conversation16    @Environment(AppModel.self) private var app17    @State private var showSwitcher = false1819    var body: some View {20        Button {21            showSwitcher.toggle()22        } label: {23            HStack(spacing: ZyquoTheme.Spacing.xs) {24                switch app.engineState {25                case .loading(let repoID):26                    ProgressView()27                        .controlSize(.mini)28                    Text("Loading \(shortModelName(repoID))…")29                        .font(ZyquoTheme.caption)30                        .foregroundStyle(ZyquoTheme.textSecondary)31                case .ready(let repoID), .generating(let repoID):32                    Circle()33                        .fill(ZyquoTheme.success)34                        .frame(width: 7, height: 7)35                    Text(chipTitle(repoID))36                        .font(ZyquoTheme.bodyEmphasis)37                        .foregroundStyle(ZyquoTheme.textPrimary)38                case .unloaded:39                    Circle()40                        .fill(ZyquoTheme.textTertiary)41                        .frame(width: 7, height: 7)42                    Text(app.store.models.isEmpty ? "No model" : "Choose a model")43                        .font(ZyquoTheme.body)44                        .foregroundStyle(ZyquoTheme.textSecondary)45                }46                Image(systemName: "chevron.down")47                    .font(.system(size: 9, weight: .semibold))48                    .foregroundStyle(ZyquoTheme.textTertiary)49            }50            .padding(.horizontal, ZyquoTheme.Spacing.s)51            .padding(.vertical, 5)52            .background(ZyquoTheme.surface, in: Capsule())53            .overlay(Capsule().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline))54            .animation(.snappy(duration: 0.2), value: app.engineState)55        }56        .buttonStyle(PressableButtonStyle())57        .popover(isPresented: $showSwitcher, arrowEdge: .bottom) {58            ModelSwitcherPopover(conversation: conversation)59        }60        .onReceive(NotificationCenter.default.publisher(for: .zyquoOpenModelSwitcher)) { _ in61            showSwitcher = true62        }63    }6465    private func chipTitle(_ repoID: String) -> String {66        let name = shortModelName(repoID)67        if let quant = app.store.model(for: repoID)?.quantization {68            let base = name.replacingOccurrences(of: "-\(quant)", with: "")69            return "\(base) · \(quant)"70        }71        return name72    }73}7475/// Popover listing downloaded models with RAM verdicts.76struct ModelSwitcherPopover: View {77    let conversation: Conversation78    @Environment(AppModel.self) private var app79    @Environment(\.dismiss) private var dismiss8081    var body: some View {82        VStack(alignment: .leading, spacing: 0) {83            Text("Switch model")84                .font(ZyquoTheme.caption)85                .foregroundStyle(ZyquoTheme.textSecondary)86                .padding(ZyquoTheme.Spacing.s)8788            if app.store.models.isEmpty {89                VStack(spacing: ZyquoTheme.Spacing.xs) {90                    Text("No models downloaded yet.")91                        .font(ZyquoTheme.body)92                        .foregroundStyle(ZyquoTheme.textSecondary)93                    Button("Open Library") {94                        app.route = .library95                        dismiss()96                    }97                    .buttonStyle(.borderedProminent)98                    .tint(ZyquoTheme.accent)99                }100                .padding(ZyquoTheme.Spacing.m)101            } else {102                ScrollView {103                    VStack(spacing: 2) {104                        ForEach(app.store.models) { model in105                            row(model)106                        }107                    }108                    .padding(.horizontal, ZyquoTheme.Spacing.xs)109                    .padding(.bottom, ZyquoTheme.Spacing.xs)110                }111                .frame(maxHeight: 320)112113                if app.loadedModelID != nil {114                    Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)115                    Button {116                        Task { await app.unloadModel() }117                        dismiss()118                    } label: {119                        Label("Unload model", systemImage: "eject")120                            .font(ZyquoTheme.body)121                            .foregroundStyle(ZyquoTheme.textSecondary)122                    }123                    .buttonStyle(.plain)124                    .padding(ZyquoTheme.Spacing.s)125                }126            }127        }128        .frame(width: 320)129    }130131    @ViewBuilder132    private func row(_ model: LocalModel) -> some View {133        let verdict = MemoryAdvisor.verdict(weightsBytes: model.sizeBytes)134        let isLoaded = app.loadedModelID == model.repoID135        Button {136            guard !isLoaded, verdict != .tooLarge else { return }137            var c = conversation138            c.modelID = model.repoID139            app.update(c, touch: false)140            Task { await app.loadModel(repoID: model.repoID) }141            dismiss()142        } label: {143            HStack(spacing: ZyquoTheme.Spacing.xs) {144                VStack(alignment: .leading, spacing: 1) {145                    Text(model.name)146                        .font(ZyquoTheme.body)147                        .foregroundStyle(ZyquoTheme.textPrimary)148                        .lineLimit(1)149                    Text("\(model.organization) · \(formatBytes(model.sizeBytes))")150                        .font(ZyquoTheme.caption)151                        .foregroundStyle(ZyquoTheme.textTertiary)152                }153                Spacer()154                if isLoaded {155                    Image(systemName: "checkmark.circle.fill")156                        .foregroundStyle(ZyquoTheme.accent)157                } else {158                    VerdictBadge(verdict: verdict)159                }160            }161            .padding(ZyquoTheme.Spacing.xs)162            .contentShape(Rectangle())163        }164        .buttonStyle(.plain)165        .hoverHighlight()166        .disabled(verdict == .tooLarge && !isLoaded)167    }168}169