From 2cac674cbad0e809a6e28bc9b41e7391c36b9f89 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 26 Mar 2026 21:44:39 -0300 Subject: [PATCH] feat: native folder/file picker, persistent config, and model management --- tauri-app/src-tauri/Cargo.toml | 1 + tauri-app/src-tauri/capabilities/default.json | 3 +- tauri-app/src-tauri/src/lib.rs | 199 ++++++--- tauri-app/src/components/ServerPanel.css | 97 +++++ tauri-app/src/components/ServerPanel.tsx | 401 ++++++++++++------ 5 files changed, 506 insertions(+), 195 deletions(-) diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index 269b06a..5a10978 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-shell = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/tauri-app/src-tauri/capabilities/default.json b/tauri-app/src-tauri/capabilities/default.json index 3bb52a0..f62d8a5 100644 --- a/tauri-app/src-tauri/capabilities/default.json +++ b/tauri-app/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "shell:allow-open" + "shell:allow-open", + "dialog:allow-open" ] } diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 751382e..9fddde9 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -1,18 +1,24 @@ use std::path::Path; use std::process::{Child, Command}; use std::sync::Mutex; -use tauri::{Manager, State}; +use tauri::{AppHandle, Manager, State}; +use tauri_plugin_dialog::DialogExt; pub struct ServerState(pub Mutex>); -/// Query GPU info via nvidia-smi. +// ─── 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", - ]) + .args(["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"]) .output() .map_err(|_| "nvidia-smi not found — no NVIDIA GPU detected".to_string())?; @@ -36,21 +42,11 @@ fn query_gpu() -> Result { let usable = vram_mib.saturating_sub(1024); let recommended_layers = (usable / 80).min(200) as i32; - Ok(GpuInfo { - name, - vram_mib, - recommended_layers, - }) + Ok(GpuInfo { name, vram_mib, recommended_layers }) } -#[derive(serde::Serialize)] -pub struct GpuInfo { - pub name: String, - pub vram_mib: u32, - pub recommended_layers: i32, -} +// ─── Server lifecycle ───────────────────────────────────────────────────────── -/// Start the inference server with full configuration. #[tauri::command] fn start_server( state: State, @@ -60,16 +56,11 @@ fn start_server( ctx_size: u32, ) -> Result { let mut guard = state.0.lock().map_err(|e| e.to_string())?; - if guard.is_some() { return Err("Server is already running".into()); } - let binary = if cfg!(target_os = "windows") { - "localllm.exe" - } else { - "localllm" - }; + let binary = if cfg!(target_os = "windows") { "localllm.exe" } else { "localllm" }; let child = Command::new(binary) .env("LOCALLLM_MODEL", &model_path) @@ -81,20 +72,16 @@ fn start_server( let pid = child.id(); *guard = Some(child); - Ok(format!("Server started (pid={pid}) on {addr}")) } #[tauri::command] fn stop_server(state: State) -> Result { let mut guard = state.0.lock().map_err(|e| e.to_string())?; - match guard.as_mut() { None => Err("Server is not running".into()), Some(child) => { - child - .kill() - .map_err(|e| format!("Failed to kill server: {e}"))?; + child.kill().map_err(|e| format!("Failed to kill server: {e}"))?; let _ = child.wait(); *guard = None; Ok("Server stopped".into()) @@ -105,11 +92,15 @@ fn stop_server(state: State) -> Result { #[tauri::command] fn server_status(state: State) -> String { let guard = state.0.lock().unwrap(); - if guard.is_some() { - "running".into() - } else { - "stopped".into() - } + if guard.is_some() { "running".into() } else { "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. @@ -131,16 +122,9 @@ fn scan_models(dir: String) -> Result, String> { 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 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.push(ModelFile { name, path: full_path }); } } } @@ -150,13 +134,67 @@ fn scan_models(dir: String) -> Result, String> { 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. -/// Returns the new ModelFile on success. #[tauri::command] fn import_model(src_path: String, dest_dir: String) -> Result { let src = Path::new(&src_path); - - // Validate source if !src.exists() { return Err(format!("Source file not found: {src_path}")); } @@ -168,7 +206,6 @@ fn import_model(src_path: String, dest_dir: String) -> Result return Err("Only .gguf files are supported".into()); } - // Ensure destination directory exists let dest_dir_path = Path::new(&dest_dir); if !dest_dir_path.exists() { std::fs::create_dir_all(dest_dir_path) @@ -178,7 +215,6 @@ fn import_model(src_path: String, dest_dir: String) -> Result let file_name = src.file_name().unwrap_or_default(); let dest_path = dest_dir_path.join(file_name); - // If already in the same directory, just return it if src == dest_path { return Ok(ModelFile { name: file_name.to_string_lossy().to_string(), @@ -186,7 +222,6 @@ fn import_model(src_path: String, dest_dir: String) -> Result }); } - // Copy the file std::fs::copy(src, &dest_path).map_err(|e| format!("Failed to copy model: {e}"))?; Ok(ModelFile { @@ -195,24 +230,80 @@ fn import_model(src_path: String, dest_dir: String) -> Result }) } -#[derive(serde::Serialize, Clone)] -pub struct ModelFile { - pub name: String, - pub path: String, +// ─── Persistent config ──────────────────────────────────────────────────────── + +/// All user-configurable settings, persisted to app data dir as config.json. +#[derive(serde::Serialize, serde::Deserialize, Clone, Default)] +pub struct AppConfig { + pub models_dir: Option, + pub model_path: Option, + pub addr: Option, + pub gpu_layers: Option, + pub ctx_size: Option, + pub api_key: Option, + pub server_url: Option, + /// Extra model paths added via file picker (not necessarily in models_dir) + pub extra_model_paths: Option>, } +fn config_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Cannot resolve app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Cannot create app data dir: {e}"))?; + Ok(data_dir.join("config.json")) +} + +#[tauri::command] +fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { + let path = config_path(&app)?; + let json = serde_json::to_string_pretty(&config) + .map_err(|e| format!("Serialization error: {e}"))?; + std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?; + Ok(()) +} + +#[tauri::command] +fn load_config(app: AppHandle) -> Result { + let path = config_path(&app)?; + if !path.exists() { + return Ok(AppConfig::default()); + } + let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?; + serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}")) +} + +/// Returns the app data directory path (useful for debugging). +#[tauri::command] +fn get_app_data_dir(app: AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|p| p.to_string_lossy().to_string()) + .map_err(|e| format!("Cannot resolve app data dir: {e}")) +} + +// ─── App entry ──────────────────────────────────────────────────────────────── + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) .manage(ServerState(Mutex::new(None))) .invoke_handler(tauri::generate_handler![ query_gpu, scan_models, + pick_models_dir, + pick_model_files, import_model, start_server, stop_server, - server_status + server_status, + save_config, + load_config, + get_app_data_dir, ]) .setup(|app| { #[cfg(debug_assertions)] diff --git a/tauri-app/src/components/ServerPanel.css b/tauri-app/src/components/ServerPanel.css index 1a618f9..6fab253 100644 --- a/tauri-app/src/components/ServerPanel.css +++ b/tauri-app/src/components/ServerPanel.css @@ -389,6 +389,103 @@ 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; diff --git a/tauri-app/src/components/ServerPanel.tsx b/tauri-app/src/components/ServerPanel.tsx index c738506..1405f32 100644 --- a/tauri-app/src/components/ServerPanel.tsx +++ b/tauri-app/src/components/ServerPanel.tsx @@ -21,6 +21,22 @@ interface ModelFile { 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…", @@ -37,108 +53,190 @@ const STATUS_COLOR: Record = { error: "#ef4444", }; -const DEFAULT_MODELS_DIR = "models"; 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( - () => localStorage.getItem("forgelm_models_dir") || DEFAULT_MODELS_DIR - ); + const [modelsDir, setModelsDir] = useState(""); const [scannedModels, setScannedModels] = useState([]); - const [selectedModel, setSelectedModel] = useState( - () => localStorage.getItem("forgelm_model_path") || "" - ); - const [addr, setAddr] = useState( - () => localStorage.getItem("forgelm_server_addr") || "127.0.0.1:8080" - ); - const [gpuLayers, setGpuLayers] = useState( - () => parseInt(localStorage.getItem("forgelm_gpu_layers") || "0", 10) - ); - const [ctxSize, setCtxSize] = useState( - () => parseInt(localStorage.getItem("forgelm_ctx_size") || String(DEFAULT_CTX), 10) - ); + 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 [importing, setImporting] = useState(false); - const [importError, setImportError] = useState(""); const [isDragOver, setIsDragOver] = useState(false); + const [configLoaded, setConfigLoaded] = useState(false); const dropRef = useRef(null); - const scanModels = useCallback(async (dir: string) => { + // 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); - if (models.length > 0) { - const current = localStorage.getItem("forgelm_model_path") || ""; - const stillValid = models.some((m) => m.path === current); - if (!stillValid) { - setSelectedModel(models[0].path); - } - } + // 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]); - const importModel = useCallback(async (srcPath: string) => { - setImporting(true); - setImportError(""); + // ── Native folder picker ───────────────────────────────────────────────────── + const handlePickDir = useCallback(async () => { try { - const model = await invoke("import_model", { - srcPath, - destDir: modelsDir.trim() || DEFAULT_MODELS_DIR, - }); - // Refresh model list and select the imported model - await scanModels(modelsDir.trim() || DEFAULT_MODELS_DIR); - setSelectedModel(model.path); + 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) { - setImportError(String(e)); - } finally { - setImporting(false); + if (String(e) !== "cancelled") setScanError(String(e)); } - }, [modelsDir, scanModels]); + }, [extraModels, persistConfig]); - // Detect GPU and scan models on mount - useEffect(() => { - invoke("query_gpu") - .then((info) => { - setGpuInfo(info); - if (!localStorage.getItem("forgelm_gpu_layers")) { - setGpuLayers(info.recommended_layers); - } - }) - .catch((e) => { - setGpuError(String(e)); - if (!localStorage.getItem("forgelm_gpu_layers")) { - setGpuLayers(0); + // ── 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]); - scanModels(modelsDir); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Drag-and-drop handlers + // ── Drag-and-drop ──────────────────────────────────────────────────────────── const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - if (e.dataTransfer.types.includes("Files")) { - setIsDragOver(true); - } + if (e.dataTransfer.types.includes("Files")) setIsDragOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - // Only clear if leaving the drop zone itself (not a child) if (dropRef.current && !dropRef.current.contains(e.relatedTarget as Node)) { setIsDragOver(false); } @@ -150,64 +248,69 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) setIsDragOver(false); const files = Array.from(e.dataTransfer.files); - const gguf = files.find((f) => f.name.toLowerCase().endsWith(".gguf")); - if (!gguf) { - setImportError("Only .gguf files are supported"); + const ggufFiles = files.filter((f) => f.name.toLowerCase().endsWith(".gguf")); + if (!ggufFiles.length) { + setScanError("Only .gguf files are supported"); return; } - // Tauri exposes the real path via the file object's path property - // In Tauri v2, we can read it from the dataTransfer items - const item = e.dataTransfer.items[files.indexOf(gguf)]; - const entry = item?.webkitGetAsEntry?.(); - // The actual file path is available as a non-standard property in Tauri's webview - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filePath = (gguf as any).path as string | undefined; - - if (filePath) { - await importModel(filePath); - } else if (entry) { - // Fallback: use the file name to construct path (best effort) - setImportError("Could not read file path. Try using the scan button after copying the file manually."); - } else { - setImportError("Could not read file path from drop event."); + 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 }); + } } - }, [importModel]); + 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 = () => { - localStorage.setItem("forgelm_model_path", selectedModel); - localStorage.setItem("forgelm_server_addr", addr); - localStorage.setItem("forgelm_gpu_layers", String(gpuLayers)); - localStorage.setItem("forgelm_models_dir", modelsDir); - localStorage.setItem("forgelm_ctx_size", String(ctxSize)); + const handleStart = async () => { + await persistConfig(); onStart({ modelPath: selectedModel, addr, gpuLayers, ctxSize }); }; const handleLayersChange = (val: number) => { - const clamped = Math.max(0, Math.min(val, maxLayers)); - setGpuLayers(clamped); - }; - - const handleCtxChange = (val: number) => { - const clamped = Math.max(512, Math.min(131072, val)); - setCtxSize(clamped); - }; - - const handleScanDir = () => { - localStorage.setItem("forgelm_models_dir", modelsDir); - scanModels(modelsDir); + setGpuLayers(Math.max(0, Math.min(val, maxLayers))); }; const vramPct = gpuInfo && gpuLayers > 0 ? Math.min(100, (gpuLayers / gpuInfo.recommended_layers) * 100) : 0; - const CTX_PRESETS = [512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072]; - return (
{/* Header */} @@ -246,36 +349,37 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) )} {/* Models directory */} - +
- setModelsDir(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleScanDir()} - placeholder="models/" - disabled={isActive} - /> +
+ {modelsDir ? modelsDir.split(/[\\/]/).pop() : "No folder selected"} +
+
- {/* Model selector + drag-and-drop zone */} + {/* Model list + drop zone */} - {/* Drop zone wrapper */}
📦 - Drop .gguf to import + Drop .gguf to add
)} - {scannedModels.length > 0 ? ( + {allModels.length > 0 ? ( ) : ( - setSelectedModel(e.target.value)} - placeholder="path/to/model.gguf" - disabled={isActive} - /> +
+ No models found — browse a folder or add files below +
)} {!isActive && ( -
- {importing ? "Importing…" : "Drop a .gguf file here to add it"} -
+
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}
} - {importError &&
{importError}
} {/* Context size */}