/ Jade / ViewModels / ChatViewModel.swift
ChatViewModel.swift
  1  //
  2  //  ChatViewModel.swift
  3  //  MLXChatExample
  4  //
  5  //  Created by İbrahim Çetin on 20.04.2025.
  6  //
  7  
  8  import Foundation
  9  import MLXLMCommon
 10  import UniformTypeIdentifiers
 11  import Observation
 12  
 13  /// ViewModel that manages the chat interface and coordinates with MLXService for text generation.
 14  /// Handles user input, message history, media attachments, and generation state.
 15  @Observable
 16  @MainActor
 17  class ChatViewModel {
 18      /// Service responsible for ML model operations
 19      private let mlxService: MLXService
 20  
 21      init(mlxService: MLXService) {
 22          self.mlxService = mlxService
 23      }
 24  
 25      /// Current user input text
 26      var prompt: String = ""
 27  
 28      var messages: [Message] = [
 29          .system("You are Jade, created by Awful Security.")
 30      ]
 31  
 32      /// Currently selected language model for generation
 33      var selectedModel: LMModel = MLXService.availableModels.first!
 34  
 35      /// Manages image and video attachments for the current message
 36      var mediaSelection = MediaSelection()
 37  
 38      /// Indicates if text generation is in progress
 39      var isGenerating = false
 40  
 41      /// Current generation task, used for cancellation
 42      private var generateTask: Task<Void, any Error>?
 43  
 44      /// Stores performance metrics from the current generation
 45      private var generateCompletionInfo: GenerateCompletionInfo?
 46  
 47      /// Current generation speed in tokens per second
 48      var tokensPerSecond: Double {
 49          generateCompletionInfo?.tokensPerSecond ?? 0
 50      }
 51  
 52      /// Progress of the current model download, if any
 53      var modelDownloadProgress: Progress? {
 54          mlxService.modelDownloadProgress
 55      }
 56  
 57      /// Most recent error message, if any
 58      var errorMessage: String?
 59      
 60      var scratchpadText: String = ""
 61  
 62      /// Generates response for the current prompt and media attachments
 63      func generate() async {
 64          // Cancel any existing generation task
 65          if let existingTask = generateTask {
 66              existingTask.cancel()
 67              generateTask = nil
 68          }
 69  
 70          isGenerating = true
 71  
 72          // Add user message with any media attachments
 73          messages.append(.user(prompt.trimmingCharacters(in: .whitespacesAndNewlines), images: mediaSelection.images, videos: mediaSelection.videos))
 74          // Add empty assistant message that will be filled during generation
 75          messages.append(.assistant(""))
 76  
 77          // Clear the input after sending
 78          clear(.prompt)
 79  
 80          generateTask = Task {
 81              // Process generation chunks and update UI
 82              for await generation in try await mlxService.generate(
 83                  messages: messages, model: selectedModel)
 84              {
 85                  switch generation {
 86                  case .chunk(let chunk):
 87                      // Append new text to the current assistant message
 88                      if let assistantMessage = messages.last {
 89                          assistantMessage.content += chunk
 90                      }
 91                  case .info(let info):
 92                      // Update performance metrics
 93                      generateCompletionInfo = info
 94                  }
 95              }
 96          }
 97  
 98          do {
 99              // Handle task completion and cancellation
100              try await withTaskCancellationHandler {
101                  try await generateTask?.value
102                  messages.last?.isComplete = true
103              } onCancel: {
104                  Task { @MainActor in
105                      generateTask?.cancel()
106  
107                      // Mark message as cancelled
108                      if let assistantMessage = messages.last {
109                          assistantMessage.content += "\n[Cancelled]"
110                      }
111                  }
112              }
113          } catch {
114              errorMessage = error.localizedDescription
115          }
116  
117          isGenerating = false
118          generateTask = nil
119      }
120  
121      /// Processes and adds media attachments to the current message
122      func addMedia(_ result: Result<URL, any Error>) {
123          do {
124              let url = try result.get()
125  
126              // Determine media type and add to appropriate collection
127              if let mediaType = UTType(filenameExtension: url.pathExtension) {
128                  if mediaType.conforms(to: .image) {
129                      mediaSelection.images = [url]
130                  } else if mediaType.conforms(to: .movie) {
131                      mediaSelection.videos = [url]
132                  }
133              }
134          } catch {
135              errorMessage = "Failed to load media item.\n\nError: \(error)"
136          }
137      }
138  
139      /// Clears various aspects of the chat state based on provided options
140      func clear(_ options: ClearOption) {
141          if options.contains(.prompt) {
142              prompt = ""
143              mediaSelection = .init()
144          }
145  
146          if options.contains(.chat) {
147              messages = []
148              generateTask?.cancel()
149          }
150  
151          if options.contains(.meta) {
152              generateCompletionInfo = nil
153          }
154  
155          errorMessage = nil
156      }
157  }
158  
159  /// Manages the state of media attachments in the chat
160  @Observable
161  class MediaSelection {
162      /// Controls visibility of media selection UI
163      var isShowing = false
164  
165      /// Currently selected image URLs
166      var images: [URL] = []
167  
168      /// Currently selected video URLs
169      var videos: [URL] = []
170  
171      /// Whether any media is currently selected
172      var isEmpty: Bool {
173          images.isEmpty && videos.isEmpty
174      }
175  }
176  
177  /// Options for clearing different aspects of the chat state
178  struct ClearOption: RawRepresentable, OptionSet {
179      let rawValue: Int
180  
181      /// Clears current prompt and media selection
182      static let prompt = ClearOption(rawValue: 1 << 0)
183      /// Clears chat history and cancels generation
184      static let chat = ClearOption(rawValue: 1 << 1)
185      /// Clears generation metadata
186      static let meta = ClearOption(rawValue: 1 << 2)
187  }