1223 lines
56 KiB
Swift
1223 lines
56 KiB
Swift
import SwiftUI
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class ModelSettingsScreenVM {
|
|
enum Section: String, Hashable, CaseIterable, Sendable {
|
|
case profiles, basic, advanced
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .profiles:
|
|
return String(localized: "settings.section.profiles",
|
|
defaultValue: "Profiles",
|
|
comment: "Segmented control label for the Profiles tab")
|
|
case .basic:
|
|
return String(localized: "settings.section.basic",
|
|
defaultValue: "Basic",
|
|
comment: "Segmented control label for the Basic tab")
|
|
case .advanced:
|
|
return String(localized: "settings.section.advanced",
|
|
defaultValue: "Advanced",
|
|
comment: "Segmented control label for the Advanced tab")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Field: Sendable {
|
|
case alias, modelType, contextLength, maxTokens
|
|
case temperature, topP, topK, minP
|
|
case repetitionPenalty, presencePenalty, ttl
|
|
case enableThinking, thinkingBudgetEnabled, thinkingBudgetTokens
|
|
case limitToolResults, toolResultLimitTokens
|
|
case forceSampling, isPinned, isFavorite
|
|
case trustRemoteCode
|
|
case reasoningParser
|
|
case chatTemplateKwargs
|
|
case turboquantKvEnabled, turboquantKvBits
|
|
case indexCacheEnabled, indexCacheFreq
|
|
case specprefillEnabled, specprefillDraftModel, specprefillKeepPct, specprefillThreshold
|
|
case dflashEnabled, dflashDraftModel, dflashMaxCtx
|
|
case dflashDraftQuantEnabled, dflashDraftQuantWeightBits
|
|
case dflashDraftQuantActivationBits, dflashDraftQuantGroupSize
|
|
case dflashVerifyMode, dflashDraftWindowSize, dflashDraftSinkSize
|
|
case dflashInMemoryCache, dflashInMemoryCacheGib, dflashInMemoryCacheMaxEntries
|
|
case dflashSsdCache, dflashSsdCacheGib
|
|
case mtpEnabled
|
|
case vlmMtpEnabled, vlmMtpDraftModel, vlmMtpDraftBlockSize
|
|
}
|
|
|
|
static var modelTypeOptions: [(String, String)] {
|
|
[
|
|
("", String(localized: "settings.model_type.auto_detect",
|
|
defaultValue: "Auto-detect",
|
|
comment: "Model type option meaning the server should auto-detect")),
|
|
("llm", String(localized: "settings.model_type.llm",
|
|
defaultValue: "LLM",
|
|
comment: "Model type option label for text language models")),
|
|
("vlm", String(localized: "settings.model_type.vlm",
|
|
defaultValue: "VLM",
|
|
comment: "Model type option label for vision-language models")),
|
|
("embed", String(localized: "settings.model_type.embed",
|
|
defaultValue: "Embedding",
|
|
comment: "Model type option label for embedding models")),
|
|
("rerank", String(localized: "settings.model_type.rerank",
|
|
defaultValue: "Reranker",
|
|
comment: "Model type option label for reranker models")),
|
|
("audio-stt", String(localized: "settings.model_type.audio_stt",
|
|
defaultValue: "Audio STT",
|
|
comment: "Model type option label for speech-to-text models")),
|
|
("audio-tts", String(localized: "settings.model_type.audio_tts",
|
|
defaultValue: "Audio TTS",
|
|
comment: "Model type option label for text-to-speech models")),
|
|
("audio-sts", String(localized: "settings.model_type.audio_sts",
|
|
defaultValue: "Audio STS",
|
|
comment: "Model type option label for speech-to-speech models")),
|
|
]
|
|
}
|
|
|
|
static var turboquantKvBitsOptions: [(String, String)] {
|
|
[
|
|
("2", String(localized: "settings.turboquant.bits.2",
|
|
defaultValue: "2-bit",
|
|
comment: "TurboQuant KV bit-width option")),
|
|
("2.5", String(localized: "settings.turboquant.bits.2_5",
|
|
defaultValue: "2.5-bit",
|
|
comment: "TurboQuant KV bit-width option")),
|
|
("3", String(localized: "settings.turboquant.bits.3",
|
|
defaultValue: "3-bit",
|
|
comment: "TurboQuant KV bit-width option")),
|
|
("3.5", String(localized: "settings.turboquant.bits.3_5",
|
|
defaultValue: "3.5-bit",
|
|
comment: "TurboQuant KV bit-width option")),
|
|
("4", String(localized: "settings.turboquant.bits.4",
|
|
defaultValue: "4-bit",
|
|
comment: "TurboQuant KV bit-width option")),
|
|
("6", String(localized: "settings.turboquant.bits.6",
|
|
defaultValue: "6-bit",
|
|
comment: "TurboQuant KV bit-width option")),
|
|
("8", String(localized: "settings.turboquant.bits.8",
|
|
defaultValue: "8-bit",
|
|
comment: "TurboQuant KV bit-width option")),
|
|
]
|
|
}
|
|
|
|
/// Keep-pct labels mirror the HTML editor's tradeoff annotations
|
|
/// so the user picks an approximate speedup, not a raw fraction.
|
|
static var specprefillKeepPctOptions: [(String, String)] {
|
|
[
|
|
("0.1", String(localized: "settings.specprefill.keep.10",
|
|
defaultValue: "10% — Aggressive (~5-7x, some quality loss)",
|
|
comment: "SpecPrefill keep-rate dropdown option")),
|
|
("0.2", String(localized: "settings.specprefill.keep.20",
|
|
defaultValue: "20% — Balanced (~3x, recommended)",
|
|
comment: "SpecPrefill keep-rate dropdown option")),
|
|
("0.25", String(localized: "settings.specprefill.keep.25",
|
|
defaultValue: "25% — Conservative+ (~2.5x)",
|
|
comment: "SpecPrefill keep-rate dropdown option")),
|
|
("0.3", String(localized: "settings.specprefill.keep.30",
|
|
defaultValue: "30% — Conservative (~2.2x)",
|
|
comment: "SpecPrefill keep-rate dropdown option")),
|
|
("0.4", String(localized: "settings.specprefill.keep.40",
|
|
defaultValue: "40% — Mild (~1.8x)",
|
|
comment: "SpecPrefill keep-rate dropdown option")),
|
|
("0.5", String(localized: "settings.specprefill.keep.50",
|
|
defaultValue: "50% — Minimal (~1.5x)",
|
|
comment: "SpecPrefill keep-rate dropdown option")),
|
|
]
|
|
}
|
|
|
|
static var dflashDraftQuantWeightBitsOptions: [(String, String)] {
|
|
[
|
|
("2", String(localized: "settings.dflash.quant.weight.2",
|
|
defaultValue: "2-bit",
|
|
comment: "DFlash draft quantization weight bits option")),
|
|
("4", String(localized: "settings.dflash.quant.weight.4",
|
|
defaultValue: "4-bit",
|
|
comment: "DFlash draft quantization weight bits option")),
|
|
("8", String(localized: "settings.dflash.quant.weight.8",
|
|
defaultValue: "8-bit",
|
|
comment: "DFlash draft quantization weight bits option")),
|
|
]
|
|
}
|
|
|
|
static var dflashDraftQuantActivationBitsOptions: [(String, String)] {
|
|
[
|
|
("", String(localized: "settings.dflash.quant.activation.default",
|
|
defaultValue: "default",
|
|
comment: "DFlash draft quantization activation bits — use server default")),
|
|
("16", String(localized: "settings.dflash.quant.activation.16",
|
|
defaultValue: "16-bit",
|
|
comment: "DFlash draft quantization activation bits option")),
|
|
("32", String(localized: "settings.dflash.quant.activation.32",
|
|
defaultValue: "32-bit",
|
|
comment: "DFlash draft quantization activation bits option")),
|
|
]
|
|
}
|
|
|
|
static var dflashDraftQuantGroupSizeOptions: [(String, String)] {
|
|
[
|
|
("", String(localized: "settings.dflash.quant.group.default",
|
|
defaultValue: "default",
|
|
comment: "DFlash draft quantization group size — use server default")),
|
|
("32", "32"),
|
|
("64", "64"),
|
|
("128", "128"),
|
|
]
|
|
}
|
|
|
|
static var dflashVerifyModeOptions: [(String, String)] {
|
|
[
|
|
("", String(localized: "settings.dflash.verify_mode.default",
|
|
defaultValue: "default (adaptive)",
|
|
comment: "DFlash verify mode option meaning the server default is used")),
|
|
("adaptive", String(localized: "settings.dflash.verify_mode.adaptive",
|
|
defaultValue: "adaptive",
|
|
comment: "DFlash verify mode: shrinks block size when acceptance drops")),
|
|
("dflash", String(localized: "settings.dflash.verify_mode.dflash",
|
|
defaultValue: "dflash",
|
|
comment: "DFlash verify mode: standard dflash verifier")),
|
|
("ddtree", String(localized: "settings.dflash.verify_mode.ddtree",
|
|
defaultValue: "ddtree",
|
|
comment: "DFlash verify mode: DDTree verifier")),
|
|
("off", String(localized: "settings.dflash.verify_mode.off",
|
|
defaultValue: "off",
|
|
comment: "DFlash verify mode: disable speculative verify")),
|
|
]
|
|
}
|
|
|
|
/// `config_model_type` values that surface IndexCache in the HTML
|
|
/// admin. Mirrored from `dashboard.js:5-7` (`DSA_MODEL_TYPES`).
|
|
static let dsaConfigModelTypes: Set<String> = [
|
|
"deepseek_v32", "glm_moe_dsa",
|
|
]
|
|
static let diffusionConfigModelTypes: Set<String> = [
|
|
"diffusion_gemma",
|
|
]
|
|
/// `config_model_type` values accepted by the HTML admin's VLM MTP
|
|
/// assistant-drafter picker. Mirrored from `dashboard.js`
|
|
/// (`VLM_MTP_DRAFTER_CONFIG_MODEL_TYPES`).
|
|
static let vlmMtpDrafterConfigModelTypes: Set<String> = [
|
|
"gemma4_assistant", "gemma4_unified_assistant", "qwen3_5_mtp",
|
|
]
|
|
static let diffusionUnsupportedCtKwargKeys: Set<String> = [
|
|
"enable_thinking", "reasoning_effort", "preserve_thinking",
|
|
]
|
|
|
|
var section: Section = .basic
|
|
|
|
var model: ModelDTO?
|
|
/// Snapshot of every other model on the server, used to populate the
|
|
/// SpecPrefill / DFlash draft-model dropdowns. Reloaded with `load()`.
|
|
var allModels: [ModelDTO] = []
|
|
var modelID: String = ""
|
|
var lastError: String?
|
|
|
|
// Basic
|
|
var alias: String = ""
|
|
var modelTypeOverride: String = ""
|
|
var contextLength: String = ""
|
|
var maxTokens: String = ""
|
|
var temperature: String = ""
|
|
var topP: String = ""
|
|
var topK: String = ""
|
|
var minP: String = ""
|
|
var repetitionPenalty: String = ""
|
|
var presencePenalty: String = ""
|
|
var ttlSeconds: String = ""
|
|
|
|
// Advanced
|
|
var enableThinking: Bool = true
|
|
var thinkingBudgetEnabled: Bool = false
|
|
var thinkingBudgetTokens: String = "8192"
|
|
var limitToolResults: Bool = false
|
|
/// Token cap when `limitToolResults` is on. Defaults to the HTML
|
|
/// admin's seeded value so the first save after enabling sends a
|
|
/// sensible number instead of zero (which the server interprets as
|
|
/// "disabled").
|
|
var toolResultLimitTokens: String = "4096"
|
|
var forceSampling: Bool = false
|
|
var isPinned: Bool = false
|
|
var isFavorite: Bool = false
|
|
|
|
// Security
|
|
var trustRemoteCode: Bool = false
|
|
|
|
// Reasoning parser (free-form override; empty = auto)
|
|
var reasoningParser: String = ""
|
|
|
|
// Chat-template kwargs — entries are the editor's view of the
|
|
// (chat_template_kwargs, forced_ct_kwargs) server pair.
|
|
var chatTemplateEntries: [ChatTemplateKwargEntry] = []
|
|
|
|
// Experimental: TurboQuant KV
|
|
var turboquantKvEnabled: Bool = false
|
|
var turboquantKvBits: String = "4"
|
|
|
|
// Experimental: IndexCache (DSA-only)
|
|
var indexCacheEnabled: Bool = false
|
|
var indexCacheFreq: String = "4"
|
|
|
|
// Experimental: SpecPrefill
|
|
var specprefillEnabled: Bool = false
|
|
var specprefillDraftModel: String = ""
|
|
var specprefillKeepPct: String = "0.2"
|
|
var specprefillThreshold: String = "8192"
|
|
|
|
// Experimental: DFlash
|
|
var dflashEnabled: Bool = false
|
|
var dflashDraftModel: String = ""
|
|
var dflashDraftQuantEnabled: Bool = false
|
|
var dflashDraftQuantWeightBits: String = "4"
|
|
var dflashDraftQuantActivationBits: String = ""
|
|
var dflashDraftQuantGroupSize: String = ""
|
|
var dflashMaxCtx: String = ""
|
|
var dflashVerifyMode: String = ""
|
|
var dflashDraftWindowSize: String = ""
|
|
var dflashDraftSinkSize: String = ""
|
|
var dflashInMemoryCache: Bool = false
|
|
var dflashInMemoryCacheGib: String = "8"
|
|
var dflashInMemoryCacheMaxEntries: String = "4"
|
|
var dflashSsdCache: Bool = false
|
|
var dflashSsdCacheGib: String = "20"
|
|
|
|
// Experimental: native MTP
|
|
var mtpEnabled: Bool = false
|
|
|
|
// Experimental: VLM MTP (assistant-drafter speculative decoding for VLMs).
|
|
// Block size is held as a string for the editor; empty = mlx-vlm default.
|
|
var vlmMtpEnabled: Bool = false
|
|
var vlmMtpDraftModel: String = ""
|
|
var vlmMtpDraftBlockSize: String = ""
|
|
|
|
// Profiles
|
|
var profiles: [ProfileDTO] = []
|
|
var templates: [ProfileDTO] = []
|
|
var activeProfileName: String?
|
|
/// Server's `GlobalSettings.sampling` snapshot, loaded alongside the
|
|
/// per-model settings so the Profiles tab's "Server Defaults" card
|
|
/// can render without a second round-trip.
|
|
var serverDefaultSampling: GlobalSettingsDTO.SamplingDTO?
|
|
/// Display scope for the active profile (derived from `source_template`).
|
|
var activeProfileScope: ProfileScope = .model
|
|
/// True when one or more profile-eligible fields have been edited
|
|
/// since the last load / apply / save. Flips the screen into the
|
|
/// "Working profile" state. Per-model fields (alias / modelType /
|
|
/// ttl / isPinned / trustRemoteCode) auto-save and never set this.
|
|
var profileDirty: Bool = false
|
|
|
|
/// State machine the banner and ProfileDetailCard render against.
|
|
/// Cheap to recompute — pure function of (profileDirty, activeProfileScope,
|
|
/// activeProfileName).
|
|
var activeProfileState: ActiveProfileState {
|
|
if profileDirty {
|
|
if let name = activeProfileName {
|
|
return .working(basedOn: .init(scope: activeProfileScope, name: name))
|
|
}
|
|
return .working(basedOn: nil)
|
|
}
|
|
if let name = activeProfileName {
|
|
return .named(scope: activeProfileScope, name: name)
|
|
}
|
|
return .defaults
|
|
}
|
|
|
|
var isDiffusionModel: Bool {
|
|
let type = (model?.configModelType ?? "")
|
|
.lowercased()
|
|
.replacingOccurrences(of: "-", with: "_")
|
|
return Self.diffusionConfigModelTypes.contains(type)
|
|
}
|
|
|
|
private func isDiffusionUnsupportedField(_ field: Field) -> Bool {
|
|
switch field {
|
|
case .topP, .topK, .minP, .repetitionPenalty, .presencePenalty:
|
|
return true
|
|
case .enableThinking, .thinkingBudgetEnabled, .thinkingBudgetTokens:
|
|
return true
|
|
case .limitToolResults, .toolResultLimitTokens:
|
|
return true
|
|
case .forceSampling, .reasoningParser:
|
|
return true
|
|
case .turboquantKvEnabled, .turboquantKvBits:
|
|
return true
|
|
case .indexCacheEnabled, .indexCacheFreq:
|
|
return true
|
|
case .specprefillEnabled, .specprefillDraftModel:
|
|
return true
|
|
case .specprefillKeepPct, .specprefillThreshold:
|
|
return true
|
|
case .dflashEnabled, .dflashDraftModel, .dflashMaxCtx:
|
|
return true
|
|
case .dflashDraftQuantEnabled, .dflashDraftQuantWeightBits:
|
|
return true
|
|
case .dflashDraftQuantActivationBits, .dflashDraftQuantGroupSize:
|
|
return true
|
|
case .dflashVerifyMode, .dflashDraftWindowSize, .dflashDraftSinkSize:
|
|
return true
|
|
case .dflashInMemoryCache, .dflashInMemoryCacheGib:
|
|
return true
|
|
case .dflashInMemoryCacheMaxEntries:
|
|
return true
|
|
case .dflashSsdCache, .dflashSsdCacheGib:
|
|
return true
|
|
case .mtpEnabled, .vlmMtpEnabled, .vlmMtpDraftModel:
|
|
return true
|
|
case .vlmMtpDraftBlockSize:
|
|
return true
|
|
case .alias, .modelType, .contextLength, .maxTokens:
|
|
return false
|
|
case .temperature, .ttl, .isPinned, .isFavorite, .trustRemoteCode:
|
|
return false
|
|
case .chatTemplateKwargs:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func diffusionCompatibleChatTemplateEntries(
|
|
_ entries: [ChatTemplateKwargEntry]
|
|
) -> [ChatTemplateKwargEntry] {
|
|
guard isDiffusionModel else { return entries }
|
|
return entries.filter { entry in
|
|
guard let key = entry.resolvedKey else { return true }
|
|
return !Self.diffusionUnsupportedCtKwargKeys.contains(key)
|
|
}
|
|
}
|
|
|
|
func bind<T: Equatable>(
|
|
_ binding: Binding<T>,
|
|
save: @escaping () -> Void
|
|
) -> Binding<T> {
|
|
Binding(
|
|
get: { binding.wrappedValue },
|
|
set: { newValue in
|
|
let changed = binding.wrappedValue != newValue
|
|
binding.wrappedValue = newValue
|
|
if changed { save() }
|
|
}
|
|
)
|
|
}
|
|
|
|
/// Binding helper for profile-eligible fields. Edits flip
|
|
/// `profileDirty` (which activates the Working banner) instead of
|
|
/// firing a per-field PUT. Network writes happen only when the user
|
|
/// chooses Apply / Save as new / Update.
|
|
func bindProfile<T: Equatable>(_ binding: Binding<T>) -> Binding<T> {
|
|
Binding(
|
|
get: { binding.wrappedValue },
|
|
set: { newValue in
|
|
let changed = binding.wrappedValue != newValue
|
|
binding.wrappedValue = newValue
|
|
if changed { self.profileDirty = true }
|
|
}
|
|
)
|
|
}
|
|
|
|
/// Flip the working-dirty flag from a non-binding callsite (e.g. the
|
|
/// chat-template kwargs editor's add / remove buttons).
|
|
func markProfileDirty() { self.profileDirty = true }
|
|
|
|
func load(modelID: String, client: OMLXClient) async {
|
|
self.modelID = modelID
|
|
do {
|
|
let models = try await client.listModels().models
|
|
self.allModels = models
|
|
if let m = models.first(where: { $0.id == modelID }) {
|
|
self.model = m
|
|
// A never-customized model has no settings record on the
|
|
// server, so `settings` arrives nil. Repopulate with the
|
|
// defaults anyway; skipping would leave edited values on
|
|
// screen after Discard (#2182).
|
|
let s = m.settings
|
|
self.alias = s?.modelAlias ?? ""
|
|
self.modelTypeOverride = s?.modelTypeOverride ?? ""
|
|
self.contextLength = s?.maxContextWindow.map(String.init) ?? ""
|
|
self.maxTokens = s?.maxTokens.map(String.init) ?? ""
|
|
self.temperature = s?.temperature.map { String($0) } ?? ""
|
|
self.topP = s?.topP.map { String($0) } ?? ""
|
|
self.topK = s?.topK.map(String.init) ?? ""
|
|
self.minP = s?.minP.map { String($0) } ?? ""
|
|
self.repetitionPenalty = s?.repetitionPenalty.map { String($0) } ?? ""
|
|
self.presencePenalty = s?.presencePenalty.map { String($0) } ?? ""
|
|
self.ttlSeconds = s?.ttlSeconds.map(String.init) ?? ""
|
|
self.enableThinking = s?.enableThinking ?? true
|
|
self.thinkingBudgetEnabled = s?.thinkingBudgetEnabled ?? false
|
|
self.thinkingBudgetTokens = s?.thinkingBudgetTokens.map(String.init) ?? "8192"
|
|
self.limitToolResults = (s?.maxToolResultTokens ?? 0) > 0
|
|
if let n = s?.maxToolResultTokens, n > 0 {
|
|
self.toolResultLimitTokens = String(n)
|
|
} else {
|
|
self.toolResultLimitTokens = "4096"
|
|
}
|
|
self.forceSampling = s?.forceSampling ?? false
|
|
self.isPinned = s?.isPinned ?? false
|
|
self.isFavorite = s?.isFavorite ?? false
|
|
self.trustRemoteCode = s?.trustRemoteCode ?? false
|
|
self.reasoningParser = s?.reasoningParser ?? ""
|
|
self.chatTemplateEntries = diffusionCompatibleChatTemplateEntries(
|
|
ChatTemplateKwargsCodec.decode(
|
|
kwargs: s?.chatTemplateKwargs,
|
|
forced: s?.forcedCtKwargs
|
|
)
|
|
)
|
|
self.turboquantKvEnabled = s?.turboquantKvEnabled ?? false
|
|
self.turboquantKvBits = s?.turboquantKvBits.map { Self.formatBits($0) } ?? "4"
|
|
self.indexCacheEnabled = s?.indexCacheFreq != nil
|
|
self.indexCacheFreq = s?.indexCacheFreq.map(String.init) ?? "4"
|
|
self.specprefillEnabled = s?.specprefillEnabled ?? false
|
|
self.specprefillDraftModel = s?.specprefillDraftModel ?? ""
|
|
self.specprefillKeepPct = s?.specprefillKeepPct.map { Self.formatPct($0) } ?? "0.2"
|
|
self.specprefillThreshold = s?.specprefillThreshold.map(String.init) ?? "8192"
|
|
self.dflashEnabled = s?.dflashEnabled ?? false
|
|
self.dflashDraftModel = s?.dflashDraftModel ?? ""
|
|
self.dflashDraftQuantEnabled = s?.dflashDraftQuantEnabled ?? false
|
|
self.dflashDraftQuantWeightBits = s?.dflashDraftQuantWeightBits.map(String.init) ?? "4"
|
|
self.dflashDraftQuantActivationBits = s?.dflashDraftQuantActivationBits.map(String.init) ?? ""
|
|
self.dflashDraftQuantGroupSize = s?.dflashDraftQuantGroupSize.map(String.init) ?? ""
|
|
self.dflashMaxCtx = s?.dflashMaxCtx.map(String.init) ?? ""
|
|
self.dflashVerifyMode = s?.dflashVerifyMode ?? ""
|
|
self.dflashDraftWindowSize = s?.dflashDraftWindowSize.map(String.init) ?? ""
|
|
self.dflashDraftSinkSize = s?.dflashDraftSinkSize.map(String.init) ?? ""
|
|
self.dflashInMemoryCache = s?.dflashInMemoryCache ?? false
|
|
self.dflashInMemoryCacheGib = DflashByteSize.bytesToGib(s?.dflashInMemoryCacheMaxBytes)
|
|
.map(String.init) ?? "8"
|
|
self.dflashInMemoryCacheMaxEntries = s?.dflashInMemoryCacheMaxEntries.map(String.init) ?? "4"
|
|
self.dflashSsdCache = s?.dflashSsdCache ?? false
|
|
self.dflashSsdCacheGib = DflashByteSize.bytesToGib(s?.dflashSsdCacheMaxBytes)
|
|
.map(String.init) ?? "20"
|
|
self.mtpEnabled = s?.mtpEnabled ?? false
|
|
self.vlmMtpEnabled = s?.vlmMtpEnabled ?? false
|
|
self.vlmMtpDraftModel = s?.vlmMtpDraftModel ?? ""
|
|
self.vlmMtpDraftBlockSize = s?.vlmMtpDraftBlockSize.map(String.init) ?? ""
|
|
self.activeProfileName = s?.activeProfileName
|
|
}
|
|
self.profiles = (try? await client.listModelProfiles(id: modelID).profiles) ?? []
|
|
self.templates = (try? await client.listProfileTemplates().templates) ?? []
|
|
self.serverDefaultSampling = (try? await client.getGlobalSettings().sampling)
|
|
// Resolve display scope from the source_template of the active
|
|
// model profile (if any) — so applying the "Balanced" preset
|
|
// lights up the Preset chip, not the local model copy.
|
|
if let display = resolveActiveProfileDisplay(
|
|
activeName: self.activeProfileName,
|
|
modelProfiles: self.profiles,
|
|
templates: self.templates
|
|
) {
|
|
self.activeProfileScope = display.scope
|
|
self.activeProfileName = display.name
|
|
} else {
|
|
self.activeProfileScope = .model
|
|
self.activeProfileName = nil
|
|
}
|
|
// Reload always re-establishes the baseline.
|
|
self.profileDirty = false
|
|
self.lastError = nil
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
func save(_ field: Field, client: OMLXClient) async {
|
|
if isDiffusionModel && isDiffusionUnsupportedField(field) {
|
|
return
|
|
}
|
|
var patch = ModelSettingsPatch()
|
|
switch field {
|
|
case .alias: patch.modelAlias = alias.isEmpty ? nil : alias
|
|
case .modelType: patch.modelTypeOverride = modelTypeOverride.isEmpty ? nil : modelTypeOverride
|
|
case .contextLength: patch.maxContextWindow = Int(contextLength)
|
|
case .maxTokens: patch.maxTokens = Int(maxTokens)
|
|
case .temperature:
|
|
switch SamplingValidator.temperature(temperature) {
|
|
case .success(let v): patch.temperature = v
|
|
case .failure(let e): self.lastError = e.message; return
|
|
}
|
|
case .topP:
|
|
switch SamplingValidator.topP(topP) {
|
|
case .success(let v): patch.topP = v
|
|
case .failure(let e): self.lastError = e.message; return
|
|
}
|
|
case .topK:
|
|
switch SamplingValidator.topK(topK) {
|
|
case .success(let v): patch.topK = v
|
|
case .failure(let e): self.lastError = e.message; return
|
|
}
|
|
case .minP:
|
|
switch SamplingValidator.minP(minP) {
|
|
case .success(let v): patch.minP = v
|
|
case .failure(let e): self.lastError = e.message; return
|
|
}
|
|
case .repetitionPenalty:
|
|
switch SamplingValidator.penalty(repetitionPenalty,
|
|
name: String(localized: "settings.validator.repetition_penalty.name",
|
|
defaultValue: "Repetition Penalty",
|
|
comment: "Field name embedded in validation errors for repetition penalty")) {
|
|
case .success(let v): patch.repetitionPenalty = v
|
|
case .failure(let e): self.lastError = e.message; return
|
|
}
|
|
case .presencePenalty:
|
|
switch SamplingValidator.penalty(presencePenalty,
|
|
name: String(localized: "settings.validator.presence_penalty.name",
|
|
defaultValue: "Presence Penalty",
|
|
comment: "Field name embedded in validation errors for presence penalty")) {
|
|
case .success(let v): patch.presencePenalty = v
|
|
case .failure(let e): self.lastError = e.message; return
|
|
}
|
|
case .ttl: patch.ttlSeconds = Int(ttlSeconds)
|
|
case .enableThinking: patch.enableThinking = enableThinking
|
|
case .thinkingBudgetEnabled: patch.thinkingBudgetEnabled = thinkingBudgetEnabled
|
|
case .thinkingBudgetTokens: patch.thinkingBudgetTokens = Int(thinkingBudgetTokens)
|
|
case .limitToolResults:
|
|
// Toggling on resends the current token count (or the default);
|
|
// toggling off sends 0 — the server's documented "disable" sentinel.
|
|
if limitToolResults {
|
|
patch.maxToolResultTokens = Int(toolResultLimitTokens) ?? 4096
|
|
} else {
|
|
patch.maxToolResultTokens = 0
|
|
}
|
|
case .toolResultLimitTokens:
|
|
// Only saved while the toggle is on; a blank/non-numeric value
|
|
// is silently ignored to match the HTML editor's behavior.
|
|
guard limitToolResults else { return }
|
|
guard let n = Int(toolResultLimitTokens), n > 0 else { return }
|
|
patch.maxToolResultTokens = n
|
|
case .forceSampling: patch.forceSampling = forceSampling
|
|
case .isPinned: patch.isPinned = isPinned
|
|
case .isFavorite: patch.isFavorite = isFavorite
|
|
case .trustRemoteCode: patch.trustRemoteCode = trustRemoteCode
|
|
case .reasoningParser:
|
|
patch.reasoningParser = reasoningParser.isEmpty ? nil : reasoningParser
|
|
case .chatTemplateKwargs:
|
|
let pair = ChatTemplateKwargsCodec.encode(
|
|
diffusionCompatibleChatTemplateEntries(chatTemplateEntries)
|
|
)
|
|
patch.chatTemplateKwargs = pair.kwargs ?? [:]
|
|
patch.forcedCtKwargs = pair.forced ?? []
|
|
case .turboquantKvEnabled: patch.turboquantKvEnabled = turboquantKvEnabled
|
|
case .turboquantKvBits: patch.turboquantKvBits = Double(turboquantKvBits)
|
|
case .indexCacheEnabled:
|
|
patch.indexCacheFreq = indexCacheEnabled ? (Int(indexCacheFreq) ?? 4) : 0
|
|
case .indexCacheFreq:
|
|
guard indexCacheEnabled, let n = Int(indexCacheFreq), n >= 2 else { return }
|
|
patch.indexCacheFreq = n
|
|
case .specprefillEnabled: patch.specprefillEnabled = specprefillEnabled
|
|
case .specprefillDraftModel: patch.specprefillDraftModel = specprefillDraftModel.isEmpty ? nil : specprefillDraftModel
|
|
case .specprefillKeepPct: patch.specprefillKeepPct = Double(specprefillKeepPct)
|
|
case .specprefillThreshold: patch.specprefillThreshold = Int(specprefillThreshold)
|
|
case .dflashEnabled: patch.dflashEnabled = dflashEnabled
|
|
case .dflashDraftModel: patch.dflashDraftModel = dflashDraftModel.isEmpty ? nil : dflashDraftModel
|
|
case .dflashDraftQuantEnabled:
|
|
patch.dflashDraftQuantEnabled = dflashDraftQuantEnabled
|
|
if !dflashDraftQuantEnabled {
|
|
patch.dflashDraftQuantWeightBits = nil
|
|
patch.dflashDraftQuantActivationBits = nil
|
|
patch.dflashDraftQuantGroupSize = nil
|
|
}
|
|
case .dflashDraftQuantWeightBits:
|
|
patch.dflashDraftQuantWeightBits = Int(dflashDraftQuantWeightBits)
|
|
case .dflashDraftQuantActivationBits:
|
|
patch.dflashDraftQuantActivationBits = Int(dflashDraftQuantActivationBits)
|
|
case .dflashDraftQuantGroupSize:
|
|
patch.dflashDraftQuantGroupSize = Int(dflashDraftQuantGroupSize)
|
|
case .dflashMaxCtx: patch.dflashMaxCtx = Int(dflashMaxCtx)
|
|
case .dflashVerifyMode: patch.dflashVerifyMode = dflashVerifyMode.isEmpty ? nil : dflashVerifyMode
|
|
case .dflashDraftWindowSize: patch.dflashDraftWindowSize = Int(dflashDraftWindowSize)
|
|
case .dflashDraftSinkSize: patch.dflashDraftSinkSize = Int(dflashDraftSinkSize)
|
|
case .dflashInMemoryCache:
|
|
patch.dflashInMemoryCache = dflashInMemoryCache
|
|
if !dflashInMemoryCache {
|
|
// Mirror the HTML editor: turning the L1 cache off also
|
|
// disables the L2 (SSD) sub-toggle.
|
|
dflashSsdCache = false
|
|
patch.dflashSsdCache = false
|
|
}
|
|
case .dflashInMemoryCacheGib:
|
|
patch.dflashInMemoryCacheMaxBytes = DflashByteSize.gibToBytes(Int(dflashInMemoryCacheGib))
|
|
case .dflashInMemoryCacheMaxEntries:
|
|
patch.dflashInMemoryCacheMaxEntries = Int(dflashInMemoryCacheMaxEntries)
|
|
case .dflashSsdCache: patch.dflashSsdCache = dflashSsdCache
|
|
case .dflashSsdCacheGib:
|
|
patch.dflashSsdCacheMaxBytes = DflashByteSize.gibToBytes(Int(dflashSsdCacheGib))
|
|
case .mtpEnabled: patch.mtpEnabled = mtpEnabled
|
|
case .vlmMtpEnabled: patch.vlmMtpEnabled = vlmMtpEnabled
|
|
case .vlmMtpDraftModel: patch.vlmMtpDraftModel = vlmMtpDraftModel.isEmpty ? nil : vlmMtpDraftModel
|
|
case .vlmMtpDraftBlockSize: patch.vlmMtpDraftBlockSize = Int(vlmMtpDraftBlockSize)
|
|
}
|
|
do {
|
|
_ = try await client.updateModelSettings(id: modelID, patch: patch)
|
|
self.lastError = nil
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
// MARK: - Chat-template kwarg list mutation
|
|
|
|
func addKwarg(_ kind: ChatTemplateKwargEntryKind) {
|
|
if isDiffusionModel {
|
|
switch kind {
|
|
case .enableThinking, .reasoningEffort:
|
|
return
|
|
case .custom:
|
|
break
|
|
}
|
|
}
|
|
let defaultValue: String
|
|
switch kind {
|
|
case .enableThinking: defaultValue = "true"
|
|
case .reasoningEffort: defaultValue = "low"
|
|
case .custom: defaultValue = ""
|
|
}
|
|
chatTemplateEntries.append(
|
|
ChatTemplateKwargEntry(kind: kind, value: defaultValue)
|
|
)
|
|
markProfileDirty()
|
|
}
|
|
|
|
func removeKwarg(id: UUID) {
|
|
chatTemplateEntries.removeAll(where: { $0.id == id })
|
|
markProfileDirty()
|
|
}
|
|
|
|
/// Options for SpecPrefill / DFlash draft-model dropdowns. Filters
|
|
/// out the current model so it can't pick itself as its own draft.
|
|
func draftModelOptions() -> [(String, String)] {
|
|
var out: [(String, String)] = [
|
|
("", String(localized: "settings.draft_model.placeholder",
|
|
defaultValue: "Select draft model…",
|
|
comment: "Initial placeholder option in the SpecPrefill/DFlash draft-model picker")),
|
|
]
|
|
for m in allModels where m.id != modelID {
|
|
out.append((m.modelPath ?? m.id, m.id))
|
|
}
|
|
return out
|
|
}
|
|
|
|
/// Draft-model options for VLM MTP. mlx-vlm's MTP loop takes an
|
|
/// assistant drafter. Match the HTML editor by accepting known
|
|
/// config-derived drafter types first, then falling back to names that
|
|
/// contain "assistant" or a standalone "mtp" token.
|
|
///
|
|
/// The stored value is the model **id**, not its path: the server
|
|
/// resolves the drafter by registry id (`engine_pool` looks it up in
|
|
/// `_entries` keyed by model_id, then uses that entry's `model_path`).
|
|
/// This matches the web modal, which binds `m.id`.
|
|
func vlmMtpDraftModelOptions() -> [(String, String)] {
|
|
var out: [(String, String)] = [
|
|
("", String(localized: "settings.vlm_mtp.draft.placeholder",
|
|
defaultValue: "Select assistant drafter…",
|
|
comment: "Initial placeholder option in the VLM MTP draft-model picker")),
|
|
]
|
|
for m in allModels
|
|
where Self.isVlmMtpDraftModelCandidate(m, currentModelID: modelID) {
|
|
out.append((m.id, m.id))
|
|
}
|
|
return out
|
|
}
|
|
|
|
static func isVlmMtpDraftModelCandidate(_ model: ModelDTO, currentModelID: String) -> Bool {
|
|
guard model.id != currentModelID else { return false }
|
|
|
|
if let type = model.configModelType?.lowercased(),
|
|
vlmMtpDrafterConfigModelTypes.contains(type) {
|
|
return true
|
|
}
|
|
|
|
let searchText = [model.id, model.modelPath]
|
|
.compactMap { $0 }
|
|
.joined(separator: " ")
|
|
if searchText.range(of: "assistant", options: .caseInsensitive) != nil {
|
|
return true
|
|
}
|
|
return searchText.range(
|
|
of: #"(^|[-_/\s])mtp($|[-_/\s])"#,
|
|
options: [.regularExpression, .caseInsensitive]
|
|
) != nil
|
|
}
|
|
|
|
var isDSAConfigModel: Bool {
|
|
guard let type = model?.configModelType else { return false }
|
|
return Self.dsaConfigModelTypes.contains(type)
|
|
}
|
|
|
|
/// MTP can't co-exist with DFlash or TurboQuant KV. The toggle uses
|
|
/// this to disable itself and surface the conflict reason.
|
|
var mtpConflictReason: String? {
|
|
if dflashEnabled {
|
|
return String(localized: "settings.mtp.conflict.dflash",
|
|
defaultValue: "Disable DFlash before enabling MTP.",
|
|
comment: "Tooltip / sublabel shown when MTP can't be enabled because DFlash is on")
|
|
}
|
|
if turboquantKvEnabled {
|
|
return String(localized: "settings.mtp.conflict.turboquant",
|
|
defaultValue: "Disable TurboQuant KV before enabling MTP.",
|
|
comment: "Tooltip / sublabel shown when MTP can't be enabled because TurboQuant KV is on")
|
|
}
|
|
if vlmMtpEnabled {
|
|
return String(localized: "settings.mtp.conflict.vlm_mtp",
|
|
defaultValue: "Disable VLM MTP before enabling Lightning MTP.",
|
|
comment: "Tooltip / sublabel shown when Lightning MTP can't be enabled because VLM MTP is on")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// VLM MTP wraps mlx-vlm's MTP loop and is mutually exclusive with the
|
|
/// other speculative-decoding / KV-quant features. Mirrors the HTML
|
|
/// editor's gating so the toggle disables itself and surfaces why.
|
|
var vlmMtpConflictReason: String? {
|
|
if dflashEnabled {
|
|
return String(localized: "settings.vlm_mtp.conflict.dflash",
|
|
defaultValue: "Disable DFlash before enabling VLM MTP.",
|
|
comment: "Tooltip / sublabel shown when VLM MTP can't be enabled because DFlash is on")
|
|
}
|
|
if specprefillEnabled {
|
|
return String(localized: "settings.vlm_mtp.conflict.specprefill",
|
|
defaultValue: "Disable SpecPrefill before enabling VLM MTP.",
|
|
comment: "Tooltip / sublabel shown when VLM MTP can't be enabled because SpecPrefill is on")
|
|
}
|
|
if mtpEnabled {
|
|
return String(localized: "settings.vlm_mtp.conflict.mtp",
|
|
defaultValue: "Disable Lightning MTP before enabling VLM MTP.",
|
|
comment: "Tooltip / sublabel shown when VLM MTP can't be enabled because Lightning MTP is on")
|
|
}
|
|
if turboquantKvEnabled {
|
|
return String(localized: "settings.vlm_mtp.conflict.turboquant",
|
|
defaultValue: "Disable TurboQuant KV before enabling VLM MTP.",
|
|
comment: "Tooltip / sublabel shown when VLM MTP can't be enabled because TurboQuant KV is on")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Working profile dict assembly
|
|
|
|
/// Snapshot the current profile-eligible field values into the
|
|
/// loose `settings` dict the server stores on profiles + templates.
|
|
/// Keys are snake_case (the server's wire shape). Empty / unparseable
|
|
/// fields are dropped — the server treats absent keys as "use defaults".
|
|
func currentSettingsDict() -> [String: AnyCodable] {
|
|
var out: [String: AnyCodable] = [:]
|
|
let isDiffusion = isDiffusionModel
|
|
|
|
func putInt(_ key: String, _ raw: String) {
|
|
let t = raw.trimmingCharacters(in: .whitespaces)
|
|
if t.isEmpty { return }
|
|
if let n = Int(t) { out[key] = AnyCodable(n) }
|
|
}
|
|
func putDouble(_ key: String, _ raw: String) {
|
|
let t = raw.trimmingCharacters(in: .whitespaces)
|
|
if t.isEmpty { return }
|
|
if let n = Double(t) { out[key] = AnyCodable(n) }
|
|
}
|
|
func putBool(_ key: String, _ v: Bool) {
|
|
out[key] = AnyCodable(v)
|
|
}
|
|
func putString(_ key: String, _ raw: String) {
|
|
let t = raw.trimmingCharacters(in: .whitespaces)
|
|
if t.isEmpty { return }
|
|
out[key] = AnyCodable(t)
|
|
}
|
|
|
|
// Universal — sampling
|
|
putInt(ProfileSettingsKey.maxContextWindow, contextLength)
|
|
putInt(ProfileSettingsKey.maxTokens, maxTokens)
|
|
putDouble(ProfileSettingsKey.temperature, temperature)
|
|
if !isDiffusion {
|
|
putDouble(ProfileSettingsKey.topP, topP)
|
|
putInt(ProfileSettingsKey.topK, topK)
|
|
putDouble(ProfileSettingsKey.minP, minP)
|
|
putDouble(ProfileSettingsKey.repetitionPenalty, repetitionPenalty)
|
|
putDouble(ProfileSettingsKey.presencePenalty, presencePenalty)
|
|
}
|
|
|
|
// Universal — thinking / tool / reasoning
|
|
if !isDiffusion {
|
|
putBool(ProfileSettingsKey.enableThinking, enableThinking)
|
|
putBool(ProfileSettingsKey.thinkingBudgetEnabled, thinkingBudgetEnabled)
|
|
putInt(ProfileSettingsKey.thinkingBudgetTokens, thinkingBudgetTokens)
|
|
putBool(ProfileSettingsKey.forceSampling, forceSampling)
|
|
putString(ProfileSettingsKey.reasoningParser, reasoningParser)
|
|
// Server uses 0 as the "disable" sentinel; encode that exactly.
|
|
out[ProfileSettingsKey.maxToolResultTokens] = AnyCodable(
|
|
limitToolResults ? (Int(toolResultLimitTokens) ?? 4096) : 0
|
|
)
|
|
}
|
|
|
|
// Universal — chat template kwargs. AnyCodable's encode walks a
|
|
// [String: AnyCodable] / [AnyCodable] explicitly, so nest those
|
|
// shapes rather than `Any` so the Sendable check is satisfied.
|
|
let kwargs = ChatTemplateKwargsCodec.encode(
|
|
diffusionCompatibleChatTemplateEntries(chatTemplateEntries)
|
|
)
|
|
if let dict = kwargs.kwargs {
|
|
out[ProfileSettingsKey.chatTemplateKwargs] = AnyCodable(dict)
|
|
}
|
|
if let forced = kwargs.forced, !forced.isEmpty {
|
|
out[ProfileSettingsKey.forcedCtKwargs] = AnyCodable(
|
|
forced.map { AnyCodable($0) }
|
|
)
|
|
}
|
|
|
|
// Model-specific — experimental
|
|
if !isDiffusion {
|
|
putBool(ProfileSettingsKey.turboquantKvEnabled, turboquantKvEnabled)
|
|
if turboquantKvEnabled, let bits = Double(turboquantKvBits) {
|
|
out[ProfileSettingsKey.turboquantKvBits] = AnyCodable(bits)
|
|
}
|
|
if indexCacheEnabled, let n = Int(indexCacheFreq), n >= 2 {
|
|
out[ProfileSettingsKey.indexCacheFreq] = AnyCodable(n)
|
|
}
|
|
putBool(ProfileSettingsKey.specprefillEnabled, specprefillEnabled)
|
|
if specprefillEnabled {
|
|
putString(ProfileSettingsKey.specprefillDraftModel, specprefillDraftModel)
|
|
putDouble(ProfileSettingsKey.specprefillKeepPct, specprefillKeepPct)
|
|
putInt(ProfileSettingsKey.specprefillThreshold, specprefillThreshold)
|
|
}
|
|
putBool(ProfileSettingsKey.dflashEnabled, dflashEnabled)
|
|
if dflashEnabled {
|
|
putString(ProfileSettingsKey.dflashDraftModel, dflashDraftModel)
|
|
putBool(ProfileSettingsKey.dflashDraftQuantEnabled, dflashDraftQuantEnabled)
|
|
if dflashDraftQuantEnabled {
|
|
putInt(ProfileSettingsKey.dflashDraftQuantWeightBits, dflashDraftQuantWeightBits)
|
|
putInt(ProfileSettingsKey.dflashDraftQuantActivationBits, dflashDraftQuantActivationBits)
|
|
putInt(ProfileSettingsKey.dflashDraftQuantGroupSize, dflashDraftQuantGroupSize)
|
|
}
|
|
putInt(ProfileSettingsKey.dflashMaxCtx, dflashMaxCtx)
|
|
if !dflashVerifyMode.isEmpty {
|
|
out[ProfileSettingsKey.dflashVerifyMode] = AnyCodable(dflashVerifyMode)
|
|
}
|
|
putInt(ProfileSettingsKey.dflashDraftWindowSize, dflashDraftWindowSize)
|
|
putInt(ProfileSettingsKey.dflashDraftSinkSize, dflashDraftSinkSize)
|
|
putBool(ProfileSettingsKey.dflashInMemoryCache, dflashInMemoryCache)
|
|
if dflashInMemoryCache {
|
|
if let bytes = DflashByteSize.gibToBytes(Int(dflashInMemoryCacheGib)) {
|
|
out[ProfileSettingsKey.dflashInMemoryCacheMaxBytes] = AnyCodable(Int(bytes))
|
|
}
|
|
putInt(ProfileSettingsKey.dflashInMemoryCacheMaxEntries, dflashInMemoryCacheMaxEntries)
|
|
}
|
|
putBool(ProfileSettingsKey.dflashSsdCache, dflashSsdCache)
|
|
if dflashSsdCache, let bytes = DflashByteSize.gibToBytes(Int(dflashSsdCacheGib)) {
|
|
out[ProfileSettingsKey.dflashSsdCacheMaxBytes] = AnyCodable(Int(bytes))
|
|
}
|
|
}
|
|
putBool(ProfileSettingsKey.mtpEnabled, mtpEnabled)
|
|
putBool(ProfileSettingsKey.vlmMtpEnabled, vlmMtpEnabled)
|
|
if vlmMtpEnabled {
|
|
putString(ProfileSettingsKey.vlmMtpDraftModel, vlmMtpDraftModel)
|
|
putInt(ProfileSettingsKey.vlmMtpDraftBlockSize, vlmMtpDraftBlockSize)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// MARK: - Profile actions
|
|
|
|
/// Apply a chip's profile to the model. Discards any working-profile
|
|
/// state per chat2.md: "Any unsaved work is silently dispatched."
|
|
/// `.preset` is routed through `applyPreset(_:client:)` — that path
|
|
/// receives the bundle entry directly since presets aren't stored as
|
|
/// server templates.
|
|
func applyChip(scope: ProfileScope, name: String, client: OMLXClient) async {
|
|
do {
|
|
switch scope {
|
|
case .preset:
|
|
// Caller dispatches via applyPreset(_:client:) — this
|
|
// branch is a defensive no-op so misrouted calls don't
|
|
// hit a template lookup that's guaranteed to miss.
|
|
return
|
|
case .model:
|
|
_ = try await client.applyModelProfile(id: modelID, name: name)
|
|
case .global:
|
|
// Templates aren't directly applicable — seed a model
|
|
// profile from the template, then apply it. Reuse the
|
|
// template's name; if a same-named model profile already
|
|
// exists we leave it alone (server returns 409, we
|
|
// silently fall through to apply).
|
|
if !self.profiles.contains(where: { $0.name == name }) {
|
|
if let tpl = self.templates.first(where: { $0.name == name }) {
|
|
_ = try? await client.createModelProfile(
|
|
id: modelID,
|
|
body: CreateProfileRequest(
|
|
name: tpl.name,
|
|
displayName: tpl.displayName,
|
|
description: tpl.description,
|
|
sourceTemplate: tpl.name,
|
|
settings: tpl.settings
|
|
)
|
|
)
|
|
}
|
|
}
|
|
_ = try await client.applyModelProfile(id: modelID, name: name)
|
|
}
|
|
await load(modelID: modelID, client: client)
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
/// Rename a global template via PUT /api/profile-templates/{name}.
|
|
/// Server validates the slug + duplicate; we already pre-checked
|
|
/// in ProfileGroup, but the server stays the source of truth for
|
|
/// the activated state — reload after success.
|
|
func renameTemplate(from original: String, to renamed: String, client: OMLXClient) async {
|
|
do {
|
|
_ = try await client.updateProfileTemplate(
|
|
name: original,
|
|
body: UpdateTemplateRequest(newName: renamed)
|
|
)
|
|
await load(modelID: modelID, client: client)
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
/// Rename a per-model profile via PUT /api/models/{id}/profiles/{name}.
|
|
/// If the renamed profile was active, the server carries the active
|
|
/// pointer to the new name; reload to pick that up.
|
|
func renameModelProfile(from original: String, to renamed: String, client: OMLXClient) async {
|
|
do {
|
|
_ = try await client.updateModelProfile(
|
|
id: modelID,
|
|
name: original,
|
|
body: UpdateProfileRequest(newName: renamed)
|
|
)
|
|
await load(modelID: modelID, client: client)
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
/// Flip `expose_as_model` on a per-model profile via PUT. The body
|
|
/// carries only the flag (absent fields are merge-no-ops server-side);
|
|
/// reload picks up the derived `model_id` the profile serves under.
|
|
func setExposeAsModel(name: String, exposed: Bool, client: OMLXClient) async {
|
|
do {
|
|
_ = try await client.updateModelProfile(
|
|
id: modelID,
|
|
name: name,
|
|
body: UpdateProfileRequest(exposeAsModel: exposed)
|
|
)
|
|
await load(modelID: modelID, client: client)
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
/// Apply a bundled preset entry to the model. Seeds a per-model
|
|
/// profile (named after the preset, no `sourceTemplate` since presets
|
|
/// aren't stored as server templates) and activates it. Mirrors
|
|
/// HTML's behavior of materializing a preset as a model profile on
|
|
/// first apply.
|
|
func applyPreset(_ entry: PresetEntry, client: OMLXClient) async {
|
|
do {
|
|
if !self.profiles.contains(where: { $0.name == entry.name }) {
|
|
_ = try? await client.createModelProfile(
|
|
id: modelID,
|
|
body: CreateProfileRequest(
|
|
name: entry.name,
|
|
displayName: entry.displayName,
|
|
description: entry.description,
|
|
sourceTemplate: nil,
|
|
settings: entry.settings
|
|
)
|
|
)
|
|
}
|
|
_ = try await client.applyModelProfile(id: modelID, name: entry.name)
|
|
await load(modelID: modelID, client: client)
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
/// Save the current working settings as a new profile (model scope)
|
|
/// or template (global scope), then activate it. Used by both the
|
|
/// Active Profile banner's "Save as new" and a chip group's
|
|
/// "Save current as new" pill.
|
|
func saveWorkingAs(scope: ProfileScope, name: String, client: OMLXClient) async {
|
|
let cleanName = name.trimmingCharacters(in: .whitespaces)
|
|
guard !cleanName.isEmpty, scope != .preset else { return }
|
|
let settings = currentSettingsDict()
|
|
do {
|
|
switch scope {
|
|
case .global:
|
|
_ = try await client.createProfileTemplate(
|
|
body: CreateTemplateRequest(
|
|
name: cleanName,
|
|
displayName: cleanName,
|
|
description: nil,
|
|
settings: settings
|
|
)
|
|
)
|
|
// Seed a per-model profile from the new template and apply it.
|
|
_ = try? await client.createModelProfile(
|
|
id: modelID,
|
|
body: CreateProfileRequest(
|
|
name: cleanName,
|
|
displayName: cleanName,
|
|
sourceTemplate: cleanName,
|
|
settings: settings
|
|
)
|
|
)
|
|
case .model:
|
|
_ = try await client.createModelProfile(
|
|
id: modelID,
|
|
body: CreateProfileRequest(
|
|
name: cleanName,
|
|
displayName: cleanName,
|
|
settings: settings
|
|
)
|
|
)
|
|
case .preset:
|
|
return
|
|
}
|
|
_ = try await client.applyModelProfile(id: modelID, name: cleanName)
|
|
await load(modelID: modelID, client: client)
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
/// Overwrite an existing profile/template with the current working
|
|
/// settings. Used by the Active Profile banner's "Update X" and the
|
|
/// ProfileDetailCard preview's "Update with working" button.
|
|
func updateProfileWithWorking(scope: ProfileScope, name: String, client: OMLXClient) async {
|
|
guard scope != .preset else { return }
|
|
let settings = currentSettingsDict()
|
|
do {
|
|
switch scope {
|
|
case .global:
|
|
_ = try await client.updateProfileTemplate(
|
|
name: name,
|
|
body: UpdateTemplateRequest(settings: settings)
|
|
)
|
|
// Update the same-named model profile too so the next
|
|
// /apply lands the latest settings.
|
|
if self.profiles.contains(where: { $0.name == name }) {
|
|
_ = try? await client.updateModelProfile(
|
|
id: modelID,
|
|
name: name,
|
|
body: UpdateProfileRequest(settings: settings)
|
|
)
|
|
}
|
|
case .model:
|
|
_ = try await client.updateModelProfile(
|
|
id: modelID,
|
|
name: name,
|
|
body: UpdateProfileRequest(settings: settings)
|
|
)
|
|
case .preset:
|
|
return
|
|
}
|
|
// If this profile is the active one, re-apply so the runtime
|
|
// picks up the new values; if not, just reload.
|
|
if activeProfileName == name {
|
|
_ = try? await client.applyModelProfile(id: modelID, name: name)
|
|
}
|
|
await load(modelID: modelID, client: client)
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
/// Discard working changes by reloading the server's view.
|
|
func revertWorking(client: OMLXClient) async {
|
|
await load(modelID: modelID, client: client)
|
|
}
|
|
|
|
/// Suggest a unique default name for the Save-as popover.
|
|
func suggestSaveAsName() -> String {
|
|
let base: String
|
|
if case .working(let basedOn) = activeProfileState, let basedOn {
|
|
base = "\(basedOn.name)-copy"
|
|
} else {
|
|
base = "profile-1"
|
|
}
|
|
let taken = Set(
|
|
templates.map(\.name) + profiles.map(\.name)
|
|
)
|
|
if !taken.contains(base) { return base }
|
|
var n = 2
|
|
let trimmed = base.replacingOccurrences(
|
|
of: #"-\d+$"#, with: "", options: .regularExpression
|
|
)
|
|
var candidate = "\(trimmed)-\(n)"
|
|
while taken.contains(candidate) {
|
|
n += 1
|
|
candidate = "\(trimmed)-\(n)"
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
func applyProfile(name: String, client: OMLXClient) async {
|
|
do {
|
|
_ = try await client.applyModelProfile(id: modelID, name: name)
|
|
await load(modelID: modelID, client: client)
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
func createProfile(name: String, client: OMLXClient) async {
|
|
do {
|
|
_ = try await client.createModelProfile(
|
|
id: modelID,
|
|
body: CreateProfileRequest(
|
|
name: name, displayName: name
|
|
)
|
|
)
|
|
self.profiles = (try? await client.listModelProfiles(id: modelID).profiles) ?? []
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
func deleteProfile(name: String, client: OMLXClient) async {
|
|
guard name != "default" else { return }
|
|
do {
|
|
_ = try await client.deleteModelProfile(id: modelID, name: name)
|
|
self.profiles = (try? await client.listModelProfiles(id: modelID).profiles) ?? []
|
|
if activeProfileName == name {
|
|
activeProfileName = "default"
|
|
}
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
func applyTemplate(template: ProfileDTO, client: OMLXClient) async {
|
|
do {
|
|
_ = try await client.createModelProfile(
|
|
id: modelID,
|
|
body: CreateProfileRequest(
|
|
name: template.name,
|
|
displayName: template.displayName,
|
|
description: template.description,
|
|
sourceTemplate: template.name,
|
|
settings: template.settings
|
|
)
|
|
)
|
|
self.profiles = (try? await client.listModelProfiles(id: modelID).profiles) ?? []
|
|
} catch {
|
|
self.lastError = error.omlxDescription
|
|
}
|
|
}
|
|
|
|
|
|
/// `4.0` → `"4"`, `2.5` → `"2.5"`. The TurboQuant Popup options are
|
|
/// declared as strings; preserving an integral display avoids the
|
|
/// "4.0" mismatch that would prevent the option from highlighting.
|
|
fileprivate static func formatBits(_ v: Double) -> String {
|
|
v.rounded() == v ? String(Int(v)) : String(v)
|
|
}
|
|
|
|
/// SpecPrefill keep-pct dropdown is declared with string options like
|
|
/// "0.2"; `String(0.2)` happens to print as `"0.2"` on Darwin but
|
|
/// `"0.20"` would not match. Format defensively so the dropdown shows
|
|
/// the saved value highlighted.
|
|
fileprivate static func formatPct(_ v: Double) -> String {
|
|
// Always 1-2 decimals to match the option values.
|
|
let rounded = (v * 100).rounded() / 100
|
|
if rounded == rounded.rounded() { return String(format: "%.1f", rounded) }
|
|
return String(format: "%.2f", rounded)
|
|
}
|
|
}
|