436 lines
20 KiB
Swift
436 lines
20 KiB
Swift
//
|
|
// ModelSettingsView.swift
|
|
// MNNLLMiOS
|
|
//
|
|
// Created by 游薪渝(揽清) on 2025/2/17.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ModelSettingsView: View {
|
|
@Binding var showSettings: Bool
|
|
@ObservedObject var viewModel: LLMChatViewModel
|
|
@State private var showAlert = false
|
|
@State private var showReloadAlert = false
|
|
@State private var iterations: Int = 20
|
|
@State private var seed: Int = 42
|
|
@State private var useRandomSeed: Bool = false
|
|
@State private var backendType: String = "cpu"
|
|
@State private var precision: String = "low"
|
|
@State private var threadNum: Int = 4
|
|
@State private var requiresReload = false
|
|
|
|
@State private var temperature: Double = 1.0
|
|
@State private var topK: Double = 40
|
|
@State private var topP: Double = 0.9
|
|
@State private var minP: Double = 0.1
|
|
|
|
@State private var tfsZ: Double = 1.0
|
|
@State private var typical: Double = 1.0
|
|
@State private var penalty: Double = 0.0
|
|
@State private var nGram: Double = 8.0
|
|
@State private var nGramFactor: Double = 1.0
|
|
|
|
@State private var selectedSampler: SamplerType = .temperature
|
|
@State private var selectedMixedSamplers: Set<String> = []
|
|
@State private var mixedSamplersOrder: [String] = []
|
|
|
|
@State private var penaltySampler: PenaltySamplerType = .greedy
|
|
@State private var videoMaxFrames: Int = 8
|
|
@State private var defaultMultimodalPrompt: String = ""
|
|
@State private var enableAudioOutput: Bool = false
|
|
@State private var talkerSpeaker: String = "default"
|
|
|
|
// iOS supported backends: CPU, Metal (GPU), NPU (CoreML/Neural Engine)
|
|
// Note: "npu" maps to MNN_FORWARD_NN which uses CoreML on iOS
|
|
private let backendOptions = ["cpu", "metal", "npu"]
|
|
private let precisionOptions = ["low", "high"]
|
|
private let talkerSpeakerOptions = ["Ethan", "Chelsie"]
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Form {
|
|
Section {
|
|
Toggle("Use mmap", isOn: $viewModel.useMmap)
|
|
.onChange(of: viewModel.useMmap) { newValue in
|
|
viewModel.modelConfigManager.updateUseMmap(newValue)
|
|
}
|
|
|
|
Button("Clear mmap Cache") {
|
|
viewModel.cleanModelTmpFolder()
|
|
showAlert = true
|
|
}
|
|
|
|
Picker("Backend", selection: $backendType) {
|
|
ForEach(backendOptions, id: \.self) { backend in
|
|
Text(backend.uppercased()).tag(backend)
|
|
}
|
|
}
|
|
.onChange(of: backendType) { _, newValue in
|
|
requiresReload = true
|
|
viewModel.modelConfigManager.updateBackendType(newValue)
|
|
}
|
|
|
|
Picker("Precision", selection: $precision) {
|
|
ForEach(precisionOptions, id: \.self) { option in
|
|
Text(option.capitalized).tag(option)
|
|
}
|
|
}
|
|
.onChange(of: precision) { _, newValue in
|
|
requiresReload = true
|
|
viewModel.modelConfigManager.updatePrecision(newValue)
|
|
}
|
|
|
|
Stepper(value: $threadNum, in: 1 ... viewModel.modelConfigManager.maxThreads) {
|
|
HStack {
|
|
Text("Threads")
|
|
Spacer()
|
|
Text("\(threadNum)")
|
|
}
|
|
}
|
|
.onChange(of: threadNum) { _, newValue in
|
|
requiresReload = true
|
|
viewModel.modelConfigManager.updateThreadNum(newValue)
|
|
}
|
|
} header: {
|
|
Text("Model Configuration")
|
|
}
|
|
|
|
Section {
|
|
// FIXME: Hidden The new Multimodal Prompt API
|
|
// Toggle("Use Multimodal Prompt API", isOn: $viewModel.useMultimodalPromptAPI)
|
|
// .onChange(of: viewModel.useMultimodalPromptAPI) { _, newValue in
|
|
// viewModel.updateUseMultimodalPromptAPI(newValue)
|
|
// }
|
|
|
|
Stepper(value: $videoMaxFrames, in: 1 ... 32) {
|
|
HStack {
|
|
Text("Video Frames")
|
|
Spacer()
|
|
Text("\(videoMaxFrames)")
|
|
}
|
|
}
|
|
.onChange(of: videoMaxFrames) { _, newValue in
|
|
viewModel.updateVideoMaxFrames(newValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Default Multimodal Prompt")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
TextEditor(text: $defaultMultimodalPrompt)
|
|
.frame(minHeight: 80)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.secondary.opacity(0.3))
|
|
)
|
|
.onChange(of: defaultMultimodalPrompt) { _, newValue in
|
|
viewModel.updateDefaultMultimodalPrompt(newValue)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Multimodal Inputs")
|
|
}
|
|
|
|
// Audio Output Settings (Omni)
|
|
if ModelUtils.supportAudioOutput(viewModel.modelInfo.modelName) {
|
|
Section {
|
|
Toggle("Enable Audio Output", isOn: $enableAudioOutput)
|
|
.onChange(of: enableAudioOutput) { _, newValue in
|
|
print("[AudioUI] Enable Audio Output changed to: \(newValue)")
|
|
viewModel.updateEnableAudioOutput(newValue)
|
|
}
|
|
|
|
Picker("Talker Speaker", selection: $talkerSpeaker) {
|
|
ForEach(talkerSpeakerOptions, id: \.self) { option in
|
|
Text(option.capitalized).tag(option)
|
|
}
|
|
}
|
|
.onChange(of: talkerSpeaker) { _, newValue in
|
|
print("[AudioUI] Talker Speaker changed to: \(newValue)")
|
|
viewModel.updateTalkerSpeaker(newValue)
|
|
}
|
|
} header: {
|
|
Text("Audio Output (Omni)")
|
|
}
|
|
}
|
|
|
|
// Diffusion Settings (Stable Diffusion and Sana Diffusion)
|
|
if viewModel.isAnyDiffusionModel {
|
|
Section {
|
|
// Model type indicator for Sana Diffusion
|
|
if viewModel.isSanaDiffusionModel {
|
|
HStack {
|
|
Text("Model Type")
|
|
Spacer()
|
|
Text("Sana Style Transfer")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Stepper(value: $iterations, in: 1 ... 100) {
|
|
HStack {
|
|
Text("Iterations")
|
|
Spacer()
|
|
Text("\(iterations)")
|
|
}
|
|
}
|
|
.onChange(of: iterations) { _, newValue in
|
|
viewModel.modelConfigManager.updateIterations(newValue)
|
|
}
|
|
|
|
Toggle("Random Seed", isOn: $useRandomSeed)
|
|
.onChange(of: useRandomSeed) { _, newValue in
|
|
if newValue {
|
|
seed = -1
|
|
viewModel.modelConfigManager.updateSeed(-1)
|
|
}
|
|
}
|
|
|
|
if !useRandomSeed {
|
|
HStack {
|
|
Text("Seed")
|
|
Spacer()
|
|
TextField("Seed", value: $seed, formatter: NumberFormatter())
|
|
.keyboardType(.numberPad)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 100)
|
|
.onChange(of: seed) { _, newValue in
|
|
viewModel.modelConfigManager.updateSeed(max(0, newValue))
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text(viewModel.isSanaDiffusionModel ? "Style Transfer Settings" : "Diffusion Settings")
|
|
}
|
|
} else {
|
|
Section {
|
|
Picker("Sampler Type", selection: $selectedSampler) {
|
|
ForEach(SamplerType.allCases, id: \.self) { sampler in
|
|
Text(sampler.displayName)
|
|
.tag(sampler)
|
|
}
|
|
}
|
|
.onChange(of: selectedSampler) { _, newValue in
|
|
viewModel.modelConfigManager.updateSamplerType(newValue)
|
|
}
|
|
|
|
switch selectedSampler {
|
|
case .temperature:
|
|
ParameterSliderView(
|
|
title: "Temperature",
|
|
value: $temperature,
|
|
range: 0.0 ... 2.0,
|
|
format: "%.2f",
|
|
intValue: false,
|
|
onChanged: viewModel.modelConfigManager.updateTemperature(_:)
|
|
)
|
|
case .topK:
|
|
ParameterSliderView(
|
|
title: "Top K",
|
|
value: $topK,
|
|
range: 1 ... 100,
|
|
format: "%.0f",
|
|
intValue: true,
|
|
onChanged: { viewModel.modelConfigManager.updateTopK(Int($0)) }
|
|
)
|
|
case .topP:
|
|
ParameterSliderView(
|
|
title: "Top P",
|
|
value: $topP,
|
|
range: 0.0 ... 1.0,
|
|
format: "%.2f",
|
|
intValue: false,
|
|
onChanged: viewModel.modelConfigManager.updateTopP(_:)
|
|
)
|
|
case .minP:
|
|
ParameterSliderView(
|
|
title: "Min P",
|
|
value: $minP,
|
|
range: 0.05 ... 0.3,
|
|
format: "%.2f",
|
|
intValue: false,
|
|
onChanged: viewModel.modelConfigManager.updateMinP(_:)
|
|
)
|
|
case .tfs:
|
|
ParameterSliderView(
|
|
title: "TFS-Z",
|
|
value: $tfsZ,
|
|
range: 0.9 ... 0.99,
|
|
format: "%.2f",
|
|
intValue: false,
|
|
onChanged: viewModel.modelConfigManager.updateTfsZ(_:)
|
|
)
|
|
case .typical:
|
|
ParameterSliderView(
|
|
title: "Typical",
|
|
value: $typical,
|
|
range: 0.8 ... 0.95,
|
|
format: "%.2f",
|
|
intValue: false,
|
|
onChanged: viewModel.modelConfigManager.updateTypical(_:)
|
|
)
|
|
case .penalty:
|
|
VStack(spacing: 8) {
|
|
// Penalty parameters
|
|
ParameterSliderView(
|
|
title: "Penalty",
|
|
value: $penalty,
|
|
range: 0.0 ... 0.5,
|
|
format: "%.2f",
|
|
intValue: false,
|
|
onChanged: viewModel.modelConfigManager.updatePenalty(_:)
|
|
)
|
|
|
|
// N-gram Size parameters
|
|
ParameterSliderView(
|
|
title: "N-gram Size",
|
|
value: $nGram,
|
|
range: 3 ... 8,
|
|
format: "%.0f",
|
|
intValue: true,
|
|
onChanged: { viewModel.modelConfigManager.updateNGram(Int($0)) }
|
|
)
|
|
|
|
// N-gram Factor parameters
|
|
ParameterSliderView(
|
|
title: "N-gram Factor",
|
|
value: $nGramFactor,
|
|
range: 1.0 ... 3.0,
|
|
format: "%.1f",
|
|
intValue: false,
|
|
onChanged: viewModel.modelConfigManager.updateNGramFactor(_:)
|
|
)
|
|
|
|
// Penalty Sampler selector
|
|
Picker("Penalty Sampler", selection: $penaltySampler) {
|
|
ForEach(PenaltySamplerType.allCases, id: \.self) { samplerType in
|
|
Text(samplerType.displayName)
|
|
.tag(samplerType)
|
|
}
|
|
}
|
|
.onChange(of: penaltySampler) { newValue in
|
|
viewModel.modelConfigManager.updatePenaltySampler(newValue)
|
|
}
|
|
}
|
|
case .mixed:
|
|
MixedSamplersView(
|
|
selectedSamplers: $selectedMixedSamplers,
|
|
samplersOrder: $mixedSamplersOrder,
|
|
onUpdate: updateMixedSamplers,
|
|
temperature: $temperature,
|
|
topK: $topK,
|
|
topP: $topP,
|
|
minP: $minP,
|
|
tfsZ: $tfsZ,
|
|
typical: $typical,
|
|
penalty: $penalty,
|
|
onUpdateTemperature: viewModel.modelConfigManager.updateTemperature(_:),
|
|
onUpdateTopK: viewModel.modelConfigManager.updateTopK(_:),
|
|
onUpdateTopP: viewModel.modelConfigManager.updateTopP(_:),
|
|
onUpdateMinP: viewModel.modelConfigManager.updateMinP(_:),
|
|
onUpdateTfsZ: viewModel.modelConfigManager.updateTfsZ(_:),
|
|
onUpdateTypical: viewModel.modelConfigManager.updateTypical(_:),
|
|
onUpdatePenalty: viewModel.modelConfigManager.updatePenalty(_:)
|
|
)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
} header: {
|
|
Text("Sampling Strategy")
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") {
|
|
if requiresReload {
|
|
showReloadAlert = true
|
|
} else {
|
|
showSettings = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.alert(NSLocalizedString("Success", comment: ""), isPresented: $showAlert) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(NSLocalizedString("Cache Cleared Successfully", comment: ""))
|
|
}
|
|
.alert(NSLocalizedString("Model Reload Required", comment: ""), isPresented: $showReloadAlert) {
|
|
Button(NSLocalizedString("Reload Now", comment: "")) {
|
|
viewModel.reloadCurrentModel()
|
|
requiresReload = false
|
|
showSettings = false
|
|
}
|
|
Button(NSLocalizedString("Later", comment: ""), role: .cancel) {
|
|
showSettings = false
|
|
}
|
|
} message: {
|
|
Text(NSLocalizedString("Changes to backend, precision, or thread count require the model to reload.", comment: ""))
|
|
}
|
|
.onAppear {
|
|
selectedSampler = viewModel.modelConfigManager.readSamplerType()
|
|
backendType = viewModel.modelConfigManager.readBackendType()
|
|
precision = viewModel.modelConfigManager.readPrecision()
|
|
threadNum = viewModel.modelConfigManager.readThreadNum()
|
|
requiresReload = false
|
|
|
|
if viewModel.isAnyDiffusionModel {
|
|
iterations = viewModel.modelConfigManager.readIterations()
|
|
seed = viewModel.modelConfigManager.readSeed()
|
|
useRandomSeed = (seed < 0)
|
|
|
|
// Set default iterations for Sana Diffusion (fewer iterations needed)
|
|
if viewModel.isSanaDiffusionModel && iterations == 20 {
|
|
iterations = 5 // Sana uses fewer iterations by default
|
|
}
|
|
} else {
|
|
temperature = viewModel.modelConfigManager.readTemperature()
|
|
topK = Double(viewModel.modelConfigManager.readTopK())
|
|
topP = viewModel.modelConfigManager.readTopP()
|
|
minP = viewModel.modelConfigManager.readMinP()
|
|
tfsZ = viewModel.modelConfigManager.readTfsZ()
|
|
typical = viewModel.modelConfigManager.readTypical()
|
|
penalty = viewModel.modelConfigManager.readPenalty()
|
|
nGram = Double(viewModel.modelConfigManager.readNGram())
|
|
nGramFactor = viewModel.modelConfigManager.readNGramFactor()
|
|
}
|
|
videoMaxFrames = viewModel.modelConfigManager.readVideoMaxFrames()
|
|
defaultMultimodalPrompt = viewModel.modelConfigManager.readDefaultMultimodalPrompt()
|
|
enableAudioOutput = viewModel.modelConfigManager.readEnableAudioOutput()
|
|
|
|
// Validate talkerSpeaker value - if not in options, reset to default
|
|
let savedTalkerSpeaker = viewModel.modelConfigManager.readTalkerSpeaker()
|
|
if talkerSpeakerOptions.contains(savedTalkerSpeaker) {
|
|
talkerSpeaker = savedTalkerSpeaker
|
|
} else {
|
|
talkerSpeaker = "default"
|
|
// Update config with valid default value
|
|
viewModel.updateTalkerSpeaker("default")
|
|
}
|
|
|
|
// Initialize mixed samplers
|
|
let savedMixedSamplers = viewModel.modelConfigManager.readMixedSamplers()
|
|
mixedSamplersOrder = ["topK", "tfs", "typical", "topP", "minP", "temperature"]
|
|
selectedMixedSamplers = Set(savedMixedSamplers)
|
|
|
|
// Initialize penalty sampler
|
|
penaltySampler = viewModel.modelConfigManager.readPenaltySampler()
|
|
}
|
|
.onDisappear {
|
|
viewModel.setModelConfig()
|
|
viewModel.updateVideoMaxFrames(videoMaxFrames)
|
|
viewModel.updateDefaultMultimodalPrompt(defaultMultimodalPrompt)
|
|
}
|
|
}
|
|
|
|
private func updateMixedSamplers() {
|
|
let orderedSelection = mixedSamplersOrder.filter { selectedMixedSamplers.contains($0) }
|
|
viewModel.modelConfigManager.updateMixedSamplers(orderedSelection)
|
|
}
|
|
}
|