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// LibraryView.swift3// Zyquo Local4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import SwiftUI1011/// Model Library: "Installed" and "Discover" tabs + downloads drawer.12struct LibraryView: View {13 enum Tab: String, CaseIterable {14 case installed = "Installed"15 case discover = "Discover"16 }1718 @Environment(AppModel.self) private var app19 @State private var tab: Tab = .discover2021 var body: some View {22 VStack(spacing: 0) {23 header24 Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)2526 switch tab {27 case .installed: InstalledModelsView()28 case .discover: DiscoverView()29 }3031 if app.downloads.activeCount > 0 || app.downloads.tasks.contains(where: { $0.state == .paused || $0.state == .failed }) {32 DownloadsDrawer()33 }34 }35 .background(ZyquoTheme.background)36 .onAppear {37 tab = app.store.models.isEmpty ? .discover : .installed38 }39 }4041 private var header: some View {42 HStack {43 Button {44 app.route = .chat45 } label: {46 Label("Back", systemImage: "chevron.left")47 .font(ZyquoTheme.body)48 .foregroundStyle(ZyquoTheme.textSecondary)49 }50 .buttonStyle(.plain)5152 Spacer()5354 Picker("", selection: $tab) {55 ForEach(Tab.allCases, id: \.self) { t in56 Text(t.rawValue).tag(t)57 }58 }59 .pickerStyle(.segmented)60 .frame(width: 240)6162 Spacer()6364 if tab == .installed {65 Text("\(formatBytes(app.store.totalSizeBytes)) on disk")66 .font(ZyquoTheme.caption)67 .foregroundStyle(ZyquoTheme.textSecondary)68 } else {69 Text("mlx-community · Hugging Face")70 .font(ZyquoTheme.caption)71 .foregroundStyle(ZyquoTheme.textTertiary)72 }73 }74 .padding(.horizontal, ZyquoTheme.Spacing.m)75 .frame(height: ZyquoTheme.chatHeaderHeight)76 }77}7879/// Installed tab: downloaded models with verdicts and actions.80struct InstalledModelsView: View {81 @Environment(AppModel.self) private var app82 @State private var deleting: LocalModel?8384 var body: some View {85 Group {86 if app.store.models.isEmpty {87 VStack(spacing: ZyquoTheme.Spacing.s) {88 Image(systemName: "square.stack.3d.up.slash")89 .font(.system(size: 36, weight: .light))90 .foregroundStyle(ZyquoTheme.textTertiary)91 Text("No models installed yet — find one in Discover.")92 .font(ZyquoTheme.body)93 .foregroundStyle(ZyquoTheme.textSecondary)94 }95 .frame(maxWidth: .infinity, maxHeight: .infinity)96 } else {97 ScrollView {98 LazyVStack(spacing: ZyquoTheme.Spacing.s) {99 ForEach(app.store.models) { model in100 InstalledModelRow(model: model, deleting: $deleting)101 }102 }103 .padding(ZyquoTheme.Spacing.l)104 .frame(maxWidth: 860)105 .frame(maxWidth: .infinity)106 }107 }108 }109 .confirmationDialog(110 "Delete \(deleting.map { shortModelName($0.repoID) } ?? "model")?",111 isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),112 titleVisibility: .visible113 ) {114 Button(115 "Delete and reclaim \(formatBytes(deleting?.sizeBytes ?? 0))",116 role: .destructive117 ) {118 if let model = deleting {119 if app.loadedModelID == model.repoID {120 Task { await app.unloadModel() }121 }122 app.store.delete(repoID: model.repoID)123 }124 deleting = nil125 }126 }127 }128}129130struct InstalledModelRow: View {131 let model: LocalModel132 @Binding var deleting: LocalModel?133 @Environment(AppModel.self) private var app134 @State private var showParams = false135136 private var isLoaded: Bool { app.loadedModelID == model.repoID }137 private var verdict: MemoryAdvisor.Verdict { MemoryAdvisor.verdict(weightsBytes: model.sizeBytes) }138139 var body: some View {140 HStack(spacing: ZyquoTheme.Spacing.m) {141 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) {142 HStack(spacing: ZyquoTheme.Spacing.xs) {143 Text(model.name)144 .font(ZyquoTheme.bodyEmphasis)145 .foregroundStyle(ZyquoTheme.textPrimary)146 if isLoaded {147 Text("Loaded")148 .font(ZyquoTheme.caption.weight(.medium))149 .foregroundStyle(ZyquoTheme.accent)150 .padding(.horizontal, ZyquoTheme.Spacing.xs)151 .padding(.vertical, 1)152 .background(ZyquoTheme.accentSubtle, in: Capsule())153 }154 }155 HStack(spacing: ZyquoTheme.Spacing.xxs) {156 if let quant = model.quantization { InfoBadge(text: quant) }157 if let arch = model.architecture { InfoBadge(text: arch) }158 InfoBadge(text: formatBytes(model.sizeBytes))159 if let ctx = model.contextWindow { InfoBadge(text: "\(ctx / 1024)k ctx") }160 }161 if let lastUsed = model.lastUsed {162 Text("Last used \(lastUsed.formatted(.relative(presentation: .named)))")163 .font(ZyquoTheme.caption)164 .foregroundStyle(ZyquoTheme.textTertiary)165 }166 }167 Spacer()168 VerdictBadge(verdict: verdict)169170 HStack(spacing: ZyquoTheme.Spacing.xs) {171 if isLoaded {172 Button("Unload") { Task { await app.unloadModel() } }173 .buttonStyle(.bordered)174 } else {175 Button("Load") { Task { await app.loadModel(repoID: model.repoID) } }176 .buttonStyle(.borderedProminent)177 .tint(ZyquoTheme.accent)178 .disabled(verdict == .tooLarge)179 }180 Button {181 var c = app.newConversation()182 c.modelID = model.repoID183 app.update(c, touch: false)184 if !isLoaded {185 Task { await app.loadModel(repoID: model.repoID) }186 }187 } label: {188 Image(systemName: "bubble.left.and.text.bubble.right")189 }190 .buttonStyle(.bordered)191 .help("New chat with this model")192193 Menu {194 Button("Default parameters…") { showParams = true }195 Button("Reveal in Finder") { app.store.revealInFinder(repoID: model.repoID) }196 Divider()197 Button("Delete…", role: .destructive) { deleting = model }198 } label: {199 Image(systemName: "ellipsis.circle")200 }201 .menuStyle(.borderlessButton)202 .menuIndicator(.hidden)203 .frame(width: 26)204 }205 }206 .padding(ZyquoTheme.Spacing.m)207 .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))208 .overlay(209 RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)210 .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)211 )212 .popover(isPresented: $showParams) {213 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) {214 Text("Default parameters for \(model.name)")215 .font(ZyquoTheme.bodyEmphasis)216 ParamsEditor(217 params: Binding(218 get: { model.defaultParams ?? GenerationParams() },219 set: { app.store.setDefaultParams($0, for: model.repoID) }220 )221 )222 Button("Reset to app defaults") {223 app.store.setDefaultParams(nil, for: model.repoID)224 }225 .font(ZyquoTheme.caption)226 }227 .padding(ZyquoTheme.Spacing.m)228 .frame(width: 300)229 }230 }231}232