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%
4.9 KB · 122 lines swift
Raw Blame History
1//2//  TranscriptView.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Lazy transcript: user right in accentSubtle, assistant left on surface,12/// 16 pt rhythm, hover timestamps, streaming tail with caret, jump-to-bottom.13struct TranscriptView: View {14    let conversation: Conversation15    @Environment(AppModel.self) private var app16    @State private var atBottom = true1718    private var isStreamingHere: Bool {19        app.chat.isGenerating && app.chat.streamingConversationID == conversation.id20    }2122    var body: some View {23        ScrollViewReader { proxy in24            ZStack(alignment: .bottom) {25                ScrollView {26                    LazyVStack(spacing: ZyquoTheme.Spacing.m) {27                        ForEach(conversation.messages.filter { $0.role != .system }) { message in28                            MessageRow(message: message, conversation: conversation)29                        }30                        if isStreamingHere {31                            StreamingRow()32                        }33                        Color.clear34                            .frame(height: 1)35                            .id("bottom")36                            .onAppear { atBottom = true }37                            .onDisappear { atBottom = false }38                    }39                    .padding(.horizontal, ZyquoTheme.Spacing.xl)40                    .padding(.vertical, ZyquoTheme.Spacing.l)41                    .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth)42                    .frame(maxWidth: .infinity)43                }44                .onChange(of: app.chat.streamingText) {45                    if atBottom {46                        proxy.scrollTo("bottom", anchor: .bottom)47                    }48                }49                .onChange(of: conversation.messages.count) {50                    withAnimation(.easeOut(duration: 0.15)) {51                        proxy.scrollTo("bottom", anchor: .bottom)52                    }53                }54                .onChange(of: conversation.id) {55                    proxy.scrollTo("bottom", anchor: .bottom)56                }5758                if !atBottom {59                    Button {60                        withAnimation(.snappy(duration: 0.25)) {61                            proxy.scrollTo("bottom", anchor: .bottom)62                        }63                    } label: {64                        Image(systemName: "arrow.down")65                            .font(.system(size: 12, weight: .semibold))66                            .foregroundStyle(ZyquoTheme.textPrimary)67                            .padding(ZyquoTheme.Spacing.xs)68                            .background(ZyquoTheme.surface, in: Circle())69                            .overlay(Circle().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline))70                            .floatingShadow()71                    }72                    .buttonStyle(PressableButtonStyle())73                    .padding(.bottom, ZyquoTheme.Spacing.m)74                    .transition(.opacity.combined(with: .move(edge: .bottom)))75                }76            }77        }78    }79}8081/// The in-flight assistant message: live thinking section, streamed text,82/// blinking caret at the stream tail.83struct StreamingRow: View {84    @Environment(AppModel.self) private var app85    @State private var caretVisible = true8687    var body: some View {88        HStack(alignment: .top) {89            VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {90                if !app.chat.streamingThinking.isEmpty {91                    ThinkingDisclosure(92                        thinking: app.chat.streamingThinking,93                        isLive: app.chat.isThinking94                    )95                }96                if !app.chat.streamingText.isEmpty || !app.chat.isThinking {97                    HStack(alignment: .bottom, spacing: 2) {98                        MarkdownText(app.chat.streamingText)99                        Rectangle()100                            .fill(ZyquoTheme.accent)101                            .frame(width: 2, height: ZyquoTheme.chatFontSize + 2)102                            .opacity(caretVisible ? 1 : 0.15)103                            .onAppear {104                                withAnimation(.easeInOut(duration: 0.55).repeatForever()) {105                                    caretVisible = false106                                }107                            }108                    }109                }110            }111            .padding(ZyquoTheme.Spacing.s)112            .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))113            .overlay(114                RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)115                    .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)116            )117            .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth * 0.86, alignment: .leading)118            Spacer(minLength: ZyquoTheme.Spacing.xxl)119        }120    }121}122