750 lines
39 KiB
Swift
750 lines
39 KiB
Swift
// PR 7 — real Server screen. ServerHero shows live ServerProcess.State and
|
||
// drives Start / Stop / Restart through AppServices. Network + Logging rows
|
||
// read/write `/admin/api/global-settings`.
|
||
//
|
||
// Scope vs design: rows whose backing field doesn't exist server-side yet
|
||
// (CORS, HTTPS, Request Timeout, Telemetry, GPU memory, KV-cache quant) are
|
||
// deferred until those settings are added to GlobalSettingsRequest. We keep
|
||
// the shipped surface honest: every row in this screen is fully wired.
|
||
|
||
import SwiftUI
|
||
import AppKit
|
||
|
||
struct ServerScreen: View {
|
||
@Environment(AppServices.self) private var services
|
||
@State private var vm = ServerScreenVM()
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
ServerHeroCard(vm: vm)
|
||
|
||
SectionHeader(String(localized: "server.section.network",
|
||
defaultValue: "Network",
|
||
comment: "Section heading for the Network rows in Server screen"))
|
||
ListGroup {
|
||
Row(label: String(localized: "server.row.listen_address",
|
||
defaultValue: "Listen Address",
|
||
comment: "Row label for the listen-address picker in Server screen")) {
|
||
Popup(
|
||
selection: vm.bind($vm.host, save: { vm.saveHost(services: services) }),
|
||
width: 220,
|
||
options: [
|
||
("127.0.0.1", String(localized: "server.host.local_only",
|
||
defaultValue: "127.0.0.1 (Local only)",
|
||
comment: "Listen-address popup option for loopback only")),
|
||
("0.0.0.0", String(localized: "server.host.all_networks",
|
||
defaultValue: "0.0.0.0 (All networks)",
|
||
comment: "Listen-address popup option for binding to all interfaces")),
|
||
("localhost", String(localized: "server.host.localhost",
|
||
defaultValue: "localhost",
|
||
comment: "Listen-address popup option for localhost")),
|
||
]
|
||
)
|
||
}
|
||
Row(
|
||
label: String(localized: "server.row.port",
|
||
defaultValue: "Port",
|
||
comment: "Row label for the server port field"),
|
||
sublabel: String(localized: "server.row.port.sub",
|
||
defaultValue: "Default 8000. Server restarts on save.",
|
||
comment: "Sublabel under the Port field"),
|
||
isLast: true
|
||
) {
|
||
TextInput(text: $vm.portText, mono: true, width: 90)
|
||
}
|
||
}
|
||
|
||
SectionHeader(String(localized: "server.section.endpoints",
|
||
defaultValue: "API Endpoints",
|
||
comment: "Section heading for the API endpoints list"))
|
||
APIEndpointsList(host: vm.effectiveHost, port: vm.effectivePort)
|
||
|
||
SectionHeader(
|
||
String(localized: "server.section.default_profile",
|
||
defaultValue: "Default Profile",
|
||
comment: "Section heading for the default sampling profile editor"),
|
||
subtitle: String(localized: "server.section.default_profile.sub",
|
||
defaultValue: "Fallback values used when a model has no profile, or when a profile leaves a field empty",
|
||
comment: "Subtitle for the Default Profile section")
|
||
)
|
||
// Deep-link target for the per-model Profiles tab's "Edit on
|
||
// Server →" link (see AppServices.ServerAnchor.defaultProfile).
|
||
.id(ServerAnchor.defaultProfile.rawValue)
|
||
ServerDefaultProfileEditor(vm: vm)
|
||
|
||
SectionHeader(String(localized: "server.section.startup",
|
||
defaultValue: "Server Startup",
|
||
comment: "Section heading for server startup behavior settings"))
|
||
ListGroup {
|
||
Row(
|
||
label: String(localized: "server.row.auto_start_on_launch",
|
||
defaultValue: "Automatically start server on launch",
|
||
comment: "Row label for automatically starting the managed server when the macOS app launches"),
|
||
sublabel: String(localized: "server.row.auto_start_on_launch.sub",
|
||
defaultValue: "When disabled, the menu bar app opens without starting the server.",
|
||
comment: "Sublabel explaining the auto-start on launch setting"),
|
||
isLast: true
|
||
) {
|
||
Toggle("", isOn: vm.bind($vm.autoStartOnLaunch, save: {
|
||
vm.saveAutoStartOnLaunch(services: services)
|
||
}))
|
||
.labelsHidden()
|
||
.toggleStyle(.switch)
|
||
}
|
||
}
|
||
|
||
SectionHeader(String(localized: "server.section.logging",
|
||
defaultValue: "Logging",
|
||
comment: "Section heading for the Logging rows"))
|
||
ListGroup {
|
||
Row(label: String(localized: "server.row.log_level",
|
||
defaultValue: "Log Level",
|
||
comment: "Row label for the log level picker"),
|
||
isLast: true) {
|
||
Popup(
|
||
selection: vm.bind($vm.logLevel, save: vm.saveLogLevel),
|
||
width: 130,
|
||
options: [
|
||
("error", String(localized: "server.log_level.error",
|
||
defaultValue: "Error",
|
||
comment: "Log level popup option")),
|
||
("warning", String(localized: "server.log_level.warning",
|
||
defaultValue: "Warning",
|
||
comment: "Log level popup option")),
|
||
("info", String(localized: "server.log_level.info",
|
||
defaultValue: "Info",
|
||
comment: "Log level popup option")),
|
||
("debug", String(localized: "server.log_level.debug",
|
||
defaultValue: "Debug",
|
||
comment: "Log level popup option")),
|
||
("trace", String(localized: "server.log_level.trace",
|
||
defaultValue: "Trace",
|
||
comment: "Log level popup option")),
|
||
]
|
||
)
|
||
}
|
||
}
|
||
|
||
SectionHeader(
|
||
String(localized: "server.section.storage",
|
||
defaultValue: "Storage",
|
||
comment: "Section heading for storage rows in Server screen"),
|
||
subtitle: String(localized: "server.section.storage.sub",
|
||
defaultValue: "Where models, settings, logs, and the SSD cache live.",
|
||
comment: "Subtitle for the Storage section in Server screen")
|
||
)
|
||
ListGroup {
|
||
Row(
|
||
label: String(localized: "server.row.base_path",
|
||
defaultValue: "Base Path",
|
||
comment: "Row label for the Base Path text input"),
|
||
sublabel: String(localized: "server.row.base_path.sub",
|
||
defaultValue: "OMLX_BASE_PATH. Files move and the server restarts when this changes.",
|
||
comment: "Sublabel under the Base Path field")
|
||
) {
|
||
TextInput(text: $vm.basePathText, mono: true, width: 280)
|
||
}
|
||
FreeRow {
|
||
ModelDirectoriesEditor(vm: vm)
|
||
}
|
||
Row(
|
||
label: String(localized: "server.row.hf_cache",
|
||
defaultValue: "Use Hugging Face local cache",
|
||
comment: "Row label for enabling standard Hugging Face Hub cache model discovery"),
|
||
sublabel: String(localized: "server.row.hf_cache.sub",
|
||
defaultValue: "Discover MLX-compatible models from the standard Hugging Face Hub cache.",
|
||
comment: "Sublabel for enabling Hugging Face local cache discovery"),
|
||
isLast: true
|
||
) {
|
||
Toggle("", isOn: $vm.hfCacheEnabled)
|
||
.labelsHidden()
|
||
.toggleStyle(.switch)
|
||
}
|
||
}
|
||
ServerAdvancedSection(vm: vm)
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button(String(localized: "server.button.apply",
|
||
defaultValue: "Apply",
|
||
comment: "Button to apply pending server settings: port, default profile, storage, and aliases")) {
|
||
vm.applyServerSettings(services: services)
|
||
}
|
||
.buttonStyle(.omlx(.primary))
|
||
.disabled(!vm.hasPendingServerChanges(services: services)
|
||
|| vm.isMovingBasePath)
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.top, 6)
|
||
|
||
HintFooter(error: vm.lastError)
|
||
}
|
||
.task {
|
||
// services.config is already populated by AppDelegate before this
|
||
// view is mounted, so .onChange never fires for the initial value —
|
||
// mirror it explicitly on first appearance.
|
||
vm.applyConfig(services.config)
|
||
await vm.load(client: services.client)
|
||
}
|
||
.onChange(of: services.config) { _, _ in
|
||
vm.applyConfig(services.config)
|
||
}
|
||
.onChange(of: services.serverState) { _, _ in
|
||
// After a restart triggered by saving host/port, reload to pick
|
||
// up the new effective values.
|
||
Task { await vm.load(client: services.client) }
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ModelDirectoriesEditor: View {
|
||
var vm: ServerScreenVM
|
||
@Environment(\.omlxTheme) private var theme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 9) {
|
||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(String(localized: "server.row.models_directories",
|
||
defaultValue: "Model Directories",
|
||
comment: "Row label for the model directories editor"))
|
||
.font(.omlxText(13, weight: .medium))
|
||
.foregroundStyle(theme.text)
|
||
Text(String(localized: "server.row.models_directories.sub",
|
||
defaultValue: "The first path is the download target. All paths are scanned for local models.",
|
||
comment: "Sublabel under the model directories editor"))
|
||
.font(.omlxText(11.5))
|
||
.foregroundStyle(theme.textSecondary)
|
||
}
|
||
Spacer(minLength: 12)
|
||
Button {
|
||
vm.addModelDirectory()
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
.buttonStyle(.omlx(.normal, size: .small))
|
||
.help(String(localized: "server.model_dirs.add.help",
|
||
defaultValue: "Add model directory",
|
||
comment: "Tooltip for the add model directory button"))
|
||
}
|
||
|
||
VStack(spacing: 7) {
|
||
ForEach(Array(vm.modelDirTexts.indices), id: \.self) { index in
|
||
modelDirRow(index: index)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func modelDirRow(index: Int) -> some View {
|
||
HStack(spacing: 7) {
|
||
Text(index == 0
|
||
? String(localized: "server.model_dirs.primary",
|
||
defaultValue: "Primary",
|
||
comment: "Badge for the first model directory")
|
||
: String(format: "#%d", index + 1))
|
||
.font(.omlxMono(10, weight: .semibold))
|
||
.foregroundStyle(index == 0 ? theme.accent : theme.textSecondary)
|
||
.frame(width: 52, alignment: .leading)
|
||
|
||
TextInput(text: Binding(
|
||
get: { vm.modelDirText(at: index) },
|
||
set: { vm.setModelDirText($0, at: index) }
|
||
), mono: true, width: 340)
|
||
|
||
Button {
|
||
vm.browseModelDirectory(at: index)
|
||
} label: {
|
||
Image(systemName: "folder")
|
||
}
|
||
.buttonStyle(.omlx(.plain, size: .small))
|
||
.help(String(localized: "server.model_dirs.browse.help",
|
||
defaultValue: "Choose folder",
|
||
comment: "Tooltip for choosing a model directory"))
|
||
|
||
Button {
|
||
vm.removeModelDirectory(at: index)
|
||
} label: {
|
||
Image(systemName: "trash")
|
||
}
|
||
.buttonStyle(.omlx(.plain, size: .small))
|
||
.help(String(localized: "server.model_dirs.remove.help",
|
||
defaultValue: "Remove model directory",
|
||
comment: "Tooltip for removing a model directory"))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Server hero
|
||
|
||
/// Hero card shared between the Server and Status screens (omlx-screens.jsx
|
||
/// uses the same component on both, lines 75 and 833). When a `vm` is wired
|
||
/// in (Server screen), the Restart button folds any pending port/host edits
|
||
/// into the restart. On the Status screen there is no such VM, so it just
|
||
/// asks AppServices to bounce the cached endpoint.
|
||
struct ServerHeroCard: View {
|
||
var vm: ServerScreenVM? = nil
|
||
|
||
@Environment(AppServices.self) private var services
|
||
@Environment(\.omlxTheme) private var theme
|
||
|
||
var body: some View {
|
||
HStack(spacing: 16) {
|
||
// App logo — `AppLogo` asset ships the rounded omlx mark (same
|
||
// artwork as the README's hero icon, light/dark variants).
|
||
// Replaces the previous gradient squircle + "oM" placeholder
|
||
// so the hero card on Server / Status reads as oMLX rather
|
||
// than a generic Apple-style server icon.
|
||
Image("AppLogo")
|
||
.resizable()
|
||
.interpolation(.high)
|
||
.frame(width: 52, height: 52)
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 10) {
|
||
Text(title)
|
||
.font(.omlxText(18, weight: .semibold))
|
||
.foregroundStyle(theme.text)
|
||
StatusPill(status: pillStatus)
|
||
}
|
||
Text(subtitle)
|
||
.font(.omlxText(11.5))
|
||
.foregroundStyle(theme.textSecondary)
|
||
}
|
||
Spacer(minLength: 12)
|
||
buttons
|
||
}
|
||
.padding(18)
|
||
// Hero card surface follows the grouped Settings style. Status is
|
||
// carried by the pill/actions, not by a colored background wash.
|
||
.background(heroBackground)
|
||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.padding(.horizontal, 14)
|
||
.padding(.bottom, 14)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var buttons: some View {
|
||
switch services.serverState {
|
||
case .running, .unresponsive:
|
||
HStack(spacing: 6) {
|
||
Button {
|
||
// Pick up any pending edits in Listen Address / Port that
|
||
// weren't committed via Enter/blur, so this button is a
|
||
// single "apply + restart" affordance rather than only
|
||
// restarting on the cached endpoint. When the hero is
|
||
// mounted without a VM (Status screen) we simply bounce.
|
||
if let vm {
|
||
vm.restart(services: services)
|
||
} else {
|
||
Task { try? await services.restartServer() }
|
||
}
|
||
} label: {
|
||
Label(String(localized: "server.hero.restart",
|
||
defaultValue: "Restart",
|
||
comment: "Hero card button to restart the server"),
|
||
systemImage: "arrow.clockwise")
|
||
.labelStyle(.titleAndIcon)
|
||
}
|
||
.buttonStyle(.omlx(.normal))
|
||
|
||
Button {
|
||
Task { await services.stopServer() }
|
||
} label: {
|
||
Label(String(localized: "server.hero.stop",
|
||
defaultValue: "Stop",
|
||
comment: "Hero card button to stop the server"),
|
||
systemImage: "stop.fill")
|
||
.labelStyle(.titleAndIcon)
|
||
}
|
||
.buttonStyle(.omlx(.destructive))
|
||
}
|
||
|
||
case .starting, .stopping:
|
||
Button(String(localized: "server.hero.working",
|
||
defaultValue: "Working…",
|
||
comment: "Hero card button label shown while the server is transitioning between states")) { }
|
||
.buttonStyle(.omlx(.normal))
|
||
.disabled(true)
|
||
|
||
case .stopped, .failed:
|
||
Button {
|
||
_ = try? services.startServer()
|
||
} label: {
|
||
Label(String(localized: "server.hero.start",
|
||
defaultValue: "Start Server",
|
||
comment: "Hero card button to start the server"),
|
||
systemImage: "play.fill")
|
||
.labelStyle(.titleAndIcon)
|
||
}
|
||
.buttonStyle(.omlx(.primary))
|
||
.disabled(!services.hasServer)
|
||
}
|
||
}
|
||
|
||
private var pillStatus: StatusPill.Status {
|
||
switch services.serverState {
|
||
case .running: return .running
|
||
case .starting: return .starting
|
||
case .stopping: return .stopping
|
||
case .stopped: return .stopped
|
||
case .unresponsive: return .custom(color: theme.amberDot,
|
||
label: String(localized: "server.hero.pill.unresponsive",
|
||
defaultValue: "Unresponsive",
|
||
comment: "Status pill label when the server process is alive but not answering health checks"),
|
||
fillBg: true)
|
||
case .failed: return .error
|
||
}
|
||
}
|
||
|
||
private var subtitle: String {
|
||
let host = services.config.host
|
||
let port = services.config.port
|
||
switch services.serverState {
|
||
case .running, .unresponsive:
|
||
return String(localized: "server.hero.subtitle.listening",
|
||
defaultValue: "Listening on \(host):\(String(port))",
|
||
comment: "Hero subtitle while server is running; placeholders are host and port (port is plain integer, no grouping)")
|
||
case .starting:
|
||
return String(localized: "server.hero.subtitle.starting",
|
||
defaultValue: "Starting on \(host):\(String(port))…",
|
||
comment: "Hero subtitle while server is starting up; placeholders are host and port (port is plain integer, no grouping)")
|
||
case .stopping:
|
||
return String(localized: "server.hero.subtitle.stopping",
|
||
defaultValue: "Stopping…",
|
||
comment: "Hero subtitle while server is shutting down")
|
||
case .stopped:
|
||
return String(localized: "server.hero.subtitle.not_running",
|
||
defaultValue: "Not running",
|
||
comment: "Hero subtitle when server is stopped")
|
||
case .failed(let m):
|
||
return m
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var heroBackground: some View {
|
||
theme.groupBg
|
||
}
|
||
|
||
private var title: String {
|
||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||
let trimmed = version?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return trimmed.isEmpty ? "oMLX" : "oMLX \(trimmed)"
|
||
}
|
||
}
|
||
|
||
// MARK: - Default profile editor
|
||
|
||
/// Editor for `GlobalSettings.sampling` (server-wide defaults).
|
||
///
|
||
/// The HTML design surfaces 15 fields here. The server's `SamplingSettings`
|
||
/// dataclass currently backs 6 (context, max-tokens, temperature, top_p,
|
||
/// top_k, repetition_penalty). The other 9 (min_p, presence_penalty, TTL,
|
||
/// thinking, force sampling, pin, etc) are per-model only — we render
|
||
/// them disabled with a "Per-model only" tag so the user knows where to
|
||
/// look. Expander mirrors the design's "Show all fields…" affordance.
|
||
private struct ServerDefaultProfileEditor: View {
|
||
@Bindable var vm: ServerScreenVM
|
||
|
||
@State private var expanded: Bool = false
|
||
@Environment(\.omlxTheme) private var theme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
ListGroup {
|
||
Row(label: String(localized: "server.profile.context_window",
|
||
defaultValue: "Context Window",
|
||
comment: "Row label for the context window field in Default Profile"),
|
||
sublabel: String(localized: "server.profile.context_window.sub",
|
||
defaultValue: "Maximum prompt + completion tokens.",
|
||
comment: "Sublabel for the context window field")) {
|
||
TextInput(text: $vm.samplingContextText, mono: true, suffix: "tk", width: 110)
|
||
}
|
||
Row(label: String(localized: "server.profile.max_tokens",
|
||
defaultValue: "Max Tokens",
|
||
comment: "Row label for the max tokens field"),
|
||
sublabel: String(localized: "server.profile.max_tokens.sub",
|
||
defaultValue: "Server-wide cap on generated tokens.",
|
||
comment: "Sublabel for the max tokens field")) {
|
||
TextInput(text: $vm.samplingMaxTokensText, mono: true, suffix: "tk", width: 110)
|
||
}
|
||
Row(label: String(localized: "server.profile.temperature",
|
||
defaultValue: "Temperature",
|
||
comment: "Row label for the temperature field"),
|
||
sublabel: String(localized: "server.profile.temperature.sub",
|
||
defaultValue: "Sampling randomness (0–2).",
|
||
comment: "Sublabel for the temperature field")) {
|
||
TextInput(text: $vm.samplingTemperatureText, placeholder: "0.7", mono: true, width: 90)
|
||
}
|
||
Row(label: String(localized: "server.profile.top_p",
|
||
defaultValue: "Top P",
|
||
comment: "Row label for the Top P field"),
|
||
sublabel: String(localized: "server.profile.top_p.sub",
|
||
defaultValue: "Nucleus sampling cutoff (0–1).",
|
||
comment: "Sublabel for the Top P field")) {
|
||
TextInput(text: $vm.samplingTopPText, mono: true, width: 90)
|
||
}
|
||
Row(label: String(localized: "server.profile.top_k",
|
||
defaultValue: "Top K",
|
||
comment: "Row label for the Top K field"),
|
||
sublabel: String(localized: "server.profile.top_k.sub",
|
||
defaultValue: "Limit candidates to top K. 0 = disabled.",
|
||
comment: "Sublabel for the Top K field")) {
|
||
TextInput(text: $vm.samplingTopKText, mono: true, width: 90)
|
||
}
|
||
Row(label: String(localized: "server.profile.repetition_penalty",
|
||
defaultValue: "Repetition Penalty",
|
||
comment: "Row label for the repetition penalty field"),
|
||
sublabel: String(localized: "server.profile.repetition_penalty.sub",
|
||
defaultValue: "Penalize repeated tokens.",
|
||
comment: "Sublabel for the repetition penalty field"),
|
||
isLast: !expanded
|
||
) {
|
||
TextInput(text: $vm.samplingRepetitionPenaltyText, mono: true, width: 90)
|
||
}
|
||
if expanded {
|
||
// The remaining design rows aren't server-backed yet —
|
||
// surfaced disabled with a "Per-model only" pill so the
|
||
// user knows to set them in the per-model Advanced tab.
|
||
perModelOnlyRow(label: String(localized: "server.profile.min_p",
|
||
defaultValue: "Min P",
|
||
comment: "Disabled row label for Min P"),
|
||
note: String(localized: "server.profile.min_p.note",
|
||
defaultValue: "Server defaults don't include min_p; set on a model profile.",
|
||
comment: "Note explaining Min P is per-model only"))
|
||
perModelOnlyRow(label: String(localized: "server.profile.presence_penalty",
|
||
defaultValue: "Presence Penalty",
|
||
comment: "Disabled row label for Presence Penalty"),
|
||
note: String(localized: "server.profile.presence_penalty.note",
|
||
defaultValue: "Per-model only.",
|
||
comment: "Note marking Presence Penalty as per-model only"))
|
||
perModelOnlyRow(label: String(localized: "server.profile.ttl",
|
||
defaultValue: "TTL",
|
||
comment: "Disabled row label for TTL"),
|
||
note: String(localized: "server.profile.ttl.note",
|
||
defaultValue: "Per-model only — see Models → [model] → Basic.",
|
||
comment: "Note explaining TTL is per-model only and where to find it"))
|
||
perModelOnlyRow(label: String(localized: "server.profile.enable_thinking",
|
||
defaultValue: "Enable Thinking",
|
||
comment: "Disabled row label for Enable Thinking"),
|
||
note: String(localized: "server.profile.enable_thinking.note",
|
||
defaultValue: "Per-model only — set on a profile.",
|
||
comment: "Note marking Enable Thinking as per-model only"))
|
||
perModelOnlyRow(label: String(localized: "server.profile.limit_tool_output",
|
||
defaultValue: "Limit Tool Output",
|
||
comment: "Disabled row label for Limit Tool Output"),
|
||
note: String(localized: "server.profile.limit_tool_output.note",
|
||
defaultValue: "Per-model only.",
|
||
comment: "Note marking Limit Tool Output as per-model only"))
|
||
perModelOnlyRow(label: String(localized: "server.profile.force_sampling",
|
||
defaultValue: "Force Sampling",
|
||
comment: "Disabled row label for Force Sampling"),
|
||
note: String(localized: "server.profile.force_sampling.note",
|
||
defaultValue: "Per-model only.",
|
||
comment: "Note marking Force Sampling as per-model only"))
|
||
perModelOnlyRow(label: String(localized: "server.profile.pin_in_memory",
|
||
defaultValue: "Pin in memory",
|
||
comment: "Disabled row label for Pin in memory"),
|
||
note: String(localized: "server.profile.pin_in_memory.note",
|
||
defaultValue: "Per-model only.",
|
||
comment: "Note marking Pin in memory as per-model only"))
|
||
perModelOnlyRow(label: String(localized: "server.profile.speculative_decoding",
|
||
defaultValue: "Speculative decoding",
|
||
comment: "Disabled row label for Speculative decoding"),
|
||
note: String(localized: "server.profile.speculative_decoding.note",
|
||
defaultValue: "Per-model only — see Models → [model] → Advanced.",
|
||
comment: "Note explaining Speculative decoding is per-model only and where to find it"),
|
||
isLast: true)
|
||
}
|
||
}
|
||
HStack {
|
||
Spacer()
|
||
Button {
|
||
expanded.toggle()
|
||
} label: {
|
||
Text(expanded
|
||
? String(localized: "server.profile.show_fewer",
|
||
defaultValue: "Show fewer",
|
||
comment: "Toggle label to collapse the advanced sampling fields list")
|
||
: String(localized: "server.profile.show_all",
|
||
defaultValue: "Show all fields…",
|
||
comment: "Toggle label to expand the advanced sampling fields list"))
|
||
.font(.omlxText(11.5, weight: .medium))
|
||
}
|
||
.buttonStyle(.omlx(.plain, size: .small))
|
||
.padding(.horizontal, 14)
|
||
.padding(.top, 4)
|
||
.padding(.bottom, 10)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func perModelOnlyRow(label: String, note: String, isLast: Bool = false) -> some View {
|
||
Row(label: label, sublabel: note, isLast: isLast) {
|
||
Text(String(localized: "server.profile.per_model_only",
|
||
defaultValue: "Per-model only",
|
||
comment: "Pill text marking a sampling field as configurable only on individual model profiles"))
|
||
.font(.omlxText(10.5, weight: .heavy))
|
||
.kerning(0.6)
|
||
.textCase(.uppercase)
|
||
.foregroundStyle(theme.textTertiary)
|
||
.padding(.horizontal, 8)
|
||
.frame(height: 22)
|
||
.background(
|
||
Capsule().fill(theme.codeBg)
|
||
)
|
||
.overlay(
|
||
Capsule().strokeBorder(theme.inputBorder, lineWidth: 0.5)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Endpoints
|
||
|
||
private struct APIEndpointsList: View {
|
||
let host: String
|
||
let port: Int
|
||
|
||
var body: some View {
|
||
ListGroup {
|
||
Row(label: String(localized: "server.endpoint.openai",
|
||
defaultValue: "OpenAI-compatible",
|
||
comment: "API endpoint row label for the OpenAI-compatible base URL")) {
|
||
CodeChip(value: "http://\(host):\(port)/v1")
|
||
}
|
||
Row(label: String(localized: "server.endpoint.anthropic",
|
||
defaultValue: "Anthropic / Claude Code",
|
||
comment: "API endpoint row label for the Anthropic/Claude Code base URL")) {
|
||
CodeChip(value: "http://\(host):\(port)")
|
||
}
|
||
Row(label: String(localized: "server.endpoint.health",
|
||
defaultValue: "Health probe",
|
||
comment: "API endpoint row label for the health probe URL")) {
|
||
CodeChip(value: "http://\(host):\(port)/health")
|
||
}
|
||
Row(label: String(localized: "server.endpoint.metrics",
|
||
defaultValue: "Metrics (Prometheus)",
|
||
comment: "API endpoint row label for the Prometheus metrics URL"),
|
||
isLast: true) {
|
||
CodeChip(value: "http://\(host):\(port)/metrics")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Advanced disclosure
|
||
|
||
/// Phase 4 — Server identity / protocol knobs that the average user never
|
||
/// touches: `server_aliases` (extra host names the server identifies as for
|
||
/// cookie + host-header purposes) and `sse_keepalive_mode`. Hidden behind a
|
||
/// chevron so they don't crowd the main ServerScreen surface, but rendered
|
||
/// inline (not in a popover) so power users can scroll-find them.
|
||
private struct ServerAdvancedSection: View {
|
||
@Bindable var vm: ServerScreenVM
|
||
|
||
@State private var expanded: Bool = false
|
||
@Environment(\.omlxTheme) private var theme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Custom disclosure header — DisclosureGroup's default styling
|
||
// doesn't match the rest of the screen (uses SF Pro vs omlxText,
|
||
// adds its own padding). Roll our own to stay consistent with
|
||
// SectionHeader.
|
||
Button {
|
||
expanded.toggle()
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(theme.textSecondary)
|
||
.rotationEffect(.degrees(expanded ? 90 : 0))
|
||
.animation(.easeOut(duration: 0.12), value: expanded)
|
||
Text(String(localized: "server.section.advanced",
|
||
defaultValue: "Advanced",
|
||
comment: "Disclosure header for the advanced server settings"))
|
||
.font(.omlxText(11, weight: .semibold))
|
||
.foregroundStyle(theme.textSecondary)
|
||
.textCase(.uppercase)
|
||
.kerning(0.6)
|
||
Spacer()
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.horizontal, 18)
|
||
.padding(.top, 22)
|
||
.padding(.bottom, 8)
|
||
|
||
if expanded {
|
||
ListGroup {
|
||
Row(
|
||
label: String(localized: "server.advanced.sse_keepalive",
|
||
defaultValue: "SSE Keep-Alive Mode",
|
||
comment: "Advanced row label for the SSE keep-alive mode picker"),
|
||
sublabel: String(localized: "server.advanced.sse_keepalive.sub",
|
||
defaultValue: "How the server keeps long-lived SSE streams open. \"chunk\" emits an empty data line, \"comment\" emits `: ping`, \"off\" disables.",
|
||
comment: "Sublabel describing the SSE keep-alive mode options")
|
||
) {
|
||
Popup(
|
||
selection: vm.bind($vm.sseKeepaliveMode, save: vm.saveSseKeepaliveMode),
|
||
width: 130,
|
||
options: [
|
||
("chunk", String(localized: "server.advanced.sse_keepalive.chunk",
|
||
defaultValue: "Chunk",
|
||
comment: "SSE keep-alive mode option: empty data chunk")),
|
||
("comment", String(localized: "server.advanced.sse_keepalive.comment",
|
||
defaultValue: "Comment",
|
||
comment: "SSE keep-alive mode option: comment ping")),
|
||
("off", String(localized: "server.advanced.sse_keepalive.off",
|
||
defaultValue: "Off",
|
||
comment: "SSE keep-alive mode option: disabled")),
|
||
]
|
||
)
|
||
}
|
||
Row(
|
||
label: String(localized: "server.advanced.aliases",
|
||
defaultValue: "Server Aliases",
|
||
comment: "Advanced row label for the server aliases input"),
|
||
sublabel: String(localized: "server.advanced.aliases.sub",
|
||
defaultValue: "Extra host names the server identifies as. Comma-separated. Used for cookie / Host header matching.",
|
||
comment: "Sublabel describing the server aliases input format"),
|
||
isLast: true
|
||
) {
|
||
TextInput(
|
||
text: $vm.serverAliasesText,
|
||
placeholder: "omlx.local, oMLX.lan",
|
||
mono: true,
|
||
width: 320
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Footer hint
|
||
|
||
private struct HintFooter: View {
|
||
let error: String?
|
||
@Environment(\.omlxTheme) private var theme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HintLine(text: String(localized: "server.footer.hint",
|
||
defaultValue: "Listen Address, Log Level, and SSE Keep-Alive Mode apply the moment you change them. The rest (port, default profile, storage, aliases) commits when you click Apply. Port and storage changes take effect after a server restart.",
|
||
comment: "Hint footer text under the Server screen explaining which controls apply immediately vs. via the Apply button"))
|
||
if let error {
|
||
Text(error)
|
||
.font(.omlxText(11))
|
||
.foregroundStyle(theme.redDot)
|
||
}
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.top, 8)
|
||
}
|
||
}
|