| import React, { useEffect, useMemo, useState, useCallback } from 'react' |
| import { Download, RefreshCw, Copy, CheckCircle2, AlertTriangle, XCircle, Settings2, Key, X, Trash2, Shield, ExternalLink } from 'lucide-react' |
|
|
| |
| |
| |
|
|
| 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_url?: string |
| civitai_version_id?: string |
| |
| requires_addons?: string[] |
| recommends_addons?: string[] |
| |
| provides_nodes?: string[] |
| |
| 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<string, Record<string, ModelCatalogEntry[]>> |
| } |
|
|
| 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<string, any> |
| } |
|
|
| type InstallResponse = { |
| ok?: boolean |
| job_id?: string |
| message?: string |
| } |
|
|
| export type ModelsParams = { |
| backendUrl: string |
| apiKey?: string |
|
|
| |
| providerChat?: string |
| providerImages?: string |
| providerVideo?: string |
|
|
| baseUrlChat?: string |
| baseUrlImages?: string |
| baseUrlVideo?: string |
|
|
| |
| experimentalCivitai?: boolean |
| civitaiApiKey?: string |
|
|
| |
| nsfwMode?: boolean |
| } |
|
|
| |
| 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<string, AvatarFeatureStatus> |
| } |
|
|
| 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 |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| |
| |
|
|
| const FALLBACK_CATALOGS: Record<string, Record<string, ModelCatalogEntry[]>> = { |
| ollama: { |
| 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)' }, |
| |
| { 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 }, |
| |
| { 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 }, |
| |
| { 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 }, |
| |
| { 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 }, |
| |
| { 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 }, |
| |
| { id: 'huihui_ai/qwen3-vl-abliterated:8b-instruct', label: 'Qwen3 Vision Abliterated (8B)', nsfw: true }, |
| |
| { 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 }, |
| |
| { 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: [ |
| |
| { 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.' }, |
| |
| { 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: [ |
| |
| { 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 }, |
| |
| { 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: [ |
| |
| { |
| 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' |
| } |
| }, |
| |
| { |
| 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' |
| } |
| }, |
| |
| { |
| 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: [ |
| |
| { |
| 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: [ |
| |
| { |
| 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' |
| }, |
| ], |
| }, |
| } |
|
|
| |
| |
| |
|
|
| function cleanBase(url: string) { |
| return (url || '').trim().replace(/\/+$/, '') |
| } |
|
|
| async function getJson<T>(baseUrl: string, path: string, apiKey?: string): Promise<T> { |
| 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<T>(baseUrl: string, path: string, body: any, apiKey?: string): Promise<T> { |
| 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 <CheckCircle2 size={14} className="text-emerald-300" /> |
| if (kind === 'warn') return <AlertTriangle size={14} className="text-amber-300" /> |
| if (kind === 'bad') return <XCircle size={14} className="text-rose-300" /> |
| return <Settings2 size={14} className="text-white/50" /> |
| } |
|
|
| 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` |
| } |
|
|
| |
| |
| |
|
|
| export default function ModelsView(props: ModelsParams) { |
| const authKey = (props.apiKey || '').trim() |
| const backendUrl = cleanBase(props.backendUrl) |
|
|
| const [providers, setProviders] = useState<Provider[]>([]) |
| const [providersError, setProvidersError] = useState<string | null>(null) |
|
|
| const [modelType, setModelType] = useState<'chat' | 'multimodal' | 'image' | 'video' | 'edit' | 'enhance' | 'addons' | 'lora'>('chat') |
| const [provider, setProvider] = useState<string>(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<string>(defaultBaseUrl) |
|
|
| |
| const [installed, setInstalled] = useState<string[]>([]) |
| const [installedError, setInstalledError] = useState<string | null>(null) |
| const [installedLoading, setInstalledLoading] = useState(false) |
|
|
| |
| const [catalog, setCatalog] = useState<ModelCatalogResponse | null>(null) |
| const [catalogError, setCatalogError] = useState<string | null>(null) |
| const [catalogLoading, setCatalogLoading] = useState(false) |
|
|
| |
| const [installBusy, setInstallBusy] = useState<string | null>(null) |
| const [deleteBusy, setDeleteBusy] = useState<string | null>(null) |
| const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null) |
| const [toast, setToast] = useState<string | null>(null) |
|
|
| |
| const [civitaiVersionId, setCivitaiVersionId] = useState<string>('') |
|
|
| |
| const [civitaiQuery, setCivitaiQuery] = useState<string>('') |
| const [civitaiResults, setCivitaiResults] = useState<CivitaiSearchResult[]>([]) |
| const [civitaiSearchLoading, setCivitaiSearchLoading] = useState(false) |
| const [civitaiSearchError, setCivitaiSearchError] = useState<string | null>(null) |
| const [civitaiPage, setCivitaiPage] = useState(1) |
| const [civitaiTotalPages, setCivitaiTotalPages] = useState(1) |
|
|
| |
| const [apiKeysExpanded, setApiKeysExpanded] = useState(false) |
| const [apiKeysStatus, setApiKeysStatus] = useState<Record<string, { configured: boolean; source: string; masked: string | null }>>({}) |
| const [apiKeyInput, setApiKeyInput] = useState<{ huggingface: string; civitai: string }>({ huggingface: '', civitai: '' }) |
| const [apiKeyTesting, setApiKeyTesting] = useState<string | null>(null) |
| const [apiKeySaving, setApiKeySaving] = useState<string | null>(null) |
|
|
| |
| const [avatarModels, setAvatarModels] = useState<AvatarModelInfo[]>([]) |
| const [avatarFeatures, setAvatarFeatures] = useState<Record<string, AvatarFeatureStatus>>({}) |
| const [avatarModelsLoading, setAvatarModelsLoading] = useState(false) |
| const [avatarModelsError, setAvatarModelsError] = useState<string | null>(null) |
| const [avatarDownloadBusy, setAvatarDownloadBusy] = useState<string | null>(null) |
| const [avatarDownloadStatus, setAvatarDownloadStatus] = useState<AvatarDownloadStatus | null>(null) |
| const [avatarDeleteConfirm, setAvatarDeleteConfirm] = useState<string | null>(null) |
| const [avatarDeleteBusy, setAvatarDeleteBusy] = useState<string | null>(null) |
| const [avatarInstallBusy, setAvatarInstallBusy] = useState<string | null>(null) |
|
|
| |
| const [loraRegistry, setLoraRegistry] = useState<any[]>([]) |
| const [loraInstalled, setLoraInstalled] = useState<any[]>([]) |
| const [loraLoading, setLoraLoading] = useState(false) |
| const [loraError, setLoraError] = useState<string | null>(null) |
| const [loraInstallBusy, setLoraInstallBusy] = useState<string | null>(null) |
| const [loraDownloadBusy, setLoraDownloadBusy] = useState<string | null>(null) |
| const [loraDownloadStatus, setLoraDownloadStatus] = useState<AvatarDownloadStatus | null>(null) |
| const [loraDeleteConfirm, setLoraDeleteConfirm] = useState<string | null>(null) |
| const [loraDeleteBusy, setLoraDeleteBusy] = useState<string | null>(null) |
|
|
| |
| const availableProviders = useMemo(() => { |
| if (modelType === 'chat') { |
| |
| return providers.filter(p => p.name !== 'comfyui' && p.name !== 'civitai') |
| } else if (modelType === 'multimodal') { |
| |
| return providers.filter(p => p.name === 'ollama') |
| } else { |
| |
| const filtered = providers.filter(p => p.name === 'comfyui') |
|
|
| |
| 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(() => { |
| |
| if (modelType === 'chat') { |
| |
| const newProvider = props.providerChat || 'ollama' |
| setProvider(newProvider) |
| setBaseUrl(props.baseUrlChat || '') |
| } else if (modelType === 'multimodal') { |
| |
| setProvider('ollama') |
| setBaseUrl(props.baseUrlChat || '') |
| } else if (modelType === 'image') { |
| |
| setProvider('comfyui') |
| setBaseUrl(props.baseUrlImages || '') |
| } else if (modelType === 'addons') { |
| |
| setProvider('comfyui') |
| setBaseUrl(props.baseUrlImages || '') |
| } else if (modelType === 'lora') { |
| |
| setProvider('comfyui') |
| setBaseUrl(props.baseUrlImages || '') |
| } else { |
| |
| setProvider('comfyui') |
| setBaseUrl(props.baseUrlVideo || '') |
| } |
| |
| }, [modelType]) |
|
|
| |
| useEffect(() => { |
| let mounted = true |
| setProvidersError(null) |
|
|
| ;(async () => { |
| try { |
| const res = await getJson<{ ok: boolean; providers: Record<string, any> }>(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]) |
|
|
| |
| const refreshCatalog = async () => { |
| setCatalogLoading(true) |
| setCatalogError(null) |
| try { |
| const data = await getJson<ModelCatalogResponse>(backendUrl, '/model-catalog', authKey) |
| setCatalog(data) |
| } catch (e: any) { |
| |
| setCatalog(null) |
| setCatalogError(e?.message || String(e)) |
| } finally { |
| setCatalogLoading(false) |
| } |
| } |
|
|
| |
| const refreshInstalled = async () => { |
| |
| |
| |
| const skipProviders = ['openai', 'claude', 'watsonx', 'civitai'] |
|
|
| if (skipProviders.includes(provider)) { |
| setInstalled([]) |
| setInstalledError(null) |
| setInstalledLoading(false) |
| return |
| } |
|
|
| setInstalledLoading(true) |
| setInstalledError(null) |
| setInstalled([]) |
| try { |
| const q = new URLSearchParams() |
| q.set('provider', provider) |
| if (baseUrl.trim()) q.set('base_url', baseUrl.trim()) |
| |
| if (modelType && provider === 'comfyui') q.set('model_type', modelType) |
| |
| if (modelType === 'multimodal' && provider === 'ollama') q.set('model_type', 'multimodal') |
|
|
| const data = await getJson<ModelsListResponse>(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() |
| |
| if (catalog === null && catalogError === null && !catalogLoading) { |
| refreshCatalog() |
| } |
| |
| }, [provider, modelType, baseUrl, backendUrl]) |
|
|
| const supportedForSelection: ModelCatalogEntry[] = useMemo(() => { |
| let list: ModelCatalogEntry[] = [] |
|
|
| |
| const p = catalog?.providers?.[provider] |
| if (p) { |
| const catalogList = p?.[modelType] || [] |
| if (Array.isArray(catalogList) && catalogList.length > 0) { |
| list = catalogList |
| } |
| } |
|
|
| |
| if (list.length === 0) { |
| const fallback = FALLBACK_CATALOGS[provider]?.[modelType] |
| list = Array.isArray(fallback) ? fallback : [] |
| } |
|
|
| |
| |
| |
| if (!props.nsfwMode) { |
| list = list.filter((m) => m.nsfw !== true) |
| } |
|
|
| return list |
| }, [catalog, provider, modelType, props.nsfwMode]) |
|
|
| const installedSet = useMemo(() => new Set(installed), [installed]) |
|
|
| |
| const isInstalled = useCallback((id: string): boolean => { |
| if (installedSet.has(id)) return true |
| |
| if (!id.includes(':') && installedSet.has(`${id}:latest`)) return true |
| |
| return false |
| }, [installedSet]) |
|
|
| |
| const merged = useMemo(() => { |
| const supportedMap = new Map<string, ModelCatalogEntry>() |
| 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'] |
| }> = [] |
|
|
| |
| 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, |
| }) |
| } |
|
|
| |
| |
| |
| |
| const otherTypeIds = new Set<string>() |
| if (provider === 'comfyui' && catalog?.providers?.comfyui) { |
| const comfyui = catalog.providers.comfyui as Record<string, ModelCatalogEntry[]> |
| 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) |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| const matchesSupportedEntry = (modelName: string): boolean => { |
| if (supportedMap.has(modelName)) return true |
| |
| 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)) { |
| |
| if (otherTypeIds.has(m)) continue |
| |
| if (modelType === 'chat' && isCheckpointFile(m)) continue |
| |
| if (modelType !== 'chat' && provider === 'comfyui' && isLlmModel(m)) continue |
| rows.push({ |
| id: m, |
| label: m, |
| status: 'installed_unsupported', |
| }) |
| } |
| } |
|
|
| |
| rows.sort((a, b) => { |
| |
| 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: {}, |
| } |
|
|
| |
| 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<InstallResponse>(backendUrl, '/models/install', body, authKey) |
|
|
| if (res?.ok) { |
| setToast(res.message || `Successfully installed ${modelId}`) |
| |
| setTimeout(() => { |
| refreshInstalled() |
| }, 2000) |
| } else { |
| setToast(res?.message || 'Installation request sent.') |
| } |
| } catch (e: any) { |
| |
| 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) |
| } |
| } |
|
|
| |
| const searchCivitai = async (page = 1) => { |
| if (!civitaiQuery.trim()) { |
| setToast('Please enter a search query') |
| return |
| } |
|
|
| setCivitaiSearchLoading(true) |
| setCivitaiSearchError(null) |
|
|
| try { |
| const headers: Record<string, string> = { |
| 'Content-Type': 'application/json', |
| ...(authKey ? { 'x-api-key': authKey } : {}), |
| } |
|
|
| |
| 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(), |
| |
| 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) |
| } |
| } |
|
|
| |
| const installFromCivitaiResult = async (model: CivitaiSearchResult, versionId: string) => { |
| setInstallBusy(model.id) |
|
|
| try { |
| const body = { |
| provider: 'civitai', |
| model_type: modelType === 'edit' ? 'image' : modelType, |
| 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<InstallResponse>(backendUrl, '/models/install', body, authKey) |
|
|
| if (res?.ok) { |
| setToast(res.message || `Successfully installed ${model.name}`) |
| |
| setTimeout(() => { |
| refreshInstalled() |
| }, 2000) |
| } else { |
| setToast(res?.message || 'Installation request sent.') |
| } |
| } catch (e: any) { |
| setToast(`Installation failed: ${e?.message || String(e)}`) |
| } finally { |
| setInstallBusy(null) |
| } |
| } |
|
|
| |
| const loadApiKeysStatus = async () => { |
| try { |
| const data = await getJson<{ ok: boolean; keys: Record<string, any> }>(backendUrl, '/settings/api-keys', authKey) |
| if (data.keys) { |
| setApiKeysStatus(data.keys) |
| } |
| } catch (e) { |
| |
| 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)}`) |
| } |
| } |
|
|
| |
| useEffect(() => { |
| if (apiKeysExpanded) { |
| loadApiKeysStatus() |
| } |
| |
| }, [apiKeysExpanded]) |
|
|
| |
| const refreshAvatarModels = useCallback(async () => { |
| setAvatarModelsLoading(true) |
| setAvatarModelsError(null) |
| try { |
| const data = await getJson<AvatarModelsResponse>(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]) |
|
|
| |
| 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 { |
| |
| |
| setLoraRegistry([]) |
| setLoraInstalled([]) |
| } |
| }, [modelType, refreshLoraModels]) |
|
|
| |
| 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) |
| } |
| } |
|
|
| |
| 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) |
| } |
| } |
|
|
| |
| 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 { |
| |
| } |
| } |
| } |
| poll() |
| return () => { cancelled = true } |
| |
| }, [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...') |
| |
| } catch (e: any) { |
| setToast(`Download error: ${e?.message || String(e)}`) |
| setAvatarDownloadBusy(null) |
| } |
| } |
|
|
| |
| 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) { |
| |
| 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) |
| |
| setTimeout(() => refreshAvatarModels(), 500) |
| break |
| } |
| } catch { |
| |
| } |
| } |
| } |
| poll() |
| return () => { cancelled = true } |
| |
| }, [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 |
| } |
| |
| setAvatarDownloadBusy(`single:${modelId}`) |
| } else { |
| setToast(`Install failed: ${data.error || 'unknown error'}`) |
| } |
| } catch (e: any) { |
| setToast(`Install error: ${e?.message || String(e)}`) |
| } finally { |
| setAvatarInstallBusy(null) |
| } |
| } |
|
|
| |
| useEffect(() => { |
| if (!toast) return |
| const t = setTimeout(() => setToast(null), 3500) |
| return () => clearTimeout(t) |
| }, [toast]) |
|
|
| return ( |
| <div className="h-full w-full bg-black text-white overflow-hidden flex flex-col"> |
| {/* Header */} |
| <div className="px-8 py-6 border-b border-white/10 flex items-center justify-between bg-gradient-to-b from-white/[0.02] to-transparent"> |
| <div> |
| <div className="text-2xl font-bold text-white tracking-tight">Model Management</div> |
| <div className="text-sm text-white/40 mt-1">Configure and deploy AI models across providers</div> |
| </div> |
| |
| <div className="flex items-center gap-3"> |
| <button |
| type="button" |
| onClick={() => 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" |
| > |
| <RefreshCw size={15} className={catalogLoading ? 'animate-spin' : ''} /> |
| Refresh Catalog |
| </button> |
| |
| <button |
| type="button" |
| onClick={() => 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" |
| > |
| <RefreshCw size={15} className={installedLoading ? 'animate-spin' : ''} /> |
| Refresh Installed |
| </button> |
| |
| {/* API Keys Settings Button */} |
| <button |
| type="button" |
| onClick={() => 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)" |
| > |
| <Key size={16} /> |
| </button> |
| </div> |
| </div> |
| |
| {/* API Keys Modal */} |
| {apiKeysExpanded && ( |
| <div className="fixed inset-0 z-50 flex items-center justify-center"> |
| {/* Backdrop */} |
| <div |
| className="absolute inset-0 bg-black/70 backdrop-blur-sm" |
| onClick={() => setApiKeysExpanded(false)} |
| /> |
| |
| {/* Modal */} |
| <div className="relative bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 overflow-hidden"> |
| {/* Modal Header */} |
| <div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]"> |
| <div className="flex items-center gap-3"> |
| <Key size={18} className="text-white/50" /> |
| <div> |
| <h2 className="text-lg font-bold text-white">API Keys</h2> |
| <p className="text-xs text-white/40">Optional - for gated HuggingFace and Civitai models</p> |
| </div> |
| </div> |
| <button |
| onClick={() => setApiKeysExpanded(false)} |
| className="p-2 rounded-lg hover:bg-white/5 text-white/40 hover:text-white/70 transition-all" |
| > |
| <X size={18} /> |
| </button> |
| </div> |
| |
| {/* Modal Content */} |
| <div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6"> |
| {/* HuggingFace Token */} |
| <div className="bg-white/5 border border-white/10 rounded-xl p-4"> |
| <div className="flex items-center justify-between mb-3"> |
| <div> |
| <div className="text-sm font-bold text-white">HuggingFace Token</div> |
| <div className="text-[10px] text-white/40 mt-0.5">For FLUX, SVD XT 1.1, gated models</div> |
| </div> |
| {apiKeysStatus.huggingface?.configured && ( |
| <span className={`px-2 py-1 rounded text-[10px] font-bold uppercase ${ |
| apiKeysStatus.huggingface.source === 'environment' |
| ? 'bg-blue-500/20 text-blue-300' |
| : 'bg-emerald-500/20 text-emerald-300' |
| }`}> |
| {apiKeysStatus.huggingface.source === 'environment' ? 'ENV' : 'Stored'} |
| </span> |
| )} |
| </div> |
| |
| {apiKeysStatus.huggingface?.configured ? ( |
| <div className="space-y-2"> |
| <div className="flex items-center gap-2 bg-white/5 rounded-lg px-3 py-2"> |
| <span className="text-emerald-400 text-sm">✓</span> |
| <span className="text-xs text-white/60 font-mono">{apiKeysStatus.huggingface.masked}</span> |
| </div> |
| <div className="flex gap-2"> |
| <button |
| onClick={() => 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'} |
| </button> |
| {apiKeysStatus.huggingface.source !== 'environment' && ( |
| <button |
| onClick={() => 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 |
| </button> |
| )} |
| </div> |
| </div> |
| ) : ( |
| <div className="space-y-2"> |
| <input |
| type="password" |
| value={apiKeyInput.huggingface} |
| onChange={(e) => 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" |
| /> |
| <div className="flex gap-2"> |
| <button |
| onClick={() => testApiKey('huggingface')} |
| disabled={!apiKeyInput.huggingface.trim() || 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'} |
| </button> |
| <button |
| onClick={() => saveApiKey('huggingface')} |
| disabled={!apiKeyInput.huggingface.trim() || apiKeySaving === 'huggingface'} |
| className="flex-1 px-3 py-2 text-xs font-semibold rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white transition-all disabled:opacity-50" |
| > |
| {apiKeySaving === 'huggingface' ? 'Saving...' : 'Save'} |
| </button> |
| </div> |
| <a |
| href="https://huggingface.co/settings/tokens" |
| target="_blank" |
| rel="noopener noreferrer" |
| className="text-[10px] text-blue-400 hover:text-blue-300" |
| > |
| Get token from huggingface.co → |
| </a> |
| </div> |
| )} |
| </div> |
| |
| {/* Civitai API Key */} |
| <div className="bg-white/5 border border-white/10 rounded-xl p-4"> |
| <div className="flex items-center justify-between mb-3"> |
| <div> |
| <div className="text-sm font-bold text-white">Civitai API Key</div> |
| <div className="text-[10px] text-white/40 mt-0.5">For NSFW and restricted downloads</div> |
| </div> |
| {apiKeysStatus.civitai?.configured && ( |
| <span className={`px-2 py-1 rounded text-[10px] font-bold uppercase ${ |
| apiKeysStatus.civitai.source === 'environment' |
| ? 'bg-blue-500/20 text-blue-300' |
| : 'bg-emerald-500/20 text-emerald-300' |
| }`}> |
| {apiKeysStatus.civitai.source === 'environment' ? 'ENV' : 'Stored'} |
| </span> |
| )} |
| </div> |
| |
| {apiKeysStatus.civitai?.configured ? ( |
| <div className="space-y-2"> |
| <div className="flex items-center gap-2 bg-white/5 rounded-lg px-3 py-2"> |
| <span className="text-emerald-400 text-sm">✓</span> |
| <span className="text-xs text-white/60 font-mono">{apiKeysStatus.civitai.masked}</span> |
| </div> |
| <div className="flex gap-2"> |
| <button |
| onClick={() => 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'} |
| </button> |
| {apiKeysStatus.civitai.source !== 'environment' && ( |
| <button |
| onClick={() => 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 |
| </button> |
| )} |
| </div> |
| </div> |
| ) : ( |
| <div className="space-y-2"> |
| <input |
| type="password" |
| value={apiKeyInput.civitai} |
| onChange={(e) => 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" |
| /> |
| <div className="flex gap-2"> |
| <button |
| onClick={() => testApiKey('civitai')} |
| disabled={!apiKeyInput.civitai.trim() || 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'} |
| </button> |
| <button |
| onClick={() => saveApiKey('civitai')} |
| disabled={!apiKeyInput.civitai.trim() || apiKeySaving === 'civitai'} |
| className="flex-1 px-3 py-2 text-xs font-semibold rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white transition-all disabled:opacity-50" |
| > |
| {apiKeySaving === 'civitai' ? 'Saving...' : 'Save'} |
| </button> |
| </div> |
| <a |
| href="https://civitai.com/user/account" |
| target="_blank" |
| rel="noopener noreferrer" |
| className="text-[10px] text-blue-400 hover:text-blue-300" |
| > |
| Get API key from civitai.com → |
| </a> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* Modal Footer */} |
| <div className="px-6 py-4 border-t border-white/10 bg-white/[0.02]"> |
| <p className="text-[11px] text-white/30"> |
| 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. |
| </p> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Controls */} |
| <div className="px-8 py-5 border-b border-white/10 bg-white/[0.01]"> |
| <div className="grid grid-cols-1 lg:grid-cols-[200px_1fr_1fr] gap-4 max-w-7xl"> |
| <div> |
| <label className="text-[10px] text-white/40 font-bold uppercase tracking-wider block mb-2.5">Model Type</label> |
| <div className="flex flex-col gap-1.5"> |
| {(['chat', 'multimodal', 'image', 'edit', 'video', 'enhance', 'lora', 'addons'] as const).map((t) => ( |
| <button |
| key={t} |
| type="button" |
| onClick={() => 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} |
| </button> |
| ))} |
| </div> |
| </div> |
| |
| <div> |
| <label className="text-[10px] text-white/40 font-bold uppercase tracking-wider block mb-2.5">Provider</label> |
| <select |
| value={provider} |
| onChange={(e) => 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) => ( |
| <option key={p.name} value={p.name}> |
| {p.label || p.name} |
| </option> |
| )) |
| ) : ( |
| // Fallback options when providers haven't loaded |
| modelType === 'chat' ? ( |
| <> |
| <option value="ollama">Ollama</option> |
| <option value="openai_compat">OpenAI-compatible (vLLM)</option> |
| <option value="openai">OpenAI</option> |
| <option value="claude">Claude</option> |
| <option value="watsonx">Watsonx</option> |
| </> |
| ) : ( |
| <option value="comfyui">ComfyUI</option> |
| ) |
| )} |
| </select> |
| {providersError ? <div className="mt-2 text-xs text-rose-400/80">{providersError}</div> : null} |
| </div> |
| |
| <div> |
| <label className="text-[10px] text-white/40 font-bold uppercase tracking-wider block mb-2.5">Base URL Override</label> |
| <input |
| value={baseUrl} |
| onChange={(e) => 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" |
| /> |
| <div className="mt-2 text-[10px] text-white/30 font-medium"> |
| Optional. Leave empty to use default configuration. |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| {} |
| {provider === 'civitai' && ( |
| <div className="px-8 py-4 border-b border-white/10 bg-gradient-to-br from-blue-500/5 to-blue-500/0"> |
| <div className="max-w-7xl"> |
| {/* Search Bar */} |
| <div className="mb-6"> |
| <label className="text-[10px] text-cyan-400 font-bold uppercase tracking-wider block mb-2.5"> |
| 🔍 Search Civitai Models |
| </label> |
| <div className="flex items-center gap-3"> |
| <input |
| value={civitaiQuery} |
| onChange={(e) => 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" |
| /> |
| <button |
| type="button" |
| onClick={() => searchCivitai()} |
| disabled={civitaiSearchLoading || !civitaiQuery.trim()} |
| className={[ |
| "px-6 py-3 rounded-xl text-white text-sm font-bold uppercase tracking-wide transition-all shadow-lg", |
| civitaiSearchLoading |
| ? "bg-cyan-700 cursor-wait shadow-cyan-700/30" |
| : !civitaiQuery.trim() |
| ? "bg-cyan-600/50 cursor-not-allowed shadow-cyan-600/10" |
| : "bg-cyan-600 hover:bg-cyan-500 shadow-cyan-600/20 hover:shadow-cyan-600/30 hover:scale-105 active:scale-95" |
| ].join(" ")} |
| > |
| {civitaiSearchLoading ? ( |
| <svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| ) : ( |
| 'Search' |
| )} |
| </button> |
| </div> |
| {civitaiSearchError && ( |
| <div className="mt-2 text-[10px] text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2"> |
| ⚠️ {civitaiSearchError} |
| </div> |
| )} |
| </div> |
| |
| {/* Search Results */} |
| {civitaiResults.length > 0 && ( |
| <div className="mb-6"> |
| <div className="flex items-center justify-between mb-3"> |
| <div className="text-[10px] text-cyan-400 font-bold uppercase tracking-wider"> |
| Search Results ({civitaiResults.length} models) |
| </div> |
| {civitaiTotalPages > 1 && ( |
| <div className="flex items-center gap-2"> |
| <button |
| onClick={() => 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 |
| </button> |
| <span className="text-xs text-white/50"> |
| Page {civitaiPage} of {civitaiTotalPages} |
| </span> |
| <button |
| onClick={() => 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 → |
| </button> |
| </div> |
| )} |
| </div> |
| |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-[400px] overflow-y-auto pr-2 scrollbar-hide"> |
| {civitaiResults.map((model) => ( |
| <div |
| key={model.id} |
| className="bg-white/5 border border-white/10 rounded-xl overflow-hidden hover:border-cyan-500/30 transition-all group" |
| > |
| {/* Thumbnail */} |
| <div className="relative h-32 bg-black/50"> |
| {model.thumbnail ? ( |
| <img |
| src={model.thumbnail} |
| alt={model.name} |
| className="w-full h-full object-cover" |
| loading="lazy" |
| /> |
| ) : ( |
| <div className="w-full h-full flex items-center justify-center text-white/20"> |
| <span className="text-4xl">🖼️</span> |
| </div> |
| )} |
| {model.nsfw && ( |
| <span className="absolute top-2 right-2 px-2 py-1 text-[9px] font-bold uppercase bg-red-600 text-white rounded"> |
| NSFW |
| </span> |
| )} |
| </div> |
| |
| {/* Info */} |
| <div className="p-3"> |
| <div className="font-semibold text-sm text-white truncate mb-1">{model.name}</div> |
| <div className="text-[10px] text-white/40 mb-2">by {model.creator}</div> |
| |
| <div className="flex items-center gap-3 text-[10px] text-white/50 mb-3"> |
| <span>⬇️ {model.downloads >= 1000 ? `${(model.downloads / 1000).toFixed(1)}K` : model.downloads}</span> |
| <span>⭐ {model.rating.toFixed(1)}</span> |
| <span>({model.ratingCount})</span> |
| </div> |
| |
| {model.tags.length > 0 && ( |
| <div className="flex flex-wrap gap-1 mb-3"> |
| {model.tags.slice(0, 3).map((tag) => ( |
| <span key={tag} className="px-2 py-0.5 text-[9px] bg-white/5 rounded text-white/40"> |
| {tag} |
| </span> |
| ))} |
| </div> |
| )} |
| |
| {/* Version selector and install */} |
| {model.versions.length > 0 && ( |
| <div className="flex items-center gap-2"> |
| <select |
| className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1.5 text-xs outline-none focus:border-cyan-500/30" |
| defaultValue={model.versions[0]?.id} |
| id={`version-${model.id}`} |
| > |
| {model.versions.map((v) => ( |
| <option key={v.id} value={v.id}> |
| {v.name} ({(v.sizeKB / 1024 / 1024).toFixed(1)}GB) |
| </option> |
| ))} |
| </select> |
| <button |
| type="button" |
| onClick={() => { |
| const select = document.getElementById(`version-${model.id}`) as HTMLSelectElement |
| 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 ? ( |
| <svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| ) : ( |
| <Download size={12} /> |
| )} |
| Install |
| </button> |
| </div> |
| )} |
| |
| {model.link && ( |
| <a |
| href={model.link} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="block mt-2 text-[10px] text-cyan-400 hover:text-cyan-300" |
| > |
| View on Civitai → |
| </a> |
| )} |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| |
| {/* Manual Version ID Input */} |
| <div className="flex items-start gap-4 mb-6 border-t border-white/10 pt-4"> |
| <div className="flex-1"> |
| <label className="text-[10px] text-blue-400 font-bold uppercase tracking-wider block mb-2.5"> |
| 📋 Direct Version ID (Advanced) |
| </label> |
| <input |
| value={civitaiVersionId} |
| onChange={(e) => 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" |
| /> |
| <div className="mt-2 text-[10px] text-blue-300/60 font-medium"> |
| Enter a version ID from any Civitai model URL (e.g., civitai.com/models/<strong>128713</strong>) |
| </div> |
| </div> |
| <div className="flex-shrink-0 pt-7"> |
| <button |
| type="button" |
| onClick={() => { |
| 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 && ( |
| <span className="absolute inset-0 bg-gradient-to-r from-blue-700 via-blue-600 to-blue-700 animate-shimmer" style={{ |
| backgroundSize: '200% 100%', |
| animation: 'shimmer 2s infinite linear' |
| }} /> |
| )} |
| <span className="relative z-10 flex items-center gap-2.5 justify-center"> |
| {installBusy !== null ? ( |
| <> |
| <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Downloading...</span> |
| </> |
| ) : ( |
| <> |
| <Download size={16} strokeWidth={2.5} /> |
| <span>Download</span> |
| </> |
| )} |
| </span> |
| </button> |
| </div> |
| </div> |
|
|
| {} |
| <div className="border-t border-blue-500/20 pt-4"> |
| <div className="text-[10px] text-blue-400 font-bold uppercase tracking-wider mb-3"> |
| ⭐ Recommended {modelType === 'image' ? 'Image' : 'Video'} Models from Civitai |
| </div> |
| <div className="text-[10px] text-blue-300/60 font-medium"> |
| Click "Install" on any model below to download directly from Civitai. Models are installed to your ComfyUI models folder. |
| </div> |
| </div> |
| </div> |
| </div> |
| )} |
|
|
| {} |
| <div className="flex-1 overflow-y-auto px-8 py-6 scrollbar-hide"> |
| <div className="flex flex-col gap-4 max-w-7xl mx-auto"> |
| {/* Error messages - hide for Civitai since it's download-only */} |
| {installedError && provider !== 'civitai' ? ( |
| <div className={`rounded-xl border p-5 ${ |
| installedError.includes('Ollama') || installedError.includes('11434') |
| ? 'border-white/10 bg-white/[0.03] text-white/60' |
| : 'border-amber-500/30 bg-gradient-to-br from-amber-500/10 to-amber-500/5 text-amber-200' |
| }`}> |
| <div className={`font-bold text-sm ${ |
| installedError.includes('Ollama') || installedError.includes('11434') |
| ? 'text-white/70' |
| : 'text-amber-100' |
| }`}> |
| {installedError.includes('Ollama') || installedError.includes('11434') |
| ? 'Ollama Not Running' |
| : 'Configuration Required'} |
| </div> |
| <div className="text-xs mt-2 font-medium opacity-70"> |
| {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} |
| </div> |
| </div> |
| ) : null} |
| |
| {catalogError && !supportedForSelection.length ? ( |
| <div className="rounded-xl border border-blue-500/20 bg-gradient-to-br from-blue-500/10 to-blue-500/5 p-5 text-blue-200"> |
| <div className="font-bold text-sm text-blue-100">Backend catalog unavailable</div> |
| <div className="mt-2 text-xs text-blue-200/70 font-medium"> |
| Using fallback model list. Configure <span className="font-mono bg-blue-500/20 px-1.5 py-0.5 rounded text-blue-100">/model-catalog</span> endpoint for enhanced functionality. |
| </div> |
| </div> |
| ) : null} |
| |
| {/* Models table */} |
| <div className="rounded-2xl border border-white/10 overflow-hidden bg-gradient-to-b from-white/[0.02] to-transparent"> |
| <div className="bg-white/5 px-6 py-4 flex items-center justify-between border-b border-white/10"> |
| <div className="text-xs font-bold text-white uppercase tracking-wider">Available Models</div> |
| <div className="text-xs text-white/50 font-semibold"> |
| {(() => { |
| 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` : ''}` |
| })()} |
| </div> |
| </div> |
| |
| <div className="divide-y divide-white/5"> |
| {merged.length === 0 ? ( |
| <div className="p-12 text-white/50 text-sm text-center font-medium"> |
| {provider === 'civitai' ? ( |
| <div className="space-y-2"> |
| <div className="text-blue-400 font-bold">🧪 Civitai Download</div> |
| <div>Enter a Civitai version ID above to download models.</div> |
| <div className="text-xs text-white/40"> |
| Find models at <a href="https://civitai.com" target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 underline">civitai.com</a> |
| </div> |
| </div> |
| ) : ( |
| 'No models found. Try changing provider/base URL and refresh.' |
| )} |
| </div> |
| ) : ( |
| 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 ( |
| <div key={row.id} className="p-5 flex items-center justify-between gap-4 hover:bg-white/[0.02] transition-all group"> |
| <div className="min-w-0 flex-1"> |
| <div className="flex items-center gap-2.5 mb-3"> |
| <div className={`px-3 py-1.5 rounded-lg border text-[10px] font-bold uppercase tracking-wider flex items-center gap-1.5 ${chipClass(statusKind)}`}> |
| <IconStatus kind={statusKind} /> |
| <span>{statusLabel}</span> |
| </div> |
| {row.recommended ? ( |
| <div className="px-3 py-1.5 rounded-lg border border-blue-500/30 bg-blue-500/10 text-[10px] font-bold uppercase tracking-wider text-blue-200"> |
| ⭐ Recommended |
| </div> |
| ) : null} |
| {row.recommended_nsfw && props.nsfwMode ? ( |
| <div className="px-3 py-1.5 rounded-lg border border-pink-500/30 bg-pink-500/10 text-[10px] font-bold uppercase tracking-wider text-pink-200"> |
| 🔥 NSFW Pick |
| </div> |
| ) : null} |
| {row.nsfw ? ( |
| <div className="px-3 py-1.5 rounded-lg border border-red-500/30 bg-red-500/10 text-[10px] font-bold uppercase tracking-wider text-red-200"> |
| 🌶️ Adult |
| </div> |
| ) : null} |
| {isCivitai && row.civitai_version_id ? ( |
| <div className="px-3 py-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 text-[10px] font-bold uppercase tracking-wider text-cyan-200"> |
| v{row.civitai_version_id} |
| </div> |
| ) : null} |
| {row.protected && (row.status === 'installed' || row.status === 'installed_unsupported') ? ( |
| <div className="px-3 py-1.5 rounded-lg border border-white/20 bg-white/5 text-[10px] font-bold uppercase tracking-wider text-white/50"> |
| 🔒 Default |
| </div> |
| ) : null} |
| </div> |
| |
| <div className="font-bold text-base text-white truncate group-hover:text-white transition-colors">{row.label}</div> |
| <div className="text-[11px] text-white/40 font-mono truncate mt-1">{row.id}</div> |
| {row.description ? ( |
| <div className="text-[11px] text-white/30 truncate mt-1">{row.description}</div> |
| ) : null} |
| |
| {/* Pack metadata - shows file count, required nodes, and hints */} |
| {row.install?.files && row.install.files.length > 1 ? ( |
| <div className="text-[11px] text-purple-400/80 mt-1"> |
| Pack: <span className="font-semibold">{row.install.files.length}</span> files |
| </div> |
| ) : null} |
| |
| {row.install?.requires_custom_nodes && row.install.requires_custom_nodes.length > 0 ? ( |
| <div className="text-[11px] text-amber-400/70 mt-1 truncate"> |
| Requires:{" "} |
| <span className="font-semibold"> |
| {row.install.requires_custom_nodes.join(", ")} |
| </span> |
| </div> |
| ) : null} |
| |
| {row.install?.hint ? ( |
| <div className="text-[11px] text-white/25 mt-1 truncate"> |
| {row.install.hint} |
| </div> |
| ) : null} |
| |
| {isCivitai && row.civitai_url ? ( |
| <a |
| href={row.civitai_url} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="text-[11px] text-blue-400 hover:text-blue-300 underline mt-1 inline-block" |
| > |
| View on Civitai → |
| </a> |
| ) : null} |
| </div> |
| |
| <div className="flex items-center gap-3 flex-shrink-0"> |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all hover:scale-105 active:scale-95" |
| onClick={() => { |
| navigator.clipboard?.writeText(row.id).catch(() => {}) |
| setToast('Model ID copied to clipboard') |
| }} |
| title="Copy model ID" |
| > |
| <Copy size={14} /> |
| <span>Copy</span> |
| </button> |
| |
| {canDownload ? ( |
| <button |
| type="button" |
| 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", |
| installBusy === row.id |
| ? "bg-blue-600 text-white cursor-wait shadow-blue-600/30" |
| : isCivitai |
| ? "bg-cyan-600 text-white hover:bg-cyan-500 hover:shadow-cyan-600/30 hover:scale-105 active:scale-95 shadow-cyan-600/20" |
| : "bg-white text-black hover:bg-gray-100 hover:shadow-white/30 hover:scale-105 active:scale-95 shadow-white/20" |
| ].join(" ")} |
| disabled={installBusy === row.id} |
| onClick={() => { |
| // 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 && ( |
| <span className="absolute inset-0 bg-gradient-to-r from-blue-600 via-blue-500 to-blue-600 animate-shimmer" style={{ |
| backgroundSize: '200% 100%', |
| animation: 'shimmer 2s infinite linear' |
| }} /> |
| )} |
| <span className="relative z-10 flex items-center gap-2.5"> |
| {installBusy === row.id ? ( |
| <> |
| <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Downloading...</span> |
| </> |
| ) : ( |
| <> |
| <Download size={15} strokeWidth={2.5} /> |
| <span>Install</span> |
| </> |
| )} |
| </span> |
| </button> |
| ) : null} |
| |
| {canDelete ? ( |
| deleteConfirm === row.id ? ( |
| <div className="flex items-center gap-2"> |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-red-600 hover:bg-red-500 text-xs font-bold text-white flex items-center gap-2 transition-all hover:scale-105 active:scale-95" |
| disabled={deleteBusy === row.id} |
| onClick={() => void tryDelete(row.id)} |
| > |
| {deleteBusy === row.id ? ( |
| <> |
| <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Deleting...</span> |
| </> |
| ) : ( |
| <span>Confirm</span> |
| )} |
| </button> |
| <button |
| type="button" |
| className="px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold transition-all" |
| onClick={() => setDeleteConfirm(null)} |
| > |
| Cancel |
| </button> |
| </div> |
| ) : ( |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-red-600/20 border border-white/10 hover:border-red-500/30 text-xs font-bold flex items-center gap-2 transition-all hover:scale-105 active:scale-95 text-white/60 hover:text-red-400" |
| onClick={() => setDeleteConfirm(row.id)} |
| title="Delete this model from disk" |
| > |
| <Trash2 size={14} /> |
| <span>Delete</span> |
| </button> |
| ) |
| ) : null} |
| </div> |
| </div> |
| ) |
| }) |
| )} |
| </div> |
| </div> |
|
|
| {} |
| {modelType === 'addons' && ( |
| <div className="rounded-2xl border border-purple-500/20 overflow-hidden bg-gradient-to-b from-purple-500/[0.03] to-transparent"> |
| <div className="bg-purple-500/5 px-6 py-4 flex items-center justify-between border-b border-purple-500/20"> |
| <div className="flex items-center gap-3"> |
| <div className="text-xs font-bold text-purple-200 uppercase tracking-wider">Avatar & Identity Models</div> |
| <div className="px-2.5 py-1 rounded-md bg-purple-500/10 border border-purple-500/20 text-[10px] font-bold text-purple-300 uppercase tracking-wider"> |
| Persona |
| </div> |
| </div> |
| <div className="flex items-center gap-3"> |
| {avatarModelsLoading ? ( |
| <div className="text-xs text-purple-300/50 font-semibold">Loading...</div> |
| ) : ( |
| <div className="text-xs text-purple-300/50 font-semibold"> |
| {avatarModels.filter(m => m.installed).length} / {avatarModels.length} Installed |
| </div> |
| )} |
| <button |
| type="button" |
| onClick={() => 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" |
| > |
| <RefreshCw size={13} className={avatarModelsLoading ? 'animate-spin' : ''} /> |
| </button> |
| </div> |
| </div> |
| |
| {avatarModelsError ? ( |
| <div className="p-5 text-xs text-purple-300/60"> |
| Could not load avatar models: {avatarModelsError} |
| </div> |
| ) : ( |
| <> |
| {/* Feature readiness dashboard */} |
| {Object.keys(avatarFeatures).length > 0 && ( |
| <div className="px-6 py-4 border-b border-purple-500/10"> |
| <div className="text-[10px] text-white/40 font-bold uppercase tracking-wider mb-3">Feature Readiness</div> |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3"> |
| {Object.entries(avatarFeatures).map(([featId, feat]) => ( |
| <div |
| key={featId} |
| className={`rounded-xl border p-3 transition-all ${ |
| feat.ready |
| ? 'border-emerald-500/20 bg-emerald-500/5' |
| : 'border-white/10 bg-white/[0.02]' |
| }`} |
| > |
| <div className="flex items-center gap-2 mb-1.5"> |
| {feat.ready ? ( |
| <CheckCircle2 size={13} className="text-emerald-400 flex-shrink-0" /> |
| ) : ( |
| <XCircle size={13} className="text-white/30 flex-shrink-0" /> |
| )} |
| <div className={`text-[11px] font-bold ${feat.ready ? 'text-emerald-200' : 'text-white/50'}`}> |
| {feat.label} |
| </div> |
| </div> |
| <div className="text-[10px] text-white/30 leading-relaxed"> |
| {feat.description} |
| </div> |
| {!feat.ready && feat.required_missing.length > 0 && ( |
| <div className="mt-2 text-[10px] text-amber-400/70"> |
| Missing: {feat.required_missing.join(', ')} |
| </div> |
| )} |
| {feat.ready && !feat.recommended_installed && feat.recommended_note && ( |
| <div className="mt-2 text-[10px] text-blue-300/60"> |
| Tip: {feat.recommended_note} |
| </div> |
| )} |
| </div> |
| ))} |
| </div> |
| <div className="mt-3 text-[10px] text-white/20"> |
| Existing personas and avatars work without any of these models. These enable optional enhanced features only. |
| </div> |
| </div> |
| )} |
| |
| {/* Quick-install preset buttons */} |
| <div className="px-6 py-4 border-b border-purple-500/10 flex flex-wrap items-center gap-3"> |
| <span className="text-[10px] text-white/40 font-bold uppercase tracking-wider mr-1">Quick Install:</span> |
| <button |
| type="button" |
| onClick={() => 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' ? ( |
| <> |
| <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Downloading...</span> |
| </> |
| ) : ( |
| <> |
| <Download size={14} strokeWidth={2.5} /> |
| <span>Basic Pack (~4.3 GB)</span> |
| </> |
| )} |
| </button> |
| <button |
| type="button" |
| onClick={() => 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' ? ( |
| <> |
| <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Downloading...</span> |
| </> |
| ) : ( |
| <> |
| <Download size={14} strokeWidth={2.5} /> |
| <span>Full Pack (~9.5 GB)</span> |
| </> |
| )} |
| </button> |
| <span className="text-[10px] text-white/25 ml-2"> |
| Basic = InsightFace + InstantID (portraits + outfits, ~4.3 GB). Full = all 9 models (~9.5 GB, + face swap, random faces). |
| </span> |
| </div> |
|
|
| {} |
| {avatarDownloadStatus && (avatarDownloadStatus.running || avatarDownloadStatus.finished) && ( |
| <div className="px-6 py-4 border-b border-purple-500/10 bg-gradient-to-r from-purple-500/[0.03] to-transparent"> |
| <div className="flex items-center justify-between mb-3"> |
| <div className="flex items-center gap-2.5"> |
| {avatarDownloadStatus.running && ( |
| <svg className="animate-spin h-4 w-4 text-purple-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| )} |
| {avatarDownloadStatus.finished && ( |
| <CheckCircle2 size={16} className="text-emerald-400" /> |
| )} |
| <span className="text-xs font-bold text-white"> |
| {avatarDownloadStatus.finished |
| ? `Download Complete — ${avatarDownloadStatus.installed_count}/${avatarDownloadStatus.total_models} installed` |
| : `Downloading ${avatarDownloadStatus.preset?.toUpperCase()} preset — ${avatarDownloadStatus.current_index}/${avatarDownloadStatus.total_models}` |
| } |
| </span> |
| </div> |
| <div className="text-[10px] text-white/40 font-mono"> |
| {Math.round(avatarDownloadStatus.elapsed)}s |
| {avatarDownloadStatus.downloaded_bytes > 0 && ( |
| <> | {formatBytes(avatarDownloadStatus.downloaded_bytes)}</> |
| )} |
| </div> |
| </div> |
|
|
| {} |
| {avatarDownloadStatus.total_models > 0 && ( |
| <div className="h-2 bg-white/10 rounded-full overflow-hidden mb-3"> |
| <div |
| className={`h-full rounded-full transition-all duration-700 ${ |
| avatarDownloadStatus.finished ? 'bg-emerald-500' : 'bg-purple-500' |
| }`} |
| style={{ |
| width: `${Math.round( |
| ((avatarDownloadStatus.results?.length || 0) / avatarDownloadStatus.total_models) * 100 |
| )}%`, |
| }} |
| /> |
| </div> |
| )} |
|
|
| {} |
| <div className="space-y-1"> |
| {(avatarDownloadStatus.results || []).map((r) => ( |
| <div key={r.id} className="flex items-center gap-2 text-[11px]"> |
| {r.status === 'installed' ? ( |
| <CheckCircle2 size={11} className="text-emerald-400 shrink-0" /> |
| ) : r.status === 'already_installed' ? ( |
| <CheckCircle2 size={11} className="text-white/30 shrink-0" /> |
| ) : ( |
| <XCircle size={11} className="text-red-400 shrink-0" /> |
| )} |
| <span className={r.status === 'already_installed' ? 'text-white/30' : 'text-white/60'}> |
| {r.name || r.id} |
| </span> |
| <span className="text-white/20 ml-auto"> |
| {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' : ''} |
| </span> |
| </div> |
| ))} |
|
|
| {} |
| {avatarDownloadStatus.running && avatarDownloadStatus.current_model && ( |
| <div className="flex items-center gap-2 text-[11px]"> |
| <svg className="animate-spin h-3 w-3 text-purple-400 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span className="text-purple-300"> |
| {avatarModels.find(m => m.id === avatarDownloadStatus.current_model)?.name || avatarDownloadStatus.current_model} |
| </span> |
| <span className="text-white/20 ml-auto">downloading...</span> |
| </div> |
| )} |
| </div> |
|
|
| {} |
| {avatarDownloadStatus.finished && ( |
| <button |
| type="button" |
| onClick={() => setAvatarDownloadStatus(null)} |
| className="mt-3 text-[10px] text-white/30 hover:text-white/50 transition-colors" |
| > |
| Dismiss |
| </button> |
| )} |
| </div> |
| )} |
|
|
| {} |
| <div className="divide-y divide-purple-500/10"> |
| {avatarModels.map((m) => { |
| const statusKind = m.installed ? 'ok' : 'warn' |
| const statusLabel = m.installed ? 'Installed' : 'Not Installed' |
|
|
| |
| const BASIC_PACK_IDS = new Set(['insightface-antelopev2', 'instantid-ip-adapter', 'instantid-controlnet']) |
| const isRecommended = BASIC_PACK_IDS.has(m.id) |
|
|
| |
| const FEATURE_LABELS: Record<string, { label: string; color: string }> = { |
| 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 ( |
| <div key={m.id} className="p-5 flex items-center justify-between gap-4 hover:bg-white/[0.02] transition-all group"> |
| <div className="min-w-0 flex-1"> |
| <div className="flex items-center gap-2.5 mb-2 flex-wrap"> |
| <div className={`px-3 py-1.5 rounded-lg border text-[10px] font-bold uppercase tracking-wider flex items-center gap-1.5 ${chipClass(statusKind)}`}> |
| <IconStatus kind={statusKind} /> |
| <span>{statusLabel}</span> |
| </div> |
| {m.is_default && ( |
| <div className="px-3 py-1.5 rounded-lg border border-blue-500/30 bg-blue-500/10 text-[10px] font-bold uppercase tracking-wider text-blue-200"> |
| Core |
| </div> |
| )} |
| {isRecommended && ( |
| <div className="px-3 py-1.5 rounded-lg border border-purple-500/30 bg-purple-500/10 text-[10px] font-bold uppercase tracking-wider text-purple-200"> |
| Recommended |
| </div> |
| )} |
| {m.license && ( |
| <div className="px-2.5 py-1 rounded-lg border border-white/10 bg-white/5 text-[10px] font-medium text-white/40 flex items-center gap-1"> |
| <Shield size={10} /> |
| {m.license} |
| </div> |
| )} |
| {m.commercial_use_ok === true && ( |
| <div className="px-2.5 py-1 rounded-lg border border-emerald-500/20 bg-emerald-500/5 text-[10px] font-medium text-emerald-300/70"> |
| Commercial OK |
| </div> |
| )} |
| {m.commercial_use_ok === false && ( |
| <div className="px-2.5 py-1 rounded-lg border border-amber-500/20 bg-amber-500/5 text-[10px] font-medium text-amber-300/70"> |
| Non-Commercial |
| </div> |
| )} |
| </div> |
| |
| <div className="font-bold text-base text-white truncate group-hover:text-white transition-colors">{m.name}</div> |
| <div className="text-[11px] text-white/40 font-mono truncate mt-1">{m.id}</div> |
| {m.description && ( |
| <div className="text-[11px] text-white/30 mt-1">{m.description}</div> |
| )} |
| {m.requires && m.requires.length > 0 && ( |
| <div className="text-[11px] text-amber-400/70 mt-1 truncate"> |
| Requires: <span className="font-semibold">{m.requires.join(', ')}</span> |
| </div> |
| )} |
| |
| {/* Feature tags: which features this model enables */} |
| {m.used_by && m.used_by.length > 0 && ( |
| <div className="flex items-center gap-1.5 mt-2 flex-wrap"> |
| <span className="text-[10px] text-white/25">Enables:</span> |
| {m.used_by.map((ub) => { |
| const fl = FEATURE_LABELS[ub.feature] |
| if (!fl) return null |
| const isRequired = ub.role === 'required' |
| return ( |
| <span |
| key={`${ub.feature}-${ub.role}`} |
| className={`px-2 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider border ${ |
| isRequired |
| ? `border-${fl.color}-500/30 bg-${fl.color}-500/10 text-${fl.color}-300` |
| : 'border-white/10 bg-white/5 text-white/40' |
| }`} |
| title={isRequired ? `Required for ${fl.label}` : `Recommended for ${fl.label}`} |
| > |
| {fl.label}{!isRequired ? ' (opt)' : ''} |
| </span> |
| ) |
| })} |
| </div> |
| )} |
| </div> |
| |
| <div className="flex items-center gap-3 flex-shrink-0"> |
| {m.homepage && ( |
| <a |
| href={m.homepage} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all text-white/50 hover:text-white/70" |
| title="View project page" |
| > |
| <ExternalLink size={13} /> |
| <span>Info</span> |
| </a> |
| )} |
| |
| {/* Install button — only for not-installed models with a download URL */} |
| {!m.installed && m.download_url && ( |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-purple-600 hover:bg-purple-500 text-xs font-bold text-white flex items-center gap-2 transition-all hover:scale-105 active:scale-95 shadow-lg shadow-purple-600/20 disabled:opacity-50 disabled:cursor-wait" |
| disabled={avatarInstallBusy === m.id || avatarDownloadBusy !== null} |
| onClick={() => installSingleAvatarModel(m.id)} |
| title={`Download and install ${m.name}`} |
| > |
| {avatarInstallBusy === m.id ? ( |
| <> |
| <svg className="animate-spin h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Installing...</span> |
| </> |
| ) : ( |
| <> |
| <Download size={13} /> |
| <span>Install</span> |
| </> |
| )} |
| </button> |
| )} |
|
|
| {} |
| {m.installed && ( |
| avatarDeleteConfirm === m.id ? ( |
| <div className="flex items-center gap-2"> |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-red-600 hover:bg-red-500 text-xs font-bold text-white flex items-center gap-2 transition-all hover:scale-105 active:scale-95" |
| disabled={avatarDeleteBusy === m.id} |
| onClick={() => deleteAvatarModel(m.id)} |
| > |
| {avatarDeleteBusy === m.id ? ( |
| <> |
| <svg className="animate-spin h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Deleting...</span> |
| </> |
| ) : ( |
| <span>Confirm</span> |
| )} |
| </button> |
| <button |
| type="button" |
| className="px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold transition-all" |
| onClick={() => setAvatarDeleteConfirm(null)} |
| > |
| Cancel |
| </button> |
| </div> |
| ) : ( |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-red-600/20 border border-white/10 hover:border-red-500/30 text-xs font-bold flex items-center gap-2 transition-all hover:scale-105 active:scale-95 text-white/60 hover:text-red-400" |
| onClick={() => setAvatarDeleteConfirm(m.id)} |
| title="Uninstall this model to free disk space" |
| > |
| <Trash2 size={13} /> |
| <span>Uninstall</span> |
| </button> |
| ) |
| )} |
| </div> |
| </div> |
| ) |
| })} |
|
|
| {avatarModels.length === 0 && !avatarModelsLoading && ( |
| <div className="p-8 text-center text-white/40 text-sm"> |
| No avatar models registered. Check backend configuration. |
| </div> |
| )} |
| </div> |
| </> |
| )} |
| </div> |
| )} |
|
|
| {} |
| {modelType === 'lora' && ( |
| <div className="rounded-2xl border border-cyan-500/20 overflow-hidden bg-gradient-to-b from-cyan-500/[0.03] to-transparent"> |
| <div className="bg-cyan-500/5 px-6 py-4 flex items-center justify-between border-b border-cyan-500/20"> |
| <div className="flex items-center gap-3"> |
| <div className="text-xs font-bold text-cyan-200 uppercase tracking-wider">LoRA Models</div> |
| <div className="px-2.5 py-1 rounded-md bg-cyan-500/10 border border-cyan-500/20 text-[10px] font-bold text-cyan-300 uppercase tracking-wider"> |
| Lightweight Adapters |
| </div> |
| </div> |
| <div className="flex items-center gap-3"> |
| {loraLoading ? ( |
| <div className="text-xs text-cyan-300/50 font-semibold">Loading...</div> |
| ) : ( |
| <div className="text-xs text-cyan-300/50 font-semibold"> |
| {loraInstalled.length} Installed / {loraRegistry.length} Available |
| </div> |
| )} |
| <button |
| type="button" |
| onClick={() => 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" |
| > |
| <RefreshCw size={13} className={loraLoading ? 'animate-spin' : ''} /> |
| </button> |
| </div> |
| </div> |
| |
| {loraError ? ( |
| <div className="p-5 text-xs text-cyan-300/60"> |
| Could not load LoRA models: {loraError} |
| </div> |
| ) : ( |
| <> |
| {/* Info banner */} |
| <div className="px-6 py-4 border-b border-cyan-500/10"> |
| <div className="text-[11px] text-white/40 leading-relaxed"> |
| 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 <span className="font-mono bg-white/10 px-1.5 py-0.5 rounded text-white/60">models/comfy/loras/</span> directory. |
| </div> |
| {!props.nsfwMode && ( |
| <div className="mt-2 text-[10px] text-white/25 flex items-center gap-1.5"> |
| <Shield size={10} /> |
| Some models are hidden. Enable Spice Mode in Settings to see all available LoRAs. |
| </div> |
| )} |
| </div> |
| |
| {/* Install progress (mirrors Add-ons progress bar) */} |
| {loraDownloadStatus && (loraDownloadStatus.running || loraDownloadStatus.finished) && ( |
| <div className="px-6 py-4 border-b border-cyan-500/10"> |
| <div className="flex items-center gap-3 mb-2"> |
| {loraDownloadStatus.running && ( |
| <svg className="animate-spin h-4 w-4 text-cyan-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| )} |
| {loraDownloadStatus.finished && <CheckCircle2 size={16} className="text-emerald-400" />} |
| <span className="text-xs font-bold text-white/70"> |
| {loraDownloadStatus.finished |
| ? `Install Complete — ${loraDownloadStatus.installed_count}/${loraDownloadStatus.total_models} installed` |
| : `Installing LoRA — ${loraDownloadStatus.current_index}/${loraDownloadStatus.total_models}` |
| } |
| </span> |
| <span className="text-[10px] text-white/30 ml-auto"> |
| {Math.round(loraDownloadStatus.elapsed)}s |
| {loraDownloadStatus.downloaded_bytes > 0 && ( |
| <> | {formatBytes(loraDownloadStatus.downloaded_bytes)}</> |
| )} |
| </span> |
| </div> |
| {loraDownloadStatus.total_models > 0 && ( |
| <div className="w-full h-1.5 bg-white/10 rounded-full overflow-hidden"> |
| <div |
| className={`h-full rounded-full transition-all duration-500 ${ |
| loraDownloadStatus.finished ? 'bg-emerald-500' : 'bg-cyan-500' |
| }`} |
| style={{ |
| width: `${loraDownloadStatus.finished ? 100 : |
| ((loraDownloadStatus.results?.length || 0) / loraDownloadStatus.total_models) * 100 |
| }%`, |
| }} |
| /> |
| </div> |
| )} |
| {loraDownloadStatus.running && loraDownloadStatus.current_model && ( |
| <div className="mt-2 flex items-center gap-2"> |
| <div className="w-2 h-2 rounded-full bg-cyan-400 animate-pulse" /> |
| <span className="text-[10px] text-white/40"> |
| Installing: {loraRegistry.find((l: any) => l.id === loraDownloadStatus.current_model)?.name || loraDownloadStatus.current_model} |
| </span> |
| </div> |
| )} |
| {loraDownloadStatus.finished && ( |
| <button |
| type="button" |
| onClick={() => setLoraDownloadStatus(null)} |
| className="mt-3 text-[10px] text-white/30 hover:text-white/50 transition-colors" |
| > |
| Dismiss |
| </button> |
| )} |
| </div> |
| )} |
|
|
| {} |
| {loraInstalled.length > 0 && ( |
| <div className="px-6 py-4 border-b border-cyan-500/10"> |
| <div className="text-[10px] text-white/40 font-bold uppercase tracking-wider mb-3">Installed LoRAs</div> |
| <div className="flex flex-wrap gap-2"> |
| {loraInstalled.map((l: any) => ( |
| <div |
| key={l.id} |
| className="px-3 py-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5 text-[11px] font-semibold text-emerald-200 flex items-center gap-2" |
| > |
| <CheckCircle2 size={12} className="text-emerald-400" /> |
| {l.id} |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
|
|
| {} |
| <div className="divide-y divide-white/5"> |
| {loraRegistry.map((lora: any) => { |
| const isInstalled = loraInstalled.some((i: any) => i.id === lora.id) |
| return ( |
| <div key={lora.id} className="p-5 flex items-center justify-between gap-4 hover:bg-white/[0.02] transition-all group"> |
| <div className="min-w-0 flex-1"> |
| <div className="flex items-center gap-2.5 mb-2 flex-wrap"> |
| <div className={`px-3 py-1.5 rounded-lg border text-[10px] font-bold uppercase tracking-wider flex items-center gap-1.5 ${ |
| isInstalled |
| ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200' |
| : 'border-white/10 bg-white/5 text-white/40' |
| }`}> |
| {isInstalled ? ( |
| <><CheckCircle2 size={10} /> Installed</> |
| ) : ( |
| <><Download size={10} /> Available</> |
| )} |
| </div> |
| <div className="px-2.5 py-1 rounded-lg border border-white/10 bg-white/5 text-[10px] font-medium text-white/40"> |
| {lora.base} |
| </div> |
| {lora.recommended ? ( |
| <div className="px-3 py-1.5 rounded-lg border border-blue-500/30 bg-blue-500/10 text-[10px] font-bold uppercase tracking-wider text-blue-200"> |
| ⭐ Recommended |
| </div> |
| ) : null} |
| {lora.recommended_nsfw && props.nsfwMode ? ( |
| <div className="px-3 py-1.5 rounded-lg border border-pink-500/30 bg-pink-500/10 text-[10px] font-bold uppercase tracking-wider text-pink-200"> |
| 🔥 NSFW Pick |
| </div> |
| ) : null} |
| {lora.gated ? ( |
| <div className="px-2.5 py-1 rounded-lg border border-red-500/30 bg-red-500/10 text-[10px] font-medium text-red-200"> |
| 🌶️ Adult |
| </div> |
| ) : null} |
| {lora.size_mb > 0 && ( |
| <div className="text-[10px] text-white/25">{lora.size_mb} MB</div> |
| )} |
| </div> |
| <div className="font-bold text-base text-white truncate group-hover:text-white transition-colors">{lora.name}</div> |
| <div className="text-[11px] text-white/40 font-mono truncate mt-1">{lora.filename}</div> |
| {lora.description && ( |
| <div className="text-[11px] text-white/30 mt-1">{lora.description}</div> |
| )} |
| {lora.trigger_words && lora.trigger_words.length > 0 && ( |
| <div className="flex items-center gap-1.5 mt-2 flex-wrap"> |
| <span className="text-[10px] text-white/25">Trigger words:</span> |
| {lora.trigger_words.map((tw: string) => ( |
| <span |
| key={tw} |
| className="px-2 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider border border-cyan-500/20 bg-cyan-500/5 text-cyan-300/70" |
| > |
| {tw} |
| </span> |
| ))} |
| </div> |
| )} |
| </div> |
|
|
| <div className="flex items-center gap-2 shrink-0"> |
| {lora.model_url && ( |
| <a |
| href={lora.model_url} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all text-white/50 hover:text-white/70" |
| title="View model page" |
| > |
| <ExternalLink size={13} /> |
| <span>View</span> |
| </a> |
| )} |
| |
| {/* Install button — not installed, has download URL */} |
| {!isInstalled && lora.download_url && ( |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-cyan-600 hover:bg-cyan-500 text-xs font-bold text-white flex items-center gap-2 transition-all hover:scale-105 active:scale-95 shadow-lg shadow-cyan-600/20 disabled:opacity-50 disabled:cursor-wait" |
| disabled={loraInstallBusy === lora.id || loraDownloadBusy !== null} |
| onClick={() => installLora(lora.id)} |
| title={`Install ${lora.name}`} |
| > |
| {loraInstallBusy === lora.id ? ( |
| <> |
| <svg className="animate-spin h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Installing...</span> |
| </> |
| ) : ( |
| <> |
| <Download size={13} /> |
| <span>Install</span> |
| </> |
| )} |
| </button> |
| )} |
|
|
| {} |
| {isInstalled && ( |
| loraDeleteConfirm === lora.id ? ( |
| <div className="flex items-center gap-2"> |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-red-600 hover:bg-red-500 text-xs font-bold text-white flex items-center gap-2 transition-all hover:scale-105 active:scale-95" |
| disabled={loraDeleteBusy === lora.id} |
| onClick={() => deleteLora(lora.id)} |
| > |
| {loraDeleteBusy === lora.id ? ( |
| <> |
| <svg className="animate-spin h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Deleting...</span> |
| </> |
| ) : ( |
| <span>Confirm</span> |
| )} |
| </button> |
| <button |
| type="button" |
| className="px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold transition-all" |
| onClick={() => setLoraDeleteConfirm(null)} |
| > |
| Cancel |
| </button> |
| </div> |
| ) : ( |
| <button |
| type="button" |
| className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-red-600/20 border border-white/10 hover:border-red-500/30 text-xs font-bold flex items-center gap-2 transition-all hover:scale-105 active:scale-95 text-white/60 hover:text-red-400" |
| onClick={() => setLoraDeleteConfirm(lora.id)} |
| title="Delete this LoRA to free disk space" |
| > |
| <Trash2 size={13} /> |
| <span>Delete</span> |
| </button> |
| ) |
| )} |
| </div> |
| </div> |
| ) |
| })} |
| {loraRegistry.length === 0 && !loraLoading && ( |
| <div className="p-8 text-center text-white/40 text-sm"> |
| No LoRA models available. Check backend configuration. |
| </div> |
| )} |
| </div> |
| </> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
|
|
| {} |
| {toast ? ( |
| <div className="fixed bottom-8 right-8 z-50 bg-gradient-to-br from-white/10 to-white/5 backdrop-blur-xl border border-white/20 rounded-2xl px-6 py-4 text-sm text-white shadow-2xl shadow-black/40 animate-in slide-in-from-bottom-4 flex items-center gap-3 min-w-[320px]"> |
| {toast.includes('Successfully') || toast.includes('installed') ? ( |
| <div className="flex-shrink-0"> |
| <CheckCircle2 size={18} className="text-emerald-400" /> |
| </div> |
| ) : toast.includes('failed') || toast.includes('error') || toast.includes('Error') ? ( |
| <div className="flex-shrink-0"> |
| <XCircle size={18} className="text-red-400" /> |
| </div> |
| ) : ( |
| <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse flex-shrink-0" /> |
| )} |
| <div className="flex-1 min-w-0"> |
| <div className="font-semibold text-white/90 truncate">{toast}</div> |
| {installBusy && ( |
| <div className="text-[10px] text-white/50 mt-1 font-medium"> |
| Large models may take several minutes... |
| </div> |
| )} |
| </div> |
| </div> |
| ) : null} |
|
|
| <style>{` |
| .scrollbar-hide::-webkit-scrollbar { display: none; } |
| .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; } |
| |
| @keyframes shimmer { |
| 0% { background-position: -200% 0; } |
| 100% { background-position: 200% 0; } |
| } |
| |
| .animate-shimmer { |
| animation: shimmer 2s infinite linear; |
| } |
| `}</style> |
| </div> |
| ) |
| } |
|
|