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// 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