Files
DS4Server/src/app/view/preferences.rs
2026-09-03 22:38:52 +02:00

960 lines
49 KiB
Rust

use super::*;
use iced::widget::column;
impl App {
pub(super) fn preferences_panel(&self) -> Element<'_, Message> {
let dspark_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.acceleration_model
.supports_dspark()
.then_some(Message::PreferenceDsparkChanged);
let dspark = hint(
toggle(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<fn(bool) -> Message> = self
.preference_draft
.acceleration_model
.supports_integrated_mtp()
.then_some(Message::PreferenceGlmMtpChanged);
let glm_mtp_timing_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.acceleration_model
.supports_integrated_mtp()
.then_some(Message::PreferenceGlmMtpTimingChanged);
let keep_vision_loaded_toggle: Option<fn(bool) -> Message> =
(self.preference_draft.acceleration_model == ModelChoice::Glm53Flash)
.then_some(Message::PreferenceKeepVisionLoadedChanged);
let dspark_strict_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.acceleration_model
.supports_dspark()
.then_some(Message::PreferenceDsparkStrictChanged);
let dspark_exact_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.acceleration_model
.supports_dspark()
.then_some(Message::PreferenceDsparkExactSamplingChanged);
let effective = self
.preference_draft
.effective_for(
self.preference_draft.model,
self.preference_draft.default_reasoning_mode,
)
.ok();
let generation_effective = self
.preference_draft
.effective_for(
self.preference_draft.generation_model,
self.preference_draft.generation_reasoning_mode,
)
.ok();
let acceleration_effective = self
.preference_draft
.effective_for(
self.preference_draft.acceleration_model,
self.preference_draft
.reasoning_mode_for(self.preference_draft.acceleration_model),
)
.ok();
let engine = effective.as_ref().map(|settings| &settings.engine);
let turn = generation_effective.as_ref().map(|settings| &settings.turn);
let acceleration_engine = acceleration_effective
.as_ref()
.map(|settings| &settings.engine);
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.8 (DeepSeek V4 Flash default)",
&self.preference_draft.dspark_confidence_threshold,
);
if !self.preference_draft.model.is_glm() {
power = power.on_input(Message::PreferencePowerChanged);
prefill = prefill.on_input(Message::PreferencePrefillChunkChanged);
}
if self.preference_draft.model != ModelChoice::Glm52 {
steering_file = steering_file.on_input(Message::PreferenceSteeringFileChanged);
steering_ffn = steering_ffn.on_input(Message::PreferenceSteeringFfnChanged);
steering_attn = steering_attn.on_input(Message::PreferenceSteeringAttnChanged);
}
if self.preference_draft.acceleration_model.is_glm() {
ssd_full_layers = ssd_full_layers.on_input(Message::PreferenceSsdFullLayersChanged);
}
if self.preference_draft.acceleration_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.",
),
row![
hint(
text("Default thinking mode").size(13).width(Length::Fill),
"Thinking mode selected when this model becomes active. Chat can switch it for later turns.",
),
pick_list(
self.preference_draft.model.reasoning_modes(),
Some(self.preference_draft.default_reasoning_mode),
Message::PreferenceDefaultReasoningChanged,
)
.width(240),
]
.spacing(12)
.align_y(Alignment::Center),
text(format!(
"Main: {}{}",
engine.map_or_else(
|| "Invalid settings".to_owned(),
|engine| engine.artifacts.model.display().to_string(),
),
engine
.and_then(|engine| engine.artifacts.support.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(
toggle(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.",
),
row![
hint(
text("Default shell permission mode").size(13).width(Length::Fill),
"Heuristic uses the built-in command classifier. AI based asks the local model once before each shell command and prompts when it reports risk.",
),
pick_list(
&PERMISSION_MODES[..],
Some(self.preference_draft.default_permission_mode),
Message::PreferencePermissionModeChanged,
)
.width(180),
]
.spacing(12)
.align_y(Alignment::Center),
]
.spacing(10),
);
let endpoint_group = preference_group(
PreferenceSection::Endpoint,
"LOCAL ENDPOINT",
column![
hint(
toggle(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(
toggle(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(
toggle(self.preference_draft.dev_brain_enabled)
.label("Enable project-backed LLM wiki")
.on_toggle(Message::PreferenceDevBrainEnabledChanged),
"Lets the agent use its normal file tools on managed pages in a dedicated Obsidian vault, with indexed search and validation. Disabled means no Dev Brain access 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),
row![
text("Restore the current built-in Dev Brain guidance after an upgrade.")
.size(12)
.width(Length::Fill),
hint(
action_button("Recreate guidance and skills")
.on_press(Message::RestoreDevBrainDefaultGuides),
"Overwrites purpose.md, schema.md, and included skills with this version's defaults. Other skill pages, generated indexes, topic pages, and log.md are preserved.",
),
]
.spacing(12)
.align_y(Alignment::Center),
]
.spacing(10),
);
let mut installed_extensions = column![].spacing(10);
if self.extensions.extensions.is_empty() {
installed_extensions = installed_extensions.push(
text("No agent extensions are installed.")
.size(12)
.color(muted_text()),
);
}
for extension in &self.extensions.extensions {
let id = extension.id.clone();
let update_id = id.clone();
let uninstall_id = id.clone();
let enabled = extension.enabled;
let toggle_control = toggle(enabled)
.label(extension.name.clone())
.on_toggle(move |enabled| Message::ToggleExtension(id.clone(), enabled));
let details = format!(
"{}{}{} skills • hooks: {}",
extension.version,
extension.author,
extension.skill_count,
extension.hook_names(),
);
let source = format!(
"{}{} • commit {}",
extension.source_url,
extension
.requested_ref
.as_ref()
.map_or_else(String::new, |reference| format!(" @ {reference}")),
&extension.resolved_commit[..extension.resolved_commit.len().min(12)],
);
let mut row_content = column![
row![
toggle_control.width(Length::Fill),
action_button("Update").on_press(Message::UpdateExtension(update_id)),
action_button("Uninstall")
.on_press(Message::RequestUninstallExtension(uninstall_id)),
]
.spacing(8)
.align_y(Alignment::Center),
text(&extension.description).size(12),
text(details).size(12).color(muted_text()),
text(source).size(11).color(muted_text()),
]
.spacing(5);
if let Some(error) = &extension.last_error {
row_content = row_content.push(
text(format!("Last hook error: {error}"))
.size(12)
.style(iced::widget::text::danger),
);
}
if self.pending_extension_trust.as_deref() == Some(extension.id.as_str()) {
row_content = row_content.push(
column![
text("Trust this extension's command hooks?").size(13),
text("Hooks run local programs with your user permissions. DS4Server isolates their environment, limits runtime and output, and never invokes a shell, but the installed code can still read or change files you can access.")
.size(12),
row![
action_button("Cancel").on_press(Message::CancelExtensionTrust),
action_button("Trust and enable")
.on_press(Message::ConfirmExtensionTrust),
]
.spacing(8),
]
.spacing(7),
);
}
if self.pending_extension_uninstall.as_deref() == Some(extension.id.as_str()) {
row_content = row_content.push(
column![
text("Remove this extension and its stored session data?").size(13),
row![
action_button("Cancel").on_press(Message::CancelUninstallExtension),
action_button("Uninstall").on_press(Message::ConfirmUninstallExtension),
]
.spacing(8),
]
.spacing(7),
);
}
installed_extensions = installed_extensions
.push(row_content)
.push(rule::horizontal(1));
}
let mut extension_content = column![
text("Install a portable Codex plugin from an HTTPS Git repository. Updates are manual and preserve the selected ref.")
.size(12),
text_input("https://example.com/owner/extension.git", &self.extension_source)
.on_input(Message::PreferenceExtensionSourceChanged)
.padding(9),
row![
text_input("Optional branch, tag, or commit", &self.extension_ref)
.on_input(Message::PreferenceExtensionRefChanged)
.padding(9)
.width(Length::Fill),
action_button("Install").on_press(Message::InstallExtension),
]
.spacing(8)
.align_y(Alignment::Center),
installed_extensions,
]
.spacing(10);
if let Some(label) = self.extension_operation_label() {
extension_content = extension_content.push(text(format!("{label}")).size(12));
}
if let Some(error) = &self.extension_error {
extension_content =
extension_content.push(text(error).size(12).style(iced::widget::text::danger));
}
let extension_group = preference_group(
PreferenceSection::Extensions,
"AGENT EXTENSIONS",
extension_content,
);
let git_group = preference_group(
PreferenceSection::Git,
"GIT DIFFS",
column![
row![
hint(
text("Default layout").size(13).width(Length::Fill),
"Layout used whenever a file diff is opened. The control in the diff can still change that one view.",
),
pick_list(
&GIT_DIFF_LAYOUTS[..],
Some(self.preference_draft.git_diff_layout),
Message::PreferenceGitDiffLayoutChanged,
)
.width(240),
]
.spacing(12)
.align_y(Alignment::Center),
row![
hint(
text("Algorithm").size(13).width(Length::Fill),
"Default uses libgit2's Myers diff. Patience favors unique matching lines; Minimal spends more time finding the smallest edit script.",
),
pick_list(
&GIT_DIFF_ALGORITHMS[..],
Some(self.preference_draft.git_diff_algorithm),
Message::PreferenceGitDiffAlgorithmChanged,
)
.width(240),
]
.spacing(12)
.align_y(Alignment::Center),
preference_input_row(
"Context lines",
"Unchanged lines shown before and after each changed block. libgit2 defaults to 3.",
text_input("3", &self.preference_draft.git_context_lines)
.on_input(Message::PreferenceGitContextLinesChanged),
),
preference_input_row(
"Interhunk lines",
"Merge nearby changed blocks when no more than this many unchanged lines separate them. Zero keeps libgit2's default separation.",
text_input("0", &self.preference_draft.git_interhunk_lines)
.on_input(Message::PreferenceGitInterhunkLinesChanged),
),
row![
hint(
text("Whitespace").size(13).width(Length::Fill),
"Controls which whitespace-only edits libgit2 omits from the displayed diff.",
),
pick_list(
&GIT_DIFF_WHITESPACE_MODES[..],
Some(self.preference_draft.git_whitespace),
Message::PreferenceGitWhitespaceChanged,
)
.width(240),
]
.spacing(12)
.align_y(Alignment::Center),
hint(
toggle(self.preference_draft.git_indent_heuristic)
.label("Use indentation heuristic")
.on_toggle(Message::PreferenceGitIndentHeuristicChanged),
"Shift ambiguous hunk boundaries toward indentation changes, which usually makes source-code diffs easier to read.",
),
hint(
toggle(self.preference_draft.git_ignore_blank_lines)
.label("Ignore blank-line changes")
.on_toggle(Message::PreferenceGitIgnoreBlankLinesChanged),
"Hide hunks whose changed lines are all blank.",
),
]
.spacing(10),
);
let prompt_group = preference_group(
PreferenceSection::Prompt,
"PROMPT",
column![
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),
text("The system prompt is shared by every model and thinking mode.").size(12),
]
.spacing(10),
);
let generation_group = preference_group(
PreferenceSection::Generation,
"GENERATION",
column![
row![
text("Model").size(13).width(Length::Fill),
pick_list(
&MODEL_CHOICES[..],
Some(self.preference_draft.generation_model),
Message::PreferenceGenerationModelChanged,
)
.width(400),
]
.spacing(12)
.align_y(Alignment::Center),
row![
text("Thinking mode").size(13).width(Length::Fill),
pick_list(
self.preference_draft.generation_model.reasoning_modes(),
Some(self.preference_draft.generation_reasoning_mode),
Message::PreferenceGenerationReasoningChanged,
)
.width(240),
]
.spacing(12)
.align_y(Alignment::Center),
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),
),
text(if self.preference_draft.generation_model == ModelChoice::Glm53Flash {
"GLM 5.3 Flash: 32768 is the recommended default on this 128 GB machine; 50000 is the validated extended-session target."
} else {
""
})
.size(12),
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),
),
text("SAMPLING").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),
),
text("Blank sampling values retain the model-family defaults.")
.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(
toggle(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(
toggle(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.is_glm() {
"GLM 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![
row![
text("Model").size(13).width(Length::Fill),
pick_list(
&MODEL_CHOICES[..],
Some(self.preference_draft.acceleration_model),
Message::PreferenceAccelerationModelChanged,
)
.width(400),
]
.spacing(12)
.align_y(Alignment::Center),
text("SPECULATIVE DECODING").size(11).color(muted_text()),
hint(
toggle(self.preference_draft.glm_mtp)
.label("Enable integrated MTP")
.on_toggle_maybe(glm_mtp_toggle),
"Uses the selected model's managed prediction head for speculative decoding. Qwen loads its pinned MTP sidecar; GLM uses its embedded head.",
),
hint(
toggle(self.preference_draft.glm_mtp_timing)
.label("Log 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.",
),
hint(
toggle(self.preference_draft.keep_vision_loaded)
.label("Keep GLM 5.3 vision weights loaded")
.on_toggle_maybe(keep_vision_loaded_toggle),
"Keeps the vision encoder mapped between image turns for lower image latency. Off releases it after encoding all images in a turn, leaving more memory for long text contexts.",
),
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 the measured DeepSeek V4 Flash optimum of 0.8.",
dspark_confidence,
),
hint(
toggle(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.",
),
hint(
toggle(self.preference_draft.dspark_exact_sampling)
.label("Use exact DSpark sampling")
.on_toggle_maybe(dspark_exact_toggle),
"For non-zero temperatures, applies DS4's exact acceptance and corrected rejection sampling. Off uses the faster opportunistic mode: sample a boundary token, then accept DSpark tokens only while they match the target's greedy path.",
),
text(if self.preference_draft.acceleration_model.supports_dspark() {
"DeepSeek V4 Flash 0731 uses its managed DSpark support artifact."
} else if self.preference_draft.acceleration_model.supports_integrated_mtp() {
"Integrated MTP is available; DSpark is unavailable for this model."
} else {
"No speculative-decoding support is available for this model."
})
.size(12),
text(acceleration_engine.map_or_else(
|| "Effective speculative settings will appear after valid values are entered."
.to_owned(),
|engine| {
let settings = engine.speculative;
format!(
"Engine: integrated MTP {} • timing {} • DSpark {} • confidence {}{} • target-only {} • exact sampling {}",
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" },
if settings.dspark_exact_sampling { "on" } else { "off" },
)
},
))
.size(12),
Space::new().height(6),
text("SSD STREAMING").size(11).color(muted_text()),
hint(
toggle(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(
toggle(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. DSpark support weights remain resident when target experts stream.")
.size(12),
text(acceleration_engine.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,
extension_group,
git_group,
prompt_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);
let base: Element<'_, Message> = 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();
if !self.restore_dev_brain_confirmation {
return base;
}
let confirmation = container(
column![
text("Recreate Dev Brain guidance?").size(22),
text("This replaces purpose.md, schema.md, and included skills in the selected vault with this version's defaults. Other skill pages, generated indexes, topic pages, and log.md are preserved.")
.size(13),
row![
Space::new().width(Length::Fill),
action_button("Cancel")
.on_press(Message::CancelRestoreDevBrainDefaultGuides),
danger_button("Recreate files")
.on_press(Message::ConfirmRestoreDevBrainDefaultGuides),
]
.spacing(8),
]
.spacing(14),
)
.padding(22)
.width(460)
.style(overview_style);
stack![
base,
opaque(
container(confirmation)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| container::Style::default()
.background(Color::from_rgba8(0, 0, 0, 0.68)))
)
]
.into()
}
}