import React, { useEffect, useMemo, useState, useCallback } from 'react' import { Download, RefreshCw, Copy, CheckCircle2, AlertTriangle, XCircle, Settings2, Key, X, Trash2, Shield, ExternalLink } from 'lucide-react' // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- type Provider = { name: string label: string kind?: 'chat' | 'image' | 'video' | 'edit' | 'multi' } type ModelCatalogEntry = { id: string label?: string recommended?: boolean recommended_nsfw?: boolean protected?: boolean nsfw?: boolean description?: string size_gb?: number resolution?: string frames?: number // Civitai-specific fields civitai_url?: string civitai_version_id?: string // Addon dependencies (for video models) requires_addons?: string[] recommends_addons?: string[] // Addon-specific fields provides_nodes?: string[] // Optional install metadata (backend-supported) install?: { type: 'ollama_pull' | 'http_files' | 'hf_files' | 'hf_snapshot' | 'script' | 'git_repo' hint?: string requires_custom_nodes?: string[] repo_url?: string files?: Array<{ url?: string repo_id?: string filename?: string dest: string sha256?: string }> repo_id?: string dest_dir?: string } } type ModelCatalogResponse = { providers?: Record> // providers[providerName][modelType] = list } type ModelsListResponse = { provider: string model_type?: string base_url?: string models: string[] error?: string | null } type InstallRequest = { provider: string model_type: string model_id: string base_url?: string options?: Record } type InstallResponse = { ok?: boolean job_id?: string message?: string } export type ModelsParams = { backendUrl: string apiKey?: string // Defaults from Enterprise Settings providerChat?: string providerImages?: string providerVideo?: string baseUrlChat?: string baseUrlImages?: string baseUrlVideo?: string // Experimental features experimentalCivitai?: boolean civitaiApiKey?: string // Optional API key for Civitai NSFW content // NSFW/Spice Mode - shows additional adult content models nsfwMode?: boolean } // Avatar model types (from /v1/avatar-models endpoint) type AvatarModelUsedBy = { feature: string role: 'required' | 'recommended' } type AvatarModelInfo = { id: string name: string description: string filename: string subdir: string installed: boolean license: string | null commercial_use_ok: boolean | null homepage: string | null download_url: string | null sha256: string | null requires: string[] | null is_default: boolean used_by: AvatarModelUsedBy[] } type AvatarFeatureStatus = { label: string description: string ready: boolean required_missing: string[] recommended_installed: boolean recommended_note: string | null } type AvatarModelsResponse = { category: string installed: string[] available: AvatarModelInfo[] defaults: string[] features: Record } type AvatarDownloadResult = { id: string name?: string status: 'installed' | 'already_installed' | 'failed' | 'timeout' | 'error' | 'not_registered' | 'no_download_url' error?: string size?: number elapsed?: number } type AvatarDownloadStartResponse = { ok: boolean message?: string error?: string preset?: string total_models?: number model_ids?: string[] } type AvatarDownloadStatus = { running: boolean preset: string | null current_model: string | null current_index: number total_models: number results: AvatarDownloadResult[] finished: boolean error: string | null elapsed: number installed_count: number failed_count: number downloaded_bytes: number } // Civitai search result types type CivitaiSearchVersion = { id: string name: string downloadUrl?: string sizeKB: number trainedWords: string[] } type CivitaiSearchResult = { id: string name: string type: string creator: string downloads: number rating: number ratingCount: number link: string | null thumbnail: string | null nsfw: boolean description: string tags: string[] versions: CivitaiSearchVersion[] } type CivitaiSearchResponse = { ok: boolean query: string model_type: string nsfw: boolean items: CivitaiSearchResult[] metadata: { currentPage: number pageSize: number totalItems: number totalPages: number } } // ----------------------------------------------------------------------------- // Fallback Model Catalogs (when backend /model-catalog is not available) // ----------------------------------------------------------------------------- const FALLBACK_CATALOGS: Record> = { 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: string) { return (url || '').trim().replace(/\/+$/, '') } async function getJson(baseUrl: string, path: string, apiKey?: string): Promise { 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()) as T } async function postJson(baseUrl: string, path: string, body: any, apiKey?: string): Promise { 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()) as T } function chipClass(kind: 'ok' | 'warn' | 'bad' | 'muted') { 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 }: { kind: 'ok' | 'warn' | 'bad' | 'muted' }) { if (kind === 'ok') return if (kind === 'warn') return if (kind === 'bad') return return } function safeLabel(id: string, label?: string) { return label?.trim() ? label.trim() : id } function formatBytes(bytes: number): string { 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: ModelsParams) { const authKey = (props.apiKey || '').trim() const backendUrl = cleanBase(props.backendUrl) const [providers, setProviders] = useState([]) const [providersError, setProvidersError] = useState(null) const [modelType, setModelType] = useState<'chat' | 'multimodal' | 'image' | 'video' | 'edit' | 'enhance' | 'addons' | 'lora'>('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: string; civitai: string }>({ 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 as any, }) } 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<{ ok: boolean; providers: Record }>(backendUrl, '/providers', authKey) if (!mounted) return const providerList: Provider[] = Object.entries(res.providers || {}).map(([name, info]: [string, any]) => ({ name, label: info.label || name, kind: 'multi', })) setProviders(providerList) } catch (e: any) { 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: any) { // 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: any) { 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: ModelCatalogEntry[] = useMemo(() => { let list: ModelCatalogEntry[] = [] // 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: string): boolean => { 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: Array<{ id: string label: string recommended?: boolean recommended_nsfw?: boolean protected?: boolean nsfw?: boolean description?: string civitai_url?: string civitai_version_id?: string status: 'installed' | 'missing' | 'installed_unsupported' install?: ModelCatalogEntry['install'] }> = [] // 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 as Record 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: string) => /\.(safetensors|ckpt|pth|onnx|gguf|pt|bin)$/i.test(id) const isLlmModel = (id: string) => 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: string): boolean => { 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: string) => 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: string, install?: ModelCatalogEntry['install']) => { setInstallBusy(modelId) try { const body: InstallRequest = { 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 as any).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: any) { // 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: string) => { setDeleteBusy(modelId) setDeleteConfirm(null) try { const body = { provider, model_type: modelType, model_id: modelId, } setToast(`Deleting ${modelId}...`) const res = await postJson<{ ok?: boolean; message?: string }>(backendUrl, '/models/delete', body, authKey) if (res?.ok) { setToast(res.message || `Deleted ${modelId}`) setTimeout(() => refreshInstalled(), 500) } else { setToast(res?.message || 'Delete failed.') } } catch (e: any) { 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: Record = { '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: CivitaiSearchResponse = 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: any) { 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: CivitaiSearchResult, versionId: string) => { 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: any) { setToast(`Installation failed: ${e?.message || String(e)}`) } finally { setInstallBusy(null) } } // API Keys management functions const loadApiKeysStatus = async () => { try { const data = await getJson<{ ok: boolean; keys: Record }>(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: 'huggingface' | 'civitai') => { 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<{ ok: boolean; message?: string }>( 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: any) { setToast(`Error saving API key: ${e?.message || String(e)}`) } finally { setApiKeySaving(null) } } const testApiKey = async (provider: 'huggingface' | 'civitai') => { setApiKeyTesting(provider) try { const keyToTest = apiKeyInput[provider].trim() || undefined const res = await postJson<{ ok: boolean; valid: boolean; message: string; username?: string }>( 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: any) { setToast(`Error testing API key: ${e?.message || String(e)}`) } finally { setApiKeyTesting(null) } } const deleteApiKey = async (provider: 'huggingface' | 'civitai') => { 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: any) { 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: any) { 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<{ ok: boolean; loras: any[] }>(backendUrl, `/v1/lora/registry?spicy=${props.nsfwMode ? 'true' : 'false'}`, authKey), getJson<{ ok: boolean; loras: any[] }>(backendUrl, '/v1/lora/installed', authKey), ]) setLoraRegistry(registryRes.loras || []) setLoraInstalled(installedRes.loras || []) } catch (e: any) { 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: string) => { setLoraInstallBusy(loraId) const entry = loraRegistry.find((l: any) => 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: any) { setToast(`Install error: ${e?.message || String(e)}`) } finally { setLoraInstallBusy(null) } } // LoRA delete β€” mirrors deleteAvatarModel pattern const deleteLora = async (loraId: string) => { 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: any) { 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: AvatarDownloadStatus = 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: 'basic' | 'full') => { 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: AvatarDownloadStartResponse = 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: any) { 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: AvatarDownloadStatus = 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: string) => { 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: any) { setToast(`Delete error: ${e?.message || String(e)}`) } finally { setAvatarDeleteBusy(null) } } const installSingleAvatarModel = async (modelId: string) => { 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: any) { 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
{/* API Keys Settings Button */}
{/* API Keys Modal */} {apiKeysExpanded && (
{/* Backdrop */}
setApiKeysExpanded(false)} /> {/* Modal */}
{/* Modal Header */}

API Keys

Optional - for gated HuggingFace and Civitai models

{/* 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}
{apiKeysStatus.huggingface.source !== 'environment' && ( )}
) : (
setApiKeyInput(prev => ({ ...prev, huggingface: e.target.value }))} placeholder="hf_xxxxxxxxxxxxxxxxxx" className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:border-white/30 transition-all placeholder:text-white/20" />
Get token from huggingface.co β†’
)}
{/* 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}
{apiKeysStatus.civitai.source !== 'environment' && ( )}
) : (
setApiKeyInput(prev => ({ ...prev, civitai: e.target.value }))} placeholder="xxxxxxxxxxxxxxxxxxxxxxxx" className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:border-white/30 transition-all placeholder:text-white/20" />
Get API key from civitai.com β†’
)}
{/* 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 */}
{(['chat', 'multimodal', 'image', 'edit', 'video', 'enhance', 'lora', 'addons'] as const).map((t) => ( ))}
{providersError ?
{providersError}
: null}
setBaseUrl(e.target.value)} placeholder={modelType === 'chat' ? 'http://localhost:11434' : 'http://localhost:8188'} 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 placeholder:text-white/30" />
Optional. Leave empty to use default configuration.
{/* Civitai Input Section (only when provider is civitai) */} {provider === 'civitai' && (
{/* Search Bar */}
setCivitaiQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && searchCivitai()} placeholder={`Search ${modelType === 'video' ? 'video' : 'image'} models on Civitai...`} className="flex-1 bg-white/5 border border-cyan-500/20 rounded-xl px-4 py-3 text-sm font-medium outline-none focus:border-cyan-500/40 focus:bg-white/10 transition-all placeholder:text-white/30" />
{civitaiSearchError && (
⚠️ {civitaiSearchError}
)}
{/* Search Results */} {civitaiResults.length > 0 && (
Search Results ({civitaiResults.length} models)
{civitaiTotalPages > 1 && (
Page {civitaiPage} of {civitaiTotalPages}
)}
{civitaiResults.map((model) => (
{/* Thumbnail */}
{model.thumbnail ? ( {model.name} ) : (
πŸ–ΌοΈ
)} {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.link && ( View on Civitai β†’ )}
))}
)} {/* Manual Version ID Input */}
setCivitaiVersionId(e.target.value)} placeholder="e.g., 128713 (from Civitai model URL)" className="w-full bg-white/5 border border-blue-500/20 rounded-xl px-4 py-3 text-sm font-medium outline-none focus:border-blue-500/40 focus:bg-white/10 transition-all placeholder:text-white/30" />
Enter a version ID from any Civitai model URL (e.g., civitai.com/models/128713)
{/* 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.
Find models at civitai.com
) : ( '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}
{canDownload ? ( ) : null} {canDelete ? ( deleteConfirm === row.id ? (
) : ( ) ) : 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
)}
{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: 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 && ( )}
)} {/* 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: Record = { 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 && ( )} {/* Uninstall button β€” only for installed models */} {m.installed && ( avatarDeleteConfirm === m.id ? (
) : ( ) )}
) })} {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
)}
{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: any) => l.id === loraDownloadStatus.current_model)?.name || loraDownloadStatus.current_model}
)} {loraDownloadStatus.finished && ( )}
)} {/* Installed LoRAs */} {loraInstalled.length > 0 && (
Installed LoRAs
{loraInstalled.map((l: any) => (
{l.id}
))}
)} {/* Registry LoRAs */}
{loraRegistry.map((lora: any) => { const isInstalled = loraInstalled.some((i: any) => 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: string) => ( {tw} ))}
)}
{lora.model_url && ( View )} {/* Install button β€” not installed, has download URL */} {!isInstalled && lora.download_url && ( )} {/* Delete button β€” installed models */} {isInstalled && ( loraDeleteConfirm === lora.id ? (
) : ( ) )}
) })} {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}
) }