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

26
Cargo.lock generated
View File

@@ -1152,6 +1152,7 @@ dependencies = [
"rfd", "rfd",
"serde", "serde",
"serde_json", "serde_json",
"serde_norway",
"sha2", "sha2",
"ureq", "ureq",
] ]
@@ -3957,6 +3958,12 @@ dependencies = [
"unicode-script", "unicode-script",
] ]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -4047,6 +4054,19 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_norway"
version = "0.9.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml-norway",
]
[[package]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.21" version = "0.1.21"
@@ -4867,6 +4887,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml-norway"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View File

@@ -20,6 +20,7 @@ png = "0.17.16"
rfd = "0.15.4" rfd = "0.15.4"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.149", features = ["preserve_order", "raw_value"] } serde_json = { version = "1.0.149", features = ["preserve_order", "raw_value"] }
serde_norway = "0.9.42"
sha2 = "0.11.0" sha2 = "0.11.0"
ureq = { version = "3.3.0", default-features = false, features = ["rustls"] } ureq = { version = "3.3.0", default-features = false, features = ["rustls"] }

View File

@@ -12,9 +12,10 @@ notices are retained in [`native/metal/LICENSE`](native/metal/LICENSE) and
The current milestone provides a Codex-inspired project/session layout. A native The current milestone provides a Codex-inspired project/session layout. A native
macOS folder picker selects each workspace, then the app asks for its display macOS folder picker selects each workspace, then the app asks for its display
name. Projects, sessions, and model preferences are persisted through Diesel in name. Projects and sessions are persisted through Diesel in SQLite, while
SQLite. Open Preferences with `Command-,` to configure model, generation, settings live in a YAML file beside it that lists only the values you changed
runtime, local endpoint, and idle-unload settings. The separate Model Manager and can be edited by hand. Open Preferences with `Command-,` to configure model,
generation, runtime, local endpoint, and idle-unload settings. The separate Model Manager
(`Shift-Command-M`) (`Shift-Command-M`)
lists local main and DSpark artifacts, their on-disk sizes and state, and lets lists local main and DSpark artifacts, their on-disk sizes and state, and lets
you download, resume, validate, or delete them. Rust-native background work you download, resume, validate, or delete them. Rust-native background work
@@ -57,6 +58,7 @@ State is stored at:
```text ```text
~/Library/Application Support/DS4Server.rfc1437.de/data.sqlite3 ~/Library/Application Support/DS4Server.rfc1437.de/data.sqlite3
~/Library/Application Support/DS4Server.rfc1437.de/config.yaml
~/Library/Application Support/DS4Server.rfc1437.de/kv-cache/ ~/Library/Application Support/DS4Server.rfc1437.de/kv-cache/
``` ```

View File

@@ -0,0 +1 @@
-- Preferences live in config.yaml; the table is not recreated.

View File

@@ -0,0 +1 @@
DROP TABLE preferences;

View File

@@ -12,7 +12,8 @@ use preferences::PreferenceDraft;
#[cfg(test)] #[cfg(test)]
use preferences::{parse_optional_gib, parse_streaming_cache}; use preferences::{parse_optional_gib, parse_streaming_cache};
use crate::database::{AppPreferences, Database, ProjectWithSessions, SessionState, StoredMessage}; use crate::config::{Config, EndpointConfig};
use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::engine::ChatTurn; use crate::engine::ChatTurn;
use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot}; use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot};
@@ -20,9 +21,9 @@ use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, M
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::runtime::{ActiveGeneration, CheckpointTarget, GenerationEvent, GenerationService}; use crate::runtime::{ActiveGeneration, CheckpointTarget, GenerationEvent, GenerationService};
use crate::settings::{ use crate::settings::{
DEFAULT_KV_BUDGET_GIB, DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences, DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences, KvCachePreferences,
KvCachePreferences, ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences,
SteeringPreferences, StreamingCacheBudget, StreamingCacheBudget,
}; };
use iced::widget::{markdown, scrollable, text_input}; use iced::widget::{markdown, scrollable, text_input};
use iced::{Size, Subscription, Task, keyboard, mouse, window}; use iced::{Size, Subscription, Task, keyboard, mouse, window};
@@ -53,7 +54,7 @@ pub(crate) struct App {
pub(super) native_edit_commands: crate::native_edit::EditCommandQueue, pub(super) native_edit_commands: crate::native_edit::EditCommandQueue,
database: Option<Database>, database: Option<Database>,
projects: Vec<ProjectWithSessions>, projects: Vec<ProjectWithSessions>,
preferences: AppPreferences, config: Config,
preference_draft: PreferenceDraft, preference_draft: PreferenceDraft,
preferences_open: bool, preferences_open: bool,
preference_error: Option<String>, preference_error: Option<String>,
@@ -99,7 +100,7 @@ pub(crate) struct App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_titling: Option<generation::TitleRequest>, active_titling: Option<generation::TitleRequest>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_preferences: Arc<RwLock<AppPreferences>>, runtime_config: Arc<RwLock<Config>>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
_endpoint: Option<crate::server::ServerHandle>, _endpoint: Option<crate::server::ServerHandle>,
error: Option<String>, error: Option<String>,
@@ -217,22 +218,24 @@ pub(crate) enum Message {
impl App { impl App {
pub(crate) fn load(main_window: window::Id) -> Self { pub(crate) fn load(main_window: window::Id) -> Self {
let config = match Config::load(&config_path()) {
Ok(config) => config,
Err(error) => return Self::failed(error, main_window),
};
let path = application_support_path().join("data.sqlite3"); let path = application_support_path().join("data.sqlite3");
match Database::open(&path) { match Database::open(&path) {
Ok(mut database) => match (database.load_projects(), database.load_preferences()) { Ok(mut database) => match database.load_projects() {
(Ok(projects), Ok(preferences)) => { Ok(projects) => {
let preference_draft = match PreferenceDraft::from_saved(&preferences) { let preference_draft = PreferenceDraft::from_saved(&config);
Ok(draft) => draft,
Err(error) => return Self::failed(error, main_window),
};
// Before the counters are taken, so they measure what is // Before the counters are taken, so they measure what is
// actually reachable. // actually reachable.
sweep_orphan_checkpoints(&kv_cache_path(), &projects); sweep_orphan_checkpoints(&kv_cache_path(), &projects);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
crate::engine::sweep_transient_cache(&transient_cache_path()); crate::engine::sweep_transient_cache(&transient_cache_path());
let context_limit = preferences.context_tokens.max(0) as u32; let context_limit = config.generation.context_tokens.max(0) as u32;
// Reopen on the project we left, with a fresh draft chat. // Reopen on the project we left, with a fresh draft chat.
let last_project = preferences let last_project = config
.interface
.last_project_id .last_project_id
.filter(|id| projects.iter().any(|item| item.project.id == *id)); .filter(|id| projects.iter().any(|item| item.project.id == *id));
let drafts = last_project let drafts = last_project
@@ -243,8 +246,8 @@ impl App {
Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); Arc::new(Metrics::new(&application_support_path().join("kv-cache")));
let metrics_snapshot = metrics.snapshot(); let metrics_snapshot = metrics.snapshot();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let (runtime_preferences, generation_service, endpoint, service_error) = let (runtime_config, generation_service, endpoint, service_error) =
spawn_services(&preferences, Arc::clone(&metrics)); spawn_services(&config, Arc::clone(&metrics));
Self { Self {
main_window, main_window,
model_manager_window: None, model_manager_window: None,
@@ -255,7 +258,7 @@ impl App {
native_edit_commands: crate::native_edit::command_queue(), native_edit_commands: crate::native_edit::command_queue(),
database: Some(database), database: Some(database),
projects, projects,
preferences, config,
preference_draft, preference_draft,
preferences_open: false, preferences_open: false,
preference_error: None, preference_error: None,
@@ -294,7 +297,7 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_titling: None, active_titling: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_preferences, runtime_config,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
_endpoint: endpoint, _endpoint: endpoint,
error: { error: {
@@ -309,22 +312,27 @@ impl App {
}, },
} }
} }
(Err(error), _) | (_, Err(error)) => Self::failed(error, main_window), Err(error) => Self::failed(
format!("Could not open the project database: {error}"),
main_window,
),
}, },
Err(error) => Self::failed(error, main_window), Err(error) => Self::failed(
format!("Could not open the project database: {error}"),
main_window,
),
} }
} }
fn failed(error: String, main_window: window::Id) -> Self { fn failed(error: String, main_window: window::Id) -> Self {
let preferences = AppPreferences::default(); let config = Config::default();
let preference_draft = PreferenceDraft::from_saved(&preferences) let preference_draft = PreferenceDraft::from_saved(&config);
.expect("default preferences must use a supported model"); let context_limit = config.generation.context_tokens.max(0) as u32;
let context_limit = preferences.context_tokens.max(0) as u32;
let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache")));
let metrics_snapshot = metrics.snapshot(); let metrics_snapshot = metrics.snapshot();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let (runtime_preferences, generation_service, endpoint, service_error) = let (runtime_config, generation_service, endpoint, service_error) =
spawn_services(&preferences, Arc::clone(&metrics)); spawn_services(&config, Arc::clone(&metrics));
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let startup_error = service_error; let startup_error = service_error;
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@@ -339,7 +347,7 @@ impl App {
native_edit_commands: crate::native_edit::command_queue(), native_edit_commands: crate::native_edit::command_queue(),
database: None, database: None,
projects: Vec::new(), projects: Vec::new(),
preferences, config,
preference_draft, preference_draft,
preferences_open: false, preferences_open: false,
preference_error: None, preference_error: None,
@@ -378,14 +386,12 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_titling: None, active_titling: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_preferences, runtime_config,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
_endpoint: endpoint, _endpoint: endpoint,
error: Some(match startup_error { error: Some(match startup_error {
Some(service_error) => { Some(service_error) => format!("{error} {service_error}"),
format!("Could not open the project database: {error}. {service_error}") None => error,
}
None => format!("Could not open the project database: {error}"),
}), }),
} }
} }
@@ -453,29 +459,19 @@ impl App {
Message::StartSidebarDrag => self.sidebar_drag = true, Message::StartSidebarDrag => self.sidebar_drag = true,
Message::DragSidebar(x) => { Message::DragSidebar(x) => {
if self.sidebar_drag { if self.sidebar_drag {
self.preferences.sidebar_width = self.config.interface.sidebar_width =
(x.round() as i32).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); (x.round() as i32).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
} }
} }
Message::EndSidebarDrag => { Message::EndSidebarDrag => {
if self.sidebar_drag { if self.sidebar_drag {
self.sidebar_drag = false; self.sidebar_drag = false;
if let Some(database) = self.database.as_mut() self.store_config();
&& let Err(error) =
database.set_sidebar_width(self.preferences.sidebar_width)
{
self.error = Some(error);
}
} }
} }
Message::ToggleSidebar => { Message::ToggleSidebar => {
let collapsed = !self.preferences.sidebar_collapsed; self.config.interface.sidebar_collapsed = !self.config.interface.sidebar_collapsed;
self.preferences.sidebar_collapsed = collapsed; self.store_config();
if let Some(database) = self.database.as_mut()
&& let Err(error) = database.set_sidebar_collapsed(collapsed)
{
self.error = Some(error);
}
} }
Message::MetricsTick => self.sample_metrics(), Message::MetricsTick => self.sample_metrics(),
Message::PreferenceModelChanged(model) => { Message::PreferenceModelChanged(model) => {
@@ -829,9 +825,9 @@ impl App {
let _ = fs::remove_file(session_checkpoint_path(session_id)); let _ = fs::remove_file(session_checkpoint_path(session_id));
} }
self.drafts.remove(&project_id); self.drafts.remove(&project_id);
if self.preferences.last_project_id == Some(project_id) { if self.config.interface.last_project_id == Some(project_id) {
self.preferences.last_project_id = None; self.config.interface.last_project_id = None;
let _ = database.set_last_project(None); let _ = self.config.save(&config_path());
} }
if self.selected_project == Some(project_id) { if self.selected_project == Some(project_id) {
self.selected_project = None; self.selected_project = None;
@@ -957,7 +953,7 @@ impl App {
self.context_limit = if limit > 0 { self.context_limit = if limit > 0 {
limit as u32 limit as u32
} else { } else {
self.preferences.context_tokens.max(0) as u32 self.config.generation.context_tokens.max(0) as u32
}; };
self.tokens_per_second = tokens_per_second; self.tokens_per_second = tokens_per_second;
self.error = None; self.error = None;
@@ -1128,6 +1124,13 @@ impl App {
self.finish_cache_change(); self.finish_cache_change();
} }
/// Writes the configuration file, surfacing a failure in the error banner.
fn store_config(&mut self) {
if let Err(error) = self.config.save(&config_path()) {
self.error = Some(error);
}
}
fn finish_cache_change(&mut self) { fn finish_cache_change(&mut self) {
self.metrics.rescan_cache(&kv_cache_path()); self.metrics.rescan_cache(&kv_cache_path());
self.metrics_snapshot = self.metrics.snapshot(); self.metrics_snapshot = self.metrics.snapshot();
@@ -1135,12 +1138,7 @@ impl App {
} }
fn scan_kv_cache(&mut self) { fn scan_kv_cache(&mut self) {
let budget = self let budget = self.config.runtime.kv_cache.settings().budget_bytes;
.preferences
.runtime()
.map_or(DEFAULT_KV_BUDGET_GIB * GIB, |runtime| {
runtime.kv_cache.settings().budget_bytes
});
self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget); self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget);
self.last_cache_scan = Instant::now(); self.last_cache_scan = Instant::now();
} }
@@ -1148,34 +1146,34 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn spawn_services( fn spawn_services(
preferences: &AppPreferences, config: &Config,
metrics: Arc<Metrics>, metrics: Arc<Metrics>,
) -> ( ) -> (
Arc<RwLock<AppPreferences>>, Arc<RwLock<Config>>,
Option<GenerationService>, Option<GenerationService>,
Option<crate::server::ServerHandle>, Option<crate::server::ServerHandle>,
Option<String>, Option<String>,
) { ) {
let runtime_preferences = Arc::new(RwLock::new(preferences.clone())); let runtime_config = Arc::new(RwLock::new(config.clone()));
let generation = match GenerationService::spawn(Arc::clone(&metrics)) { let generation = match GenerationService::spawn(Arc::clone(&metrics)) {
Ok(generation) => generation, Ok(generation) => generation,
Err(error) => return (runtime_preferences, None, None, Some(error)), Err(error) => return (runtime_config, None, None, Some(error)),
}; };
let endpoint = preferences.endpoint_enabled.then(|| { let endpoint = config.endpoint.enabled.then(|| {
crate::server::ServerHandle::spawn( crate::server::ServerHandle::spawn(
generation.clone(), generation.clone(),
Arc::clone(&runtime_preferences), Arc::clone(&runtime_config),
models_path(), models_path(),
transient_cache_path(), transient_cache_path(),
u16::try_from(preferences.endpoint_port).unwrap_or(4000), u16::try_from(config.endpoint.port).unwrap_or(4000),
preferences.endpoint_cors, config.endpoint.cors,
metrics, metrics,
) )
}); });
match endpoint { match endpoint {
Some(Ok(endpoint)) => (runtime_preferences, Some(generation), Some(endpoint), None), Some(Ok(endpoint)) => (runtime_config, Some(generation), Some(endpoint), None),
Some(Err(error)) => (runtime_preferences, Some(generation), None, Some(error)), Some(Err(error)) => (runtime_config, Some(generation), None, Some(error)),
None => (runtime_preferences, Some(generation), None, None), None => (runtime_config, Some(generation), None, None),
} }
} }
@@ -1219,6 +1217,11 @@ fn models_path() -> PathBuf {
application_support_path().join("models") application_support_path().join("models")
} }
/// The settings file, beside the project database.
fn config_path() -> PathBuf {
application_support_path().join("config.yaml")
}
pub(super) fn chat_scroll_id() -> scrollable::Id { pub(super) fn chat_scroll_id() -> scrollable::Id {
scrollable::Id::new("chat-transcript") scrollable::Id::new("chat-transcript")
} }

View File

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

View File

@@ -44,104 +44,60 @@ pub(super) struct PreferenceDraft {
} }
impl PreferenceDraft { impl PreferenceDraft {
pub(super) fn from_saved(preferences: &AppPreferences) -> Result<Self, String> { pub(super) fn from_saved(config: &Config) -> Self {
let model = ModelChoice::from_id(&preferences.selected_model) let generation = &config.generation;
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?; let runtime = &config.runtime;
let generation = preferences.generation()?;
let runtime = preferences.runtime()?;
runtime.validate(model)?;
let execution = &runtime.execution; let execution = &runtime.execution;
let speculative = &runtime.speculative; let speculative = &runtime.speculative;
Ok(Self { Self {
model, model: config.model,
dspark_enabled: speculative.dspark_enabled, dspark_enabled: speculative.dspark_enabled,
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(), idle_timeout_minutes: config.idle_timeout_minutes.to_string(),
endpoint_port: preferences.endpoint_port.to_string(), endpoint_port: config.endpoint.port.to_string(),
endpoint_enabled: preferences.endpoint_enabled, endpoint_enabled: config.endpoint.enabled,
endpoint_cors: preferences.endpoint_cors, endpoint_cors: config.endpoint.cors,
context_tokens: generation.context_tokens.to_string(), context_tokens: generation.context_tokens.to_string(),
max_generated_tokens: generation.max_generated_tokens.to_string(), max_generated_tokens: generation.max_generated_tokens.to_string(),
system_prompt: generation.system_prompt, system_prompt: generation.system_prompt.clone(),
temperature: generation temperature: optional_string(generation.temperature),
.temperature top_p: optional_string(generation.top_p),
.map_or_else(String::new, |value| value.to_string()), min_p: optional_string(generation.min_p),
top_p: generation seed: optional_string(generation.seed),
.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()),
reasoning_mode: generation.reasoning_mode, reasoning_mode: generation.reasoning_mode,
cpu_threads: execution cpu_threads: optional_string(execution.cpu_threads),
.cpu_threads power_percent: optional_string(execution.power_percent),
.map_or_else(String::new, |value| value.to_string()), prefill_chunk: optional_string(execution.prefill_chunk),
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()),
quality: execution.quality, quality: execution.quality,
warm_weights: execution.warm_weights, warm_weights: execution.warm_weights,
mtp_draft_tokens: speculative.mtp_draft_tokens.to_string(), mtp_draft_tokens: speculative.mtp_draft_tokens.to_string(),
mtp_margin: speculative.mtp_margin.to_string(), mtp_margin: speculative.mtp_margin.to_string(),
glm_mtp: speculative.glm_mtp, glm_mtp: speculative.glm_mtp,
glm_mtp_timing: speculative.glm_mtp_timing, glm_mtp_timing: speculative.glm_mtp_timing,
dspark_confidence_threshold: speculative dspark_confidence_threshold: optional_string(speculative.dspark_confidence_threshold),
.dspark_confidence_threshold
.map_or_else(String::new, |value| value.to_string()),
dspark_strict: speculative.dspark_strict, dspark_strict: speculative.dspark_strict,
ssd_streaming: runtime.ssd.enabled, ssd_streaming: runtime.ssd.enabled,
ssd_streaming_cold: runtime.ssd.cold, ssd_streaming_cold: runtime.ssd.cold,
ssd_cache: runtime ssd_cache: optional_string(runtime.ssd.cache),
.ssd ssd_full_layers: optional_string(runtime.ssd.full_layers),
.cache ssd_preload_experts: optional_string(runtime.ssd.preload_experts),
.map_or_else(String::new, |cache| match cache { directional_steering_file: runtime.steering.file.clone().unwrap_or_default(),
StreamingCacheBudget::Experts(experts) => experts.to_string(), directional_steering_ffn: optional_string(runtime.steering.ffn_scale),
StreamingCacheBudget::Gib(gib) => format!("{gib}GB"), directional_steering_attn: optional_string(runtime.steering.attention_scale),
}), simulated_used_memory_gib: optional_string(
ssd_full_layers: runtime runtime.diagnostics.simulated_used_memory_gib,
.ssd ),
.full_layers expert_profile_path: runtime
.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
.diagnostics .diagnostics
.simulated_used_memory_gib .expert_profile_path
.map_or_else(String::new, |value| value.to_string()), .clone()
expert_profile_path: runtime.diagnostics.expert_profile_path.unwrap_or_default(), .unwrap_or_default(),
kv_budget_gib: runtime kv_budget_gib: optional_string(runtime.kv_cache.budget_gib),
.kv_cache kv_min_tokens: optional_string(runtime.kv_cache.min_tokens),
.budget_gib kv_cold_max_tokens: optional_string(runtime.kv_cache.cold_max_tokens),
.map_or_else(String::new, |value| value.to_string()), kv_continued_interval_tokens: optional_string(
kv_min_tokens: runtime runtime.kv_cache.continued_interval_tokens,
.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()),
})
} }
pub(super) fn generation(&self) -> Result<GenerationPreferences, String> { pub(super) fn generation(&self) -> Result<GenerationPreferences, String> {
@@ -163,49 +119,7 @@ impl PreferenceDraft {
} }
pub(super) fn reset(&mut self) { pub(super) fn reset(&mut self) {
let defaults = GenerationPreferences::default(); *self = Self::from_saved(&Config::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();
} }
pub(super) fn execution(&self) -> Result<ExecutionPreferences, String> { 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() { if value.is_empty() {
return Ok(None); return Ok(None);
} }
if value.len() > 2 value.parse().map(Some)
&& 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()
})
} }
pub(super) fn parse_optional_gib(name: &str, value: &str) -> Result<Option<u64>, String> { 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()) (!value.is_empty()).then(|| value.to_owned())
} }
fn update_runtime_preferences( /// An unset preference shows as an empty field.
runtime_preferences: &RwLock<AppPreferences>, fn optional_string<T: ToString>(value: Option<T>) -> String {
preferences: &AppPreferences, value.map_or_else(String::new, |value| value.to_string())
) { }
*runtime_preferences
fn update_runtime_config(runtime_config: &RwLock<Config>, config: &Config) {
*runtime_config
.write() .write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = preferences.clone(); .unwrap_or_else(|poisoned| poisoned.into_inner()) = config.clone();
} }
impl App { impl App {
@@ -418,14 +311,9 @@ impl App {
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder { if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
return; return;
} }
match PreferenceDraft::from_saved(&self.preferences) { self.preference_draft = PreferenceDraft::from_saved(&self.config);
Ok(draft) => { self.preference_error = None;
self.preference_draft = draft; self.preferences_open = true;
self.preference_error = None;
self.preferences_open = true;
}
Err(error) => self.error = Some(error),
}
} }
pub(super) fn save_preferences(&mut self) { 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()); self.preference_error = Some("Idle timeout must be a whole number.".into());
return; 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 { 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()); self.preference_error = Some("Endpoint port must be a whole number.".into());
return; 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() { let generation = match self.preference_draft.generation() {
Ok(generation) => generation, Ok(generation) => generation,
Err(error) => { Err(error) => {
@@ -457,8 +337,6 @@ impl App {
return; return;
} }
}; };
let model = self.preference_draft.model;
let runtime = match self.preference_draft.runtime() { let runtime = match self.preference_draft.runtime() {
Ok(runtime) => runtime, Ok(runtime) => runtime,
Err(error) => { Err(error) => {
@@ -466,76 +344,72 @@ impl App {
return; 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); self.preference_error = Some(error);
return; return;
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let endpoint_changed = self.preferences.endpoint_port != i32::from(endpoint_port) let endpoint_changed = self.config.endpoint != config.endpoint;
|| self.preferences.endpoint_enabled != self.preference_draft.endpoint_enabled
|| self.preferences.endpoint_cors != self.preference_draft.endpoint_cors;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if endpoint_changed if endpoint_changed
&& self.preferences.endpoint_port == i32::from(endpoint_port) && self.config.endpoint.port == config.endpoint.port
&& self._endpoint.is_some() && self._endpoint.is_some()
{ {
self._endpoint = None; self._endpoint = None;
} }
let pending_endpoint = if self.preference_draft.endpoint_enabled let pending_endpoint =
&& (endpoint_changed || self._endpoint.is_none()) if config.endpoint.enabled && (endpoint_changed || self._endpoint.is_none()) {
{ let Some(generation) = &self.generation_service else {
let Some(generation) = &self.generation_service else { self.preference_error = Some("The model runtime is unavailable.".into());
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);
return; 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 {
} else { None
None };
}; if let Err(error) = config.save(&config_path()) {
let Some(database) = &mut self.database else { self.preference_error = Some(error);
return; 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::*; use super::*;
#[test] #[test]
fn runtime_preferences_update_after_lock_poisoning() { fn runtime_config_updates_after_lock_poisoning() {
let runtime = Arc::new(RwLock::new(AppPreferences::default())); let runtime = Arc::new(RwLock::new(Config::default()));
let poisoned = Arc::clone(&runtime); let poisoned = Arc::clone(&runtime);
let _ = std::thread::spawn(move || { let _ = std::thread::spawn(move || {
let _guard = poisoned.write().unwrap(); let _guard = poisoned.write().unwrap();
panic!("poison preference lock"); panic!("poison configuration lock");
}) })
.join(); .join();
let preferences = AppPreferences { let config = Config {
endpoint_port: 4567, endpoint: EndpointConfig {
..AppPreferences::default() port: 4567,
..EndpointConfig::default()
},
..Config::default()
}; };
update_runtime_preferences(&runtime, &preferences); update_runtime_config(&runtime, &config);
assert_eq!( assert_eq!(
runtime runtime
.read() .read()
.unwrap_or_else(|poisoned| poisoned.into_inner()) .unwrap_or_else(|poisoned| poisoned.into_inner())
.endpoint_port, .endpoint
.port,
4567 4567
); );
} }

View File

@@ -75,7 +75,7 @@ impl App {
self.conversation.clear(); self.conversation.clear();
self.composer.clear(); self.composer.clear();
self.context_used = 0; 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.tokens_per_second = None;
self.error = 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. /// 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) { pub(super) fn remember_project(&mut self, project_id: i32) {
self.selected_project = Some(project_id); 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; return;
} }
self.preferences.last_project_id = Some(project_id); self.config.interface.last_project_id = Some(project_id);
if let Some(database) = &mut self.database self.store_config();
&& let Err(error) = database.set_last_project(Some(project_id))
{
self.error = Some(error);
}
} }
/// True when the sidebar row for this project's draft is the active chat. /// 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> { fn main_view(&self) -> Element<'_, Message> {
let mut body = row![].width(Length::Fill).height(Length::Fill); 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(self.sidebar());
body = body.push( body = body.push(
mouse_area( mouse_area(
@@ -272,7 +272,8 @@ impl App {
.spacing(10), .spacing(10),
) )
.width( .width(
self.preferences self.config
.interface
.sidebar_width .sidebar_width
.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32, .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32,
) )
@@ -327,10 +328,11 @@ impl App {
.style(button::text); .style(button::text);
// Center the tabs over the detail area, not the whole window, so the // Center the tabs over the detail area, not the whole window, so the
// sidebar's width (plus its divider) shifts them along. // 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 0.0
} else { } else {
self.preferences self.config
.interface
.sidebar_width .sidebar_width
.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32 .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32
+ 5.0 + 5.0

View File

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

199
src/config.rs Normal file
View File

@@ -0,0 +1,199 @@
use serde::{Deserialize, Serialize};
use serde_norway::{Mapping, Value};
use std::fs;
use std::path::Path;
use crate::model::ModelChoice;
use crate::settings::{GenerationPreferences, RuntimePreferences};
/// Settings the application persists beside its database, as a YAML file users
/// and agents can edit by hand. Only values that differ from the defaults are
/// written; anything missing falls back to the default.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub model: ModelChoice,
pub idle_timeout_minutes: i32,
pub endpoint: EndpointConfig,
pub generation: GenerationPreferences,
pub runtime: RuntimePreferences,
pub interface: InterfaceConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
model: ModelChoice::default(),
idle_timeout_minutes: 10,
endpoint: EndpointConfig::default(),
generation: GenerationPreferences::default(),
runtime: RuntimePreferences::default(),
interface: InterfaceConfig::default(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EndpointConfig {
pub port: i32,
pub enabled: bool,
pub cors: bool,
}
impl Default for EndpointConfig {
fn default() -> Self {
Self {
port: 4000,
enabled: true,
cors: false,
}
}
}
/// Window state the application restores on the next launch.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct InterfaceConfig {
pub sidebar_collapsed: bool,
pub sidebar_width: i32,
/// Project the app reopens on. Cleared when that project goes away.
pub last_project_id: Option<i32>,
}
impl Default for InterfaceConfig {
fn default() -> Self {
Self {
sidebar_collapsed: false,
sidebar_width: 276,
last_project_id: None,
}
}
}
impl Config {
/// A missing file is the default configuration; a malformed one is an error,
/// so a typo never silently resets everything.
pub fn load(path: &Path) -> Result<Self, String> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default());
}
Err(error) => return Err(format!("Could not read {}: {error}", path.display())),
};
let config: Self = serde_norway::from_str(&text)
.map_err(|error| format!("Could not read {}: {error}", path.display()))?;
config.validate()?;
Ok(config)
}
pub fn save(&self, path: &Path) -> Result<(), String> {
self.validate()?;
let value = serde_norway::to_value(self).map_err(|error| error.to_string())?;
let defaults =
serde_norway::to_value(Self::default()).map_err(|error| error.to_string())?;
let value = without_defaults(value, &defaults)
.unwrap_or_else(|| Value::Mapping(Mapping::default()));
let text = serde_norway::to_string(&value).map_err(|error| error.to_string())?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
fs::write(path, text)
.map_err(|error| format!("Could not write {}: {error}", path.display()))
}
pub fn validate(&self) -> Result<(), String> {
if !(1..=1440).contains(&self.idle_timeout_minutes) {
return Err("Idle timeout must be between 1 and 1440 minutes.".into());
}
if !(1..=65_535).contains(&self.endpoint.port) {
return Err("Endpoint port must be between 1 and 65535.".into());
}
self.generation.validate()?;
self.runtime.validate(self.model)
}
}
/// Drops every value that still matches the default, so the file lists only what
/// the user actually set. Mappings that end up empty disappear with their key.
fn without_defaults(value: Value, defaults: &Value) -> Option<Value> {
if let (Value::Mapping(entries), Value::Mapping(defaults)) = (&value, defaults) {
let kept = entries
.iter()
.filter_map(|(key, entry)| {
let default = defaults.get(key)?;
Some((key.clone(), without_defaults(entry.clone(), default)?))
})
.collect::<Mapping>();
return (!kept.is_empty()).then_some(Value::Mapping(kept));
}
(&value != defaults).then_some(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::{ReasoningMode, SsdPreferences, StreamingCacheBudget};
#[test]
fn defaults_write_an_empty_file_and_reload_unchanged() {
let directory = std::env::temp_dir().join(format!("ds4-config-{}", std::process::id()));
let path = directory.join("config.yaml");
Config::default().save(&path).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap().trim(), "{}");
assert_eq!(Config::load(&path).unwrap(), Config::default());
fs::remove_dir_all(&directory).unwrap();
}
#[test]
fn only_changed_values_are_written_and_read_back() {
let directory = std::env::temp_dir().join(format!("ds4-config-set-{}", std::process::id()));
let path = directory.join("config.yaml");
let config = Config {
model: ModelChoice::Glm52,
generation: GenerationPreferences {
context_tokens: 65_536,
reasoning_mode: ReasoningMode::Direct,
..GenerationPreferences::default()
},
runtime: RuntimePreferences {
ssd: SsdPreferences {
enabled: true,
cache: Some(StreamingCacheBudget::Gib(64)),
..SsdPreferences::default()
},
..RuntimePreferences::default()
},
..Config::default()
};
config.save(&path).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert_eq!(
text,
"model: glm-5.2\n\
generation:\n context_tokens: 65536\n reasoning_mode: none\n\
runtime:\n ssd:\n enabled: true\n cache: 64GB\n"
);
assert_eq!(Config::load(&path).unwrap(), config);
fs::remove_dir_all(&directory).unwrap();
}
#[test]
fn invalid_files_are_reported_instead_of_reset() {
let directory = std::env::temp_dir().join(format!("ds4-config-bad-{}", std::process::id()));
let path = directory.join("config.yaml");
fs::create_dir_all(&directory).unwrap();
fs::write(&path, "idle_timeout_minutes: 0\n").unwrap();
assert!(Config::load(&path).is_err());
fs::write(&path, "nonsense: 1\n").unwrap();
assert!(Config::load(&path).is_err());
fs::remove_dir_all(&directory).unwrap();
// A missing file is simply the defaults.
assert_eq!(Config::load(&path).unwrap(), Config::default());
}
}

View File

@@ -4,288 +4,10 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use crate::schema::{messages, preferences, projects, sessions}; use crate::schema::{messages, projects, sessions};
use crate::settings::{
DiagnosticPreferences, ExecutionPreferences, GenerationPreferences, KvCachePreferences,
ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences,
StreamingCacheBudget,
};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = preferences)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct AppPreferences {
pub id: i32,
pub selected_model: String,
pub dspark_enabled: bool,
pub idle_timeout_minutes: i32,
pub context_tokens: i32,
pub max_generated_tokens: i32,
pub system_prompt: String,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub min_p: Option<f32>,
pub seed: Option<String>,
pub reasoning_mode: String,
pub cpu_threads: Option<i32>,
pub power_percent: Option<i32>,
pub prefill_chunk: Option<i32>,
pub quality: bool,
pub warm_weights: bool,
pub mtp_draft_tokens: i32,
pub mtp_margin: f32,
pub glm_mtp: bool,
pub glm_mtp_timing: bool,
pub dspark_confidence_threshold: Option<f32>,
pub dspark_strict: bool,
pub ssd_streaming: bool,
pub ssd_streaming_cold: bool,
pub ssd_cache_experts: Option<i64>,
pub ssd_cache_gib: Option<i64>,
pub ssd_full_layers: Option<i32>,
pub ssd_preload_experts: Option<i32>,
pub directional_steering_file: Option<String>,
pub directional_steering_ffn: Option<f32>,
pub directional_steering_attn: Option<f32>,
pub simulated_used_memory_gib: Option<i64>,
pub expert_profile_path: Option<String>,
pub endpoint_port: i32,
pub endpoint_enabled: bool,
pub endpoint_cors: bool,
pub sidebar_collapsed: bool,
/// Project the app reopens on. Cleared when that project goes away.
pub last_project_id: Option<i32>,
pub sidebar_width: i32,
pub kv_budget_gib: Option<i64>,
pub kv_min_tokens: Option<i32>,
pub kv_cold_max_tokens: Option<i32>,
pub kv_continued_interval_tokens: Option<i32>,
}
impl Default for AppPreferences {
fn default() -> Self {
Self {
id: 1,
selected_model: "deepseek-v4-flash".into(),
dspark_enabled: false,
idle_timeout_minutes: 10,
context_tokens: 32_768,
max_generated_tokens: 50_000,
system_prompt: "You are a helpful assistant".into(),
temperature: None,
top_p: None,
min_p: None,
seed: None,
reasoning_mode: "high".into(),
cpu_threads: None,
power_percent: None,
prefill_chunk: None,
quality: false,
warm_weights: false,
mtp_draft_tokens: 1,
mtp_margin: 3.0,
glm_mtp: false,
glm_mtp_timing: false,
dspark_confidence_threshold: None,
dspark_strict: false,
ssd_streaming: false,
ssd_streaming_cold: false,
ssd_cache_experts: None,
ssd_cache_gib: None,
ssd_full_layers: None,
ssd_preload_experts: None,
directional_steering_file: None,
directional_steering_ffn: None,
directional_steering_attn: None,
simulated_used_memory_gib: None,
expert_profile_path: None,
endpoint_port: 4000,
endpoint_enabled: true,
endpoint_cors: false,
sidebar_collapsed: false,
last_project_id: None,
sidebar_width: 276,
kv_budget_gib: None,
kv_min_tokens: None,
kv_cold_max_tokens: None,
kv_continued_interval_tokens: None,
}
}
}
impl AppPreferences {
pub(crate) fn generation(&self) -> Result<GenerationPreferences, String> {
let preferences = GenerationPreferences {
context_tokens: self.context_tokens,
max_generated_tokens: self.max_generated_tokens,
system_prompt: self.system_prompt.clone(),
temperature: self.temperature,
top_p: self.top_p,
min_p: self.min_p,
seed: self
.seed
.as_deref()
.map(str::parse)
.transpose()
.map_err(|_| "Saved seed is not a valid positive whole number.".to_owned())?,
reasoning_mode: ReasoningMode::from_id(&self.reasoning_mode)
.ok_or_else(|| format!("Unsupported reasoning mode: {}", self.reasoning_mode))?,
};
preferences.validate()?;
Ok(preferences)
}
pub(crate) fn execution(&self) -> Result<ExecutionPreferences, String> {
Ok(ExecutionPreferences {
cpu_threads: self
.cpu_threads
.map(u32::try_from)
.transpose()
.map_err(|_| "Saved CPU helper threads is invalid.".to_owned())?,
power_percent: self
.power_percent
.map(u8::try_from)
.transpose()
.map_err(|_| "Saved GPU power is invalid.".to_owned())?,
prefill_chunk: self
.prefill_chunk
.map(u32::try_from)
.transpose()
.map_err(|_| "Saved prefill chunk is invalid.".to_owned())?,
quality: self.quality,
warm_weights: self.warm_weights,
})
}
pub(crate) fn speculative(&self) -> SpeculativePreferences {
SpeculativePreferences {
mtp_draft_tokens: self.mtp_draft_tokens,
mtp_margin: self.mtp_margin,
glm_mtp: self.glm_mtp,
glm_mtp_timing: self.glm_mtp_timing,
dspark_enabled: self.dspark_enabled,
dspark_confidence_threshold: self.dspark_confidence_threshold,
dspark_strict: self.dspark_strict,
}
}
pub(crate) fn runtime(&self) -> Result<RuntimePreferences, String> {
let cache = match (self.ssd_cache_experts, self.ssd_cache_gib) {
(None, None) => None,
(Some(experts), None) => Some(StreamingCacheBudget::Experts(
u32::try_from(experts).map_err(|_| "Saved SSD expert count is invalid.")?,
)),
(None, Some(gib)) => Some(StreamingCacheBudget::Gib(
u64::try_from(gib).map_err(|_| "Saved SSD cache budget is invalid.")?,
)),
(Some(_), Some(_)) => {
return Err("Saved SSD cache budget has both count and GiB values.".into());
}
};
Ok(RuntimePreferences {
execution: self.execution()?,
speculative: self.speculative(),
ssd: SsdPreferences {
enabled: self.ssd_streaming,
cold: self.ssd_streaming_cold,
cache,
full_layers: self
.ssd_full_layers
.map(u32::try_from)
.transpose()
.map_err(|_| "Saved SSD full-layer count is invalid.")?,
preload_experts: self
.ssd_preload_experts
.map(u32::try_from)
.transpose()
.map_err(|_| "Saved SSD preload count is invalid.")?,
},
steering: SteeringPreferences {
file: self.directional_steering_file.clone(),
ffn_scale: self.directional_steering_ffn,
attention_scale: self.directional_steering_attn,
},
diagnostics: DiagnosticPreferences {
simulated_used_memory_gib: self
.simulated_used_memory_gib
.map(u64::try_from)
.transpose()
.map_err(|_| "Saved simulated memory is invalid.")?,
expert_profile_path: self.expert_profile_path.clone(),
},
kv_cache: KvCachePreferences {
budget_gib: self
.kv_budget_gib
.map(u64::try_from)
.transpose()
.map_err(|_| "Saved KV cache budget is invalid.")?,
min_tokens: self
.kv_min_tokens
.map(u32::try_from)
.transpose()
.map_err(|_| "Saved KV cache minimum tokens is invalid.")?,
cold_max_tokens: self
.kv_cold_max_tokens
.map(u32::try_from)
.transpose()
.map_err(|_| "Saved KV cache cold maximum is invalid.")?,
continued_interval_tokens: self
.kv_continued_interval_tokens
.map(u32::try_from)
.transpose()
.map_err(|_| "Saved KV cache continued interval is invalid.")?,
},
})
}
}
#[derive(AsChangeset)]
#[diesel(table_name = preferences)]
struct PreferenceChanges<'a> {
selected_model: &'a str,
dspark_enabled: bool,
idle_timeout_minutes: i32,
context_tokens: i32,
max_generated_tokens: i32,
system_prompt: &'a str,
temperature: Option<f32>,
top_p: Option<f32>,
min_p: Option<f32>,
seed: Option<&'a str>,
reasoning_mode: &'a str,
cpu_threads: Option<i32>,
power_percent: Option<i32>,
prefill_chunk: Option<i32>,
quality: bool,
warm_weights: bool,
mtp_draft_tokens: i32,
mtp_margin: f32,
glm_mtp: bool,
glm_mtp_timing: bool,
dspark_confidence_threshold: Option<f32>,
dspark_strict: bool,
ssd_streaming: bool,
ssd_streaming_cold: bool,
ssd_cache_experts: Option<i64>,
ssd_cache_gib: Option<i64>,
ssd_full_layers: Option<i32>,
ssd_preload_experts: Option<i32>,
directional_steering_file: Option<&'a str>,
directional_steering_ffn: Option<f32>,
directional_steering_attn: Option<f32>,
simulated_used_memory_gib: Option<i64>,
expert_profile_path: Option<&'a str>,
endpoint_port: i32,
endpoint_enabled: bool,
endpoint_cors: bool,
kv_budget_gib: Option<i64>,
kv_min_tokens: Option<i32>,
kv_cold_max_tokens: Option<i32>,
kv_continued_interval_tokens: Option<i32>,
}
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)] #[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = projects)] #[diesel(table_name = projects)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
@@ -466,126 +188,6 @@ impl Database {
.collect()) .collect())
} }
pub fn load_preferences(&mut self) -> Result<AppPreferences, String> {
preferences::table
.find(1)
.select(AppPreferences::as_select())
.first(&mut self.connection)
.map_err(|error| error.to_string())
}
pub fn set_sidebar_collapsed(&mut self, collapsed: bool) -> Result<(), String> {
diesel::update(preferences::table.find(1))
.set(preferences::sidebar_collapsed.eq(collapsed))
.execute(&mut self.connection)
.map(|_| ())
.map_err(|error| error.to_string())
}
pub fn set_sidebar_width(&mut self, width: i32) -> Result<(), String> {
diesel::update(preferences::table.find(1))
.set(preferences::sidebar_width.eq(width))
.execute(&mut self.connection)
.map(|_| ())
.map_err(|error| error.to_string())
}
pub fn set_last_project(&mut self, project_id: Option<i32>) -> Result<(), String> {
diesel::update(preferences::table.find(1))
.set(preferences::last_project_id.eq(project_id))
.execute(&mut self.connection)
.map(|_| ())
.map_err(|error| error.to_string())
}
#[allow(clippy::too_many_arguments)]
pub fn update_preferences(
&mut self,
selected_model: &str,
idle_timeout_minutes: i32,
endpoint_port: i32,
endpoint_enabled: bool,
endpoint_cors: bool,
generation: &GenerationPreferences,
runtime: &RuntimePreferences,
) -> Result<AppPreferences, String> {
generation.validate()?;
let model = crate::model::ModelChoice::from_id(selected_model)
.ok_or_else(|| format!("Unsupported model: {selected_model}"))?;
runtime.validate(model)?;
if !(1..=65_535).contains(&endpoint_port) {
return Err("Endpoint port must be between 1 and 65535.".into());
}
let execution = &runtime.execution;
let speculative = &runtime.speculative;
let (ssd_cache_experts, ssd_cache_gib) = match runtime.ssd.cache {
None => (None, None),
Some(StreamingCacheBudget::Experts(experts)) => (Some(i64::from(experts)), None),
Some(StreamingCacheBudget::Gib(gib)) => (
None,
Some(i64::try_from(gib).map_err(|_| "SSD cache budget is too large.")?),
),
};
let seed = generation.seed.map(|seed| seed.to_string());
diesel::update(preferences::table.find(1))
.set(PreferenceChanges {
selected_model,
dspark_enabled: speculative.dspark_enabled,
idle_timeout_minutes,
context_tokens: generation.context_tokens,
max_generated_tokens: generation.max_generated_tokens,
system_prompt: &generation.system_prompt,
temperature: generation.temperature,
top_p: generation.top_p,
min_p: generation.min_p,
seed: seed.as_deref(),
reasoning_mode: generation.reasoning_mode.id(),
cpu_threads: execution.cpu_threads.map(|value| value as i32),
power_percent: execution.power_percent.map(i32::from),
prefill_chunk: execution.prefill_chunk.map(|value| value as i32),
quality: execution.quality,
warm_weights: execution.warm_weights,
mtp_draft_tokens: speculative.mtp_draft_tokens,
mtp_margin: speculative.mtp_margin,
glm_mtp: speculative.glm_mtp,
glm_mtp_timing: speculative.glm_mtp_timing,
dspark_confidence_threshold: speculative.dspark_confidence_threshold,
dspark_strict: speculative.dspark_strict,
ssd_streaming: runtime.ssd.enabled,
ssd_streaming_cold: runtime.ssd.cold,
ssd_cache_experts,
ssd_cache_gib,
ssd_full_layers: runtime.ssd.full_layers.map(|value| value as i32),
ssd_preload_experts: runtime.ssd.preload_experts.map(|value| value as i32),
directional_steering_file: runtime.steering.file.as_deref(),
directional_steering_ffn: runtime.steering.ffn_scale,
directional_steering_attn: runtime.steering.attention_scale,
simulated_used_memory_gib: runtime
.diagnostics
.simulated_used_memory_gib
.map(|value| value as i64),
expert_profile_path: runtime.diagnostics.expert_profile_path.as_deref(),
endpoint_port,
endpoint_enabled,
endpoint_cors,
kv_budget_gib: runtime
.kv_cache
.budget_gib
.map(i64::try_from)
.transpose()
.map_err(|_| "KV cache budget is too large.")?,
kv_min_tokens: runtime.kv_cache.min_tokens.map(|value| value as i32),
kv_cold_max_tokens: runtime.kv_cache.cold_max_tokens.map(|value| value as i32),
kv_continued_interval_tokens: runtime
.kv_cache
.continued_interval_tokens
.map(|value| value as i32),
})
.returning(AppPreferences::as_returning())
.get_result(&mut self.connection)
.map_err(|error| error.to_string())
}
pub fn create_project(&mut self, name: &str, path: &str) -> Result<Project, String> { pub fn create_project(&mut self, name: &str, path: &str) -> Result<Project, String> {
diesel::insert_into(projects::table) diesel::insert_into(projects::table)
.values(NewProject { name, path }) .values(NewProject { name, path })
@@ -805,94 +407,6 @@ mod tests {
let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3")); let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3"));
let mut database = Database::open(&path).unwrap(); let mut database = Database::open(&path).unwrap();
let preferences = database.load_preferences().unwrap();
assert_eq!(preferences.selected_model, "deepseek-v4-flash");
assert!(!preferences.dspark_enabled);
assert_eq!(preferences.idle_timeout_minutes, 10);
assert_eq!(preferences.endpoint_port, 4000);
assert!(preferences.endpoint_enabled);
assert!(!preferences.endpoint_cors);
let generation = GenerationPreferences::default();
let runtime = RuntimePreferences::default();
assert!(
database
.update_preferences(
"glm-5.2",
30,
4000,
true,
false,
&generation,
&RuntimePreferences {
speculative: SpeculativePreferences {
dspark_enabled: true,
..runtime.speculative.clone()
},
..runtime.clone()
}
)
.is_err()
);
assert!(
database
.update_preferences(
"deepseek-v4-flash",
0,
4000,
true,
false,
&generation,
&runtime,
)
.is_err()
);
let generation = GenerationPreferences {
top_p: Some(0.7),
seed: Some(u64::MAX),
reasoning_mode: ReasoningMode::Max,
..generation
};
let execution = ExecutionPreferences {
cpu_threads: Some(8),
quality: true,
..runtime.execution
};
let speculative = SpeculativePreferences {
glm_mtp: true,
glm_mtp_timing: true,
mtp_draft_tokens: 4,
mtp_margin: 2.5,
..runtime.speculative
};
let runtime = RuntimePreferences {
execution,
speculative,
ssd: SsdPreferences {
enabled: true,
cache: Some(StreamingCacheBudget::Gib(64)),
full_layers: Some(0),
preload_experts: Some(32),
..SsdPreferences::default()
},
diagnostics: DiagnosticPreferences {
simulated_used_memory_gib: Some(8),
expert_profile_path: Some("/tmp/experts.json".into()),
},
kv_cache: KvCachePreferences {
budget_gib: Some(16),
min_tokens: Some(256),
cold_max_tokens: Some(0),
continued_interval_tokens: Some(4_096),
},
..RuntimePreferences::default()
};
database
.update_preferences("glm-5.2", 30, 4567, false, true, &generation, &runtime)
.unwrap();
let preferences = database.load_preferences().unwrap();
assert!(!preferences.endpoint_enabled);
assert!(preferences.endpoint_cors);
let project = database.create_project("DS4", "/tmp/ds4").unwrap(); let project = database.create_project("DS4", "/tmp/ds4").unwrap();
let first = database let first = database
.create_session(project.id, "First session") .create_session(project.id, "First session")
@@ -937,9 +451,6 @@ mod tests {
let loaded = database.load_projects().unwrap(); let loaded = database.load_projects().unwrap();
assert_eq!(loaded[0].sessions[0].id, ordinary); assert_eq!(loaded[0].sessions[0].id, ordinary);
database.set_sidebar_collapsed(true).unwrap();
database.set_last_project(Some(project.id)).unwrap();
database.set_sidebar_width(320).unwrap();
assert!(!loaded[0].project.collapsed); assert!(!loaded[0].project.collapsed);
database.set_project_collapsed(project.id, true).unwrap(); database.set_project_collapsed(project.id, true).unwrap();
assert!(database.load_projects().unwrap()[0].project.collapsed); assert!(database.load_projects().unwrap()[0].project.collapsed);
@@ -949,15 +460,7 @@ mod tests {
drop(database); drop(database);
let mut reopened = Database::open(&path).unwrap(); let mut reopened = Database::open(&path).unwrap();
let preferences = reopened.load_preferences().unwrap(); assert!(reopened.load_projects().unwrap().is_empty());
assert!(preferences.sidebar_collapsed);
assert_eq!(preferences.last_project_id, Some(project.id));
assert_eq!(preferences.sidebar_width, 320);
assert_eq!(preferences.selected_model, "glm-5.2");
assert_eq!(preferences.idle_timeout_minutes, 30);
assert_eq!(preferences.endpoint_port, 4567);
assert_eq!(preferences.generation().unwrap(), generation);
assert_eq!(preferences.runtime().unwrap(), runtime);
drop(reopened); drop(reopened);
fs::remove_file(path).unwrap(); fs::remove_file(path).unwrap();
} }

View File

@@ -1,5 +1,6 @@
mod agent; mod agent;
mod app; mod app;
mod config;
mod database; mod database;
mod engine; mod engine;
mod metrics; mod metrics;

View File

@@ -4,6 +4,7 @@ pub(crate) use transfer::{
delete_managed_artifact, download_managed_artifact, validate_managed_artifact, delete_managed_artifact, download_managed_artifact, validate_managed_artifact,
}; };
use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -56,11 +57,14 @@ const GLM: Artifact = Artifact {
support: Some(false), support: Some(false),
}; };
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) enum ModelChoice { pub(crate) enum ModelChoice {
#[default] #[default]
#[serde(rename = "deepseek-v4-flash")]
DeepSeekV4Flash, DeepSeekV4Flash,
#[serde(rename = "deepseek-v4-pro")]
DeepSeekV4Pro, DeepSeekV4Pro,
#[serde(rename = "glm-5.2")]
Glm52, Glm52,
} }

View File

@@ -19,55 +19,6 @@ diesel::table! {
} }
} }
diesel::table! {
preferences (id) {
id -> Integer,
selected_model -> Text,
dspark_enabled -> Bool,
idle_timeout_minutes -> Integer,
context_tokens -> Integer,
max_generated_tokens -> Integer,
system_prompt -> Text,
temperature -> Nullable<Float>,
top_p -> Nullable<Float>,
min_p -> Nullable<Float>,
seed -> Nullable<Text>,
reasoning_mode -> Text,
cpu_threads -> Nullable<Integer>,
power_percent -> Nullable<Integer>,
prefill_chunk -> Nullable<Integer>,
quality -> Bool,
warm_weights -> Bool,
mtp_draft_tokens -> Integer,
mtp_margin -> Float,
glm_mtp -> Bool,
glm_mtp_timing -> Bool,
dspark_confidence_threshold -> Nullable<Float>,
dspark_strict -> Bool,
ssd_streaming -> Bool,
ssd_streaming_cold -> Bool,
ssd_cache_experts -> Nullable<BigInt>,
ssd_cache_gib -> Nullable<BigInt>,
ssd_full_layers -> Nullable<Integer>,
ssd_preload_experts -> Nullable<Integer>,
directional_steering_file -> Nullable<Text>,
directional_steering_ffn -> Nullable<Float>,
directional_steering_attn -> Nullable<Float>,
simulated_used_memory_gib -> Nullable<BigInt>,
expert_profile_path -> Nullable<Text>,
endpoint_port -> Integer,
endpoint_enabled -> Bool,
endpoint_cors -> Bool,
sidebar_collapsed -> Bool,
last_project_id -> Nullable<Integer>,
sidebar_width -> Integer,
kv_budget_gib -> Nullable<BigInt>,
kv_min_tokens -> Nullable<Integer>,
kv_cold_max_tokens -> Nullable<Integer>,
kv_continued_interval_tokens -> Nullable<Integer>,
}
}
diesel::table! { diesel::table! {
sessions (id) { sessions (id) {
id -> Integer, id -> Integer,
@@ -82,4 +33,4 @@ diesel::table! {
diesel::joinable!(sessions -> projects (project_id)); diesel::joinable!(sessions -> projects (project_id));
diesel::joinable!(messages -> sessions (session_id)); diesel::joinable!(messages -> sessions (session_id));
diesel::allow_tables_to_appear_in_same_query!(messages, preferences, projects, sessions); diesel::allow_tables_to_appear_in_same_query!(messages, projects, sessions);

View File

@@ -20,7 +20,7 @@ use tools::{
#[cfg(test)] #[cfg(test)]
use tools::{canonical_tools, validate_tool_results}; use tools::{canonical_tools, validate_tool_results};
use crate::database::AppPreferences; use crate::config::Config;
use crate::engine::ChatTurn; use crate::engine::ChatTurn;
use crate::metrics::Metrics; use crate::metrics::Metrics;
use crate::model::{self, ModelChoice}; use crate::model::{self, ModelChoice};
@@ -70,7 +70,7 @@ pub(crate) struct ServerHandle {
struct State { struct State {
generation: GenerationService, generation: GenerationService,
preferences: Arc<RwLock<AppPreferences>>, config: Arc<RwLock<Config>>,
models_path: PathBuf, models_path: PathBuf,
cache_path: PathBuf, cache_path: PathBuf,
sequence: AtomicU64, sequence: AtomicU64,
@@ -212,7 +212,7 @@ struct RawOpenAiTool<'a> {
impl ServerHandle { impl ServerHandle {
pub(crate) fn spawn( pub(crate) fn spawn(
generation: GenerationService, generation: GenerationService,
preferences: Arc<RwLock<AppPreferences>>, config: Arc<RwLock<Config>>,
models_path: PathBuf, models_path: PathBuf,
cache_path: PathBuf, cache_path: PathBuf,
port: u16, port: u16,
@@ -230,7 +230,7 @@ impl ServerHandle {
let tool_memory = read_tool_memory(&cache_path); let tool_memory = read_tool_memory(&cache_path);
let state = Arc::new(State { let state = Arc::new(State {
generation, generation,
preferences, config,
models_path, models_path,
cache_path, cache_path,
sequence: AtomicU64::new(0), sequence: AtomicU64::new(0),
@@ -417,12 +417,12 @@ fn handle(mut stream: TcpStream, state: &State) {
let installed = installed_endpoint_models(&state.models_path); let installed = installed_endpoint_models(&state.models_path);
let model = model_alias(id).filter(|model| installed.contains(model)); let model = model_alias(id).filter(|model| installed.contains(model));
if let Some(model) = model { if let Some(model) = model {
let (context, tokens) = state let (context, tokens) = state.config.read().map_or((32_768, 32_768), |config| {
.preferences (
.read() config.generation.context_tokens,
.map_or((32_768, 32_768), |preferences| { config.generation.max_generated_tokens,
(preferences.context_tokens, preferences.max_generated_tokens) )
}); });
send_json_with_cors( send_json_with_cors(
&mut stream, &mut stream,
200, 200,
@@ -549,12 +549,12 @@ fn installed_endpoint_models(models_path: &std::path::Path) -> Vec<ModelChoice>
} }
fn models_json(state: &State) -> Value { fn models_json(state: &State) -> Value {
let (context, tokens) = state let (context, tokens) = state.config.read().map_or((32_768, 32_768), |config| {
.preferences (
.read() config.generation.context_tokens,
.map_or((32_768, 32_768), |preferences| { config.generation.max_generated_tokens,
(preferences.context_tokens, preferences.max_generated_tokens) )
}); });
json!({ json!({
"object": "list", "object": "list",
"data": installed_endpoint_models(&state.models_path) "data": installed_endpoint_models(&state.models_path)
@@ -735,7 +735,7 @@ mod tests {
let metrics = Arc::new(Metrics::new(PathBuf::new().as_path())); let metrics = Arc::new(Metrics::new(PathBuf::new().as_path()));
let state = State { let state = State {
generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(), generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(),
preferences: Arc::new(RwLock::new(AppPreferences::default())), config: Arc::new(RwLock::new(Config::default())),
models_path: PathBuf::new(), models_path: PathBuf::new(),
cache_path: PathBuf::new(), cache_path: PathBuf::new(),
sequence: AtomicU64::new(0), sequence: AtomicU64::new(0),
@@ -773,7 +773,7 @@ mod tests {
let metrics = Arc::new(Metrics::new(&directory)); let metrics = Arc::new(Metrics::new(&directory));
let state = State { let state = State {
generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(), generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(),
preferences: Arc::new(RwLock::new(AppPreferences::default())), config: Arc::new(RwLock::new(Config::default())),
models_path: PathBuf::new(), models_path: PathBuf::new(),
cache_path: directory.clone(), cache_path: directory.clone(),
sequence: AtomicU64::new(0), sequence: AtomicU64::new(0),
@@ -794,7 +794,7 @@ mod tests {
let metrics = Arc::new(Metrics::new(PathBuf::new().as_path())); let metrics = Arc::new(Metrics::new(PathBuf::new().as_path()));
let state = State { let state = State {
generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(), generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(),
preferences: Arc::new(RwLock::new(AppPreferences::default())), config: Arc::new(RwLock::new(Config::default())),
models_path: PathBuf::new(), models_path: PathBuf::new(),
cache_path: PathBuf::new(), cache_path: PathBuf::new(),
sequence: AtomicU64::new(0), sequence: AtomicU64::new(0),
@@ -847,7 +847,7 @@ mod tests {
let metrics = Arc::new(Metrics::new(PathBuf::new().as_path())); let metrics = Arc::new(Metrics::new(PathBuf::new().as_path()));
let state = State { let state = State {
generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(), generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(),
preferences: Arc::new(RwLock::new(AppPreferences::default())), config: Arc::new(RwLock::new(Config::default())),
models_path: PathBuf::new(), models_path: PathBuf::new(),
cache_path: PathBuf::new(), cache_path: PathBuf::new(),
sequence: AtomicU64::new(0), sequence: AtomicU64::new(0),

View File

@@ -484,13 +484,13 @@ pub(super) fn parse_chat_request(
return Err((400, "missing messages".into())); return Err((400, "missing messages".into()));
} }
let requested_id = request.model.as_deref().unwrap_or_default(); let requested_id = request.model.as_deref().unwrap_or_default();
let preferences = state let config = state
.preferences .config
.read() .read()
.map_err(|_| (500, "preferences are unavailable".to_owned()))? .map_err(|_| (500, "preferences are unavailable".to_owned()))?
.clone(); .clone();
let model = if requested_id.is_empty() { let model = if requested_id.is_empty() {
ModelChoice::from_id(&preferences.selected_model) Some(config.model)
} else { } else {
model_alias(requested_id) model_alias(requested_id)
} }
@@ -499,7 +499,7 @@ pub(super) fn parse_chat_request(
return Err((400, format!("model is not installed and verified: {model}"))); return Err((400, format!("model is not installed and verified: {model}")));
} }
let mut generation = preferences.generation().map_err(|error| (500, error))?; let mut generation = config.generation.clone();
generation.system_prompt.clear(); generation.system_prompt.clear();
generation.max_generated_tokens = request generation.max_generated_tokens = request
.max_completion_tokens .max_completion_tokens
@@ -525,8 +525,7 @@ pub(super) fn parse_chat_request(
protocol, protocol,
)?; )?;
generation.system_prompt = system; generation.system_prompt = system;
let runtime = preferences.runtime().map_err(|error| (500, error))?; let mut effective = effective_settings(model, &generation, &config.runtime, &state.models_path)
let mut effective = effective_settings(model, &generation, &runtime, &state.models_path)
.map_err(|error| (400, error))?; .map_err(|error| (400, error))?;
effective.turn.top_k = request.top_k.unwrap_or(0); effective.turn.top_k = request.top_k.unwrap_or(0);
if effective.turn.top_k < 0 { if effective.turn.top_k < 0 {
@@ -549,7 +548,7 @@ pub(super) fn parse_chat_request(
messages, messages,
turn: effective.turn, turn: effective.turn,
engine: effective.engine, engine: effective.engine,
idle_timeout: Duration::from_secs(preferences.idle_timeout_minutes.max(1) as u64 * 60), idle_timeout: Duration::from_secs(config.idle_timeout_minutes.max(1) as u64 * 60),
stream: request.stream, stream: request.stream,
include_usage: request include_usage: request
.stream_options .stream_options

View File

@@ -1,4 +1,5 @@
use crate::model::{self, EngineArtifacts, ModelChoice}; use crate::model::{self, EngineArtifacts, ModelChoice};
use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::path::Path; use std::path::Path;
@@ -17,7 +18,8 @@ const DEFAULT_KV_MIN_TOKENS: u32 = 512;
const DEFAULT_KV_COLD_MAX_TOKENS: u32 = 30_000; const DEFAULT_KV_COLD_MAX_TOKENS: u32 = 30_000;
const DEFAULT_KV_CONTINUED_INTERVAL_TOKENS: u32 = 10_000; const DEFAULT_KV_CONTINUED_INTERVAL_TOKENS: u32 = 10_000;
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct SpeculativePreferences { pub(crate) struct SpeculativePreferences {
pub(crate) mtp_draft_tokens: i32, pub(crate) mtp_draft_tokens: i32,
pub(crate) mtp_margin: f32, pub(crate) mtp_margin: f32,
@@ -94,13 +96,70 @@ pub(crate) struct EngineSpeculativeSettings {
pub(crate) dspark_strict: bool, pub(crate) dspark_strict: bool,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] /// An expert count, or a whole GiB budget. Written as `4` or `64GB`, the same
/// spelling the preferences window accepts.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(into = "String", try_from = "String")]
pub(crate) enum StreamingCacheBudget { pub(crate) enum StreamingCacheBudget {
Experts(u32), Experts(u32),
Gib(u64), Gib(u64),
} }
#[derive(Clone, Debug, Default, Eq, PartialEq)] impl fmt::Display for StreamingCacheBudget {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Experts(experts) => write!(formatter, "{experts}"),
Self::Gib(gib) => write!(formatter, "{gib}GB"),
}
}
}
impl From<StreamingCacheBudget> for String {
fn from(budget: StreamingCacheBudget) -> Self {
budget.to_string()
}
}
impl TryFrom<String> for StreamingCacheBudget {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
impl std::str::FromStr for StreamingCacheBudget {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let value = value.trim();
let gib = value
.get(value.len().saturating_sub(2)..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case("gb"));
let digits = if gib {
&value[..value.len() - 2]
} else {
value
};
let number = (!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
.then(|| digits.parse::<u64>().ok())
.flatten()
.filter(|number| *number > 0);
match number {
Some(number) if gib && number <= u64::MAX / GIB => Ok(Self::Gib(number)),
Some(number) if !gib && number <= u64::from(u32::MAX) => {
Ok(Self::Experts(number as u32))
}
_ => Err(
"SSD cache budget must be a positive expert count or whole GiB value such as 64GB."
.into(),
),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct SsdPreferences { pub(crate) struct SsdPreferences {
pub(crate) enabled: bool, pub(crate) enabled: bool,
pub(crate) cold: bool, pub(crate) cold: bool,
@@ -166,7 +225,8 @@ pub(crate) struct EngineSsdSettings {
/// Disk KV cache management, mirroring ds4's `--kv-disk-space-mb` budget and /// Disk KV cache management, mirroring ds4's `--kv-disk-space-mb` budget and
/// its store-side gates. Unset values keep the DS4 defaults. /// its store-side gates. Unset values keep the DS4 defaults.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct KvCachePreferences { pub(crate) struct KvCachePreferences {
pub(crate) budget_gib: Option<u64>, pub(crate) budget_gib: Option<u64>,
pub(crate) min_tokens: Option<u32>, pub(crate) min_tokens: Option<u32>,
@@ -219,7 +279,8 @@ impl KvCacheSettings {
} }
} }
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct SteeringPreferences { pub(crate) struct SteeringPreferences {
pub(crate) file: Option<String>, pub(crate) file: Option<String>,
pub(crate) ffn_scale: Option<f32>, pub(crate) ffn_scale: Option<f32>,
@@ -270,7 +331,8 @@ pub(crate) struct EngineSteeringSettings {
pub(crate) attention_scale: f32, pub(crate) attention_scale: f32,
} }
#[derive(Clone, Debug, Default, Eq, PartialEq)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct DiagnosticPreferences { pub(crate) struct DiagnosticPreferences {
pub(crate) simulated_used_memory_gib: Option<u64>, pub(crate) simulated_used_memory_gib: Option<u64>,
pub(crate) expert_profile_path: Option<String>, pub(crate) expert_profile_path: Option<String>,
@@ -298,7 +360,8 @@ pub(crate) struct EngineDiagnosticSettings {
pub(crate) expert_profile_path: Option<String>, pub(crate) expert_profile_path: Option<String>,
} }
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct RuntimePreferences { pub(crate) struct RuntimePreferences {
pub(crate) execution: ExecutionPreferences, pub(crate) execution: ExecutionPreferences,
pub(crate) speculative: SpeculativePreferences, pub(crate) speculative: SpeculativePreferences,
@@ -354,7 +417,8 @@ pub(crate) struct EngineSettings {
pub(crate) diagnostics: EngineDiagnosticSettings, pub(crate) diagnostics: EngineDiagnosticSettings,
} }
#[derive(Clone, Debug, Default, Eq, PartialEq)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ExecutionPreferences { pub(crate) struct ExecutionPreferences {
pub(crate) cpu_threads: Option<u32>, pub(crate) cpu_threads: Option<u32>,
pub(crate) power_percent: Option<u8>, pub(crate) power_percent: Option<u8>,
@@ -420,28 +484,16 @@ pub(crate) struct EngineExecutionSettings {
pub(crate) warm_weights: bool, pub(crate) warm_weights: bool,
} }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ReasoningMode { pub(crate) enum ReasoningMode {
#[serde(rename = "none")]
Direct, Direct,
#[default] #[default]
High, High,
Max, Max,
} }
impl ReasoningMode {
pub(crate) fn id(self) -> &'static str {
match self {
Self::Direct => "none",
Self::High => "high",
Self::Max => "max",
}
}
pub(crate) fn from_id(id: &str) -> Option<Self> {
REASONING_MODES.into_iter().find(|mode| mode.id() == id)
}
}
impl fmt::Display for ReasoningMode { impl fmt::Display for ReasoningMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self { formatter.write_str(match self {
@@ -452,7 +504,8 @@ impl fmt::Display for ReasoningMode {
} }
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct GenerationPreferences { pub(crate) struct GenerationPreferences {
pub(crate) context_tokens: i32, pub(crate) context_tokens: i32,
pub(crate) max_generated_tokens: i32, pub(crate) max_generated_tokens: i32,