import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { Download, RefreshCw, Copy, CheckCircle2, AlertTriangle, XCircle, Settings2, Key, X, Trash2, Shield, ExternalLink } from 'lucide-react';
// -----------------------------------------------------------------------------
// Fallback Model Catalogs (when backend /model-catalog is not available)
// -----------------------------------------------------------------------------
const FALLBACK_CATALOGS = {
ollama: {
chat: [
// Standard Chat
{ id: 'llama3:8b', label: 'Llama 3 8B', recommended: true },
{ id: 'llama3:70b', label: 'Llama 3 70B' },
{ id: 'llama3.1', label: 'Llama 3.1 (8B)', recommended: true },
{ id: 'llama3.1:70b', label: 'Llama 3.1 70B' },
{ id: 'llama3.2', label: 'Llama 3.2 (3B)', recommended: true },
{ id: 'mistral:7b', label: 'Mistral 7B' },
{ id: 'mistral-nemo', label: 'Mistral Nemo (12B)', recommended: true },
{ id: 'mixtral:8x7b', label: 'Mixtral 8x7B' },
{ id: 'qwen2.5', label: 'Qwen 2.5 (7B)', recommended: true },
{ id: 'gemma2', label: 'Gemma 2 (9B)' },
{ id: 'phi3:3.8b', label: 'Phi-3 3.8B' },
{ id: 'phi4', label: 'Phi-4 (14B)' },
{ id: 'deepseek-r1:latest', label: 'DeepSeek R1 (7B)' },
// Uncensored & Roleplay (hidden when Spice Mode off)
{ id: 'dolphin3', label: 'Dolphin 3.0 (8B)', nsfw: true, recommended_nsfw: true },
{ id: 'dolphin-llama3', label: 'Dolphin Llama 3 (8B)', nsfw: true },
{ id: 'dolphin-mistral', label: 'Dolphin Mistral (7B)', nsfw: true, recommended_nsfw: true },
{ id: 'dolphin-mixtral:8x7b', label: 'Dolphin Mixtral (8x7B MoE)', nsfw: true },
{ id: 'hermes3', label: 'Hermes 3 (8B)', nsfw: true, recommended_nsfw: true },
{ id: 'solar', label: 'Solar (10.7B)', nsfw: true },
{ id: 'wizardlm2', label: 'WizardLM2 (7B)', nsfw: true },
// Legacy Uncensored
{ id: 'llama2-uncensored', label: 'Llama 2 Uncensored (7B)', nsfw: true },
{ id: 'wizardlm-uncensored', label: 'WizardLM Uncensored (13B)', nsfw: true },
{ id: 'wizard-vicuna-uncensored', label: 'Wizard Vicuna Uncensored (7B)', nsfw: true },
// Abliterated - Mannix
{ id: 'mannix/llama3.1-8b-abliterated', label: 'Llama 3.1 Abliterated (8B)', nsfw: true },
{ id: 'mannix/dolphin-2.9-llama3-8b', label: 'Dolphin 2.9 Llama 3 (8B)', nsfw: true },
// Abliterated - Huihui.ai
{ id: 'huihui_ai/qwen3-abliterated', label: 'Qwen3 Abliterated', nsfw: true, recommended_nsfw: true },
{ id: 'huihui_ai/qwen3-abliterated:8b', label: 'Qwen3 Abliterated (8B)', nsfw: true, recommended_nsfw: true },
{ id: 'huihui_ai/qwen3-abliterated:4b', label: 'Qwen3 Abliterated (4B)', nsfw: true, recommended_nsfw: true },
{ id: 'huihui_ai/qwen3-coder-abliterated', label: 'Qwen3 Coder Abliterated', nsfw: true },
{ id: 'huihui_ai/qwen3-next-abliterated', label: 'Qwen3-Next Abliterated', nsfw: true },
{ id: 'huihui_ai/dolphin3-abliterated', label: 'Dolphin 3 Abliterated (8B)', nsfw: true, recommended_nsfw: true },
{ id: 'huihui_ai/huihui-moe-abliterated', label: 'Huihui MoE Abliterated', nsfw: true },
{ id: 'huihui_ai/gpt-oss-abliterated', label: 'GPT-OSS Abliterated', nsfw: true },
// Abliterated - JOSIEFIED
{ id: 'goekdenizguelmez/JOSIEFIED-Qwen3', label: 'JOSIEFIED Qwen3', nsfw: true, recommended_nsfw: true },
{ id: 'goekdenizguelmez/JOSIEFIED-Qwen3:8b', label: 'JOSIEFIED Qwen3 (8B)', nsfw: true, recommended_nsfw: true },
{ id: 'goekdenizguelmez/JOSIEFIED-Qwen2.5', label: 'JOSIEFIED Qwen2.5', nsfw: true },
{ id: 'goekdenizguelmez/JOSIEFIED-Qwen2.5:7b', label: 'JOSIEFIED Qwen2.5 (7B)', nsfw: true, recommended_nsfw: true },
{ id: 'goekdenizguelmez/JOSIEFIED-Qwen2.5:14b', label: 'JOSIEFIED Qwen2.5 (14B)', nsfw: true },
{ id: 'goekdenizguelmez/JOSIEFIED-Qwen2.5:3b', label: 'JOSIEFIED Qwen2.5 (3B)', nsfw: true, recommended_nsfw: true },
// Vision
{ id: 'huihui_ai/qwen3-vl-abliterated:8b-instruct', label: 'Qwen3 Vision Abliterated (8B)', nsfw: true },
// Niche Uncensored
{ id: 'yarn-mistral', label: 'Yarn Mistral (7B)', nsfw: true },
{ id: 'openhermes', label: 'OpenHermes (7B)', nsfw: true },
{ id: 'neural-chat', label: 'Neural Chat (7B)', nsfw: true },
// Additional Abliterated
{ id: 'huihui_ai/llama3.2-abliterate:3b', label: 'Llama 3.2 Abliterated (3B)', nsfw: true },
{ id: 'huihui_ai/gemma3-abliterated', label: 'Gemma 3 Abliterated', nsfw: true },
{ id: 'huihui_ai/deepseek-r1-abliterated:8b', label: 'DeepSeek R1 Abliterated (8B)', nsfw: true },
{ id: 'huihui_ai/deepseek-r1-abliterated:14b', label: 'DeepSeek R1 Abliterated (14B)', nsfw: true },
{ id: 'huihui_ai/deepseek-r1-abliterated:1.5b', label: 'DeepSeek R1 Abliterated (1.5B)', nsfw: true },
{ id: 'dolphincoder', label: 'Dolphin Coder (7B)', nsfw: true },
{ id: 'goekdenizguelmez/JOSIEFIED-Llama', label: 'JOSIEFIED Llama', nsfw: true, recommended_nsfw: true },
{ id: 'samantha-mistral', label: 'Samantha Mistral (7B)', nsfw: true, recommended_nsfw: true },
],
multimodal: [
// SFW Vision Models
{ id: 'moondream', label: 'Moondream (1.6 GB)', recommended: true, description: 'Ultra-light vision captioning + OCR.' },
{ id: 'gemma3:4b', label: 'Gemma 3 Vision 4B (3 GB)', recommended: true, description: 'Best overall edge multimodal model.' },
{ id: 'llava:7b', label: 'LLaVA 1.6 7B (4.7 GB)', recommended: true, description: 'Strong general-purpose vision model.' },
{ id: 'minicpm-v:latest', label: 'MiniCPM-V 2.6 (5 GB)', description: 'Strong multi-image reasoning.' },
{ id: 'llama3.2-vision:11b', label: 'Llama 3.2 Vision 11B (7 GB)', description: 'Best reasoning near RAM limit.' },
// NSFW Vision Models
{ id: 'huihui_ai/qwen3-vl-abliterated:8b-instruct', label: 'Qwen3-VL Abliterated 8B (5 GB)', nsfw: true, recommended_nsfw: true, description: 'Unfiltered image descriptions.' },
{ id: 'internvl3:8b', label: 'InternVL3 8B (7 GB)', nsfw: true, recommended_nsfw: true, description: 'Detailed scene analysis.' },
{ id: 'smolvlm2:latest', label: 'SmolVLM2 2.2B (2 GB)', nsfw: true, recommended_nsfw: true, description: 'Fast unrestricted captioning.' },
],
},
comfyui: {
image: [
// Standard SFW models
{ id: 'sd_xl_base_1.0.safetensors', label: 'SDXL Base 1.0 (7GB)', recommended: true, nsfw: false },
{ id: 'flux1-schnell.safetensors', label: 'Flux.1 Schnell (23GB)', nsfw: false },
{ id: 'flux1-dev.safetensors', label: 'Flux.1 Dev (23GB)', nsfw: false },
{ id: 'sd15.safetensors', label: 'Stable Diffusion 1.5 (4GB)', nsfw: false },
{ id: 'realisticVisionV51.safetensors', label: 'Realistic Vision v5.1 (2GB)', nsfw: false },
// NSFW models (shown when Spice Mode enabled)
{ id: 'ponyDiffusionV6XL.safetensors', label: 'Pony Diffusion v6 XL (7GB)', nsfw: true },
{ id: 'dreamshaper_8.safetensors', label: 'DreamShaper 8 (2GB)', nsfw: true, recommended_nsfw: true },
{ id: 'deliberate_v3.safetensors', label: 'Deliberate v3 (2GB)', nsfw: true },
{ id: 'epicrealism_pureEvolution.safetensors', label: 'epiCRealism Pure Evolution (2GB)', nsfw: true, recommended_nsfw: true },
{ id: 'cyberrealistic_v42.safetensors', label: 'CyberRealistic v4.2 (2GB)', nsfw: true },
{ id: 'absolutereality_v181.safetensors', label: 'AbsoluteReality v1.8.1 (2GB)', nsfw: true },
{ id: 'aZovyaRPGArtist_v5.safetensors', label: 'aZovya RPG Artist v5 (2GB)', nsfw: true },
{ id: 'unstableDiffusion.safetensors', label: 'Unstable Diffusion (4GB)', nsfw: true },
{ id: 'majicmixRealistic_v7.safetensors', label: 'MajicMix Realistic v7 (2GB)', nsfw: true },
{ id: 'bbmix_v4.safetensors', label: 'BBMix v4 (2GB)', nsfw: true },
{ id: 'realisian_v50.safetensors', label: 'Realisian v5.0 (2GB)', nsfw: true },
],
video: [
{ id: 'svd_xt_1_1.safetensors', label: 'Stable Video Diffusion XT 1.1 (10GB)', recommended: true, nsfw: false },
{ id: 'svd_xt.safetensors', label: 'Stable Video Diffusion XT (10GB)', nsfw: false },
{ id: 'svd.safetensors', label: 'Stable Video Diffusion (10GB)', nsfw: false },
{ id: 'ltx-video-2b-v0.9.1.safetensors', label: 'LTX-Video 2B v0.9.1 (6GB)', recommended: true, nsfw: false, description: 'Best for RTX 4080. Fast, lightweight video model.' },
{ id: 'hunyuanvideo_t2v_720p_gguf_q4_k_m_pack', label: 'HunyuanVideo GGUF Q4_K_M Pack (10GB)', recommended: true, nsfw: false, description: 'GGUF pack for 16GB cards. Requires ComfyUI-GGUF.' },
{ id: 'wan2.2_5b_fp16_pack', label: 'Wan 2.2 5B FP16 Pack (22GB)', recommended: true, nsfw: false, description: 'Strong motion + modern video. Official Comfy-Org repack.' },
{ id: 'mochi_preview_fp8_pack', label: 'Mochi 1 Preview FP8 Pack (28GB)', nsfw: false, description: 'Heavier model - may push VRAM limits on 16GB.' },
{ id: 'cogvideox1.5_5b_i2v_snapshot', label: 'CogVideoX 1.5 5B I2V (20GB)', nsfw: false, description: 'Diffusers-style repo. Requires CogVideoX wrapper.' },
],
edit: [
{ id: 'sd_xl_base_1.0_inpainting_0.1.safetensors', label: 'SDXL Inpainting 0.1 (7GB)', recommended: true, nsfw: false },
{ id: 'sd-v1-5-inpainting.ckpt', label: 'SD 1.5 Inpainting (4GB)', recommended: true, nsfw: false },
{ id: 'control_v11p_sd15_inpaint.safetensors', label: 'ControlNet Inpaint (1.5GB)', recommended: true, nsfw: false },
{ id: 'sam_vit_h_4b8939.pth', label: 'SAM ViT-H (2.5GB)', nsfw: false },
{ id: 'u2net.onnx', label: 'Background Remove U2Net (170MB)', nsfw: false },
],
enhance: [
{ id: '4x-UltraSharp.pth', label: '4x UltraSharp (Upscale)', recommended: true, nsfw: false, description: 'Sharp, clean 4x upscaler for general photos.' },
{ id: 'RealESRGAN_x4plus.pth', label: 'RealESRGAN x4+ (Photo)', recommended: true, nsfw: false, description: 'Excellent photo upscaling with natural texture recovery.' },
{ id: 'realesr-general-x4v3.pth', label: 'Real-ESRGAN General x4v3', nsfw: false, description: 'General-purpose Real-ESRGAN model, good for mixed content.' },
{ id: 'SwinIR_4x.pth', label: 'SwinIR 4x (Restore)', nsfw: false, description: 'Restoration upscaler for compression and mild blur cleanup.' },
{ id: 'GFPGANv1.4.pth', label: 'GFPGAN v1.4 (Face Restore)', nsfw: false, description: 'Optional face restoration after heavy edits or upscaling.' },
{ id: 'u2net.onnx', label: 'U2Net (Background Remove)', recommended: true, nsfw: false, description: 'Background removal for Edit mode. Downloads to ~/.u2net or models/comfy/rembg.' },
],
addons: [
// Text Encoders (required for video models)
{
id: 't5xxl_fp8_e4m3fn.safetensors',
label: 'T5-XXL FP8 Text Encoder (5GB)',
recommended: true,
nsfw: false,
description: 'For 12-16GB VRAM (RTX 4080, 3080). Uses ~5GB vs ~10GB for FP16. Required for LTX-Video on limited VRAM.',
install: {
type: 'hf_files',
files: [{
repo_id: 'comfyanonymous/flux_text_encoders',
filename: 't5xxl_fp8_e4m3fn.safetensors',
dest: 'models/clip/t5xxl_fp8_e4m3fn.safetensors'
}],
hint: 'Download to ComfyUI/models/clip folder'
}
},
{
id: 't5xxl_fp16.safetensors',
label: 'T5-XXL FP16 Text Encoder (10GB)',
recommended: true,
nsfw: false,
description: 'For 24GB+ VRAM (RTX 4090, A5000). Full precision for best quality. Baseline ~20GB + sampling ~6-10GB peak.',
install: {
type: 'hf_files',
files: [{
repo_id: 'comfyanonymous/flux_text_encoders',
filename: 't5xxl_fp16.safetensors',
dest: 'models/clip/t5xxl_fp16.safetensors'
}],
hint: 'Download to ComfyUI/models/clip folder'
}
},
// VAE Models
{
id: 'mochi_vae.safetensors',
label: 'Mochi VAE (400MB)',
nsfw: false,
description: 'Required VAE for Mochi video model.',
install: {
type: 'hf_files',
files: [{
repo_id: 'Comfy-Org/mochi_preview_repackaged',
filename: 'split_files/vae/mochi_vae.safetensors',
dest: 'models/vae/mochi_vae.safetensors'
}],
hint: 'Download to ComfyUI/models/vae folder'
}
},
// CLIP Models
{
id: 'clip_l.safetensors',
label: 'CLIP-L Text Encoder (250MB)',
nsfw: false,
description: 'CLIP-L text encoder for SDXL and video models.',
install: {
type: 'hf_files',
files: [{
repo_id: 'comfyanonymous/flux_text_encoders',
filename: 'clip_l.safetensors',
dest: 'models/clip/clip_l.safetensors'
}],
hint: 'Download to ComfyUI/models/clip folder'
}
},
],
},
openai_compat: {
chat: [
{ id: 'local-model', label: 'Local Model (auto-detect)', recommended: true },
],
},
civitai: {
image: [
// Recommended Civitai models for image generation
{
id: 'pony_diffusion_v6_xl',
label: 'Pony Diffusion V6 XL',
recommended: true,
nsfw: true,
description: 'Base model for character consistency and prompt adherence',
civitai_url: 'https://civitai.com/models/257749/pony-diffusion-v6-xl',
civitai_version_id: '290640'
},
{
id: 'cyberrealistic_pony',
label: 'CyberRealistic Pony',
recommended: true,
nsfw: true,
description: 'Best blend of Pony prompt understanding with photorealism',
civitai_url: 'https://civitai.com/models/443821/cyberrealistic-pony',
civitai_version_id: '544666'
},
{
id: 'realvisxl_v50',
label: 'RealVisXL V5.0',
recommended: true,
nsfw: false,
description: 'Gold standard for photorealistic skin texture and lighting',
civitai_url: 'https://civitai.com/models/139562/realvisxl-v50',
civitai_version_id: '361593'
},
{
id: 'juggernaut_xl',
label: 'Juggernaut XL',
recommended: true,
nsfw: false,
description: 'Cinematic and moody photorealism',
civitai_url: 'https://civitai.com/models/133005/juggernaut-xl',
civitai_version_id: '471120'
},
{
id: 'flux1_checkpoint',
label: 'Flux.1 Checkpoint (Easy)',
nsfw: false,
description: 'High-quality Flux checkpoint for ComfyUI',
civitai_url: 'https://civitai.com/models/628682/flux-1-checkpoint-easy-to-use',
civitai_version_id: '704954'
},
],
video: [
// Recommended Civitai models for video generation
{
id: 'ltx_video_workflow',
label: 'LTX Video (I2V)',
recommended: true,
nsfw: false,
description: 'Fast, lightweight video generation for RTX cards',
civitai_url: 'https://civitai.com/models/995093/ltx-image-to-video-with-stg-caption-and-clip-extend-workflow',
civitai_version_id: '1119428'
},
{
id: 'mochi_1_pack',
label: 'Mochi 1 Video Pack',
recommended: true,
nsfw: false,
description: 'High motion fidelity video model',
civitai_url: 'https://civitai.com/models/886896/donut-mochi-pack-video-generation',
civitai_version_id: '992820'
},
{
id: 'animatediff_sdxl',
label: 'AnimateDiff SDXL',
nsfw: false,
description: 'Animate SDXL images using AnimateDiff',
civitai_url: 'https://civitai.com/models/331700/odinson-sdxl-animatediff',
civitai_version_id: '373089'
},
{
id: 'animatediff_lightning',
label: 'AnimateDiff Lightning',
nsfw: false,
description: 'Fast 4-step AnimateDiff model',
civitai_url: 'https://civitai.com/models/500187/animatediff-lightning',
civitai_version_id: '554533'
},
],
},
};
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
function cleanBase(url) {
return (url || '').trim().replace(/\/+$/, '');
}
async function getJson(baseUrl, path, apiKey) {
const url = `${cleanBase(baseUrl)}${path.startsWith('/') ? path : `/${path}`}`;
const res = await fetch(url, {
method: 'GET',
headers: {
...(apiKey ? { 'x-api-key': apiKey } : {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ''}`);
}
return (await res.json());
}
async function postJson(baseUrl, path, body, apiKey) {
const url = `${cleanBase(baseUrl)}${path.startsWith('/') ? path : `/${path}`}`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { 'x-api-key': apiKey } : {}),
},
credentials: 'include',
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ''}`);
}
return (await res.json());
}
function chipClass(kind) {
switch (kind) {
case 'ok':
return 'bg-emerald-500/10 text-emerald-200 border-emerald-500/20';
case 'warn':
return 'bg-amber-500/10 text-amber-200 border-amber-500/20';
case 'bad':
return 'bg-rose-500/10 text-rose-200 border-rose-500/20';
default:
return 'bg-white/5 text-white/60 border-white/10';
}
}
function IconStatus({ kind }) {
if (kind === 'ok')
return ;
if (kind === 'warn')
return ;
if (kind === 'bad')
return ;
return ;
}
function safeLabel(id, label) {
return label?.trim() ? label.trim() : id;
}
function formatBytes(bytes) {
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export default function ModelsView(props) {
const authKey = (props.apiKey || '').trim();
const backendUrl = cleanBase(props.backendUrl);
const [providers, setProviders] = useState([]);
const [providersError, setProvidersError] = useState(null);
const [modelType, setModelType] = useState('chat');
const [provider, setProvider] = useState(props.providerChat || 'ollama');
const defaultBaseUrl = useMemo(() => {
if (modelType === 'chat')
return props.baseUrlChat || '';
if (modelType === 'multimodal')
return props.baseUrlChat || '';
if (modelType === 'image')
return props.baseUrlImages || '';
if (modelType === 'edit')
return props.baseUrlImages || '';
if (modelType === 'enhance')
return props.baseUrlImages || '';
if (modelType === 'addons')
return props.baseUrlImages || '';
if (modelType === 'lora')
return props.baseUrlImages || '';
return props.baseUrlVideo || '';
}, [modelType, props.baseUrlChat, props.baseUrlImages, props.baseUrlVideo]);
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl);
// Installed models (dynamic)
const [installed, setInstalled] = useState([]);
const [installedError, setInstalledError] = useState(null);
const [installedLoading, setInstalledLoading] = useState(false);
// Supported models (curated) β optional endpoint
const [catalog, setCatalog] = useState(null);
const [catalogError, setCatalogError] = useState(null);
const [catalogLoading, setCatalogLoading] = useState(false);
// Install/delete jobs β optional endpoint (we degrade if not present)
const [installBusy, setInstallBusy] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [toast, setToast] = useState(null);
// Civitai-specific state
const [civitaiVersionId, setCivitaiVersionId] = useState('');
// Civitai search state
const [civitaiQuery, setCivitaiQuery] = useState('');
const [civitaiResults, setCivitaiResults] = useState([]);
const [civitaiSearchLoading, setCivitaiSearchLoading] = useState(false);
const [civitaiSearchError, setCivitaiSearchError] = useState(null);
const [civitaiPage, setCivitaiPage] = useState(1);
const [civitaiTotalPages, setCivitaiTotalPages] = useState(1);
// API Keys state (optional - for gated models)
const [apiKeysExpanded, setApiKeysExpanded] = useState(false);
const [apiKeysStatus, setApiKeysStatus] = useState({});
const [apiKeyInput, setApiKeyInput] = useState({ huggingface: '', civitai: '' });
const [apiKeyTesting, setApiKeyTesting] = useState(null);
const [apiKeySaving, setApiKeySaving] = useState(null);
// Avatar & Identity models state (shown in Add-ons tab)
const [avatarModels, setAvatarModels] = useState([]);
const [avatarFeatures, setAvatarFeatures] = useState({});
const [avatarModelsLoading, setAvatarModelsLoading] = useState(false);
const [avatarModelsError, setAvatarModelsError] = useState(null);
const [avatarDownloadBusy, setAvatarDownloadBusy] = useState(null); // 'basic' | 'full' | null
const [avatarDownloadStatus, setAvatarDownloadStatus] = useState(null);
const [avatarDeleteConfirm, setAvatarDeleteConfirm] = useState(null);
const [avatarDeleteBusy, setAvatarDeleteBusy] = useState(null);
const [avatarInstallBusy, setAvatarInstallBusy] = useState(null); // model ID being installed
// LoRA registry state (additive β Golden Rule 1.0)
const [loraRegistry, setLoraRegistry] = useState([]);
const [loraInstalled, setLoraInstalled] = useState([]);
const [loraLoading, setLoraLoading] = useState(false);
const [loraError, setLoraError] = useState(null);
const [loraInstallBusy, setLoraInstallBusy] = useState(null);
const [loraDownloadBusy, setLoraDownloadBusy] = useState(null);
const [loraDownloadStatus, setLoraDownloadStatus] = useState(null);
const [loraDeleteConfirm, setLoraDeleteConfirm] = useState(null);
const [loraDeleteBusy, setLoraDeleteBusy] = useState(null);
// Filter providers based on model type
const availableProviders = useMemo(() => {
if (modelType === 'chat') {
// For chat: all providers EXCEPT comfyui and civitai
return providers.filter(p => p.name !== 'comfyui' && p.name !== 'civitai');
}
else if (modelType === 'multimodal') {
// Multimodal: only Ollama (vision models run locally)
return providers.filter(p => p.name === 'ollama');
}
else {
// For image/video/edit/enhance: comfyui + civitai (if experimental enabled)
const filtered = providers.filter(p => p.name === 'comfyui');
// Add Civitai only for image/video (Civitai API only supports these types)
if (props.experimentalCivitai && (modelType === 'image' || modelType === 'video')) {
filtered.push({
name: 'civitai',
label: 'π§ͺ Civitai (Experimental)',
kind: modelType,
});
}
return filtered;
}
}, [providers, modelType, props.experimentalCivitai]);
useEffect(() => {
// When modelType changes, update provider + baseUrl defaults
if (modelType === 'chat') {
// Switch to a chat provider (prefer the one from settings, or default to ollama)
const newProvider = props.providerChat || 'ollama';
setProvider(newProvider);
setBaseUrl(props.baseUrlChat || '');
}
else if (modelType === 'multimodal') {
// Multimodal models are served via Ollama (vision-capable models)
setProvider('ollama');
setBaseUrl(props.baseUrlChat || '');
}
else if (modelType === 'image') {
// Switch to comfyui for images
setProvider('comfyui');
setBaseUrl(props.baseUrlImages || '');
}
else if (modelType === 'addons') {
// Switch to comfyui for addons (extensions)
setProvider('comfyui');
setBaseUrl(props.baseUrlImages || '');
}
else if (modelType === 'lora') {
// LoRA tab β uses own registry, no provider needed
setProvider('comfyui');
setBaseUrl(props.baseUrlImages || '');
}
else {
// Switch to comfyui for videos
setProvider('comfyui');
setBaseUrl(props.baseUrlVideo || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modelType]);
// Load providers list from backend
useEffect(() => {
let mounted = true;
setProvidersError(null);
(async () => {
try {
const res = await getJson(backendUrl, '/providers', authKey);
if (!mounted)
return;
const providerList = Object.entries(res.providers || {}).map(([name, info]) => ({
name,
label: info.label || name,
kind: 'multi',
}));
setProviders(providerList);
}
catch (e) {
if (!mounted)
return;
setProviders([]);
setProvidersError(e?.message || String(e));
}
})();
return () => {
mounted = false;
};
}, [backendUrl, authKey]);
// Load supported catalog (optional)
const refreshCatalog = async () => {
setCatalogLoading(true);
setCatalogError(null);
try {
const data = await getJson(backendUrl, '/model-catalog', authKey);
setCatalog(data);
}
catch (e) {
// Degrade gracefully if endpoint doesn't exist
setCatalog(null);
setCatalogError(e?.message || String(e));
}
finally {
setCatalogLoading(false);
}
};
// Load installed models (only for local providers)
const refreshInstalled = async () => {
// Skip fetching installed models for remote API providers and Civitai
// Remote providers: they're cloud services, not local installations
// Civitai: download-only provider, models get installed to ComfyUI after download
const skipProviders = ['openai', 'claude', 'watsonx', 'civitai'];
if (skipProviders.includes(provider)) {
setInstalled([]);
setInstalledError(null);
setInstalledLoading(false);
return;
}
setInstalledLoading(true);
setInstalledError(null);
setInstalled([]); // Clear stale list immediately so old type models don't flash
try {
const q = new URLSearchParams();
q.set('provider', provider);
if (baseUrl.trim())
q.set('base_url', baseUrl.trim());
// Pass model_type so backend returns correct installed models for this type
if (modelType && provider === 'comfyui')
q.set('model_type', modelType);
// For Ollama multimodal, filter to vision-capable models only
if (modelType === 'multimodal' && provider === 'ollama')
q.set('model_type', 'multimodal');
const data = await getJson(backendUrl, `/models?${q.toString()}`, authKey);
setInstalled(Array.isArray(data.models) ? data.models : []);
if (data.error)
setInstalledError(String(data.error));
}
catch (e) {
setInstalled([]);
setInstalledError(e?.message || String(e));
}
finally {
setInstalledLoading(false);
}
};
useEffect(() => {
refreshInstalled();
// Load catalog once initially (optional)
if (catalog === null && catalogError === null && !catalogLoading) {
refreshCatalog();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, modelType, baseUrl, backendUrl]);
const supportedForSelection = useMemo(() => {
let list = [];
// Try backend catalog first
const p = catalog?.providers?.[provider];
if (p) {
const catalogList = p?.[modelType] || [];
if (Array.isArray(catalogList) && catalogList.length > 0) {
list = catalogList;
}
}
// Fallback to hardcoded catalogs if backend catalog not available
if (list.length === 0) {
const fallback = FALLBACK_CATALOGS[provider]?.[modelType];
list = Array.isArray(fallback) ? fallback : [];
}
// Filter based on NSFW mode:
// - When nsfwMode is OFF: show only non-NSFW models (nsfw !== true)
// - When nsfwMode is ON: show ALL models (both SFW and NSFW)
if (!props.nsfwMode) {
list = list.filter((m) => m.nsfw !== true);
}
return list;
}, [catalog, provider, modelType, props.nsfwMode]);
const installedSet = useMemo(() => new Set(installed), [installed]);
// For Ollama: also match "model" with "model:latest" (Ollama convention)
const isInstalled = useCallback((id) => {
if (installedSet.has(id))
return true;
// Check if installed list contains id:latest (e.g. catalog has "moondream", Ollama reports "moondream:latest")
if (!id.includes(':') && installedSet.has(`${id}:latest`))
return true;
// Check if catalog has "model:tag" and installed has exactly that
return false;
}, [installedSet]);
// Merge supported + installed
const merged = useMemo(() => {
const supportedMap = new Map();
for (const s of supportedForSelection)
supportedMap.set(s.id, s);
const rows = [];
// Supported first
for (const s of supportedForSelection) {
rows.push({
id: s.id,
label: safeLabel(s.id, s.label),
recommended: s.recommended,
recommended_nsfw: s.recommended_nsfw,
protected: s.protected,
nsfw: s.nsfw,
description: s.description,
civitai_url: s.civitai_url,
civitai_version_id: s.civitai_version_id,
status: isInstalled(s.id) ? 'installed' : 'missing',
install: s.install,
});
}
// Then show installed but not in supported catalog
// Use the catalog as source of truth to prevent type mixing:
// e.g. video checkpoints (svd_xt_1_1, ltx-video) must NOT appear
// in the Image tab just because they share the checkpoints/ directory.
const otherTypeIds = new Set();
if (provider === 'comfyui' && catalog?.providers?.comfyui) {
const comfyui = catalog.providers.comfyui;
for (const [t, entries] of Object.entries(comfyui)) {
if (t !== modelType && Array.isArray(entries)) {
for (const entry of entries) {
if (entry.id)
otherTypeIds.add(entry.id);
}
}
}
}
// Heuristic: filter out "custom" models that clearly belong to the wrong type.
// Checkpoint files (.safetensors, .ckpt) should never appear in the chat tab;
// Ollama-style models (name:tag) should never appear in image/video/edit tabs.
const isCheckpointFile = (id) => /\.(safetensors|ckpt|pth|onnx|gguf|pt|bin)$/i.test(id);
const isLlmModel = (id) => id.includes(':') || /^(llama|mistral|gemma|qwen|phi|deepseek|codellama|vicuna|samantha)/i.test(id);
// Check if an installed model name matches any supported catalog entry
// e.g. "moondream:latest" matches catalog "moondream"
const matchesSupportedEntry = (modelName) => {
if (supportedMap.has(modelName))
return true;
// "model:latest" β try "model" (Ollama convention)
if (modelName.endsWith(':latest')) {
const base = modelName.slice(0, -':latest'.length);
if (supportedMap.has(base))
return true;
}
return false;
};
for (const m of installed) {
if (!matchesSupportedEntry(m)) {
// Skip models that belong to another type's catalog
if (otherTypeIds.has(m))
continue;
// Skip checkpoint files on the chat tab
if (modelType === 'chat' && isCheckpointFile(m))
continue;
// Skip LLM models on ComfyUI tabs (image/video/edit/enhance)
if (modelType !== 'chat' && provider === 'comfyui' && isLlmModel(m))
continue;
rows.push({
id: m,
label: m,
status: 'installed_unsupported',
});
}
}
// Sorting: recommended β recommended_nsfw β installed β missing β unsupported
rows.sort((a, b) => {
// Priority: recommended (or recommended_nsfw in NSFW mode) first
const isRecommendedA = a.recommended || (props.nsfwMode && a.recommended_nsfw);
const isRecommendedB = b.recommended || (props.nsfwMode && b.recommended_nsfw);
const ar = isRecommendedA ? 0 : 1;
const br = isRecommendedB ? 0 : 1;
if (ar !== br)
return ar - br;
const order = (s) => s === 'installed' ? 0 : s === 'missing' ? 1 : 2;
return order(a.status) - order(b.status);
});
return rows;
}, [supportedForSelection, installed, installedSet, isInstalled, props.nsfwMode, modelType, provider]);
const tryInstall = async (modelId, install) => {
setInstallBusy(modelId);
try {
const body = {
provider,
model_type: modelType,
model_id: modelId,
base_url: baseUrl.trim() || undefined,
options: {},
};
// Add civitai_version_id if provider is civitai
if (provider === 'civitai') {
if (!civitaiVersionId.trim()) {
setToast('Please enter a Civitai version ID');
setInstallBusy(null);
return;
}
body.civitai_version_id = civitaiVersionId.trim();
setToast(`Starting Civitai download for version ${civitaiVersionId}...`);
}
else {
setToast(`Starting download for ${modelId}...`);
}
const res = await postJson(backendUrl, '/models/install', body, authKey);
if (res?.ok) {
setToast(res.message || `Successfully installed ${modelId}`);
// Refresh installed list after successful installation
setTimeout(() => {
refreshInstalled();
}, 2000);
}
else {
setToast(res?.message || 'Installation request sent.');
}
}
catch (e) {
// Fallback to copy-paste instructions if API fails
if (provider === 'ollama') {
const cmd = `ollama pull ${modelId}`;
navigator.clipboard?.writeText(cmd).catch(() => { });
setToast(`API failed. Command copied: ${cmd}`);
}
else if (provider === 'comfyui') {
navigator.clipboard?.writeText(`Run: python scripts/download.py --model ${modelId}`).catch(() => { });
setToast('API failed. Download command copied to clipboard.');
}
else if (provider === 'civitai') {
setToast(`Installation failed: ${e?.message || String(e)}`);
}
else {
setToast(`Installation failed: ${e?.message || String(e)}`);
}
}
finally {
setInstallBusy(null);
}
};
const tryDelete = async (modelId) => {
setDeleteBusy(modelId);
setDeleteConfirm(null);
try {
const body = {
provider,
model_type: modelType,
model_id: modelId,
};
setToast(`Deleting ${modelId}...`);
const res = await postJson(backendUrl, '/models/delete', body, authKey);
if (res?.ok) {
setToast(res.message || `Deleted ${modelId}`);
setTimeout(() => refreshInstalled(), 500);
}
else {
setToast(res?.message || 'Delete failed.');
}
}
catch (e) {
setToast(`Delete failed: ${e?.message || String(e)}`);
}
finally {
setDeleteBusy(null);
}
};
// Civitai search function
const searchCivitai = async (page = 1) => {
if (!civitaiQuery.trim()) {
setToast('Please enter a search query');
return;
}
setCivitaiSearchLoading(true);
setCivitaiSearchError(null);
try {
const headers = {
'Content-Type': 'application/json',
...(authKey ? { 'x-api-key': authKey } : {}),
};
// Pass Civitai API key if available and NSFW mode is enabled
if (props.civitaiApiKey && props.nsfwMode) {
headers['X-Civitai-Api-Key'] = props.civitaiApiKey;
}
const res = await fetch(`${backendUrl}/civitai/search`, {
method: 'POST',
headers,
body: JSON.stringify({
query: civitaiQuery.trim(),
// Civitai only supports 'image' or 'video' - map other types appropriately
model_type: modelType === 'video' ? 'video' : 'image',
nsfw: props.nsfwMode || false,
limit: 20,
page,
sort: 'Highest Rated',
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${text}`);
}
const data = await res.json();
if (data.ok) {
setCivitaiResults(data.items || []);
setCivitaiPage(data.metadata?.currentPage || page);
setCivitaiTotalPages(data.metadata?.totalPages || 1);
if (data.items.length === 0) {
setToast('No models found. Try a different search term.');
}
}
else {
throw new Error('Search failed');
}
}
catch (e) {
console.error('[Civitai Search Error]', e);
setCivitaiSearchError(e?.message || String(e));
setToast(`Search failed: ${e?.message || 'Unknown error'}`);
}
finally {
setCivitaiSearchLoading(false);
}
};
// Install from Civitai search result
const installFromCivitaiResult = async (model, versionId) => {
setInstallBusy(model.id);
try {
const body = {
provider: 'civitai',
model_type: modelType === 'edit' ? 'image' : modelType, // Civitai uses 'image' for edit models
model_id: model.id,
civitai_version_id: versionId,
civitai_api_key: props.nsfwMode ? props.civitaiApiKey : undefined,
};
setToast(`Starting download for ${model.name}...`);
const res = await postJson(backendUrl, '/models/install', body, authKey);
if (res?.ok) {
setToast(res.message || `Successfully installed ${model.name}`);
// Refresh installed list after successful installation
setTimeout(() => {
refreshInstalled();
}, 2000);
}
else {
setToast(res?.message || 'Installation request sent.');
}
}
catch (e) {
setToast(`Installation failed: ${e?.message || String(e)}`);
}
finally {
setInstallBusy(null);
}
};
// API Keys management functions
const loadApiKeysStatus = async () => {
try {
const data = await getJson(backendUrl, '/settings/api-keys', authKey);
if (data.keys) {
setApiKeysStatus(data.keys);
}
}
catch (e) {
// API keys endpoint not available - that's OK, it's optional
console.debug('[API Keys] Endpoint not available:', e);
}
};
const saveApiKey = async (provider) => {
const key = apiKeyInput[provider].trim();
if (!key) {
setToast(`Please enter a ${provider === 'huggingface' ? 'HuggingFace' : 'Civitai'} API key`);
return;
}
setApiKeySaving(provider);
try {
const res = await postJson(backendUrl, '/settings/api-keys', { provider, key }, authKey);
if (res.ok) {
setToast(res.message || `${provider} API key saved successfully`);
setApiKeyInput(prev => ({ ...prev, [provider]: '' }));
await loadApiKeysStatus();
}
else {
setToast(`Failed to save ${provider} API key`);
}
}
catch (e) {
setToast(`Error saving API key: ${e?.message || String(e)}`);
}
finally {
setApiKeySaving(null);
}
};
const testApiKey = async (provider) => {
setApiKeyTesting(provider);
try {
const keyToTest = apiKeyInput[provider].trim() || undefined;
const res = await postJson(backendUrl, '/settings/api-keys/test', { provider, key: keyToTest }, authKey);
if (res.valid) {
setToast(`${provider} key valid: ${res.message}`);
}
else {
setToast(`${provider} key invalid: ${res.message}`);
}
}
catch (e) {
setToast(`Error testing API key: ${e?.message || String(e)}`);
}
finally {
setApiKeyTesting(null);
}
};
const deleteApiKey = async (provider) => {
try {
const res = await fetch(`${backendUrl}/settings/api-keys/${provider}`, {
method: 'DELETE',
headers: authKey ? { 'x-api-key': authKey } : {},
});
const data = await res.json();
if (data.ok) {
setToast(data.message || `${provider} API key removed`);
await loadApiKeysStatus();
}
}
catch (e) {
setToast(`Error removing API key: ${e?.message || String(e)}`);
}
};
// Load API keys status when expanded
useEffect(() => {
if (apiKeysExpanded) {
loadApiKeysStatus();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiKeysExpanded]);
// Fetch avatar models when Add-ons tab is active
const refreshAvatarModels = useCallback(async () => {
setAvatarModelsLoading(true);
setAvatarModelsError(null);
try {
const data = await getJson(backendUrl, '/v1/avatar-models', authKey);
setAvatarModels(data.available || []);
setAvatarFeatures(data.features || {});
}
catch (e) {
setAvatarModelsError(e?.message || String(e));
setAvatarModels([]);
setAvatarFeatures({});
}
finally {
setAvatarModelsLoading(false);
}
}, [backendUrl, authKey]);
useEffect(() => {
if (modelType === 'addons') {
refreshAvatarModels();
}
}, [modelType, refreshAvatarModels]);
// Fetch LoRA registry + installed when LoRA tab is active (additive)
const refreshLoraModels = useCallback(async () => {
setLoraLoading(true);
setLoraError(null);
try {
const [registryRes, installedRes] = await Promise.all([
getJson(backendUrl, `/v1/lora/registry?spicy=${props.nsfwMode ? 'true' : 'false'}`, authKey),
getJson(backendUrl, '/v1/lora/installed', authKey),
]);
setLoraRegistry(registryRes.loras || []);
setLoraInstalled(installedRes.loras || []);
}
catch (e) {
setLoraError(e?.message || String(e));
setLoraRegistry([]);
setLoraInstalled([]);
}
finally {
setLoraLoading(false);
}
}, [backendUrl, authKey, props.nsfwMode]);
useEffect(() => {
if (modelType === 'lora') {
refreshLoraModels();
}
else {
// Clear stale registry when leaving LoRA tab so NSFW entries
// don't linger in memory after spicy mode is toggled off.
setLoraRegistry([]);
setLoraInstalled([]);
}
}, [modelType, refreshLoraModels]);
// LoRA install β mirrors installSingleAvatarModel pattern
const installLora = async (loraId) => {
setLoraInstallBusy(loraId);
const entry = loraRegistry.find((l) => l.id === loraId);
setToast(`Installing ${entry?.name || loraId}...`);
try {
const res = await fetch(`${backendUrl}/v1/lora/${loraId}/install`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authKey ? { 'x-api-key': authKey } : {}),
},
});
const data = await res.json();
if (data.ok) {
if (data.already_installed) {
setToast(`${entry?.name || loraId} is already installed`);
setLoraInstallBusy(null);
return;
}
setLoraDownloadBusy(`single:${loraId}`);
}
else {
setToast(`Install failed: ${data.error || 'unknown error'}`);
}
}
catch (e) {
setToast(`Install error: ${e?.message || String(e)}`);
}
finally {
setLoraInstallBusy(null);
}
};
// LoRA delete β mirrors deleteAvatarModel pattern
const deleteLora = async (loraId) => {
setLoraDeleteBusy(loraId);
setLoraDeleteConfirm(null);
try {
const res = await fetch(`${backendUrl}/v1/lora/${loraId}`, {
method: 'DELETE',
headers: authKey ? { 'x-api-key': authKey } : {},
});
const data = await res.json();
if (data.ok) {
setToast(`Deleted ${data.name || loraId} β freed ${data.freed_human || ''}`);
setTimeout(() => refreshLoraModels(), 500);
}
else {
setToast(`Delete failed: ${data.error || 'unknown error'}`);
}
}
catch (e) {
setToast(`Delete error: ${e?.message || String(e)}`);
}
finally {
setLoraDeleteBusy(null);
}
};
// Poll LoRA download status β mirrors avatar download polling
useEffect(() => {
if (!loraDownloadBusy)
return;
let cancelled = false;
const poll = async () => {
while (!cancelled) {
await new Promise((r) => setTimeout(r, 4000));
if (cancelled)
break;
try {
const res = await fetch(`${backendUrl}/v1/lora/download/status`, {
headers: authKey ? { 'x-api-key': authKey } : {},
});
if (!res.ok)
continue;
const status = await res.json();
if (cancelled)
break;
setLoraDownloadStatus(status);
if (status.finished || !status.running) {
const msg = `Install complete: ${status.installed_count}/${status.total_models} installed`
+ (status.failed_count > 0 ? ` (${status.failed_count} failed)` : '')
+ (status.downloaded_bytes > 0 ? ` β ${formatBytes(status.downloaded_bytes)} in ${Math.round(status.elapsed)}s` : '');
setToast(msg);
setLoraDownloadBusy(null);
setTimeout(() => refreshLoraModels(), 500);
break;
}
}
catch {
// Network hiccup β keep polling
}
}
};
poll();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loraDownloadBusy, backendUrl, authKey]);
const downloadAvatarPreset = async (preset) => {
setAvatarDownloadBusy(preset);
setAvatarDownloadStatus(null);
setToast(`Starting ${preset === 'basic' ? 'Basic' : 'Full'} avatar model download...`);
try {
const res = await fetch(`${backendUrl}/v1/avatar-models/download?preset=${preset}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authKey ? { 'x-api-key': authKey } : {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ''}`);
}
const data = await res.json();
if (!data.ok) {
setToast(data.error || 'Download failed to start.');
setAvatarDownloadBusy(null);
return;
}
setToast(data.message || 'Download started β monitoring progress...');
// Start polling
}
catch (e) {
setToast(`Download error: ${e?.message || String(e)}`);
setAvatarDownloadBusy(null);
}
};
// Poll download status while a download is active
useEffect(() => {
if (!avatarDownloadBusy)
return;
let cancelled = false;
const poll = async () => {
while (!cancelled) {
await new Promise((r) => setTimeout(r, 4000));
if (cancelled)
break;
try {
const res = await fetch(`${backendUrl}/v1/avatar-models/download/status`, {
headers: authKey ? { 'x-api-key': authKey } : {},
});
if (!res.ok)
continue;
const status = await res.json();
if (cancelled)
break;
setAvatarDownloadStatus(status);
if (status.finished || !status.running) {
// Done β show final summary toast only once
const msg = `Download complete: ${status.installed_count}/${status.total_models} installed`
+ (status.failed_count > 0 ? ` (${status.failed_count} failed)` : '')
+ ` β ${formatBytes(status.downloaded_bytes)} in ${Math.round(status.elapsed)}s`;
setToast(msg);
setAvatarDownloadBusy(null);
// Refresh model list
setTimeout(() => refreshAvatarModels(), 500);
break;
}
}
catch {
// Network hiccup β keep polling
}
}
};
poll();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [avatarDownloadBusy, backendUrl, authKey]);
const deleteAvatarModel = async (modelId) => {
setAvatarDeleteBusy(modelId);
setAvatarDeleteConfirm(null);
try {
const res = await fetch(`${backendUrl}/v1/avatar-models/${modelId}`, {
method: 'DELETE',
headers: authKey ? { 'x-api-key': authKey } : {},
});
const data = await res.json();
if (data.ok) {
setToast(`Uninstalled ${data.name || modelId} β freed ${data.freed_human || ''}`);
setTimeout(() => refreshAvatarModels(), 500);
}
else {
setToast(`Delete failed: ${data.error || 'unknown error'}`);
}
}
catch (e) {
setToast(`Delete error: ${e?.message || String(e)}`);
}
finally {
setAvatarDeleteBusy(null);
}
};
const installSingleAvatarModel = async (modelId) => {
setAvatarInstallBusy(modelId);
setToast(`Starting download for ${modelId}...`);
try {
const res = await fetch(`${backendUrl}/v1/avatar-models/${modelId}/install`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authKey ? { 'x-api-key': authKey } : {}),
},
});
const data = await res.json();
if (data.ok) {
if (data.already_installed) {
setToast(`${modelId} is already installed`);
setAvatarInstallBusy(null);
return;
}
// Start polling for progress
setAvatarDownloadBusy(`single:${modelId}`);
}
else {
setToast(`Install failed: ${data.error || 'unknown error'}`);
}
}
catch (e) {
setToast(`Install error: ${e?.message || String(e)}`);
}
finally {
setAvatarInstallBusy(null);
}
};
// Auto-dismiss toast
useEffect(() => {
if (!toast)
return;
const t = setTimeout(() => setToast(null), 3500);
return () => clearTimeout(t);
}, [toast]);
return (
{/* Header */}
Model Management
Configure and deploy AI models across providers
refreshCatalog()} disabled={catalogLoading} className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold flex items-center gap-2.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
Refresh Catalog
refreshInstalled()} disabled={installedLoading} className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold flex items-center gap-2.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
Refresh Installed
{/* API Keys Settings Button */}
setApiKeysExpanded(true)} className="p-2.5 rounded-xl bg-transparent hover:bg-white/5 border border-transparent hover:border-white/10 text-white/40 hover:text-white/70 transition-all" title="API Keys (for gated models)">
{/* API Keys Modal */}
{apiKeysExpanded && (
{/* Backdrop */}
setApiKeysExpanded(false)}/>
{/* Modal */}
{/* Modal Header */}
API Keys
Optional - for gated HuggingFace and Civitai models
setApiKeysExpanded(false)} className="p-2 rounded-lg hover:bg-white/5 text-white/40 hover:text-white/70 transition-all">
{/* Modal Content */}
{/* HuggingFace Token */}
HuggingFace Token
For FLUX, SVD XT 1.1, gated models
{apiKeysStatus.huggingface?.configured && (
{apiKeysStatus.huggingface.source === 'environment' ? 'ENV' : 'Stored'}
)}
{apiKeysStatus.huggingface?.configured ? (
β
{apiKeysStatus.huggingface.masked}
testApiKey('huggingface')} disabled={apiKeyTesting === 'huggingface'} className="flex-1 px-3 py-2 text-xs font-semibold rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 transition-all disabled:opacity-50">
{apiKeyTesting === 'huggingface' ? 'Testing...' : 'Test'}
{apiKeysStatus.huggingface.source !== 'environment' && ( deleteApiKey('huggingface')} className="px-3 py-2 text-xs font-semibold rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-300 border border-red-500/20 transition-all">
Remove
)}
) : (
)}
{/* Civitai API Key */}
Civitai API Key
For NSFW and restricted downloads
{apiKeysStatus.civitai?.configured && (
{apiKeysStatus.civitai.source === 'environment' ? 'ENV' : 'Stored'}
)}
{apiKeysStatus.civitai?.configured ? (
β
{apiKeysStatus.civitai.masked}
testApiKey('civitai')} disabled={apiKeyTesting === 'civitai'} className="flex-1 px-3 py-2 text-xs font-semibold rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 transition-all disabled:opacity-50">
{apiKeyTesting === 'civitai' ? 'Testing...' : 'Test'}
{apiKeysStatus.civitai.source !== 'environment' && ( deleteApiKey('civitai')} className="px-3 py-2 text-xs font-semibold rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-300 border border-red-500/20 transition-all">
Remove
)}
) : (
)}
{/* Modal Footer */}
Keys are stored locally and never sent to external servers except for authentication.
Environment variables (HF_TOKEN, CIVITAI_API_KEY) take precedence over stored keys.
)}
{/* Controls */}
Model Type
{['chat', 'multimodal', 'image', 'edit', 'video', 'enhance', 'lora', 'addons'].map((t) => ( setModelType(t)} className={`px-4 py-2.5 rounded-lg border text-xs font-bold uppercase tracking-wide transition-all ${modelType === t
? 'bg-white text-black border-white shadow-lg shadow-white/20'
: 'bg-transparent border-white/10 text-white/60 hover:bg-white/5 hover:border-white/20 hover:text-white/80'}`}>
{t === 'addons' ? 'π§© Add-ons' : t === 'lora' ? 'LoRA' : t}
))}
Provider
setProvider(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm font-medium outline-none focus:border-white/30 focus:bg-white/10 transition-all">
{availableProviders.length > 0 ? (availableProviders.map((p) => (
{p.label || p.name}
))) : (
// Fallback options when providers haven't loaded
modelType === 'chat' ? (<>
Ollama
OpenAI-compatible (vLLM)
OpenAI
Claude
Watsonx
>) : (ComfyUI ))}
{providersError ?
{providersError}
: null}
{/* Civitai Input Section (only when provider is civitai) */}
{provider === 'civitai' && (
{/* Search Bar */}
π Search Civitai Models
{civitaiSearchError && (
β οΈ {civitaiSearchError}
)}
{/* Search Results */}
{civitaiResults.length > 0 && (
Search Results ({civitaiResults.length} models)
{civitaiTotalPages > 1 && (
searchCivitai(civitaiPage - 1)} disabled={civitaiPage <= 1 || civitaiSearchLoading} className="px-3 py-1.5 text-xs font-semibold rounded-lg bg-white/5 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
β Prev
Page {civitaiPage} of {civitaiTotalPages}
searchCivitai(civitaiPage + 1)} disabled={civitaiPage >= civitaiTotalPages || civitaiSearchLoading} className="px-3 py-1.5 text-xs font-semibold rounded-lg bg-white/5 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
Next β
)}
{civitaiResults.map((model) => (
{/* Thumbnail */}
{model.thumbnail ? (
) : (
πΌοΈ
)}
{model.nsfw && (
NSFW
)}
{/* Info */}
{model.name}
by {model.creator}
β¬οΈ {model.downloads >= 1000 ? `${(model.downloads / 1000).toFixed(1)}K` : model.downloads}
β {model.rating.toFixed(1)}
({model.ratingCount})
{model.tags.length > 0 && (
{model.tags.slice(0, 3).map((tag) => (
{tag}
))}
)}
{/* Version selector and install */}
{model.versions.length > 0 && (
{model.versions.map((v) => (
{v.name} ({(v.sizeKB / 1024 / 1024).toFixed(1)}GB)
))}
{
const select = document.getElementById(`version-${model.id}`);
const versionId = select?.value || model.versions[0]?.id;
installFromCivitaiResult(model, versionId);
}} disabled={installBusy === model.id} className={[
"px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all",
installBusy === model.id
? "bg-cyan-700 text-white cursor-wait"
: "bg-cyan-600 text-white hover:bg-cyan-500"
].join(" ")}>
{installBusy === model.id ? (
) : ()}
Install
)}
{model.link && (
View on Civitai β
)}
))}
)}
{/* Manual Version ID Input */}
{
if (!civitaiVersionId.trim()) {
setToast('Please enter a Civitai version ID first');
return;
}
tryInstall(civitaiVersionId, undefined);
}} disabled={!civitaiVersionId.trim() || installBusy !== null} className={[
"px-6 py-3 rounded-xl text-white text-sm font-bold uppercase tracking-wide transition-all shadow-lg relative overflow-hidden",
installBusy !== null
? "bg-blue-700 cursor-wait shadow-blue-700/30"
: !civitaiVersionId.trim()
? "bg-blue-600/50 cursor-not-allowed shadow-blue-600/10"
: "bg-blue-600 hover:bg-blue-500 shadow-blue-600/20 hover:shadow-blue-600/30 hover:scale-105 active:scale-95"
].join(" ")}>
{installBusy !== null && ( )}
{installBusy !== null ? (<>
Downloading...
>) : (<>
Download
>)}
{/* Recommended Models Info */}
β Recommended {modelType === 'image' ? 'Image' : 'Video'} Models from Civitai
Click "Install" on any model below to download directly from Civitai. Models are installed to your ComfyUI models folder.
)}
{/* Body */}
{/* Error messages - hide for Civitai since it's download-only */}
{installedError && provider !== 'civitai' ? (
{installedError.includes('Ollama') || installedError.includes('11434')
? 'Ollama Not Running'
: 'Configuration Required'}
{installedError.includes('Ollama') || installedError.includes('11434')
? 'Ollama is not reachable. Start Ollama or switch to a different provider tab. ComfyUI models and Add-ons still work normally.'
: installedError.includes('LLM_BASE_URL')
? 'Configure LLM_BASE_URL environment variable to use OpenAI-compatible (vLLM) provider. Or switch to a different provider.'
: installedError}
) : null}
{catalogError && !supportedForSelection.length ? (
Backend catalog unavailable
Using fallback model list. Configure /model-catalog endpoint for enhanced functionality.
) : null}
{/* Models table */}
Available Models
{(() => {
const isRemoteProvider = ['openai', 'claude', 'watsonx'].includes(provider);
if (installedLoading)
return 'Loadingβ¦';
if (provider === 'civitai') {
return `π§ͺ ${supportedForSelection.length} Recommended Models`;
}
if (isRemoteProvider) {
return `${supportedForSelection.length} API Models`;
}
return `${installed.length} Installed${supportedForSelection.length ? ` Β· ${supportedForSelection.length} Available` : ''}`;
})()}
{merged.length === 0 ? (
{provider === 'civitai' ? (
π§ͺ Civitai Download
Enter a Civitai version ID above to download models.
) : ('No models found. Try changing provider/base URL and refresh.')}
) : (merged.map((row) => {
const statusKind = row.status === 'installed'
? 'ok'
: row.status === 'missing'
? 'warn'
: 'muted';
const statusLabel = row.status === 'installed'
? 'Installed'
: row.status === 'missing'
? 'Available'
: 'Custom';
// Only allow downloads for local providers (Ollama, ComfyUI, Civitai)
// Remote API providers (OpenAI, Claude, Watsonx, openai_compat) don't support local installation
const isLocalProvider = provider === 'ollama' || provider === 'comfyui' || provider === 'civitai';
const canDownload = row.status === 'missing' && isLocalProvider;
const canDelete = (row.status === 'installed' || row.status === 'installed_unsupported') && isLocalProvider && !row.protected;
const isCivitai = provider === 'civitai';
return (
{statusLabel}
{row.recommended ? (
β Recommended
) : null}
{row.recommended_nsfw && props.nsfwMode ? (
π₯ NSFW Pick
) : null}
{row.nsfw ? (
πΆοΈ Adult
) : null}
{isCivitai && row.civitai_version_id ? (
v{row.civitai_version_id}
) : null}
{row.protected && (row.status === 'installed' || row.status === 'installed_unsupported') ? (
π Default
) : null}
{row.label}
{row.id}
{row.description ? (
{row.description}
) : null}
{/* Pack metadata - shows file count, required nodes, and hints */}
{row.install?.files && row.install.files.length > 1 ? (
Pack: {row.install.files.length} files
) : null}
{row.install?.requires_custom_nodes && row.install.requires_custom_nodes.length > 0 ? (
Requires:{" "}
{row.install.requires_custom_nodes.join(", ")}
) : null}
{row.install?.hint ? (
{row.install.hint}
) : null}
{isCivitai && row.civitai_url ? (
View on Civitai β
) : null}
{
navigator.clipboard?.writeText(row.id).catch(() => { });
setToast('Model ID copied to clipboard');
}} title="Copy model ID">
Copy
{canDownload ? (
{
// For Civitai, use the version ID if available
if (isCivitai && row.civitai_version_id) {
setCivitaiVersionId(row.civitai_version_id);
void tryInstall(row.civitai_version_id, row.install);
}
else {
void tryInstall(row.id, row.install);
}
}} title={installBusy === row.id ? "Downloading model... Please wait" : isCivitai ? "Download from Civitai" : "Download and install model"}>
{installBusy === row.id && ( )}
{installBusy === row.id ? (<>
Downloading...
>) : (<>
Install
>)}
) : null}
{canDelete ? (deleteConfirm === row.id ? (
void tryDelete(row.id)}>
{deleteBusy === row.id ? (<>
Deleting...
>) : (Confirm )}
setDeleteConfirm(null)}>
Cancel
) : (
setDeleteConfirm(row.id)} title="Delete this model from disk">
Delete
)) : null}
);
}))}
{/* Avatar & Identity Section (Add-ons tab only) */}
{modelType === 'addons' && (
Avatar & Identity Models
Persona
{avatarModelsLoading ? (
Loading...
) : (
{avatarModels.filter(m => m.installed).length} / {avatarModels.length} Installed
)}
refreshAvatarModels()} disabled={avatarModelsLoading} className="p-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 transition-all disabled:opacity-50" title="Refresh avatar models">
{avatarModelsError ? (
Could not load avatar models: {avatarModelsError}
) : (<>
{/* Feature readiness dashboard */}
{Object.keys(avatarFeatures).length > 0 && (
Feature Readiness
{Object.entries(avatarFeatures).map(([featId, feat]) => (
{feat.ready ? (
) : (
)}
{feat.label}
{feat.description}
{!feat.ready && feat.required_missing.length > 0 && (
Missing: {feat.required_missing.join(', ')}
)}
{feat.ready && !feat.recommended_installed && feat.recommended_note && (
Tip: {feat.recommended_note}
)}
))}
Existing personas and avatars work without any of these models. These enable optional enhanced features only.
)}
{/* Quick-install preset buttons */}
Quick Install:
downloadAvatarPreset('basic')} disabled={avatarDownloadBusy !== null} className={[
"px-5 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wide flex items-center gap-2.5 transition-all shadow-lg relative overflow-hidden",
avatarDownloadBusy === 'basic'
? "bg-purple-700 text-white cursor-wait shadow-purple-700/30"
: "bg-purple-600 text-white hover:bg-purple-500 shadow-purple-600/20 hover:shadow-purple-600/30 hover:scale-105 active:scale-95"
].join(" ")}>
{avatarDownloadBusy === 'basic' ? (<>
Downloading...
>) : (<>
Basic Pack (~4.3 GB)
>)}
downloadAvatarPreset('full')} disabled={avatarDownloadBusy !== null} className={[
"px-5 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wide flex items-center gap-2.5 transition-all shadow-lg relative overflow-hidden",
avatarDownloadBusy === 'full'
? "bg-purple-700 text-white cursor-wait shadow-purple-700/30"
: "bg-white/5 text-white/70 hover:bg-white/10 border border-white/10 hover:border-purple-500/30 hover:text-white shadow-none hover:shadow-purple-600/10 hover:scale-105 active:scale-95"
].join(" ")}>
{avatarDownloadBusy === 'full' ? (<>
Downloading...
>) : (<>
Full Pack (~9.5 GB)
>)}
Basic = InsightFace + InstantID (portraits + outfits, ~4.3 GB). Full = all 9 models (~9.5 GB, + face swap, random faces).
{/* Download progress panel (visible during active download) */}
{avatarDownloadStatus && (avatarDownloadStatus.running || avatarDownloadStatus.finished) && (
{avatarDownloadStatus.running && (
)}
{avatarDownloadStatus.finished && (
)}
{avatarDownloadStatus.finished
? `Download Complete β ${avatarDownloadStatus.installed_count}/${avatarDownloadStatus.total_models} installed`
: `Downloading ${avatarDownloadStatus.preset?.toUpperCase()} preset β ${avatarDownloadStatus.current_index}/${avatarDownloadStatus.total_models}`}
{Math.round(avatarDownloadStatus.elapsed)}s
{avatarDownloadStatus.downloaded_bytes > 0 && (<> | {formatBytes(avatarDownloadStatus.downloaded_bytes)}>)}
{/* Progress bar */}
{avatarDownloadStatus.total_models > 0 && (
)}
{/* Per-model status rows */}
{(avatarDownloadStatus.results || []).map((r) => (
{r.status === 'installed' ? ( ) : r.status === 'already_installed' ? ( ) : ( )}
{r.name || r.id}
{r.status === 'installed' && r.size ? formatBytes(r.size) : ''}
{r.status === 'installed' && r.elapsed ? ` (${r.elapsed}s)` : ''}
{r.status === 'already_installed' ? 'already installed' : ''}
{r.status === 'failed' ? 'failed' : ''}
{r.status === 'timeout' ? 'timeout' : ''}
{r.status === 'error' ? r.error || 'error' : ''}
))}
{/* Currently downloading indicator */}
{avatarDownloadStatus.running && avatarDownloadStatus.current_model && (
{avatarModels.find(m => m.id === avatarDownloadStatus.current_model)?.name || avatarDownloadStatus.current_model}
downloading...
)}
{/* Dismiss when finished */}
{avatarDownloadStatus.finished && (
setAvatarDownloadStatus(null)} className="mt-3 text-[10px] text-white/30 hover:text-white/50 transition-colors">
Dismiss
)}
)}
{/* Model list */}
{avatarModels.map((m) => {
const statusKind = m.installed ? 'ok' : 'warn';
const statusLabel = m.installed ? 'Installed' : 'Not Installed';
// Basic pack models are tagged as "Recommended" β minimum needed for Avatar to work
const BASIC_PACK_IDS = new Set(['insightface-antelopev2', 'instantid-ip-adapter', 'instantid-controlnet']);
const isRecommended = BASIC_PACK_IDS.has(m.id);
// Feature labels from used_by
const FEATURE_LABELS = {
photo_variations: { label: 'Portraits', color: 'purple' },
outfit_generation: { label: 'Outfits', color: 'indigo' },
face_swap: { label: 'Face Swap', color: 'cyan' },
random_faces: { label: 'Random Faces', color: 'pink' },
};
return (
{statusLabel}
{m.is_default && (
Core
)}
{isRecommended && (
Recommended
)}
{m.license && (
{m.license}
)}
{m.commercial_use_ok === true && (
Commercial OK
)}
{m.commercial_use_ok === false && (
Non-Commercial
)}
{m.name}
{m.id}
{m.description && (
{m.description}
)}
{m.requires && m.requires.length > 0 && (
Requires: {m.requires.join(', ')}
)}
{/* Feature tags: which features this model enables */}
{m.used_by && m.used_by.length > 0 && (
Enables:
{m.used_by.map((ub) => {
const fl = FEATURE_LABELS[ub.feature];
if (!fl)
return null;
const isRequired = ub.role === 'required';
return (
{fl.label}{!isRequired ? ' (opt)' : ''}
);
})}
)}
{m.homepage && (
Info
)}
{/* Install button β only for not-installed models with a download URL */}
{!m.installed && m.download_url && (
installSingleAvatarModel(m.id)} title={`Download and install ${m.name}`}>
{avatarInstallBusy === m.id ? (<>
Installing...
>) : (<>
Install
>)}
)}
{/* Uninstall button β only for installed models */}
{m.installed && (avatarDeleteConfirm === m.id ? (
deleteAvatarModel(m.id)}>
{avatarDeleteBusy === m.id ? (<>
Deleting...
>) : (Confirm )}
setAvatarDeleteConfirm(null)}>
Cancel
) : (
setAvatarDeleteConfirm(m.id)} title="Uninstall this model to free disk space">
Uninstall
))}
);
})}
{avatarModels.length === 0 && !avatarModelsLoading && (
No avatar models registered. Check backend configuration.
)}
>)}
)}
{/* LoRA Models Section (LoRA tab only β additive, Golden Rule 1.0) */}
{modelType === 'lora' && (
LoRA Models
Lightweight Adapters
{loraLoading ? (
Loading...
) : (
{loraInstalled.length} Installed / {loraRegistry.length} Available
)}
refreshLoraModels()} disabled={loraLoading} className="p-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 transition-all disabled:opacity-50" title="Refresh LoRA models">
{loraError ? (
Could not load LoRA models: {loraError}
) : (<>
{/* Info banner */}
LoRA (Low-Rank Adaptation) models are lightweight adapters that modify checkpoint behavior without replacing the base model.
Perfect for GPUs with less than 12 GB VRAM. Click Install to add a LoRA to your models/comfy/loras/ directory.
{!props.nsfwMode && (
Some models are hidden. Enable Spice Mode in Settings to see all available LoRAs.
)}
{/* Install progress (mirrors Add-ons progress bar) */}
{loraDownloadStatus && (loraDownloadStatus.running || loraDownloadStatus.finished) && (
{loraDownloadStatus.running && (
)}
{loraDownloadStatus.finished &&
}
{loraDownloadStatus.finished
? `Install Complete β ${loraDownloadStatus.installed_count}/${loraDownloadStatus.total_models} installed`
: `Installing LoRA β ${loraDownloadStatus.current_index}/${loraDownloadStatus.total_models}`}
{Math.round(loraDownloadStatus.elapsed)}s
{loraDownloadStatus.downloaded_bytes > 0 && (<> | {formatBytes(loraDownloadStatus.downloaded_bytes)}>)}
{loraDownloadStatus.total_models > 0 && (
)}
{loraDownloadStatus.running && loraDownloadStatus.current_model && (
Installing: {loraRegistry.find((l) => l.id === loraDownloadStatus.current_model)?.name || loraDownloadStatus.current_model}
)}
{loraDownloadStatus.finished && (
setLoraDownloadStatus(null)} className="mt-3 text-[10px] text-white/30 hover:text-white/50 transition-colors">
Dismiss
)}
)}
{/* Installed LoRAs */}
{loraInstalled.length > 0 && (
Installed LoRAs
{loraInstalled.map((l) => (
{l.id}
))}
)}
{/* Registry LoRAs */}
{loraRegistry.map((lora) => {
const isInstalled = loraInstalled.some((i) => i.id === lora.id);
return (
{isInstalled ? (<> Installed>) : (<> Available>)}
{lora.base}
{lora.recommended ? (
β Recommended
) : null}
{lora.recommended_nsfw && props.nsfwMode ? (
π₯ NSFW Pick
) : null}
{lora.gated ? (
πΆοΈ Adult
) : null}
{lora.size_mb > 0 && (
{lora.size_mb} MB
)}
{lora.name}
{lora.filename}
{lora.description && (
{lora.description}
)}
{lora.trigger_words && lora.trigger_words.length > 0 && (
Trigger words:
{lora.trigger_words.map((tw) => (
{tw}
))}
)}
{lora.model_url && (
View
)}
{/* Install button β not installed, has download URL */}
{!isInstalled && lora.download_url && (
installLora(lora.id)} title={`Install ${lora.name}`}>
{loraInstallBusy === lora.id ? (<>
Installing...
>) : (<>
Install
>)}
)}
{/* Delete button β installed models */}
{isInstalled && (loraDeleteConfirm === lora.id ? (
deleteLora(lora.id)}>
{loraDeleteBusy === lora.id ? (<>
Deleting...
>) : (Confirm )}
setLoraDeleteConfirm(null)}>
Cancel
) : (
setLoraDeleteConfirm(lora.id)} title="Delete this LoRA to free disk space">
Delete
))}
);
})}
{loraRegistry.length === 0 && !loraLoading && (
No LoRA models available. Check backend configuration.
)}
>)}
)}
{/* Toast notification */}
{toast ? (
{toast.includes('Successfully') || toast.includes('installed') ? (
) : toast.includes('failed') || toast.includes('error') || toast.includes('Error') ? (
) : (
)}
{toast}
{installBusy && (
Large models may take several minutes...
)}
) : null}
);
}