Files
ForgeLM/tauri-app/src/components/ServerPanel.tsx
T

562 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ProcessStatus, string> = {
stopped: "Stopped",
starting: "Starting…",
running: "Running",
stopping: "Stopping…",
error: "Error",
};
const STATUS_COLOR: Record<ProcessStatus, string> = {
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<string>("");
const [scannedModels, setScannedModels] = useState<ModelFile[]>([]);
const [extraModels, setExtraModels] = useState<ModelFile[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [addr, setAddr] = useState("127.0.0.1:8080");
const [gpuLayers, setGpuLayers] = useState(0);
const [ctxSize, setCtxSize] = useState(DEFAULT_CTX);
const [gpuInfo, setGpuInfo] = useState<GpuInfo | null>(null);
const [gpuError, setGpuError] = useState<string>("");
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<HTMLDivElement>(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<AppConfig> = {}) => {
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<AppConfig>("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<GpuInfo>("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<ModelFile[]>("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<PickDirResult>("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<ModelFile[]>("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 (
<div className="server-panel">
{/* Header */}
<div className="server-panel-header" onClick={() => setExpanded(!expanded)}>
<div className="server-panel-title">
<span className="server-status-dot" style={{ background: STATUS_COLOR[status] }} />
<span className="server-panel-label">Local Server</span>
</div>
<div className="server-panel-header-right">
<span className="server-status-badge" style={{ color: STATUS_COLOR[status] }}>
{STATUS_LABEL[status]}
</span>
<span className="server-panel-chevron">{expanded ? "▲" : "▼"}</span>
</div>
</div>
{expanded && (
<div className="server-panel-body">
{/* GPU badge */}
{gpuInfo ? (
<div className="sp-gpu-badge">
<span className="sp-gpu-icon">⚡</span>
<span>{gpuInfo.name}</span>
<span className="sp-gpu-vram">{(gpuInfo.vram_mib / 1024).toFixed(1)} GB</span>
</div>
) : gpuError ? (
<div className="sp-gpu-badge sp-gpu-cpu">
<span className="sp-gpu-icon">🖥</span>
<span>CPU only</span>
</div>
) : (
<div className="sp-gpu-badge sp-gpu-loading">
<span className="sp-gpu-icon">⟳</span>
<span>Detecting GPU…</span>
</div>
)}
{/* Models directory */}
<label className="sp-label">Models folder</label>
<div className="sp-dir-row">
<div className="sp-dir-path" title={modelsDir || "No folder selected"}>
{modelsDir ? modelsDir.split(/[\\/]/).pop() : "No folder selected"}
</div>
<button
className="sp-scan-btn"
onClick={handlePickDir}
disabled={isActive}
title="Browse for models folder"
>
📁
</button>
<button
className="sp-scan-btn"
onClick={() => modelsDir && scanDir(modelsDir)}
disabled={isActive || scanning || !modelsDir}
title="Rescan folder"
>
{scanning ? "…" : "⟳"}
</button>
</div>
{/* Model list + drop zone */}
<label className="sp-label">
Models
{allModels.length > 0 && (
<span className="sp-label-hint"> ({allModels.length} available)</span>
)}
</label>
<div
ref={dropRef}
className={`sp-drop-zone${isDragOver ? " sp-drop-zone--active" : ""}${isActive ? " sp-drop-zone--disabled" : ""}`}
onDragOver={!isActive ? handleDragOver : undefined}
onDragLeave={!isActive ? handleDragLeave : undefined}
onDrop={!isActive ? handleDrop : undefined}
>
{isDragOver && (
<div className="sp-drop-overlay">
<span className="sp-drop-icon">📦</span>
<span>Drop .gguf to add</span>
</div>
)}
{allModels.length > 0 ? (
<select
className="sp-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={isActive}
>
{allModels.map((m) => (
<option key={m.path} value={m.path} title={m.path}>
{m.name}
</option>
))}
</select>
) : (
<div className="sp-no-models">
No models found — browse a folder or add files below
</div>
)}
{!isActive && (
<div className="sp-drop-hint">Drop .gguf here or use the button below</div>
)}
</div>
{/* Extra models list (removable) */}
{extraModels.length > 0 && (
<div className="sp-extra-models">
{extraModels.map((m) => (
<div key={m.path} className="sp-extra-model-item">
<span className="sp-extra-model-name" title={m.path}>{m.name}</span>
{!isActive && (
<button
className="sp-extra-model-remove"
onClick={() => removeModel(m.path)}
title="Remove from list"
>
×
</button>
)}
</div>
))}
</div>
)}
{/* Add models button */}
{!isActive && (
<button className="sp-add-models-btn" onClick={handlePickFiles}>
<span>+</span> Add Models…
</button>
)}
{scanError && <div className="sp-scan-error">{scanError}</div>}
{/* Context size */}
<label className="sp-label">
Context size
<span className="sp-label-hint"> (tokens)</span>
</label>
<div className="sp-ctx-row">
<input
className="sp-input sp-input-num"
type="number"
min={512}
max={131072}
step={512}
value={ctxSize}
onChange={(e) => setCtxSize(Math.max(512, Math.min(131072, parseInt(e.target.value, 10) || DEFAULT_CTX)))}
disabled={isActive}
/>
<div className="sp-ctx-presets">
{CTX_PRESETS.map((p) => (
<button
key={p}
className={`sp-ctx-preset${ctxSize === p ? " sp-ctx-preset--active" : ""}`}
onClick={() => setCtxSize(p)}
disabled={isActive}
title={`${p.toLocaleString()} tokens`}
>
{p >= 1024 ? `${p / 1024}k` : p}
</button>
))}
</div>
</div>
{/* Address */}
<label className="sp-label">Listen address</label>
<input
className="sp-input"
type="text"
value={addr}
onChange={(e) => setAddr(e.target.value)}
placeholder="127.0.0.1:8080"
disabled={isActive}
/>
{/* GPU Layers */}
<label className="sp-label">
GPU layers
{gpuInfo && <span className="sp-label-hint"> (max {gpuInfo.recommended_layers})</span>}
</label>
<div className="sp-layers-row">
<input
className="sp-input sp-input-num"
type="number"
min={0}
max={maxLayers}
value={gpuLayers}
onChange={(e) => handleLayersChange(parseInt(e.target.value, 10) || 0)}
disabled={isActive}
/>
{gpuInfo && (
<button
className="sp-btn-max"
onClick={() => handleLayersChange(gpuInfo.recommended_layers)}
disabled={isActive}
title="Set to maximum recommended"
>
Max
</button>
)}
{gpuLayers === 0 && <span className="sp-layers-hint">CPU only</span>}
</div>
{/* VRAM bar */}
{gpuInfo && gpuLayers > 0 && (
<div className="sp-vram-bar">
<div className="sp-vram-fill" style={{ width: `${vramPct}%` }} />
<span className="sp-vram-label">
~{Math.round(gpuLayers * 80)} MB / {gpuInfo.vram_mib} MB VRAM
</span>
</div>
)}
{/* Action buttons */}
<div className="sp-actions">
{status === "stopped" || status === "error" ? (
<button
className="sp-btn sp-btn-start"
onClick={handleStart}
disabled={!canStart}
title={!selectedModel ? "Select a model first" : ""}
>
<span className="sp-btn-icon">▶</span> Start Server
</button>
) : (
<button
className="sp-btn sp-btn-stop"
onClick={onStop}
disabled={status === "starting" || status === "stopping"}
>
<span className="sp-btn-icon">■</span> Stop Server
</button>
)}
</div>
{message && (
<div className={`sp-message ${status === "error" ? "sp-message-error" : ""}`}>
{message}
</div>
)}
</div>
)}
</div>
);
}