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.3 KB · 242 lines swift
Raw Blame History
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