// // LLMChatViewModel.swift // MNNLLMiOS // Created by 游薪渝(揽清) on 2025/9/29. // import AVFoundation import Combine import SwiftUI import UIKit import ExyteChat final class LLMChatViewModel: ObservableObject, StreamingMessageProvider { private var llm: LLMInferenceEngineWrapper? private var diffusion: DiffusionSession? private var sanaDiffusion: SanaDiffusionSession? private let llmState = LLMState() private var audioPlaybackManager: AudioPlaybackManager? @Published var messages: [Message] = [] @Published var isModelLoaded = false @Published var isProcessing: Bool = false @Published var currentStreamingMessageId: String? = nil @Published var streamingStates: [String: StreamingMessageStateManager] = [:] @Published var useMmap: Bool = false @Published var useMultimodalPromptAPI: Bool = true // MARK: - Think Mode Properties @Published var isThinkingModeEnabled: Bool = true @Published var supportsThinkingMode: Bool = false // MARK: - Sana Diffusion Default Prompt /// Default prompt for Sana Diffusion Ghibli style transfer static let sanaDiffusionDefaultPrompt = "Convert to a Ghibli-style illustration: soft contrast, warm tones, slight linework, keep the scene consistent." /// Default input text for the chat input field (used for Sana Diffusion default prompt) @Published var defaultInputText: String = "" var chatInputUnavilable: Bool { if isModelLoaded == false || isProcessing == true { return true } return false } var chatStatus: String { if isModelLoaded { if isProcessing { "Processing..." } else { "Ready" } } else { "Model Loading..." } } var chatCover: URL? { interactor.otherSenders.count == 1 ? interactor.otherSenders.first?.avatar : nil } private let interactor: LLMChatInteractor private var subscriptions = Set() var modelInfo: ModelInfo var history: ChatHistory? private var historyId: String let modelConfigManager: ModelConfigManager var isDiffusionModel: Bool { return modelInfo.modelName.lowercased().contains("stable-diffusion") } var isSanaDiffusionModel: Bool { return ModelUtils.isSanaDiffusionModel(modelInfo.modelName) } var isAnyDiffusionModel: Bool { return isDiffusionModel || isSanaDiffusionModel } init(modelInfo: ModelInfo, history: ChatHistory? = nil) { self.modelInfo = modelInfo self.history = history historyId = history?.id ?? UUID().uuidString let messages = self.history?.messages interactor = LLMChatInteractor(modelInfo: modelInfo, historyMessages: messages) modelConfigManager = ModelConfigManager(modelPath: modelInfo.localPath) useMmap = modelConfigManager.readUseMmap() useMultimodalPromptAPI = modelConfigManager.readUseMultimodalPromptAPI() // Check if model supports thinking mode supportsThinkingMode = ModelUtils.isSupportThinkingSwitch(modelInfo.tags, modelName: modelInfo.modelName) // Listen for streaming animation completion notifications NotificationCenter.default.addObserver( self, selector: #selector(onStreamingAnimationComplete(_:)), name: NSNotification.Name("StreamingAnimationCompleted"), object: nil ) } deinit { // Cancel ongoing inference llm?.cancelInference() llm = nil isProcessing = false diffusion = nil // Stop audio playback audioPlaybackManager?.stop() audioPlaybackManager = nil sanaDiffusion = nil // Clean up streaming states clearAllStreamingStates() // Remove notification observers NotificationCenter.default.removeObserver(self) } // MARK: - Think Mode Methods /// Toggle thinking mode on/off func toggleThinkingMode() { guard supportsThinkingMode else { return } isThinkingModeEnabled.toggle() configureThinkingMode() print("Think mode toggled to: \(isThinkingModeEnabled)") } func setupLLM(modelPath: String) { Task { @MainActor in self.isModelLoaded = false do { try await self.send(draft: DraftMessage( text: NSLocalizedString("ModelLoadingText", comment: ""), thinkText: "", useMarkdown: false, medias: [], recording: nil, replyMessage: nil, createdAt: Date() ), userType: .system) } catch { print("Error sending model loading status: \(error)") } } if isSanaDiffusionModel { // Load Sana Diffusion model for style transfer sanaDiffusion = SanaDiffusionSession(modelPath: modelPath, completion: { [weak self] success in Task { @MainActor in print("Sana Diffusion Model loaded: \(success)") self?.sendModelLoadStatus(success: success) self?.isModelLoaded = success // Set default prompt for Sana Diffusion if success { self?.defaultInputText = LLMChatViewModel.sanaDiffusionDefaultPrompt } } }) } else if isDiffusionModel { diffusion = DiffusionSession(modelPath: modelPath, completion: { [weak self] success in Task { @MainActor in print("Diffusion Model \(success)") self?.sendModelLoadStatus(success: success) self?.isModelLoaded = success } }) } else { llm = LLMInferenceEngineWrapper(modelPath: modelPath) { [weak self] success in Task { @MainActor in self?.sendModelLoadStatus(success: success) self?.processHistoryMessages() self?.isModelLoaded = success // Configure thinking mode after model is loaded if success { self?.setModelConfig() self?.configureThinkingMode() self?.setupAudioOutput() } } } } } /// Configure thinking mode after model loading private func configureThinkingMode() { guard let llm = llm, supportsThinkingMode else { return } if supportsThinkingMode { llm.setThinkingModeEnabled(isThinkingModeEnabled) } interactor.isThinkingModeEnabled = isThinkingModeEnabled print("Thinking mode configured: \(isThinkingModeEnabled)") } private func sendModelLoadStatus(success: Bool) { let modelLoadSuccessText = NSLocalizedString("ModelLoadingSuccessText", comment: "") let modelLoadFailText = NSLocalizedString("ModelLoadingFailText", comment: "") let loadResult = success ? modelLoadSuccessText : modelLoadFailText Task { do { try await send(draft: DraftMessage( text: loadResult, thinkText: "", useMarkdown: false, medias: [], recording: nil, replyMessage: nil, createdAt: Date() ), userType: .system) } catch { print("Error sending model load status: \(error)") } } } private func processHistoryMessages() { guard let history = history else { return } let historyPrompts = history.messages.flatMap { msg -> [[String: String]] in var prompts: [[String: String]] = [] let sender = msg.isUser ? "user" : "assistant" prompts.append([sender: msg.content]) if let images = msg.images { let imgStr = images.map { "\($0.full.path)" }.joined() prompts.append([sender: imgStr]) } if let audio = msg.audio, let url = audio.url { prompts.append([sender: ""]) } return prompts } let nsArray = historyPrompts as [[AnyHashable: Any]] llm?.addPrompts(from: nsArray) } /// Sends a draft message to the LLM for processing /// - Parameter draft: The draft message to send func sendToLLM(draft: DraftMessage) { NotificationCenter.default.post(name: .dismissKeyboard, object: nil) Task { do { // Update Message UI and wait for completion try await send(draft: draft, userType: .user) recordModelUsage() if isModelLoaded { if isSanaDiffusionModel { getSanaDiffusionResponse(draft: draft) } else if isDiffusionModel { getDiffusionResponse(draft: draft) } else { getLLMRespsonse(draft: draft) } } } catch { print("Error sending message to LLM: \(error)") // Send error message to user Task { do { try await send(draft: DraftMessage( text: "Error: Failed to send message. Please try again.", thinkText: "", useMarkdown: false, medias: [], recording: nil, replyMessage: nil, createdAt: Date() ), userType: .system) } catch { print("Failed to send error message: \(error)") } } } } } /// Sends a draft message to the chat interactor asynchronously /// - Parameters: /// - draft: The draft message to send /// - userType: The type of user sending the message /// - Throws: Any error that occurs during message sending func send(draft: DraftMessage, userType: UserType) async throws { try await interactor.send(draftMessage: draft, userType: userType) } func getDiffusionResponse(draft: DraftMessage) { Task { let tempImagePath = FileOperationManager.shared.generateTempImagePath().path var lastProcess: Int32 = 0 try await self.send(draft: DraftMessage(text: "Start Generating Image...", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .assistant) // Get user-configured iteration count and seed value let userIterations = self.modelConfigManager.readIterations() let userSeed = self.modelConfigManager.readSeed() diffusion?.run(withPrompt: draft.text, imagePath: tempImagePath, iterations: Int32(userIterations), seed: Int32(userSeed), progressCallback: { [weak self] progress in guard let self = self else { return } if progress == 100 { Task { do { try await self.send(draft: DraftMessage(text: "Image generated successfully!", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .system) } catch { print("Error sending image generation success message: \(error)") } } self.interactor.sendImage(imageURL: URL(fileURLWithPath: tempImagePath)) } else if (progress - lastProcess) > 20 { lastProcess = progress Task { do { try await self.send(draft: DraftMessage(text: "Generating Image \(progress)%", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .system) } catch { print("Error sending image generation progress message: \(error)") } } } }) } } // MARK: - Sana Diffusion Style Transfer func getSanaDiffusionResponse(draft: DraftMessage) { Task { // 1. Check if we have an input image guard !draft.medias.isEmpty else { try? await self.send( draft: DraftMessage( text: NSLocalizedString("Please select an image for style transfer.", comment: ""), thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date() ), userType: .system ) return } // 2. Get the first image from medias var inputImagePath: String? for media in draft.medias { guard media.type == .image, let url = await media.getURL() else { continue } let fileName = url.lastPathComponent if let processedUrl = FileOperationManager.shared.processImageFile(from: url, fileName: fileName) { inputImagePath = processedUrl.path break } } guard let inputPath = inputImagePath else { try? await self.send( draft: DraftMessage( text: NSLocalizedString("Unsupported image format. Please use JPG/JPEG images for style transfer.", comment: ""), thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date() ), userType: .system ) return } // 3. Prepare output path let outputPath = FileOperationManager.shared.generateTempImagePath().path // 4. Get prompt (use default if empty) let prompt = draft.text.isEmpty ? LLMChatViewModel.sanaDiffusionDefaultPrompt : draft.text // 5. Get user-configured iteration count and seed value let userIterations = self.modelConfigManager.readIterations() let userSeed = self.modelConfigManager.readSeed() // 6. Show initial status (one system message; we will update it in place for progress) let initialStage = NSLocalizedString("Starting style transfer...", comment: "") try? await self.send( draft: DraftMessage( text: "\(initialStage) (0%)", thinkText: "", useMarkdown: false, medias: [], recording: nil, replyMessage: nil, createdAt: Date() ), userType: .system ) await MainActor.run { self.isProcessing = true } var lastProgress: Int32 = 0 // 7. Run style transfer sanaDiffusion?.runStyleTransfer( withInputImage: inputPath, prompt: prompt, outputPath: outputPath, iterations: Int32(userIterations), seed: Int32(userSeed), progressCallback: { [weak self] progress, _ in guard let self = self else { return } if progress >= 100 || progress - lastProgress >= 5 { lastProgress = progress let stageText: String if progress <= 10 { stageText = NSLocalizedString("Processing prompt...", comment: "Sana diffusion progress stage") } else if progress >= 95 { stageText = NSLocalizedString("Generating image...", comment: "Sana diffusion progress stage") } else { stageText = NSLocalizedString("Running diffusion...", comment: "Sana diffusion progress stage") } self.interactor.updateLastMessage(text: "\(stageText) (\(progress)%)") } }, completion: { [weak self] success, error, totalTimeMs in guard let self = self else { return } Task { @MainActor in self.isProcessing = false if success { let completionText = NSLocalizedString("Style transfer completed!", comment: "") self.interactor.updateLastMessage(text: completionText) // Send total time as a separate message after the image let totalTimeSec = totalTimeMs / 1000.0 let timeText = String(format: "%.1f", totalTimeSec) let timeMessage = NSLocalizedString("Total time:", comment: "Sana diffusion total time label") + " \(timeText)s" do { try await self.send(draft: DraftMessage(text: timeMessage, thinkText: "", useMarkdown: false, medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .system) } catch { print("Error sending time message: \(error)") } self.interactor.sendImage(imageURL: URL(fileURLWithPath: outputPath)) } else { let errorMessage = error ?? NSLocalizedString("Style transfer failed.", comment: "") self.interactor.updateLastMessage(text: errorMessage) } } } ) } } func getLLMRespsonse(draft: DraftMessage) { Task { await llmState.setProcessing(true) var content = draft.text let medias = draft.medias var multimodalImagePlaceholders: [String] = [] var legacyImagePlaceholders: [String] = [] var videoPlaceholders: [String] = [] var imageDictionary: [String: UIImage] = [:] var missingAttachments: [String] = [] var hasVideoInput = false let shouldUseMultimodalAPI = self.useMultimodalPromptAPI for (index, media) in medias.enumerated() { switch media.type { case .image: guard let url = await media.getURL() else { continue } let fileName = url.lastPathComponent guard let processedUrl = FileOperationManager.shared.processImageFile(from: url, fileName: fileName), FileOperationManager.shared.fileExists(at: processedUrl) else { missingAttachments.append("图片 \(fileName) 无法读取,已跳过。") continue } if shouldUseMultimodalAPI { let key = "img_\(index)" guard let image = UIImage(contentsOfFile: processedUrl.path) else { missingAttachments.append("图片 \(fileName) 转换失败,已跳过。") continue } imageDictionary[key] = image multimodalImagePlaceholders.append("\(key)") } else { legacyImagePlaceholders.append("\(processedUrl.path)") } case .video: guard let url = await media.getURL() else { continue } let fileName = url.lastPathComponent guard let preparedURL = FileOperationManager.shared.prepareVideoFileURL(from: url, fileName: fileName) else { missingAttachments.append("视频 \(fileName) 复制失败,已跳过。") continue } guard FileOperationManager.shared.fileExists(at: preparedURL) else { missingAttachments.append("视频 \(fileName) 文件不存在或已被移除。") continue } videoPlaceholders.append("") hasVideoInput = true default: continue } } let selectedImagePlaceholders = shouldUseMultimodalAPI ? multimodalImagePlaceholders : legacyImagePlaceholders if !selectedImagePlaceholders.isEmpty || !videoPlaceholders.isEmpty { let mediaPrefix = (selectedImagePlaceholders + videoPlaceholders).joined() content = mediaPrefix + content } if let audio = draft.recording, let path = audio.url { if FileOperationManager.shared.fileExists(at: path) { content = "" + content } else { missingAttachments.append("音频文件已丢失,未能发送。") } } if !missingAttachments.isEmpty { let warningDraft = DraftMessage( text: missingAttachments.joined(separator: "\n"), thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date() ) do { try await self.send(draft: warningDraft, userType: .system) } catch { print("Error sending missing attachment warning: \(error)") } } let hasImageInput = shouldUseMultimodalAPI ? !imageDictionary.isEmpty : !legacyImagePlaceholders.isEmpty let hasAudioInput = draft.recording != nil && FileOperationManager.shared.fileExists(at: draft.recording?.url) let hasVisualInput = hasImageInput || hasVideoInput let hasTextInput = !draft.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if !hasTextInput && (hasVisualInput || hasAudioInput) { let defaultPrompt = modelConfigManager.readDefaultMultimodalPrompt() if !defaultPrompt.isEmpty { content = defaultPrompt + "\n" + content } } if !hasVisualInput && !hasAudioInput && !hasTextInput { await llmState.setProcessing(false) let warningText = NSLocalizedString( "video.frameExtractionFailed", comment: "Warning shown when a pure video input cannot provide frames." ) let warningDraft = DraftMessage( text: warningText, thinkText: "", useMarkdown: false, medias: [], recording: nil, replyMessage: nil, createdAt: Date() ) do { try await self.send(draft: warningDraft, userType: .system) } catch { print("Error sending warning message: \(error)") } return } // First, send the empty message asynchronously let emptyMessage = DraftMessage( text: "", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date() ) do { try await self.send(draft: emptyMessage, userType: .assistant) } catch { print("Error sending empty message: \(error)") await llmState.setProcessing(false) return } // Then update UI state on main actor await MainActor.run { self.isProcessing = true if let lastMessage = self.messages.last { self.currentStreamingMessageId = lastMessage.id // Create and start state manager let stateManager = StreamingMessageStateManager(messageId: lastMessage.id) self.streamingStates[lastMessage.id] = stateManager stateManager.startStreaming() } } let convertedContent = self.convertDeepSeekMutliChat(content: content) let outputHandler: (String) -> Void = { [weak self] output in guard let self = self else { return } if output.contains("") { Task { await UIUpdateOptimizer.shared.forceFlush { [weak self] finalOutput in guard let self = self else { return } if !finalOutput.isEmpty { Task { do { try await self.send(draft: DraftMessage( text: finalOutput, thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date() ), userType: .assistant) } catch { print("Error sending final output message: \(error)") } } } } await MainActor.run { // Mark model output as complete if let messageId = self.currentStreamingMessageId, let stateManager = self.streamingStates[messageId] { stateManager.markOutputComplete() } // currentStreamingMessageId will be cleared when animation completes via callback DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post(name: .dismissKeyboard, object: nil) } } await self.llmState.setProcessing(false) } return } Task { await UIUpdateOptimizer.shared.addUpdate(output) { [weak self] output in guard let self = self else { return } Task { do { try await self.send(draft: DraftMessage( text: output, thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date() ), userType: .assistant) } catch { print("Error sending streaming message: \(error)") } } } } } if shouldUseMultimodalAPI { await llmState.processMultimodalContent( convertedContent, images: imageDictionary, llm: self.llm, showPerformance: true, completion: outputHandler ) } else { await llmState.processContent( convertedContent, llm: self.llm, showPerformance: true, completion: outputHandler ) } } } /// Retrieves batch LLM responses for the provided prompts. /// /// This method forwards the prompts to the LLM state, which performs batch processing /// using the underlying inference engine wrapper. /// - Parameters: /// - prompts: An array of prompt strings to process in batch. /// - completion: A closure invoked with the list of response strings. func getBatchLLMResponse(prompts: [String], completion: @escaping ([String]) -> Void) { Task { [weak self] in guard let self = self else { return } await self.llmState.processBatchTestContent(prompts, llm: self.llm) { responses in completion(responses) } } } func setModelConfig() { if let configStr = modelConfigManager.readConfigAsJSONString(), let llm = llm { llm.setConfigWithJSONString(configStr) llm.setVideoMaxFrames(modelConfigManager.readVideoMaxFrames()) } } func updateVideoMaxFrames(_ value: Int) { modelConfigManager.saveVideoMaxFrames(value) llm?.setVideoMaxFrames(value) } func updateDefaultMultimodalPrompt(_ prompt: String) { modelConfigManager.saveDefaultMultimodalPrompt(prompt) } func updateEnableAudioOutput(_ enable: Bool) { print("[AudioViewModel] updateEnableAudioOutput: \(enable)") modelConfigManager.saveEnableAudioOutput(enable) llm?.setEnableAudioOutput(enable) } func updateTalkerSpeaker(_ speaker: String) { print("[AudioViewModel] updateTalkerSpeaker: \(speaker)") modelConfigManager.saveTalkerSpeaker(speaker) llm?.setTalkerSpeaker(speaker) } private func setupAudioOutput() { print("[AudioViewModel] setupAudioOutput called for model: \(modelInfo.modelName)") // Only setup audio for Omni models guard ModelUtils.supportAudioOutput(modelInfo.modelName) else { print("[AudioViewModel] Model does not support audio output, skipping setup") return } print("[AudioViewModel] Model supports audio output, initializing...") // Initialize audio playback manager if audioPlaybackManager == nil { print("[AudioViewModel] Creating AudioPlaybackManager") audioPlaybackManager = AudioPlaybackManager() audioPlaybackManager?.start() } else { print("[AudioViewModel] AudioPlaybackManager already exists") } // Configure audio output settings let enableAudio = modelConfigManager.readEnableAudioOutput() let talkerSpeaker = modelConfigManager.readTalkerSpeaker() print("[AudioViewModel] Configuring audio: enable=\(enableAudio), speaker=\(talkerSpeaker)") llm?.setEnableAudioOutput(enableAudio) llm?.setTalkerSpeaker(talkerSpeaker) // Set up audio waveform callback var audioChunkCount = 0 var audioLastSeen = false print("[AudioViewModel] Setting up audio waveform callback") llm?.setAudioWaveformCallback { [weak self] data, size, isLastChunk in guard let self = self else { print("[AudioViewModel] Callback: self is nil, returning") return false } audioChunkCount += 1 audioLastSeen = isLastChunk print("[AudioViewModel] chunk #\(audioChunkCount), size=\(size), isLastChunk=\(isLastChunk)") if isLastChunk { print("[AudioViewModel] tail received at #\(audioChunkCount)") } print("[AudioViewModel] Audio waveform callback: size=\(size), isLastChunk=\(isLastChunk)") // Convert C array to Swift array let floatArray = Array(UnsafeBufferPointer(start: data, count: Int(size))) // Check for NaN or invalid values and filter them let validArray = floatArray.map { value -> Float in if value.isNaN || value.isInfinite { return 0.0 } // Clamp to valid audio range [-1.0, 1.0] return max(-1.0, min(1.0, value)) } // Check if we have any non-zero valid data let hasValidData = validArray.contains { abs($0) > 0.0001 } if !hasValidData && !isLastChunk { print("[AudioViewModel] Warning: Audio chunk contains only zeros/NaN, skipping playback (size=\(size))") // Don't skip if it's the last chunk, as it might be silence return false } // Log data statistics for debugging if size > 0 { let maxVal = validArray.max() ?? 0 let minVal = validArray.min() ?? 0 let avgVal = validArray.reduce(0, +) / Float(validArray.count) print("[AudioViewModel] Audio data stats: min=\(minVal), max=\(maxVal), avg=\(avgVal), hasValid=\(hasValidData)") } // Play audio chunk DispatchQueue.main.async { self.audioPlaybackManager?.playChunk(data: validArray, isLastChunk: isLastChunk) } // Return false to continue, true to stop return false } print("[AudioViewModel] Audio output setup completed") } private func convertDeepSeekMutliChat(content: String) -> String { if modelInfo.modelName.lowercased().contains("deepseek") { var deepSeekContent = "<|begin_of_sentence|>" for message in messages { let senderTag: String switch message.user.id { case "1": senderTag = "<|User|>" case "2": senderTag = "<|Assistant|>" default: continue } deepSeekContent += "\(senderTag)\(message.text)" } deepSeekContent += "<|end_of_sentence|><\n>" print(deepSeekContent) return deepSeekContent } else { return content } } // MARK: - Public Methods for File Operations /// Cleans the model temporary folder using FileOperationManager func cleanModelTmpFolder() { FileOperationManager.shared.cleanModelTempFolder(modelPath: modelInfo.localPath) } func updateUseMultimodalPromptAPI(_ value: Bool) { useMultimodalPromptAPI = value modelConfigManager.saveUseMultimodalPromptAPI(value) } /// Reloads the currently selected model to apply config changes that require recreation. func reloadCurrentModel() { llm?.cancelInference() llm = nil setupLLM(modelPath: modelInfo.localPath) } func onStart() { interactor.messages .map { messages in messages.map { $0.toChatMessage() } } .sink { messages in self.messages = messages } .store(in: &subscriptions) interactor.connect() setupLLM(modelPath: modelInfo.localPath) recordModelUsage() } func onStop() { recordModelUsage() ChatHistoryManager.shared.saveChat( historyId: historyId, modelInfo: modelInfo, messages: messages ) subscriptions.removeAll() interactor.disconnect() llm?.cancelInference() llm = nil diffusion = nil sanaDiffusion = nil FileOperationManager.shared.cleanTempDirectories() if !useMmap { FileOperationManager.shared.cleanModelTempFolder(modelPath: modelInfo.localPath) } } func loadMoreMessage(before _: Message) { interactor.loadNextPage() .sink { _ in } .store(in: &subscriptions) } private func recordModelUsage() { ModelStorageManager.shared.updateLastUsed(for: modelInfo.modelName) NotificationCenter.default.post( name: .modelUsageUpdated, object: nil, userInfo: ["modelName": modelInfo.modelName] ) } /** * Called when streaming animation completes * Clears the currentStreamingMessageId to update UI state */ @objc func onStreamingAnimationComplete(_ notification: Notification) { guard let messageId = notification.userInfo?["messageId"] as? String, let stateManager = streamingStates[messageId] else { return } DispatchQueue.main.async { // Mark animation as complete stateManager.markAnimationComplete() // Clean up state if fully complete if stateManager.state.isFullyComplete { self.streamingStates.removeValue(forKey: messageId) if messageId == self.currentStreamingMessageId { self.isProcessing = false // MARK: isProcessing self.currentStreamingMessageId = nil } } } } // MARK: - Streaming State Helpers /// Get the streaming state of a message func getStreamingState(_ messageId: String) -> StreamingMessageState? { return streamingStates[messageId]?.state } /// Get the streaming state of a message (convenience method with default value) func getStreamingState(for messageId: String) -> StreamingMessageState { return streamingStates[messageId]?.state ?? .none } /// Check if a message is in streaming state func isMessageStreaming(_ messageId: String) -> Bool { return streamingStates[messageId]?.state.isStreaming ?? false } /// Force complete streaming message (for error handling or cleanup) func forceCompleteStreaming(for messageId: String) { if let stateManager = streamingStates[messageId] { stateManager.forceComplete() streamingStates.removeValue(forKey: messageId) if messageId == currentStreamingMessageId { currentStreamingMessageId = nil } } } /// Clear all streaming states (for reset or error recovery) func clearAllStreamingStates() { streamingStates.removeAll() currentStreamingMessageId = nil } }