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