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// 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