diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index a694475..fa915bc 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -4,6 +4,57 @@ 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") + .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()); + } + + // Format: "GPU Name, 8192" + 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); + + // 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; + + Ok(GpuInfo { + name, + vram_mib, + recommended_layers, + }) +} + +#[derive(serde::Serialize)] +pub struct GpuInfo { + pub name: String, + pub vram_mib: u32, + pub recommended_layers: i32, +} + #[tauri::command] fn start_server( state: State, @@ -17,7 +68,6 @@ fn start_server( return Err("Server is already running".into()); } - // Resolve binary path relative to the app's resource dir or PATH let binary = if cfg!(target_os = "windows") { "localllm.exe" } else { @@ -70,6 +120,7 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .manage(ServerState(Mutex::new(None))) .invoke_handler(tauri::generate_handler![ + query_gpu, start_server, stop_server, server_status diff --git a/tauri-app/src-tauri/tauri.conf.json b/tauri-app/src-tauri/tauri.conf.json index 469edbc..9348a27 100644 --- a/tauri-app/src-tauri/tauri.conf.json +++ b/tauri-app/src-tauri/tauri.conf.json @@ -29,8 +29,7 @@ "bundle": { "active": true, "targets": "all", - "icon": [], - "resources": ["../../../target/release/localllm.exe", "../../../target/release/localllm"] + "icon": [] }, "plugins": { "shell": { diff --git a/tauri-app/src/components/ServerPanel.css b/tauri-app/src/components/ServerPanel.css index a969bda..ab81219 100644 --- a/tauri-app/src/components/ServerPanel.css +++ b/tauri-app/src/components/ServerPanel.css @@ -144,3 +144,90 @@ .sp-message-error { color: #ef4444; } + +.sp-gpu-badge { + font-size: 11px; + color: #22c55e; + background: rgba(34, 197, 94, 0.08); + border: 1px solid rgba(34, 197, 94, 0.2); + border-radius: 5px; + padding: 4px 8px; + margin-top: 6px; +} + +.sp-gpu-cpu { + color: #9ca3af; + background: rgba(156, 163, 175, 0.08); + border-color: rgba(156, 163, 175, 0.2); +} + +.sp-gpu-loading { + color: #f59e0b; + background: rgba(245, 158, 11, 0.08); + border-color: rgba(245, 158, 11, 0.2); +} + +.sp-label-hint { + color: var(--accent, #5b6af0); + font-weight: 400; +} + +.sp-layers-row { + display: flex; + align-items: center; + gap: 8px; +} + +.sp-btn-max { + background: var(--accent, #5b6af0); + color: #fff; + border: none; + border-radius: 5px; + padding: 5px 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} + +.sp-btn-max:hover:not(:disabled) { + opacity: 0.85; +} + +.sp-btn-max:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.sp-layers-hint { + font-size: 11px; + color: #9ca3af; +} + +.sp-vram-bar { + position: relative; + height: 18px; + background: rgba(255,255,255,0.06); + border-radius: 4px; + overflow: hidden; + margin-top: 4px; +} + +.sp-vram-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent, #5b6af0), #818cf8); + transition: width 0.3s ease; + border-radius: 4px; +} + +.sp-vram-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: #e5e7eb; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0,0,0,0.5); +} diff --git a/tauri-app/src/components/ServerPanel.tsx b/tauri-app/src/components/ServerPanel.tsx index 307e8a5..a1b5a41 100644 --- a/tauri-app/src/components/ServerPanel.tsx +++ b/tauri-app/src/components/ServerPanel.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess"; import "./ServerPanel.css"; @@ -9,6 +10,12 @@ interface Props { onStop: () => void; } +interface GpuInfo { + name: string; + vram_mib: number; + recommended_layers: number; +} + const STATUS_LABEL: Record = { stopped: "Stopped", starting: "Starting…", @@ -35,9 +42,31 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) const [gpuLayers, setGpuLayers] = useState( () => parseInt(localStorage.getItem("forgelm_gpu_layers") || "0", 10) ); + const [gpuInfo, setGpuInfo] = useState(null); + const [gpuError, setGpuError] = useState(""); const [expanded, setExpanded] = useState(false); + // Detect GPU on mount + useEffect(() => { + invoke("query_gpu") + .then((info) => { + setGpuInfo(info); + // Only auto-set if user hasn't saved a preference yet + if (!localStorage.getItem("forgelm_gpu_layers")) { + setGpuLayers(info.recommended_layers); + } + }) + .catch((e) => { + setGpuError(String(e)); + // Default to CPU-only if no GPU + if (!localStorage.getItem("forgelm_gpu_layers")) { + setGpuLayers(0); + } + }); + }, []); + const isActive = status === "running" || status === "starting" || status === "stopping"; + const maxLayers = gpuInfo ? gpuInfo.recommended_layers : 999; const handleStart = () => { localStorage.setItem("forgelm_model_path", modelPath); @@ -46,6 +75,11 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) onStart({ modelPath, addr, gpuLayers }); }; + const handleLayersChange = (val: number) => { + const clamped = Math.max(0, Math.min(val, maxLayers)); + setGpuLayers(clamped); + }; + return (
setExpanded(!expanded)}> @@ -64,6 +98,19 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) {expanded && (
+ {/* GPU info badge */} + {gpuInfo ? ( +
+ 🎮 {gpuInfo.name} — {(gpuInfo.vram_mib / 1024).toFixed(1)} GB VRAM +
+ ) : gpuError ? ( +
+ 💻 CPU only — {gpuError.includes("not found") ? "No NVIDIA GPU" : gpuError} +
+ ) : ( +
🔍 Detecting GPU…
+ )} + - - setGpuLayers(parseInt(e.target.value, 10) || 0)} - disabled={isActive} - /> + +
+ handleLayersChange(parseInt(e.target.value, 10) || 0)} + disabled={isActive} + /> + {gpuInfo && ( + + )} + {gpuLayers === 0 && ( + CPU only + )} +
+ + {gpuInfo && gpuLayers > 0 && ( +
+
+ + ~{Math.round(gpuLayers * 80)} MB / {gpuInfo.vram_mib} MB VRAM + +
+ )}
{status === "stopped" || status === "error" ? (