diff --git a/docs/app/backend-commands.md b/docs/app/backend-commands.md index 44bef2d..5379079 100644 --- a/docs/app/backend-commands.md +++ b/docs/app/backend-commands.md @@ -62,14 +62,6 @@ Copia um arquivo `.gguf` para um diretorio de modelos. ## Configuracao e paths -### `save_config` - -Compatibilidade legada para salvar `RuntimeConfig` em arquivo. - -### `load_config` - -Leitura da configuracao persistida. - ### `find_server_binary` Resolve o caminho do binario `localllm` para diagnostico e start local. @@ -78,15 +70,7 @@ Resolve o caminho do binario `localllm` para diagnostico e start local. Retorna o diretorio de dados do app. -## Comandos legados de compatibilidade - -O backend ainda expoe: - -- `start_server` -- `stop_server` -- `server_status` - -Esses comandos existem como shims de compatibilidade. O fluxo principal do frontend atual deve preferir o runtime manager unificado (`bootstrap_runtime_command`, `get_runtime_snapshot_command`, `save_runtime_config_command_handler`, `start_runtime_command`, `stop_runtime_command`). +O backend desktop agora expoe apenas a superficie unificada do runtime manager para configuracao e ciclo de vida. ## Tipos principais diff --git a/tauri-app/src-tauri/src/gpu.rs b/tauri-app/src-tauri/src/gpu.rs new file mode 100644 index 0000000..0414967 --- /dev/null +++ b/tauri-app/src-tauri/src/gpu.rs @@ -0,0 +1,69 @@ +use std::process::Command; + +#[derive(serde::Serialize, Clone, Debug, PartialEq, Eq)] +pub struct GpuInfo { + pub name: String, + pub vram_mib: u32, + pub recommended_layers: i32, +} + +fn parse_gpu_info(stdout: &str) -> Result { + let line = stdout.lines().next().unwrap_or("").trim(); + if line.is_empty() { + return Err("No GPU found".into()); + } + + let parts: Vec<&str> = line.splitn(2, ',').collect(); + if parts.len() < 2 { + return Err(format!("Unexpected nvidia-smi output: {line}")); + } + + let name = parts[0].trim().to_string(); + let vram_mib: u32 = parts[1].trim().parse().unwrap_or(0); + let usable = vram_mib.saturating_sub(1024); + let recommended_layers = (usable / 80).min(200) as i32; + + Ok(GpuInfo { + name, + vram_mib, + recommended_layers, + }) +} + +#[tauri::command] +pub fn query_gpu() -> Result { + let output = Command::new("nvidia-smi") + .args([ + "--query-gpu=name,memory.total", + "--format=csv,noheader,nounits", + ]) + .output() + .map_err(|_| "nvidia-smi not found — no NVIDIA GPU detected".to_string())?; + + if !output.status.success() { + return Err("nvidia-smi failed — no NVIDIA GPU detected".into()); + } + + parse_gpu_info(&String::from_utf8_lossy(&output.stdout)) +} + +#[cfg(test)] +mod tests { + use super::parse_gpu_info; + + #[test] + fn parse_gpu_info_accepts_valid_csv_line() { + let info = parse_gpu_info("RTX 4090,24564\n").expect("parse gpu info"); + + assert_eq!(info.name, "RTX 4090"); + assert_eq!(info.vram_mib, 24564); + assert_eq!(info.recommended_layers, 200); + } + + #[test] + fn parse_gpu_info_rejects_malformed_output() { + let error = parse_gpu_info("RTX 4090").expect_err("reject malformed output"); + + assert!(error.contains("Unexpected nvidia-smi output")); + } +} diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 7d9abaf..669531a 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -1,53 +1,14 @@ -use std::path::Path; -use std::process::Command; use tauri::{AppHandle, Manager, State}; -use tauri_plugin_dialog::DialogExt; +mod gpu; +mod model_files; mod runtime; -use runtime::config::{load_runtime_config, save_runtime_config}; use runtime::process::find_localllm_binary; use runtime::types::{RuntimeConfig, RuntimeSnapshot}; use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState}; - -// ─── GPU ────────────────────────────────────────────────────────────────────── - -#[derive(serde::Serialize)] -pub struct GpuInfo { - pub name: String, - pub vram_mib: u32, - pub recommended_layers: i32, -} - -#[tauri::command] -fn query_gpu() -> Result { - let output = Command::new("nvidia-smi") - .args(["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"]) - .output() - .map_err(|_| "nvidia-smi not found — no NVIDIA GPU detected".to_string())?; - - if !output.status.success() { - return Err("nvidia-smi failed — no NVIDIA GPU detected".into()); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let line = stdout.lines().next().unwrap_or("").trim().to_string(); - if line.is_empty() { - return Err("No GPU found".into()); - } - - let parts: Vec<&str> = line.splitn(2, ',').collect(); - if parts.len() < 2 { - return Err(format!("Unexpected nvidia-smi output: {line}")); - } - - let name = parts[0].trim().to_string(); - let vram_mib: u32 = parts[1].trim().parse().unwrap_or(0); - let usable = vram_mib.saturating_sub(1024); - let recommended_layers = (usable / 80).min(200) as i32; - - Ok(GpuInfo { name, vram_mib, recommended_layers }) -} +use gpu::query_gpu; +use model_files::{import_model, pick_model_files, pick_models_dir, scan_models}; // ─── Server lifecycle ───────────────────────────────────────────────────────── @@ -86,189 +47,6 @@ fn stop_runtime_command(state: State) -> Result, - model_path: String, - addr: String, - gpu_layers: i32, - ctx_size: u32, -) -> Result { - let mut config = load_runtime_config(&app).unwrap_or_default(); - config.connection_mode = runtime::types::ConnectionMode::Local; - config.model_path = Some(model_path); - config.addr = addr; - config.gpu_layers = gpu_layers; - config.ctx_size = ctx_size; - save_runtime_config(&app, &config)?; - let snapshot = start_runtime(app, state)?; - Ok(format!("Local API {}", snapshot.resolved_url)) -} - -#[tauri::command] -fn stop_server(state: State) -> Result { - let snapshot = stop_runtime(state)?; - Ok(format!("Local API stopped ({})", snapshot.resolved_url)) -} - -#[tauri::command] -fn server_status(state: State) -> String { - match get_runtime_snapshot(state) { - Ok(snapshot) if snapshot.process_running => "running".into(), - _ => "stopped".into(), - } -} - -// ─── Model files ────────────────────────────────────────────────────────────── - -#[derive(serde::Serialize, serde::Deserialize, Clone)] -pub struct ModelFile { - pub name: String, - pub path: String, -} - -/// Scan a directory for .gguf model files. -#[tauri::command] -fn scan_models(dir: String) -> Result, String> { - let path = Path::new(&dir); - if !path.exists() { - return Err(format!("Directory not found: {dir}")); - } - if !path.is_dir() { - return Err(format!("Not a directory: {dir}")); - } - - let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?; - let mut models = Vec::new(); - - for entry in entries.flatten() { - let p = entry.path(); - if p.is_file() { - if let Some(ext) = p.extension() { - if ext.eq_ignore_ascii_case("gguf") { - let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); - let full_path = p.to_string_lossy().to_string(); - models.push(ModelFile { name, path: full_path }); - } - } - } - } - - models.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(models) -} - -/// Open a native folder picker and scan the chosen directory for .gguf files. -/// Returns (chosen_dir, models[]). -#[derive(serde::Serialize)] -pub struct PickDirResult { - pub dir: String, - pub models: Vec, -} - -#[tauri::command] -async fn pick_models_dir(app: AppHandle) -> Result { - let folder = app - .dialog() - .file() - .set_title("Select models folder") - .blocking_pick_folder(); - - match folder { - Some(path) => { - let dir = path.to_string(); - let models = scan_models(dir.clone())?; - Ok(PickDirResult { dir, models }) - } - None => Err("cancelled".into()), - } -} - -/// Open a native multi-file picker filtered to .gguf files. -/// Returns the selected ModelFile list (files in their original locations — no copy). -#[tauri::command] -async fn pick_model_files(app: AppHandle) -> Result, String> { - let files = app - .dialog() - .file() - .set_title("Select model files") - .add_filter("GGUF models", &["gguf"]) - .blocking_pick_files(); - - match files { - Some(paths) => { - let models: Vec = paths - .iter() - .map(|p| { - let path_str = p.to_string(); - let name = Path::new(&path_str) - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - ModelFile { name, path: path_str } - }) - .collect(); - Ok(models) - } - None => Err("cancelled".into()), - } -} - -/// Copy a .gguf file into the models directory. -#[tauri::command] -fn import_model(src_path: String, dest_dir: String) -> Result { - let src = Path::new(&src_path); - if !src.exists() { - return Err(format!("Source file not found: {src_path}")); - } - if !src.is_file() { - return Err("Source is not a file".into()); - } - let ext = src.extension().unwrap_or_default(); - if !ext.eq_ignore_ascii_case("gguf") { - return Err("Only .gguf files are supported".into()); - } - - let dest_dir_path = Path::new(&dest_dir); - if !dest_dir_path.exists() { - std::fs::create_dir_all(dest_dir_path) - .map_err(|e| format!("Failed to create models directory: {e}"))?; - } - - let file_name = src.file_name().unwrap_or_default(); - let dest_path = dest_dir_path.join(file_name); - - if src == dest_path { - return Ok(ModelFile { - name: file_name.to_string_lossy().to_string(), - path: dest_path.to_string_lossy().to_string(), - }); - } - - std::fs::copy(src, &dest_path).map_err(|e| format!("Failed to copy model: {e}"))?; - - Ok(ModelFile { - name: file_name.to_string_lossy().to_string(), - path: dest_path.to_string_lossy().to_string(), - }) -} - -// ─── Persistent config ──────────────────────────────────────────────────────── - -/// All user-configurable settings, persisted to app data dir as config.json. -#[tauri::command] -fn save_config(app: AppHandle, config: RuntimeConfig) -> Result<(), String> { - save_runtime_config(&app, &config)?; - Ok(()) -} - -#[tauri::command] -fn load_config(app: AppHandle) -> Result { - load_runtime_config(&app) -} - /// Returns the resolved path to the localllm binary (useful for debugging). #[tauri::command] fn find_server_binary() -> Result { @@ -308,11 +86,6 @@ pub fn run() { save_runtime_config_command_handler, start_runtime_command, stop_runtime_command, - start_server, - stop_server, - server_status, - save_config, - load_config, get_app_data_dir, find_server_binary, ]) diff --git a/tauri-app/src-tauri/src/model_files.rs b/tauri-app/src-tauri/src/model_files.rs new file mode 100644 index 0000000..ea0ef46 --- /dev/null +++ b/tauri-app/src-tauri/src/model_files.rs @@ -0,0 +1,188 @@ +use std::path::Path; + +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ModelFile { + pub name: String, + pub path: String, +} + +#[derive(serde::Serialize)] +pub struct PickDirResult { + pub dir: String, + pub models: Vec, +} + +fn model_file_from_path(path: &Path) -> ModelFile { + ModelFile { + name: path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + path: path.to_string_lossy().to_string(), + } +} + +fn scan_model_dir(path: &Path) -> Result, String> { + if !path.exists() { + return Err(format!("Directory not found: {}", path.display())); + } + if !path.is_dir() { + return Err(format!("Not a directory: {}", path.display())); + } + + let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?; + let mut models = Vec::new(); + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && path + .extension() + .map(|ext| ext.eq_ignore_ascii_case("gguf")) + .unwrap_or(false) + { + models.push(model_file_from_path(&path)); + } + } + + models.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(models) +} + +fn import_model_file(src: &Path, dest_dir: &Path) -> Result { + if !src.exists() { + return Err(format!("Source file not found: {}", src.display())); + } + if !src.is_file() { + return Err("Source is not a file".into()); + } + if !src + .extension() + .unwrap_or_default() + .eq_ignore_ascii_case("gguf") + { + return Err("Only .gguf files are supported".into()); + } + + if !dest_dir.exists() { + std::fs::create_dir_all(dest_dir) + .map_err(|e| format!("Failed to create models directory: {e}"))?; + } + + let file_name = src.file_name().unwrap_or_default(); + let dest_path = dest_dir.join(file_name); + + if src == dest_path { + return Ok(model_file_from_path(&dest_path)); + } + + std::fs::copy(src, &dest_path).map_err(|e| format!("Failed to copy model: {e}"))?; + Ok(model_file_from_path(&dest_path)) +} + +#[tauri::command] +pub fn scan_models(dir: String) -> Result, String> { + scan_model_dir(Path::new(&dir)) +} + +#[tauri::command] +pub async fn pick_models_dir(app: AppHandle) -> Result { + let folder = app + .dialog() + .file() + .set_title("Select models folder") + .blocking_pick_folder(); + + match folder { + Some(path) => { + let dir = path.to_string(); + let models = scan_model_dir(Path::new(&dir))?; + Ok(PickDirResult { dir, models }) + } + None => Err("cancelled".into()), + } +} + +#[tauri::command] +pub async fn pick_model_files(app: AppHandle) -> Result, String> { + let files = app + .dialog() + .file() + .set_title("Select model files") + .add_filter("GGUF models", &["gguf"]) + .blocking_pick_files(); + + match files { + Some(paths) => Ok(paths.iter().map(|path| model_file_from_path(Path::new(&path.to_string()))).collect()), + None => Err("cancelled".into()), + } +} + +#[tauri::command] +pub fn import_model(src_path: String, dest_dir: String) -> Result { + import_model_file(Path::new(&src_path), Path::new(&dest_dir)) +} + +#[cfg(test)] +mod tests { + use super::{import_model_file, scan_model_dir}; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + std::env::temp_dir().join(format!("forgelm-{name}-{unique}")) + } + + #[test] + fn scan_model_dir_filters_and_sorts_gguf_files() { + let dir = temp_path("scan-models"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + std::fs::write(dir.join("zeta.gguf"), b"demo").expect("write zeta"); + std::fs::write(dir.join("alpha.gguf"), b"demo").expect("write alpha"); + std::fs::write(dir.join("notes.txt"), b"ignore").expect("write non-model file"); + + let models = scan_model_dir(&dir).expect("scan model dir"); + let names: Vec<_> = models.into_iter().map(|model| model.name).collect(); + + assert_eq!(names, vec!["alpha.gguf", "zeta.gguf"]); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scan_model_dir_ignores_directories_named_like_models() { + let dir = temp_path("scan-model-dirs"); + std::fs::create_dir_all(dir.join("folder.gguf")).expect("create fake model dir"); + std::fs::write(dir.join("real.gguf"), b"demo").expect("write model file"); + + let models = scan_model_dir(&dir).expect("scan model dir"); + let names: Vec<_> = models.into_iter().map(|model| model.name).collect(); + + assert_eq!(names, vec!["real.gguf"]); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn import_model_file_returns_existing_model_when_source_matches_destination() { + let dir = temp_path("import-model"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let model_path = dir.join("demo.gguf"); + std::fs::write(&model_path, b"demo").expect("write demo model"); + + let model = import_model_file(&model_path, &dir).expect("import model file"); + + assert_eq!(model.name, "demo.gguf"); + assert_eq!(PathBuf::from(model.path), model_path); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/tauri-app/src-tauri/src/runtime/mod.rs b/tauri-app/src-tauri/src/runtime/mod.rs index bcd06c5..ce0693a 100644 --- a/tauri-app/src-tauri/src/runtime/mod.rs +++ b/tauri-app/src-tauri/src/runtime/mod.rs @@ -1,129 +1,18 @@ pub mod config; pub mod health; pub mod process; +pub mod state; pub mod types; -use std::process::Child; -use std::sync::Mutex; use std::time::Duration; use tauri::{AppHandle, State}; use self::config::{load_runtime_config, save_runtime_config}; -use self::health::{check_health, wait_for_health}; -use self::process::{find_localllm_binary, spawn_localllm}; -use self::types::{ - ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeSnapshot, RuntimeStatus, StartPolicy, -}; - -pub struct RuntimeInner { - pub child: Option, - pub status: RuntimeStatus, - pub last_error: Option, - pub config: RuntimeConfig, -} - -pub struct RuntimeState(pub Mutex); - -impl RuntimeState { - pub fn new() -> Self { - Self(Mutex::new(RuntimeInner { - child: None, - status: RuntimeStatus::Stopped, - last_error: None, - config: RuntimeConfig::default(), - })) - } -} - -fn runtime_error(code: &str, message: impl Into) -> RuntimeErrorInfo { - RuntimeErrorInfo { - code: code.to_string(), - message: message.into(), - } -} - -fn resolve_runtime_url(config: &RuntimeConfig) -> String { - match config.connection_mode { - ConnectionMode::Local => format!("http://{}", config.addr.trim()), - ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(), - } -} - -fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool { - current.connection_mode != next.connection_mode - || current.model_path != next.model_path - || current.addr != next.addr - || current.gpu_layers != next.gpu_layers - || current.ctx_size != next.ctx_size -} - -fn refresh_process_state(inner: &mut RuntimeInner) { - let Some(child) = inner.child.as_mut() else { - if matches!( - inner.status, - RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded - ) { - inner.status = RuntimeStatus::Stopped; - } - return; - }; - - match child.try_wait() { - Ok(Some(status)) => { - inner.child = None; - inner.status = RuntimeStatus::Error; - inner.last_error = Some(runtime_error( - "unexpected_exit", - format!("Local API exited unexpectedly with status {status}"), - )); - } - Ok(None) => { - if inner.status == RuntimeStatus::Stopped { - inner.status = RuntimeStatus::Ready; - } - - if inner.config.connection_mode == ConnectionMode::Local - && matches!(inner.status, RuntimeStatus::Ready | RuntimeStatus::Degraded) - { - if check_health(&inner.config.addr, Duration::from_millis(400)) { - inner.status = RuntimeStatus::Ready; - if inner.last_error.as_ref().map(|error| error.code.as_str()) - == Some("health_check_failed") - { - inner.last_error = None; - } - } else { - inner.status = RuntimeStatus::Degraded; - inner.last_error = Some(runtime_error( - "health_check_failed", - format!( - "Local API process is still running, but /health is no longer ready on {}.", - inner.config.addr - ), - )); - } - } - } - Err(error) => { - inner.child = None; - inner.status = RuntimeStatus::Error; - inner.last_error = Some(runtime_error("process_probe_failed", error.to_string())); - } - } -} - -fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot { - RuntimeSnapshot { - status: inner.status, - ready: inner.status == RuntimeStatus::Ready, - process_running: inner.child.is_some(), - resolved_url: resolve_runtime_url(&inner.config), - binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()), - last_error: inner.last_error.clone(), - config: inner.config.clone(), - } -} +use self::health::wait_for_health; +use self::process::spawn_localllm; +use self::state::{build_snapshot, refresh_process_state, requires_runtime_restart, runtime_error}; +use self::types::{ConnectionMode, RuntimeConfig, RuntimeSnapshot, RuntimeStatus, StartPolicy}; pub fn get_runtime_snapshot(state: State<'_, RuntimeState>) -> Result { let mut inner = state.0.lock().map_err(|e| e.to_string())?; @@ -275,115 +164,4 @@ pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result String { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind healthy port"); - let addr = listener - .local_addr() - .expect("read healthy addr") - .to_string(); - - std::thread::spawn(move || { - if let Ok((mut stream, _)) = listener.accept() { - use std::io::{Read, Write}; - - let mut buffer = [0_u8; 1024]; - let _ = stream.read(&mut buffer); - let _ = stream.write_all( - b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}", - ); - let _ = stream.flush(); - } - }); - - addr - } - - fn unused_local_addr() -> String { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind unused port"); - let addr = listener.local_addr().expect("read local addr").to_string(); - drop(listener); - addr - } - - fn spawn_sleeping_child() -> Child { - std::process::Command::new("python") - .args(["-c", "import time; time.sleep(60)"]) - .spawn() - .expect("spawn sleeping child") - } - - #[test] - fn runtime_revalidation_marks_unhealthy_local_process_as_degraded() { - let mut child = spawn_sleeping_child(); - let addr = unused_local_addr(); - - let (status, error_code) = { - let mut inner = RuntimeInner { - child: Some(child), - status: RuntimeStatus::Ready, - last_error: None, - config: RuntimeConfig { - model_path: Some("D:/models/demo.gguf".to_string()), - addr, - ..RuntimeConfig::default() - }, - }; - - refresh_process_state(&mut inner); - - child = inner.child.take().expect("process remains managed"); - - ( - inner.status, - inner.last_error.as_ref().map(|error| error.code.clone()), - ) - }; - - let _ = child.kill(); - let _ = child.wait(); - - assert_eq!(status, RuntimeStatus::Degraded); - assert_eq!(error_code.as_deref(), Some("health_check_failed")); - } - - #[test] - fn runtime_revalidation_restores_local_process_to_ready_after_health_recovers() { - let mut child = spawn_sleeping_child(); - let addr = spawn_healthy_server(); - - let (status, error_code) = { - let mut inner = RuntimeInner { - child: Some(child), - status: RuntimeStatus::Degraded, - last_error: Some(runtime_error( - "health_check_failed", - "Local API process is still running, but /health is no longer ready.", - )), - config: RuntimeConfig { - model_path: Some("D:/models/demo.gguf".to_string()), - addr, - ..RuntimeConfig::default() - }, - }; - - refresh_process_state(&mut inner); - - child = inner.child.take().expect("process remains managed"); - - ( - inner.status, - inner.last_error.as_ref().map(|error| error.code.clone()), - ) - }; - - let _ = child.kill(); - let _ = child.wait(); - - assert_eq!(status, RuntimeStatus::Ready); - assert_eq!(error_code, None); - } -} +pub use state::RuntimeState; diff --git a/tauri-app/src-tauri/src/runtime/state.rs b/tauri-app/src-tauri/src/runtime/state.rs new file mode 100644 index 0000000..d4f5b11 --- /dev/null +++ b/tauri-app/src-tauri/src/runtime/state.rs @@ -0,0 +1,231 @@ +use std::process::Child; +use std::sync::Mutex; +use std::time::Duration; + +use super::health::check_health; +use super::process::find_localllm_binary; +use super::types::{ + ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeSnapshot, RuntimeStatus, +}; + +pub struct RuntimeInner { + pub child: Option, + pub status: RuntimeStatus, + pub last_error: Option, + pub config: RuntimeConfig, +} + +pub struct RuntimeState(pub Mutex); + +impl RuntimeState { + pub fn new() -> Self { + Self(Mutex::new(RuntimeInner { + child: None, + status: RuntimeStatus::Stopped, + last_error: None, + config: RuntimeConfig::default(), + })) + } +} + +pub(crate) fn runtime_error(code: &str, message: impl Into) -> RuntimeErrorInfo { + RuntimeErrorInfo { + code: code.to_string(), + message: message.into(), + } +} + +fn resolve_runtime_url(config: &RuntimeConfig) -> String { + match config.connection_mode { + ConnectionMode::Local => format!("http://{}", config.addr.trim()), + ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(), + } +} + +pub(crate) fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool { + current.connection_mode != next.connection_mode + || current.model_path != next.model_path + || current.addr != next.addr + || current.gpu_layers != next.gpu_layers + || current.ctx_size != next.ctx_size +} + +pub(crate) fn refresh_process_state(inner: &mut RuntimeInner) { + let Some(child) = inner.child.as_mut() else { + if matches!( + inner.status, + RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded + ) { + inner.status = RuntimeStatus::Stopped; + } + return; + }; + + match child.try_wait() { + Ok(Some(status)) => { + inner.child = None; + inner.status = RuntimeStatus::Error; + inner.last_error = Some(runtime_error( + "unexpected_exit", + format!("Local API exited unexpectedly with status {status}"), + )); + } + Ok(None) => { + if inner.status == RuntimeStatus::Stopped { + inner.status = RuntimeStatus::Ready; + } + + if inner.config.connection_mode == ConnectionMode::Local + && matches!(inner.status, RuntimeStatus::Ready | RuntimeStatus::Degraded) + { + if check_health(&inner.config.addr, Duration::from_millis(400)) { + inner.status = RuntimeStatus::Ready; + if inner.last_error.as_ref().map(|error| error.code.as_str()) + == Some("health_check_failed") + { + inner.last_error = None; + } + } else { + inner.status = RuntimeStatus::Degraded; + inner.last_error = Some(runtime_error( + "health_check_failed", + format!( + "Local API process is still running, but /health is no longer ready on {}.", + inner.config.addr + ), + )); + } + } + } + Err(error) => { + inner.child = None; + inner.status = RuntimeStatus::Error; + inner.last_error = Some(runtime_error("process_probe_failed", error.to_string())); + } + } +} + +pub(crate) fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot { + RuntimeSnapshot { + status: inner.status, + ready: inner.status == RuntimeStatus::Ready, + process_running: inner.child.is_some(), + resolved_url: resolve_runtime_url(&inner.config), + binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()), + last_error: inner.last_error.clone(), + config: inner.config.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spawn_healthy_server() -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind healthy port"); + let addr = listener + .local_addr() + .expect("read healthy addr") + .to_string(); + + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + use std::io::{Read, Write}; + + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}", + ); + let _ = stream.flush(); + } + }); + + addr + } + + fn unused_local_addr() -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind unused port"); + let addr = listener.local_addr().expect("read local addr").to_string(); + drop(listener); + addr + } + + fn spawn_sleeping_child() -> Child { + std::process::Command::new("python") + .args(["-c", "import time; time.sleep(60)"]) + .spawn() + .expect("spawn sleeping child") + } + + #[test] + fn runtime_revalidation_marks_unhealthy_local_process_as_degraded() { + let mut child = spawn_sleeping_child(); + let addr = unused_local_addr(); + + let (status, error_code) = { + let mut inner = RuntimeInner { + child: Some(child), + status: RuntimeStatus::Ready, + last_error: None, + config: RuntimeConfig { + model_path: Some("D:/models/demo.gguf".to_string()), + addr, + ..RuntimeConfig::default() + }, + }; + + refresh_process_state(&mut inner); + + child = inner.child.take().expect("process remains managed"); + + let error_code: Option = + inner.last_error.as_ref().map(|error| error.code.clone()); + + (inner.status, error_code) + }; + + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!(status, RuntimeStatus::Degraded); + assert_eq!(error_code.as_deref(), Some("health_check_failed")); + } + + #[test] + fn runtime_revalidation_restores_local_process_to_ready_after_health_recovers() { + let mut child = spawn_sleeping_child(); + let addr = spawn_healthy_server(); + + let (status, error_code) = { + let mut inner = RuntimeInner { + child: Some(child), + status: RuntimeStatus::Degraded, + last_error: Some(runtime_error( + "health_check_failed", + "Local API process is still running, but /health is no longer ready.", + )), + config: RuntimeConfig { + model_path: Some("D:/models/demo.gguf".to_string()), + addr, + ..RuntimeConfig::default() + }, + }; + + refresh_process_state(&mut inner); + + child = inner.child.take().expect("process remains managed"); + + let error_code: Option = + inner.last_error.as_ref().map(|error| error.code.clone()); + + (inner.status, error_code) + }; + + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!(status, RuntimeStatus::Ready); + assert_eq!(error_code, None); + } +} diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 02fc920..45f9678 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -4,6 +4,7 @@ import ChatView from "./components/ChatView"; import { useDB } from "./hooks/useDB"; import { useModels } from "./hooks/useModels"; import { useRuntimeManager } from "./features/runtime/useRuntimeManager"; +import { useRuntimeReadyModelRefresh } from "./features/runtime/useRuntimeReadyModelRefresh"; import type { Chat } from "./lib/types"; import "./App.css"; @@ -19,12 +20,7 @@ export default function App() { Boolean(runtime.connection.serverUrl), ); - // Auto-refresh models when server starts - useEffect(() => { - if (runtime.snapshot.status === "ready") { - setTimeout(refreshModels, 2000); - } - }, [runtime.snapshot.status, refreshModels]); + useRuntimeReadyModelRefresh(runtime.snapshot.status, refreshModels); const loadChats = useCallback(() => { if (!db) return; diff --git a/tauri-app/src/components/ServerPanel.css b/tauri-app/src/components/ServerPanel.css deleted file mode 100644 index 6fab253..0000000 --- a/tauri-app/src/components/ServerPanel.css +++ /dev/null @@ -1,580 +0,0 @@ -.server-panel { - margin: 8px 10px; - border-radius: var(--radius); - background: var(--bg3); - border: 1px solid var(--border); - overflow: hidden; - transition: box-shadow 0.2s; -} - -.server-panel:has(.server-panel-body) { - box-shadow: 0 2px 12px rgba(0,0,0,0.3); -} - -/* ── Header ── */ -.server-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - cursor: pointer; - user-select: none; - transition: background 0.15s; -} - -.server-panel-header:hover { - background: rgba(255,255,255,0.03); -} - -.server-panel-title { - display: flex; - align-items: center; - gap: 8px; -} - -.server-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - transition: background 0.3s, box-shadow 0.3s; -} - -.server-panel-label { - font-size: 12px; - font-weight: 600; - color: var(--text); - letter-spacing: 0.01em; -} - -.server-panel-header-right { - display: flex; - align-items: center; - gap: 8px; -} - -.server-status-badge { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - transition: color 0.3s; -} - -.server-panel-chevron { - font-size: 9px; - color: var(--text3); - transition: transform 0.2s; -} - -/* ── Body ── */ -.server-panel-body { - padding: 4px 12px 12px; - display: flex; - flex-direction: column; - gap: 4px; - border-top: 1px solid var(--border); - animation: sp-expand 0.15s ease; -} - -@keyframes sp-expand { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } -} - -/* ── GPU badge ── */ -.sp-gpu-badge { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 500; - color: var(--success); - background: rgba(48, 209, 88, 0.08); - border: 1px solid rgba(48, 209, 88, 0.18); - border-radius: var(--radius-sm); - padding: 5px 8px; - margin-top: 6px; -} - -.sp-gpu-cpu { - color: var(--text2); - background: rgba(255,255,255,0.04); - border-color: var(--border); -} - -.sp-gpu-loading { - color: var(--warning); - background: rgba(255, 214, 10, 0.07); - border-color: rgba(255, 214, 10, 0.2); -} - -.sp-gpu-icon { - font-size: 12px; - flex-shrink: 0; -} - -.sp-gpu-vram { - margin-left: auto; - font-size: 10px; - font-weight: 700; - color: var(--text2); - background: rgba(255,255,255,0.06); - padding: 1px 6px; - border-radius: 4px; -} - -/* ── Labels ── */ -.sp-label { - font-size: 10px; - font-weight: 700; - color: var(--text3); - text-transform: uppercase; - letter-spacing: 0.06em; - margin-top: 8px; -} - -.sp-label-hint { - color: var(--accent); - font-weight: 500; - text-transform: none; - letter-spacing: 0; -} - -/* ── Inputs ── */ -.sp-input { - background: var(--bg4); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text); - font-size: 12px; - padding: 7px 9px; - outline: none; - width: 100%; - box-sizing: border-box; - transition: border-color 0.15s, background 0.15s; - font-family: inherit; -} - -.sp-input:focus { - border-color: var(--accent); - background: var(--bg5); -} - -.sp-input:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.sp-input-num { - width: 72px; -} - -/* ── Directory row ── */ -.sp-dir-row { - display: flex; - gap: 6px; - align-items: center; -} - -.sp-dir-row .sp-input { - flex: 1; -} - -.sp-scan-btn { - background: var(--bg5); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text2); - font-size: 14px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex-shrink: 0; - transition: background 0.15s, color 0.15s, border-color 0.15s; - font-family: inherit; -} - -.sp-scan-btn:hover:not(:disabled) { - background: var(--accent); - border-color: var(--accent); - color: #fff; -} - -.sp-scan-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* ── Model select ── */ -.sp-model-select { - background: var(--bg4); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text); - font-size: 12px; - padding: 7px 9px; - outline: none; - width: 100%; - box-sizing: border-box; - cursor: pointer; - font-family: inherit; - transition: border-color 0.15s; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 9px center; - padding-right: 28px; -} - -.sp-model-select:focus { - border-color: var(--accent); -} - -.sp-model-select:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.sp-scan-error { - font-size: 10px; - color: var(--danger); - margin-top: 2px; - line-height: 1.4; -} - -/* ── Layers row ── */ -.sp-layers-row { - display: flex; - align-items: center; - gap: 8px; -} - -.sp-btn-max { - background: rgba(91,106,240,0.15); - color: var(--accent); - border: 1px solid rgba(91,106,240,0.3); - border-radius: var(--radius-sm); - padding: 5px 10px; - font-size: 11px; - font-weight: 700; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; - font-family: inherit; - white-space: nowrap; -} - -.sp-btn-max:hover:not(:disabled) { - background: rgba(91,106,240,0.25); - border-color: var(--accent); -} - -.sp-btn-max:disabled { - opacity: 0.35; - cursor: not-allowed; -} - -.sp-layers-hint { - font-size: 11px; - color: var(--text3); -} - -/* ── VRAM bar ── */ -.sp-vram-bar { - position: relative; - height: 20px; - background: var(--bg4); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; - margin-top: 4px; -} - -.sp-vram-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent), #818cf8); - transition: width 0.35s ease; - border-radius: var(--radius-sm); -} - -.sp-vram-label { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: 700; - color: #fff; - text-shadow: 0 1px 3px rgba(0,0,0,0.7); - letter-spacing: 0.02em; -} - -/* ── Action buttons ── */ -.sp-actions { - margin-top: 10px; -} - -.sp-btn { - width: 100%; - padding: 9px 12px; - border: none; - border-radius: var(--radius-sm); - font-size: 13px; - font-weight: 700; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 7px; - transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; - letter-spacing: 0.01em; - font-family: inherit; -} - -.sp-btn:active:not(:disabled) { - transform: scale(0.98); -} - -.sp-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.sp-btn-icon { - font-size: 11px; -} - -.sp-btn-start { - background: linear-gradient(135deg, var(--accent), #7c3aed); - color: #fff; - box-shadow: 0 2px 12px rgba(91,106,240,0.3); -} - -.sp-btn-start:hover:not(:disabled) { - opacity: 0.9; - box-shadow: 0 4px 16px rgba(91,106,240,0.45); -} - -.sp-btn-stop { - background: rgba(240,64,64,0.15); - color: var(--danger); - border: 1px solid rgba(240,64,64,0.3); -} - -.sp-btn-stop:hover:not(:disabled) { - background: rgba(240,64,64,0.25); - border-color: var(--danger); -} - -/* ── Status message ── */ -.sp-message { - font-size: 11px; - color: var(--success); - margin-top: 6px; - word-break: break-all; - line-height: 1.5; - padding: 5px 8px; - background: rgba(48,209,88,0.07); - border-radius: var(--radius-sm); - border: 1px solid rgba(48,209,88,0.15); -} - -.sp-message-error { - color: var(--danger); - background: rgba(240,64,64,0.07); - border-color: rgba(240,64,64,0.2); -} - -/* ── Dir path display ── */ -.sp-dir-path { - flex: 1; - font-size: 11px; - color: var(--text2); - background: var(--bg4); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 7px 9px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -/* ── No models placeholder ── */ -.sp-no-models { - font-size: 11px; - color: var(--text3); - text-align: center; - padding: 10px 8px; - background: var(--bg4); - border: 1px dashed var(--border); - border-radius: var(--radius-sm); -} - -/* ── Extra models list ── */ -.sp-extra-models { - display: flex; - flex-direction: column; - gap: 3px; - margin-top: 4px; -} - -.sp-extra-model-item { - display: flex; - align-items: center; - gap: 6px; - background: var(--bg4); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 4px 8px; -} - -.sp-extra-model-name { - flex: 1; - font-size: 11px; - color: var(--text2); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.sp-extra-model-remove { - background: none; - border: none; - color: var(--text3); - font-size: 14px; - line-height: 1; - cursor: pointer; - padding: 0 2px; - flex-shrink: 0; - transition: color 0.12s; - font-family: inherit; -} - -.sp-extra-model-remove:hover { - color: var(--danger); -} - -/* ── Add models button ── */ -.sp-add-models-btn { - display: flex; - align-items: center; - justify-content: center; - gap: 5px; - width: 100%; - padding: 6px 10px; - background: var(--bg4); - border: 1px dashed var(--border); - border-radius: var(--radius-sm); - color: var(--text2); - font-size: 11px; - font-weight: 600; - cursor: pointer; - transition: background 0.12s, border-color 0.12s, color 0.12s; - font-family: inherit; - margin-top: 4px; -} - -.sp-add-models-btn:hover { - background: rgba(91,106,240,0.1); - border-color: var(--accent); - color: var(--accent); -} - -/* ── Drop zone ── */ -.sp-drop-zone { - position: relative; - border-radius: var(--radius-sm); - transition: border-color 0.15s, background 0.15s; -} - -.sp-drop-zone--active { - outline: 2px dashed var(--accent); - outline-offset: 2px; - background: rgba(91,106,240,0.06); - border-radius: var(--radius-sm); -} - -.sp-drop-zone--disabled { - pointer-events: none; -} - -.sp-drop-overlay { - position: absolute; - inset: 0; - z-index: 10; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 6px; - background: rgba(91,106,240,0.18); - border-radius: var(--radius-sm); - backdrop-filter: blur(2px); - font-size: 12px; - font-weight: 600; - color: var(--accent); - pointer-events: none; -} - -.sp-drop-icon { - font-size: 22px; -} - -.sp-drop-hint { - font-size: 10px; - color: var(--text3); - text-align: center; - margin-top: 4px; - padding: 0 2px; - line-height: 1.4; -} - -/* ── Context size row ── */ -.sp-ctx-row { - display: flex; - flex-direction: column; - gap: 6px; -} - -.sp-ctx-presets { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.sp-ctx-preset { - background: var(--bg4); - border: 1px solid var(--border); - border-radius: 4px; - color: var(--text2); - font-size: 10px; - font-weight: 600; - padding: 3px 7px; - cursor: pointer; - transition: background 0.12s, border-color 0.12s, color 0.12s; - font-family: inherit; - letter-spacing: 0.02em; -} - -.sp-ctx-preset:hover:not(:disabled) { - background: rgba(91,106,240,0.15); - border-color: var(--accent); - color: var(--accent); -} - -.sp-ctx-preset--active { - background: rgba(91,106,240,0.2); - border-color: var(--accent); - color: var(--accent); -} - -.sp-ctx-preset:disabled { - opacity: 0.35; - cursor: not-allowed; -} diff --git a/tauri-app/src/components/ServerPanel.tsx b/tauri-app/src/components/ServerPanel.tsx deleted file mode 100644 index 1405f32..0000000 --- a/tauri-app/src/components/ServerPanel.tsx +++ /dev/null @@ -1,561 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess"; -import "./ServerPanel.css"; - -interface Props { - status: ProcessStatus; - message: string; - onStart: (cfg: ServerConfig) => void; - onStop: () => void; -} - -interface GpuInfo { - name: string; - vram_mib: number; - recommended_layers: number; -} - -interface ModelFile { - name: string; - path: string; -} - -interface PickDirResult { - dir: string; - models: ModelFile[]; -} - -interface AppConfig { - models_dir?: string; - model_path?: string; - addr?: string; - gpu_layers?: number; - ctx_size?: number; - api_key?: string; - server_url?: string; - extra_model_paths?: string[]; -} - -const STATUS_LABEL: Record = { - stopped: "Stopped", - starting: "Starting…", - running: "Running", - stopping: "Stopping…", - error: "Error", -}; - -const STATUS_COLOR: Record = { - stopped: "#6b7280", - starting: "#f59e0b", - running: "#22c55e", - stopping: "#f59e0b", - error: "#ef4444", -}; - -const DEFAULT_CTX = 4096; -const CTX_PRESETS = [512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072]; - -export default function ServerPanel({ status, message, onStart, onStop }: Props) { - const [modelsDir, setModelsDir] = useState(""); - const [scannedModels, setScannedModels] = useState([]); - const [extraModels, setExtraModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(""); - const [addr, setAddr] = useState("127.0.0.1:8080"); - const [gpuLayers, setGpuLayers] = useState(0); - const [ctxSize, setCtxSize] = useState(DEFAULT_CTX); - const [gpuInfo, setGpuInfo] = useState(null); - const [gpuError, setGpuError] = useState(""); - const [expanded, setExpanded] = useState(true); - const [scanning, setScanning] = useState(false); - const [scanError, setScanError] = useState(""); - const [isDragOver, setIsDragOver] = useState(false); - const [configLoaded, setConfigLoaded] = useState(false); - const dropRef = useRef(null); - - // All models = scanned from dir + extra picked files (deduplicated by path) - const allModels: ModelFile[] = [ - ...scannedModels, - ...extraModels.filter((e) => !scannedModels.some((s) => s.path === e.path)), - ]; - - // ── Persist config to app data dir ────────────────────────────────────────── - const persistConfig = useCallback( - async (overrides: Partial = {}) => { - const cfg: AppConfig = { - models_dir: modelsDir || undefined, - model_path: selectedModel || undefined, - addr, - gpu_layers: gpuLayers, - ctx_size: ctxSize, - extra_model_paths: extraModels.map((m) => m.path), - ...overrides, - }; - try { - await invoke("save_config", { config: cfg }); - } catch { - // Fallback to localStorage if not in Tauri context - localStorage.setItem("forgelm_config", JSON.stringify(cfg)); - } - }, - [modelsDir, selectedModel, addr, gpuLayers, ctxSize, extraModels] - ); - - // ── Load config on mount ───────────────────────────────────────────────────── - useEffect(() => { - const loadConfig = async () => { - let cfg: AppConfig = {}; - try { - cfg = await invoke("load_config"); - } catch { - // Fallback: try localStorage - const raw = localStorage.getItem("forgelm_config"); - if (raw) { - try { cfg = JSON.parse(raw); } catch { /* ignore */ } - } - } - - if (cfg.addr) setAddr(cfg.addr); - if (cfg.gpu_layers !== undefined) setGpuLayers(cfg.gpu_layers); - if (cfg.ctx_size !== undefined) setCtxSize(cfg.ctx_size); - if (cfg.model_path) setSelectedModel(cfg.model_path); - - // Restore extra model paths - if (cfg.extra_model_paths?.length) { - const extras: ModelFile[] = cfg.extra_model_paths.map((p) => ({ - name: p.split(/[\\/]/).pop() ?? p, - path: p, - })); - setExtraModels(extras); - } - - // Restore models dir and scan it - if (cfg.models_dir) { - setModelsDir(cfg.models_dir); - setConfigLoaded(true); // trigger scan in next effect - } else { - setConfigLoaded(true); - } - }; - - loadConfig(); - // Detect GPU - invoke("query_gpu") - .then((info) => { - setGpuInfo(info); - setGpuLayers((prev) => prev || info.recommended_layers); - }) - .catch((e) => setGpuError(String(e))); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Scan models dir once config is loaded - useEffect(() => { - if (configLoaded && modelsDir) { - scanDir(modelsDir); - } - }, [configLoaded]); // eslint-disable-line react-hooks/exhaustive-deps - - // ── Scan directory ─────────────────────────────────────────────────────────── - const scanDir = useCallback(async (dir: string) => { - if (!dir.trim()) return; - setScanning(true); - setScanError(""); - try { - const models = await invoke("scan_models", { dir: dir.trim() }); - setScannedModels(models); - // Auto-select first if current selection is gone - setSelectedModel((prev) => { - const allPaths = [ - ...models.map((m) => m.path), - ...extraModels.map((m) => m.path), - ]; - if (prev && allPaths.includes(prev)) return prev; - return models[0]?.path ?? extraModels[0]?.path ?? ""; - }); - } catch (e) { - setScanError(String(e)); - setScannedModels([]); - } finally { - setScanning(false); - } - }, [extraModels]); - - // ── Native folder picker ───────────────────────────────────────────────────── - const handlePickDir = useCallback(async () => { - try { - const result = await invoke("pick_models_dir"); - setModelsDir(result.dir); - setScannedModels(result.models); - if (result.models.length > 0) { - setSelectedModel((prev) => { - const allPaths = [ - ...result.models.map((m) => m.path), - ...extraModels.map((m) => m.path), - ]; - if (prev && allPaths.includes(prev)) return prev; - return result.models[0].path; - }); - } - await persistConfig({ models_dir: result.dir }); - } catch (e) { - if (String(e) !== "cancelled") setScanError(String(e)); - } - }, [extraModels, persistConfig]); - - // ── Native file picker (multi-select) ──────────────────────────────────────── - const handlePickFiles = useCallback(async () => { - try { - const picked = await invoke("pick_model_files"); - if (!picked.length) return; - setExtraModels((prev) => { - const merged = [...prev]; - for (const m of picked) { - if (!merged.some((x) => x.path === m.path)) merged.push(m); - } - return merged; - }); - // Select the first newly picked model - setSelectedModel((prev) => prev || picked[0].path); - await persistConfig({ - extra_model_paths: [ - ...extraModels.map((m) => m.path), - ...picked.map((m) => m.path), - ], - }); - } catch (e) { - if (String(e) !== "cancelled") setScanError(String(e)); - } - }, [extraModels, persistConfig]); - - // ── Drag-and-drop ──────────────────────────────────────────────────────────── - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer.types.includes("Files")) setIsDragOver(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (dropRef.current && !dropRef.current.contains(e.relatedTarget as Node)) { - setIsDragOver(false); - } - }, []); - - const handleDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - const files = Array.from(e.dataTransfer.files); - const ggufFiles = files.filter((f) => f.name.toLowerCase().endsWith(".gguf")); - if (!ggufFiles.length) { - setScanError("Only .gguf files are supported"); - return; - } - - const newModels: ModelFile[] = []; - for (const f of ggufFiles) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filePath = (f as any).path as string | undefined; - if (filePath) { - newModels.push({ name: f.name, path: filePath }); - } - } - - if (newModels.length) { - setExtraModels((prev) => { - const merged = [...prev]; - for (const m of newModels) { - if (!merged.some((x) => x.path === m.path)) merged.push(m); - } - return merged; - }); - setSelectedModel((prev) => prev || newModels[0].path); - await persistConfig({ - extra_model_paths: [ - ...extraModels.map((m) => m.path), - ...newModels.map((m) => m.path), - ], - }); - } else { - setScanError("Could not read file path. Use 'Add Models…' button instead."); - } - }, [extraModels, persistConfig]); - - // ── Remove a model from the extra list ─────────────────────────────────────── - const removeModel = useCallback(async (path: string) => { - setExtraModels((prev) => { - const next = prev.filter((m) => m.path !== path); - persistConfig({ extra_model_paths: next.map((m) => m.path) }); - return next; - }); - setSelectedModel((prev) => (prev === path ? allModels.find((m) => m.path !== path)?.path ?? "" : prev)); - }, [allModels, persistConfig]); - - // ── Start / Stop ───────────────────────────────────────────────────────────── - const isActive = status === "running" || status === "starting" || status === "stopping"; - const maxLayers = gpuInfo ? gpuInfo.recommended_layers : 999; - const canStart = !!selectedModel.trim() && !isActive; - - const handleStart = async () => { - await persistConfig(); - onStart({ modelPath: selectedModel, addr, gpuLayers, ctxSize }); - }; - - const handleLayersChange = (val: number) => { - setGpuLayers(Math.max(0, Math.min(val, maxLayers))); - }; - - const vramPct = gpuInfo && gpuLayers > 0 - ? Math.min(100, (gpuLayers / gpuInfo.recommended_layers) * 100) - : 0; - - return ( -
- {/* Header */} -
setExpanded(!expanded)}> -
- - Local Server -
-
- - {STATUS_LABEL[status]} - - {expanded ? "▲" : "▼"} -
-
- - {expanded && ( -
- {/* GPU badge */} - {gpuInfo ? ( -
- ⚡ - {gpuInfo.name} - {(gpuInfo.vram_mib / 1024).toFixed(1)} GB -
- ) : gpuError ? ( -
- 🖥 - CPU only -
- ) : ( -
- ⟳ - Detecting GPU… -
- )} - - {/* Models directory */} - -
-
- {modelsDir ? modelsDir.split(/[\\/]/).pop() : "No folder selected"} -
- - -
- - {/* Model list + drop zone */} - - -
- {isDragOver && ( -
- 📦 - Drop .gguf to add -
- )} - - {allModels.length > 0 ? ( - - ) : ( -
- No models found — browse a folder or add files below -
- )} - - {!isActive && ( -
Drop .gguf here or use the button below
- )} -
- - {/* Extra models list (removable) */} - {extraModels.length > 0 && ( -
- {extraModels.map((m) => ( -
- {m.name} - {!isActive && ( - - )} -
- ))} -
- )} - - {/* Add models button */} - {!isActive && ( - - )} - - {scanError &&
{scanError}
} - - {/* Context size */} - -
- setCtxSize(Math.max(512, Math.min(131072, parseInt(e.target.value, 10) || DEFAULT_CTX)))} - disabled={isActive} - /> -
- {CTX_PRESETS.map((p) => ( - - ))} -
-
- - {/* Address */} - - setAddr(e.target.value)} - placeholder="127.0.0.1:8080" - disabled={isActive} - /> - - {/* GPU Layers */} - -
- handleLayersChange(parseInt(e.target.value, 10) || 0)} - disabled={isActive} - /> - {gpuInfo && ( - - )} - {gpuLayers === 0 && CPU only} -
- - {/* VRAM bar */} - {gpuInfo && gpuLayers > 0 && ( -
-
- - ~{Math.round(gpuLayers * 80)} MB / {gpuInfo.vram_mib} MB VRAM - -
- )} - - {/* Action buttons */} -
- {status === "stopped" || status === "error" ? ( - - ) : ( - - )} -
- - {message && ( -
- {message} -
- )} -
- )} -
- ); -} diff --git a/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx b/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx index 4de891b..996649f 100644 --- a/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx +++ b/tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx @@ -5,6 +5,14 @@ import { queryRuntimeGpu, scanRuntimeModels, } from "../api"; +import { + chooseModel, + createRuntimeConfigPatch, + dedupeModels, + getLocalRuntimeStartReason, + pathName, + toModelFile, +} from "../runtime-settings-form"; import type { RuntimeConfig, RuntimeGpuInfo, @@ -15,33 +23,6 @@ import "./RuntimeSettingsDrawer.css"; const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536]; -function pathName(path: string | null | undefined): string { - if (!path) return ""; - const parts = path.split(/[\\/]/).filter(Boolean); - return parts[parts.length - 1] || path; -} - -function toModelFile(path: string): RuntimeModelFile { - return { name: pathName(path) || path, path }; -} - -function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] { - const seen = new Set(); - return models.filter((model) => { - if (seen.has(model.path)) return false; - seen.add(model.path); - return true; - }); -} - -function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string { - const nextChoices = [...scanned, ...extra].map((model) => model.path); - if (current && (nextChoices.length === 0 || nextChoices.includes(current))) { - return current; - } - return scanned[0]?.path || extra[0]?.path || ""; -} - interface Props { open: boolean; snapshot: RuntimeSnapshot; @@ -128,29 +109,31 @@ export default function RuntimeSettingsDrawer({ const savedLocalModel = snapshot.config.model_path?.trim() || ""; const hasPendingModeChange = mode !== snapshot.config.connection_mode; const maxLayers = gpuInfo?.recommended_layers ?? 200; - const startReason = - mode !== "local" - ? "Save mode before starting the local runtime." - : !snapshot.binary_path - ? "Build or bundle the local API binary before starting." - : !selectedModel.trim() - ? "Choose a model before starting the local runtime." - : ""; + const startReason = getLocalRuntimeStartReason({ + mode, + binaryPath: snapshot.binary_path, + selectedModel, + }); if (!open) return null; - const buildPatch = (): Partial => ({ - connection_mode: mode, - start_policy: startPolicy, - server_url: serverUrl.trim(), - api_key: apiKey.trim(), - models_dir: mode === "local" ? (modelsDir.trim() || null) : snapshot.config.models_dir, - model_path: mode === "local" ? (selectedModel.trim() || null) : snapshot.config.model_path, - addr: mode === "local" ? addr.trim() || snapshot.config.addr : snapshot.config.addr, - gpu_layers: mode === "local" ? Math.max(0, Math.min(gpuLayers, maxLayers)) : snapshot.config.gpu_layers, - ctx_size: mode === "local" ? Math.max(512, ctxSize || 4096) : snapshot.config.ctx_size, - extra_model_paths: mode === "local" ? extraModels.map((model) => model.path) : snapshot.config.extra_model_paths, - }); + const buildPatch = (): RuntimeConfig => + createRuntimeConfigPatch( + snapshot.config, + { + mode, + startPolicy, + serverUrl, + apiKey, + modelsDir, + selectedModel, + addr, + gpuLayers, + ctxSize, + extraModels, + }, + maxLayers, + ); const handleSave = async () => { setLocalError(""); diff --git a/tauri-app/src/features/runtime/runtime-settings-form.test.ts b/tauri-app/src/features/runtime/runtime-settings-form.test.ts new file mode 100644 index 0000000..8f1f124 --- /dev/null +++ b/tauri-app/src/features/runtime/runtime-settings-form.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + chooseModel, + createRuntimeConfigPatch, + dedupeModels, + getLocalRuntimeStartReason, + pathName, + toModelFile, + type RuntimeSettingsFormState, +} from "./runtime-settings-form"; +import { createDefaultRuntimeSnapshot } from "./runtime-utils"; + +function createFormState(overrides: Partial = {}): RuntimeSettingsFormState { + return { + mode: "local", + startPolicy: "manual", + serverUrl: "https://api.example.com/", + apiKey: " sk-demo ", + modelsDir: "D:/models", + selectedModel: "D:/models/demo.gguf", + addr: "127.0.0.1:8080", + gpuLayers: 48, + ctxSize: 8192, + extraModels: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }], + ...overrides, + }; +} + +describe("runtime settings form helpers", () => { + it("keeps the current model when it still exists", () => { + const current = "D:/models/demo.gguf"; + const scanned = [{ name: "demo.gguf", path: current }]; + const extra = [{ name: "other.gguf", path: "D:/models/other.gguf" }]; + + expect(chooseModel(current, scanned, extra)).toBe(current); + }); + + it("falls back to the first available model when the current one disappears", () => { + expect( + chooseModel( + "D:/models/missing.gguf", + [{ name: "demo.gguf", path: "D:/models/demo.gguf" }], + [], + ), + ).toBe("D:/models/demo.gguf"); + }); + + it("deduplicates model entries by path while keeping first occurrence", () => { + expect( + dedupeModels([ + { name: "demo.gguf", path: "D:/models/demo.gguf" }, + { name: "duplicate.gguf", path: "D:/models/demo.gguf" }, + { name: "other.gguf", path: "D:/models/other.gguf" }, + ]), + ).toEqual([ + { name: "demo.gguf", path: "D:/models/demo.gguf" }, + { name: "other.gguf", path: "D:/models/other.gguf" }, + ]); + }); + + it("builds a trimmed local patch from the form state", () => { + const snapshot = createDefaultRuntimeSnapshot(); + const patch = createRuntimeConfigPatch(snapshot.config, createFormState(), 32); + + expect(patch).toMatchObject({ + connection_mode: "local", + server_url: "https://api.example.com", + api_key: "sk-demo", + model_path: "D:/models/demo.gguf", + models_dir: "D:/models", + gpu_layers: 32, + ctx_size: 8192, + extra_model_paths: ["D:/models/demo.gguf"], + }); + }); + + it("keeps saved local values untouched when editing remote mode", () => { + const snapshot = createDefaultRuntimeSnapshot(); + const patch = createRuntimeConfigPatch( + { + ...snapshot.config, + model_path: "D:/models/keep.gguf", + models_dir: "D:/models", + extra_model_paths: ["D:/models/keep.gguf"], + }, + createFormState({ + mode: "remote", + serverUrl: " https://api.example.com/ ", + apiKey: " remote-key ", + modelsDir: "", + selectedModel: "", + extraModels: [], + }), + 16, + ); + + expect(patch).toMatchObject({ + connection_mode: "remote", + server_url: "https://api.example.com", + api_key: "remote-key", + model_path: "D:/models/keep.gguf", + models_dir: "D:/models", + extra_model_paths: ["D:/models/keep.gguf"], + }); + }); + + it("explains why local runtime start is blocked", () => { + expect( + getLocalRuntimeStartReason({ mode: "local", binaryPath: null, selectedModel: "D:/models/demo.gguf" }), + ).toContain("Build or bundle"); + + expect( + getLocalRuntimeStartReason({ mode: "local", binaryPath: "D:/bin/localllm.exe", selectedModel: "" }), + ).toContain("Choose a model"); + + expect( + getLocalRuntimeStartReason({ + mode: "local", + binaryPath: "D:/bin/localllm.exe", + selectedModel: "D:/models/demo.gguf", + }), + ).toBe(""); + }); + + it("normalizes model file labels from a path", () => { + expect(pathName("D:/models/demo.gguf")).toBe("demo.gguf"); + expect(toModelFile("D:/models/demo.gguf")).toEqual({ + name: "demo.gguf", + path: "D:/models/demo.gguf", + }); + }); +}); diff --git a/tauri-app/src/features/runtime/runtime-settings-form.ts b/tauri-app/src/features/runtime/runtime-settings-form.ts new file mode 100644 index 0000000..e08d3bc --- /dev/null +++ b/tauri-app/src/features/runtime/runtime-settings-form.ts @@ -0,0 +1,98 @@ +import type { + ConnectionMode, + RuntimeConfig, + RuntimeModelFile, + StartPolicy, +} from "./types"; + +function normalizeUrl(value: string): string { + const trimmed = value.trim(); + return trimmed.replace(/\/+$/, ""); +} + +export interface RuntimeSettingsFormState { + mode: ConnectionMode; + startPolicy: StartPolicy; + serverUrl: string; + apiKey: string; + modelsDir: string; + selectedModel: string; + addr: string; + gpuLayers: number; + ctxSize: number; + extraModels: RuntimeModelFile[]; +} + +export function pathName(path: string | null | undefined): string { + if (!path) return ""; + const parts = path.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] || path; +} + +export function toModelFile(path: string): RuntimeModelFile { + return { name: pathName(path) || path, path }; +} + +export function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] { + const seen = new Set(); + return models.filter((model) => { + if (seen.has(model.path)) return false; + seen.add(model.path); + return true; + }); +} + +export function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string { + const nextChoices = [...scanned, ...extra].map((model) => model.path); + if (current && (nextChoices.length === 0 || nextChoices.includes(current))) { + return current; + } + return scanned[0]?.path || extra[0]?.path || ""; +} + +export function getLocalRuntimeStartReason(params: { + mode: ConnectionMode; + binaryPath: string | null; + selectedModel: string; +}): string { + const { mode, binaryPath, selectedModel } = params; + + if (mode !== "local") { + return "Save mode before starting the local runtime."; + } + + if (!binaryPath) { + return "Build or bundle the local API binary before starting."; + } + + if (!selectedModel.trim()) { + return "Choose a model before starting the local runtime."; + } + + return ""; +} + +export function createRuntimeConfigPatch( + snapshotConfig: RuntimeConfig, + form: RuntimeSettingsFormState, + maxLayers: number, +): RuntimeConfig { + return { + connection_mode: form.mode, + start_policy: form.startPolicy, + server_url: normalizeUrl(form.serverUrl), + api_key: form.apiKey.trim(), + models_dir: form.mode === "local" ? (form.modelsDir.trim() || null) : snapshotConfig.models_dir, + model_path: form.mode === "local" ? (form.selectedModel.trim() || null) : snapshotConfig.model_path, + addr: form.mode === "local" ? form.addr.trim() || snapshotConfig.addr : snapshotConfig.addr, + gpu_layers: + form.mode === "local" + ? Math.max(0, Math.min(form.gpuLayers, maxLayers)) + : snapshotConfig.gpu_layers, + ctx_size: form.mode === "local" ? Math.max(512, form.ctxSize || 4096) : snapshotConfig.ctx_size, + extra_model_paths: + form.mode === "local" + ? form.extraModels.map((model) => model.path) + : snapshotConfig.extra_model_paths, + }; +} diff --git a/tauri-app/src/features/runtime/useRuntimeReadyModelRefresh.test.tsx b/tauri-app/src/features/runtime/useRuntimeReadyModelRefresh.test.tsx new file mode 100644 index 0000000..7fa48e9 --- /dev/null +++ b/tauri-app/src/features/runtime/useRuntimeReadyModelRefresh.test.tsx @@ -0,0 +1,82 @@ +/* @vitest-environment jsdom */ + +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeStatus } from "./types"; +import { useRuntimeReadyModelRefresh } from "./useRuntimeReadyModelRefresh"; + +function Harness({ status, onRefresh }: { status: RuntimeStatus; onRefresh: () => void }) { + useRuntimeReadyModelRefresh(status, onRefresh); + return null; +} + +describe("useRuntimeReadyModelRefresh", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + // @ts-expect-error React test environment flag + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + // @ts-expect-error React test environment flag + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("refreshes once when the runtime transitions into ready", async () => { + const onRefresh = vi.fn(); + + await act(async () => { + root.render(); + }); + + await act(async () => { + root.render(); + }); + + await act(async () => { + root.render(); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("refreshes again after leaving ready and coming back", async () => { + const onRefresh = vi.fn(); + + await act(async () => { + root.render(); + }); + + await act(async () => { + root.render(); + }); + + await act(async () => { + root.render(); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("ignores non-ready transitions", async () => { + const onRefresh = vi.fn(); + + await act(async () => { + root.render(); + }); + + await act(async () => { + root.render(); + }); + + expect(onRefresh).not.toHaveBeenCalled(); + }); +}); diff --git a/tauri-app/src/features/runtime/useRuntimeReadyModelRefresh.ts b/tauri-app/src/features/runtime/useRuntimeReadyModelRefresh.ts new file mode 100644 index 0000000..57cf681 --- /dev/null +++ b/tauri-app/src/features/runtime/useRuntimeReadyModelRefresh.ts @@ -0,0 +1,14 @@ +import { useEffect, useRef } from "react"; +import type { RuntimeStatus } from "./types"; + +export function useRuntimeReadyModelRefresh(status: RuntimeStatus, onRefresh: () => void) { + const previousStatus = useRef(status); + + useEffect(() => { + if (status === "ready" && previousStatus.current !== "ready") { + onRefresh(); + } + + previousStatus.current = status; + }, [status, onRefresh]); +} diff --git a/tauri-app/src/hooks/model-selection.test.ts b/tauri-app/src/hooks/model-selection.test.ts new file mode 100644 index 0000000..9d33bb2 --- /dev/null +++ b/tauri-app/src/hooks/model-selection.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { getNextSelectedModel } from "./model-selection"; + +const models = [ + { id: "alpha", object: "model", created: 1, owned_by: "me" }, + { id: "beta", object: "model", created: 2, owned_by: "me" }, +]; + +describe("getNextSelectedModel", () => { + it("keeps the current selection when it still exists", () => { + expect(getNextSelectedModel("beta", models)).toBe("beta"); + }); + + it("falls back to the first model when the selection is missing", () => { + expect(getNextSelectedModel("missing", models)).toBe("alpha"); + }); + + it("clears the selection when the model list becomes empty", () => { + expect(getNextSelectedModel("alpha", [])).toBe(""); + }); +}); diff --git a/tauri-app/src/hooks/model-selection.ts b/tauri-app/src/hooks/model-selection.ts new file mode 100644 index 0000000..8fa19e9 --- /dev/null +++ b/tauri-app/src/hooks/model-selection.ts @@ -0,0 +1,13 @@ +import type { ModelInfo } from "../lib/types"; + +export function getNextSelectedModel(current: string, models: ModelInfo[]): string { + if (models.length === 0) { + return ""; + } + + if (current && models.some((model) => model.id === current)) { + return current; + } + + return models[0]?.id || ""; +} diff --git a/tauri-app/src/hooks/useModels.ts b/tauri-app/src/hooks/useModels.ts index 28733b2..90ae34d 100644 --- a/tauri-app/src/hooks/useModels.ts +++ b/tauri-app/src/hooks/useModels.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import type { ModelInfo, ServerStatus } from "../lib/types"; +import { getNextSelectedModel } from "./model-selection"; export function useModels(server: string, apiKey: string, enabled = true) { const [models, setModels] = useState([]); @@ -24,17 +25,13 @@ export function useModels(server: string, apiKey: string, enabled = true) { const data = await res.json(); const list: ModelInfo[] = data.data || []; setModels(list); - if (list.length === 0) { - setSelectedModel(""); - } else if (!selectedModel || !list.some((item) => item.id === selectedModel)) { - setSelectedModel(list[0].id); - } + setSelectedModel((current) => getNextSelectedModel(current, list)); setStatus("ok"); } catch { setModels([]); setStatus("error"); } - }, [enabled, server, apiKey, selectedModel]); + }, [enabled, server, apiKey]); useEffect(() => { refresh(); diff --git a/tauri-app/src/hooks/useServerProcess.ts b/tauri-app/src/hooks/useServerProcess.ts deleted file mode 100644 index a0e8c0a..0000000 --- a/tauri-app/src/hooks/useServerProcess.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { invoke } from "@tauri-apps/api/core"; - -export type ProcessStatus = "stopped" | "starting" | "running" | "stopping" | "error"; - -export interface ServerConfig { - modelPath: string; - addr: string; - gpuLayers: number; - ctxSize: number; -} - -export function useServerProcess() { - const [status, setStatus] = useState("stopped"); - const [message, setMessage] = useState(""); - - // Poll actual process status every 3s - useEffect(() => { - const poll = async () => { - try { - const s = await invoke("server_status"); - setStatus((prev) => { - if (prev === "starting" || prev === "stopping") return prev; - return s === "running" ? "running" : "stopped"; - }); - } catch { - // not in Tauri context (browser dev) - } - }; - poll(); - const id = setInterval(poll, 3000); - return () => clearInterval(id); - }, []); - - const start = useCallback(async (cfg: ServerConfig) => { - setStatus("starting"); - setMessage(""); - try { - const msg = await invoke("start_server", { - modelPath: cfg.modelPath, - addr: cfg.addr, - gpuLayers: cfg.gpuLayers, - ctxSize: cfg.ctxSize, - }); - setStatus("running"); - setMessage(msg); - } catch (e) { - setStatus("error"); - setMessage(String(e)); - } - }, []); - - const stop = useCallback(async () => { - setStatus("stopping"); - try { - const msg = await invoke("stop_server"); - setStatus("stopped"); - setMessage(msg); - } catch (e) { - setStatus("error"); - setMessage(String(e)); - } - }, []); - - return { status, message, start, stop }; -}