562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|