/** * LoraManager — Lightweight LoRA toggle + weight control for the Edit page. * * Additive component (Golden Rule 1.0). * Does NOT modify any existing Edit tab logic — it just provides state * that the parent can read and pass into the edit message flags. * * Features: * - Fetches installed LoRAs from /v1/lora/installed * - Real-time compatibility check against current checkpoint model * - Architecture badges (SD1.5 / SDXL / Pony / Flux) per LoRA * - Grouped display: Compatible first, then Incompatible * - Warning tooltips for incompatible LoRAs * - Checkbox toggle per LoRA * - Weight slider per LoRA (0.0 – 1.5, default 0.8) * - Max 4 LoRA stack limit (VRAM guard) * - Cyber-Noir aesthetic matching the Edit page */ import React, { useEffect, useState, useMemo, useCallback } from 'react'; const MAX_LORA_STACK = 4; export function LoraManager({ backendUrl, apiKey, activeLoras, onLorasChange, disabled, currentModel, nsfwMode }) { const [installed, setInstalled] = useState([]); const [compatData, setCompatData] = useState(null); const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState(false); const [hoveredLora, setHoveredLora] = useState(null); const [deletingLora, setDeletingLora] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const headers = useMemo(() => (apiKey ? { 'x-api-key': apiKey } : {}), [apiKey]); const base = useMemo(() => backendUrl.replace(/\/+$/, ''), [backendUrl]); // Fetch installed LoRAs on mount useEffect(() => { let cancelled = false; const load = async () => { try { const res = await fetch(`${base}/v1/lora/installed`, { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (!cancelled) { const items = data.loras || []; setInstalled(items); // Initialize activeLoras from installed list (only if empty) if (activeLoras.length === 0 && items.length > 0) { onLorasChange(items.map((l) => ({ id: l.id, weight: 0.8, enabled: false }))); } } } catch { // Silently degrade — no LoRAs installed if (!cancelled) setInstalled([]); } finally { if (!cancelled) setLoading(false); } }; load(); return () => { cancelled = true; }; }, [backendUrl, apiKey]); // eslint-disable-line react-hooks/exhaustive-deps // Filter out gated/NSFW LoRAs when nsfwMode is disabled const visibleInstalled = useMemo(() => installed.filter((l) => !l.gated || nsfwMode), [installed, nsfwMode]); // Sync activeLoras: remove any gated LoRAs that are now hidden useEffect(() => { if (!nsfwMode && activeLoras.length > 0) { const gatedIds = new Set(installed.filter((l) => l.gated).map((l) => l.id)); const hasGated = activeLoras.some((l) => gatedIds.has(l.id) && l.enabled); if (hasGated) { onLorasChange(activeLoras.map((l) => gatedIds.has(l.id) ? { ...l, enabled: false } : l)); } } }, [nsfwMode]); // eslint-disable-line react-hooks/exhaustive-deps // Fetch compatibility whenever currentModel changes useEffect(() => { if (!currentModel || installed.length === 0) { setCompatData(null); return; } let cancelled = false; const check = async () => { try { const res = await fetch(`${base}/v1/lora/compatibility?checkpoint=${encodeURIComponent(currentModel)}`, { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (!cancelled) setCompatData(data); } catch { if (!cancelled) setCompatData(null); } }; check(); return () => { cancelled = true; }; }, [currentModel, installed.length, base, headers]); // Build a compatibility lookup map: lora id → compatible (true/false/null) const compatMap = useMemo(() => { const map = new Map(); if (compatData?.loras) { for (const l of compatData.loras) { map.set(l.id, l.compatible); } } return map; }, [compatData]); // Build installed lookup: lora id → InstalledLora (with base info) const installedMap = useMemo(() => { const map = new Map(); for (const l of visibleInstalled) { map.set(l.id, l); } return map; }, [visibleInstalled]); // Group and sort LoRAs: compatible first, then unknown, then incompatible const sortedLoras = useMemo(() => { if (!activeLoras.length) return []; return [...activeLoras].sort((a, b) => { const ca = compatMap.get(a.id); const cb = compatMap.get(b.id); const order = (v) => v === true ? 0 : v === null || v === undefined ? 1 : 2; return order(ca) - order(cb); }); }, [activeLoras, compatMap]); // Count by group const groupCounts = useMemo(() => { let compatible = 0, incompatible = 0, unknown = 0; for (const lora of activeLoras) { const c = compatMap.get(lora.id); if (c === true) compatible++; else if (c === false) incompatible++; else unknown++; } return { compatible, incompatible, unknown }; }, [activeLoras, compatMap]); const enabledCount = activeLoras.filter((l) => l.enabled).length; const toggleLora = useCallback((id) => { const updated = activeLoras.map((l) => { if (l.id !== id) return l; // Guard: don't enable if at max if (!l.enabled && enabledCount >= MAX_LORA_STACK) return l; return { ...l, enabled: !l.enabled }; }); onLorasChange(updated); }, [activeLoras, enabledCount, onLorasChange]); const updateWeight = useCallback((id, weight) => { const updated = activeLoras.map((l) => l.id === id ? { ...l, weight } : l); onLorasChange(updated); }, [activeLoras, onLorasChange]); // Reload installed LoRAs (after delete) const reloadInstalled = useCallback(async () => { try { const res = await fetch(`${base}/v1/lora/installed`, { headers }); if (!res.ok) return; const data = await res.json(); const items = data.loras || []; setInstalled(items); // Remove deleted LoRAs from active list const ids = new Set(items.map((l) => l.id)); onLorasChange(activeLoras.filter((l) => ids.has(l.id))); } catch { /* ignore */ } }, [base, headers, activeLoras, onLorasChange]); // Delete a corrupt/unwanted LoRA file const deleteLora = useCallback(async (id) => { setDeletingLora(id); setDeleteConfirm(null); try { const res = await fetch(`${base}/v1/lora/${id}`, { method: 'DELETE', headers }); const data = await res.json(); if (data.ok) { // Refresh list after deletion setTimeout(() => reloadInstalled(), 300); } } catch { /* ignore */ } finally { setDeletingLora(null); } }, [base, headers, reloadInstalled]); // Count corrupt files (must be before any early return to respect Rules of Hooks) const corruptCount = visibleInstalled.filter((l) => l.healthy === false).length; const corruptIds = useMemo(() => new Set(visibleInstalled.filter((l) => l.healthy === false).map((l) => l.id)), [visibleInstalled]); const hasIncompatibleEnabled = activeLoras.some((l) => l.enabled && compatMap.get(l.id) === false); const hasCorruptEnabled = activeLoras.some((l) => l.enabled && corruptIds.has(l.id)); if (loading || visibleInstalled.length === 0) return null; return (