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// ChatHeaderView.swift3// Zyquo Local4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import SwiftUI1011/// 52 pt header: editable title · centered model chip · perf toggle, export,12/// info popover (system prompt, params, context usage bar).13struct ChatHeaderView: View {14 let conversation: Conversation15 @Environment(AppModel.self) private var app16 @State private var title = ""17 @State private var showInfo = false18 @State private var showPerf = true19 @State private var contextUsage: (used: Int, window: Int)?2021 var body: some View {22 HStack(spacing: ZyquoTheme.Spacing.s) {23 // Editable title (left)24 TextField(25 "Title", text: $title,26 onCommit: {27 var c = conversation28 c.title = title.isEmpty ? conversation.title : title29 app.update(c, touch: false)30 }31 )32 .textFieldStyle(.plain)33 .font(ZyquoTheme.bodyEmphasis)34 .foregroundStyle(ZyquoTheme.textPrimary)35 .frame(maxWidth: 220, alignment: .leading)36 .onAppear { title = conversation.title }37 .onChange(of: conversation.id) { title = conversation.title }38 .onChange(of: conversation.title) { title = conversation.title }3940 Spacer()4142 ModelChipView(conversation: conversation)4344 Spacer()4546 // Live perf while generating47 if showPerf, app.chat.isGenerating, app.chat.liveTokensPerSecond > 0 {48 Text(String(format: "%.1f tok/s", app.chat.liveTokensPerSecond))49 .font(ZyquoTheme.caption.monospacedDigit())50 .foregroundStyle(ZyquoTheme.accent)51 .transition(.opacity)52 }5354 Button {55 showPerf.toggle()56 } label: {57 Image(systemName: "gauge.with.dots.needle.67percent")58 .foregroundStyle(showPerf ? ZyquoTheme.accent : ZyquoTheme.textSecondary)59 }60 .buttonStyle(.plain)61 .help("Show live tokens/sec while generating")6263 Menu {64 Button("Export as Markdown…") { ExportService.exportMarkdown(conversation) }65 Button("Export as PDF…") { ExportService.exportPDF(conversation) }66 } label: {67 Image(systemName: "square.and.arrow.up")68 .foregroundStyle(ZyquoTheme.textSecondary)69 }70 .menuStyle(.borderlessButton)71 .menuIndicator(.hidden)72 .frame(width: 24)73 .help("Export conversation (⇧⌘E)")7475 Button {76 Task { contextUsage = await app.engine.contextUsage }77 showInfo.toggle()78 } label: {79 Image(systemName: "info.circle")80 .foregroundStyle(ZyquoTheme.textSecondary)81 }82 .buttonStyle(.plain)83 .popover(isPresented: $showInfo, arrowEdge: .bottom) {84 ConversationInfoPopover(conversation: conversation, contextUsage: contextUsage)85 }86 .help("Conversation settings")87 }88 .padding(.horizontal, ZyquoTheme.Spacing.m)89 .frame(height: ZyquoTheme.chatHeaderHeight)90 .background(ZyquoTheme.background)91 .onReceive(NotificationCenter.default.publisher(for: .zyquoExportConversation)) { _ in92 ExportService.exportMarkdown(conversation)93 }94 }95}9697/// Info popover: system prompt, per-conversation params, context usage bar.98struct ConversationInfoPopover: View {99 let conversation: Conversation100 let contextUsage: (used: Int, window: Int)?101 @Environment(AppModel.self) private var app102 @State private var systemPrompt = ""103104 var body: some View {105 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.m) {106 Text("Conversation")107 .font(ZyquoTheme.bodyEmphasis)108109 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) {110 Text("System prompt")111 .font(ZyquoTheme.caption)112 .foregroundStyle(ZyquoTheme.textSecondary)113 TextEditor(text: $systemPrompt)114 .font(ZyquoTheme.body)115 .frame(height: 72)116 .scrollContentBackground(.hidden)117 .padding(ZyquoTheme.Spacing.xxs)118 .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s))119 .onChange(of: systemPrompt) {120 var c = conversation121 c.systemPrompt = systemPrompt.isEmpty ? nil : systemPrompt122 app.update(c, touch: false)123 }124 }125126 ParamsEditor(127 params: Binding(128 get: { app.selectedConversation?.params ?? conversation.params },129 set: { newParams in130 var c = app.selectedConversation ?? conversation131 c.params = newParams132 app.update(c, touch: false)133 }134 )135 )136137 if let usage = contextUsage, usage.window > 0 {138 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) {139 HStack {140 Text("Context")141 .font(ZyquoTheme.caption)142 .foregroundStyle(ZyquoTheme.textSecondary)143 Spacer()144 Text("≈\(usage.used) / \(usage.window) tokens")145 .font(ZyquoTheme.caption.monospacedDigit())146 .foregroundStyle(ZyquoTheme.textSecondary)147 }148 ProgressView(value: min(1, Double(usage.used) / Double(usage.window)))149 .tint(usage.used > usage.window * 3 / 4 ? ZyquoTheme.warning : ZyquoTheme.accent)150 }151 }152 }153 .padding(ZyquoTheme.Spacing.m)154 .frame(width: 340)155 .onAppear { systemPrompt = conversation.systemPrompt ?? "" }156 }157}158159/// Inline editors for the generation parameters, with plain-language help.160struct ParamsEditor: View {161 @Binding var params: GenerationParams162163 var body: some View {164 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {165 slider(166 "Temperature", value: Binding(167 get: { Double(params.temperature) },168 set: { params.temperature = Float($0) }),169 range: 0...2, format: "%.2f",170 help: "Higher = more creative, lower = more focused")171 slider(172 "Top-p", value: Binding(173 get: { Double(params.topP) },174 set: { params.topP = Float($0) }),175 range: 0.05...1, format: "%.2f",176 help: "Nucleus sampling probability mass")177 slider(178 "Repetition penalty", value: Binding(179 get: { Double(params.repetitionPenalty ?? 1.0) },180 set: { params.repetitionPenalty = $0 <= 1.001 ? nil : Float($0) }),181 range: 1...1.5, format: "%.2f",182 help: "Discourages repeating recent tokens (1 = off)")183 HStack {184 Text("Max tokens")185 .font(ZyquoTheme.caption)186 .foregroundStyle(ZyquoTheme.textSecondary)187 Spacer()188 TextField(189 "∞",190 text: Binding(191 get: { params.maxTokens.map(String.init) ?? "" },192 set: { params.maxTokens = Int($0).flatMap { $0 > 0 ? $0 : nil } }193 )194 )195 .textFieldStyle(.roundedBorder)196 .frame(width: 80)197 .multilineTextAlignment(.trailing)198 .font(ZyquoTheme.caption.monospacedDigit())199 }200 HStack {201 Text("Seed")202 .font(ZyquoTheme.caption)203 .foregroundStyle(ZyquoTheme.textSecondary)204 Spacer()205 TextField(206 "random",207 text: Binding(208 get: { params.seed.map(String.init) ?? "" },209 set: { params.seed = UInt64($0) }210 )211 )212 .textFieldStyle(.roundedBorder)213 .frame(width: 110)214 .multilineTextAlignment(.trailing)215 .font(ZyquoTheme.caption.monospacedDigit())216 }217 }218 }219220 @ViewBuilder221 private func slider(222 _ label: String, value: Binding<Double>, range: ClosedRange<Double>,223 format: String, help: String224 ) -> some View {225 VStack(alignment: .leading, spacing: 2) {226 HStack {227 Text(label)228 .font(ZyquoTheme.caption)229 .foregroundStyle(ZyquoTheme.textSecondary)230 Spacer()231 Text(String(format: format, value.wrappedValue))232 .font(ZyquoTheme.caption.monospacedDigit())233 .foregroundStyle(ZyquoTheme.textPrimary)234 }235 Slider(value: value, in: range)236 .controlSize(.mini)237 .tint(ZyquoTheme.accent)238 }239 .help(help)240 }241}242