// // ModelChipView.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Centered header chip: model name + quant badge. Click → switcher popover /// listing downloaded models with RAM verdicts; switching shows inline /// progress in the chip. Morphs smoothly between unloaded/loading/ready. struct ModelChipView: View { let conversation: Conversation @Environment(AppModel.self) private var app @State private var showSwitcher = false var body: some View { Button { showSwitcher.toggle() } label: { HStack(spacing: ZyquoTheme.Spacing.xs) { switch app.engineState { case .loading(let repoID): ProgressView() .controlSize(.mini) Text("Loading \(shortModelName(repoID))…") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) case .ready(let repoID), .generating(let repoID): Circle() .fill(ZyquoTheme.success) .frame(width: 7, height: 7) Text(chipTitle(repoID)) .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.textPrimary) case .unloaded: Circle() .fill(ZyquoTheme.textTertiary) .frame(width: 7, height: 7) Text(app.store.models.isEmpty ? "No model" : "Choose a model") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textSecondary) } Image(systemName: "chevron.down") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(ZyquoTheme.textTertiary) } .padding(.horizontal, ZyquoTheme.Spacing.s) .padding(.vertical, 5) .background(ZyquoTheme.surface, in: Capsule()) .overlay(Capsule().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)) .animation(.snappy(duration: 0.2), value: app.engineState) } .buttonStyle(PressableButtonStyle()) .popover(isPresented: $showSwitcher, arrowEdge: .bottom) { ModelSwitcherPopover(conversation: conversation) } .onReceive(NotificationCenter.default.publisher(for: .zyquoOpenModelSwitcher)) { _ in showSwitcher = true } } private func chipTitle(_ repoID: String) -> String { let name = shortModelName(repoID) if let quant = app.store.model(for: repoID)?.quantization { let base = name.replacingOccurrences(of: "-\(quant)", with: "") return "\(base) · \(quant)" } return name } } /// Popover listing downloaded models with RAM verdicts. struct ModelSwitcherPopover: View { let conversation: Conversation @Environment(AppModel.self) private var app @Environment(\.dismiss) private var dismiss var body: some View { VStack(alignment: .leading, spacing: 0) { Text("Switch model") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) .padding(ZyquoTheme.Spacing.s) if app.store.models.isEmpty { VStack(spacing: ZyquoTheme.Spacing.xs) { Text("No models downloaded yet.") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textSecondary) Button("Open Library") { app.route = .library dismiss() } .buttonStyle(.borderedProminent) .tint(ZyquoTheme.accent) } .padding(ZyquoTheme.Spacing.m) } else { ScrollView { VStack(spacing: 2) { ForEach(app.store.models) { model in row(model) } } .padding(.horizontal, ZyquoTheme.Spacing.xs) .padding(.bottom, ZyquoTheme.Spacing.xs) } .frame(maxHeight: 320) if app.loadedModelID != nil { Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) Button { Task { await app.unloadModel() } dismiss() } label: { Label("Unload model", systemImage: "eject") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) .padding(ZyquoTheme.Spacing.s) } } } .frame(width: 320) } @ViewBuilder private func row(_ model: LocalModel) -> some View { let verdict = MemoryAdvisor.verdict(weightsBytes: model.sizeBytes) let isLoaded = app.loadedModelID == model.repoID Button { guard !isLoaded, verdict != .tooLarge else { return } var c = conversation c.modelID = model.repoID app.update(c, touch: false) Task { await app.loadModel(repoID: model.repoID) } dismiss() } label: { HStack(spacing: ZyquoTheme.Spacing.xs) { VStack(alignment: .leading, spacing: 1) { Text(model.name) .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textPrimary) .lineLimit(1) Text("\(model.organization) · \(formatBytes(model.sizeBytes))") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } Spacer() if isLoaded { Image(systemName: "checkmark.circle.fill") .foregroundStyle(ZyquoTheme.accent) } else { VerdictBadge(verdict: verdict) } } .padding(ZyquoTheme.Spacing.xs) .contentShape(Rectangle()) } .buttonStyle(.plain) .hoverHighlight() .disabled(verdict == .tooLarge && !isLoaded) } }