1017 lines
39 KiB
Swift
1017 lines
39 KiB
Swift
//
|
|
// 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<AnyCancellable>()
|
|
|
|
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 { "<img>\($0.full.path)</img>" }.joined()
|
|
prompts.append([sender: imgStr])
|
|
}
|
|
|
|
if let audio = msg.audio, let url = audio.url {
|
|
prompts.append([sender: "<audio>\(url.path)</audio>"])
|
|
}
|
|
|
|
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("<img>\(key)</img>")
|
|
} else {
|
|
legacyImagePlaceholders.append("<img>\(processedUrl.path)</img>")
|
|
}
|
|
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("<video>\(preparedURL.path)</video>")
|
|
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 = "<audio>\(path.path)</audio>" + 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("<eop>") {
|
|
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|><think><\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
|
|
}
|
|
}
|