MessageView.swift
1 // 2 // MessageView.swift 3 // MLXChatExample 4 // 5 // Created by İbrahim Çetin on 20.04.2025. 6 // 7 8 import AVKit 9 import SwiftUI 10 import MarkdownUI 11 import HighlightSwift 12 import LaTeXSwiftUI 13 14 #if os(iOS) 15 import UIKit 16 #elseif os(macOS) 17 import AppKit 18 #endif 19 20 /// A view that displays a single message in the chat interface. 21 /// Supports different message roles (user, assistant, system) and media attachments. 22 struct MessageView: View { 23 @ObservedObject var message: Message 24 var viewModel: ChatViewModel 25 @Binding var selectedTab: Int 26 27 init(_ message: Message, viewModel: ChatViewModel, selectedTab: Binding<Int>) { 28 self.message = message 29 self.viewModel = viewModel 30 self._selectedTab = selectedTab 31 } 32 33 var body: some View { 34 switch message.role { 35 case .user: 36 HStack { 37 Spacer() 38 VStack(alignment: .trailing, spacing: 8) { 39 if let firstImage = message.images.first { 40 AsyncImage(url: firstImage) { image in 41 image 42 .resizable() 43 .aspectRatio(contentMode: .fill) 44 } placeholder: { 45 ProgressView() 46 } 47 .frame(maxWidth: 250, maxHeight: 200) 48 .clipShape(.rect(cornerRadius: 12)) 49 } 50 51 if let firstVideo = message.videos.first { 52 VideoPlayer(player: AVPlayer(url: firstVideo)) 53 .frame(width: 250, height: 340) 54 .clipShape(.rect(cornerRadius: 12)) 55 } 56 57 Group { 58 if message.isComplete { 59 VStack(alignment: .leading, spacing: 4) { 60 let segments = parseMessageContent(message.content) 61 ForEach(segments) { segment in 62 renderSegment(segment) 63 } 64 } 65 } else { 66 Text(message.content) 67 .font(.body) 68 } 69 } 70 .padding(.vertical, 8) 71 .padding(.horizontal, 12) 72 .background(.tint, in: .rect(cornerRadius: 16)) 73 .textSelection(.enabled) 74 #if os(iOS) 75 .contextMenu { 76 Button(action: { 77 UIPasteboard.general.string = message.content 78 }) { 79 Label("Copy Message", systemImage: "doc.on.doc") 80 } 81 } 82 #elseif os(macOS) 83 .contextMenu { 84 Button(action: { 85 NSPasteboard.general.clearContents() 86 NSPasteboard.general.setString(message.content, forType: .string) 87 }) { 88 Label("Copy Message", systemImage: "doc.on.doc") 89 } 90 } 91 #endif 92 } 93 } 94 .gesture( 95 DragGesture(minimumDistance: 30) 96 .onEnded { value in 97 if value.translation.width < -50 { 98 viewModel.scratchpadText = message.content 99 selectedTab = 1 100 } 101 } 102 ) 103 104 case .assistant: 105 HStack { 106 Group { 107 if message.isComplete { 108 VStack(alignment: .leading, spacing: 4) { 109 let segments = parseMessageContent(message.content) 110 ForEach(segments) { segment in 111 renderSegment(segment) 112 } 113 } 114 .padding(12) 115 #if os(macOS) 116 .background(Color(.windowBackgroundColor)) 117 #else 118 .background(Color(UIColor.secondarySystemBackground)) 119 #endif 120 .cornerRadius(16) 121 } else { 122 Markdown(message.content) 123 .markdownTheme(.basic) 124 .font(.custom("Georgia", size: 16)) 125 .padding(12) 126 #if os(macOS) 127 .background(Color(.windowBackgroundColor)) 128 #else 129 .background(Color(UIColor.secondarySystemBackground)) 130 #endif 131 .cornerRadius(16) 132 } 133 } 134 .textSelection(.enabled) 135 #if os(iOS) 136 .contextMenu { 137 Button(action: { 138 UIPasteboard.general.string = message.content 139 }) { 140 Label("Copy Message", systemImage: "doc.on.doc") 141 } 142 } 143 #elseif os(macOS) 144 .contextMenu { 145 Button(action: { 146 NSPasteboard.general.clearContents() 147 NSPasteboard.general.setString(message.content, forType: .string) 148 }) { 149 Label("Copy Message", systemImage: "doc.on.doc") 150 } 151 } 152 #endif 153 154 Spacer() 155 } 156 .gesture( 157 DragGesture(minimumDistance: 30) 158 .onEnded { value in 159 if value.translation.width < -50 { 160 viewModel.scratchpadText = message.content 161 selectedTab = 1 162 } 163 } 164 ) 165 166 case .system: 167 Label { 168 Text(message.content) 169 .font(.headline) 170 .foregroundColor(.secondary) 171 } icon: { 172 Image("Brandmark") 173 .resizable() 174 .frame(width: 20, height: 20) 175 .clipShape(Circle()) 176 } 177 .frame(maxWidth: .infinity, alignment: .center) 178 } 179 } 180 181 @ViewBuilder 182 private func renderSegment(_ segment: MessageSegment) -> some View { 183 switch segment { 184 case .markdown(let text): 185 Markdown(text) 186 .markdownTheme(.basic) 187 .font(.custom("Georgia", size: 16)) 188 .padding(.bottom, 8) 189 #if os(macOS) 190 .background(Color(nsColor: .windowBackgroundColor)) 191 #else 192 .background(Color(UIColor.secondarySystemBackground)) 193 #endif 194 195 case .code(let code): 196 CodeText(code) 197 .padding(.vertical, 12) 198 199 case .latex(let tex): 200 LaTeX(#"$$\#(tex)$$"#) 201 #if os(iOS) 202 .font(UIFont(name: "Times New Roman", size: 18) ?? .systemFont(ofSize: 18)) 203 #elseif os(macOS) 204 .font(NSFont.systemFont(ofSize: 20)) 205 #endif 206 .padding() 207 .background(Color.gray.opacity(0.1)) 208 .cornerRadius(8) 209 } 210 } 211 } 212 213 #Preview { 214 MessageViewPreviewWrapper() 215 } 216 217 private struct MessageViewPreviewWrapper: View { 218 @State private var selectedTab = 0 219 private let vm = ChatViewModel(mlxService: MLXService()) 220 221 var body: some View { 222 VStack(spacing: 20) { 223 MessageView( 224 .system("You are a helpful assistant."), 225 viewModel: vm, 226 selectedTab: $selectedTab 227 ) 228 229 MessageView( 230 .user( 231 "Here's a photo", 232 images: [URL(string: "https://picsum.photos/200")!] 233 ), 234 viewModel: vm, 235 selectedTab: $selectedTab 236 ) 237 238 MessageView( 239 .assistant("I see your photo!"), 240 viewModel: vm, 241 selectedTab: $selectedTab 242 ) 243 } 244 .padding() 245 } 246 }