use super::*; use iced::widget::column; impl App { pub(super) fn preferences_panel(&self) -> Element<'_, Message> { let legacy_mtp_toggle: Option Message> = self .preference_draft .model .supports_dspark() .then_some(Message::PreferenceLegacyMtpChanged); let legacy_mtp = hint( checkbox(self.preference_draft.legacy_mtp_enabled) .label("Enable legacy MTP for this model") .on_toggle_maybe(legacy_mtp_toggle), "Uses the managed one-stage MTP support GGUF. The target model verifies every drafted token; it is mutually exclusive with DSpark.", ); let dspark_toggle: Option Message> = self .preference_draft .model .supports_dspark() .then_some(Message::PreferenceDsparkChanged); let dspark = hint( checkbox(self.preference_draft.dspark_enabled) .label("Enable DSpark for this model") .on_toggle_maybe(dspark_toggle), "Speculative decoding with the managed DSpark draft artifact: a small model proposes tokens that the main model verifies in one pass. Usually a large speedup; the target model may also stream routed experts from SSD.", ); let glm_mtp_toggle: Option Message> = (self.preference_draft.model == ModelChoice::Glm52) .then_some(Message::PreferenceGlmMtpChanged); let glm_mtp_timing_toggle: Option Message> = (self.preference_draft.model == ModelChoice::Glm52) .then_some(Message::PreferenceGlmMtpTimingChanged); let dspark_strict_toggle: Option Message> = self .preference_draft .model .supports_dspark() .then_some(Message::PreferenceDsparkStrictChanged); let effective = self .preference_draft .generation() .and_then(|generation| { self.preference_draft.runtime().and_then(|runtime| { crate::settings::effective_settings( self.preference_draft.model, &generation, &runtime, &models_path(), ) }) }) .ok(); let engine = effective.as_ref().map(|settings| &settings.engine); let turn = effective.as_ref().map(|settings| &settings.turn); let mut power = text_input("100", &self.preference_draft.power_percent); let mut prefill = text_input("Automatic", &self.preference_draft.prefill_chunk); let mut ssd_full_layers = text_input("Automatic", &self.preference_draft.ssd_full_layers); let mut steering_file = text_input( "Direction-vector file path", &self.preference_draft.directional_steering_file, ); let mut steering_ffn = text_input("Automatic", &self.preference_draft.directional_steering_ffn); let mut steering_attn = text_input("0", &self.preference_draft.directional_steering_attn); let mut dspark_confidence = text_input( "0.9 (DS4 default)", &self.preference_draft.dspark_confidence_threshold, ); if self.preference_draft.model != ModelChoice::Glm52 { power = power.on_input(Message::PreferencePowerChanged); prefill = prefill.on_input(Message::PreferencePrefillChunkChanged); steering_file = steering_file.on_input(Message::PreferenceSteeringFileChanged); steering_ffn = steering_ffn.on_input(Message::PreferenceSteeringFfnChanged); steering_attn = steering_attn.on_input(Message::PreferenceSteeringAttnChanged); } else { ssd_full_layers = ssd_full_layers.on_input(Message::PreferenceSsdFullLayersChanged); } if self.preference_draft.model.supports_dspark() { dspark_confidence = dspark_confidence.on_input(Message::PreferenceDsparkConfidenceChanged); } let model_group = preference_group( PreferenceSection::Model, "MODEL & LIFECYCLE", column![ hint( pick_list( &MODEL_CHOICES[..], Some(self.preference_draft.model), Message::PreferenceModelChanged, ) .width(Length::Fill), "Which local weights the Metal engine loads for chats and for the HTTP endpoint. The choice decides which accelerations below apply, and the matching artifacts must be downloaded in the model manager.", ), text(format!( "Main: {}{}", engine.map_or_else( || "Invalid settings".to_owned(), |engine| engine.artifacts.model.display().to_string(), ), engine .and_then(|engine| engine.artifacts.mtp.as_ref()) .map_or_else(String::new, |path| format!( " • support: {}", path.display() )), )) .size(12), row![ text_input("10", &self.preference_draft.idle_timeout_minutes) .on_input(Message::PreferenceTimeoutChanged) .width(90) .padding(9), hint( text("minutes before unloading the model").size(13), "Idle time after the last request before the weights are released. Unloading gives tens of gigabytes of memory back to the system; the next prompt then pays the full load time again.", ), ] .spacing(10) .align_y(Alignment::Center), text("Enter a whole number from 1 to 1440.").size(12), hint( checkbox(self.preference_draft.a2ui_enabled) .label("Enable interactive A2UI chat surfaces") .on_toggle(Message::PreferenceA2uiChanged), "Lets the local model build validated native charts, tables, forms and other interactive chat surfaces. Turning it off removes the A2UI catalog from the system prompt.", ), ] .spacing(10), ); let endpoint_group = preference_group( PreferenceSection::Endpoint, "LOCAL ENDPOINT", column![ hint( checkbox(self.preference_draft.endpoint_enabled) .label("Enable OpenAI-compatible endpoint") .on_toggle(Message::PreferenceEndpointEnabledChanged), "Serves the loaded model over an OpenAI-style HTTP API, so editors, scripts and agents on this machine can use it. Turned off, only this window can generate.", ), preference_input_row( "Port", "TCP port the local API listens on. Change it when another program already holds 4000; every client has to be pointed at the same number.", text_input("4000", &self.preference_draft.endpoint_port) .on_input(Message::PreferenceEndpointPortChanged), ), hint( checkbox(self.preference_draft.endpoint_cors) .label("Allow browser clients (CORS)") .on_toggle(Message::PreferenceEndpointCorsChanged), "Answers with permissive CORS headers so JavaScript running in a web page may call the endpoint. Leave it off when only native tools connect.", ), text("Listens only on 127.0.0.1. Saving changed endpoint settings restarts it.") .size(12), ] .spacing(10), ); let dev_brain_group = preference_group( PreferenceSection::DevBrain, "DEV BRAIN", column![ hint( checkbox(self.preference_draft.dev_brain_enabled) .label("Enable project-backed LLM wiki") .on_toggle(Message::PreferenceDevBrainEnabledChanged), "Adds validated search, read, and batch-publication tools for a dedicated Obsidian vault. Disabled means no Dev Brain tools or prompt instructions.", ), row![ text_input( "/path/to/Obsidian vault", &self.preference_draft.dev_brain_vault_path, ) .on_input(Message::PreferenceDevBrainVaultChanged) .padding(9) .width(Length::Fill), action_button("Choose…").on_press(Message::ChooseDevBrainVault), ] .spacing(8) .align_y(Alignment::Center), text("The folder must already contain .obsidian. DS4Server manages only its declared wiki pages and leaves settings, attachments, hidden files, and unrelated notes untouched.") .size(12), ] .spacing(10), ); let generation_group = preference_group( PreferenceSection::Generation, "GENERATION", column![ preference_input_row( "Context tokens", "Size of the window the model can see: system prompt, history, the new question and the answer all have to fit. Larger windows allow longer sessions but reserve much more memory for the key-value cache.", text_input("32768", &self.preference_draft.context_tokens) .on_input(Message::PreferenceContextChanged), ), preference_input_row( "Maximum generated tokens", "Hard stop for a single reply, counted from the first generated token. It bounds runaway answers and reasoning loops; it does not reserve memory.", text_input("50000", &self.preference_draft.max_generated_tokens) .on_input(Message::PreferenceMaxTokensChanged), ), hint( text("System prompt").size(13), "Standing instruction sent before every conversation: persona, tone, house rules. Leave it empty to send no system message at all.", ), text_editor(&self.preference_draft.system_prompt) .placeholder("Additional system instructions…") .height(140) .on_action(Message::PreferenceSystemPromptAction) .padding(9), Space::new().height(4), text("SAMPLING & REASONING").size(11).color(muted_text()), preference_input_row( "Temperature", "How adventurous token choice is. Near 0 the model repeats the most likely continuation, which suits code and extraction; higher values invent more and drift more.", text_input("DS4 default", &self.preference_draft.temperature) .on_input(Message::PreferenceTemperatureChanged), ), preference_input_row( "Top-p", "Nucleus sampling: at each step only the likeliest tokens that together reach this probability mass stay candidates. Lower cuts the long tail of odd words.", text_input("DS4 default", &self.preference_draft.top_p) .on_input(Message::PreferenceTopPChanged), ), preference_input_row( "Min-p", "Drops any token less likely than this fraction of the best token. A cheaper tail cut than top-p that keeps working when temperature is high.", text_input("DS4 default", &self.preference_draft.min_p) .on_input(Message::PreferenceMinPChanged), ), preference_input_row( "Seed", "Fixes the random stream, so the same prompt and settings reproduce the same answer — useful for comparing configurations. Blank draws a fresh seed per request.", text_input("Random", &self.preference_draft.seed) .on_input(Message::PreferenceSeedChanged), ), row![ hint( text("Reasoning").size(13).width(Length::Fill), "How much hidden thinking precedes the answer. Direct skips it and replies fastest, Thinking is the balanced default, Think Max reasons longest and needs at least 393216 context tokens.", ), 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(turn.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), ] .spacing(10), ); let execution_group = preference_group( PreferenceSection::Execution, "EXECUTION", column![ preference_input_row( "CPU helper threads", "Worker threads for the work that stays on the CPU while the GPU decodes: tokenizing, sampling and cache moves. Blank lets the engine pick; the engine never uses more than 32.", text_input("Automatic", &self.preference_draft.cpu_threads) .on_input(Message::PreferenceCpuThreadsChanged), ), preference_input_row( "GPU power percent", "Throttles how hard the Metal engine drives the GPU, from 1 to 100. Lowering it keeps the machine cool and the desktop responsive, and costs tokens per second.", power, ), preference_input_row( "Prefill chunk", "How many prompt tokens are pushed through the model per batch before generation starts. Bigger chunks prefill long prompts faster but raise peak memory; blank sizes them automatically.", prefill, ), hint( checkbox(self.preference_draft.quality) .label("Prefer exact quality kernels") .on_toggle(Message::PreferenceQualityChanged), "Runs the exact Metal kernels instead of the fast approximations. Slightly slower, and it removes the small numeric differences those approximations introduce.", ), hint( checkbox(self.preference_draft.warm_weights) .label("Warm mapped weights at load time") .on_toggle(Message::PreferenceWarmWeightsChanged), "Reads every mapped weight page once at load, so the first reply is not interrupted by page faults from disk. Loading takes longer and memory pressure rises immediately.", ), text(if self.preference_draft.model == ModelChoice::Glm52 { "GLM 5.2 uses full GPU power and selects prefill chunks automatically." } else { "Blank numeric values preserve DS4's automatic engine behavior." }) .size(12), text(engine.as_ref().map_or_else( || "Effective execution settings will appear after valid values are entered." .to_owned(), |engine| { let settings = engine.execution; format!( "Metal engine: threads {} • power {}% • prefill {} • quality {} • warm weights {}", if settings.cpu_threads == 0 { "auto".to_owned() } else { settings.cpu_threads.to_string() }, if settings.power_percent == 0 { 100 } else { settings.power_percent }, if settings.prefill_chunk == 0 { "auto".to_owned() } else { settings.prefill_chunk.to_string() }, if settings.quality { "on" } else { "off" }, if settings.warm_weights { "on" } else { "off" }, ) }, )) .size(12), ] .spacing(10), ); let acceleration_group = preference_group( PreferenceSection::Acceleration, "ACCELERATION & MEMORY", column![ text("SPECULATIVE DECODING").size(11).color(muted_text()), preference_input_row( "MTP draft tokens", "How many tokens the multi-token-prediction head guesses ahead for the main model to check in a single pass. More drafting pays off on predictable text and is wasted work on surprising text; the engine caps it at 16.", text_input("1", &self.preference_draft.mtp_draft_tokens) .on_input(Message::PreferenceMtpDraftChanged), ), preference_input_row( "MTP verifier margin", "How much more likely the main model must find a drafted token before accepting it. A high margin accepts few drafts and stays close to plain decoding; a low one accepts more and rolls back more often.", text_input("3", &self.preference_draft.mtp_margin) .on_input(Message::PreferenceMtpMarginChanged), ), hint( checkbox(self.preference_draft.glm_mtp) .label("Enable integrated GLM MTP") .on_toggle_maybe(glm_mtp_toggle), "Uses the prediction head built into GLM 5.2 for speculative decoding, so no separate draft model is loaded. Available for GLM 5.2 only.", ), hint( checkbox(self.preference_draft.glm_mtp_timing) .label("Log GLM MTP timing counters") .on_toggle_maybe(glm_mtp_timing_toggle), "Records per-stage timings of the speculative path to the log, to show where the acceleration actually goes. A diagnostic aid that costs a little throughput.", ), legacy_mtp, dspark, preference_input_row( "DSpark confidence threshold", "How sure the draft model must be, from 0 to 1, before its token is handed to the verifier. Lower forwards more guesses for more speed and more rejected work; blank uses DS4's 0.9.", dspark_confidence, ), hint( checkbox(self.preference_draft.dspark_strict) .label("DSpark target-only decode") .on_toggle_maybe(dspark_strict_toggle), "Lets the draft model only propose, never decide: every token is sampled by the full model. Gives up some of the speedup in exchange for output identical to non-speculative decoding.", ), text(if self.preference_draft.model.supports_dspark() { "Legacy MTP and DSpark use separate managed support artifacts; entering a DSpark threshold or enabling strict mode selects DSpark." } else if self.preference_draft.model == ModelChoice::Glm52 { "GLM MTP is integrated; DSpark is unavailable for this model." } else { "No managed MTP support artifact is available for this model." }) .size(12), text(engine.as_ref().map_or_else( || "Effective speculative settings will appear after valid values are entered." .to_owned(), |engine| { let settings = engine.speculative; format!( "Engine: MTP draft {} • margin {} • legacy MTP {} • GLM MTP {} • timing {} • DSpark {} • confidence {}{} • target-only {}", settings.mtp_draft_tokens, settings.mtp_margin, if self.preference_draft.legacy_mtp_enabled { "on" } else { "off" }, if settings.glm_mtp { "on" } else { "off" }, if settings.glm_mtp_timing { "on" } else { "off" }, if settings.dspark { "on" } else { "off" }, settings.dspark_confidence_threshold, if settings.dspark_confidence_threshold_set { " explicit" } else { " default" }, if settings.dspark_strict { "on" } else { "off" }, ) }, )) .size(12), Space::new().height(6), text("SSD STREAMING").size(11).color(muted_text()), hint( checkbox(self.preference_draft.ssd_streaming) .label("Enable SSD-backed model streaming") .on_toggle(Message::PreferenceSsdChanged), "Leaves the routed expert weights on disk and pages them in as they are needed, so a model larger than this machine's memory still runs. Every cache miss waits for the SSD; speculative support weights remain resident while target experts stream.", ), hint( checkbox(self.preference_draft.ssd_streaming_cold) .label("Skip automatic expert preload") .on_toggle(Message::PreferenceSsdColdChanged), "Starts with an empty expert cache instead of reading the likely experts up front. The model is ready sooner and uses less memory, at the price of slow first replies.", ), preference_input_row( "Expert cache count or GiB", "How much of the streamed experts stay in memory: a plain count such as 128, or a size such as 64GB. A larger cache means fewer trips to the SSD once the working set settles.", text_input("Automatic, 128, or 64GB", &self.preference_draft.ssd_cache) .on_input(Message::PreferenceSsdCacheChanged), ), preference_input_row( "Fully resident GLM layers", "How many of the first GLM layers are held in memory complete rather than streamed, keeping the hottest part of the model off the disk path. Blank decides automatically, an explicit 0 streams everything.", ssd_full_layers, ), preference_input_row( "Explicit expert preload count", "Overrides how many experts are read in before the first token when preload is on. Blank derives the number from the cache budget.", text_input("Automatic", &self.preference_draft.ssd_preload_experts) .on_input(Message::PreferenceSsdPreloadChanged), ), text("A blank full-layer value is automatic; an explicit 0 disables fully resident GLM layers. Flash legacy MTP and DSpark support weights remain resident when target experts stream.") .size(12), text(engine.as_ref().map_or_else( || "Effective SSD settings will appear after valid values are entered." .to_owned(), |engine| { let settings = engine.ssd; let cache = if settings.cache_bytes > 0 { format!("{} GiB", settings.cache_bytes / GIB) } else if settings.cache_experts > 0 { format!("{} experts", settings.cache_experts) } else { "auto".to_owned() }; format!( "Engine: streaming {} • cold {} • cache {} • full layers {}{} • preload {}", if settings.enabled { "on" } else { "off" }, if settings.cold { "on" } else { "off" }, cache, settings.full_layers, if settings.full_layers_set { " explicit" } else { " auto" }, if settings.preload_experts == 0 { "auto".to_owned() } else { settings.preload_experts.to_string() }, ) }, )) .size(12), ] .spacing(10), ); let steering_group = preference_group( PreferenceSection::Steering, "STEERING & DIAGNOSTICS", column![ text("DIRECTIONAL STEERING").size(11).color(muted_text()), hint( text("Direction-vector file").size(13), "Path to a saved activation direction — a vector distilled from contrasting examples — that is added during generation to push the model toward or away from a behaviour. With no file, steering stays off.", ), steering_file.padding(9), preference_input_row( "FFN scale", "How strongly the direction is added to the feed-forward activations, -100 through 100. Negative values steer away from it; with a file and no explicit scale DS4 uses 1.", steering_ffn, ), preference_input_row( "Attention scale", "The same direction applied to attention activations instead. It stays at 0 unless set, which leaves attention untouched.", steering_attn, ), text(if self.preference_draft.model == ModelChoice::Glm52 { "Directional steering is not supported for GLM 5.2." } else { "With a file and no explicit scale, DS4 defaults the FFN scale to 1. Scales accept -100 through 100." }) .size(12), text(engine.as_ref().map_or_else( || "Effective steering settings will appear after valid values are entered." .to_owned(), |engine| format!( "Engine: file {} • FFN scale {} • attention scale {}", if engine.steering.file.is_some() { "set" } else { "off" }, engine.steering.ffn_scale, engine.steering.attention_scale, ), )) .size(12), Space::new().height(6), text("ADVANCED DIAGNOSTICS").size(11).color(muted_text()), preference_input_row( "Simulated used memory (GiB)", "Tells the engine to plan as if this much memory were already taken, to rehearse how the model would behave on a smaller machine. Nothing is actually reserved or freed.", text_input("Disabled", &self.preference_draft.simulated_used_memory_gib) .on_input(Message::PreferenceSimulatedMemoryChanged), ), hint( text("Routed expert profile output").size(13), "Writes which experts each run actually routed to, so cache and preload sizes can be tuned from measurements instead of guesses. Blank disables profiling.", ), text_input("Output file path", &self.preference_draft.expert_profile_path) .on_input(Message::PreferenceExpertProfileChanged) .padding(9), text(engine.as_ref().map_or_else( || "Effective diagnostic settings will appear after valid values are entered." .to_owned(), |engine| format!( "{} load: simulated memory {} • expert profile {}", engine.model, if engine.diagnostics.simulated_used_memory_bytes == 0 { "off".to_owned() } else { format!("{} GiB", engine.diagnostics.simulated_used_memory_bytes / GIB) }, if engine.diagnostics.expert_profile_path.is_some() { "set" } else { "off" }, ), )) .size(12), ] .spacing(10), ); let kv_cache_group = preference_group( PreferenceSection::KvCache, "KV CACHE", column![ preference_input_row( "Disc budget (GiB)", "How much disc the reusable prompt checkpoints of the endpoint and one-shot requests may occupy. When a new checkpoint does not fit, the least valuable older ones are deleted; they cost only a prefill to rebuild. Blank uses DS4's 4 GiB.", text_input("4", &self.preference_draft.kv_budget_gib) .on_input(Message::PreferenceKvBudgetChanged), ), preference_input_row( "Minimum tokens", "Shortest prompt worth keeping a checkpoint for. Below this the prefill is cheaper than the disc traffic of storing and loading it.", text_input("512", &self.preference_draft.kv_min_tokens) .on_input(Message::PreferenceKvMinTokensChanged), ), preference_input_row( "Cold maximum tokens", "Largest first prompt of a conversation that is still stored. Very long cold prompts write huge checkpoints that are rarely asked for a second time; 0 stops storing cold prompts entirely.", text_input("30000", &self.preference_draft.kv_cold_max_tokens) .on_input(Message::PreferenceKvColdMaxChanged), ), preference_input_row( "Continued interval tokens", "How far a conversation has to grow before the next checkpoint of it is written. Wider spacing writes less and keeps fewer near-identical copies; 0 stores no continued checkpoints at all.", text_input("10000", &self.preference_draft.kv_continued_interval_tokens) .on_input(Message::PreferenceKvContinuedIntervalChanged), ), text("Session checkpoints are not covered by the budget: they belong to their session and go away with it. Blank values keep the DS4 defaults.") .size(12), text( self.preference_draft .runtime() .map_or_else( |_| "Effective KV cache settings will appear after valid values are entered.".to_owned(), |runtime| { let settings = runtime.kv_cache.settings(); format!( "Transient store: budget {} GiB • minimum {} • cold max {} • continued every {}", settings.budget_bytes / GIB, settings.min_tokens, if settings.cold_max_tokens == 0 { "off".to_owned() } else { settings.cold_max_tokens.to_string() }, if settings.continued_interval_tokens == 0 { "off".to_owned() } else { settings.continued_interval_tokens.to_string() }, ) }, ) ) .size(12), ] .spacing(10), ); let mut fields = column![ model_group, endpoint_group, dev_brain_group, generation_group, execution_group, acceleration_group, kv_cache_group, steering_group, ] .spacing(12); if let Some(error) = &self.preference_error { fields = fields.push(text(error).style(iced::widget::text::danger)); } let navigation = PreferenceSection::ALL.iter().fold( column![ row![icon(ICON_SETTINGS, 20), text("Preferences").size(20)] .spacing(9) .align_y(Alignment::Center), text("Jump to section").size(12).color(muted_text()), rule::horizontal(1), ] .spacing(8), |navigation, section| { navigation.push( button(text(section.label()).size(13)) .on_press(Message::ScrollPreferences(*section)) .width(Length::Fill) .padding([8, 10]) .style(button::text), ) }, ); let footer = row![ action_button("Reset DS4 defaults").on_press(Message::ResetPreferences), Space::new().width(Length::Fill), action_button("Cancel").on_press(Message::ClosePreferences), action_button("Save").on_press(Message::SavePreferences), ] .spacing(8); container(row![ container(navigation) .width(210) .height(Length::Fill) .padding(20) .style(sidebar_style), container( column![ row![ text("Settings").size(24), Space::new().width(Length::Fill), text("⌘,").size(12).color(muted_text()), ] .align_y(Alignment::Center), scrollable(container(fields).padding(iced::Padding::ZERO.right(18))) .id(preferences_scroll_id()) .height(Length::Fill), footer, ] .spacing(16), ) .padding(24) .width(Length::Fill) .height(Length::Fill), ]) .width(Length::Fill) .height(Length::Fill) .into() } }