246 lines
9.3 KiB
Swift
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
|
|
}
|
|
}
|