Files
2026-07-13 13:33:03 +08:00

246 lines
9.3 KiB
Swift

//
// LLMChatView.swift
// MNNLLMiOS
//
// Created by 游薪渝(揽清) on 2025/9/29.
//
import AVFoundation
import ExyteChat
import ExyteMediaPicker
import Foundation
import SwiftUI
struct LLMChatView: View {
// MARK: - State Properties
/// Controls the visibility of the settings sheet
@State private var showSettings = false
/// Controls the visibility of the batch file test sheet
@State private var showBatchFileTest = false
/// View model for LLM chat functionality
@StateObject private var viewModel: LLMChatViewModel
/// Environment variable for presentation mode
@Environment(\.presentationMode) private var presentationMode
// MARK: - Properties
/// Title of the chat interface
private let title: String
/// Path to the model file
private let modelPath: String
/// Audio recorder settings configuration
private let recorderSettings = RecorderSettings(audioFormatID: kAudioFormatLinearPCM,
sampleRate: 44100, numberOfChannels: 2,
linearPCMBitDepth: 16)
private var supportsAudioInput: Bool {
if ModelUtils.isAudioModel(viewModel.modelInfo.modelName) {
return true
}
return viewModel.modelInfo.tags.contains { $0.localizedCaseInsensitiveContains("audio") }
}
private var supportsVisualInput: Bool {
if ModelUtils.isVisualModel(viewModel.modelInfo.modelName) {
return true
}
if viewModel.isSanaDiffusionModel {
return true
}
let tagMatches = viewModel.modelInfo.tags.contains { tag in
tag.localizedCaseInsensitiveContains("image") || tag.localizedCaseInsensitiveContains("video")
}
let categoryMatches = (viewModel.modelInfo.categories ?? []).contains { category in
category.localizedCaseInsensitiveContains("image") || category.localizedCaseInsensitiveContains("video")
}
return tagMatches || categoryMatches
}
private var supportsVideoInput: Bool {
guard supportsVisualInput else { return false }
let nameContainsVideo = viewModel.modelInfo.modelName.localizedCaseInsensitiveContains("video")
let tagsContainVideo = viewModel.modelInfo.tags.contains { $0.localizedCaseInsensitiveContains("video") }
let categoriesContainVideo = (viewModel.modelInfo.categories ?? []).contains { $0.localizedCaseInsensitiveContains("video") }
return nameContainsVideo || tagsContainVideo || categoriesContainVideo || ModelUtils.isOmni(viewModel.modelInfo.modelName)
}
private var resolvedAvailableInput: AvailableInputType {
if supportsAudioInput && supportsVisualInput {
return .full
} else if supportsAudioInput {
return .textAndAudio
} else if supportsVisualInput {
return .textAndMedia
} else {
return .textOnly
}
}
// MARK: - Initialization
/// Initializes the chat view with model information and optional history
/// - Parameters:
/// - modelInfo: Information about the model to use
/// - history: Optional chat history to restore
init(modelInfo: ModelInfo, history: ChatHistory? = nil) {
title = modelInfo.modelName
modelPath = modelInfo.localPath
let viewModel = LLMChatViewModel(modelInfo: modelInfo, history: history)
_viewModel = StateObject(wrappedValue: viewModel)
}
// MARK: - Body
var body: some View {
ZStack {
ChatView(messages: viewModel.messages, chatType: .conversation) { draft in
viewModel.sendToLLM(draft: draft)
}
.setStreamingMessageProvider(viewModel)
.setDefaultInputText($viewModel.defaultInputText)
.setAvailableInput(
resolvedAvailableInput
// viewModel.isSanaDiffusionModel ? .textAndMedia :
// self.title.lowercased().contains("omni") ? .full :
// self.title.lowercased().contains("vl") ? .textAndMedia :
// self.title.lowercased().contains("audio") ? .textAndAudio :
// (self.title.isEmpty ? .textOnly : .textOnly)
)
.messageUseMarkdown(true)
.setRecorderSettings(recorderSettings)
.setThinkingMode(
supportsThinkingMode: viewModel.supportsThinkingMode,
isEnabled: viewModel.isThinkingModeEnabled,
onToggle: {
viewModel.toggleThinkingMode()
}
)
.setMediaPickerSelectionParameters(
MediaPickerParameters(mediaType: supportsVideoInput ? .photoAndVideo : .photo,
selectionLimit: 1,
showFullscreenPreview: false)
)
.chatTheme(
ChatTheme(
colors: .init(
messageMyBG: .customBlue.opacity(0.2),
messageFriendBG: .clear
),
images: .init(
attach: Image(systemName: "photo"),
attachCamera: Image("attachCamera", bundle: .current)
)
)
)
.mediaPickerTheme(
main: .init(
text: .white,
albumSelectionBackground: .customPickerBg,
fullscreenPhotoBackground: .customPickerBg,
cameraBackground: .black,
cameraSelectionBackground: .black
),
selection: .init(
emptyTint: .white,
emptyBackground: .black.opacity(0.25),
selectedTint: .customBlue,
fullscreenTint: .white
)
)
.navigationBarTitle("")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden()
.disabled(viewModel.chatInputUnavilable)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Image("backArrow", bundle: .current)
}
}
ToolbarItem(placement: .principal) {
HStack {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.fontWeight(.semibold)
.font(.headline)
.foregroundColor(.black)
Text(viewModel.chatStatus)
.font(.footnote)
.foregroundColor(Color(hex: "AFB3B8"))
}
Spacer()
}
.padding(.leading, 10)
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 8) {
// Settings Button
Button(action: { showSettings.toggle() }) {
Image(systemName: "gear")
}
.sheet(isPresented: $showSettings) {
ModelSettingsView(showSettings: $showSettings, viewModel: viewModel)
}
// Three-dot menu with batch testing options
ChatMenuView(
showBatchFileTest: $showBatchFileTest
)
}
}
}
.onAppear {
viewModel.onStart()
setupBatchTestCallbacks()
}
.onDisappear(perform: viewModel.onStop)
.onReceive(NotificationCenter.default.publisher(for: .dismissKeyboard)) { _ in
// Hidden keyboard
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
// Batch File Test Sheet
.sheet(isPresented: $showBatchFileTest) {
BatchFileTestView(chatViewModel: viewModel)
}
// Loading overlay
if !viewModel.isModelLoaded {
Color.black.opacity(0.4)
.ignoresSafeArea()
.overlay(
VStack(spacing: 20) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.5)
Text(NSLocalizedString("Model is loading...", comment: ""))
.font(.system(size: 15, weight: .regular))
.foregroundColor(.white)
.font(.headline)
}
)
}
}
}
// MARK: - Private Methods
/// Setup callbacks for batch test functionality
private func setupBatchTestCallbacks() {
// Setup any additional callbacks if needed
}
}