Add typed generation preferences

This commit is contained in:
Georg Bauer
2026-07-24 15:26:15 +02:00
parent ad7167e84a
commit bccce697af
9 changed files with 529 additions and 28 deletions

View File

@@ -45,9 +45,9 @@ Exit criterion: restart the app and see the same project/session tree.
## Phase 1 — model library and on-demand loading
Status: **partially implemented**. Basic preferences and the complete
background download workflow are implemented; DS4 CLI preference parity,
loading, inference, and idle unloading remain open.
Status: **partially implemented**. Basic preferences, typed generation defaults,
and the complete background download workflow are implemented; the remaining
DS4 runtime preferences, loading, inference, and idle unloading remain open.
1. Complete the typed model/runtime preference contract described below before
connecting chat, the HTTP endpoint, or the engine lifecycle.

View File

@@ -0,0 +1,8 @@
ALTER TABLE preferences DROP COLUMN reasoning_mode;
ALTER TABLE preferences DROP COLUMN seed;
ALTER TABLE preferences DROP COLUMN min_p;
ALTER TABLE preferences DROP COLUMN top_p;
ALTER TABLE preferences DROP COLUMN temperature;
ALTER TABLE preferences DROP COLUMN system_prompt;
ALTER TABLE preferences DROP COLUMN max_generated_tokens;
ALTER TABLE preferences DROP COLUMN context_tokens;

View File

@@ -0,0 +1,14 @@
ALTER TABLE preferences ADD COLUMN context_tokens INTEGER NOT NULL DEFAULT 32768
CHECK (context_tokens > 0);
ALTER TABLE preferences ADD COLUMN max_generated_tokens INTEGER NOT NULL DEFAULT 50000
CHECK (max_generated_tokens > 0);
ALTER TABLE preferences ADD COLUMN system_prompt TEXT NOT NULL DEFAULT 'You are a helpful assistant';
ALTER TABLE preferences ADD COLUMN temperature REAL
CHECK (temperature IS NULL OR temperature BETWEEN 0 AND 100);
ALTER TABLE preferences ADD COLUMN top_p REAL
CHECK (top_p IS NULL OR top_p BETWEEN 0 AND 1);
ALTER TABLE preferences ADD COLUMN min_p REAL
CHECK (min_p IS NULL OR min_p BETWEEN 0 AND 1);
ALTER TABLE preferences ADD COLUMN seed TEXT;
ALTER TABLE preferences ADD COLUMN reasoning_mode TEXT NOT NULL DEFAULT 'high'
CHECK (reasoning_mode IN ('none', 'high', 'max'));

View File

@@ -4,6 +4,7 @@ pub(crate) use view::app_theme;
use crate::database::{AppPreferences, Database, ProjectWithSessions};
use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice};
use crate::settings::{GenerationPreferences, ReasoningMode};
use iced::{Size, Subscription, Task, keyboard, window};
use rfd::AsyncFileDialog;
use std::fs;
@@ -21,18 +22,109 @@ struct PreferenceDraft {
model: ModelChoice,
dspark_enabled: bool,
idle_timeout_minutes: String,
context_tokens: String,
max_generated_tokens: String,
system_prompt: String,
temperature: String,
top_p: String,
min_p: String,
seed: String,
reasoning_mode: ReasoningMode,
}
impl PreferenceDraft {
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()?;
Ok(Self {
model,
dspark_enabled: model.supports_dspark() && preferences.dspark_enabled,
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
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()),
reasoning_mode: generation.reasoning_mode,
})
}
fn generation(&self) -> Result<GenerationPreferences, String> {
let preferences = GenerationPreferences {
context_tokens: parse_positive_i32("Context tokens", &self.context_tokens)?,
max_generated_tokens: parse_positive_i32(
"Maximum generated tokens",
&self.max_generated_tokens,
)?,
system_prompt: self.system_prompt.clone(),
temperature: parse_optional_f32("Temperature", &self.temperature)?,
top_p: parse_optional_f32("Top-p", &self.top_p)?,
min_p: parse_optional_f32("Min-p", &self.min_p)?,
seed: parse_optional_u64("Seed", &self.seed)?,
reasoning_mode: self.reasoning_mode,
};
preferences.validate()?;
Ok(preferences)
}
fn reset(&mut self) {
let defaults = GenerationPreferences::default();
self.model = ModelChoice::default();
self.dspark_enabled = false;
self.idle_timeout_minutes = "10".into();
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;
}
}
fn parse_positive_i32(name: &str, value: &str) -> Result<i32, String> {
value
.trim()
.parse::<i32>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| format!("{name} must be a positive whole number."))
}
fn parse_optional_f32(name: &str, value: &str) -> Result<Option<f32>, String> {
let value = value.trim();
if value.is_empty() {
Ok(None)
} else {
value
.parse()
.map(Some)
.map_err(|_| format!("{name} must be a number or left blank for the DS4 default."))
}
}
fn parse_optional_u64(name: &str, value: &str) -> Result<Option<u64>, String> {
let value = value.trim();
if value.is_empty() {
Ok(None)
} else {
value
.parse()
.map(Some)
.map_err(|_| format!("{name} must be a positive whole number or left blank."))
}
}
pub(crate) struct App {
@@ -96,6 +188,15 @@ pub(crate) enum Message {
PreferenceModelChanged(ModelChoice),
PreferenceDsparkChanged(bool),
PreferenceTimeoutChanged(String),
PreferenceContextChanged(String),
PreferenceMaxTokensChanged(String),
PreferenceSystemPromptChanged(String),
PreferenceTemperatureChanged(String),
PreferenceTopPChanged(String),
PreferenceMinPChanged(String),
PreferenceSeedChanged(String),
PreferenceReasoningChanged(ReasoningMode),
ResetPreferences,
SavePreferences,
DownloadArtifact(ManagedArtifactId),
ValidateArtifact(ManagedArtifactId),
@@ -237,6 +338,42 @@ impl App {
self.preference_draft.idle_timeout_minutes = value;
self.preference_error = None;
}
Message::PreferenceContextChanged(value) => {
self.preference_draft.context_tokens = value;
self.preference_error = None;
}
Message::PreferenceMaxTokensChanged(value) => {
self.preference_draft.max_generated_tokens = value;
self.preference_error = None;
}
Message::PreferenceSystemPromptChanged(value) => {
self.preference_draft.system_prompt = value;
self.preference_error = None;
}
Message::PreferenceTemperatureChanged(value) => {
self.preference_draft.temperature = value;
self.preference_error = None;
}
Message::PreferenceTopPChanged(value) => {
self.preference_draft.top_p = value;
self.preference_error = None;
}
Message::PreferenceMinPChanged(value) => {
self.preference_draft.min_p = value;
self.preference_error = None;
}
Message::PreferenceSeedChanged(value) => {
self.preference_draft.seed = value;
self.preference_error = None;
}
Message::PreferenceReasoningChanged(value) => {
self.preference_draft.reasoning_mode = value;
self.preference_error = None;
}
Message::ResetPreferences => {
self.preference_draft.reset();
self.preference_error = None;
}
Message::SavePreferences => self.save_preferences(),
Message::DownloadArtifact(artifact) => {
self.start_model_operation(artifact, ModelOperation::Download)
@@ -464,13 +601,25 @@ impl App {
self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into());
return;
}
let generation = match self.preference_draft.generation() {
Ok(generation) => generation,
Err(error) => {
self.preference_error = Some(error);
return;
}
};
let model = self.preference_draft.model;
let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled;
let Some(database) = &mut self.database else {
return;
};
match database.update_preferences(model.id(), dspark_enabled, idle_timeout_minutes) {
match database.update_preferences(
model.id(),
dspark_enabled,
idle_timeout_minutes,
&generation,
) {
Ok(preferences) => {
self.preferences = preferences;
self.preference_draft = PreferenceDraft::from_saved(&self.preferences)

View File

@@ -3,6 +3,7 @@ use crate::database::{ProjectWithSessions, Session};
use crate::model::{
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,
};
use crate::settings::REASONING_MODES;
use iced::theme::{Palette, palette};
use iced::widget::{
Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list,
@@ -299,17 +300,13 @@ impl App {
self.preference_draft.dspark_enabled,
)
.on_toggle_maybe(dspark_toggle);
let effective = self
.preference_draft
.generation()
.ok()
.map(|generation| generation.effective(self.preference_draft.model));
let mut content = column![
row![
icon(ICON_SETTINGS, 22),
text("Preferences").size(24),
Space::with_width(Length::Fill),
text("⌘,").size(12),
]
.spacing(10)
.align_y(Alignment::Center),
Space::with_height(6),
let mut fields = column![
text("MODEL").size(11),
pick_list(
&MODEL_CHOICES[..],
@@ -325,6 +322,75 @@ impl App {
})
.size(12),
Space::with_height(8),
text("CAPACITY").size(11),
preference_input_row(
"Context tokens",
text_input("32768", &self.preference_draft.context_tokens)
.on_input(Message::PreferenceContextChanged),
),
preference_input_row(
"Maximum generated tokens",
text_input("50000", &self.preference_draft.max_generated_tokens)
.on_input(Message::PreferenceMaxTokensChanged),
),
text("System prompt").size(13),
text_input(
"You are a helpful assistant",
&self.preference_draft.system_prompt,
)
.on_input(Message::PreferenceSystemPromptChanged)
.padding(9),
Space::with_height(8),
text("SAMPLING AND REASONING").size(11),
preference_input_row(
"Temperature",
text_input("DS4 default", &self.preference_draft.temperature)
.on_input(Message::PreferenceTemperatureChanged),
),
preference_input_row(
"Top-p",
text_input("DS4 default", &self.preference_draft.top_p)
.on_input(Message::PreferenceTopPChanged),
),
preference_input_row(
"Min-p",
text_input("DS4 default", &self.preference_draft.min_p)
.on_input(Message::PreferenceMinPChanged),
),
preference_input_row(
"Seed",
text_input("Random", &self.preference_draft.seed)
.on_input(Message::PreferenceSeedChanged),
),
row![
text("Reasoning").size(13).width(Length::Fill),
pick_list(
&REASONING_MODES[..],
Some(self.preference_draft.reasoning_mode),
Message::PreferenceReasoningChanged,
)
.width(240),
]
.spacing(12)
.align_y(Alignment::Center),
text("Blank sampling values retain DS4's model-family defaults. Think Max needs at least 393216 context tokens.")
.size(12),
text(effective.map_or_else(
|| "Effective settings will appear after valid values are entered.".to_owned(),
|settings| format!(
"Effective: {} context • {} max • temp {} • top-p {} • min-p {} • seed {}{} • system prompt {}",
settings.context_tokens,
settings.max_generated_tokens,
settings.temperature,
settings.top_p,
settings.min_p,
settings.seed.map_or_else(|| "random".to_owned(), |seed| seed.to_string()),
settings.reasoning_mode,
if settings.system_prompt.is_empty() { "off" } else { "on" },
),
))
.size(12),
Space::with_height(8),
text("INACTIVITY").size(11),
row![
text_input("10", &self.preference_draft.idle_timeout_minutes)
@@ -340,23 +406,33 @@ impl App {
.spacing(10);
if let Some(error) = &self.preference_error {
content = content.push(text(error).style(iced::widget::text::danger));
fields = fields.push(text(error).style(iced::widget::text::danger));
}
content = content.push(
row![
Space::with_width(Length::Fill),
action_button("Cancel").on_press(Message::DismissPanel),
action_button("Save").on_press(Message::SavePreferences),
]
.spacing(8),
);
let header = row![
icon(ICON_SETTINGS, 22),
text("Preferences").size(24),
Space::with_width(Length::Fill),
text("⌘,").size(12),
]
.spacing(10)
.align_y(Alignment::Center);
let footer = row![
action_button("Reset DS4 defaults").on_press(Message::ResetPreferences),
Space::with_width(Length::Fill),
action_button("Cancel").on_press(Message::DismissPanel),
action_button("Save").on_press(Message::SavePreferences),
]
.spacing(8);
let panel = container(content)
let panel = container(column![header, scrollable(fields), footer].spacing(16))
.padding(24)
.width(520)
.width(600)
.height(Length::Fill)
.max_height(660)
.style(overview_style);
opaque(
container(panel)
.padding(24)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| {
@@ -541,6 +617,19 @@ fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static
.into()
}
fn preference_input_row<'a>(
label: &'a str,
input: iced::widget::TextInput<'a, Message>,
) -> Element<'a, Message> {
row![
text(label).size(13).width(Length::Fill),
input.width(240).padding(9),
]
.spacing(12)
.align_y(Alignment::Center)
.into()
}
fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
let progress = &download.progress;
let percent = progress.fraction() * 100.0;

View File

@@ -5,6 +5,7 @@ use std::fs;
use std::path::Path;
use crate::schema::{preferences, projects, sessions};
use crate::settings::{GenerationPreferences, ReasoningMode};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
@@ -16,6 +17,14 @@ pub struct AppPreferences {
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,
}
impl Default for AppPreferences {
@@ -25,16 +34,55 @@ impl Default for AppPreferences {
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(),
}
}
}
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)
}
}
#[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,
}
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
@@ -137,12 +185,23 @@ impl Database {
selected_model: &str,
dspark_enabled: bool,
idle_timeout_minutes: i32,
generation: &GenerationPreferences,
) -> Result<AppPreferences, String> {
generation.validate()?;
let seed = generation.seed.map(|seed| seed.to_string());
diesel::update(preferences::table.find(1))
.set(PreferenceChanges {
selected_model,
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(),
})
.returning(AppPreferences::as_returning())
.get_result(&mut self.connection)
@@ -202,13 +261,26 @@ mod tests {
assert_eq!(preferences.selected_model, "deepseek-v4-flash");
assert!(!preferences.dspark_enabled);
assert_eq!(preferences.idle_timeout_minutes, 10);
assert!(database.update_preferences("glm-5.2", true, 30).is_err());
let generation = GenerationPreferences::default();
assert!(
database
.update_preferences("deepseek-v4-flash", false, 0)
.update_preferences("glm-5.2", true, 30, &generation)
.is_err()
);
database.update_preferences("glm-5.2", false, 30).unwrap();
assert!(
database
.update_preferences("deepseek-v4-flash", false, 0, &generation)
.is_err()
);
let generation = GenerationPreferences {
top_p: Some(0.7),
seed: Some(u64::MAX),
reasoning_mode: ReasoningMode::Max,
..generation
};
database
.update_preferences("glm-5.2", false, 30, &generation)
.unwrap();
let project = database.create_project("DS4", "/tmp/ds4").unwrap();
let first = database
@@ -229,6 +301,7 @@ mod tests {
let preferences = reopened.load_preferences().unwrap();
assert_eq!(preferences.selected_model, "glm-5.2");
assert_eq!(preferences.idle_timeout_minutes, 30);
assert_eq!(preferences.generation().unwrap(), generation);
drop(reopened);
fs::remove_file(path).unwrap();
}

View File

@@ -4,6 +4,7 @@ mod model;
#[cfg(target_os = "macos")]
mod native_menu;
mod schema;
mod settings;
use app::{App, Message, app_icon, app_theme};
use iced::{Size, window};

View File

@@ -12,6 +12,14 @@ diesel::table! {
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,
}
}

159
src/settings.rs Normal file
View File

@@ -0,0 +1,159 @@
use crate::model::ModelChoice;
use std::fmt;
pub(crate) const REASONING_MODES: [ReasoningMode; 3] = [
ReasoningMode::High,
ReasoningMode::Max,
ReasoningMode::Direct,
];
const THINK_MAX_MIN_CONTEXT: i32 = 393_216;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ReasoningMode {
Direct,
#[default]
High,
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 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Direct => "Direct (no thinking)",
Self::High => "Thinking",
Self::Max => "Think Max",
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct GenerationPreferences {
pub(crate) context_tokens: i32,
pub(crate) max_generated_tokens: i32,
pub(crate) system_prompt: String,
pub(crate) temperature: Option<f32>,
pub(crate) top_p: Option<f32>,
pub(crate) min_p: Option<f32>,
pub(crate) seed: Option<u64>,
pub(crate) reasoning_mode: ReasoningMode,
}
impl Default for GenerationPreferences {
fn default() -> Self {
Self {
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: ReasoningMode::High,
}
}
}
impl GenerationPreferences {
pub(crate) fn validate(&self) -> Result<(), String> {
if self.context_tokens <= 0 {
return Err("Context tokens must be a positive whole number.".into());
}
if self.max_generated_tokens <= 0 {
return Err("Maximum generated tokens must be a positive whole number.".into());
}
validate_optional_float("Temperature", self.temperature, 0.0, 100.0)?;
validate_optional_float("Top-p", self.top_p, 0.0, 1.0)?;
validate_optional_float("Min-p", self.min_p, 0.0, 1.0)?;
if self.seed == Some(0) {
return Err("Seed must be a positive whole number.".into());
}
Ok(())
}
pub(crate) fn effective(&self, model: ModelChoice) -> EffectiveGenerationSettings {
let glm = model == ModelChoice::Glm52;
EffectiveGenerationSettings {
context_tokens: self.context_tokens,
max_generated_tokens: self.max_generated_tokens,
system_prompt: self.system_prompt.clone(),
temperature: self.temperature.unwrap_or(1.0),
top_p: self.top_p.unwrap_or(if glm { 0.95 } else { 1.0 }),
min_p: self.min_p.unwrap_or(if glm { 0.0 } else { 0.05 }),
seed: self.seed,
reasoning_mode: if self.reasoning_mode == ReasoningMode::Max
&& self.context_tokens < THINK_MAX_MIN_CONTEXT
{
ReasoningMode::High
} else {
self.reasoning_mode
},
}
}
}
fn validate_optional_float(
name: &str,
value: Option<f32>,
minimum: f32,
maximum: f32,
) -> Result<(), String> {
if value.is_some_and(|value| !value.is_finite() || !(minimum..=maximum).contains(&value)) {
return Err(format!("{name} must be between {minimum} and {maximum}."));
}
Ok(())
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct EffectiveGenerationSettings {
pub(crate) context_tokens: i32,
pub(crate) max_generated_tokens: i32,
pub(crate) system_prompt: String,
pub(crate) temperature: f32,
pub(crate) top_p: f32,
pub(crate) min_p: f32,
pub(crate) seed: Option<u64>,
pub(crate) reasoning_mode: ReasoningMode,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effective_settings_preserve_unset_model_defaults() {
let defaults = GenerationPreferences::default();
let deepseek = defaults.effective(ModelChoice::DeepSeekV4Flash);
assert_eq!(
(deepseek.temperature, deepseek.top_p, deepseek.min_p),
(1.0, 1.0, 0.05)
);
let glm = defaults.effective(ModelChoice::Glm52);
assert_eq!((glm.temperature, glm.top_p, glm.min_p), (1.0, 0.95, 0.0));
let explicit = GenerationPreferences {
top_p: Some(0.4),
min_p: Some(0.2),
reasoning_mode: ReasoningMode::Max,
..defaults
};
let effective = explicit.effective(ModelChoice::Glm52);
assert_eq!((effective.top_p, effective.min_p), (0.4, 0.2));
assert_eq!(effective.reasoning_mode, ReasoningMode::High);
}
}