diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index c28406f..751382e 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -6,8 +6,6 @@ use tauri::{Manager, State}; pub struct ServerState(pub Mutex>); /// Query GPU info via nvidia-smi. -/// Returns (gpu_name, vram_mib, recommended_layers). -/// recommended_layers is estimated as vram_mib / 80 (rough heuristic for 4-bit quant). #[tauri::command] fn query_gpu() -> Result { let output = Command::new("nvidia-smi") @@ -28,7 +26,6 @@ fn query_gpu() -> Result { return Err("No GPU found".into()); } - // Format: "GPU Name, 8192" let parts: Vec<&str> = line.splitn(2, ',').collect(); if parts.len() < 2 { return Err(format!("Unexpected nvidia-smi output: {line}")); @@ -36,9 +33,6 @@ fn query_gpu() -> Result { let name = parts[0].trim().to_string(); let vram_mib: u32 = parts[1].trim().parse().unwrap_or(0); - - // Heuristic: ~80 MiB per layer for Q4/Q6 models (conservative) - // Reserve 1 GiB for OS/other usage let usable = vram_mib.saturating_sub(1024); let recommended_layers = (usable / 80).min(200) as i32; @@ -56,12 +50,14 @@ pub struct GpuInfo { pub recommended_layers: i32, } +/// Start the inference server with full configuration. #[tauri::command] fn start_server( state: State, model_path: String, addr: String, gpu_layers: i32, + ctx_size: u32, ) -> Result { let mut guard = state.0.lock().map_err(|e| e.to_string())?; @@ -79,6 +75,7 @@ fn start_server( .env("LOCALLLM_MODEL", &model_path) .env("LOCALLLM_ADDR", &addr) .env("LOCALLLM_GPU_LAYERS", gpu_layers.to_string()) + .env("LOCALLLM_CTX", ctx_size.to_string()) .spawn() .map_err(|e| format!("Failed to start server: {e}"))?; @@ -116,7 +113,6 @@ fn server_status(state: State) -> String { } /// Scan a directory for .gguf model files. -/// Returns a list of { name, path } objects. #[tauri::command] fn scan_models(dir: String) -> Result, String> { let path = Path::new(&dir); @@ -154,6 +150,51 @@ fn scan_models(dir: String) -> Result, String> { Ok(models) } +/// 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}")); + } + 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()); + } + + // 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) + .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 already in the same directory, just return it + if src == dest_path { + return Ok(ModelFile { + name: file_name.to_string_lossy().to_string(), + path: dest_path.to_string_lossy().to_string(), + }); + } + + // Copy the file + 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(), + }) +} + #[derive(serde::Serialize, Clone)] pub struct ModelFile { pub name: String, @@ -168,6 +209,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ query_gpu, scan_models, + import_model, start_server, stop_server, server_status diff --git a/tauri-app/src/components/ServerPanel.css b/tauri-app/src/components/ServerPanel.css index 5f51e70..1a618f9 100644 --- a/tauri-app/src/components/ServerPanel.css +++ b/tauri-app/src/components/ServerPanel.css @@ -388,3 +388,96 @@ background: rgba(240,64,64,0.07); border-color: rgba(240,64,64,0.2); } + +/* ── 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 index 857c592..c738506 100644 --- a/tauri-app/src/components/ServerPanel.tsx +++ b/tauri-app/src/components/ServerPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess"; import "./ServerPanel.css"; @@ -37,8 +37,8 @@ const STATUS_COLOR: Record = { error: "#ef4444", }; -// Default models directory relative to the repo root const DEFAULT_MODELS_DIR = "models"; +const DEFAULT_CTX = 4096; export default function ServerPanel({ status, message, onStart, onStop }: Props) { const [modelsDir, setModelsDir] = useState( @@ -54,11 +54,18 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) 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 [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 dropRef = useRef(null); const scanModels = useCallback(async (dir: string) => { if (!dir.trim()) return; @@ -67,7 +74,6 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) try { const models = await invoke("scan_models", { dir: dir.trim() }); setScannedModels(models); - // Auto-select first model if none selected or current not in list if (models.length > 0) { const current = localStorage.getItem("forgelm_model_path") || ""; const stillValid = models.some((m) => m.path === current); @@ -83,6 +89,24 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) } }, []); + const importModel = useCallback(async (srcPath: string) => { + setImporting(true); + setImportError(""); + 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); + } catch (e) { + setImportError(String(e)); + } finally { + setImporting(false); + } + }, [modelsDir, scanModels]); + // Detect GPU and scan models on mount useEffect(() => { invoke("query_gpu") @@ -102,6 +126,54 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) scanModels(modelsDir); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Drag-and-drop handlers + 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(); + // Only clear if leaving the drop zone itself (not a child) + 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 gguf = files.find((f) => f.name.toLowerCase().endsWith(".gguf")); + if (!gguf) { + setImportError("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."); + } + }, [importModel]); + const isActive = status === "running" || status === "starting" || status === "stopping"; const maxLayers = gpuInfo ? gpuInfo.recommended_layers : 999; const canStart = !!selectedModel.trim() && !isActive; @@ -111,7 +183,8 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) localStorage.setItem("forgelm_server_addr", addr); localStorage.setItem("forgelm_gpu_layers", String(gpuLayers)); localStorage.setItem("forgelm_models_dir", modelsDir); - onStart({ modelPath: selectedModel, addr, gpuLayers }); + localStorage.setItem("forgelm_ctx_size", String(ctxSize)); + onStart({ modelPath: selectedModel, addr, gpuLayers, ctxSize }); }; const handleLayersChange = (val: number) => { @@ -119,6 +192,11 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) 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); @@ -128,6 +206,8 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) ? Math.min(100, (gpuLayers / gpuInfo.recommended_layers) * 100) : 0; + const CTX_PRESETS = [512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072]; + return (
{/* Header */} @@ -187,37 +267,93 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props)
- {/* Model selector */} + {/* Model selector + drag-and-drop zone */} - {scannedModels.length > 0 ? ( - - ) : ( + + {/* Drop zone wrapper */} +
+ {isDragOver && ( +
+ 📦 + Drop .gguf to import +
+ )} + + {scannedModels.length > 0 ? ( + + ) : ( + setSelectedModel(e.target.value)} + placeholder="path/to/model.gguf" + disabled={isActive} + /> + )} + + {!isActive && ( +
+ {importing ? "Importing…" : "Drop a .gguf file here to add it"} +
+ )} +
+ + {scanError &&
{scanError}
} + {importError &&
{importError}
} + + {/* Context size */} + +
setSelectedModel(e.target.value)} - placeholder="path/to/model.gguf" + className="sp-input sp-input-num" + type="number" + min={512} + max={131072} + step={512} + value={ctxSize} + onChange={(e) => handleCtxChange(parseInt(e.target.value, 10) || DEFAULT_CTX)} disabled={isActive} /> - )} - {scanError &&
{scanError}
} +
+ {CTX_PRESETS.map((p) => ( + + ))} +
+
{/* Address */} diff --git a/tauri-app/src/hooks/useServerProcess.ts b/tauri-app/src/hooks/useServerProcess.ts index 4e32ab9..a0e8c0a 100644 --- a/tauri-app/src/hooks/useServerProcess.ts +++ b/tauri-app/src/hooks/useServerProcess.ts @@ -7,6 +7,7 @@ export interface ServerConfig { modelPath: string; addr: string; gpuLayers: number; + ctxSize: number; } export function useServerProcess() { @@ -39,6 +40,7 @@ export function useServerProcess() { modelPath: cfg.modelPath, addr: cfg.addr, gpuLayers: cfg.gpuLayers, + ctxSize: cfg.ctxSize, }); setStatus("running"); setMessage(msg);