Split Rust code into domain modules
This commit is contained in:
252
src/app/generation.rs
Normal file
252
src/app/generation.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChatMessage {
|
||||
pub(super) id: i32,
|
||||
pub(super) user: bool,
|
||||
pub(super) reasoning: Option<String>,
|
||||
pub(super) reasoning_complete: bool,
|
||||
pub(super) reasoning_open: bool,
|
||||
pub(super) content: String,
|
||||
pub(super) markdown: Vec<markdown::Item>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub(super) fn append(&mut self, reasoning: bool, chunk: &str) {
|
||||
if reasoning {
|
||||
self.reasoning.get_or_insert_default().push_str(chunk);
|
||||
} else {
|
||||
self.reasoning_complete = true;
|
||||
self.content.push_str(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn refresh_markdown(&mut self) {
|
||||
if !self.user {
|
||||
let content = if self.reasoning.is_some() {
|
||||
self.content.trim_start()
|
||||
} else {
|
||||
&self.content
|
||||
};
|
||||
self.markdown = markdown::parse(content).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StoredMessage> for ChatMessage {
|
||||
fn from(message: StoredMessage) -> Self {
|
||||
let mut message = Self {
|
||||
id: message.id,
|
||||
user: message.user,
|
||||
reasoning: message.reasoning,
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
reasoning_open: false,
|
||||
content: message.content,
|
||||
markdown: Vec::new(),
|
||||
};
|
||||
message.refresh_markdown();
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn start_generation(&mut self) {
|
||||
if self.generating || self.selected_session.is_none() {
|
||||
return;
|
||||
}
|
||||
let prompt = self.composer.trim().to_owned();
|
||||
if prompt.is_empty() {
|
||||
return;
|
||||
}
|
||||
let model = match ModelChoice::from_id(&self.preferences.selected_model) {
|
||||
Some(model) => model,
|
||||
None => {
|
||||
self.error = Some("The selected model is not supported.".into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let effective = self.preferences.generation().and_then(|generation| {
|
||||
self.preferences.runtime().and_then(|runtime| {
|
||||
crate::settings::effective_settings(model, &generation, &runtime, &models_path())
|
||||
})
|
||||
});
|
||||
let effective = match effective {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
||||
let session_id = self
|
||||
.selected_session
|
||||
.expect("a selected session was checked");
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut messages = self
|
||||
.conversation
|
||||
.iter()
|
||||
.map(|message| ChatTurn {
|
||||
user: message.user,
|
||||
skip_previous_eos: false,
|
||||
reasoning: message.reasoning.clone(),
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
content: message.content.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
#[cfg(target_os = "macos")]
|
||||
messages.push(ChatTurn {
|
||||
user: true,
|
||||
skip_previous_eos: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
content: prompt.clone(),
|
||||
});
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let Some(service) = &self.generation_service else {
|
||||
self.error = Some("The model runtime is unavailable.".into());
|
||||
return;
|
||||
};
|
||||
let Some(database) = &mut self.database else {
|
||||
return;
|
||||
};
|
||||
let saved = match database.start_chat_turn(session_id, &prompt, assistant_reasoning) {
|
||||
Ok(turn) => turn,
|
||||
Err(error) => {
|
||||
self.error = Some(format!("Could not save the chat turn: {error}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let idle_timeout =
|
||||
Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60);
|
||||
self.active_generation = match service.generate(
|
||||
effective.engine,
|
||||
effective.turn,
|
||||
messages,
|
||||
CheckpointTarget::Local(session_checkpoint_path(session_id)),
|
||||
idle_timeout,
|
||||
) {
|
||||
Ok(active) => Some(active),
|
||||
Err(error) => {
|
||||
self.generation_service = None;
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let user = ChatMessage::from(saved.0);
|
||||
let mut assistant = ChatMessage::from(saved.1);
|
||||
assistant.reasoning_open = assistant_reasoning;
|
||||
self.composer.clear();
|
||||
self.conversation.push(user);
|
||||
self.conversation.push(assistant);
|
||||
self.generating = true;
|
||||
self.tokens_per_second = None;
|
||||
self.error = None;
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = effective;
|
||||
self.error = Some("Local Metal generation requires macOS.".into());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn poll_generation(&mut self) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
let Some(active) = &mut self.active_generation else {
|
||||
self.generating = false;
|
||||
return false;
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut transcript_changed = false;
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut context_changed = false;
|
||||
#[cfg(target_os = "macos")]
|
||||
loop {
|
||||
match active.events.try_recv() {
|
||||
Ok(GenerationEvent::Loading) => {}
|
||||
Ok(GenerationEvent::Chunk { reasoning, content }) => {
|
||||
if let Some(message) = self.conversation.last_mut()
|
||||
&& !message.user
|
||||
{
|
||||
message.append(reasoning, &content);
|
||||
transcript_changed = true;
|
||||
}
|
||||
}
|
||||
Ok(GenerationEvent::Context {
|
||||
used,
|
||||
limit,
|
||||
tokens_per_second,
|
||||
}) => {
|
||||
self.context_used = used;
|
||||
self.context_limit = limit;
|
||||
self.tokens_per_second = tokens_per_second;
|
||||
context_changed = true;
|
||||
}
|
||||
Ok(GenerationEvent::Finished(result)) => {
|
||||
self.generating = false;
|
||||
if let Err(error) = result {
|
||||
self.error = Some(error);
|
||||
}
|
||||
self.active_generation = None;
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.generating = false;
|
||||
self.active_generation = None;
|
||||
self.error = Some("The model runtime stopped unexpectedly.".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if transcript_changed && let Some(message) = self.conversation.last_mut() {
|
||||
message.refresh_markdown();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if transcript_changed
|
||||
&& let Some(message) = self.conversation.last()
|
||||
&& let Some(database) = &mut self.database
|
||||
&& let Err(error) = database.update_message(
|
||||
message.id,
|
||||
message.reasoning.as_deref(),
|
||||
message.reasoning_complete,
|
||||
&message.content,
|
||||
)
|
||||
{
|
||||
if let Some(active) = &self.active_generation {
|
||||
active.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
self.error = Some(format!("Could not save generated chat text: {error}"));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if context_changed
|
||||
&& let Some(session_id) = self.selected_session
|
||||
&& let Some(database) = &mut self.database
|
||||
{
|
||||
if let Err(error) = database.update_session_context(
|
||||
session_id,
|
||||
self.context_used,
|
||||
self.context_limit,
|
||||
self.tokens_per_second,
|
||||
) {
|
||||
self.error = Some(format!("Could not save context usage: {error}"));
|
||||
} else if let Some(session) = self
|
||||
.projects
|
||||
.iter_mut()
|
||||
.flat_map(|project| &mut project.sessions)
|
||||
.find(|session| session.id == session_id)
|
||||
{
|
||||
session.context_used = self.context_used as i32;
|
||||
session.context_limit = self.context_limit as i32;
|
||||
session.last_tokens_per_second = self.tokens_per_second;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
return transcript_changed;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
false
|
||||
}
|
||||
}
|
||||
173
src/app/model_manager.rs
Normal file
173
src/app/model_manager.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum ModelDownload {
|
||||
Idle,
|
||||
Active(ActiveDownload),
|
||||
Complete(ManagedArtifactId, ModelOperation, DownloadProgress),
|
||||
Failed(ManagedArtifactId, String, DownloadProgress),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum ModelOperation {
|
||||
Download,
|
||||
Validate,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ActiveDownload {
|
||||
pub(super) artifact: ManagedArtifactId,
|
||||
pub(super) operation: ModelOperation,
|
||||
pub(super) progress: DownloadProgress,
|
||||
pub(super) sampled_at: Instant,
|
||||
pub(super) sampled_bytes: u64,
|
||||
pub(super) bytes_per_second: f64,
|
||||
pub(super) verified_bytes: Arc<AtomicU64>,
|
||||
pub(super) cancel: Arc<AtomicBool>,
|
||||
pub(super) result: mpsc::Receiver<Result<DownloadOutcome, String>>,
|
||||
pub(super) stopping: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn open_model_manager(&mut self) -> Task<Message> {
|
||||
if let Some(id) = self.model_manager_window {
|
||||
return window::gain_focus(id);
|
||||
}
|
||||
let (id, open) = window::open(window::Settings {
|
||||
size: Size::new(760.0, 560.0),
|
||||
min_size: Some(Size::new(620.0, 420.0)),
|
||||
icon: Some(app_icon()),
|
||||
..Default::default()
|
||||
});
|
||||
self.model_manager_window = Some(id);
|
||||
open.map(Message::ModelManagerOpened)
|
||||
}
|
||||
|
||||
pub(super) fn start_model_operation(
|
||||
&mut self,
|
||||
artifact: ManagedArtifactId,
|
||||
operation: ModelOperation,
|
||||
) {
|
||||
if matches!(self.model_download, ModelDownload::Active(_)) {
|
||||
return;
|
||||
}
|
||||
let models_path = models_path();
|
||||
let progress = match operation {
|
||||
ModelOperation::Download => model::artifact_download_progress(artifact, &models_path),
|
||||
ModelOperation::Validate => model::artifact_verification_progress(artifact, 0),
|
||||
};
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let worker_cancel = Arc::clone(&cancel);
|
||||
let verified_bytes = Arc::new(AtomicU64::new(0));
|
||||
let worker_verified_bytes = Arc::clone(&verified_bytes);
|
||||
let (result_sender, result_receiver) = mpsc::channel();
|
||||
let thread_name = match operation {
|
||||
ModelOperation::Download => "model-download",
|
||||
ModelOperation::Validate => "model-validation",
|
||||
};
|
||||
if let Err(error) = thread::Builder::new()
|
||||
.name(thread_name.to_owned())
|
||||
.spawn(move || {
|
||||
let result = match operation {
|
||||
ModelOperation::Download => model::download_managed_artifact(
|
||||
artifact,
|
||||
&models_path,
|
||||
&worker_cancel,
|
||||
&worker_verified_bytes,
|
||||
),
|
||||
ModelOperation::Validate => model::validate_managed_artifact(
|
||||
artifact,
|
||||
&models_path,
|
||||
&worker_cancel,
|
||||
&worker_verified_bytes,
|
||||
),
|
||||
};
|
||||
let _ = result_sender.send(result);
|
||||
})
|
||||
{
|
||||
self.error = Some(format!("Could not start {thread_name}: {error}"));
|
||||
return;
|
||||
}
|
||||
self.pending_model_delete = None;
|
||||
self.model_download = ModelDownload::Active(ActiveDownload {
|
||||
artifact,
|
||||
operation,
|
||||
sampled_at: Instant::now(),
|
||||
sampled_bytes: progress.completed(),
|
||||
progress,
|
||||
bytes_per_second: 0.0,
|
||||
verified_bytes,
|
||||
cancel,
|
||||
result: result_receiver,
|
||||
stopping: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn update_download_progress(&mut self) {
|
||||
let ModelDownload::Active(download) = &mut self.model_download else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
let verified = download.verified_bytes.load(Ordering::Relaxed);
|
||||
let mut progress = match download.operation {
|
||||
ModelOperation::Download => {
|
||||
model::artifact_download_progress(download.artifact, &models_path())
|
||||
}
|
||||
ModelOperation::Validate => {
|
||||
model::artifact_verification_progress(download.artifact, verified)
|
||||
}
|
||||
};
|
||||
if download.operation == ModelOperation::Download
|
||||
&& let Some(verification) = &mut progress.verification
|
||||
{
|
||||
verification.verified = verified.min(verification.total);
|
||||
}
|
||||
let elapsed = now.duration_since(download.sampled_at).as_secs_f64();
|
||||
let completed = progress.completed();
|
||||
let phase_changed = progress.phase != download.progress.phase;
|
||||
let transferred = completed.saturating_sub(download.sampled_bytes);
|
||||
if phase_changed {
|
||||
download.bytes_per_second = 0.0;
|
||||
} else if transferred > 0 && elapsed > 0.0 {
|
||||
let current = transferred as f64 / elapsed;
|
||||
download.bytes_per_second = if download.bytes_per_second == 0.0 {
|
||||
current
|
||||
} else {
|
||||
download.bytes_per_second * 0.75 + current * 0.25
|
||||
};
|
||||
}
|
||||
download.progress = progress;
|
||||
download.sampled_at = now;
|
||||
download.sampled_bytes = completed;
|
||||
|
||||
let result = match download.result.try_recv() {
|
||||
Ok(result) => Some(result),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
Some(Err("Download worker stopped unexpectedly.".into()))
|
||||
}
|
||||
};
|
||||
let artifact = download.artifact;
|
||||
let operation = download.operation;
|
||||
let progress = download.progress.clone();
|
||||
if let Some(result) = result {
|
||||
match result {
|
||||
Ok(DownloadOutcome::Complete) => {
|
||||
let progress = model::artifact_download_progress(artifact, &models_path());
|
||||
self.model_download = ModelDownload::Complete(artifact, operation, progress);
|
||||
self.error = None;
|
||||
}
|
||||
Ok(DownloadOutcome::Stopped) => {
|
||||
self.model_download = ModelDownload::Idle;
|
||||
self.error = None;
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error.clone());
|
||||
self.model_download = ModelDownload::Failed(artifact, error, progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
476
src/app/preferences.rs
Normal file
476
src/app/preferences.rs
Normal file
@@ -0,0 +1,476 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct PreferenceDraft {
|
||||
pub(super) model: ModelChoice,
|
||||
pub(super) dspark_enabled: bool,
|
||||
pub(super) idle_timeout_minutes: String,
|
||||
pub(super) endpoint_port: String,
|
||||
pub(super) context_tokens: String,
|
||||
pub(super) max_generated_tokens: String,
|
||||
pub(super) system_prompt: String,
|
||||
pub(super) temperature: String,
|
||||
pub(super) top_p: String,
|
||||
pub(super) min_p: String,
|
||||
pub(super) seed: String,
|
||||
pub(super) reasoning_mode: ReasoningMode,
|
||||
pub(super) cpu_threads: String,
|
||||
pub(super) power_percent: String,
|
||||
pub(super) prefill_chunk: String,
|
||||
pub(super) quality: bool,
|
||||
pub(super) warm_weights: bool,
|
||||
pub(super) mtp_draft_tokens: String,
|
||||
pub(super) mtp_margin: String,
|
||||
pub(super) glm_mtp: bool,
|
||||
pub(super) glm_mtp_timing: bool,
|
||||
pub(super) dspark_confidence_threshold: String,
|
||||
pub(super) dspark_strict: bool,
|
||||
pub(super) ssd_streaming: bool,
|
||||
pub(super) ssd_streaming_cold: bool,
|
||||
pub(super) ssd_cache: String,
|
||||
pub(super) ssd_full_layers: String,
|
||||
pub(super) ssd_preload_experts: String,
|
||||
pub(super) directional_steering_file: String,
|
||||
pub(super) directional_steering_ffn: String,
|
||||
pub(super) directional_steering_attn: String,
|
||||
pub(super) simulated_used_memory_gib: String,
|
||||
pub(super) expert_profile_path: String,
|
||||
}
|
||||
|
||||
impl PreferenceDraft {
|
||||
pub(super) 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()?;
|
||||
let runtime = preferences.runtime()?;
|
||||
runtime.validate(model)?;
|
||||
let execution = &runtime.execution;
|
||||
let speculative = &runtime.speculative;
|
||||
Ok(Self {
|
||||
model,
|
||||
dspark_enabled: speculative.dspark_enabled,
|
||||
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
|
||||
endpoint_port: preferences.endpoint_port.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,
|
||||
cpu_threads: execution
|
||||
.cpu_threads
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
power_percent: execution
|
||||
.power_percent
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
prefill_chunk: execution
|
||||
.prefill_chunk
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
quality: execution.quality,
|
||||
warm_weights: execution.warm_weights,
|
||||
mtp_draft_tokens: speculative.mtp_draft_tokens.to_string(),
|
||||
mtp_margin: speculative.mtp_margin.to_string(),
|
||||
glm_mtp: speculative.glm_mtp,
|
||||
glm_mtp_timing: speculative.glm_mtp_timing,
|
||||
dspark_confidence_threshold: speculative
|
||||
.dspark_confidence_threshold
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
dspark_strict: speculative.dspark_strict,
|
||||
ssd_streaming: runtime.ssd.enabled,
|
||||
ssd_streaming_cold: runtime.ssd.cold,
|
||||
ssd_cache: runtime
|
||||
.ssd
|
||||
.cache
|
||||
.map_or_else(String::new, |cache| match cache {
|
||||
StreamingCacheBudget::Experts(experts) => experts.to_string(),
|
||||
StreamingCacheBudget::Gib(gib) => format!("{gib}GB"),
|
||||
}),
|
||||
ssd_full_layers: runtime
|
||||
.ssd
|
||||
.full_layers
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
ssd_preload_experts: runtime
|
||||
.ssd
|
||||
.preload_experts
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
directional_steering_file: runtime.steering.file.unwrap_or_default(),
|
||||
directional_steering_ffn: runtime
|
||||
.steering
|
||||
.ffn_scale
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
directional_steering_attn: runtime
|
||||
.steering
|
||||
.attention_scale
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
simulated_used_memory_gib: runtime
|
||||
.diagnostics
|
||||
.simulated_used_memory_gib
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
expert_profile_path: runtime.diagnostics.expert_profile_path.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
pub(super) fn reset(&mut self) {
|
||||
let defaults = GenerationPreferences::default();
|
||||
let execution = ExecutionPreferences::default();
|
||||
let speculative = SpeculativePreferences::default();
|
||||
let ssd = SsdPreferences::default();
|
||||
self.model = ModelChoice::default();
|
||||
self.dspark_enabled = false;
|
||||
self.idle_timeout_minutes = "10".into();
|
||||
self.endpoint_port = "4000".into();
|
||||
self.context_tokens = defaults.context_tokens.to_string();
|
||||
self.max_generated_tokens = defaults.max_generated_tokens.to_string();
|
||||
self.system_prompt = defaults.system_prompt;
|
||||
self.temperature.clear();
|
||||
self.top_p.clear();
|
||||
self.min_p.clear();
|
||||
self.seed.clear();
|
||||
self.reasoning_mode = defaults.reasoning_mode;
|
||||
self.cpu_threads.clear();
|
||||
self.power_percent.clear();
|
||||
self.prefill_chunk.clear();
|
||||
self.quality = execution.quality;
|
||||
self.warm_weights = execution.warm_weights;
|
||||
self.mtp_draft_tokens = speculative.mtp_draft_tokens.to_string();
|
||||
self.mtp_margin = speculative.mtp_margin.to_string();
|
||||
self.glm_mtp = speculative.glm_mtp;
|
||||
self.glm_mtp_timing = speculative.glm_mtp_timing;
|
||||
self.dspark_confidence_threshold.clear();
|
||||
self.dspark_strict = speculative.dspark_strict;
|
||||
self.ssd_streaming = ssd.enabled;
|
||||
self.ssd_streaming_cold = ssd.cold;
|
||||
self.ssd_cache.clear();
|
||||
self.ssd_full_layers.clear();
|
||||
self.ssd_preload_experts.clear();
|
||||
self.directional_steering_file.clear();
|
||||
self.directional_steering_ffn.clear();
|
||||
self.directional_steering_attn.clear();
|
||||
self.simulated_used_memory_gib.clear();
|
||||
self.expert_profile_path.clear();
|
||||
}
|
||||
|
||||
pub(super) fn execution(&self) -> Result<ExecutionPreferences, String> {
|
||||
Ok(ExecutionPreferences {
|
||||
cpu_threads: parse_optional_u32("CPU helper threads", &self.cpu_threads)?,
|
||||
power_percent: parse_optional_u8("GPU power", &self.power_percent)?,
|
||||
prefill_chunk: parse_optional_u32("Prefill chunk", &self.prefill_chunk)?,
|
||||
quality: self.quality,
|
||||
warm_weights: self.warm_weights,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn speculative(&self) -> Result<SpeculativePreferences, String> {
|
||||
Ok(SpeculativePreferences {
|
||||
mtp_draft_tokens: parse_positive_i32("MTP draft tokens", &self.mtp_draft_tokens)?,
|
||||
mtp_margin: parse_f32("MTP margin", &self.mtp_margin)?,
|
||||
glm_mtp: self.glm_mtp,
|
||||
glm_mtp_timing: self.glm_mtp_timing,
|
||||
dspark_enabled: self.dspark_enabled,
|
||||
dspark_confidence_threshold: parse_optional_f32(
|
||||
"DSpark confidence",
|
||||
&self.dspark_confidence_threshold,
|
||||
)?,
|
||||
dspark_strict: self.dspark_strict,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn runtime(&self) -> Result<RuntimePreferences, String> {
|
||||
Ok(RuntimePreferences {
|
||||
execution: self.execution()?,
|
||||
speculative: self.speculative()?,
|
||||
ssd: SsdPreferences {
|
||||
enabled: self.ssd_streaming,
|
||||
cold: self.ssd_streaming_cold,
|
||||
cache: parse_streaming_cache(&self.ssd_cache)?,
|
||||
full_layers: parse_optional_u32("SSD full-layer count", &self.ssd_full_layers)?,
|
||||
preload_experts: parse_optional_u32(
|
||||
"SSD preload experts",
|
||||
&self.ssd_preload_experts,
|
||||
)?,
|
||||
},
|
||||
steering: SteeringPreferences {
|
||||
file: optional_text(&self.directional_steering_file),
|
||||
ffn_scale: parse_optional_f32(
|
||||
"Directional FFN scale",
|
||||
&self.directional_steering_ffn,
|
||||
)?,
|
||||
attention_scale: parse_optional_f32(
|
||||
"Directional attention scale",
|
||||
&self.directional_steering_attn,
|
||||
)?,
|
||||
},
|
||||
diagnostics: DiagnosticPreferences {
|
||||
simulated_used_memory_gib: parse_optional_gib(
|
||||
"Simulated used memory",
|
||||
&self.simulated_used_memory_gib,
|
||||
)?,
|
||||
expert_profile_path: optional_text(&self.expert_profile_path),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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_f32(name: &str, value: &str) -> Result<f32, String> {
|
||||
value
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("{name} must be a number."))
|
||||
}
|
||||
|
||||
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."))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_u32(name: &str, value: &str) -> Result<Option<u32>, String> {
|
||||
parse_optional_number(name, value)
|
||||
}
|
||||
|
||||
fn parse_optional_u8(name: &str, value: &str) -> Result<Option<u8>, String> {
|
||||
parse_optional_number(name, value)
|
||||
}
|
||||
|
||||
fn parse_optional_number<T: std::str::FromStr>(
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> Result<Option<T>, 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(super) fn parse_streaming_cache(value: &str) -> Result<Option<StreamingCacheBudget>, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.len() > 2
|
||||
&& value
|
||||
.get(value.len() - 2..)
|
||||
.is_some_and(|suffix| suffix.eq_ignore_ascii_case("gb"))
|
||||
{
|
||||
return parse_gib("SSD cache budget", value)
|
||||
.map(|gib| Some(StreamingCacheBudget::Gib(gib)));
|
||||
}
|
||||
if !value.chars().all(|character| character.is_ascii_digit()) {
|
||||
return Err(
|
||||
"SSD cache budget must be a positive expert count or whole GiB value such as 64GB."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
value
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|value| *value > 0)
|
||||
.map(StreamingCacheBudget::Experts)
|
||||
.map(Some)
|
||||
.ok_or_else(|| {
|
||||
"SSD cache budget must be a positive expert count or whole GiB value such as 64GB."
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_optional_gib(name: &str, value: &str) -> Result<Option<u64>, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
parse_gib(name, value).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_gib(name: &str, value: &str) -> Result<u64, String> {
|
||||
let value = value
|
||||
.get(value.len().saturating_sub(2)..)
|
||||
.filter(|suffix| suffix.eq_ignore_ascii_case("gb"))
|
||||
.map_or(value, |_| &value[..value.len() - 2]);
|
||||
if !value.chars().all(|character| character.is_ascii_digit()) {
|
||||
return Err(format!("{name} must be a positive whole GiB value."));
|
||||
}
|
||||
value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|value| *value > 0 && *value <= u64::MAX / GIB)
|
||||
.ok_or_else(|| format!("{name} must be a positive whole GiB value."))
|
||||
}
|
||||
|
||||
fn optional_text(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn open_preferences(&mut self) {
|
||||
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
|
||||
return;
|
||||
}
|
||||
match PreferenceDraft::from_saved(&self.preferences) {
|
||||
Ok(draft) => {
|
||||
self.preference_draft = draft;
|
||||
self.preference_error = None;
|
||||
self.preferences_open = true;
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_preferences(&mut self) {
|
||||
let Ok(idle_timeout_minutes) = self
|
||||
.preference_draft
|
||||
.idle_timeout_minutes
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
else {
|
||||
self.preference_error = Some("Idle timeout must be a whole number.".into());
|
||||
return;
|
||||
};
|
||||
if !(1..=1440).contains(&idle_timeout_minutes) {
|
||||
self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into());
|
||||
return;
|
||||
}
|
||||
let Ok(endpoint_port) = self.preference_draft.endpoint_port.trim().parse::<u16>() else {
|
||||
self.preference_error = Some("Endpoint port must be a whole number.".into());
|
||||
return;
|
||||
};
|
||||
if endpoint_port == 0 {
|
||||
self.preference_error = Some("Endpoint port must be between 1 and 65535.".into());
|
||||
return;
|
||||
}
|
||||
let generation = match self.preference_draft.generation() {
|
||||
Ok(generation) => generation,
|
||||
Err(error) => {
|
||||
self.preference_error = Some(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let model = self.preference_draft.model;
|
||||
let runtime = match self.preference_draft.runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
self.preference_error = Some(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = runtime.validate(model) {
|
||||
self.preference_error = Some(error);
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let pending_endpoint = if self.preferences.endpoint_port != i32::from(endpoint_port)
|
||||
|| self._endpoint.is_none()
|
||||
{
|
||||
let Some(generation) = &self.generation_service else {
|
||||
self.preference_error = Some("The model runtime is unavailable.".into());
|
||||
return;
|
||||
};
|
||||
match crate::server::ServerHandle::spawn(
|
||||
generation.clone(),
|
||||
Arc::clone(&self.runtime_preferences),
|
||||
models_path(),
|
||||
application_support_path().join("kv-cache").join("http"),
|
||||
endpoint_port,
|
||||
Arc::clone(&self.metrics),
|
||||
) {
|
||||
Ok(endpoint) => Some(endpoint),
|
||||
Err(error) => {
|
||||
self.preference_error = Some(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(database) = &mut self.database else {
|
||||
return;
|
||||
};
|
||||
match database.update_preferences(
|
||||
model.id(),
|
||||
idle_timeout_minutes,
|
||||
i32::from(endpoint_port),
|
||||
&generation,
|
||||
&runtime,
|
||||
) {
|
||||
Ok(preferences) => {
|
||||
self.preferences = preferences;
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Ok(mut runtime_preferences) = self.runtime_preferences.write() {
|
||||
*runtime_preferences = self.preferences.clone();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(endpoint) = pending_endpoint {
|
||||
self._endpoint = Some(endpoint);
|
||||
}
|
||||
self.preference_draft = PreferenceDraft::from_saved(&self.preferences)
|
||||
.expect("the saved model was selected from the supported catalog");
|
||||
self.preferences_open = false;
|
||||
self.preference_error = None;
|
||||
self.error = None;
|
||||
}
|
||||
Err(error) => self.preference_error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/app/projects.rs
Normal file
102
src/app/projects.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
pub(super) fn prepare_project(&mut self, path: PathBuf) {
|
||||
let Ok(path) = fs::canonicalize(path) else {
|
||||
self.error = Some("The selected folder is no longer available.".into());
|
||||
return;
|
||||
};
|
||||
let Some(path_text) = path.to_str() else {
|
||||
self.error = Some("The selected folder path is not valid UTF-8.".into());
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.projects
|
||||
.iter()
|
||||
.any(|item| item.project.path == path_text)
|
||||
{
|
||||
self.error = Some("That project is already in the sidebar.".into());
|
||||
return;
|
||||
}
|
||||
|
||||
self.project_name_input = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Project")
|
||||
.to_owned();
|
||||
self.pending_project_path = Some(path);
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
pub(super) fn create_project(&mut self) {
|
||||
let name = self.project_name_input.trim();
|
||||
if name.is_empty() {
|
||||
self.error = Some("Project name cannot be empty.".into());
|
||||
return;
|
||||
}
|
||||
let Some(path) = &self.pending_project_path else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = path.to_str() else {
|
||||
self.error = Some("The selected folder path is not valid UTF-8.".into());
|
||||
return;
|
||||
};
|
||||
let Some(database) = &mut self.database else {
|
||||
return;
|
||||
};
|
||||
|
||||
match database.create_project(name, path) {
|
||||
Ok(project) => {
|
||||
self.selected_project = Some(project.id);
|
||||
self.selected_session = None;
|
||||
self.pending_project_path = None;
|
||||
self.project_name_input.clear();
|
||||
self.error = None;
|
||||
self.reload_projects();
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create_session(&mut self) {
|
||||
if self.generating {
|
||||
self.error = Some("Stop the active generation before creating a session.".into());
|
||||
return;
|
||||
}
|
||||
let Some(project_id) = self.selected_project else {
|
||||
self.error = Some("Select a project first.".into());
|
||||
return;
|
||||
};
|
||||
let default_number = self
|
||||
.selected_project()
|
||||
.map(|project| project.sessions.len() + 1)
|
||||
.unwrap_or(1);
|
||||
let title = format!("Session {default_number}");
|
||||
let Some(database) = &mut self.database else {
|
||||
return;
|
||||
};
|
||||
|
||||
match database.create_session(project_id, &title) {
|
||||
Ok(session) => {
|
||||
self.selected_session = Some(session.id);
|
||||
self.conversation.clear();
|
||||
self.composer.clear();
|
||||
self.context_used = 0;
|
||||
self.context_limit = self.preferences.context_tokens.max(0) as u32;
|
||||
self.tokens_per_second = None;
|
||||
self.error = None;
|
||||
self.reload_projects();
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reload_projects(&mut self) {
|
||||
if let Some(database) = &mut self.database {
|
||||
match database.load_projects() {
|
||||
Ok(projects) => self.projects = projects,
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1308
src/app/view.rs
1308
src/app/view.rs
File diff suppressed because it is too large
Load Diff
210
src/app/view/chat.rs
Normal file
210
src/app/view/chat.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
|
||||
impl App {
|
||||
pub(super) fn chat_detail(&self) -> Element<'_, Message> {
|
||||
let Some(item) = self.selected_project() else {
|
||||
let open_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Open project…"),]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
let open_project = if self.database.is_some() && !self.choosing_folder {
|
||||
action_button(open_project_content).on_press(Message::ChooseProjectFolder)
|
||||
} else {
|
||||
action_button(open_project_content)
|
||||
};
|
||||
return container(
|
||||
column![
|
||||
icon(ICON_SPARK, 36),
|
||||
text("Start a local coding session").size(28),
|
||||
text("Choose a project folder to create your first session.").size(14),
|
||||
Space::with_height(10),
|
||||
open_project,
|
||||
]
|
||||
.spacing(10)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into();
|
||||
};
|
||||
|
||||
let project = &item.project;
|
||||
let selected_title = self
|
||||
.selected_session(item)
|
||||
.map(|session| session.title.as_str())
|
||||
.unwrap_or(project.name.as_str());
|
||||
let header = row![
|
||||
icon(ICON_FOLDER, 19),
|
||||
text(selected_title).size(18),
|
||||
icon(ICON_MORE, 18),
|
||||
Space::with_width(Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let body: Element<'_, Message> = if let Some(session) = self.selected_session(item) {
|
||||
let mut messages = column![].spacing(12);
|
||||
if self.conversation.is_empty() {
|
||||
messages = messages.push(
|
||||
column![
|
||||
text(&session.title).size(26),
|
||||
text("Run DeepSeek locally with the Rust Metal engine.").size(14),
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
} else {
|
||||
let markdown_style = markdown::Style::from_palette(app_theme().palette());
|
||||
for (index, message) in self.conversation.iter().enumerate() {
|
||||
let label = if message.user { "You" } else { "DS4" };
|
||||
let active = self.generating && index + 1 == self.conversation.len();
|
||||
let mut body = column![text(label).size(11)].spacing(5);
|
||||
if let Some(reasoning) = &message.reasoning {
|
||||
let reasoning_label =
|
||||
match (message.reasoning_open, message.reasoning_complete, active) {
|
||||
(true, false, true) => "▾ Thinking",
|
||||
(false, false, true) => "› Thinking",
|
||||
(true, false, false) => "▾ Reasoning (stopped)",
|
||||
(false, false, false) => "› Reasoning (stopped)",
|
||||
(true, true, _) => "▾ Reasoning",
|
||||
(false, true, _) => "› Reasoning",
|
||||
};
|
||||
body = body.push(
|
||||
button(text(reasoning_label).size(12))
|
||||
.padding(0)
|
||||
.style(button::text)
|
||||
.on_press(Message::ToggleReasoning(index)),
|
||||
);
|
||||
if message.reasoning_open {
|
||||
body = body.push(
|
||||
text(if reasoning.is_empty() && active {
|
||||
"Thinking…"
|
||||
} else {
|
||||
reasoning
|
||||
})
|
||||
.size(13)
|
||||
.color(muted_text()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !message.content.is_empty() {
|
||||
if message.user || message.markdown.is_empty() {
|
||||
let content = if message.reasoning.is_some() {
|
||||
message.content.trim_start()
|
||||
} else {
|
||||
&message.content
|
||||
};
|
||||
body = body.push(text(content).size(14));
|
||||
} else {
|
||||
body = body.push(
|
||||
markdown::view(
|
||||
&message.markdown,
|
||||
markdown::Settings::with_text_size(14),
|
||||
markdown_style,
|
||||
)
|
||||
.map(Message::OpenLink),
|
||||
);
|
||||
}
|
||||
} else if active && message.reasoning.is_none() {
|
||||
body = body.push(text("Loading model…").size(14));
|
||||
}
|
||||
let user = message.user;
|
||||
messages = messages.push(
|
||||
container(body)
|
||||
.padding(14)
|
||||
.width(Length::Fill)
|
||||
.style(move |theme| chat_message_style(theme, user)),
|
||||
);
|
||||
}
|
||||
}
|
||||
let composer = text_input("Ask DS4Server anything…", &self.composer)
|
||||
.on_input(Message::ComposerChanged)
|
||||
.on_submit(Message::SubmitPrompt)
|
||||
.padding(12)
|
||||
.size(14);
|
||||
let action = if self.generating {
|
||||
action_button(text("Stop").size(12)).on_press(Message::StopGeneration)
|
||||
} else if self.composer.trim().is_empty() {
|
||||
action_button(icon(ICON_SEND, 18)).padding(8)
|
||||
} else {
|
||||
action_button(icon(ICON_SEND, 18))
|
||||
.padding(8)
|
||||
.on_press(Message::SubmitPrompt)
|
||||
};
|
||||
let context_fraction = if self.context_limit == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.context_used.min(self.context_limit) as f32 / self.context_limit as f32
|
||||
};
|
||||
let conversation = column![
|
||||
scrollable(messages)
|
||||
.id(chat_scroll_id())
|
||||
.height(Length::Fill),
|
||||
container(
|
||||
column![
|
||||
composer,
|
||||
progress_bar(0.0..=1.0, context_fraction).height(3),
|
||||
row![
|
||||
icon(ICON_PAPERCLIP, 19),
|
||||
text(format!(
|
||||
"{} / {} tokens ({:.0}%) • {}",
|
||||
self.context_used,
|
||||
self.context_limit,
|
||||
context_fraction * 100.0,
|
||||
self.tokens_per_second.map_or_else(
|
||||
|| "— tok/s".to_owned(),
|
||||
|speed| format!("{speed:.1} tok/s")
|
||||
)
|
||||
))
|
||||
.size(11)
|
||||
.color(muted_text()),
|
||||
Space::with_width(Length::Fill),
|
||||
icon(ICON_MODEL, 16),
|
||||
text(
|
||||
ModelChoice::from_id(&self.preferences.selected_model)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
.size(12),
|
||||
action,
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(overview_style),
|
||||
]
|
||||
.height(Length::Fill)
|
||||
.spacing(8);
|
||||
container(conversation)
|
||||
.max_width(860)
|
||||
.center_x(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
container(
|
||||
column![
|
||||
text(if item.sessions.is_empty() {
|
||||
"No sessions yet"
|
||||
} else {
|
||||
"Choose a session"
|
||||
})
|
||||
.size(24),
|
||||
text("Create a session above or select one from the sidebar.").size(14),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
container(column![header, body].spacing(24))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(24)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
296
src/app/view/model_manager.rs
Normal file
296
src/app/view/model_manager.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
|
||||
impl App {
|
||||
pub(super) fn model_manager(&self) -> Element<'_, Message> {
|
||||
let busy = matches!(self.model_download, ModelDownload::Active(_));
|
||||
let mut artifacts = column![];
|
||||
for (index, artifact) in model::managed_artifacts(&models_path()).iter().enumerate() {
|
||||
if index > 0 {
|
||||
artifacts = artifacts.push(horizontal_rule(1));
|
||||
}
|
||||
artifacts = artifacts.push(model_artifact_row(artifact, busy));
|
||||
}
|
||||
|
||||
let mut content = column![
|
||||
text("Model Manager").size(26),
|
||||
text("Download, verify, or remove locally stored model files.")
|
||||
.size(14)
|
||||
.color(muted_text()),
|
||||
]
|
||||
.spacing(8);
|
||||
if !matches!(self.model_download, ModelDownload::Idle) {
|
||||
content = content.push(
|
||||
container(model_download_status(&self.model_download))
|
||||
.padding(16)
|
||||
.style(overview_style),
|
||||
);
|
||||
}
|
||||
if let Some(error) = &self.error {
|
||||
content = content.push(text(error).style(iced::widget::text::danger));
|
||||
}
|
||||
content = content.push(Space::with_height(8)).push(
|
||||
container(scrollable(artifacts).height(Length::Fill))
|
||||
.height(Length::Fill)
|
||||
.style(overview_style),
|
||||
);
|
||||
|
||||
let base: Element<'_, Message> = container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(28)
|
||||
.into();
|
||||
let Some(artifact) = self.pending_model_delete else {
|
||||
return base;
|
||||
};
|
||||
let confirmation = container(
|
||||
column![
|
||||
text("Delete model file?").size(22),
|
||||
text(format!(
|
||||
"Delete {artifact}, including any resumable partial download?"
|
||||
))
|
||||
.size(13),
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
action_button("Cancel").on_press(Message::CancelDeleteArtifact),
|
||||
danger_button("Delete").on_press(Message::ConfirmDeleteArtifact),
|
||||
]
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static, Message> {
|
||||
let status = match artifact.state {
|
||||
ManagedArtifactState::Missing => "Not downloaded",
|
||||
ManagedArtifactState::Partial => "Partial download",
|
||||
ManagedArtifactState::NeedsVerification => "Downloaded; verification required",
|
||||
ManagedArtifactState::Ready => "Ready and verified",
|
||||
};
|
||||
let download_label = match artifact.state {
|
||||
ManagedArtifactState::Ready => "Downloaded",
|
||||
_ if artifact.stored > 0 => "Resume",
|
||||
_ => "Download",
|
||||
};
|
||||
let download = if artifact.state == ManagedArtifactState::Ready || busy {
|
||||
action_button(download_label)
|
||||
} else {
|
||||
action_button(download_label).on_press(Message::DownloadArtifact(artifact.id))
|
||||
};
|
||||
let validate = if artifact.can_validate() && !busy {
|
||||
action_button("Validate").on_press(Message::ValidateArtifact(artifact.id))
|
||||
} else {
|
||||
action_button("Validate")
|
||||
};
|
||||
let delete = if artifact.stored > 0 && !busy {
|
||||
danger_button("Delete").on_press(Message::DeleteArtifact(artifact.id))
|
||||
} else {
|
||||
danger_button("Delete")
|
||||
};
|
||||
|
||||
container(
|
||||
row![
|
||||
icon(ICON_MODEL, 28),
|
||||
column![
|
||||
text(artifact.id.to_string()).size(16),
|
||||
text(status).size(13).color(muted_text()),
|
||||
text(format!(
|
||||
"{} on disk • {} expected",
|
||||
format_bytes(artifact.stored),
|
||||
format_bytes(artifact.expected),
|
||||
))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
]
|
||||
.spacing(5),
|
||||
Space::with_width(Length::Fill),
|
||||
row![download, validate, delete]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(16)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([18, 20])
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
|
||||
let progress = &download.progress;
|
||||
let percent = progress.fraction() * 100.0;
|
||||
let measurement = if progress.verification.is_some() {
|
||||
format!(
|
||||
"{} verified of {} ({percent:.1}%)",
|
||||
format_bytes(progress.completed()),
|
||||
format_bytes(progress.active_total()),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} of {} ({percent:.1}%)",
|
||||
format_bytes(progress.completed()),
|
||||
format_bytes(progress.active_total()),
|
||||
)
|
||||
};
|
||||
let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 {
|
||||
format!(
|
||||
"{}/s • about {} remaining",
|
||||
format_bytes(download.bytes_per_second as u64),
|
||||
format_duration(progress.remaining() as f64 / download.bytes_per_second),
|
||||
)
|
||||
} else {
|
||||
"Calculating time remaining…".to_owned()
|
||||
};
|
||||
let stop = if download.stopping {
|
||||
danger_button("Stopping…")
|
||||
} else {
|
||||
danger_button("Stop").on_press(Message::StopModelDownload)
|
||||
};
|
||||
|
||||
container(
|
||||
row![
|
||||
text(phase_text(progress.phase)).size(12),
|
||||
progress_bar(0.0..=1.0, progress.fraction())
|
||||
.width(180)
|
||||
.height(7),
|
||||
text(measurement).size(12),
|
||||
text(transfer).size(12),
|
||||
Space::with_width(Length::Fill),
|
||||
stop,
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([7, 14])
|
||||
.style(sidebar_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn model_download_status(download: &ModelDownload) -> Element<'_, Message> {
|
||||
let (progress, heading, speed, failed) = match download {
|
||||
ModelDownload::Idle => unreachable!("idle operations are not displayed"),
|
||||
ModelDownload::Active(active) => (
|
||||
&active.progress,
|
||||
if active.stopping {
|
||||
format!("Stopping {}…", active.artifact)
|
||||
} else {
|
||||
phase_text(active.progress.phase)
|
||||
},
|
||||
active.bytes_per_second,
|
||||
false,
|
||||
),
|
||||
ModelDownload::Complete(artifact, operation, progress) => (
|
||||
progress,
|
||||
match operation {
|
||||
ModelOperation::Download => format!("{artifact} is downloaded and verified."),
|
||||
ModelOperation::Validate => format!("{artifact} passed validation."),
|
||||
},
|
||||
0.0,
|
||||
false,
|
||||
),
|
||||
ModelDownload::Failed(artifact, error, progress) => (
|
||||
progress,
|
||||
format!("{artifact} operation failed: {error}"),
|
||||
0.0,
|
||||
true,
|
||||
),
|
||||
};
|
||||
|
||||
let percent = progress.fraction() * 100.0;
|
||||
let measurement = if progress.verification.is_some() {
|
||||
format!(
|
||||
"{} verified of {} ({percent:.1}%) • {} remaining",
|
||||
format_bytes(progress.completed()),
|
||||
format_bytes(progress.active_total()),
|
||||
format_bytes(progress.remaining()),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} of {} ({percent:.1}%) • {} remaining",
|
||||
format_bytes(progress.completed()),
|
||||
format_bytes(progress.active_total()),
|
||||
format_bytes(progress.remaining()),
|
||||
)
|
||||
};
|
||||
let heading = if failed {
|
||||
text(heading).size(12).style(iced::widget::text::danger)
|
||||
} else {
|
||||
text(heading).size(12)
|
||||
};
|
||||
let mut heading_row = row![heading, Space::with_width(Length::Fill)]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(8);
|
||||
if let ModelDownload::Active(active) = download {
|
||||
heading_row = heading_row.push(if active.stopping {
|
||||
danger_button("Stopping…")
|
||||
} else {
|
||||
danger_button("Stop").on_press(Message::StopModelDownload)
|
||||
});
|
||||
}
|
||||
let mut status = column![
|
||||
heading_row,
|
||||
progress_bar(0.0..=1.0, progress.fraction()).height(8),
|
||||
text(measurement).size(12),
|
||||
]
|
||||
.spacing(6);
|
||||
if speed > 0.0 && progress.remaining() > 0 {
|
||||
status = status.push(
|
||||
text(format!(
|
||||
"{}/s • about {} remaining",
|
||||
format_bytes(speed as u64),
|
||||
format_duration(progress.remaining() as f64 / speed),
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
status.into()
|
||||
}
|
||||
|
||||
fn phase_text(phase: DownloadPhase) -> String {
|
||||
match phase {
|
||||
DownloadPhase::Pending(artifact) => format!("Ready to download {artifact}."),
|
||||
DownloadPhase::Downloading(artifact) => format!("Downloading {artifact}…"),
|
||||
DownloadPhase::Verifying(artifact) => format!("Verifying {artifact}…"),
|
||||
DownloadPhase::Complete => "Download complete.".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn format_bytes(bytes: u64) -> String {
|
||||
const GB: f64 = 1_000_000_000.0;
|
||||
const MB: f64 = 1_000_000.0;
|
||||
if bytes >= 1_000_000_000 {
|
||||
format!("{:.1} GB", bytes as f64 / GB)
|
||||
} else {
|
||||
format!("{:.1} MB", bytes as f64 / MB)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn format_duration(seconds: f64) -> String {
|
||||
let seconds = seconds.max(0.0).round() as u64;
|
||||
let hours = seconds / 3600;
|
||||
let minutes = seconds % 3600 / 60;
|
||||
if hours > 0 {
|
||||
format!("{hours}h {minutes}m")
|
||||
} else if minutes > 0 {
|
||||
format!("{minutes}m {}s", seconds % 60)
|
||||
} else {
|
||||
format!("{seconds}s")
|
||||
}
|
||||
}
|
||||
440
src/app/view/preferences.rs
Normal file
440
src/app/view/preferences.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
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
|
||||
.model
|
||||
.supports_dspark()
|
||||
.then_some(Message::PreferenceDsparkChanged);
|
||||
let dspark = checkbox(
|
||||
"Enable DSpark for this model",
|
||||
self.preference_draft.dspark_enabled,
|
||||
)
|
||||
.on_toggle_maybe(dspark_toggle);
|
||||
let glm_mtp_toggle: Option<fn(bool) -> Message> = (self.preference_draft.model
|
||||
== ModelChoice::Glm52)
|
||||
.then_some(Message::PreferenceGlmMtpChanged);
|
||||
let glm_mtp_timing_toggle: Option<fn(bool) -> Message> = (self.preference_draft.model
|
||||
== ModelChoice::Glm52)
|
||||
.then_some(Message::PreferenceGlmMtpTimingChanged);
|
||||
let dspark_strict_toggle: Option<fn(bool) -> 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(
|
||||
"MODEL & LIFECYCLE",
|
||||
column![
|
||||
pick_list(
|
||||
&MODEL_CHOICES[..],
|
||||
Some(self.preference_draft.model),
|
||||
Message::PreferenceModelChanged,
|
||||
)
|
||||
.width(Length::Fill),
|
||||
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),
|
||||
text("minutes before unloading the model").size(13),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
text("Enter a whole number from 1 to 1440.").size(12),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
let endpoint_group = preference_group(
|
||||
"LOCAL ENDPOINT",
|
||||
column![
|
||||
preference_input_row(
|
||||
"Port",
|
||||
text_input("4000", &self.preference_draft.endpoint_port)
|
||||
.on_input(Message::PreferenceEndpointPortChanged),
|
||||
),
|
||||
text("Listens on 127.0.0.1. Saving a changed port restarts the local endpoint.")
|
||||
.size(12),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
let generation_group = preference_group(
|
||||
"GENERATION",
|
||||
column![
|
||||
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(4),
|
||||
text("SAMPLING & REASONING").size(11).color(muted_text()),
|
||||
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(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(
|
||||
"EXECUTION",
|
||||
column![
|
||||
preference_input_row(
|
||||
"CPU helper threads",
|
||||
text_input("Automatic", &self.preference_draft.cpu_threads)
|
||||
.on_input(Message::PreferenceCpuThreadsChanged),
|
||||
),
|
||||
preference_input_row("GPU power percent", power),
|
||||
preference_input_row("Prefill chunk", prefill),
|
||||
checkbox("Prefer exact quality kernels", self.preference_draft.quality)
|
||||
.on_toggle(Message::PreferenceQualityChanged),
|
||||
checkbox("Warm mapped weights at load time", self.preference_draft.warm_weights)
|
||||
.on_toggle(Message::PreferenceWarmWeightsChanged),
|
||||
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(
|
||||
"ACCELERATION & MEMORY",
|
||||
column![
|
||||
text("SPECULATIVE DECODING").size(11).color(muted_text()),
|
||||
preference_input_row(
|
||||
"MTP draft tokens",
|
||||
text_input("1", &self.preference_draft.mtp_draft_tokens)
|
||||
.on_input(Message::PreferenceMtpDraftChanged),
|
||||
),
|
||||
preference_input_row(
|
||||
"MTP verifier margin",
|
||||
text_input("3", &self.preference_draft.mtp_margin)
|
||||
.on_input(Message::PreferenceMtpMarginChanged),
|
||||
),
|
||||
checkbox("Enable integrated GLM MTP", self.preference_draft.glm_mtp)
|
||||
.on_toggle_maybe(glm_mtp_toggle),
|
||||
checkbox(
|
||||
"Log GLM MTP timing counters",
|
||||
self.preference_draft.glm_mtp_timing,
|
||||
)
|
||||
.on_toggle_maybe(glm_mtp_timing_toggle),
|
||||
dspark,
|
||||
preference_input_row("DSpark confidence threshold", dspark_confidence),
|
||||
checkbox(
|
||||
"DSpark target-only decode",
|
||||
self.preference_draft.dspark_strict,
|
||||
)
|
||||
.on_toggle_maybe(dspark_strict_toggle),
|
||||
text(if self.preference_draft.model.supports_dspark() {
|
||||
"DSpark uses the managed support artifact; entering a threshold or enabling strict mode also enables 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 {} • GLM MTP {} • timing {} • DSpark {} • confidence {}{} • target-only {}",
|
||||
settings.mtp_draft_tokens,
|
||||
settings.mtp_margin,
|
||||
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::with_height(6),
|
||||
text("SSD STREAMING").size(11).color(muted_text()),
|
||||
checkbox("Enable SSD-backed model streaming", self.preference_draft.ssd_streaming)
|
||||
.on_toggle(Message::PreferenceSsdChanged),
|
||||
checkbox("Skip automatic expert preload", self.preference_draft.ssd_streaming_cold)
|
||||
.on_toggle(Message::PreferenceSsdColdChanged),
|
||||
preference_input_row(
|
||||
"Expert cache count or GiB",
|
||||
text_input("Automatic, 128, or 64GB", &self.preference_draft.ssd_cache)
|
||||
.on_input(Message::PreferenceSsdCacheChanged),
|
||||
),
|
||||
preference_input_row("Fully resident GLM layers", ssd_full_layers),
|
||||
preference_input_row(
|
||||
"Explicit expert preload count",
|
||||
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. SSD streaming and DSpark are mutually exclusive.")
|
||||
.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(
|
||||
"STEERING & DIAGNOSTICS",
|
||||
column![
|
||||
text("DIRECTIONAL STEERING").size(11).color(muted_text()),
|
||||
text("Direction-vector file").size(13),
|
||||
steering_file.padding(9),
|
||||
preference_input_row("FFN scale", steering_ffn),
|
||||
preference_input_row("Attention scale", 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::with_height(6),
|
||||
text("ADVANCED DIAGNOSTICS").size(11).color(muted_text()),
|
||||
preference_input_row(
|
||||
"Simulated used memory (GiB)",
|
||||
text_input("Disabled", &self.preference_draft.simulated_used_memory_gib)
|
||||
.on_input(Message::PreferenceSimulatedMemoryChanged),
|
||||
),
|
||||
text("Routed expert profile output").size(13),
|
||||
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 mut fields = column![
|
||||
model_group,
|
||||
endpoint_group,
|
||||
generation_group,
|
||||
execution_group,
|
||||
acceleration_group,
|
||||
steering_group,
|
||||
]
|
||||
.spacing(12);
|
||||
|
||||
if let Some(error) = &self.preference_error {
|
||||
fields = fields.push(text(error).style(iced::widget::text::danger));
|
||||
}
|
||||
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(
|
||||
column![
|
||||
header,
|
||||
scrollable(container(fields).padding(iced::Padding::ZERO.right(18)))
|
||||
.height(Length::Fill),
|
||||
footer
|
||||
]
|
||||
.spacing(16),
|
||||
)
|
||||
.padding(24)
|
||||
.width(700)
|
||||
.height(Length::Fill)
|
||||
.max_height(660)
|
||||
.style(overview_style);
|
||||
opaque(
|
||||
container(panel)
|
||||
.padding(24)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
371
src/app/view/stats.rs
Normal file
371
src/app/view/stats.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
|
||||
impl App {
|
||||
pub(super) fn stats_dashboard(&self) -> Element<'_, Message> {
|
||||
let stats = &self.metrics_snapshot;
|
||||
let context_fraction = if stats.context_limit == 0 {
|
||||
0.0
|
||||
} else {
|
||||
stats.context_used.min(stats.context_limit) as f32 / stats.context_limit as f32
|
||||
};
|
||||
let cache_fraction = if stats.last_prompt_tokens == 0 {
|
||||
0.0
|
||||
} else {
|
||||
stats.last_cached_tokens as f32 / stats.last_prompt_tokens as f32
|
||||
};
|
||||
let cache_hit_fraction = if stats.kv_lookups == 0 {
|
||||
0.0
|
||||
} else {
|
||||
stats.kv_hits as f32 / stats.kv_lookups as f32
|
||||
};
|
||||
let endpoint = if stats.server_listening {
|
||||
format!("Listening · 127.0.0.1:{}", stats.server_port)
|
||||
} else {
|
||||
"Stopped".to_owned()
|
||||
};
|
||||
let phase_color = match stats.phase {
|
||||
crate::metrics::RuntimePhase::Generating => Color::from_rgb8(84, 170, 255),
|
||||
crate::metrics::RuntimePhase::Prefilling | crate::metrics::RuntimePhase::Loading => {
|
||||
Color::from_rgb8(240, 180, 70)
|
||||
}
|
||||
crate::metrics::RuntimePhase::Ready => Color::from_rgb8(72, 176, 112),
|
||||
crate::metrics::RuntimePhase::Failed => Color::from_rgb8(220, 80, 86),
|
||||
crate::metrics::RuntimePhase::Unloaded => muted_text(),
|
||||
};
|
||||
let heading = container(
|
||||
row![
|
||||
column![
|
||||
text("Runtime observability").size(24),
|
||||
text(format!(
|
||||
"{} · {} · uptime {}",
|
||||
stats.model,
|
||||
stats.source.label(),
|
||||
format_duration(stats.uptime_seconds as f64)
|
||||
))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
]
|
||||
.spacing(5),
|
||||
Space::with_width(Length::Fill),
|
||||
container(text(stats.phase.label()).size(12).color(phase_color))
|
||||
.padding([7, 11])
|
||||
.style(move |_| status_badge_style(phase_color)),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(overview_style);
|
||||
|
||||
let headline = column![
|
||||
row![
|
||||
metric_card(
|
||||
"DECODE",
|
||||
format!("{:.1} tok/s", stats.decode_tokens_per_second),
|
||||
format!(
|
||||
"{} completion tokens total",
|
||||
format_count(stats.completion_tokens)
|
||||
),
|
||||
),
|
||||
metric_card(
|
||||
"PREFILL",
|
||||
format!("{:.1} tok/s", stats.prefill_tokens_per_second),
|
||||
format!("{} prompt tokens total", format_count(stats.prompt_tokens)),
|
||||
),
|
||||
]
|
||||
.spacing(10),
|
||||
row![
|
||||
metric_card(
|
||||
"CONTEXT",
|
||||
format!(
|
||||
"{} / {}",
|
||||
format_count(u64::from(stats.context_used)),
|
||||
format_count(u64::from(stats.context_limit))
|
||||
),
|
||||
format!("{:.0}% occupied", context_fraction * 100.0),
|
||||
),
|
||||
metric_card(
|
||||
"WORK",
|
||||
format!(
|
||||
"{} active · {} queued",
|
||||
stats.http_active, stats.queue_depth
|
||||
),
|
||||
format!("{} runtime requests", format_count(stats.runtime_requests)),
|
||||
),
|
||||
]
|
||||
.spacing(10),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
let throughput = stats_panel(
|
||||
"MODEL ACTIVITY · LAST 24 SECONDS",
|
||||
column![
|
||||
mini_chart(
|
||||
&self.metrics_history,
|
||||
|point| point.decode_tokens_per_second,
|
||||
Color::from_rgb8(84, 170, 255),
|
||||
),
|
||||
row![
|
||||
text("Decode")
|
||||
.size(12)
|
||||
.color(Color::from_rgb8(84, 170, 255)),
|
||||
Space::with_width(Length::Fill),
|
||||
text(format!("{:.1} tok/s", stats.decode_tokens_per_second))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
],
|
||||
mini_chart(
|
||||
&self.metrics_history,
|
||||
|point| point.prefill_tokens_per_second,
|
||||
Color::from_rgb8(157, 119, 255),
|
||||
),
|
||||
row![
|
||||
text("Prefill")
|
||||
.size(12)
|
||||
.color(Color::from_rgb8(157, 119, 255)),
|
||||
Space::with_width(Length::Fill),
|
||||
text(format!("{:.1} tok/s", stats.prefill_tokens_per_second))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
],
|
||||
]
|
||||
.spacing(7)
|
||||
.into(),
|
||||
);
|
||||
let requests = stats_panel(
|
||||
"SERVER REQUEST RATE · LAST 24 SECONDS",
|
||||
column![
|
||||
mini_chart(
|
||||
&self.metrics_history,
|
||||
|point| point.http_requests_per_second,
|
||||
Color::from_rgb8(72, 176, 112),
|
||||
),
|
||||
row![
|
||||
text(format!("{} requests", format_count(stats.http_requests))).size(12),
|
||||
Space::with_width(Length::Fill),
|
||||
text(format!(
|
||||
"{} errors · {} streaming",
|
||||
stats.http_errors, stats.http_streaming_requests
|
||||
))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
],
|
||||
]
|
||||
.spacing(7)
|
||||
.into(),
|
||||
);
|
||||
let latest = self.metrics_history.back().copied().unwrap_or_default();
|
||||
let kv_io = stats_panel(
|
||||
"KV CHECKPOINT I/O · LAST 24 SECONDS",
|
||||
column![
|
||||
mini_chart(
|
||||
&self.metrics_history,
|
||||
|point| point.kv_read_bytes_per_second,
|
||||
Color::from_rgb8(67, 194, 203),
|
||||
),
|
||||
row![
|
||||
text(if stats.kv_read_active {
|
||||
"Disk read · active"
|
||||
} else {
|
||||
"Disk read"
|
||||
})
|
||||
.size(12)
|
||||
.color(Color::from_rgb8(67, 194, 203)),
|
||||
Space::with_width(Length::Fill),
|
||||
text(format_rate(latest.kv_read_bytes_per_second))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
],
|
||||
mini_chart(
|
||||
&self.metrics_history,
|
||||
|point| point.kv_write_bytes_per_second,
|
||||
Color::from_rgb8(240, 180, 70),
|
||||
),
|
||||
row![
|
||||
text(if stats.kv_write_active {
|
||||
"Disk write · active"
|
||||
} else {
|
||||
"Disk write"
|
||||
})
|
||||
.size(12)
|
||||
.color(Color::from_rgb8(240, 180, 70)),
|
||||
Space::with_width(Length::Fill),
|
||||
text(format_rate(latest.kv_write_bytes_per_second))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
],
|
||||
]
|
||||
.spacing(7)
|
||||
.into(),
|
||||
);
|
||||
|
||||
let model = stats_panel(
|
||||
"MODEL CORE",
|
||||
column![
|
||||
metric_row("State", stats.phase.label()),
|
||||
metric_row("Loaded model", stats.model),
|
||||
metric_row("Mapped weights", format_bytes(stats.model_bytes)),
|
||||
metric_row("Tensors", format_count(stats.tensor_count)),
|
||||
metric_row("Vocabulary", format_count(stats.vocabulary_size)),
|
||||
metric_row("Last load", format_milliseconds(stats.model_load_ms)),
|
||||
metric_row(
|
||||
"Lifecycle",
|
||||
format!(
|
||||
"{} loads · {} unloads",
|
||||
stats.model_loads, stats.model_unloads
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(9)
|
||||
.into(),
|
||||
);
|
||||
let runtime = stats_panel(
|
||||
"GENERATION",
|
||||
column![
|
||||
metric_row("Last runtime", format_milliseconds(stats.last_runtime_ms)),
|
||||
metric_row(
|
||||
"Average runtime",
|
||||
format_milliseconds(stats.average_runtime_ms)
|
||||
),
|
||||
metric_row("Last prompt", format_count(stats.last_prompt_tokens)),
|
||||
metric_row("Last reused", format_count(stats.last_cached_tokens)),
|
||||
metric_row(
|
||||
"Last completion",
|
||||
format_count(stats.last_completion_tokens)
|
||||
),
|
||||
metric_row("Cached tokens total", format_count(stats.cached_tokens)),
|
||||
metric_row(
|
||||
"Cache reuse",
|
||||
format!("{:.0}% of last prompt", cache_fraction * 100.0),
|
||||
),
|
||||
metric_row(
|
||||
"Results",
|
||||
format!(
|
||||
"{} completed · {} failed",
|
||||
stats.completed_requests, stats.failed_requests
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(9)
|
||||
.into(),
|
||||
);
|
||||
let cache = stats_panel(
|
||||
"KV CACHE",
|
||||
column![
|
||||
metric_row(
|
||||
"Total",
|
||||
format!(
|
||||
"{} · {} files",
|
||||
format_bytes(stats.kv_bytes),
|
||||
stats.kv_files
|
||||
)
|
||||
),
|
||||
metric_row(
|
||||
"Local sessions",
|
||||
format!(
|
||||
"{} · {} files",
|
||||
format_bytes(stats.local_kv_bytes),
|
||||
stats.local_kv_files
|
||||
),
|
||||
),
|
||||
metric_row(
|
||||
"HTTP transient",
|
||||
format!(
|
||||
"{} · {} files",
|
||||
format_bytes(stats.http_kv_bytes),
|
||||
stats.http_kv_files
|
||||
),
|
||||
),
|
||||
metric_row("Checkpoint writes", format_count(stats.checkpoint_writes)),
|
||||
metric_row(
|
||||
"Exact hits",
|
||||
format!(
|
||||
"{} · {} memory / {} disk",
|
||||
stats.kv_hits, stats.kv_memory_hits, stats.kv_disk_hits
|
||||
),
|
||||
),
|
||||
metric_row(
|
||||
"Misses",
|
||||
format!("{} · {} invalid", stats.kv_misses, stats.kv_invalid),
|
||||
),
|
||||
metric_row("Lookups", format_count(stats.kv_lookups)),
|
||||
metric_row(
|
||||
"Exact hit rate",
|
||||
format!("{:.1}%", cache_hit_fraction * 100.0)
|
||||
),
|
||||
metric_row("Prefix hits", format_count(stats.kv_prefix_hits)),
|
||||
metric_row(
|
||||
"Reads",
|
||||
format!(
|
||||
"{} · {} · {} errors · last {}",
|
||||
stats.kv_read_operations,
|
||||
format_bytes(stats.kv_read_bytes),
|
||||
stats.kv_read_errors,
|
||||
format_milliseconds(stats.last_kv_read_ms),
|
||||
),
|
||||
),
|
||||
metric_row(
|
||||
"Writes",
|
||||
format!(
|
||||
"{} · {} · {} errors · last {}",
|
||||
stats.kv_write_operations,
|
||||
format_bytes(stats.kv_write_bytes),
|
||||
stats.kv_write_errors,
|
||||
format_milliseconds(stats.last_kv_write_ms),
|
||||
),
|
||||
),
|
||||
progress_bar(0.0..=1.0, cache_fraction.min(1.0)).height(4),
|
||||
]
|
||||
.spacing(9)
|
||||
.into(),
|
||||
);
|
||||
let server = stats_panel(
|
||||
"LOCAL SERVER",
|
||||
column![
|
||||
metric_row("Endpoint", endpoint),
|
||||
metric_row("Active", format_count(u64::from(stats.http_active))),
|
||||
metric_row("Completed", format_count(stats.http_completed)),
|
||||
metric_row("Chat completions", format_count(stats.http_chat_requests)),
|
||||
metric_row("Model queries", format_count(stats.http_model_requests)),
|
||||
metric_row(
|
||||
"Runtime sources",
|
||||
format!(
|
||||
"{} local · {} HTTP",
|
||||
stats.local_requests, stats.endpoint_generations
|
||||
),
|
||||
),
|
||||
metric_row("Received", format_bytes(stats.http_bytes_received)),
|
||||
metric_row("Last latency", format_milliseconds(stats.last_http_ms)),
|
||||
metric_row(
|
||||
"Average latency",
|
||||
format_milliseconds(stats.average_http_ms)
|
||||
),
|
||||
]
|
||||
.spacing(9)
|
||||
.into(),
|
||||
);
|
||||
|
||||
scrollable(
|
||||
container(
|
||||
column![
|
||||
heading,
|
||||
headline,
|
||||
throughput,
|
||||
kv_io,
|
||||
requests,
|
||||
row![model, runtime].spacing(10),
|
||||
row![cache, server].spacing(10),
|
||||
text("Counters are published by the runtime with relaxed atomics and sampled by the UI every 200 ms.")
|
||||
.size(11)
|
||||
.color(muted_text()),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(24)
|
||||
.max_width(960)
|
||||
.center_x(Length::Fill),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user