Files
DS4Server/src/app/preferences.rs
2026-07-28 19:07:59 +02:00

539 lines
20 KiB
Rust

use super::*;
#[derive(Clone)]
pub(super) struct PreferenceDraft {
pub(super) model: ModelChoice,
pub(super) legacy_mtp_enabled: bool,
pub(super) dspark_enabled: bool,
pub(super) idle_timeout_minutes: String,
pub(super) a2ui_enabled: bool,
pub(super) endpoint_port: String,
pub(super) endpoint_enabled: bool,
pub(super) endpoint_cors: bool,
pub(super) dev_brain_enabled: bool,
pub(super) dev_brain_vault_path: String,
pub(super) git_diff_layout: GitDiffLayout,
pub(super) git_diff_algorithm: GitDiffAlgorithm,
pub(super) git_context_lines: String,
pub(super) git_interhunk_lines: String,
pub(super) git_indent_heuristic: bool,
pub(super) git_whitespace: GitDiffWhitespace,
pub(super) git_ignore_blank_lines: bool,
pub(super) context_tokens: String,
pub(super) max_generated_tokens: String,
pub(super) system_prompt: text_editor::Content,
pub(super) temperature: String,
pub(super) top_p: String,
pub(super) min_p: String,
pub(super) seed: String,
pub(super) reasoning_mode: ReasoningMode,
pub(super) cpu_threads: String,
pub(super) power_percent: String,
pub(super) prefill_chunk: String,
pub(super) quality: bool,
pub(super) warm_weights: bool,
pub(super) mtp_draft_tokens: String,
pub(super) mtp_margin: String,
pub(super) glm_mtp: bool,
pub(super) glm_mtp_timing: bool,
pub(super) dspark_confidence_threshold: String,
pub(super) dspark_strict: bool,
pub(super) ssd_streaming: bool,
pub(super) ssd_streaming_cold: bool,
pub(super) ssd_cache: String,
pub(super) ssd_full_layers: String,
pub(super) ssd_preload_experts: String,
pub(super) directional_steering_file: String,
pub(super) directional_steering_ffn: String,
pub(super) directional_steering_attn: String,
pub(super) simulated_used_memory_gib: String,
pub(super) expert_profile_path: String,
pub(super) kv_budget_gib: String,
pub(super) kv_min_tokens: String,
pub(super) kv_cold_max_tokens: String,
pub(super) kv_continued_interval_tokens: String,
}
impl PreferenceDraft {
pub(super) fn from_saved(config: &Config) -> Self {
let generation = &config.generation;
let runtime = &config.runtime;
let execution = &runtime.execution;
let speculative = &runtime.speculative;
Self {
model: config.model,
legacy_mtp_enabled: speculative.legacy_mtp_enabled,
dspark_enabled: speculative.dspark_enabled,
idle_timeout_minutes: config.idle_timeout_minutes.to_string(),
a2ui_enabled: config.a2ui_enabled,
endpoint_port: config.endpoint.port.to_string(),
endpoint_enabled: config.endpoint.enabled,
endpoint_cors: config.endpoint.cors,
dev_brain_enabled: config.dev_brain.enabled,
dev_brain_vault_path: config.dev_brain.vault_path.clone().unwrap_or_default(),
git_diff_layout: config.git.diff_layout,
git_diff_algorithm: config.git.diff_algorithm,
git_context_lines: config.git.context_lines.to_string(),
git_interhunk_lines: config.git.interhunk_lines.to_string(),
git_indent_heuristic: config.git.indent_heuristic,
git_whitespace: config.git.whitespace,
git_ignore_blank_lines: config.git.ignore_blank_lines,
context_tokens: generation.context_tokens.to_string(),
max_generated_tokens: generation.max_generated_tokens.to_string(),
system_prompt: text_editor::Content::with_text(&generation.system_prompt),
temperature: optional_string(generation.temperature),
top_p: optional_string(generation.top_p),
min_p: optional_string(generation.min_p),
seed: optional_string(generation.seed),
reasoning_mode: generation.reasoning_mode,
cpu_threads: optional_string(execution.cpu_threads),
power_percent: optional_string(execution.power_percent),
prefill_chunk: optional_string(execution.prefill_chunk),
quality: execution.quality,
warm_weights: execution.warm_weights,
mtp_draft_tokens: speculative.mtp_draft_tokens.to_string(),
mtp_margin: speculative.mtp_margin.to_string(),
glm_mtp: speculative.glm_mtp,
glm_mtp_timing: speculative.glm_mtp_timing,
dspark_confidence_threshold: optional_string(speculative.dspark_confidence_threshold),
dspark_strict: speculative.dspark_strict,
ssd_streaming: runtime.ssd.enabled,
ssd_streaming_cold: runtime.ssd.cold,
ssd_cache: optional_string(runtime.ssd.cache),
ssd_full_layers: optional_string(runtime.ssd.full_layers),
ssd_preload_experts: optional_string(runtime.ssd.preload_experts),
directional_steering_file: runtime.steering.file.clone().unwrap_or_default(),
directional_steering_ffn: optional_string(runtime.steering.ffn_scale),
directional_steering_attn: optional_string(runtime.steering.attention_scale),
simulated_used_memory_gib: optional_string(
runtime.diagnostics.simulated_used_memory_gib,
),
expert_profile_path: runtime
.diagnostics
.expert_profile_path
.clone()
.unwrap_or_default(),
kv_budget_gib: optional_string(runtime.kv_cache.budget_gib),
kv_min_tokens: optional_string(runtime.kv_cache.min_tokens),
kv_cold_max_tokens: optional_string(runtime.kv_cache.cold_max_tokens),
kv_continued_interval_tokens: optional_string(
runtime.kv_cache.continued_interval_tokens,
),
}
}
pub(super) fn generation(&self) -> Result<GenerationPreferences, String> {
let preferences = GenerationPreferences {
context_tokens: parse_positive_i32("Context tokens", &self.context_tokens)?,
max_generated_tokens: parse_positive_i32(
"Maximum generated tokens",
&self.max_generated_tokens,
)?,
system_prompt: self.system_prompt.text(),
temperature: parse_optional_f32("Temperature", &self.temperature)?,
top_p: parse_optional_f32("Top-p", &self.top_p)?,
min_p: parse_optional_f32("Min-p", &self.min_p)?,
seed: parse_optional_u64("Seed", &self.seed)?,
reasoning_mode: self.reasoning_mode,
};
preferences.validate()?;
Ok(preferences)
}
pub(super) fn git(&self) -> Result<GitConfig, String> {
Ok(GitConfig {
diff_layout: self.git_diff_layout,
diff_algorithm: self.git_diff_algorithm,
context_lines: parse_u32("Git diff context lines", &self.git_context_lines)?,
interhunk_lines: parse_u32("Git diff interhunk lines", &self.git_interhunk_lines)?,
indent_heuristic: self.git_indent_heuristic,
whitespace: self.git_whitespace,
ignore_blank_lines: self.git_ignore_blank_lines,
})
}
pub(super) fn reset(&mut self) {
*self = Self::from_saved(&Config::default());
}
pub(super) fn execution(&self) -> Result<ExecutionPreferences, String> {
Ok(ExecutionPreferences {
cpu_threads: parse_optional_u32("CPU helper threads", &self.cpu_threads)?,
power_percent: parse_optional_u8("GPU power", &self.power_percent)?,
prefill_chunk: parse_optional_u32("Prefill chunk", &self.prefill_chunk)?,
quality: self.quality,
warm_weights: self.warm_weights,
})
}
pub(super) fn speculative(&self) -> Result<SpeculativePreferences, String> {
Ok(SpeculativePreferences {
mtp_draft_tokens: parse_positive_i32("MTP draft tokens", &self.mtp_draft_tokens)?,
mtp_margin: parse_f32("MTP margin", &self.mtp_margin)?,
legacy_mtp_enabled: self.legacy_mtp_enabled,
glm_mtp: self.glm_mtp,
glm_mtp_timing: self.glm_mtp_timing,
dspark_enabled: self.dspark_enabled,
dspark_confidence_threshold: parse_optional_f32(
"DSpark confidence",
&self.dspark_confidence_threshold,
)?,
dspark_strict: self.dspark_strict,
})
}
pub(super) fn runtime(&self) -> Result<RuntimePreferences, String> {
Ok(RuntimePreferences {
execution: self.execution()?,
speculative: self.speculative()?,
ssd: SsdPreferences {
enabled: self.ssd_streaming,
cold: self.ssd_streaming_cold,
cache: parse_streaming_cache(&self.ssd_cache)?,
full_layers: parse_optional_u32("SSD full-layer count", &self.ssd_full_layers)?,
preload_experts: parse_optional_u32(
"SSD preload experts",
&self.ssd_preload_experts,
)?,
},
steering: SteeringPreferences {
file: optional_text(&self.directional_steering_file),
ffn_scale: parse_optional_f32(
"Directional FFN scale",
&self.directional_steering_ffn,
)?,
attention_scale: parse_optional_f32(
"Directional attention scale",
&self.directional_steering_attn,
)?,
},
diagnostics: DiagnosticPreferences {
simulated_used_memory_gib: parse_optional_gib(
"Simulated used memory",
&self.simulated_used_memory_gib,
)?,
expert_profile_path: optional_text(&self.expert_profile_path),
},
kv_cache: KvCachePreferences {
budget_gib: parse_optional_gib("KV cache budget", &self.kv_budget_gib)?,
min_tokens: parse_optional_u32("KV cache minimum tokens", &self.kv_min_tokens)?,
cold_max_tokens: parse_optional_u32(
"KV cache cold maximum",
&self.kv_cold_max_tokens,
)?,
continued_interval_tokens: parse_optional_u32(
"KV cache continued interval",
&self.kv_continued_interval_tokens,
)?,
},
})
}
}
fn parse_positive_i32(name: &str, value: &str) -> Result<i32, String> {
value
.trim()
.parse::<i32>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| format!("{name} must be a positive whole number."))
}
fn parse_optional_f32(name: &str, value: &str) -> Result<Option<f32>, String> {
let value = value.trim();
if value.is_empty() {
Ok(None)
} else {
value
.parse()
.map(Some)
.map_err(|_| format!("{name} must be a number or left blank for the DS4 default."))
}
}
fn parse_f32(name: &str, value: &str) -> Result<f32, String> {
value
.trim()
.parse()
.map_err(|_| format!("{name} must be a number."))
}
fn parse_optional_u64(name: &str, value: &str) -> Result<Option<u64>, String> {
let value = value.trim();
if value.is_empty() {
Ok(None)
} else {
value
.parse()
.map(Some)
.map_err(|_| format!("{name} must be a positive whole number or left blank."))
}
}
fn parse_optional_u32(name: &str, value: &str) -> Result<Option<u32>, String> {
parse_optional_number(name, value)
}
fn parse_u32(name: &str, value: &str) -> Result<u32, String> {
value
.trim()
.parse()
.map_err(|_| format!("{name} must be a non-negative whole number."))
}
fn parse_optional_u8(name: &str, value: &str) -> Result<Option<u8>, String> {
parse_optional_number(name, value)
}
fn parse_optional_number<T: std::str::FromStr>(
name: &str,
value: &str,
) -> Result<Option<T>, String> {
let value = value.trim();
if value.is_empty() {
Ok(None)
} else {
value
.parse()
.map(Some)
.map_err(|_| format!("{name} must be a positive whole number or left blank."))
}
}
pub(super) fn parse_streaming_cache(value: &str) -> Result<Option<StreamingCacheBudget>, String> {
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
value.parse().map(Some)
}
pub(super) fn parse_optional_gib(name: &str, value: &str) -> Result<Option<u64>, String> {
let value = value.trim();
if value.is_empty() {
Ok(None)
} else {
parse_gib(name, value).map(Some)
}
}
fn parse_gib(name: &str, value: &str) -> Result<u64, String> {
let value = value
.get(value.len().saturating_sub(2)..)
.filter(|suffix| suffix.eq_ignore_ascii_case("gb"))
.map_or(value, |_| &value[..value.len() - 2]);
if !value.chars().all(|character| character.is_ascii_digit()) {
return Err(format!("{name} must be a positive whole GiB value."));
}
value
.parse::<u64>()
.ok()
.filter(|value| *value > 0 && *value <= u64::MAX / GIB)
.ok_or_else(|| format!("{name} must be a positive whole GiB value."))
}
fn optional_text(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
}
/// An unset preference shows as an empty field.
fn optional_string<T: ToString>(value: Option<T>) -> String {
value.map_or_else(String::new, |value| value.to_string())
}
fn update_runtime_config(runtime_config: &RwLock<Config>, config: &Config) {
*runtime_config
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = config.clone();
}
impl App {
pub(super) fn open_preferences(&mut self) -> Task<Message> {
if let Some(id) = self.preferences_window {
return window::gain_focus(id);
}
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
return Task::none();
}
self.preference_draft = PreferenceDraft::from_saved(&self.config);
self.preference_error = None;
let (id, open) = window::open(window::Settings {
size: Size::new(920.0, 700.0),
min_size: Some(Size::new(720.0, 480.0)),
icon: Some(app_icon()),
..Default::default()
});
self.preferences_window = Some(id);
open.map(Message::PreferencesOpened)
}
pub(super) fn save_preferences(&mut self) {
let Ok(idle_timeout_minutes) = self
.preference_draft
.idle_timeout_minutes
.trim()
.parse::<i32>()
else {
self.preference_error = Some("Idle timeout must be a whole number.".into());
return;
};
let Ok(endpoint_port) = self.preference_draft.endpoint_port.trim().parse::<u16>() else {
self.preference_error = Some("Endpoint port must be a whole number.".into());
return;
};
let generation = match self.preference_draft.generation() {
Ok(generation) => generation,
Err(error) => {
self.preference_error = Some(error);
return;
}
};
let runtime = match self.preference_draft.runtime() {
Ok(runtime) => runtime,
Err(error) => {
self.preference_error = Some(error);
return;
}
};
let git = match self.preference_draft.git() {
Ok(git) => git,
Err(error) => {
self.preference_error = Some(error);
return;
}
};
let config = Config {
model: self.preference_draft.model,
idle_timeout_minutes,
a2ui_enabled: self.preference_draft.a2ui_enabled,
endpoint: EndpointConfig {
port: i32::from(endpoint_port),
enabled: self.preference_draft.endpoint_enabled,
cors: self.preference_draft.endpoint_cors,
},
dev_brain: DevBrainConfig {
enabled: self.preference_draft.dev_brain_enabled,
vault_path: optional_text(&self.preference_draft.dev_brain_vault_path),
},
generation,
runtime,
git,
interface: self.config.interface.clone(),
};
if let Err(error) = config.validate() {
self.preference_error = Some(error);
return;
}
if config.dev_brain.enabled {
let projects = self
.projects
.iter()
.map(|project| project.project.clone())
.collect::<Vec<_>>();
if let Err(error) = crate::dev_brain::DevBrain::open(&config.dev_brain, &projects) {
self.preference_error = Some(error);
return;
}
}
let dev_brain_changed = self.config.dev_brain != config.dev_brain;
#[cfg(target_os = "macos")]
let endpoint_changed = self.config.endpoint != config.endpoint;
#[cfg(target_os = "macos")]
if endpoint_changed
&& self.config.endpoint.port == config.endpoint.port
&& self._endpoint.is_some()
{
self._endpoint = None;
}
let pending_endpoint =
if config.endpoint.enabled && (endpoint_changed || self._endpoint.is_none()) {
let Some(generation) = &self.generation_service else {
self.preference_error = Some("The model runtime is unavailable.".into());
return;
};
match crate::server::ServerHandle::spawn(
generation.clone(),
Arc::clone(&self.runtime_config),
models_path(),
application_support_path().join("kv-cache").join("http"),
endpoint_port,
config.endpoint.cors,
Arc::clone(&self.metrics),
) {
Ok(endpoint) => Some(endpoint),
Err(error) => {
self.preference_error = Some(error);
return;
}
}
} else {
None
};
if let Err(error) = config.save(&config_path()) {
self.preference_error = Some(error);
return;
}
self.config = config;
#[cfg(target_os = "macos")]
if dev_brain_changed {
self.invalidate_dev_brain_context();
}
#[cfg(target_os = "macos")]
update_runtime_config(&self.runtime_config, &self.config);
#[cfg(target_os = "macos")]
if endpoint_changed {
self._endpoint = pending_endpoint;
} else if let Some(endpoint) = pending_endpoint {
self._endpoint = Some(endpoint);
}
self.preference_draft = PreferenceDraft::from_saved(&self.config);
self.preference_error = None;
self.error = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multiline_system_prompt_round_trips_through_the_editor() {
let draft = PreferenceDraft::from_saved(&Config::default());
assert_eq!(
draft.generation().unwrap().system_prompt,
crate::settings::DEFAULT_SYSTEM_PROMPT
);
}
#[test]
fn runtime_config_updates_after_lock_poisoning() {
let runtime = Arc::new(RwLock::new(Config::default()));
let poisoned = Arc::clone(&runtime);
let _ = std::thread::spawn(move || {
let _guard = poisoned.write().unwrap();
panic!("poison configuration lock");
})
.join();
let config = Config {
endpoint: EndpointConfig {
port: 4567,
..EndpointConfig::default()
},
..Config::default()
};
update_runtime_config(&runtime, &config);
assert_eq!(
runtime
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.endpoint
.port,
4567
);
}
}