// // LibraryView.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Model Library: "Installed" and "Discover" tabs + downloads drawer. struct LibraryView: View { enum Tab: String, CaseIterable { case installed = "Installed" case discover = "Discover" } @Environment(AppModel.self) private var app @State private var tab: Tab = .discover var body: some View { VStack(spacing: 0) { header Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) switch tab { case .installed: InstalledModelsView() case .discover: DiscoverView() } if app.downloads.activeCount > 0 || app.downloads.tasks.contains(where: { $0.state == .paused || $0.state == .failed }) { DownloadsDrawer() } } .background(ZyquoTheme.background) .onAppear { tab = app.store.models.isEmpty ? .discover : .installed } } private var header: some View { HStack { Button { app.route = .chat } label: { Label("Back", systemImage: "chevron.left") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) Spacer() Picker("", selection: $tab) { ForEach(Tab.allCases, id: \.self) { t in Text(t.rawValue).tag(t) } } .pickerStyle(.segmented) .frame(width: 240) Spacer() if tab == .installed { Text("\(formatBytes(app.store.totalSizeBytes)) on disk") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) } else { Text("mlx-community · Hugging Face") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } } .padding(.horizontal, ZyquoTheme.Spacing.m) .frame(height: ZyquoTheme.chatHeaderHeight) } } /// Installed tab: downloaded models with verdicts and actions. struct InstalledModelsView: View { @Environment(AppModel.self) private var app @State private var deleting: LocalModel? var body: some View { Group { if app.store.models.isEmpty { VStack(spacing: ZyquoTheme.Spacing.s) { Image(systemName: "square.stack.3d.up.slash") .font(.system(size: 36, weight: .light)) .foregroundStyle(ZyquoTheme.textTertiary) Text("No models installed yet — find one in Discover.") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textSecondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { LazyVStack(spacing: ZyquoTheme.Spacing.s) { ForEach(app.store.models) { model in InstalledModelRow(model: model, deleting: $deleting) } } .padding(ZyquoTheme.Spacing.l) .frame(maxWidth: 860) .frame(maxWidth: .infinity) } } } .confirmationDialog( "Delete \(deleting.map { shortModelName($0.repoID) } ?? "model")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible ) { Button( "Delete and reclaim \(formatBytes(deleting?.sizeBytes ?? 0))", role: .destructive ) { if let model = deleting { if app.loadedModelID == model.repoID { Task { await app.unloadModel() } } app.store.delete(repoID: model.repoID) } deleting = nil } } } } struct InstalledModelRow: View { let model: LocalModel @Binding var deleting: LocalModel? @Environment(AppModel.self) private var app @State private var showParams = false private var isLoaded: Bool { app.loadedModelID == model.repoID } private var verdict: MemoryAdvisor.Verdict { MemoryAdvisor.verdict(weightsBytes: model.sizeBytes) } var body: some View { HStack(spacing: ZyquoTheme.Spacing.m) { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) { HStack(spacing: ZyquoTheme.Spacing.xs) { Text(model.name) .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.textPrimary) if isLoaded { Text("Loaded") .font(ZyquoTheme.caption.weight(.medium)) .foregroundStyle(ZyquoTheme.accent) .padding(.horizontal, ZyquoTheme.Spacing.xs) .padding(.vertical, 1) .background(ZyquoTheme.accentSubtle, in: Capsule()) } } HStack(spacing: ZyquoTheme.Spacing.xxs) { if let quant = model.quantization { InfoBadge(text: quant) } if let arch = model.architecture { InfoBadge(text: arch) } InfoBadge(text: formatBytes(model.sizeBytes)) if let ctx = model.contextWindow { InfoBadge(text: "\(ctx / 1024)k ctx") } } if let lastUsed = model.lastUsed { Text("Last used \(lastUsed.formatted(.relative(presentation: .named)))") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } } Spacer() VerdictBadge(verdict: verdict) HStack(spacing: ZyquoTheme.Spacing.xs) { if isLoaded { Button("Unload") { Task { await app.unloadModel() } } .buttonStyle(.bordered) } else { Button("Load") { Task { await app.loadModel(repoID: model.repoID) } } .buttonStyle(.borderedProminent) .tint(ZyquoTheme.accent) .disabled(verdict == .tooLarge) } Button { var c = app.newConversation() c.modelID = model.repoID app.update(c, touch: false) if !isLoaded { Task { await app.loadModel(repoID: model.repoID) } } } label: { Image(systemName: "bubble.left.and.text.bubble.right") } .buttonStyle(.bordered) .help("New chat with this model") Menu { Button("Default parameters…") { showParams = true } Button("Reveal in Finder") { app.store.revealInFinder(repoID: model.repoID) } Divider() Button("Delete…", role: .destructive) { deleting = model } } label: { Image(systemName: "ellipsis.circle") } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .frame(width: 26) } } .padding(ZyquoTheme.Spacing.m) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) .popover(isPresented: $showParams) { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) { Text("Default parameters for \(model.name)") .font(ZyquoTheme.bodyEmphasis) ParamsEditor( params: Binding( get: { model.defaultParams ?? GenerationParams() }, set: { app.store.setDefaultParams($0, for: model.repoID) } ) ) Button("Reset to app defaults") { app.store.setDefaultParams(nil, for: model.repoID) } .font(ZyquoTheme.caption) } .padding(ZyquoTheme.Spacing.m) .frame(width: 300) } } }