/ Jade / Views / MessageView.swift
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  }