// // ChatHeaderView.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// 52 pt header: editable title · centered model chip · perf toggle, export, /// info popover (system prompt, params, context usage bar). struct ChatHeaderView: View { let conversation: Conversation @Environment(AppModel.self) private var app @State private var title = "" @State private var showInfo = false @State private var showPerf = true @State private var contextUsage: (used: Int, window: Int)? var body: some View { HStack(spacing: ZyquoTheme.Spacing.s) { // Editable title (left) TextField( "Title", text: $title, onCommit: { var c = conversation c.title = title.isEmpty ? conversation.title : title app.update(c, touch: false) } ) .textFieldStyle(.plain) .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.textPrimary) .frame(maxWidth: 220, alignment: .leading) .onAppear { title = conversation.title } .onChange(of: conversation.id) { title = conversation.title } .onChange(of: conversation.title) { title = conversation.title } Spacer() ModelChipView(conversation: conversation) Spacer() // Live perf while generating if showPerf, app.chat.isGenerating, app.chat.liveTokensPerSecond > 0 { Text(String(format: "%.1f tok/s", app.chat.liveTokensPerSecond)) .font(ZyquoTheme.caption.monospacedDigit()) .foregroundStyle(ZyquoTheme.accent) .transition(.opacity) } Button { showPerf.toggle() } label: { Image(systemName: "gauge.with.dots.needle.67percent") .foregroundStyle(showPerf ? ZyquoTheme.accent : ZyquoTheme.textSecondary) } .buttonStyle(.plain) .help("Show live tokens/sec while generating") Menu { Button("Export as Markdown…") { ExportService.exportMarkdown(conversation) } Button("Export as PDF…") { ExportService.exportPDF(conversation) } } label: { Image(systemName: "square.and.arrow.up") .foregroundStyle(ZyquoTheme.textSecondary) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .frame(width: 24) .help("Export conversation (⇧⌘E)") Button { Task { contextUsage = await app.engine.contextUsage } showInfo.toggle() } label: { Image(systemName: "info.circle") .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) .popover(isPresented: $showInfo, arrowEdge: .bottom) { ConversationInfoPopover(conversation: conversation, contextUsage: contextUsage) } .help("Conversation settings") } .padding(.horizontal, ZyquoTheme.Spacing.m) .frame(height: ZyquoTheme.chatHeaderHeight) .background(ZyquoTheme.background) .onReceive(NotificationCenter.default.publisher(for: .zyquoExportConversation)) { _ in ExportService.exportMarkdown(conversation) } } } /// Info popover: system prompt, per-conversation params, context usage bar. struct ConversationInfoPopover: View { let conversation: Conversation let contextUsage: (used: Int, window: Int)? @Environment(AppModel.self) private var app @State private var systemPrompt = "" var body: some View { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.m) { Text("Conversation") .font(ZyquoTheme.bodyEmphasis) VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) { Text("System prompt") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) TextEditor(text: $systemPrompt) .font(ZyquoTheme.body) .frame(height: 72) .scrollContentBackground(.hidden) .padding(ZyquoTheme.Spacing.xxs) .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) .onChange(of: systemPrompt) { var c = conversation c.systemPrompt = systemPrompt.isEmpty ? nil : systemPrompt app.update(c, touch: false) } } ParamsEditor( params: Binding( get: { app.selectedConversation?.params ?? conversation.params }, set: { newParams in var c = app.selectedConversation ?? conversation c.params = newParams app.update(c, touch: false) } ) ) if let usage = contextUsage, usage.window > 0 { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) { HStack { Text("Context") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) Spacer() Text("≈\(usage.used) / \(usage.window) tokens") .font(ZyquoTheme.caption.monospacedDigit()) .foregroundStyle(ZyquoTheme.textSecondary) } ProgressView(value: min(1, Double(usage.used) / Double(usage.window))) .tint(usage.used > usage.window * 3 / 4 ? ZyquoTheme.warning : ZyquoTheme.accent) } } } .padding(ZyquoTheme.Spacing.m) .frame(width: 340) .onAppear { systemPrompt = conversation.systemPrompt ?? "" } } } /// Inline editors for the generation parameters, with plain-language help. struct ParamsEditor: View { @Binding var params: GenerationParams var body: some View { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { slider( "Temperature", value: Binding( get: { Double(params.temperature) }, set: { params.temperature = Float($0) }), range: 0...2, format: "%.2f", help: "Higher = more creative, lower = more focused") slider( "Top-p", value: Binding( get: { Double(params.topP) }, set: { params.topP = Float($0) }), range: 0.05...1, format: "%.2f", help: "Nucleus sampling probability mass") slider( "Repetition penalty", value: Binding( get: { Double(params.repetitionPenalty ?? 1.0) }, set: { params.repetitionPenalty = $0 <= 1.001 ? nil : Float($0) }), range: 1...1.5, format: "%.2f", help: "Discourages repeating recent tokens (1 = off)") HStack { Text("Max tokens") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) Spacer() TextField( "∞", text: Binding( get: { params.maxTokens.map(String.init) ?? "" }, set: { params.maxTokens = Int($0).flatMap { $0 > 0 ? $0 : nil } } ) ) .textFieldStyle(.roundedBorder) .frame(width: 80) .multilineTextAlignment(.trailing) .font(ZyquoTheme.caption.monospacedDigit()) } HStack { Text("Seed") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) Spacer() TextField( "random", text: Binding( get: { params.seed.map(String.init) ?? "" }, set: { params.seed = UInt64($0) } ) ) .textFieldStyle(.roundedBorder) .frame(width: 110) .multilineTextAlignment(.trailing) .font(ZyquoTheme.caption.monospacedDigit()) } } } @ViewBuilder private func slider( _ label: String, value: Binding, range: ClosedRange, format: String, help: String ) -> some View { VStack(alignment: .leading, spacing: 2) { HStack { Text(label) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) Spacer() Text(String(format: format, value.wrappedValue)) .font(ZyquoTheme.caption.monospacedDigit()) .foregroundStyle(ZyquoTheme.textPrimary) } Slider(value: value, in: range) .controlSize(.mini) .tint(ZyquoTheme.accent) } .help(help) } }