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%
6.6 KB · 185 lines swift
Raw Blame History
1//2//  MessageRow.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// One transcript message: bubble, hover timestamp + actions, thinking12/// disclosure and the stats caption line for assistant messages.13struct MessageRow: View {14    let message: Message15    let conversation: Conversation16    @Environment(AppModel.self) private var app17    @State private var hovering = false18    @State private var editing = false19    @State private var editText = ""2021    var body: some View {22        HStack(alignment: .top, spacing: 0) {23            if message.role == .user { Spacer(minLength: ZyquoTheme.Spacing.xxl) }2425            VStack(alignment: message.role == .user ? .trailing : .leading, spacing: ZyquoTheme.Spacing.xxs) {26                bubble27                captionLine28            }29            .frame(30                maxWidth: ZyquoTheme.messageColumnMaxWidth * 0.86,31                alignment: message.role == .user ? .trailing : .leading32            )3334            if message.role == .assistant { Spacer(minLength: ZyquoTheme.Spacing.xxl) }35        }36        .onHover { hovering = $0 }37        .transition(.opacity.combined(with: .offset(y: 6)))38    }3940    @ViewBuilder41    private var bubble: some View {42        VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {43            if let thinking = message.thinking, !thinking.isEmpty {44                ThinkingDisclosure(thinking: thinking, isLive: false)45            }46            if editing {47                editor48            } else {49                MarkdownText(message.content)50                    .textSelection(.enabled)51            }52        }53        .padding(ZyquoTheme.Spacing.s)54        .background(bubbleBackground, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))55        .overlay(56            RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)57                .stroke(message.role == .assistant ? ZyquoTheme.border : .clear, lineWidth: ZyquoTheme.hairline)58        )59        .contextMenu { actions }60    }6162    private var bubbleBackground: Color {63        message.role == .user ? ZyquoTheme.accentSubtle : ZyquoTheme.surface64    }6566    private var editor: some View {67        VStack(alignment: .trailing, spacing: ZyquoTheme.Spacing.xs) {68            TextEditor(text: $editText)69                .font(ZyquoTheme.chatBody)70                .frame(minHeight: 60, maxHeight: 160)71                .scrollContentBackground(.hidden)72            HStack {73                Button("Cancel") { editing = false }74                    .buttonStyle(.bordered)75                Button("Resend") {76                    editing = false77                    app.chat.editAndResend(messageID: message.id, newText: editText, in: conversation)78                }79                .buttonStyle(.borderedProminent)80                .tint(ZyquoTheme.accent)81                .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)82            }83        }84        .frame(width: 420)85    }8687    @ViewBuilder88    private var actions: some View {89        Button("Copy") {90            NSPasteboard.general.clearContents()91            NSPasteboard.general.setString(message.content, forType: .string)92        }93        if message.role == .user {94            Button("Edit & Resend") {95                editText = message.content96                editing = true97            }98        }99        if message.role == .assistant {100            Button("Regenerate") {101                app.chat.regenerate(in: conversation)102            }103            .disabled(app.chat.isGenerating)104        }105        Button("Quote Reply") {106            let quoted = message.content107                .split(separator: "\n", omittingEmptySubsequences: false)108                .map { "> \($0)" }109                .joined(separator: "\n")110            NotificationCenter.default.post(name: .zyquoQuoteReply, object: quoted + "\n\n")111        }112        Divider()113        Button("Delete", role: .destructive) {114            var c = conversation115            c.messages.removeAll { $0.id == message.id }116            app.update(c, touch: false)117        }118    }119120    @ViewBuilder121    private var captionLine: some View {122        HStack(spacing: ZyquoTheme.Spacing.xs) {123            if message.role == .assistant, let stats = message.stats {124                Text(String(125                    format: "⚡ %.1f tok/s · %d tokens · %.1fs to first token",126                    stats.tokensPerSecond, stats.generationTokenCount, stats.timeToFirstToken127                ))128                .font(ZyquoTheme.caption)129                .foregroundStyle(ZyquoTheme.textTertiary)130            }131            if hovering {132                Text(message.date, format: .dateTime.hour().minute())133                    .font(ZyquoTheme.caption)134                    .foregroundStyle(ZyquoTheme.textTertiary)135                    .transition(.opacity)136            }137        }138        .animation(.easeOut(duration: 0.08), value: hovering)139    }140}141142/// Collapsible "Thinking…" section for reasoning models.143struct ThinkingDisclosure: View {144    let thinking: String145    let isLive: Bool146    @State private var expanded = false147148    var body: some View {149        VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) {150            Button {151                withAnimation(.snappy(duration: 0.2)) { expanded.toggle() }152            } label: {153                HStack(spacing: ZyquoTheme.Spacing.xxs) {154                    Image(systemName: "brain")155                        .font(.system(size: 10))156                    Text(isLive ? "Thinking…" : "Thought process")157                        .font(ZyquoTheme.caption.weight(.medium))158                    Image(systemName: "chevron.right")159                        .font(.system(size: 8, weight: .semibold))160                        .rotationEffect(.degrees(expanded ? 90 : 0))161                }162                .foregroundStyle(ZyquoTheme.textSecondary)163            }164            .buttonStyle(.plain)165166            if expanded || isLive {167                Text(thinking)168                    .font(ZyquoTheme.caption)169                    .foregroundStyle(ZyquoTheme.textSecondary)170                    .lineSpacing(3)171                    .textSelection(.enabled)172                    .padding(ZyquoTheme.Spacing.xs)173                    .frame(maxWidth: .infinity, alignment: .leading)174                    .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s))175                    .frame(maxHeight: isLive && !expanded ? 96 : .infinity)176                    .clipped()177            }178        }179    }180}181182extension Notification.Name {183    static let zyquoQuoteReply = Notification.Name("zyquoQuoteReply")184}185