Files
jundot--omlx/apps/omlx-mac/Sources/AppView/ViewModels/ServerScreenVM.swift
T
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

576 lines
25 KiB
Swift

import SwiftUI
@MainActor
@Observable
final class ServerScreenVM {
var host: String = "127.0.0.1"
var portText: String = "8000"
var logLevel: String = "info"
var autoStartOnLaunch: Bool = true
// Phase 4 — Advanced disclosure.
var sseKeepaliveMode: String = "chunk"
/// Comma-separated text shown in the input. Parsed to `[String]` on save
/// so the user can edit incrementally without intermediate trips to the
/// server. Empty string clears all aliases.
var serverAliasesText: String = ""
var basePathText: String = AppConfig.defaultBasePath()
var modelDirTexts: [String] = [""]
var hfCacheEnabled: Bool = true
var lastError: String?
private(set) var isMovingBasePath: Bool = false
// Server default profile (GlobalSettings.sampling). Backed by 6
// server-side fields; the other design rows render disabled.
var samplingContextText: String = "32768"
var samplingMaxTokensText: String = "32768"
var samplingTemperatureText: String = "1.0"
var samplingTopPText: String = "0.95"
var samplingTopKText: String = "0"
var samplingRepetitionPenaltyText: String = "1.0"
/// Last applied (effective) values used to build endpoint URLs. Distinct
/// from `host`/`portText` so the URLs don't flicker mid-edit.
var effectiveHost: String = "127.0.0.1"
var effectivePort: Int = 8000
var appliedBindAddress: String = "127.0.0.1"
/// Apply-button baselines: snapshots of each Apply-managed draft taken
/// after a successful `load()` or `applyServerSettings()`. The button's
/// `disabled` state is `drafts == baselines`, so re-snapshotting on
/// success collapses the page back to "no pending changes".
///
/// Listen Address / Log Level / SSE Keep-Alive Mode auto-apply via the
/// `bind()` wrapper and don't need baselines here.
private var baselinePortText: String = "8000"
private var baselineSamplingContextText: String = "32768"
private var baselineSamplingMaxTokensText: String = "32768"
private var baselineSamplingTemperatureText: String = "1.0"
private var baselineSamplingTopPText: String = "0.95"
private var baselineSamplingTopKText: String = "0"
private var baselineSamplingRepetitionPenaltyText: String = "1.0"
private var baselineServerAliasesText: String = ""
private var baselineModelDirs: [String] = []
private var baselineHfCacheEnabled: Bool = true
@ObservationIgnored
private weak var client: OMLXClient?
@ObservationIgnored
private var hasLoaded = false
func load(client: OMLXClient) async {
self.client = client
do {
let dto = try await client.getGlobalSettings()
self.host = dto.server.host
self.portText = String(dto.server.port)
self.logLevel = canonicalize(level: dto.server.logLevel)
self.autoStartOnLaunch = dto.server.autoStartOnLaunch ?? true
self.appliedBindAddress = dto.server.host
self.effectiveHost = AppConfig.connectableHost(for: dto.server.host)
self.effectivePort = dto.server.port
self.sseKeepaliveMode = dto.server.sseKeepaliveMode ?? "chunk"
self.serverAliasesText = dto.server.serverAliases.joined(separator: ", ")
let modelDirs = Self.cleanedModelDirs(
dto.model?.modelDirs ?? dto.model?.modelDir.map { [$0] } ?? []
)
if !modelDirs.isEmpty {
self.modelDirTexts = modelDirs
}
self.hfCacheEnabled = dto.huggingface?.hfCacheEnabled ?? true
if let s = dto.sampling {
self.samplingContextText = String(s.maxContextWindow)
self.samplingMaxTokensText = String(s.maxTokens)
self.samplingTemperatureText = trimDouble(s.temperature)
self.samplingTopPText = trimDouble(s.topP)
self.samplingTopKText = String(s.topK)
self.samplingRepetitionPenaltyText = trimDouble(s.repetitionPenalty)
}
self.lastError = nil
self.hasLoaded = true
self.snapshotApplyBaselines()
} catch {
self.lastError = error.omlxDescription
}
}
// MARK: - Apply orchestrator
/// Snapshot current draft values as the new "applied" baseline. Called
/// at the end of `load()` and after a successful `applyServerSettings()`.
private func snapshotApplyBaselines() {
let t = { (s: String) in s.trimmingCharacters(in: .whitespaces) }
baselinePortText = t(portText)
baselineSamplingContextText = t(samplingContextText)
baselineSamplingMaxTokensText = t(samplingMaxTokensText)
baselineSamplingTemperatureText = t(samplingTemperatureText)
baselineSamplingTopPText = t(samplingTopPText)
baselineSamplingTopKText = t(samplingTopKText)
baselineSamplingRepetitionPenaltyText = t(samplingRepetitionPenaltyText)
baselineServerAliasesText = serverAliasesText
baselineModelDirs = Self.normalizedModelDirs(from: modelDirTexts)
baselineHfCacheEnabled = hfCacheEnabled
}
/// Apply-button gate: true when any Apply-managed draft diverges from
/// its baseline, or when Storage has uncommitted changes. Listen
/// Address / Log Level / SSE Keep-Alive Mode auto-apply via `bind()`
/// and are intentionally excluded from this check.
func hasPendingServerChanges(services: AppServices) -> Bool {
let t = { (s: String) in s.trimmingCharacters(in: .whitespaces) }
if t(portText) != baselinePortText { return true }
if t(samplingContextText) != baselineSamplingContextText { return true }
if t(samplingMaxTokensText) != baselineSamplingMaxTokensText { return true }
if t(samplingTemperatureText) != baselineSamplingTemperatureText { return true }
if t(samplingTopPText) != baselineSamplingTopPText { return true }
if t(samplingTopKText) != baselineSamplingTopKText { return true }
if t(samplingRepetitionPenaltyText) != baselineSamplingRepetitionPenaltyText { return true }
if parseAliases(serverAliasesText) != parseAliases(baselineServerAliasesText) { return true }
if hfCacheEnabled != baselineHfCacheEnabled { return true }
return hasPendingStorageChanges(services: services)
}
/// Page-wide Apply. Validates every dirty Apply-managed field upfront
/// (bail loudly on bad input — no partial patches), bundles the
/// non-storage changes into a single `GlobalSettingsPatch`, then runs
/// the storage move flow if needed. A bundled port change rides along
/// with the storage move's single restart (passed as `port:`); a
/// port-only change with no storage move goes through
/// `applyServerEndpoint` instead.
func applyServerSettings(services: AppServices) {
let t = { (s: String) in s.trimmingCharacters(in: .whitespaces) }
var patch = GlobalSettingsPatch()
var nextPort: Int? = nil
if t(portText) != baselinePortText {
guard let p = Int(t(portText)), (1...65535).contains(p) else {
self.lastError = String(localized: "server.error.port_invalid",
defaultValue: "Port must be a number between 1 and 65535.",
comment: "Server screen error when port value is out of valid range")
return
}
patch.port = p
nextPort = p
}
if t(samplingContextText) != baselineSamplingContextText {
guard let n = Int(t(samplingContextText)), n > 0 else {
self.lastError = String(localized: "server.error.context_window_invalid",
defaultValue: "Context Window must be a positive integer.",
comment: "Server screen error when Context Window input is invalid")
return
}
patch.samplingMaxContextWindow = n
}
if t(samplingMaxTokensText) != baselineSamplingMaxTokensText {
guard let n = Int(t(samplingMaxTokensText)), n > 0 else {
self.lastError = String(localized: "server.error.max_tokens_invalid",
defaultValue: "Max Tokens must be a positive integer.",
comment: "Server screen error when Max Tokens input is invalid")
return
}
patch.samplingMaxTokens = n
}
if t(samplingTemperatureText) != baselineSamplingTemperatureText {
guard let v = Double(t(samplingTemperatureText)), v >= 0, v <= 2 else {
self.lastError = String(localized: "server.error.temperature_invalid",
defaultValue: "Temperature must be a number in [0, 2].",
comment: "Server screen error when Temperature input is out of range")
return
}
patch.samplingTemperature = v
}
if t(samplingTopPText) != baselineSamplingTopPText {
guard let v = Double(t(samplingTopPText)), v >= 0, v <= 1 else {
self.lastError = String(localized: "server.error.top_p_invalid",
defaultValue: "Top P must be a number in [0, 1].",
comment: "Server screen error when Top P input is out of range")
return
}
patch.samplingTopP = v
}
if t(samplingTopKText) != baselineSamplingTopKText {
guard let n = Int(t(samplingTopKText)), n >= 0 else {
self.lastError = String(localized: "server.error.top_k_invalid",
defaultValue: "Top K must be ≥ 0.",
comment: "Server screen error when Top K input is negative or not a number")
return
}
patch.samplingTopK = n
}
if t(samplingRepetitionPenaltyText) != baselineSamplingRepetitionPenaltyText {
guard let v = Double(t(samplingRepetitionPenaltyText)), v >= 0 else {
self.lastError = String(localized: "server.error.repetition_penalty_invalid",
defaultValue: "Repetition Penalty must be a non-negative number.",
comment: "Server screen error when Repetition Penalty is invalid")
return
}
patch.samplingRepetitionPenalty = v
}
let newAliases = parseAliases(serverAliasesText)
if newAliases != parseAliases(baselineServerAliasesText) {
patch.serverAliases = newAliases
}
if hfCacheEnabled != baselineHfCacheEnabled {
patch.hfCacheEnabled = hfCacheEnabled
}
let diff = storageDiff(services: services)
if diff.baseChanged && diff.normalizedBase.isEmpty {
self.lastError = String(localized: "server.error.base_path_empty",
defaultValue: "Base path cannot be empty.",
comment: "Server screen error when Base Path is empty on Apply")
return
}
if diff.modelDirsChanged && diff.normalizedModelDirs.isEmpty {
self.lastError = String(localized: "server.error.models_dir_empty",
defaultValue: "At least one model directory is required.",
comment: "Server screen error when all model directory fields are empty on Apply")
return
}
if diff.modelDirsChanged && !diff.baseChanged {
patch.modelDirs = diff.normalizedModelDirs
}
let patchHasFields = patch.port != nil
|| patch.samplingMaxContextWindow != nil
|| patch.samplingMaxTokens != nil
|| patch.samplingTemperature != nil
|| patch.samplingTopP != nil
|| patch.samplingTopK != nil
|| patch.samplingRepetitionPenalty != nil
|| patch.serverAliases != nil
|| patch.hfCacheEnabled != nil
|| patch.modelDirs != nil
if !patchHasFields && !diff.hasChanges {
self.lastError = String(localized: "server.error.nothing_to_apply",
defaultValue: "Nothing to apply — every field matches the current config.",
comment: "Server screen error when Apply is tapped with no pending changes")
return
}
if diff.baseChanged { isMovingBasePath = true }
Task {
defer {
Task { @MainActor in
if self.isMovingBasePath { self.isMovingBasePath = false }
}
}
do {
let oldBase = services.config.basePath
if patchHasFields, let client {
_ = try await client.updateGlobalSettings(patch)
}
if diff.baseChanged {
// Hand the bundled port to the storage flow so its single
// restart binds the new port. Without this the restart
// reuses the cached --port args and silently keeps the old
// port even though we just PATCHed the new one.
let relocatedModelDirs = diff.modelDirsChanged
? diff.normalizedModelDirs.map {
AppServices.relocate(
path: $0,
oldBase: oldBase,
newBase: diff.normalizedBase
)
}
: nil
try await services.applyStorageChanges(
basePath: diff.baseChanged ? diff.normalizedBase : nil,
modelDirs: relocatedModelDirs,
port: nextPort
)
self.basePathText = services.config.basePath
self.modelDirTexts = services.config.effectiveModelDirs
if let p = nextPort { self.effectivePort = p }
} else {
if let p = nextPort {
try await services.applyServerEndpoint(port: p)
self.effectivePort = p
}
if diff.modelDirsChanged {
var updated = services.config
updated.setModelDirs(diff.normalizedModelDirs)
services.updateConfig(updated)
self.modelDirTexts = diff.normalizedModelDirs
}
}
self.lastError = nil
self.snapshotApplyBaselines()
} catch {
self.lastError = error.omlxDescription
}
}
}
/// Parse the comma-separated aliases text into a dedupe-preserving list.
/// Used both to build the outbound patch and to diff drafts against the
/// saved baseline (which is also parsed before compare so reordering /
/// whitespace-only edits don't false-trigger Apply).
private func parseAliases(_ text: String) -> [String] {
let parts = text
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
var seen = Set<String>()
return parts.filter { seen.insert($0).inserted }
}
func modelDirText(at index: Int) -> String {
guard modelDirTexts.indices.contains(index) else { return "" }
return modelDirTexts[index]
}
func setModelDirText(_ value: String, at index: Int) {
guard modelDirTexts.indices.contains(index) else { return }
modelDirTexts[index] = value
}
func addModelDirectory(_ value: String = "") {
modelDirTexts.append(value)
}
func removeModelDirectory(at index: Int) {
guard modelDirTexts.indices.contains(index) else { return }
if modelDirTexts.count == 1 {
modelDirTexts[0] = ""
} else {
modelDirTexts.remove(at: index)
}
}
func browseModelDirectory(at index: Int) {
guard modelDirTexts.indices.contains(index) else { return }
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = String(localized: "server.model_dirs.browse.prompt",
defaultValue: "Select",
comment: "NSOpenPanel button label for choosing a model directory")
panel.message = String(localized: "server.model_dirs.browse.message",
defaultValue: "Choose a directory containing MLX model folders.",
comment: "NSOpenPanel message for choosing a model directory")
if panel.runModal() == .OK, let url = panel.url {
modelDirTexts[index] = url.path
}
}
private static func cleanedModelDirs(_ dirs: [String]) -> [String] {
dirs
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private static func normalizedPath(_ path: String) -> String {
((path.trimmingCharacters(in: .whitespacesAndNewlines) as NSString)
.expandingTildeInPath as NSString).standardizingPath
}
private static func normalizedModelDirs(from dirs: [String]) -> [String] {
var seen = Set<String>()
return dirs.compactMap { raw in
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = normalizedPath(trimmed)
return seen.insert(normalized).inserted ? normalized : nil
}
}
/// Format a double for an editable field: `1.0` → `"1.0"`, `0.95` →
/// `"0.95"`, drops trailing zeros above the first decimal.
private func trimDouble(_ v: Double) -> String {
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.1f", v)
: String(v)
}
func applyConfig(_ config: AppConfig) {
if !hasLoaded {
self.host = config.bindAddress
self.portText = String(config.port)
self.autoStartOnLaunch = config.autoStartOnLaunch
self.appliedBindAddress = config.bindAddress
self.effectiveHost = config.host
self.effectivePort = config.port
}
// basePath/modelDirs always mirror the live config before the server
// settings have loaded. After that, `model_dirs` comes from
// /global-settings so web-admin edits stay visible instead of being
// collapsed back to AppConfig's primary path on unrelated config
// notifications.
self.basePathText = config.basePath
if !hasLoaded {
self.modelDirTexts = config.effectiveModelDirs
}
}
func saveHost(services: AppServices) {
let next = host
Task {
await commit(GlobalSettingsPatch(host: next))
do {
try await services.applyServerEndpoint(host: next)
self.appliedBindAddress = next
self.effectiveHost = AppConfig.connectableHost(for: next)
} catch {
self.lastError = error.omlxDescription
}
}
}
/// True when either Storage text field differs from the current config.
/// Drives the Apply button's `disabled` state so we don't bounce the
/// server for an idempotent click.
func hasPendingStorageChanges(services: AppServices) -> Bool {
storageDiff(services: services).hasChanges
}
/// Computed diff against the last loaded settings, with tilde expansion,
/// path normalization, and duplicate removal. The primary model directory
/// is the first entry in `normalizedModelDirs`.
struct StorageDiff: Equatable {
let normalizedBase: String
let normalizedModelDirs: [String]
let baseChanged: Bool
let modelDirsChanged: Bool
var normalizedModelDir: String { normalizedModelDirs.first ?? "" }
var dirChanged: Bool { modelDirsChanged }
var hasChanges: Bool { baseChanged || modelDirsChanged }
}
func storageDiff(services: AppServices) -> StorageDiff {
let normalizedBase = Self.normalizedPath(basePathText)
let currentBase = Self.normalizedPath(services.config.basePath)
let baseChanged = !normalizedBase.isEmpty && normalizedBase != currentBase
let normalizedDirs = Self.normalizedModelDirs(from: modelDirTexts)
let currentDirs = baselineModelDirs.isEmpty
? Self.normalizedModelDirs(from: services.config.effectiveModelDirs)
: baselineModelDirs
let modelDirsChanged = normalizedDirs != currentDirs
return StorageDiff(
normalizedBase: normalizedBase,
normalizedModelDirs: normalizedDirs,
baseChanged: baseChanged,
modelDirsChanged: modelDirsChanged
)
}
/// Restart wired to the hero button. Folds any pending edits in the
/// Listen Address / Port fields into the restart so the user can either
/// hit Enter on the field OR just click Restart — both reach the same
/// place.
func restart(services: AppServices) {
let trimmedPort = portText.trimmingCharacters(in: .whitespaces)
let parsedPort = Int(trimmedPort)
let portChanged = parsedPort.map { $0 != effectivePort } ?? false
let hostChanged = host != appliedBindAddress
if portChanged, let p = parsedPort, !(1...65535).contains(p) {
self.lastError = String(localized: "server.error.port_invalid",
defaultValue: "Port must be a number between 1 and 65535.",
comment: "Server screen error when port value is out of valid range")
return
}
if portChanged && parsedPort == nil {
self.lastError = String(localized: "server.error.port_invalid",
defaultValue: "Port must be a number between 1 and 65535.",
comment: "Server screen error when port value is out of valid range")
return
}
Task {
do {
if portChanged || hostChanged {
if portChanged, let p = parsedPort {
await commit(GlobalSettingsPatch(port: p))
}
if hostChanged {
await commit(GlobalSettingsPatch(host: host))
}
try await services.applyServerEndpoint(
host: hostChanged ? host : nil,
port: portChanged ? parsedPort : nil
)
if let p = parsedPort, portChanged { self.effectivePort = p }
if hostChanged {
self.appliedBindAddress = host
self.effectiveHost = AppConfig.connectableHost(for: host)
}
} else {
try await services.restartServer()
}
} catch {
self.lastError = error.omlxDescription
}
}
}
func saveLogLevel() {
Task { await commit(GlobalSettingsPatch(logLevel: logLevel)) }
}
func saveSseKeepaliveMode() {
Task { await commit(GlobalSettingsPatch(sseKeepaliveMode: sseKeepaliveMode)) }
}
func saveAutoStartOnLaunch(services: AppServices) {
let enabled = autoStartOnLaunch
Task {
do {
switch services.serverState {
case .running, .unresponsive:
if await commit(GlobalSettingsPatch(autoStartOnLaunch: enabled)) {
try services.setAutoStartOnLaunch(enabled, persist: false)
}
default:
try services.setAutoStartOnLaunch(enabled)
}
} catch {
self.lastError = error.omlxDescription
}
}
}
/// Build a `Binding` that calls `save` after the value changes. Used for
/// Popups that have no `onSubmit` hook.
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() }
}
)
}
@discardableResult
private func commit(_ patch: GlobalSettingsPatch) async -> Bool {
guard let client else { return false }
do {
_ = try await client.updateGlobalSettings(patch)
self.lastError = nil
return true
} catch {
self.lastError = error.omlxDescription
return false
}
}
private func canonicalize(level raw: String) -> String {
switch raw.lowercased() {
case "warn": return "warning"
default: return raw.lowercased()
}
}
}