Move preferences from the database to a YAML config file

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Georg Bauer
2026-07-26 09:50:39 +02:00
parent 3f2c42513f
commit 5d441038bc
19 changed files with 571 additions and 964 deletions

View File

@@ -79,18 +79,13 @@ impl App {
if prompt.is_empty() {
return;
}
let model = match ModelChoice::from_id(&self.preferences.selected_model) {
Some(model) => model,
None => {
self.error = Some("The selected model is not supported.".into());
return;
}
};
let effective = self.preferences.generation().and_then(|generation| {
self.preferences.runtime().and_then(|runtime| {
crate::settings::effective_settings(model, &generation, &runtime, &models_path())
})
});
let model = self.config.model;
let effective = crate::settings::effective_settings(
model,
&self.config.generation,
&self.config.runtime,
&models_path(),
);
let mut effective = match effective {
Ok(settings) => settings,
Err(error) => {
@@ -158,7 +153,7 @@ impl App {
}
};
let idle_timeout =
Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60);
Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
self.active_generation = match service.generate(
effective.engine,
effective.turn,
@@ -255,8 +250,7 @@ impl App {
Ok(GenerationEvent::Finished(result)) => {
match result {
Ok(_) => {
let model = ModelChoice::from_id(&self.preferences.selected_model)
.unwrap_or_default();
let model = self.config.model;
let content = self
.conversation
.last()
@@ -356,7 +350,7 @@ impl App {
.find(|project| project.project.id == project_id)
.map(|project| PathBuf::from(&project.project.path))
.ok_or_else(|| "The active project is unavailable.".to_owned())?;
let tools = crate::agent::Tools::new(&root, self.preferences.context_tokens)?;
let tools = crate::agent::Tools::new(&root, self.config.generation.context_tokens)?;
self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
}
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
@@ -369,12 +363,13 @@ impl App {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
let model = ModelChoice::from_id(&self.preferences.selected_model)
.ok_or_else(|| "The selected model is not supported.".to_owned())?;
let generation = self.preferences.generation()?;
let runtime = self.preferences.runtime()?;
let mut effective =
crate::settings::effective_settings(model, &generation, &runtime, &models_path())?;
let model = self.config.model;
let mut effective = crate::settings::effective_settings(
model,
&self.config.generation,
&self.config.runtime,
&models_path(),
)?;
effective.turn.system_prompt =
crate::agent::system_prompt(model, &effective.turn.system_prompt);
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
@@ -400,8 +395,7 @@ impl App {
let mut assistant = ChatMessage::from(saved.1);
assistant.reasoning_open = assistant_reasoning;
self.conversation.push(assistant);
let idle_timeout =
Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60);
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
self.active_generation = Some(
self.generation_service
.as_ref()
@@ -428,13 +422,13 @@ impl App {
if self.active_titling.is_some() {
return Err("A title is already being generated.".into());
}
let model = ModelChoice::from_id(&self.preferences.selected_model)
.ok_or_else(|| "The selected model is not supported.".to_owned())?;
let mut effective = self.preferences.generation().and_then(|generation| {
self.preferences.runtime().and_then(|runtime| {
crate::settings::effective_settings(model, &generation, &runtime, &models_path())
})
})?;
let model = self.config.model;
let mut effective = crate::settings::effective_settings(
model,
&self.config.generation,
&self.config.runtime,
&models_path(),
)?;
let database = self
.database
.as_mut()
@@ -475,8 +469,7 @@ impl App {
.generation_service
.as_ref()
.ok_or_else(|| "The model runtime is unavailable.".to_owned())?;
let idle_timeout =
Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60);
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
match service.generate(
effective.engine,
effective.turn,

View File

@@ -44,104 +44,60 @@ pub(super) struct PreferenceDraft {
}
impl PreferenceDraft {
pub(super) fn from_saved(preferences: &AppPreferences) -> Result<Self, String> {
let model = ModelChoice::from_id(&preferences.selected_model)
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?;
let generation = preferences.generation()?;
let runtime = preferences.runtime()?;
runtime.validate(model)?;
pub(super) fn from_saved(config: &Config) -> Self {
let generation = &config.generation;
let runtime = &config.runtime;
let execution = &runtime.execution;
let speculative = &runtime.speculative;
Ok(Self {
model,
Self {
model: config.model,
dspark_enabled: speculative.dspark_enabled,
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
endpoint_port: preferences.endpoint_port.to_string(),
endpoint_enabled: preferences.endpoint_enabled,
endpoint_cors: preferences.endpoint_cors,
idle_timeout_minutes: config.idle_timeout_minutes.to_string(),
endpoint_port: config.endpoint.port.to_string(),
endpoint_enabled: config.endpoint.enabled,
endpoint_cors: config.endpoint.cors,
context_tokens: generation.context_tokens.to_string(),
max_generated_tokens: generation.max_generated_tokens.to_string(),
system_prompt: generation.system_prompt,
temperature: generation
.temperature
.map_or_else(String::new, |value| value.to_string()),
top_p: generation
.top_p
.map_or_else(String::new, |value| value.to_string()),
min_p: generation
.min_p
.map_or_else(String::new, |value| value.to_string()),
seed: generation
.seed
.map_or_else(String::new, |value| value.to_string()),
system_prompt: generation.system_prompt.clone(),
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: execution
.cpu_threads
.map_or_else(String::new, |value| value.to_string()),
power_percent: execution
.power_percent
.map_or_else(String::new, |value| value.to_string()),
prefill_chunk: execution
.prefill_chunk
.map_or_else(String::new, |value| value.to_string()),
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: speculative
.dspark_confidence_threshold
.map_or_else(String::new, |value| value.to_string()),
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: runtime
.ssd
.cache
.map_or_else(String::new, |cache| match cache {
StreamingCacheBudget::Experts(experts) => experts.to_string(),
StreamingCacheBudget::Gib(gib) => format!("{gib}GB"),
}),
ssd_full_layers: runtime
.ssd
.full_layers
.map_or_else(String::new, |value| value.to_string()),
ssd_preload_experts: runtime
.ssd
.preload_experts
.map_or_else(String::new, |value| value.to_string()),
directional_steering_file: runtime.steering.file.unwrap_or_default(),
directional_steering_ffn: runtime
.steering
.ffn_scale
.map_or_else(String::new, |value| value.to_string()),
directional_steering_attn: runtime
.steering
.attention_scale
.map_or_else(String::new, |value| value.to_string()),
simulated_used_memory_gib: runtime
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
.simulated_used_memory_gib
.map_or_else(String::new, |value| value.to_string()),
expert_profile_path: runtime.diagnostics.expert_profile_path.unwrap_or_default(),
kv_budget_gib: runtime
.kv_cache
.budget_gib
.map_or_else(String::new, |value| value.to_string()),
kv_min_tokens: runtime
.kv_cache
.min_tokens
.map_or_else(String::new, |value| value.to_string()),
kv_cold_max_tokens: runtime
.kv_cache
.cold_max_tokens
.map_or_else(String::new, |value| value.to_string()),
kv_continued_interval_tokens: runtime
.kv_cache
.continued_interval_tokens
.map_or_else(String::new, |value| value.to_string()),
})
.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> {
@@ -163,49 +119,7 @@ impl PreferenceDraft {
}
pub(super) fn reset(&mut self) {
let defaults = GenerationPreferences::default();
let execution = ExecutionPreferences::default();
let speculative = SpeculativePreferences::default();
let ssd = SsdPreferences::default();
self.model = ModelChoice::default();
self.dspark_enabled = false;
self.idle_timeout_minutes = "10".into();
self.endpoint_port = "4000".into();
self.endpoint_enabled = true;
self.endpoint_cors = false;
self.context_tokens = defaults.context_tokens.to_string();
self.max_generated_tokens = defaults.max_generated_tokens.to_string();
self.system_prompt = defaults.system_prompt;
self.temperature.clear();
self.top_p.clear();
self.min_p.clear();
self.seed.clear();
self.reasoning_mode = defaults.reasoning_mode;
self.cpu_threads.clear();
self.power_percent.clear();
self.prefill_chunk.clear();
self.quality = execution.quality;
self.warm_weights = execution.warm_weights;
self.mtp_draft_tokens = speculative.mtp_draft_tokens.to_string();
self.mtp_margin = speculative.mtp_margin.to_string();
self.glm_mtp = speculative.glm_mtp;
self.glm_mtp_timing = speculative.glm_mtp_timing;
self.dspark_confidence_threshold.clear();
self.dspark_strict = speculative.dspark_strict;
self.ssd_streaming = ssd.enabled;
self.ssd_streaming_cold = ssd.cold;
self.ssd_cache.clear();
self.ssd_full_layers.clear();
self.ssd_preload_experts.clear();
self.directional_steering_file.clear();
self.directional_steering_ffn.clear();
self.directional_steering_attn.clear();
self.simulated_used_memory_gib.clear();
self.expert_profile_path.clear();
self.kv_budget_gib.clear();
self.kv_min_tokens.clear();
self.kv_cold_max_tokens.clear();
self.kv_continued_interval_tokens.clear();
*self = Self::from_saved(&Config::default());
}
pub(super) fn execution(&self) -> Result<ExecutionPreferences, String> {
@@ -349,30 +263,7 @@ pub(super) fn parse_streaming_cache(value: &str) -> Result<Option<StreamingCache
if value.is_empty() {
return Ok(None);
}
if value.len() > 2
&& value
.get(value.len() - 2..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case("gb"))
{
return parse_gib("SSD cache budget", value)
.map(|gib| Some(StreamingCacheBudget::Gib(gib)));
}
if !value.chars().all(|character| character.is_ascii_digit()) {
return Err(
"SSD cache budget must be a positive expert count or whole GiB value such as 64GB."
.into(),
);
}
value
.parse::<u32>()
.ok()
.filter(|value| *value > 0)
.map(StreamingCacheBudget::Experts)
.map(Some)
.ok_or_else(|| {
"SSD cache budget must be a positive expert count or whole GiB value such as 64GB."
.into()
})
value.parse().map(Some)
}
pub(super) fn parse_optional_gib(name: &str, value: &str) -> Result<Option<u64>, String> {
@@ -404,13 +295,15 @@ fn optional_text(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_owned())
}
fn update_runtime_preferences(
runtime_preferences: &RwLock<AppPreferences>,
preferences: &AppPreferences,
) {
*runtime_preferences
/// 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()) = preferences.clone();
.unwrap_or_else(|poisoned| poisoned.into_inner()) = config.clone();
}
impl App {
@@ -418,14 +311,9 @@ impl App {
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
return;
}
match PreferenceDraft::from_saved(&self.preferences) {
Ok(draft) => {
self.preference_draft = draft;
self.preference_error = None;
self.preferences_open = true;
}
Err(error) => self.error = Some(error),
}
self.preference_draft = PreferenceDraft::from_saved(&self.config);
self.preference_error = None;
self.preferences_open = true;
}
pub(super) fn save_preferences(&mut self) {
@@ -438,18 +326,10 @@ impl App {
self.preference_error = Some("Idle timeout must be a whole number.".into());
return;
};
if !(1..=1440).contains(&idle_timeout_minutes) {
self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".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;
};
if endpoint_port == 0 {
self.preference_error = Some("Endpoint port must be between 1 and 65535.".into());
return;
}
let generation = match self.preference_draft.generation() {
Ok(generation) => generation,
Err(error) => {
@@ -457,8 +337,6 @@ impl App {
return;
}
};
let model = self.preference_draft.model;
let runtime = match self.preference_draft.runtime() {
Ok(runtime) => runtime,
Err(error) => {
@@ -466,76 +344,72 @@ impl App {
return;
}
};
if let Err(error) = runtime.validate(model) {
let config = Config {
model: self.preference_draft.model,
idle_timeout_minutes,
endpoint: EndpointConfig {
port: i32::from(endpoint_port),
enabled: self.preference_draft.endpoint_enabled,
cors: self.preference_draft.endpoint_cors,
},
generation,
runtime,
interface: self.config.interface.clone(),
};
if let Err(error) = config.validate() {
self.preference_error = Some(error);
return;
}
#[cfg(target_os = "macos")]
let endpoint_changed = self.preferences.endpoint_port != i32::from(endpoint_port)
|| self.preferences.endpoint_enabled != self.preference_draft.endpoint_enabled
|| self.preferences.endpoint_cors != self.preference_draft.endpoint_cors;
let endpoint_changed = self.config.endpoint != config.endpoint;
#[cfg(target_os = "macos")]
if endpoint_changed
&& self.preferences.endpoint_port == i32::from(endpoint_port)
&& self.config.endpoint.port == config.endpoint.port
&& self._endpoint.is_some()
{
self._endpoint = None;
}
let pending_endpoint = if self.preference_draft.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_preferences),
models_path(),
application_support_path().join("kv-cache").join("http"),
endpoint_port,
self.preference_draft.endpoint_cors,
Arc::clone(&self.metrics),
) {
Ok(endpoint) => Some(endpoint),
Err(error) => {
self.preference_error = Some(error);
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
};
let Some(database) = &mut self.database else {
} else {
None
};
if let Err(error) = config.save(&config_path()) {
self.preference_error = Some(error);
return;
};
match database.update_preferences(
model.id(),
idle_timeout_minutes,
i32::from(endpoint_port),
self.preference_draft.endpoint_enabled,
self.preference_draft.endpoint_cors,
&generation,
&runtime,
) {
Ok(preferences) => {
self.preferences = preferences;
#[cfg(target_os = "macos")]
update_runtime_preferences(&self.runtime_preferences, &self.preferences);
#[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.preferences)
.expect("the saved model was selected from the supported catalog");
self.preferences_open = false;
self.preference_error = None;
self.error = None;
}
Err(error) => self.preference_error = Some(error),
}
self.config = config;
#[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.preferences_open = false;
self.preference_error = None;
self.error = None;
}
}
@@ -544,26 +418,30 @@ mod tests {
use super::*;
#[test]
fn runtime_preferences_update_after_lock_poisoning() {
let runtime = Arc::new(RwLock::new(AppPreferences::default()));
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 preference lock");
panic!("poison configuration lock");
})
.join();
let preferences = AppPreferences {
endpoint_port: 4567,
..AppPreferences::default()
let config = Config {
endpoint: EndpointConfig {
port: 4567,
..EndpointConfig::default()
},
..Config::default()
};
update_runtime_preferences(&runtime, &preferences);
update_runtime_config(&runtime, &config);
assert_eq!(
runtime
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.endpoint_port,
.endpoint
.port,
4567
);
}

View File

@@ -75,7 +75,7 @@ impl App {
self.conversation.clear();
self.composer.clear();
self.context_used = 0;
self.context_limit = self.preferences.context_tokens.max(0) as u32;
self.context_limit = self.config.generation.context_tokens.max(0) as u32;
self.tokens_per_second = None;
self.error = None;
}
@@ -113,15 +113,11 @@ impl App {
/// Selects a project and stores it as the one to reopen on the next launch.
pub(super) fn remember_project(&mut self, project_id: i32) {
self.selected_project = Some(project_id);
if self.preferences.last_project_id == Some(project_id) {
if self.config.interface.last_project_id == Some(project_id) {
return;
}
self.preferences.last_project_id = Some(project_id);
if let Some(database) = &mut self.database
&& let Err(error) = database.set_last_project(Some(project_id))
{
self.error = Some(error);
}
self.config.interface.last_project_id = Some(project_id);
self.store_config();
}
/// True when the sidebar row for this project's draft is the active chat.

View File

@@ -64,7 +64,7 @@ impl App {
fn main_view(&self) -> Element<'_, Message> {
let mut body = row![].width(Length::Fill).height(Length::Fill);
if !self.preferences.sidebar_collapsed {
if !self.config.interface.sidebar_collapsed {
body = body.push(self.sidebar());
body = body.push(
mouse_area(
@@ -272,7 +272,8 @@ impl App {
.spacing(10),
)
.width(
self.preferences
self.config
.interface
.sidebar_width
.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32,
)
@@ -327,10 +328,11 @@ impl App {
.style(button::text);
// Center the tabs over the detail area, not the whole window, so the
// sidebar's width (plus its divider) shifts them along.
let detail_offset = if self.preferences.sidebar_collapsed {
let detail_offset = if self.config.interface.sidebar_collapsed {
0.0
} else {
self.preferences
self.config
.interface
.sidebar_width
.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32
+ 5.0

View File

@@ -115,11 +115,10 @@ impl App {
} else if active && message.reasoning.is_none() {
body = body.push(text("Loading model…").size(14));
}
if !message.user
&& !message.tool
&& let Some(model) = ModelChoice::from_id(&self.preferences.selected_model)
{
for summary in crate::agent::tool_summaries(model, &message.content) {
if !message.user && !message.tool {
for summary in
crate::agent::tool_summaries(self.config.model, &message.content)
{
body = body.push(text(summary).size(13).color(muted_text()));
}
}
@@ -185,12 +184,7 @@ impl App {
.color(muted_text()),
Space::with_width(Length::Fill),
icon(ICON_MODEL, 16),
text(
ModelChoice::from_id(&self.preferences.selected_model)
.unwrap_or_default()
.to_string(),
)
.size(12),
text(self.config.model.to_string()).size(12),
action,
]
.spacing(6)