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}
)}
)}
); }