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%
9.6 KB · 265 lines swift
Raw Blame History
1//2//  SidebarView.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Translucent 260 pt sidebar: wordmark, Chats (search + groups), Library12/// entry with live download badge, footer with settings + loaded-model chip.13struct SidebarView: View {14    @Environment(AppModel.self) private var app15    @Environment(\.openSettings) private var openSettings16    @State private var query = ""17    @State private var renamingID: UUID?18    @State private var renameText = ""19    @FocusState private var searchFocused: Bool2021    var body: some View {22        VStack(spacing: 0) {23            wordmark24                .padding(.horizontal, ZyquoTheme.Spacing.m)25                .padding(.top, ZyquoTheme.Spacing.s)2627            searchField28                .padding(.horizontal, ZyquoTheme.Spacing.s)29                .padding(.top, ZyquoTheme.Spacing.s)3031            List(selection: selectionBinding) {32                Section {33                    newChatButton34                    libraryRow35                }36                ForEach(app.sidebarGroups(query: query)) { group in37                    Section(group.title) {38                        ForEach(group.conversations) { conversation in39                            conversationRow(conversation)40                                .tag(conversation.id)41                        }42                    }43                }44            }45            .scrollContentBackground(.hidden)46            .listStyle(.sidebar)4748            footer49        }50        .background(.regularMaterial)51        .onReceive(NotificationCenter.default.publisher(for: .zyquoFocusSearch)) { _ in52            searchFocused = true53        }54    }5556    private var selectionBinding: Binding<UUID?> {57        Binding(58            get: { app.route == .chat ? app.selectedConversationID : nil },59            set: { id in60                if let id {61                    app.selectedConversationID = id62                    app.route = .chat63                }64            }65        )66    }6768    private var wordmark: some View {69        HStack(spacing: ZyquoTheme.Spacing.xs) {70            ZyquoGlyph(size: 18)71            Text("Zyquo Local")72                .font(ZyquoTheme.bodyEmphasis)73                .foregroundStyle(ZyquoTheme.textPrimary)74            Spacer()75        }76    }7778    private var searchField: some View {79        HStack(spacing: ZyquoTheme.Spacing.xxs) {80            Image(systemName: "magnifyingglass")81                .font(.system(size: 11))82                .foregroundStyle(ZyquoTheme.textTertiary)83            TextField("Search chats", text: $query)84                .textFieldStyle(.plain)85                .font(ZyquoTheme.body)86                .focused($searchFocused)87        }88        .padding(.horizontal, ZyquoTheme.Spacing.xs)89        .padding(.vertical, 5)90        .background(ZyquoTheme.surfaceSecondary.opacity(0.6), in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s))91        .overlay(92            RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)93                .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)94        )95    }9697    private var newChatButton: some View {98        Button {99            app.newConversation()100        } label: {101            Label("New Chat", systemImage: "square.and.pencil")102                .font(ZyquoTheme.bodyEmphasis)103                .foregroundStyle(ZyquoTheme.accent)104        }105        .buttonStyle(.plain)106        .keyboardShortcut("n", modifiers: .command)107    }108109    private var libraryRow: some View {110        Button {111            app.route = .library112        } label: {113            HStack {114                Label("Library", systemImage: "square.stack.3d.up")115                    .font(ZyquoTheme.body)116                Spacer()117                if app.downloads.activeCount > 0 {118                    HStack(spacing: ZyquoTheme.Spacing.xxs) {119                        ProgressView(value: app.downloads.overallFraction)120                            .progressViewStyle(.circular)121                            .controlSize(.mini)122                        Text("\(app.downloads.activeCount)")123                            .font(ZyquoTheme.caption)124                            .foregroundStyle(.white)125                            .padding(.horizontal, 6)126                            .padding(.vertical, 1)127                            .background(ZyquoTheme.accent, in: Capsule())128                    }129                }130            }131            .contentShape(Rectangle())132        }133        .buttonStyle(.plain)134        .keyboardShortcut("l", modifiers: .command)135    }136137    @ViewBuilder138    private func conversationRow(_ conversation: Conversation) -> some View {139        HStack(spacing: ZyquoTheme.Spacing.xs) {140            VStack(alignment: .leading, spacing: 2) {141                if renamingID == conversation.id {142                    TextField(143                        "Title", text: $renameText,144                        onCommit: {145                            var c = conversation146                            c.title = renameText.isEmpty ? conversation.title : renameText147                            app.update(c, touch: false)148                            renamingID = nil149                        }150                    )151                    .textFieldStyle(.plain)152                    .font(ZyquoTheme.body)153                } else {154                    Text(conversation.title)155                        .font(ZyquoTheme.body)156                        .lineLimit(1)157                }158                HStack(spacing: ZyquoTheme.Spacing.xxs) {159                    if let modelID = conversation.modelID {160                        Text(shortModelName(modelID))161                            .font(ZyquoTheme.caption)162                            .foregroundStyle(ZyquoTheme.textTertiary)163                            .lineLimit(1)164                    }165                    Text(conversation.updatedAt, format: .relative(presentation: .named))166                        .font(ZyquoTheme.caption)167                        .foregroundStyle(ZyquoTheme.textTertiary)168                }169            }170            Spacer(minLength: 0)171            if conversation.pinned {172                Image(systemName: "pin.fill")173                    .font(.system(size: 9))174                    .foregroundStyle(ZyquoTheme.textTertiary)175            }176        }177        .contextMenu {178            Button(conversation.pinned ? "Unpin" : "Pin") {179                app.togglePin(conversationID: conversation.id)180            }181            Button("Rename") {182                renameText = conversation.title183                renamingID = conversation.id184            }185            Divider()186            Button("Export as Markdown…") {187                ExportService.exportMarkdown(conversation)188            }189            Button("Export as PDF…") {190                ExportService.exportPDF(conversation)191            }192            Divider()193            Button("Delete", role: .destructive) {194                app.delete(conversationID: conversation.id)195            }196        }197    }198199    private var footer: some View {200        VStack(spacing: 0) {201            Rectangle()202                .fill(ZyquoTheme.border)203                .frame(height: ZyquoTheme.hairline)204            HStack(spacing: ZyquoTheme.Spacing.xs) {205                Button {206                    openSettings()207                } label: {208                    Image(systemName: "gearshape")209                        .foregroundStyle(ZyquoTheme.textSecondary)210                }211                .buttonStyle(.plain)212                .help("Settings (⌘,)")213214                Spacer()215216                if let repoID = app.loadedModelID {217                    HStack(spacing: ZyquoTheme.Spacing.xxs) {218                        Circle()219                            .fill(memoryDotColor)220                            .frame(width: 7, height: 7)221                        Text(shortModelName(repoID))222                            .font(ZyquoTheme.caption)223                            .foregroundStyle(ZyquoTheme.textSecondary)224                            .lineLimit(1)225                        if app.liveMemoryBytes > 0 {226                            Text(formatBytes(Int64(app.liveMemoryBytes)))227                                .font(ZyquoTheme.caption)228                                .foregroundStyle(ZyquoTheme.textTertiary)229                        }230                    }231                    .padding(.horizontal, ZyquoTheme.Spacing.xs)232                    .padding(.vertical, 3)233                    .background(ZyquoTheme.surfaceSecondary.opacity(0.7), in: Capsule())234                    .help("Loaded model · active memory")235                } else {236                    Text("No model loaded")237                        .font(ZyquoTheme.caption)238                        .foregroundStyle(ZyquoTheme.textTertiary)239                }240            }241            .padding(.horizontal, ZyquoTheme.Spacing.m)242            .padding(.vertical, ZyquoTheme.Spacing.s)243        }244    }245246    private var memoryDotColor: Color {247        let ram = Double(MemoryAdvisor.physicalMemoryBytes)248        let used = Double(app.liveMemoryBytes)249        if used < ram * 0.5 { return ZyquoTheme.success }250        if used < ram * 0.7 { return ZyquoTheme.warning }251        return ZyquoTheme.danger252    }253}254255/// "Qwen3-8B-4bit" from "mlx-community/Qwen3-8B-4bit".256func shortModelName(_ repoID: String) -> String {257    repoID.split(separator: "/").last.map(String.init) ?? repoID258}259260extension Notification.Name {261    static let zyquoFocusSearch = Notification.Name("zyquoFocusSearch")262    static let zyquoOpenModelSwitcher = Notification.Name("zyquoOpenModelSwitcher")263    static let zyquoExportConversation = Notification.Name("zyquoExportConversation")264}265