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