Add typed generation preferences
This commit is contained in:
6
PLAN.md
6
PLAN.md
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
14
migrations/20260724180000_add_generation_preferences/up.sql
Normal file
14
migrations/20260724180000_add_generation_preferences/up.sql
Normal 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'));
|
||||
151
src/app.rs
151
src/app.rs
@@ -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)
|
||||
|
||||
131
src/app/view.rs
131
src/app/view.rs
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
159
src/settings.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user