├── api ├── __init__.py └── civitai.py ├── utils ├── __init__.py └── helpers.py ├── downloader └── __init__.py ├── .DS_Store ├── .gitattributes ├── web ├── .DS_Store ├── js │ ├── .DS_Store │ ├── utils │ │ ├── cookies.js │ │ └── dom.js │ ├── ui │ │ ├── feedback.js │ │ ├── handlers │ │ │ ├── searchHandler.js │ │ │ ├── settingsHandler.js │ │ │ ├── downloadHandler.js │ │ │ ├── statusHandler.js │ │ │ └── eventListeners.js │ │ ├── previewRenderer.js │ │ ├── statusRenderer.js │ │ ├── searchRenderer.js │ │ ├── templates.js │ │ └── UI.js │ ├── civitaiDownloader.js │ ├── api │ │ └── civitai.js │ └── civitaiDownloader.css └── images │ └── placeholder.jpeg ├── server ├── __init__.py ├── routes │ ├── __init__.py │ ├── GetStatus.py │ ├── GetBaseModels.py │ ├── GetModelTypes.py │ ├── ClearHistory.py │ ├── RetryDownload.py │ ├── CancelDownload.py │ ├── OpenPath.py │ ├── SearchModels.py │ ├── GetModelDetails.py │ └── GetModelDirs.py └── utils.py ├── pyproject.toml ├── .github └── workflows │ └── publish.yml ├── LICENSE ├── README.md ├── .gitignore ├── config.py └── __init__.py /api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /downloader/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonGoblinDev/Civicomfy/HEAD/.DS_Store -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /web/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonGoblinDev/Civicomfy/HEAD/web/.DS_Store -------------------------------------------------------------------------------- /web/js/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonGoblinDev/Civicomfy/HEAD/web/js/.DS_Store -------------------------------------------------------------------------------- /web/images/placeholder.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonGoblinDev/Civicomfy/HEAD/web/images/placeholder.jpeg -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/__init__.py 3 | # ================================================ 4 | # Import the routes package to ensure all route decorators are executed. 5 | from . import routes -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "civicomfy" 3 | description = "Civicomfy seamlessly integrates Civitai's vast model repository directly into ComfyUI, allowing you to search, download, and organize AI models without leaving your workflow." 4 | version = "1.0.9" 5 | license = {file = "LICENSE"} 6 | 7 | [project.urls] 8 | Repository = "https://github.com/MoonGoblinDev/Civicomfy" 9 | # Used by Comfy Registry https://comfyregistry.org 10 | 11 | [tool.comfy] 12 | PublisherId = "moongoblin" 13 | DisplayName = "Civicomfy" 14 | Icon = "" 15 | -------------------------------------------------------------------------------- /server/routes/__init__.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/__init__.py 3 | # ================================================ 4 | # This file imports all the individual route modules. 5 | # When the `routes` package is imported by `server/__init__.py`, 6 | # these imports will be executed, registering the routes with the 7 | # ComfyUI server instance. 8 | 9 | from . import CancelDownload 10 | from . import ClearHistory 11 | from . import DownloadModel 12 | from . import GetBaseModels 13 | from . import GetModelDetails 14 | from . import GetModelTypes 15 | from . import GetModelDirs 16 | from . import GetStatus 17 | from . import OpenPath 18 | from . import RetryDownload 19 | from . import SearchModels 20 | 21 | print("[Civicomfy] All server route modules loaded.") 22 | -------------------------------------------------------------------------------- /web/js/utils/cookies.js: -------------------------------------------------------------------------------- 1 | // Lightweight cookie helpers for Civicomfy UI 2 | // Exports: setCookie, getCookie 3 | 4 | export function setCookie(name, value, days) { 5 | let expires = ""; 6 | if (days) { 7 | const date = new Date(); 8 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); 9 | expires = "; expires=" + date.toUTCString(); 10 | } 11 | document.cookie = `${name}=${value || ""}${expires}; path=/; SameSite=Lax`; 12 | } 13 | 14 | export function getCookie(name) { 15 | const nameEQ = name + "="; 16 | const parts = document.cookie.split(";"); 17 | for (let i = 0; i < parts.length; i++) { 18 | let c = parts[i]; 19 | while (c.charAt(0) === " ") c = c.substring(1); 20 | if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length); 21 | } 22 | return null; 23 | } 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - "pyproject.toml" 10 | 11 | permissions: 12 | issues: write 13 | 14 | jobs: 15 | publish-node: 16 | name: Publish Custom Node to registry 17 | runs-on: ubuntu-latest 18 | if: ${{ github.repository_owner == 'MoonGoblinDev' }} 19 | steps: 20 | - name: Check out code 21 | uses: actions/checkout@v4 22 | with: 23 | submodules: true 24 | - name: Publish Custom Node 25 | uses: Comfy-Org/publish-node-action@v1 26 | with: 27 | ## Add your own personal access token to your Github Repository secrets and reference it here. 28 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 29 | -------------------------------------------------------------------------------- /server/routes/GetStatus.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/GetStatus.py 3 | # ================================================ 4 | from aiohttp import web 5 | import server # ComfyUI server instance 6 | from ...downloader.manager import manager as download_manager 7 | 8 | prompt_server = server.PromptServer.instance 9 | 10 | @prompt_server.routes.get("/civitai/status") 11 | async def route_get_status(request): 12 | """API Endpoint to get the status of downloads.""" 13 | try: 14 | status = download_manager.get_status() 15 | return web.json_response(status) 16 | except Exception as e: 17 | print(f"Error getting download status: {e}") 18 | # Format error response consistently 19 | return web.json_response({"error": "Internal Server Error", "details": f"Failed to get status: {str(e)}", "status_code": 500}, status=500) -------------------------------------------------------------------------------- /server/routes/GetBaseModels.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/GetBaseModels.py 3 | # ================================================ 4 | from aiohttp import web 5 | import server # ComfyUI server instance 6 | from ...config import AVAILABLE_MEILI_BASE_MODELS 7 | 8 | prompt_server = server.PromptServer.instance 9 | 10 | @prompt_server.routes.get("/civitai/base_models") 11 | async def route_get_base_models(request): 12 | """API Endpoint to get the known base model types for filtering.""" 13 | try: 14 | # Return the hardcoded list for now 15 | # In future, this *could* fetch dynamically if Civitai provides an endpoint 16 | return web.json_response({"base_models": AVAILABLE_MEILI_BASE_MODELS}) 17 | except Exception as e: 18 | print(f"Error getting base model types: {e}") 19 | return web.json_response({"error": "Internal Server Error", "details": str(e), "status_code": 500}, status=500) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 MoonGoblin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/js/utils/dom.js: -------------------------------------------------------------------------------- 1 | // File: web/js/utils/dom.js 2 | 3 | /** 4 | * Dynamically adds a CSS link to the document's head. 5 | * It resolves the path relative to this script's location using import.meta.url, 6 | * making it robust against case-sensitivity issues and different install paths. 7 | * @param {string} relativeHref - Relative path to the CSS file (e.g., '../civitaiDownloader.css'). 8 | * @param {string} [id="civitai-downloader-styles"] - The ID for the link element. 9 | */ 10 | export function addCssLink(relativeHref, id = "civitai-downloader-styles") { 11 | if (document.getElementById(id)) return; // Prevent duplicates 12 | 13 | try { 14 | const absoluteUrl = new URL(relativeHref, import.meta.url); 15 | 16 | const link = document.createElement("link"); 17 | link.id = id; 18 | link.rel = "stylesheet"; 19 | link.href = absoluteUrl.href; 20 | 21 | link.onload = () => { 22 | console.log("[Civicomfy] CSS loaded successfully:", link.href); 23 | }; 24 | link.onerror = () => { 25 | console.error("[Civicomfy] Critical error: Failed to load CSS from:", link.href); 26 | }; 27 | 28 | document.head.appendChild(link); 29 | } catch (e) { 30 | console.error("[Civicomfy] Error creating CSS link. import.meta.url may be unsupported in this context.", e); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/routes/GetModelTypes.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/GetModelTypes.py 3 | # ================================================ 4 | import os 5 | from aiohttp import web 6 | import server # ComfyUI server instance 7 | import folder_paths 8 | 9 | prompt_server = server.PromptServer.instance 10 | 11 | @prompt_server.routes.get("/civitai/model_types") 12 | async def route_get_model_types(request): 13 | """API Endpoint to get the known model types and their mapping.""" 14 | try: 15 | # Dynamically list all first-level folders under the main models directory 16 | models_dir = getattr(folder_paths, 'models_dir', None) 17 | if not models_dir: 18 | base = getattr(folder_paths, 'base_path', os.getcwd()) 19 | models_dir = os.path.join(base, 'models') 20 | if not os.path.isdir(models_dir): 21 | return web.json_response({}) 22 | 23 | entries = {} 24 | for name in sorted(os.listdir(models_dir)): 25 | p = os.path.join(models_dir, name) 26 | if os.path.isdir(p): 27 | entries[name] = name 28 | return web.json_response(entries) 29 | except Exception as e: 30 | print(f"Error getting model types: {e}") 31 | return web.json_response({"error": "Internal Server Error", "details": str(e), "status_code": 500}, status=500) 32 | -------------------------------------------------------------------------------- /server/routes/ClearHistory.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/ClearHistory.py 3 | # ================================================ 4 | import asyncio 5 | from aiohttp import web 6 | 7 | import server # ComfyUI server instance 8 | from ...downloader.manager import manager as download_manager 9 | 10 | prompt_server = server.PromptServer.instance 11 | 12 | @prompt_server.routes.post("/civitai/clear_history") 13 | async def route_clear_history(request): 14 | """API Endpoint to clear the download history.""" 15 | if not download_manager: 16 | return web.json_response({"error": "Download Manager not initialized"}, status=500) 17 | 18 | try: 19 | # No request body needed for this action 20 | print(f"[API Route /civitai/clear_history] Received clear history request.") 21 | 22 | # Call manager method in thread 23 | result = await asyncio.to_thread(download_manager.clear_history) 24 | 25 | status_code = 200 if result.get("success") else 500 # Use 500 for internal clear error 26 | return web.json_response(result, status=status_code) 27 | 28 | except Exception as e: 29 | import traceback 30 | print(f"Error handling /civitai/clear_history request: {e}") 31 | # traceback.print_exc() # Uncomment for detailed logs 32 | return web.json_response({"error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}"}, status=500) -------------------------------------------------------------------------------- /web/js/ui/feedback.js: -------------------------------------------------------------------------------- 1 | // Centralized feedback utilities: toasts and icon CSS 2 | 3 | export class Feedback { 4 | constructor(toastElement) { 5 | this.toastElement = toastElement || null; 6 | this.toastTimeout = null; 7 | } 8 | 9 | ensureFontAwesome() { 10 | if (!document.getElementById('civitai-fontawesome-link')) { 11 | const faLink = document.createElement('link'); 12 | faLink.id = 'civitai-fontawesome-link'; 13 | faLink.rel = 'stylesheet'; 14 | faLink.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css'; 15 | faLink.integrity = 'sha512-1ycn6IcaQQ40/MKBW2W4Rhis/DbILU74C1vSrLJxCq57o941Ym01SwNsOMqvEBFlcgUa6xLiPY/NS5R+E6ztJQ=='; 16 | faLink.crossOrigin = 'anonymous'; 17 | faLink.referrerPolicy = 'no-referrer'; 18 | document.head.appendChild(faLink); 19 | } 20 | } 21 | 22 | show(message, type = 'info', duration = 3000) { 23 | if (!this.toastElement) return; 24 | if (this.toastTimeout) { 25 | clearTimeout(this.toastTimeout); 26 | this.toastTimeout = null; 27 | } 28 | const valid = ['info', 'success', 'error', 'warning']; 29 | const toastType = valid.includes(type) ? type : 'info'; 30 | 31 | this.toastElement.textContent = message; 32 | this.toastElement.className = 'civitai-toast'; 33 | this.toastElement.classList.add(toastType); 34 | requestAnimationFrame(() => this.toastElement.classList.add('show')); 35 | this.toastTimeout = setTimeout(() => { 36 | this.toastElement.classList.remove('show'); 37 | this.toastTimeout = null; 38 | }, duration); 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /web/js/ui/handlers/searchHandler.js: -------------------------------------------------------------------------------- 1 | import { CivitaiDownloaderAPI } from "../../api/civitai.js"; 2 | 3 | export async function handleSearchSubmit(ui) { 4 | ui.searchSubmitButton.disabled = true; 5 | ui.searchSubmitButton.textContent = 'Searching...'; 6 | ui.searchResultsContainer.innerHTML = '

Searching...

'; 7 | ui.searchPaginationContainer.innerHTML = ''; 8 | ui.ensureFontAwesome(); 9 | 10 | const params = { 11 | query: ui.searchQueryInput.value.trim(), 12 | model_types: ui.searchTypeSelect.value === 'any' ? [] : [ui.searchTypeSelect.value], 13 | base_models: ui.searchBaseModelSelect.value === 'any' ? [] : [ui.searchBaseModelSelect.value], 14 | sort: ui.searchSortSelect.value, 15 | limit: ui.searchPagination.limit, 16 | page: ui.searchPagination.currentPage, 17 | api_key: ui.settings.apiKey, 18 | }; 19 | 20 | try { 21 | const response = await CivitaiDownloaderAPI.searchModels(params); 22 | if (!response || !response.metadata || !Array.isArray(response.items)) { 23 | console.error("Invalid search response structure:", response); 24 | throw new Error("Received invalid data from search API."); 25 | } 26 | 27 | ui.renderSearchResults(response.items); 28 | ui.renderSearchPagination(response.metadata); 29 | 30 | } catch (error) { 31 | const message = `Search failed: ${error.details || error.message || 'Unknown error'}`; 32 | console.error("Search Submit Error:", error); 33 | ui.searchResultsContainer.innerHTML = `

${message}

`; 34 | ui.showToast(message, 'error'); 35 | } finally { 36 | ui.searchSubmitButton.disabled = false; 37 | ui.searchSubmitButton.textContent = 'Search'; 38 | } 39 | } -------------------------------------------------------------------------------- /server/routes/RetryDownload.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/RetryDownload.py 3 | # ================================================ 4 | import asyncio 5 | import json 6 | from aiohttp import web 7 | 8 | import server # ComfyUI server instance 9 | from ...downloader.manager import manager as download_manager 10 | 11 | prompt_server = server.PromptServer.instance 12 | 13 | @prompt_server.routes.post("/civitai/retry") 14 | async def route_retry_download(request): 15 | """API Endpoint to retry a failed/cancelled download.""" 16 | if not download_manager: 17 | return web.json_response({"error": "Download Manager not initialized"}, status=500) 18 | 19 | try: 20 | data = await request.json() 21 | download_id = data.get("download_id") 22 | 23 | if not download_id: 24 | return web.json_response({"error": "Missing 'download_id'", "details": "The request body must contain the 'download_id' of the item to retry."}, status=400) 25 | 26 | print(f"[API Route /civitai/retry] Received retry request for ID: {download_id}") 27 | # Call manager method (which handles locking) 28 | result = await asyncio.to_thread(download_manager.retry_download, download_id) # Run sync manager method in thread 29 | 30 | status_code = 200 if result.get("success") else 404 if "not found" in result.get("error", "").lower() else 400 31 | return web.json_response(result, status=status_code) 32 | 33 | except json.JSONDecodeError: 34 | return web.json_response({"error": "Invalid JSON body"}, status=400) 35 | except Exception as e: 36 | import traceback 37 | print(f"Error handling /civitai/retry request for ID '{data.get('download_id', 'N/A')}': {e}") 38 | # traceback.print_exc() # Uncomment for detailed logs 39 | return web.json_response({"error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}"}, status=500) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Civicomfy - Civitai Model Downloader for ComfyUI 2 | 3 | Civicomfy seamlessly integrates Civitai's vast model repository directly into ComfyUI, allowing you to search, download, and organize AI models without leaving your workflow. 4 | 5 | ## Features 6 | 7 | - **Integrated Model Search**: Search Civitai's extensive library directly from ComfyUI 8 | - **One-Click Downloads**: Download models with associated metadata and thumbnails 9 | - **Automatic Organization**: Models are automatically saved to their appropriate directories 10 | - **Clean UI**: Clean, intuitive interface that complements ComfyUI's aesthetic 11 | 12 | ## Installation 13 | 14 | Git clone 15 | ```bash 16 | cd ComfyUI/custom_nodes 17 | git clone https://github.com/MoonGoblinDev/Civicomfy.git 18 | ``` 19 | 20 | Comfy-CLI 21 | ```bash 22 | comfy node registry-install civicomfy 23 | ``` 24 | 25 | ComfyUI Manager 26 | 27 | Screenshot 2025-04-08 at 11 42 46 28 | 29 | ## Usage 30 | 31 | 1. Start ComfyUI with Civicomfy installed 32 | 2. Access the Civicomfy panel from the Civicomfy menu button at the right top area. 33 | 3. Search for models 34 | 4. Click the download button on any model to save it to your local installation 35 | 5. Models become immediately available in ComfyUI nodes 36 | 37 | ## Configuration 38 | 39 | - Enter your Civitai API Token in the setting 40 | 41 | ## Screenshots 42 | Screenshot 2025-04-08 at 11 24 40 43 | Screenshot 2025-04-08 at 11 23 17 44 | Screenshot 2025-04-08 at 11 25 15 45 | Screenshot 2025-04-08 at 11 25 24 46 | 47 | 48 | 49 | 50 | ## Contributing 51 | 52 | Contributions are welcome! Please feel free to submit a Pull Request. 53 | -------------------------------------------------------------------------------- /server/routes/CancelDownload.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/CancelDownload.py 3 | # ================================================ 4 | import json 5 | from aiohttp import web 6 | import server # ComfyUI server instance 7 | from ..utils import get_request_json 8 | from ...downloader.manager import manager as download_manager 9 | 10 | prompt_server = server.PromptServer.instance 11 | 12 | @prompt_server.routes.post("/civitai/cancel") 13 | async def route_cancel_download(request): 14 | """API Endpoint to cancel a download.""" 15 | try: 16 | data = await get_request_json(request) 17 | download_id = data.get("download_id") 18 | if not download_id: 19 | print("not download id " + download_id) 20 | raise web.HTTPBadRequest(reason="Missing 'download_id'") 21 | 22 | success = download_manager.cancel_download(download_id) 23 | if success: 24 | return web.json_response({ 25 | "status": "cancelled", # Or "cancellation_requested" ? 26 | "message": f"Cancellation requested for download ID: {download_id}.", 27 | "download_id": download_id 28 | }) 29 | else: 30 | # Might be already completed/failed/cancelled and in history, or invalid ID 31 | raise web.HTTPNotFound(reason=f"Download ID {download_id} not found in active queue or running downloads.") 32 | 33 | except web.HTTPError as http_err: 34 | # Consistent error handling 35 | body_detail = "" 36 | try: 37 | body_detail = await http_err.text() if hasattr(http_err, 'text') else http_err.body.decode('utf-8', errors='ignore') if http_err.body else "" 38 | if body_detail.startswith('{') and body_detail.endswith('}'): body_detail = json.loads(body_detail) 39 | except Exception: pass 40 | return web.json_response({"error": http_err.reason, "details": body_detail or "No details", "status_code": http_err.status}, status=http_err.status) 41 | 42 | except Exception as e: 43 | print(f"Error cancelling download: {e}") 44 | return web.json_response({"error": "Internal Server Error", "details": f"Failed to cancel download: {str(e)}", "status_code": 500}, status=500) -------------------------------------------------------------------------------- /server/routes/OpenPath.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/OpenPath.py 3 | # ================================================ 4 | import asyncio 5 | import json 6 | from aiohttp import web 7 | 8 | import server # ComfyUI server instance 9 | from ...downloader.manager import manager as download_manager 10 | 11 | prompt_server = server.PromptServer.instance 12 | 13 | @prompt_server.routes.post("/civitai/open_path") 14 | async def route_open_path(request): 15 | """API Endpoint to open the containing folder of a completed download.""" 16 | if not download_manager: 17 | return web.json_response({"error": "Download Manager not initialized"}, status=500) 18 | 19 | try: 20 | data = await request.json() 21 | download_id = data.get("download_id") 22 | 23 | if not download_id: 24 | return web.json_response({"error": "Missing 'download_id'", "details": "The request body must contain the 'download_id' of the completed item."}, status=400) 25 | 26 | print(f"[API Route /civitai/open_path] Received open path request for ID: {download_id}") 27 | # Call manager method in thread 28 | result = await asyncio.to_thread(download_manager.open_containing_folder, download_id) 29 | 30 | status_code = 200 if result.get("success") else 404 if "not found" in result.get("error", "").lower() else 400 # Use 400 for OS error, security etc 31 | # Check for specific errors to return better codes 32 | if not result.get("success"): 33 | error_lower = result.get("error", "").lower() 34 | if "directory does not exist" in error_lower or "id not found" in error_lower: 35 | status_code = 404 36 | elif "cannot open path" in error_lower or "unsupported os" in error_lower or "failed to open" in error_lower or "xdg-open" in error_lower: 37 | status_code = 501 # Not Implemented / Failed on server side 38 | elif "must be 'completed'" in error_lower: 39 | status_code = 409 # Conflict - wrong state 40 | else: 41 | status_code = 400 # Bad Request / general failure 42 | 43 | # Prevent sensitive path info leakage in error messages by default 44 | if not result.get("success") and "error" in result and status_code != 200: 45 | print(f"[API Route /civitai/open_path] Error for ID {download_id}: {result['error']}") # Log full error on server 46 | # Optionally sanitize error sent to client 47 | # if "Directory:" in result["error"] or "Path:" in result["error"]: 48 | # result["error"] = "Server failed to open the specified directory." 49 | 50 | return web.json_response(result, status=status_code) 51 | 52 | except json.JSONDecodeError: 53 | return web.json_response({"error": "Invalid JSON body"}, status=400) 54 | except Exception as e: 55 | import traceback 56 | print(f"Error handling /civitai/open_path request for ID '{data.get('download_id', 'N/A')}': {e}") 57 | # traceback.print_exc() 58 | return web.json_response({"error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}"}, status=500) -------------------------------------------------------------------------------- /web/js/civitaiDownloader.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { addCssLink } from "./utils/dom.js"; 3 | import { CivitaiDownloaderUI } from "./ui/UI.js"; 4 | 5 | console.log("Loading Civicomfy UI..."); 6 | 7 | // --- Configuration --- 8 | const EXTENSION_NAME = "Civicomfy"; 9 | const CSS_URL = `../civitaiDownloader.css`; 10 | const PLACEHOLDER_IMAGE_URL = `/extensions/Civicomfy/images/placeholder.jpeg`; 11 | 12 | // Add Menu Button to ComfyUI 13 | function addMenuButton() { 14 | const buttonGroup = document.querySelector(".comfyui-button-group"); 15 | 16 | if (!buttonGroup) { 17 | console.warn(`[${EXTENSION_NAME}] ComfyUI button group not found. Retrying...`); 18 | setTimeout(addMenuButton, 500); 19 | return; 20 | } 21 | 22 | if (document.getElementById("civitai-downloader-button")) { 23 | console.log(`[${EXTENSION_NAME}] Button already exists.`); 24 | return; 25 | } 26 | 27 | const civitaiButton = document.createElement("button"); 28 | civitaiButton.textContent = "Civicomfy"; 29 | civitaiButton.id = "civitai-downloader-button"; 30 | civitaiButton.title = "Open Civicomfy"; 31 | 32 | civitaiButton.onclick = async () => { 33 | if (!window.civitaiDownloaderUI) { 34 | console.info(`[${EXTENSION_NAME}] Creating CivitaiDownloaderUI instance...`); 35 | window.civitaiDownloaderUI = new CivitaiDownloaderUI(); 36 | document.body.appendChild(window.civitaiDownloaderUI.modal); 37 | 38 | try { 39 | await window.civitaiDownloaderUI.initializeUI(); 40 | console.info(`[${EXTENSION_NAME}] UI Initialization complete.`); 41 | } catch (error) { 42 | console.error(`[${EXTENSION_NAME}] Error during UI initialization:`, error); 43 | window.civitaiDownloaderUI?.showToast("Error initializing UI components. Check console.", "error", 5000); 44 | } 45 | } 46 | 47 | if (window.civitaiDownloaderUI) { 48 | window.civitaiDownloaderUI.openModal(); 49 | } else { 50 | console.error(`[${EXTENSION_NAME}] Cannot open modal: UI instance not available.`); 51 | alert("Civicomfy failed to initialize. Please check the browser console for errors."); 52 | } 53 | }; 54 | 55 | buttonGroup.appendChild(civitaiButton); 56 | console.log(`[${EXTENSION_NAME}] Civicomfy button added to .comfyui-button-group.`); 57 | 58 | const menu = document.querySelector(".comfy-menu"); 59 | if (!buttonGroup.contains(civitaiButton) && menu && !menu.contains(civitaiButton)) { 60 | console.warn(`[${EXTENSION_NAME}] Failed to append button to group, falling back to menu.`); 61 | const settingsButton = menu.querySelector("#comfy-settings-button"); 62 | if (settingsButton) { 63 | settingsButton.insertAdjacentElement("beforebegin", civitaiButton); 64 | } else { 65 | menu.appendChild(civitaiButton); 66 | } 67 | } 68 | } 69 | 70 | // --- Initialization --- 71 | app.registerExtension({ 72 | name: "Civicomfy.CivitaiDownloader", 73 | async setup(appInstance) { 74 | console.log(`[${EXTENSION_NAME}] Setting up Civicomfy Extension...`); 75 | addCssLink(CSS_URL); 76 | addMenuButton(); 77 | 78 | // Optional: Pre-check placeholder image 79 | fetch(PLACEHOLDER_IMAGE_URL) 80 | .then(res => { 81 | if (!res.ok) { 82 | console.warn(`[${EXTENSION_NAME}] Placeholder image not found at ${PLACEHOLDER_IMAGE_URL}.`); 83 | } 84 | }) 85 | .catch(err => console.warn(`[${EXTENSION_NAME}] Error checking for placeholder image:`, err)); 86 | 87 | console.log(`[${EXTENSION_NAME}] Extension setup complete. UI will initialize on first click.`); 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | download_history.json 162 | .DS_Store 163 | /Old Reference 164 | CLAUDE.md 165 | web/.DS_Store 166 | /tests 167 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: config.py 3 | # ================================================ 4 | import os 5 | import folder_paths # Use ComfyUI's folder_paths 6 | 7 | # --- Configuration --- 8 | MAX_CONCURRENT_DOWNLOADS = 3 9 | DEFAULT_CHUNK_SIZE = 1024 * 1024 # 1MB 10 | DEFAULT_CONNECTIONS = 4 11 | DOWNLOAD_HISTORY_LIMIT = 100 12 | DOWNLOAD_TIMEOUT = 60 # Timeout for individual download chunks/requests (seconds) 13 | HEAD_REQUEST_TIMEOUT = 25 # Timeout for initial HEAD request (seconds) 14 | METADATA_DOWNLOAD_TIMEOUT = 20 # Timeout for downloading thumbnail (seconds) 15 | 16 | # --- Paths --- 17 | # The root directory of *this specific plugin/extension* 18 | # Calculated based on the location of this config.py file 19 | PLUGIN_ROOT = os.path.dirname(os.path.realpath(__file__)) 20 | 21 | # Construct web paths relative to the plugin's root directory 22 | WEB_DIRECTORY = os.path.join(PLUGIN_ROOT, "web") 23 | JAVASCRIPT_PATH = os.path.join(WEB_DIRECTORY, "js") 24 | CSS_PATH = os.path.join(WEB_DIRECTORY, "css") 25 | # Corrected path construction to avoid issues with leading slashes 26 | PLACEHOLDER_IMAGE_PATH = os.path.join(WEB_DIRECTORY, "images", "placeholder.jpeg") 27 | 28 | # Get ComfyUI directories using folder_paths 29 | COMFYUI_ROOT_DIR = folder_paths.base_path 30 | # MODELS_DIR removed; resolve per-type via folder_paths 31 | 32 | # --- Model Types --- 33 | # Maps the internal key (lowercase) to a tuple: (display_name, folder_paths_type) 34 | # The folder_paths_type is used by ComfyUI's folder_paths.get_directory_by_type(). 35 | MODEL_TYPE_DIRS = { 36 | "checkpoint": ("Checkpoint", "checkpoints"), 37 | "diffusionmodels": ("Diffusion Models", "diffusers"), 38 | "unet": ("Unet", "unet"), 39 | "lora": ("Lora", "loras"), 40 | "locon": ("LoCon", "loras"), 41 | "lycoris": ("LyCORIS", "loras"), 42 | "vae": ("VAE", "vae"), 43 | "embedding": ("Embedding", "embeddings"), 44 | "hypernetwork": ("Hypernetwork", "hypernetworks"), 45 | "controlnet": ("ControlNet", "controlnet"), 46 | "upscaler": ("Upscaler", "upscale_models"), 47 | "motionmodule": ("Motion Module", "motion_models"), 48 | "poses": ("Poses", "poses"), 49 | "wildcards": ("Wildcards", "wildcards"), 50 | # 'other' will save to a dedicated folder inside the Civicomfy extension directory 51 | "other": ("Other", None) 52 | } 53 | 54 | # Civitai API specific type mapping (for search filters) 55 | # Maps internal key (lowercase) to Civitai API 'types' parameter value 56 | CIVITAI_API_TYPE_MAP = { 57 | "checkpoint": "Checkpoint", 58 | "lora": "LORA", 59 | "locon": "LoCon", 60 | "lycoris": "LORA", # Civitai might group LyCORIS under LORA search type 61 | "vae": "VAE", 62 | "embedding": "TextualInversion", 63 | "hypernetwork": "Hypernetwork", 64 | "controlnet": "Controlnet", 65 | "motionmodule": "MotionModule", 66 | "poses": "Poses", 67 | "wildcards": "Wildcards", 68 | "upscaler": "Upscaler", 69 | "unet": "UNET", 70 | "diffusionmodels": "Checkpoint", # No specific type, map to checkpoint 71 | } 72 | 73 | AVAILABLE_MEILI_BASE_MODELS = [ 74 | "AuraFlow", "CogVideoX", "Flux.1 D", "Flux.1 S", "Hunyuan 1", "Hunyuan Video", 75 | "Illustrious", "Kolors", "LTXV", "Lumina", "Mochi", "NoobAI", "ODOR", "Other", 76 | "PixArt E", "PixArt a", "Playground v2", "Pony", "SD 1.4", "SD 1.5", 77 | "SD 1.5 Hyper", "SD 1.5 LCM", "SD 2.0", "SD 2.0 768", "SD 2.1", "SD 2.1 768", 78 | "SD 2.1 Unclip", "SD 3", "SD 3.5", "SD 3.5 Large", "SD 3.5 Large Turbo", 79 | "SD 3.5 Medium", "SDXL 0.9", "SDXL 1.0", "SDXL 1.0 LCM", "SDXL Distilled", 80 | "SDXL Hyper", "SDXL Lightning", "SDXL Turbo", "SVD", "SVD XT", "Stable Cascade", 81 | "Wan Video" 82 | ] 83 | 84 | # --- Filename Suffixes --- 85 | METADATA_SUFFIX = ".cminfo.json" 86 | PREVIEW_SUFFIX = ".preview.jpeg" # Keep as requested, even if source is png/webp 87 | 88 | # --- Log Initial Paths for Verification --- 89 | print("-" * 30) 90 | print("[Civicomfy Config Initialized]") 91 | print(f" - Plugin Root: {PLUGIN_ROOT}") 92 | print(f" - Web Directory: {WEB_DIRECTORY}") 93 | print(f" - ComfyUI Base Path: {COMFYUI_ROOT_DIR}") 94 | print("-" * 30) 95 | -------------------------------------------------------------------------------- /web/js/ui/handlers/settingsHandler.js: -------------------------------------------------------------------------------- 1 | import { setCookie, getCookie } from "../../utils/cookies.js"; 2 | 3 | const SETTINGS_COOKIE_NAME = 'civitaiDownloaderSettings'; 4 | 5 | export function getDefaultSettings() { 6 | return { 7 | apiKey: '', 8 | numConnections: 1, 9 | defaultModelType: 'checkpoint', 10 | autoOpenStatusTab: true, 11 | searchResultLimit: 20, 12 | hideMatureInSearch: true, 13 | nsfwBlurMinLevel: 4, // Blur thumbnails with nsfwLevel >= this value 14 | }; 15 | } 16 | 17 | export function loadAndApplySettings(ui) { 18 | ui.settings = ui.loadSettingsFromCookie(); 19 | ui.applySettings(); 20 | } 21 | 22 | export function loadSettingsFromCookie(ui) { 23 | const defaults = ui.getDefaultSettings(); 24 | const cookieValue = getCookie(SETTINGS_COOKIE_NAME); 25 | 26 | if (cookieValue) { 27 | try { 28 | const loadedSettings = JSON.parse(cookieValue); 29 | return { ...defaults, ...loadedSettings }; 30 | } catch (e) { 31 | console.error("Failed to parse settings cookie:", e); 32 | return defaults; 33 | } 34 | } 35 | return defaults; 36 | } 37 | 38 | export function saveSettingsToCookie(ui) { 39 | try { 40 | const settingsString = JSON.stringify(ui.settings); 41 | setCookie(SETTINGS_COOKIE_NAME, settingsString, 365); 42 | ui.showToast('Settings saved successfully!', 'success'); 43 | } catch (e) { 44 | console.error("Failed to save settings to cookie:", e); 45 | ui.showToast('Error saving settings', 'error'); 46 | } 47 | } 48 | 49 | export function applySettings(ui) { 50 | if (ui.settingsApiKeyInput) { 51 | ui.settingsApiKeyInput.value = ui.settings.apiKey || ''; 52 | } 53 | if (ui.settingsConnectionsInput) { 54 | ui.settingsConnectionsInput.value = Math.max(1, Math.min(16, ui.settings.numConnections || 1)); 55 | } 56 | if (ui.settingsDefaultTypeSelect) { 57 | ui.settingsDefaultTypeSelect.value = ui.settings.defaultModelType || 'checkpoint'; 58 | } 59 | if (ui.settingsAutoOpenCheckbox) { 60 | ui.settingsAutoOpenCheckbox.checked = ui.settings.autoOpenStatusTab === true; 61 | } 62 | if (ui.settingsHideMatureCheckbox) { 63 | ui.settingsHideMatureCheckbox.checked = ui.settings.hideMatureInSearch === true; 64 | } 65 | if (ui.settingsNsfwThresholdInput) { 66 | const val = Number(ui.settings.nsfwBlurMinLevel); 67 | ui.settingsNsfwThresholdInput.value = Number.isFinite(val) ? val : 4; 68 | } 69 | if (ui.downloadConnectionsInput) { 70 | ui.downloadConnectionsInput.value = Math.max(1, Math.min(16, ui.settings.numConnections || 1)); 71 | } 72 | if (ui.downloadModelTypeSelect && Object.keys(ui.modelTypes).length > 0) { 73 | ui.downloadModelTypeSelect.value = ui.settings.defaultModelType || 'checkpoint'; 74 | } 75 | ui.searchPagination.limit = ui.settings.searchResultLimit || 20; 76 | } 77 | 78 | export function handleSettingsSave(ui) { 79 | const apiKey = ui.settingsApiKeyInput.value.trim(); 80 | const numConnections = parseInt(ui.settingsConnectionsInput.value, 10); 81 | const defaultModelType = ui.settingsDefaultTypeSelect.value; 82 | const autoOpenStatusTab = ui.settingsAutoOpenCheckbox.checked; 83 | const hideMatureInSearch = ui.settingsHideMatureCheckbox.checked; 84 | const nsfwBlurMinLevel = Number(ui.settingsNsfwThresholdInput.value); 85 | 86 | if (isNaN(numConnections) || numConnections < 1 || numConnections > 16) { 87 | ui.showToast("Invalid Default Connections (must be 1-16).", "error"); 88 | return; 89 | } 90 | if (!ui.settingsDefaultTypeSelect.querySelector(`option[value="${defaultModelType}"]`)) { 91 | ui.showToast("Invalid Default Model Type selected.", "error"); 92 | return; 93 | } 94 | 95 | ui.settings.apiKey = apiKey; 96 | ui.settings.numConnections = numConnections; 97 | ui.settings.defaultModelType = defaultModelType; 98 | ui.settings.autoOpenStatusTab = autoOpenStatusTab; 99 | ui.settings.hideMatureInSearch = hideMatureInSearch; 100 | ui.settings.nsfwBlurMinLevel = (Number.isFinite(nsfwBlurMinLevel) && nsfwBlurMinLevel >= 0) ? Math.min(128, Math.round(nsfwBlurMinLevel)) : 4; 101 | 102 | ui.saveSettingsToCookie(); 103 | ui.applySettings(); 104 | } 105 | -------------------------------------------------------------------------------- /web/js/ui/handlers/downloadHandler.js: -------------------------------------------------------------------------------- 1 | import { CivitaiDownloaderAPI } from "../../api/civitai.js"; 2 | 3 | export function debounceFetchDownloadPreview(ui, delay = 500) { 4 | clearTimeout(ui.modelPreviewDebounceTimeout); 5 | ui.modelPreviewDebounceTimeout = setTimeout(() => { 6 | fetchAndDisplayDownloadPreview(ui); 7 | }, delay); 8 | } 9 | 10 | export async function fetchAndDisplayDownloadPreview(ui) { 11 | const modelUrlOrId = ui.modelUrlInput.value.trim(); 12 | const versionId = ui.modelVersionIdInput.value.trim(); 13 | 14 | if (!modelUrlOrId) { 15 | ui.downloadPreviewArea.innerHTML = ''; 16 | return; 17 | } 18 | 19 | ui.downloadPreviewArea.innerHTML = '

Loading model details...

'; 20 | ui.ensureFontAwesome(); 21 | 22 | const params = { 23 | model_url_or_id: modelUrlOrId, 24 | model_version_id: versionId ? parseInt(versionId, 10) : null, 25 | api_key: ui.settings.apiKey 26 | }; 27 | 28 | try { 29 | const result = await CivitaiDownloaderAPI.getModelDetails(params); 30 | if (result && result.success) { 31 | ui.renderDownloadPreview(result); 32 | // Auto-select model type save location based on Civitai model type 33 | if (result.model_type) { 34 | await ui.autoSelectModelTypeFromCivitai(result.model_type); 35 | } 36 | } else { 37 | const message = `Failed to get details: ${result.details || result.error || 'Unknown backend error'}`; 38 | ui.downloadPreviewArea.innerHTML = `

${message}

`; 39 | } 40 | } catch (error) { 41 | const message = `Error fetching details: ${error.details || error.message || 'Unknown error'}`; 42 | console.error("Download Preview Fetch Error:", error); 43 | ui.downloadPreviewArea.innerHTML = `

${message}

`; 44 | } 45 | } 46 | 47 | export async function handleDownloadSubmit(ui) { 48 | if (!ui.settings.apiKey) { 49 | ui.showToast("API key empty, please fill your API key in the settings", "error"); 50 | ui.switchTab("settings"); 51 | return; 52 | } 53 | 54 | ui.downloadSubmitButton.disabled = true; 55 | ui.downloadSubmitButton.textContent = 'Starting...'; 56 | 57 | const modelUrlOrId = ui.modelUrlInput.value.trim(); 58 | if (!modelUrlOrId) { 59 | ui.showToast("Model URL or ID cannot be empty.", "error"); 60 | ui.downloadSubmitButton.disabled = false; 61 | ui.downloadSubmitButton.textContent = 'Start Download'; 62 | return; 63 | } 64 | 65 | // Subfolder comes from dropdown; filename is base name only 66 | const selectedSubdir = ui.subdirSelect ? ui.subdirSelect.value.trim() : ''; 67 | const userFilename = ui.customFilenameInput.value.trim(); 68 | 69 | const params = { 70 | model_url_or_id: modelUrlOrId, 71 | model_type: ui.downloadModelTypeSelect.value, 72 | model_version_id: ui.modelVersionIdInput.value ? parseInt(ui.modelVersionIdInput.value, 10) : null, 73 | custom_filename: userFilename, 74 | subdir: selectedSubdir, 75 | num_connections: parseInt(ui.downloadConnectionsInput.value, 10), 76 | force_redownload: ui.forceRedownloadCheckbox.checked, 77 | api_key: ui.settings.apiKey 78 | }; 79 | 80 | const fileSelectEl = ui.modal.querySelector('#civitai-file-select'); 81 | if (fileSelectEl && fileSelectEl.value) { 82 | const fid = parseInt(fileSelectEl.value, 10); 83 | if (!Number.isNaN(fid)) params.file_id = fid; 84 | } 85 | 86 | try { 87 | const result = await CivitaiDownloaderAPI.downloadModel(params); 88 | 89 | if (result.status === 'queued') { 90 | ui.showToast(`Download queued: ${result.details?.filename || 'Model'}`, 'success'); 91 | if (ui.settings.autoOpenStatusTab) { 92 | ui.switchTab('status'); 93 | } else { 94 | ui.updateStatus(); 95 | } 96 | } else if (result.status === 'exists' || result.status === 'exists_size_mismatch') { 97 | ui.showToast(`${result.message}`, 'info', 4000); 98 | } else { 99 | console.warn("Unexpected success response from /civitai/download:", result); 100 | ui.showToast(`Unexpected status: ${result.status} - ${result.message || ''}`, 'info'); 101 | } 102 | } catch (error) { 103 | const message = `Download failed: ${error.details || error.message || 'Unknown error'}`; 104 | console.error("Download Submit Error:", error); 105 | ui.showToast(message, 'error', 6000); 106 | } finally { 107 | ui.downloadSubmitButton.disabled = false; 108 | ui.downloadSubmitButton.textContent = 'Start Download'; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /web/js/api/civitai.js: -------------------------------------------------------------------------------- 1 | // API client for Civicomfy UI 2 | // Wraps ComfyUI's fetchApi with consistent error handling 3 | 4 | import { api } from "../../../../scripts/api.js"; 5 | 6 | export class CivitaiDownloaderAPI { 7 | static async _request(endpoint, options = {}) { 8 | try { 9 | const url = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; 10 | const response = await api.fetchApi(url, options); 11 | 12 | if (!response.ok) { 13 | let errorData; 14 | const status = response.status; 15 | const statusText = response.statusText; 16 | try { 17 | errorData = await response.json(); 18 | if (typeof errorData !== "object" || errorData === null) { 19 | errorData = { detail: String(errorData) }; 20 | } 21 | } catch (_) { 22 | const detailText = await response.text().catch(() => `Status ${status} - Could not read error text`); 23 | errorData = { 24 | error: `HTTP error ${status}`, 25 | details: String(detailText).substring(0, 500), 26 | }; 27 | } 28 | const err = new Error(errorData.error || errorData.reason || `HTTP Error: ${status} ${statusText}`); 29 | err.details = errorData.details || errorData.detail || errorData.error || "No details provided."; 30 | err.status = status; 31 | throw err; 32 | } 33 | 34 | if (response.status === 204 || response.headers.get("Content-Length") === "0") { 35 | return null; 36 | } 37 | return await response.json(); 38 | } catch (error) { 39 | if (!error.details) error.details = error.message; 40 | throw error; 41 | } 42 | } 43 | 44 | static async downloadModel(params) { 45 | return await this._request("/civitai/download", { 46 | method: "POST", 47 | headers: { "Content-Type": "application/json" }, 48 | body: JSON.stringify(params), 49 | }); 50 | } 51 | 52 | static async getModelDetails(params) { 53 | return await this._request("/civitai/get_model_details", { 54 | method: "POST", 55 | headers: { "Content-Type": "application/json" }, 56 | body: JSON.stringify(params), 57 | }); 58 | } 59 | 60 | static async getStatus() { 61 | return await this._request("/civitai/status"); 62 | } 63 | 64 | static async cancelDownload(downloadId) { 65 | return await this._request("/civitai/cancel", { 66 | method: "POST", 67 | headers: { "Content-Type": "application/json" }, 68 | body: JSON.stringify({ download_id: downloadId }), 69 | }); 70 | } 71 | 72 | static async searchModels(params) { 73 | return await this._request("/civitai/search", { 74 | method: "POST", 75 | headers: { "Content-Type": "application/json" }, 76 | body: JSON.stringify(params), 77 | }); 78 | } 79 | 80 | static async getBaseModels() { 81 | return await this._request("/civitai/base_models"); 82 | } 83 | 84 | static async getModelTypes() { 85 | return await this._request("/civitai/model_types"); 86 | } 87 | 88 | static async getModelDirs(modelType) { 89 | const q = encodeURIComponent(modelType || 'checkpoint'); 90 | return await this._request(`/civitai/model_dirs?type=${q}`); 91 | } 92 | 93 | static async createModelDir(modelType, newDir) { 94 | return await this._request("/civitai/create_dir", { 95 | method: "POST", 96 | headers: { "Content-Type": "application/json" }, 97 | body: JSON.stringify({ model_type: modelType, new_dir: newDir }), 98 | }); 99 | } 100 | 101 | static async createModelType(name) { 102 | return await this._request("/civitai/create_model_type", { 103 | method: "POST", 104 | headers: { "Content-Type": "application/json" }, 105 | body: JSON.stringify({ name }), 106 | }); 107 | } 108 | 109 | static async getModelRoots(modelType) { 110 | const q = encodeURIComponent(modelType || 'checkpoint'); 111 | return await this._request(`/civitai/model_roots?type=${q}`); 112 | } 113 | 114 | static async createModelRoot(modelType, absPath) { 115 | return await this._request("/civitai/create_root", { 116 | method: "POST", 117 | headers: { "Content-Type": "application/json" }, 118 | body: JSON.stringify({ model_type: modelType, path: absPath }), 119 | }); 120 | } 121 | 122 | static async retryDownload(downloadId) { 123 | return await this._request("/civitai/retry", { 124 | method: "POST", 125 | headers: { "Content-Type": "application/json" }, 126 | body: JSON.stringify({ download_id: downloadId }), 127 | }); 128 | } 129 | 130 | static async openPath(downloadId) { 131 | return await this._request("/civitai/open_path", { 132 | method: "POST", 133 | headers: { "Content-Type": "application/json" }, 134 | body: JSON.stringify({ download_id: downloadId }), 135 | }); 136 | } 137 | 138 | static async clearHistory() { 139 | return await this._request("/civitai/clear_history", { 140 | method: "POST", 141 | headers: { "Content-Type": "application/json" }, 142 | }); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: __init__.py 3 | # ================================================ 4 | 5 | import os 6 | 7 | # Define paths relative to this __init__.py file 8 | EXTENSION_ROOT = os.path.dirname(os.path.realpath(__file__)) 9 | WEB_PATH = os.path.join(EXTENSION_ROOT, "web") 10 | JS_PATH = os.path.join(WEB_PATH, "js") 11 | # CSS_PATH definition removed as it was unused and pointed to a non-existent directory. 12 | JS_FILENAME = "civitaiDownloader.js" 13 | CSS_FILENAME = "civitaiDownloader.css" 14 | JS_FILE_PATH = os.path.join(JS_PATH, JS_FILENAME) 15 | CSS_FILE_PATH = os.path.join(JS_PATH, CSS_FILENAME) 16 | 17 | # --- Import Core Components --- 18 | # Import configurations and utility functions first 19 | # Ensure config and helpers don't have side effects unsuitable for just checking files 20 | try: 21 | from .config import WEB_DIRECTORY as config_WEB_DIRECTORY 22 | # Import downloader manager (creates the instance) 23 | from .downloader import manager as download_manager 24 | # Import server routes (registers the routes) 25 | from .server import routes 26 | imports_successful = True 27 | print("[Civicomfy] Core modules imported successfully.") 28 | except ImportError as e: 29 | imports_successful = False 30 | print("*"*80) 31 | print(f"[Civicomfy] ERROR: Failed to import core modules: {e}") 32 | print("Please ensure the file structure is correct and all required files exist.") 33 | print("Extension will likely not function correctly.") 34 | print("*"*80) 35 | except Exception as e: 36 | imports_successful = False 37 | # Catch other potential init errors during import 38 | import traceback 39 | print("*"*80) 40 | print(f"[Civicomfy] ERROR: An unexpected error occurred during module import:") 41 | traceback.print_exc() 42 | print("Extension will likely not function correctly.") 43 | print("*"*80) 44 | 45 | # --- Check for Frontend Files --- 46 | frontend_files_ok = True 47 | if not os.path.exists(CSS_FILE_PATH): 48 | print("*"*80) 49 | print(f"[Civicomfy] WARNING: Frontend CSS file not found!") 50 | print(f" Expected at: {CSS_FILE_PATH}") 51 | print(" The downloader UI may not display correctly.") 52 | print(f" Please ensure '{CSS_FILENAME}' is placed in the '{os.path.basename(JS_PATH)}' directory inside 'web'.") # Corrected path hint 53 | print("*"*80) 54 | frontend_files_ok = False 55 | 56 | if not os.path.exists(JS_FILE_PATH): 57 | print("*"*80) 58 | print(f"[Civicomfy] WARNING: Frontend JavaScript file not found!") 59 | print(f" Expected at: {JS_FILE_PATH}") 60 | print(" The downloader UI functionality will be missing.") 61 | print(f" Please ensure '{JS_FILENAME}' is placed in the '{os.path.basename(JS_PATH)}' directory inside 'web'.") # Corrected path hint 62 | print("*"*80) 63 | frontend_files_ok = False 64 | 65 | # --- ComfyUI Registration --- 66 | if imports_successful: 67 | # Standard ComfyUI extension variables 68 | # No custom nodes defined in this extension 69 | NODE_CLASS_MAPPINGS = {} 70 | NODE_DISPLAY_NAME_MAPPINGS = {} 71 | 72 | # Define the web directory for ComfyUI to serve 73 | # The key is the path component in the URL: /extensions/Civicomfy/... 74 | # The value is the directory path relative to this __init__.py file 75 | WEB_DIRECTORY = "./web" # This tells ComfyUI to serve the ./web folder relative to this file 76 | 77 | # --- Startup Messages --- 78 | print("-" * 30) 79 | print("--- Civicomfy Custom Extension Loaded ---") 80 | print(f"- Serving frontend files from: {os.path.abspath(WEB_PATH)} (Relative: {WEB_DIRECTORY})") 81 | # Download manager and routes are initialized/registered upon import 82 | print(f"- Download Manager Initialized: {'Yes' if 'download_manager' in locals() else 'No! Import failed.'}") 83 | print(f"- API Endpoints Registered: {'Yes' if 'routes' in locals() else 'No! Import failed.'}") 84 | if frontend_files_ok: 85 | print("- Frontend files found.") 86 | else: 87 | print("- WARNING: Frontend files missing (see warnings above). UI may not work.") 88 | print("- Look for 'Civicomfy' button in the ComfyUI menu.") 89 | print("-" * 30) 90 | 91 | # Ensure default model-type directories exist at startup 92 | try: 93 | from .utils.helpers import get_model_dir 94 | from .config import MODEL_TYPE_DIRS 95 | created = [] 96 | for key in MODEL_TYPE_DIRS.keys(): 97 | path = get_model_dir(key) 98 | created.append((key, path)) 99 | print("[Civicomfy] Verified model type directories:") 100 | for k, p in created: 101 | print(f" - {k}: {p}") 102 | except Exception as e: 103 | print(f"[Civicomfy] Warning: Failed ensuring model directories at startup: {e}") 104 | 105 | else: 106 | # If imports failed, don't register anything with ComfyUI 107 | print("[Civicomfy] Initialization failed due to import errors. Extension inactive.") 108 | NODE_CLASS_MAPPINGS = {} 109 | NODE_DISPLAY_NAME_MAPPINGS = {} 110 | WEB_DIRECTORY = None # Do not serve web directory if backend failed 111 | -------------------------------------------------------------------------------- /web/js/ui/previewRenderer.js: -------------------------------------------------------------------------------- 1 | // Renders the download preview panel 2 | 3 | const PLACEHOLDER_IMAGE_URL = `/extensions/Civicomfy/images/placeholder.jpeg`; 4 | 5 | export function renderDownloadPreview(ui, data) { 6 | if (!ui.downloadPreviewArea) return; 7 | ui.ensureFontAwesome(); 8 | 9 | const modelId = data.model_id; 10 | const modelName = data.model_name || 'Untitled Model'; 11 | const creator = data.creator_username || 'Unknown Creator'; 12 | const modelType = data.model_type || 'N/A'; 13 | const versionName = data.version_name || 'N/A'; 14 | const baseModel = data.base_model || 'N/A'; 15 | const stats = data.stats || {}; 16 | const descriptionHtml = data.description_html || '

No description.

'; 17 | const version_description_html = data.version_description_html || '

No description.

'; 18 | const fileInfo = data.file_info || {}; 19 | const files = Array.isArray(data.files) ? data.files : []; 20 | const thumbnail = data.thumbnail_url || PLACEHOLDER_IMAGE_URL; 21 | const nsfwLevel = Number(data.nsfw_level ?? 0); 22 | const blurMinLevel = Number(ui.settings?.nsfwBlurMinLevel ?? 4); 23 | const shouldBlur = ui.settings?.hideMatureInSearch === true && nsfwLevel >= blurMinLevel; 24 | const civitaiLink = `https://civitai.com/models/${modelId}${data.version_id ? '?modelVersionId=' + data.version_id : ''}`; 25 | 26 | const onErrorScript = `this.onerror=null; this.src='${PLACEHOLDER_IMAGE_URL}'; this.style.backgroundColor='#444';`; 27 | 28 | const overlayHtml = shouldBlur ? `
R
` : ''; 29 | const containerClasses = `civitai-thumbnail-container${shouldBlur ? ' blurred' : ''}`; 30 | 31 | const previewHtml = ` 32 |
33 |
34 | ${modelName} thumbnail 35 | ${overlayHtml} 36 |
${modelType}
37 |
38 |
39 |

${modelName} by ${creator}

40 |

Version: ${versionName} ${baseModel}

41 |
42 | ${stats.downloads?.toLocaleString() || 0} 43 | ${stats.likes?.toLocaleString(0) || 0} 44 | ${stats.dislikes?.toLocaleString() || 0} 45 | ${stats.buzz?.toLocaleString() || 0} 46 |
47 |

Primary File:

48 |

49 | Name: ${fileInfo.name || 'N/A'}
50 | Size: ${ui.formatBytes(fileInfo.size_kb * 1024) || 'N/A'}
51 | Format: ${fileInfo.format || 'N/A'}
52 | Precision: ${fileInfo.precision || 'N/A'}
53 | Model Size: ${fileInfo.model_size || 'N/A'} 54 |

55 | ${files.length > 0 ? ` 56 |
57 | 58 | 73 |

Pick other variants when available.

74 |
75 | ` : ''} 76 | 77 | View on Civitai 78 | 79 |
80 |
81 |
82 |
Model Description:
83 |
84 | ${descriptionHtml} 85 |
86 |
87 |
88 |
Version Description:
89 |
90 | ${version_description_html} 91 |
92 |
93 | `; 94 | 95 | ui.downloadPreviewArea.innerHTML = previewHtml; 96 | } 97 | -------------------------------------------------------------------------------- /web/js/ui/handlers/statusHandler.js: -------------------------------------------------------------------------------- 1 | import { CivitaiDownloaderAPI } from "../../api/civitai.js"; 2 | 3 | export function startStatusUpdates(ui) { 4 | if (!ui.statusInterval) { 5 | console.log("[Civicomfy] Starting status updates (every 3s)..."); 6 | ui.updateStatus(); 7 | ui.statusInterval = setInterval(() => ui.updateStatus(), 3000); 8 | } 9 | } 10 | 11 | export function stopStatusUpdates(ui) { 12 | if (ui.statusInterval) { 13 | clearInterval(ui.statusInterval); 14 | ui.statusInterval = null; 15 | console.log("[Civicomfy] Stopped status updates."); 16 | } 17 | } 18 | 19 | export async function updateStatus(ui) { 20 | if (!ui.modal || !ui.modal.classList.contains('open')) return; 21 | 22 | try { 23 | const newStatusData = await CivitaiDownloaderAPI.getStatus(); 24 | if (!newStatusData || !Array.isArray(newStatusData.active) || !Array.isArray(newStatusData.queue) || !Array.isArray(newStatusData.history)) { 25 | throw new Error("Invalid status data structure received from server."); 26 | } 27 | 28 | const oldStateString = JSON.stringify(ui.statusData); 29 | const newStateString = JSON.stringify(newStatusData); 30 | 31 | // Cache new state if it differs 32 | if (oldStateString !== newStateString) { 33 | ui.statusData = newStatusData; 34 | } 35 | 36 | // Always keep counters in sync 37 | const activeCount = ui.statusData.active.length + ui.statusData.queue.length; 38 | ui.activeCountSpan.textContent = activeCount; 39 | ui.statusIndicator.style.display = activeCount > 0 ? 'inline' : 'none'; 40 | 41 | // Always render when Status tab is active, even if data hasn't changed 42 | if (ui.activeTab === 'status') { 43 | ui.renderDownloadList(ui.statusData.active, ui.activeListContainer, 'No active downloads.'); 44 | ui.renderDownloadList(ui.statusData.queue, ui.queuedListContainer, 'Download queue is empty.'); 45 | ui.renderDownloadList(ui.statusData.history, ui.historyListContainer, 'No download history yet.'); 46 | } 47 | } catch (error) { 48 | console.error("[Civicomfy] Failed to update status:", error); 49 | if (ui.activeTab === 'status') { 50 | const errorHtml = `

${error.details || error.message}

`; 51 | if (ui.activeListContainer) ui.activeListContainer.innerHTML = errorHtml; 52 | if (ui.queuedListContainer) ui.queuedListContainer.innerHTML = ''; 53 | if (ui.historyListContainer) ui.historyListContainer.innerHTML = ''; 54 | } 55 | } 56 | } 57 | 58 | export async function handleCancelDownload(ui, downloadId) { 59 | const button = ui.modal.querySelector(`.civitai-cancel-button[data-id="${downloadId}"]`); 60 | if (button) { 61 | button.disabled = true; 62 | button.innerHTML = ''; 63 | button.title = "Cancelling..."; 64 | } 65 | try { 66 | const result = await CivitaiDownloaderAPI.cancelDownload(downloadId); 67 | ui.showToast(result.message || `Cancellation requested for ${downloadId}`, 'info'); 68 | ui.updateStatus(); 69 | } catch (error) { 70 | const message = `Cancel failed: ${error.details || error.message}`; 71 | console.error("Cancel Download Error:", error); 72 | ui.showToast(message, 'error'); 73 | if (button) { 74 | button.disabled = false; 75 | button.innerHTML = ''; 76 | button.title = "Cancel Download"; 77 | } 78 | } 79 | } 80 | 81 | export async function handleRetryDownload(ui, downloadId, button) { 82 | button.disabled = true; 83 | button.innerHTML = ''; 84 | button.title = "Retrying..."; 85 | try { 86 | const result = await CivitaiDownloaderAPI.retryDownload(downloadId); 87 | if (result.success) { 88 | ui.showToast(result.message || `Retry queued successfully!`, 'success'); 89 | if (ui.settings.autoOpenStatusTab) ui.switchTab('status'); 90 | else ui.updateStatus(); 91 | } else { 92 | ui.showToast(`Retry failed: ${result.details || result.error}`, 'error', 5000); 93 | button.disabled = false; 94 | button.innerHTML = ''; 95 | button.title = "Retry Download"; 96 | } 97 | } catch (error) { 98 | const message = `Retry failed: ${error.details || error.message}`; 99 | console.error("Retry Download UI Error:", error); 100 | ui.showToast(message, 'error', 5000); 101 | button.disabled = false; 102 | button.innerHTML = ''; 103 | button.title = "Retry Download"; 104 | } 105 | } 106 | 107 | export async function handleOpenPath(ui, downloadId, button) { 108 | const originalIcon = button.innerHTML; 109 | button.disabled = true; 110 | button.innerHTML = ''; 111 | button.title = "Opening..."; 112 | try { 113 | const result = await CivitaiDownloaderAPI.openPath(downloadId); 114 | if (result.success) { 115 | ui.showToast(result.message || `Opened path successfully!`, 'success'); 116 | } else { 117 | ui.showToast(`Open path failed: ${result.details || result.error}`, 'error', 5000); 118 | } 119 | } catch (error) { 120 | const message = `Open path failed: ${error.details || error.message}`; 121 | console.error("Open Path UI Error:", error); 122 | ui.showToast(message, 'error', 5000); 123 | } finally { 124 | button.disabled = false; 125 | button.innerHTML = originalIcon; 126 | button.title = "Open Containing Folder"; 127 | } 128 | } 129 | 130 | export async function handleClearHistory(ui) { 131 | ui.confirmClearYesButton.disabled = true; 132 | ui.confirmClearNoButton.disabled = true; 133 | ui.confirmClearYesButton.textContent = 'Clearing...'; 134 | 135 | try { 136 | const result = await CivitaiDownloaderAPI.clearHistory(); 137 | if (result.success) { 138 | ui.showToast(result.message || 'History cleared successfully!', 'success'); 139 | ui.statusData.history = []; 140 | ui.renderDownloadList(ui.statusData.history, ui.historyListContainer, 'No download history yet.'); 141 | ui.confirmClearModal.style.display = 'none'; 142 | } else { 143 | ui.showToast(`Clear history failed: ${result.details || result.error}`, 'error', 5000); 144 | } 145 | } catch (error) { 146 | const message = `Clear history failed: ${error.details || error.message}`; 147 | console.error("Clear History UI Error:", error); 148 | ui.showToast(message, 'error', 5000); 149 | } finally { 150 | ui.confirmClearYesButton.disabled = false; 151 | ui.confirmClearNoButton.disabled = false; 152 | ui.confirmClearYesButton.textContent = 'Confirm Clear'; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /web/js/ui/statusRenderer.js: -------------------------------------------------------------------------------- 1 | // Renders active/queued/history download lists 2 | 3 | const PLACEHOLDER_IMAGE_URL = `/extensions/Civicomfy/images/placeholder.jpeg`; 4 | 5 | export function renderDownloadList(ui, items, container, emptyMessage) { 6 | if (!items || items.length === 0) { 7 | container.innerHTML = `

${emptyMessage}

`; 8 | return; 9 | } 10 | 11 | const fragment = document.createDocumentFragment(); 12 | items.forEach(item => { 13 | const id = item.id || 'unknown-id'; 14 | const progress = item.progress !== undefined ? Math.max(0, Math.min(100, item.progress)) : 0; 15 | const speed = item.speed !== undefined ? Math.max(0, item.speed) : 0; 16 | const status = item.status || 'unknown'; 17 | const size = item.known_size !== undefined && item.known_size !== null ? item.known_size : (item.file_size || 0); 18 | const downloadedBytes = size > 0 ? size * (progress / 100) : 0; 19 | const errorMsg = item.error || null; 20 | const modelName = item.model_name || item.model?.name || 'Unknown Model'; 21 | const versionName = item.version_name || 'Unknown Version'; 22 | const filename = item.filename || 'N/A'; 23 | const addedTime = item.added_time || null; 24 | const startTime = item.start_time || null; 25 | const endTime = item.end_time || null; 26 | const thumbnail = item.thumbnail || PLACEHOLDER_IMAGE_URL; 27 | const nsfwLevel = Number(item.thumbnail_nsfw_level ?? 0); 28 | const blurMinLevel = Number(ui.settings?.nsfwBlurMinLevel ?? 4); 29 | const shouldBlur = ui.settings?.hideMatureInSearch === true && nsfwLevel >= blurMinLevel; 30 | const connectionType = item.connection_type || "N/A"; 31 | 32 | let progressBarClass = ''; 33 | let statusText = status.charAt(0).toUpperCase() + status.slice(1); 34 | switch (status) { 35 | case 'completed': progressBarClass = 'completed'; break; 36 | case 'failed': progressBarClass = 'failed'; statusText = 'Failed'; break; 37 | case 'cancelled': progressBarClass = 'cancelled'; statusText = 'Cancelled'; break; 38 | case 'downloading': case 'queued': case 'starting': default: break; 39 | } 40 | 41 | const listItem = document.createElement('div'); 42 | listItem.className = 'civitai-download-item'; 43 | listItem.dataset.id = id; 44 | 45 | const onErrorScript = `this.onerror=null; this.src='${PLACEHOLDER_IMAGE_URL}'; this.style.backgroundColor='#444';`; 46 | const addedTooltip = addedTime ? `data-tooltip="Added: ${new Date(addedTime).toLocaleString()}"` : ''; 47 | const startedTooltip = startTime ? `data-tooltip="Started: ${new Date(startTime).toLocaleString()}"` : ''; 48 | const endedTooltip = endTime ? `data-tooltip="Ended: ${new Date(endTime).toLocaleString()}"` : ''; 49 | const durationTooltip = startTime && endTime ? `data-tooltip="Duration: ${ui.formatDuration(startTime, endTime)}"` : ''; 50 | const filenameTooltip = filename !== 'N/A' ? `title="Filename: ${filename}"` : ''; 51 | const errorTooltip = errorMsg ? `title="Error Details: ${String(errorMsg).substring(0, 200)}${String(errorMsg).length > 200 ? '...' : ''}"` : ''; 52 | const connectionInfoHtml = connectionType !== "N/A" ? `(Conn: ${connectionType})` : ''; 53 | 54 | const overlayHtml = shouldBlur ? `
R
` : ''; 55 | const containerClasses = `civitai-thumbnail-container${shouldBlur ? ' blurred' : ''}`; 56 | 57 | let innerHTML = ` 58 |
59 | thumbnail 60 | ${overlayHtml} 61 |
62 |
63 | ${modelName} 64 |

Ver: ${versionName}

65 |

${filename}

66 | ${size > 0 ? `

Size: ${ui.formatBytes(size)}

` : ''} 67 | ${item.file_format ? `

Format: ${item.file_format}

` : ''} 68 | ${item.file_precision || item.file_model_size ? `

${item.file_precision ? 'Precision: ' + String(item.file_precision).toUpperCase() : ''}${item.file_precision && item.file_model_size ? ' • ' : ''}${item.file_model_size ? 'Model Size: ' + item.file_model_size : ''}

` : ''} 69 | ${errorMsg ? `

${String(errorMsg).substring(0, 100)}${String(errorMsg).length > 100 ? '...' : ''}

` : ''} 70 | `; 71 | 72 | if (status === 'downloading' || status === 'starting' || status === 'completed') { 73 | const statusLine = `
Status: ${statusText} ${connectionInfoHtml}
`; 74 | innerHTML += ` 75 |
76 |
77 | ${progress > 15 ? progress.toFixed(0)+'%' : ''} 78 |
79 |
80 | `; 81 | const speedText = (status === 'downloading' && speed > 0) ? ui.formatSpeed(speed) : ''; 82 | const progressText = (status === 'downloading' && size > 0) ? `(${ui.formatBytes(downloadedBytes)} / ${ui.formatBytes(size)})` : ''; 83 | const completedText = status === 'completed' ? '' : ''; 84 | const speedProgLine = `
${speedText} ${progressText} ${completedText}
`; 85 | if (status === 'downloading') { innerHTML += speedProgLine; } 86 | innerHTML += statusLine; 87 | } else if (status === 'failed' || status === 'cancelled' || status === 'queued') { 88 | innerHTML += `
Status: ${statusText} ${connectionInfoHtml}
`; 89 | } else { 90 | innerHTML += `
Status: ${statusText} ${connectionInfoHtml}
`; 91 | } 92 | 93 | innerHTML += `
`; 94 | innerHTML += `
`; 95 | if (status === 'queued' || status === 'downloading' || status === 'starting') { 96 | innerHTML += ``; 97 | } 98 | if (status === 'failed' || status === 'cancelled') { 99 | innerHTML += ``; 100 | } 101 | if (status === 'completed') { 102 | innerHTML += ``; 103 | } 104 | innerHTML += `
`; 105 | 106 | listItem.innerHTML = innerHTML; 107 | fragment.appendChild(listItem); 108 | }); 109 | 110 | container.innerHTML = ''; 111 | container.appendChild(fragment); 112 | ui.ensureFontAwesome(); 113 | } 114 | -------------------------------------------------------------------------------- /server/routes/SearchModels.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/SearchModels.py 3 | # ================================================ 4 | import json 5 | import math 6 | import traceback 7 | from aiohttp import web 8 | 9 | import server # ComfyUI server instance 10 | from ..utils import get_request_json 11 | from ...api.civitai import CivitaiAPI 12 | from ...config import CIVITAI_API_TYPE_MAP 13 | 14 | prompt_server = server.PromptServer.instance 15 | 16 | @prompt_server.routes.post("/civitai/search") 17 | async def route_search_models(request): 18 | """API Endpoint for searching models using Civitai's Meilisearch.""" 19 | api_key = None # Meili might not use the standard key 20 | try: 21 | data = await get_request_json(request) 22 | 23 | query = data.get("query", "").strip() 24 | model_type_keys = data.get("model_types", []) # e.g., ["lora", "checkpoint"] (frontend internal keys) 25 | base_model_filters = data.get("base_models", []) # e.g., ["SD 1.5", "Pony"] 26 | sort = data.get("sort", "Most Downloaded") # Frontend display value 27 | # Make period optional or remove if not supported by Meili sort directly 28 | # period = data.get("period", "AllTime") 29 | limit = int(data.get("limit", 20)) 30 | page = int(data.get("page", 1)) 31 | api_key = data.get("api_key", "") # Keep for potential future use or different endpoints 32 | nsfw = data.get("nsfw", None) # Expect Boolean or None 33 | 34 | if not query and not model_type_keys and not base_model_filters: 35 | raise web.HTTPBadRequest(reason="Search requires a query or at least one filter (type or base model).") 36 | 37 | # Instantiate API - API key might not be needed for Meili public search 38 | api = CivitaiAPI(api_key or None) 39 | 40 | # --- Prepare Filters for Meili API call --- 41 | 42 | # 1. Map internal type keys to Civitai API 'type' names (used in Meili filter) 43 | # This assumes Meili filters on the uppercase names like "LORA", "Checkpoint" 44 | api_types_filter = [] 45 | if isinstance(model_type_keys, list) and model_type_keys and "any" not in model_type_keys: 46 | for key in model_type_keys: 47 | # Map key.lower() for robustness - use the existing map from config 48 | # CIVITAI_API_TYPE_MAP maps internal key -> Civitai API type name (e.g. 'lora' -> 'LORA') 49 | api_type = CIVITAI_API_TYPE_MAP.get(key.lower()) 50 | # Ensure we handle cases where the map might return None or duplicate types 51 | if api_type and api_type not in api_types_filter: 52 | api_types_filter.append(api_type) 53 | 54 | # 2. Base Model Filters (assume frontend sends exact names like "SD 1.5") 55 | valid_base_models = [] 56 | if isinstance(base_model_filters, list) and base_model_filters: 57 | # Optional: Validate against known list? 58 | valid_base_models = [bm for bm in base_model_filters if isinstance(bm, str) and bm] 59 | # Example validation (optional): 60 | # valid_base_models = [bm for bm in base_model_filters if bm in AVAILABLE_MEILI_BASE_MODELS] 61 | # if len(valid_base_models) != len(base_model_filters): 62 | # print("Warning: Some provided base model filters were invalid.") 63 | 64 | # --- Call the New API Method --- 65 | print(f"[Server Search] Meili: query='{query if query else ''}', types={api_types_filter or 'Any'}, baseModels={valid_base_models or 'Any'}, sort={sort}, nsfw={nsfw}, limit={limit}, page={page}") 66 | 67 | # Call the new search method 68 | meili_results = api.search_models_meili( 69 | query=query or None, # Meili handles empty query if filters exist 70 | types=api_types_filter or None, 71 | base_models=valid_base_models or None, 72 | sort=sort, # Pass the frontend value, mapping happens inside search_models_meili 73 | limit=limit, 74 | page=page, 75 | nsfw=nsfw 76 | ) 77 | 78 | # Handle API error response from CivitaiAPI helper 79 | if meili_results and isinstance(meili_results, dict) and "error" in meili_results: 80 | status_code = meili_results.get("status_code", 500) or 500 81 | reason = f"Civitai API Meili Search Error: {meili_results.get('details', meili_results.get('error', 'Unknown error'))}" 82 | raise web.HTTPException(reason=reason, status=status_code, body=json.dumps(meili_results)) 83 | 84 | # --- Process Meili Response for Frontend --- 85 | if meili_results and isinstance(meili_results, dict) and "hits" in meili_results: 86 | processed_items = [] 87 | image_base_url = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7QA" # Base URL for images 88 | 89 | for hit in meili_results.get("hits", []): 90 | if not isinstance(hit, dict): continue # Skip invalid hits 91 | 92 | thumbnail_url = None 93 | # Get thumbnail from images array (prefer first image) 94 | images = hit.get("images") 95 | if images and isinstance(images, list) and len(images) > 0: 96 | first_image = images[0] 97 | # Ensure first image is a dict with a 'url' field 98 | if isinstance(first_image, dict) and first_image.get("url"): 99 | image_id = first_image["url"] 100 | # Construct URL with a default width (e.g., 256 or 450) 101 | thumbnail_url = f"{image_base_url}/{image_id}/width=256" # Adjust width as needed 102 | 103 | # Extract latest version info (Meili response includes 'version' object for the primary version) 104 | latest_version_info = hit.get("version", {}) or {} # Ensure it's a dict 105 | 106 | # Prepare item structure for frontend (can pass raw hit + extras, or build a specific structure) 107 | # Let's pass the raw `hit` and add the `thumbnailUrl` and potentially other processed fields. 108 | hit['thumbnailUrl'] = thumbnail_url # Add processed thumbnail URL directly to the hit object 109 | 110 | # Optional: Add more processed fields if needed, e.g., formatted stats 111 | # hit['processedStats'] = { ... } 112 | 113 | processed_items.append(hit) 114 | 115 | # --- Calculate Pagination Info --- 116 | total_hits = meili_results.get("estimatedTotalHits", 0) 117 | current_page = page # Use the requested page number 118 | total_pages = math.ceil(total_hits / limit) if limit > 0 else 0 119 | 120 | # --- Return Structure for Frontend --- 121 | response_data = { 122 | "items": processed_items, # The array of processed hits 123 | "metadata": { 124 | "totalItems": total_hits, 125 | "currentPage": current_page, 126 | "pageSize": limit, # The limit used for the request 127 | "totalPages": total_pages, 128 | # Meili provides offset, limit, processingTimeMs which could also be passed if useful 129 | "meiliProcessingTimeMs": meili_results.get("processingTimeMs"), 130 | "meiliOffset": meili_results.get("offset"), 131 | } 132 | } 133 | return web.json_response(response_data) 134 | else: 135 | # Handle unexpected format from API or empty results 136 | print(f"[Server Search] Warning: Unexpected Meili search result format or empty hits: {meili_results}") 137 | return web.json_response({"items": [], "metadata": {"totalItems": 0, "currentPage": page, "pageSize": limit, "totalPages": 0}}, status=500) 138 | 139 | # --- Keep existing error handlers --- 140 | except web.HTTPError as http_err: 141 | # ... (keep existing HTTP error handling) ... 142 | body_detail = "" 143 | try: 144 | body_detail = await http_err.text() if hasattr(http_err, 'text') else http_err.body.decode('utf-8', errors='ignore') if http_err.body else "" 145 | if body_detail.startswith('{') and body_detail.endswith('}'): body_detail = json.loads(body_detail) 146 | except Exception: pass 147 | return web.json_response({"error": http_err.reason, "details": body_detail or "No details", "status_code": http_err.status}, status=http_err.status) 148 | 149 | except Exception as e: 150 | # ... (keep existing generic error handling) ... 151 | print("--- Unhandled Error in /civitai/search ---") 152 | traceback.print_exc() 153 | print("--- End Error ---") 154 | return web.json_response({"error": "Internal Server Error", "details": f"An unexpected search error occurred: {str(e)}", "status_code": 500}, status=500) -------------------------------------------------------------------------------- /web/js/ui/searchRenderer.js: -------------------------------------------------------------------------------- 1 | // Rendering of search results list 2 | // Usage: renderSearchResults(uiInstance, itemsArray) 3 | 4 | const PLACEHOLDER_IMAGE_URL = `/extensions/Civicomfy/images/placeholder.jpeg`; 5 | 6 | export function renderSearchResults(ui, items) { 7 | ui.feedback?.ensureFontAwesome(); 8 | 9 | if (!items || items.length === 0) { 10 | const queryUsed = ui.searchQueryInput && ui.searchQueryInput.value.trim(); 11 | const typeFilterUsed = ui.searchTypeSelect && ui.searchTypeSelect.value !== 'any'; 12 | const baseModelFilterUsed = ui.searchBaseModelSelect && ui.searchBaseModelSelect.value !== 'any'; 13 | const message = (queryUsed || typeFilterUsed || baseModelFilterUsed) 14 | ? 'No models found matching your criteria.' 15 | : 'Enter a query or select filters and click Search.'; 16 | ui.searchResultsContainer.innerHTML = `

${message}

`; 17 | return; 18 | } 19 | 20 | const placeholder = PLACEHOLDER_IMAGE_URL; 21 | const onErrorScript = `this.onerror=null; this.src='${placeholder}'; this.style.backgroundColor='#444';`; 22 | const fragment = document.createDocumentFragment(); 23 | 24 | items.forEach(hit => { 25 | const modelId = hit.id; 26 | if (!modelId) return; 27 | 28 | const creator = hit.user?.username || 'Unknown Creator'; 29 | const modelName = hit.name || 'Untitled Model'; 30 | const modelTypeApi = hit.type || 'other'; 31 | console.log('Model type for badge:', modelTypeApi); 32 | const stats = hit.metrics || {}; 33 | const tags = hit.tags?.map(t => t.name) || []; 34 | 35 | const thumbnailUrl = hit.thumbnailUrl || placeholder; 36 | const firstImage = Array.isArray(hit.images) && hit.images.length > 0 ? hit.images[0] : null; 37 | const thumbnailType = firstImage?.type; 38 | const nsfwLevel = Number(firstImage?.nsfwLevel ?? hit.nsfwLevel ?? 0); 39 | const blurMinLevel = Number(ui.settings?.nsfwBlurMinLevel ?? 4); 40 | const shouldBlur = ui.settings?.hideMatureInSearch === true && nsfwLevel >= blurMinLevel; 41 | 42 | const allVersions = hit.versions || []; 43 | const primaryVersion = hit.version || (allVersions.length > 0 ? allVersions[0] : {}); 44 | const primaryVersionId = primaryVersion.id; 45 | const primaryBaseModel = primaryVersion.baseModel || 'N/A'; 46 | 47 | const uniqueBaseModels = allVersions.length > 0 48 | ? [...new Set(allVersions.map(v => v.baseModel).filter(Boolean))] 49 | : (primaryBaseModel !== 'N/A' ? [primaryBaseModel] : []); 50 | const baseModelsDisplay = uniqueBaseModels.length > 0 ? uniqueBaseModels.join(', ') : 'N/A'; 51 | 52 | const publishedAt = hit.publishedAt; 53 | let lastUpdatedFormatted = 'N/A'; 54 | if (publishedAt) { 55 | try { 56 | const date = new Date(publishedAt); 57 | lastUpdatedFormatted = date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); 58 | } catch (_) {} 59 | } 60 | 61 | const listItem = document.createElement('div'); 62 | listItem.className = 'civitai-search-item'; 63 | listItem.dataset.modelId = modelId; 64 | 65 | const MAX_VISIBLE_VERSIONS = 3; 66 | let visibleVersions = []; 67 | if (primaryVersionId) { 68 | visibleVersions.push({ id: primaryVersionId, name: primaryVersion.name || 'Primary Version', baseModel: primaryBaseModel }); 69 | } 70 | allVersions.forEach(v => { 71 | if (v.id !== primaryVersionId && visibleVersions.length < MAX_VISIBLE_VERSIONS) visibleVersions.push(v); 72 | }); 73 | 74 | let versionButtonsHtml = visibleVersions.map(version => { 75 | const versionId = version.id; 76 | const versionName = version.name || 'Unknown Version'; 77 | const baseModel = version.baseModel || 'N/A'; 78 | return ` 79 | 86 | `; 87 | }).join(''); 88 | 89 | const hasMoreVersions = allVersions.length > visibleVersions.length; 90 | const totalVersionCount = allVersions.length; 91 | const moreButtonHtml = hasMoreVersions ? ` 92 | 98 | ` : ''; 99 | 100 | let allVersionsHtml = ''; 101 | if (hasMoreVersions) { 102 | const hiddenVersions = allVersions.filter(v => !visibleVersions.some(vis => vis.id === v.id)); 103 | allVersionsHtml = ` 104 | 120 | `; 121 | } 122 | 123 | let thumbnailHtml = ''; 124 | const videoTitle = `Video preview for ${modelName}`; 125 | const imageAlt = `${modelName} thumbnail`; 126 | if (thumbnailUrl && typeof thumbnailUrl === 'string' && thumbnailType === 'video') { 127 | thumbnailHtml = ` 128 | 133 | `; 134 | } else { 135 | const effective = thumbnailUrl || placeholder; 136 | thumbnailHtml = ` 137 | ${imageAlt} 138 | `; 139 | } 140 | 141 | const overlayHtml = shouldBlur ? `
R
` : ''; 142 | const containerClasses = `civitai-thumbnail-container${shouldBlur ? ' blurred' : ''}`; 143 | 144 | listItem.innerHTML = ` 145 |
146 | ${thumbnailHtml} 147 | ${overlayHtml} 148 |
${modelTypeApi}
149 |
150 |
151 |

${modelName}

152 |
153 | ${creator} 154 | ${baseModelsDisplay} 155 | ${lastUpdatedFormatted} 156 |
157 |
158 | ${stats.downloadCount?.toLocaleString() || 0} 159 | ${stats.thumbsUpCount?.toLocaleString() || 0} 160 | ${stats.collectedCount?.toLocaleString() || 0} 161 | ${stats.tippedAmountCount?.toLocaleString() || 0} 162 |
163 | ${tags.length > 0 ? ` 164 |
165 | ${tags.slice(0, 5).map(tag => `${tag}`).join('')} 166 | ${tags.length > 5 ? `...` : ''} 167 |
168 | ` : ''} 169 |
170 |
171 | 174 | View 175 | 176 |
177 | ${versionButtonsHtml} 178 | ${moreButtonHtml} 179 |
180 | ${allVersionsHtml} 181 |
182 | `; 183 | 184 | fragment.appendChild(listItem); 185 | }); 186 | 187 | ui.searchResultsContainer.innerHTML = ''; 188 | ui.searchResultsContainer.appendChild(fragment); 189 | } 190 | -------------------------------------------------------------------------------- /server/routes/GetModelDetails.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/GetModelDetails.py 3 | # ================================================ 4 | import os 5 | import json 6 | import traceback 7 | from aiohttp import web 8 | 9 | import server # ComfyUI server instance 10 | from ..utils import get_request_json, get_civitai_model_and_version_details 11 | from ...api.civitai import CivitaiAPI 12 | from ...config import PLACEHOLDER_IMAGE_PATH 13 | 14 | prompt_server = server.PromptServer.instance 15 | 16 | @prompt_server.routes.post("/civitai/get_model_details") 17 | async def route_get_model_details(request): 18 | """API Endpoint to fetch model/version details for preview.""" 19 | try: 20 | data = await get_request_json(request) 21 | model_url_or_id = data.get("model_url_or_id") 22 | req_version_id = data.get("model_version_id") # Optional explicit version ID 23 | api_key = data.get("api_key", "") 24 | 25 | if not model_url_or_id: 26 | raise web.HTTPBadRequest(reason="Missing 'model_url_or_id'") 27 | 28 | # Instantiate API 29 | api = CivitaiAPI(api_key or None) 30 | 31 | # Use the helper to get details 32 | details = await get_civitai_model_and_version_details(api, model_url_or_id, req_version_id) 33 | model_info = details['model_info'] 34 | version_info = details['version_info'] 35 | primary_file = details['primary_file'] 36 | target_model_id = details['target_model_id'] 37 | target_version_id = details['target_version_id'] 38 | 39 | # --- Extract Data for Frontend Preview --- 40 | model_name = model_info.get('name') 41 | creator_username = model_info.get('creator', {}).get('username', 'Unknown Creator') 42 | model_type = model_info.get('type', 'Unknown') # Checkpoint, LORA etc. 43 | 44 | stats = model_info.get('stats', version_info.get('stats', {})) # Ensure stats is a dict 45 | download_count = stats.get('downloadCount', 0) 46 | likes_count = stats.get('thumbsUpCount', 0) 47 | dislikes_count = stats.get('thumbsDownCount', 0) 48 | buzz_count = stats.get('tippedAmountCount', 0) 49 | 50 | 51 | # Get description from model_info (version description might be update notes) 52 | # Handle potential None value for description 53 | description_html = model_info.get('description') 54 | if description_html is None: 55 | description_html = "

No description provided.

" 56 | else: 57 | # Basic sanitization/check (could be more robust if needed) 58 | if not isinstance(description_html, str): 59 | description_html = "

Invalid description format.

" 60 | elif not description_html.strip(): 61 | description_html = "

Description is empty.

" 62 | 63 | version_description_html = version_info.get('description') 64 | if version_description_html is None: 65 | version_description_html = "

No description provided.

" 66 | else: 67 | # Basic sanitization/check (could be more robust if needed) 68 | if not isinstance(version_description_html, str): 69 | version_description_html = "

Invalid description format.

" 70 | elif not version_description_html.strip(): 71 | version_description_html = "

Description is empty.

" 72 | 73 | # File details 74 | def _guess_precision(file_dict): 75 | try: 76 | name = (file_dict.get('name') or '').lower() 77 | meta = (file_dict.get('metadata') or {}) 78 | for key in ('precision', 'dtype', 'fp'): 79 | val = (meta.get(key) or '').lower() 80 | if val: 81 | return val 82 | if 'fp8' in name or 'int8' in name or '8bit' in name or '8-bit' in name: 83 | return 'fp8' 84 | if 'fp16' in name or 'bf16' in name or '16bit' in name or '16-bit' in name: 85 | return 'fp16' 86 | if 'fp32' in name or '32bit' in name or '32-bit' in name: 87 | return 'fp32' 88 | except Exception: 89 | pass 90 | return 'N/A' 91 | 92 | file_name = primary_file.get('name', 'N/A') 93 | file_size_kb = primary_file.get('sizeKB', 0) 94 | _meta = (primary_file.get('metadata') or {}) 95 | file_format = _meta.get('format', 'N/A') # e.g., SafeTensor, PickleTensor 96 | file_model_size = _meta.get('size', 'N/A') # e.g., Pruned/Full 97 | file_precision = _guess_precision(primary_file) 98 | 99 | thumbnail_url = None 100 | images = version_info.get("images") # Get the images list from the version info 101 | nsfw_level = None 102 | 103 | # Check if images list exists, is a list, has items, and the first item is valid with a URL 104 | if images and isinstance(images, list) and len(images) > 0 and \ 105 | isinstance(images[0], dict) and images[0].get("url"): 106 | # Use the URL of the very first image directly 107 | first_image = images[0] 108 | thumbnail_url = first_image["url"] 109 | try: 110 | lvl = first_image.get("nsfwLevel") 111 | nsfw_level = int(lvl) if lvl is not None else None 112 | except Exception: 113 | nsfw_level = None 114 | print(f"[Get Details Route] Using first image URL as thumbnail: {thumbnail_url}") 115 | else: 116 | print("[Get Details Route] No valid first image found in version info, falling back to placeholder.") 117 | # Fallback placeholder logic (remains the same) 118 | placeholder_filename = os.path.basename(PLACEHOLDER_IMAGE_PATH) if PLACEHOLDER_IMAGE_PATH else "placeholder.jpeg" 119 | thumbnail_url = f"./{placeholder_filename}" # Relative path for JS to resolve 120 | 121 | 122 | # Fallback placeholder if no thumbnail found 123 | if not thumbnail_url: 124 | placeholder_filename = os.path.basename(PLACEHOLDER_IMAGE_PATH) if PLACEHOLDER_IMAGE_PATH else "placeholder.jpeg" 125 | thumbnail_url = f"./{placeholder_filename}" # Relative path for JS 126 | 127 | # Build minimal files listing for selection in UI/clients 128 | files_list = [] 129 | vfiles = version_info.get("files", []) or [] 130 | if isinstance(vfiles, list): 131 | for f in vfiles: 132 | if not isinstance(f, dict): 133 | continue 134 | _fmeta = (f.get("metadata") or {}) 135 | files_list.append({ 136 | "id": f.get("id"), 137 | "name": f.get("name"), 138 | "size_kb": f.get("sizeKB"), 139 | "format": _fmeta.get("format"), 140 | "model_size": _fmeta.get("size"), 141 | "precision": _guess_precision(f), 142 | "downloadable": bool(f.get("downloadUrl")), 143 | }) 144 | 145 | # --- Return curated data --- 146 | return web.json_response({ 147 | "success": True, 148 | "model_id": target_model_id, 149 | "version_id": target_version_id, 150 | "model_name": model_name, 151 | "version_name": version_info.get('name', 'Unknown Version'), 152 | "creator_username": creator_username, 153 | "model_type": model_type, 154 | "description_html": description_html, # Send raw HTML (frontend should handle display safely) 155 | "version_description_html": version_description_html, 156 | "stats": { 157 | "downloads": download_count, 158 | "likes": likes_count, 159 | "dislikes": dislikes_count, 160 | "buzz": buzz_count, 161 | }, 162 | "file_info": { 163 | "name": file_name, 164 | "size_kb": file_size_kb, 165 | "format": file_format, 166 | "model_size": file_model_size, 167 | "precision": file_precision, 168 | }, 169 | "files": files_list, 170 | "thumbnail_url": thumbnail_url, 171 | "nsfw_level": nsfw_level, 172 | # Optionally include basic version info like baseModel 173 | "base_model": version_info.get("baseModel", "N/A"), 174 | # You could add tags here too if desired: model_info.get('tags', []) 175 | }) 176 | 177 | except web.HTTPError as http_err: 178 | # Consistent error handling (copied from route_download_model) 179 | print(f"[Server GetDetails] HTTP Error: {http_err.status} {http_err.reason}") 180 | body_detail = "" 181 | try: 182 | body_detail = await http_err.text() if hasattr(http_err, 'text') else http_err.body.decode('utf-8', errors='ignore') if http_err.body else "" 183 | if body_detail.startswith('{') and body_detail.endswith('}'): body_detail = json.loads(body_detail) 184 | except Exception: pass 185 | return web.json_response({"success": False, "error": http_err.reason, "details": body_detail or "No details", "status_code": http_err.status}, status=http_err.status) 186 | 187 | except Exception as e: 188 | # Consistent error handling (copied from route_download_model) 189 | print("--- Unhandled Error in /civitai/get_model_details ---") 190 | traceback.print_exc() 191 | print("--- End Error ---") 192 | return web.json_response({"success": False, "error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}", "status_code": 500}, status=500) 193 | -------------------------------------------------------------------------------- /server/utils.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/utils.py 3 | # ================================================ 4 | import json 5 | from typing import Any, Dict, Optional 6 | from aiohttp import web 7 | 8 | # Import necessary components from our modules 9 | from ..api.civitai import CivitaiAPI 10 | from ..utils.helpers import parse_civitai_input, select_primary_file 11 | 12 | async def get_request_json(request): 13 | """Safely get JSON data from request.""" 14 | try: 15 | return await request.json() 16 | except Exception as e: 17 | print(f"Error parsing request JSON: {e}") 18 | raise web.HTTPBadRequest(reason=f"Invalid JSON format: {e}") 19 | 20 | async def get_civitai_model_and_version_details(api: CivitaiAPI, model_url_or_id: str, req_version_id: Optional[int]) -> Dict[str, Any]: 21 | """ 22 | Helper to fetch Civitai details. 23 | Prioritizes fetching model info based on resolved Model ID. 24 | Fetches specific version info if version ID is provided/resolved, otherwise latest. 25 | Returns a dict with 'model_info', 'version_info', 'primary_file', and resolved IDs. 26 | Raises HTTP exceptions on critical failures. 27 | """ 28 | target_model_id = None 29 | target_version_id = None 30 | potential_version_id_from_input = None 31 | model_info = {} 32 | version_info_to_use = {} # The version (specific or latest) whose file we'll use 33 | primary_file = None 34 | 35 | # --- 1. Parse Input to get potential IDs --- 36 | parsed_model_id, parsed_version_id = parse_civitai_input(model_url_or_id) 37 | 38 | # Determine the initial target model ID (input URL/ID takes precedence) 39 | target_model_id = parsed_model_id 40 | 41 | # Determine the specific version requested (explicit param > URL param) 42 | if req_version_id and str(req_version_id).isdigit(): 43 | try: 44 | potential_version_id_from_input = int(req_version_id) 45 | except (ValueError, TypeError): 46 | print(f"[API Helper] Warning: Invalid req_version_id: {req_version_id}. Ignoring.") 47 | elif parsed_version_id: 48 | potential_version_id_from_input = parsed_version_id 49 | 50 | # --- 2. Ensure we have a Model ID --- 51 | # If we only got a version ID from the input (e.g., civitai.com/model-versions/456), 52 | # we need to fetch that version *first* just to find the model ID. 53 | if not target_model_id and potential_version_id_from_input: 54 | print(f"[API Helper] Input requires fetching version {potential_version_id_from_input} first to find model ID.") 55 | temp_version_info = api.get_model_version_info(potential_version_id_from_input) 56 | if temp_version_info and "error" not in temp_version_info and temp_version_info.get('modelId'): 57 | target_model_id = temp_version_info['modelId'] 58 | print(f"[API Helper] Found Model ID {target_model_id} from Version ID {potential_version_id_from_input}.") 59 | # We might reuse temp_version_info later if this was the specifically requested version 60 | else: 61 | err = temp_version_info.get('details', 'Could not find model ID from version') if isinstance(temp_version_info, dict) else 'API error' 62 | raise web.HTTPNotFound(reason=f"Could not determine Model ID from Version ID {potential_version_id_from_input}", body=json.dumps({"error": f"Version {potential_version_id_from_input} not found or missing modelId", "details": err})) 63 | 64 | # If still no model ID after potential lookup, fail 65 | if not target_model_id: 66 | raise web.HTTPBadRequest(reason="Could not determine a valid Model ID from the input.") 67 | 68 | # --- 3. Fetch Core Model Information (Always based on target_model_id) --- 69 | print(f"[API Helper] Fetching core model info for Model ID: {target_model_id}") 70 | model_info_result = api.get_model_info(target_model_id) 71 | if not model_info_result or "error" in model_info_result: 72 | err_details = model_info_result.get('details', 'Unknown API error') if isinstance(model_info_result, dict) else 'Unknown API error' 73 | raise web.HTTPNotFound(reason=f"Model {target_model_id} not found or API error", body=json.dumps({"error": f"Model {target_model_id} not found or API error", "details": err_details})) 74 | model_info = model_info_result # Store the successfully fetched model info 75 | 76 | # --- 4. Determine and Fetch Version Info for File Details --- 77 | if potential_version_id_from_input: 78 | # User specified a version explicitly, fetch its details 79 | print(f"[API Helper] Fetching specific version info for Version ID: {potential_version_id_from_input}") 80 | target_version_id = potential_version_id_from_input # This is the version we need info for 81 | # Check if we already fetched this during Model ID lookup 82 | if 'temp_version_info' in locals() and temp_version_info.get('id') == target_version_id: 83 | print("[API Helper] Reusing version info fetched earlier.") 84 | version_info_to_use = temp_version_info 85 | else: 86 | version_info_result = api.get_model_version_info(target_version_id) 87 | if not version_info_result or "error" in version_info_result: 88 | err_details = version_info_result.get('details', 'Unknown API error') if isinstance(version_info_result, dict) else 'Unknown API error' 89 | raise web.HTTPNotFound(reason=f"Specified Version {target_version_id} not found or API error", body=json.dumps({"error": f"Version {target_version_id} not found or API error", "details": err_details})) 90 | version_info_to_use = version_info_result 91 | else: 92 | # No specific version requested, find latest/default from model_info 93 | print(f"[API Helper] Finding latest/default version for Model ID: {target_model_id}") 94 | versions = model_info.get("modelVersions") 95 | if not versions or not isinstance(versions, list) or len(versions) == 0: 96 | raise web.HTTPNotFound(reason=f"Model {target_model_id} has no listed model versions.") 97 | 98 | # Find the 'best' default version (usually first published) 99 | default_version_summary = next((v for v in versions if v.get('status') == 'Published'), versions[0]) 100 | target_version_id = default_version_summary.get('id') 101 | if not target_version_id: 102 | raise web.HTTPNotFound(reason=f"Model {target_model_id}'s latest version has no ID.") 103 | 104 | print(f"[API Helper] Using latest/default Version ID: {target_version_id}. Fetching its full details.") 105 | # Fetch full details for this latest version 106 | version_info_result = api.get_model_version_info(target_version_id) 107 | if not version_info_result or "error" in version_info_result: 108 | # Log error, but maybe try to proceed with summary data if desperate? Risky. 109 | err_details = version_info_result.get('details', 'Unknown error getting full version') if isinstance(version_info_result, dict) else 'Error' 110 | print(f"[API Helper] Warning: Could not fetch full details for latest version {target_version_id}. Details: {err_details}. Falling back to summary.") 111 | # Use summary data from model_info as fallback - file info might be missing! 112 | version_info_to_use = default_version_summary 113 | # Ensure minimal structure for file finding later 114 | version_info_to_use['files'] = version_info_to_use.get('files', []) 115 | version_info_to_use['images'] = version_info_to_use.get('images', []) 116 | version_info_to_use['modelId'] = version_info_to_use.get('modelId', target_model_id) # Ensure modelId is present 117 | version_info_to_use['model'] = version_info_to_use.get('model', {'name': model_info.get('name', 'Unknown')}) # Add fallback model name 118 | 119 | else: 120 | version_info_to_use = version_info_result 121 | 122 | # --- 5. Find Primary File from the Determined Version (version_info_to_use) --- 123 | print(f"[API Helper] Finding primary file for Version ID: {target_version_id}") 124 | files = version_info_to_use.get("files", []) 125 | if not isinstance(files, list): files = [] 126 | 127 | # Handle fallback downloadUrl at version level if 'files' is empty/missing 128 | if not files and 'downloadUrl' in version_info_to_use and version_info_to_use['downloadUrl']: 129 | print("[API Helper] Warning: No 'files' array found, using version-level 'downloadUrl'.") 130 | files = [{ 131 | "id": None, "name": version_info_to_use.get('name', f"version_{target_version_id}_file"), 132 | "primary": True, "type": "Model", "sizeKB": version_info_to_use.get('fileSizeKB'), 133 | "downloadUrl": version_info_to_use['downloadUrl'], "hashes": {}, "metadata": {} 134 | }] 135 | 136 | if not files: 137 | raise web.HTTPNotFound(reason=f"Version {target_version_id} (Name: {version_info_to_use.get('name', 'N/A')}) has no files listed.") 138 | 139 | # Use the centralized helper to select the best file 140 | primary_file = select_primary_file(files) 141 | 142 | if not primary_file: 143 | raise web.HTTPNotFound(reason=f"Could not find any usable file with a download URL for version {target_version_id}.") 144 | 145 | print(f"[API Helper] Selected file: Name='{primary_file.get('name', 'N/A')}', SizeKB={primary_file.get('sizeKB')}") 146 | 147 | # --- 6. Return Results --- 148 | return { 149 | "model_info": model_info, # Always the full model info 150 | "version_info": version_info_to_use, # Info for the specific/latest version 151 | "primary_file": primary_file, # The file from that version 152 | "target_model_id": target_model_id, # Resolved model ID 153 | "target_version_id": target_version_id, # Resolved version ID (specific or latest) 154 | } -------------------------------------------------------------------------------- /server/routes/GetModelDirs.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: server/routes/GetModelDirs.py 3 | # ================================================ 4 | import os 5 | import json 6 | from aiohttp import web 7 | 8 | import server # ComfyUI server instance 9 | from ...utils.helpers import get_model_dir, sanitize_filename 10 | from ...config import PLUGIN_ROOT 11 | import folder_paths 12 | 13 | prompt_server = server.PromptServer.instance 14 | 15 | CUSTOM_ROOTS_FILE = os.path.join(PLUGIN_ROOT, "custom_roots.json") 16 | 17 | def _load_custom_roots(): 18 | try: 19 | if os.path.exists(CUSTOM_ROOTS_FILE): 20 | with open(CUSTOM_ROOTS_FILE, 'r', encoding='utf-8') as f: 21 | data = json.load(f) 22 | if isinstance(data, dict): 23 | # Normalize values to lists of strings 24 | return {k: [str(p) for p in (v or []) if isinstance(p, str)] for k, v in data.items()} 25 | except Exception as e: 26 | print(f"[Civicomfy] Warning: Failed to load custom roots: {e}") 27 | return {} 28 | 29 | def _save_custom_roots(data): 30 | try: 31 | with open(CUSTOM_ROOTS_FILE, 'w', encoding='utf-8') as f: 32 | json.dump(data, f, indent=2) 33 | return True 34 | except Exception as e: 35 | print(f"[Civicomfy] Error writing custom roots file: {e}") 36 | return False 37 | 38 | def _get_all_roots_for_type(model_type: str): 39 | model_type = (model_type or '').lower().strip() 40 | roots = [] 41 | try: 42 | # Preferred: ask ComfyUI for all registered directories for this type 43 | get_fp = getattr(folder_paths, 'get_folder_paths', None) 44 | if callable(get_fp): 45 | lst = get_fp(model_type) 46 | if isinstance(lst, (list, tuple)): 47 | roots.extend([os.path.abspath(p) for p in lst if isinstance(p, str)]) 48 | else: 49 | d = folder_paths.get_directory_by_type(model_type) 50 | if d: 51 | roots.append(os.path.abspath(d)) 52 | except Exception: 53 | # Fallback to our main model dir for type 54 | d = get_model_dir(model_type) 55 | if d: 56 | roots.append(os.path.abspath(d)) 57 | 58 | custom = _load_custom_roots().get(model_type, []) 59 | for p in custom: 60 | ap = os.path.abspath(p) 61 | if ap not in roots: 62 | roots.append(ap) 63 | # Include all immediate subdirectories inside the main ComfyUI models folder 64 | try: 65 | models_dir = getattr(folder_paths, 'models_dir', None) 66 | if not models_dir: 67 | base = getattr(folder_paths, 'base_path', os.getcwd()) 68 | models_dir = os.path.join(base, 'models') 69 | if os.path.isdir(models_dir): 70 | for name in os.listdir(models_dir): 71 | p = os.path.join(models_dir, name) 72 | if os.path.isdir(p): 73 | ap = os.path.abspath(p) 74 | if ap not in roots: 75 | roots.append(ap) 76 | except Exception as e: 77 | print(f"[Civicomfy] Warning: Failed to enumerate models dir subfolders: {e}") 78 | return roots 79 | 80 | def _list_subdirs(root_dir: str, max_entries: int = 5000): 81 | """Return a sorted list of relative subdirectory paths under root_dir, including nested.""" 82 | rel_dirs = set() 83 | root_dir = os.path.abspath(root_dir) 84 | count = 0 85 | for current, dirs, _files in os.walk(root_dir): 86 | # Avoid following symlinks to reduce risk 87 | abs_current = os.path.abspath(current) 88 | try: 89 | rel = os.path.relpath(abs_current, root_dir) 90 | except Exception: 91 | continue 92 | if rel == ".": 93 | rel = "" # represent root as empty 94 | rel_dirs.add(rel) 95 | count += 1 96 | if count >= max_entries: 97 | break 98 | return sorted(rel_dirs) 99 | 100 | @prompt_server.routes.get("/civitai/model_dirs") 101 | async def route_get_model_dirs(request): 102 | """List the base directory (or provided root) and all subdirectories for a given model type.""" 103 | model_type = request.query.get("type", "checkpoint").lower().strip() 104 | root = (request.query.get("root") or "").strip() 105 | try: 106 | base_dir = root if root else get_model_dir(model_type) 107 | subdirs = _list_subdirs(base_dir) 108 | return web.json_response({ 109 | "model_type": model_type, 110 | "base_dir": base_dir, 111 | "subdirs": subdirs, # relative paths, "" represents the base root 112 | }) 113 | except Exception as e: 114 | return web.json_response({"error": "Failed to list directories", "details": str(e)}, status=500) 115 | 116 | @prompt_server.routes.post("/civitai/create_dir") 117 | async def route_create_model_dir(request): 118 | """Create a new subdirectory under a model type's base directory.""" 119 | try: 120 | data = await request.json() 121 | model_type = (data.get("model_type") or "checkpoint").lower().strip() 122 | new_dir = (data.get("new_dir") or "").strip() 123 | if not new_dir: 124 | return web.json_response({"error": "Missing 'new_dir'"}, status=400) 125 | 126 | # If client provided an explicit root, prefer it 127 | base_dir = (data.get("root") or "").strip() or get_model_dir(model_type) 128 | 129 | # Normalize and sanitize each part; disallow absolute and traversal 130 | norm = os.path.normpath(new_dir.replace("\\", "/")) 131 | parts = [p for p in norm.split("/") if p and p not in (".", "..")] 132 | safe_parts = [sanitize_filename(p) for p in parts] 133 | rel_path = os.path.join(*safe_parts) if safe_parts else "" 134 | if not rel_path: 135 | return web.json_response({"error": "Invalid folder name"}, status=400) 136 | 137 | abs_path = os.path.abspath(os.path.join(base_dir, rel_path)) 138 | # Ensure it remains inside base_dir 139 | if os.path.commonpath([abs_path, os.path.abspath(base_dir)]) != os.path.abspath(base_dir): 140 | return web.json_response({"error": "Invalid path"}, status=400) 141 | 142 | os.makedirs(abs_path, exist_ok=True) 143 | return web.json_response({ 144 | "success": True, 145 | "created": rel_path, 146 | "abs_path": abs_path, 147 | }) 148 | except Exception as e: 149 | return web.json_response({"error": "Failed to create directory", "details": str(e)}, status=500) 150 | 151 | @prompt_server.routes.post("/civitai/create_model_type") 152 | async def route_create_model_type(request): 153 | """Create a new first-level folder under the main models directory.""" 154 | try: 155 | data = await request.json() 156 | name = (data.get("name") or "").strip() 157 | if not name: 158 | return web.json_response({"error": "Missing 'name'"}, status=400) 159 | 160 | # Sanitize folder name to a single path component 161 | from ...utils.helpers import sanitize_filename 162 | safe = sanitize_filename(name) 163 | if not safe: 164 | return web.json_response({"error": "Invalid folder name"}, status=400) 165 | 166 | # Resolve models directory 167 | models_dir = getattr(folder_paths, 'models_dir', None) 168 | if not models_dir: 169 | base = getattr(folder_paths, 'base_path', os.getcwd()) 170 | models_dir = os.path.join(base, 'models') 171 | 172 | abs_path = os.path.abspath(os.path.join(models_dir, safe)) 173 | # Ensure it remains inside models_dir 174 | if os.path.commonpath([abs_path, os.path.abspath(models_dir)]) != os.path.abspath(models_dir): 175 | return web.json_response({"error": "Invalid path"}, status=400) 176 | 177 | os.makedirs(abs_path, exist_ok=True) 178 | return web.json_response({"success": True, "name": safe, "path": abs_path}) 179 | except Exception as e: 180 | return web.json_response({"error": "Failed to create model type folder", "details": str(e)}, status=500) 181 | 182 | @prompt_server.routes.get("/civitai/model_roots") 183 | async def route_get_model_roots(request): 184 | """Return all known root directories for a model type (ComfyUI + plugin custom roots).""" 185 | model_type = request.query.get("type", "checkpoint").lower().strip() 186 | roots = _get_all_roots_for_type(model_type) 187 | return web.json_response({ 188 | "model_type": model_type, 189 | "roots": roots, 190 | }) 191 | 192 | @prompt_server.routes.post("/civitai/create_root") 193 | async def route_create_model_root(request): 194 | """Create a new root directory for a model type and register it in plugin config. 195 | Note: ComfyUI may require restart to recognize this root globally; the plugin uses it immediately. 196 | """ 197 | try: 198 | data = await request.json() 199 | model_type = (data.get("model_type") or "checkpoint").lower().strip() 200 | abs_path = (data.get("path") or "").strip() 201 | if not abs_path: 202 | return web.json_response({"error": "Missing 'path'"}, status=400) 203 | # Normalize to absolute path 204 | abs_path = os.path.abspath(abs_path) 205 | # Create directory if missing 206 | os.makedirs(abs_path, exist_ok=True) 207 | roots = _load_custom_roots() 208 | current = roots.get(model_type, []) 209 | if abs_path not in current: 210 | current.append(abs_path) 211 | roots[model_type] = current 212 | if not _save_custom_roots(roots): 213 | return web.json_response({"error": "Failed to persist custom root"}, status=500) 214 | return web.json_response({"success": True, "path": abs_path}) 215 | except Exception as e: 216 | return web.json_response({"error": "Failed to create root", "details": str(e)}, status=500) 217 | -------------------------------------------------------------------------------- /web/js/ui/templates.js: -------------------------------------------------------------------------------- 1 | // Modal template for Civicomfy UI 2 | // Keep structure identical to the original inline HTML to minimize risk 3 | 4 | export function modalTemplate(settings = {}) { 5 | const numConnections = Number.isFinite(settings.numConnections) ? settings.numConnections : 1; 6 | return ` 7 |
8 |
9 |

Civicomfy

10 | 11 |
12 |
13 |
14 | 15 | 16 | 17 | 18 |
19 |
20 |
21 |
22 | 23 | 24 |
25 |

You can optionally specify a version ID using "?modelVersionId=xxxxx" in the URL or in the field below.

26 |
27 |
28 | 29 |
30 | 31 | 32 |
33 |
34 |
35 | 36 |
37 | 40 | 41 |
42 |
43 |
44 | 45 | 46 |
47 |
48 |
49 |
50 | 51 | 52 |
53 |
54 | 55 | 56 |

Disabled: Only single connection possible for now

57 |
58 |
59 |
60 | 61 | 62 |
63 |
64 | 65 |
66 | 67 |
68 |
69 | 95 |
96 |
97 |
98 |

Active Downloads

99 |
100 |

No active downloads.

101 |
102 |
103 |
104 |

Queued Downloads

105 |
106 |

Download queue is empty.

107 |
108 |
109 |
110 |
111 |

Download History (Recent)

112 | 115 |
116 |
117 |

No download history yet.

118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |

API & Defaults

127 |
128 | 129 | 130 |

Needed for some downloads/features. Find keys at civitai.com/user/account

131 |
132 |
133 | 134 | 135 |

Disabled. Only single connection possible for now

136 |
137 |
138 | 139 | 140 |
141 |
142 |
143 |

Interface & Search

144 |
145 | 146 | 147 |
148 |
149 | 150 | 151 |
152 |
153 | 154 | 155 |

156 | Blur thumbnails when an image's nsfwLevel is greater than or equal to this value. 157 | Higher numbers indicate more explicit content. None (Safe/PG): 1, Mild (PG-13): 2, Mature (R): 4, Adult (X): 5, Extra Explicit (R): 8, Explicit (XXX): 16/32+ 158 |

159 |
160 |
161 |
162 | 163 |
164 |
165 |
166 | 167 |
168 | 169 |
170 |
171 |

Confirm Clear History

172 |

Are you sure you want to clear the download history? This action cannot be undone.

173 |
174 | 175 | 176 |
177 |
178 |
179 |
180 | `; 181 | } 182 | -------------------------------------------------------------------------------- /web/js/ui/handlers/eventListeners.js: -------------------------------------------------------------------------------- 1 | import { CivitaiDownloaderAPI } from "../../api/civitai.js"; 2 | export function setupEventListeners(ui) { 3 | // Modal close 4 | ui.closeButton.addEventListener('click', () => ui.closeModal()); 5 | ui.modal.addEventListener('click', (event) => { 6 | if (event.target === ui.modal) ui.closeModal(); 7 | }); 8 | 9 | // Tab switching 10 | ui.tabContainer.addEventListener('click', (event) => { 11 | if (event.target.matches('.civitai-downloader-tab')) { 12 | ui.switchTab(event.target.dataset.tab); 13 | } 14 | }); 15 | 16 | // --- FORMS --- 17 | ui.downloadForm.addEventListener('submit', (event) => { 18 | event.preventDefault(); 19 | ui.handleDownloadSubmit(); 20 | }); 21 | 22 | // Change of model type should refresh subdir list 23 | ui.downloadModelTypeSelect.addEventListener('change', async () => { 24 | await ui.loadAndPopulateSubdirs(ui.downloadModelTypeSelect.value); 25 | }); 26 | 27 | // Create new model type folder (first-level under models/) 28 | ui.createModelTypeButton.addEventListener('click', async () => { 29 | const name = prompt('Enter new model type folder name (will be created under models/)'); 30 | if (!name) return; 31 | try { 32 | const res = await CivitaiDownloaderAPI.createModelType(name); 33 | if (res && res.success) { 34 | await ui.populateModelTypes(); 35 | ui.downloadModelTypeSelect.value = res.name; 36 | await ui.loadAndPopulateSubdirs(res.name); 37 | ui.showToast(`Created model type folder: ${res.name}`, 'success'); 38 | } else { 39 | ui.showToast(res?.error || 'Failed to create model type folder', 'error'); 40 | } 41 | } catch (e) { 42 | ui.showToast(e.details || e.message || 'Error creating model type folder', 'error'); 43 | } 44 | }); 45 | 46 | // Create new subfolder under current model type 47 | ui.createSubdirButton.addEventListener('click', async () => { 48 | const type = ui.downloadModelTypeSelect.value; 49 | const name = prompt('Enter new subfolder name (you can include nested paths like A/B):'); 50 | if (!name) return; 51 | try { 52 | const res = await CivitaiDownloaderAPI.createModelDir(type, name); 53 | if (res && res.success) { 54 | await ui.loadAndPopulateSubdirs(type); 55 | if (ui.subdirSelect) ui.subdirSelect.value = res.created || ''; 56 | ui.showToast(`Created folder: ${res.created}`, 'success'); 57 | } else { 58 | ui.showToast(res?.error || 'Failed to create folder', 'error'); 59 | } 60 | } catch (e) { 61 | ui.showToast(e.details || e.message || 'Error creating folder', 'error'); 62 | } 63 | }); 64 | 65 | ui.searchForm.addEventListener('submit', (event) => { 66 | event.preventDefault(); 67 | if (!ui.searchQueryInput.value.trim() && ui.searchTypeSelect.value === 'any' && ui.searchBaseModelSelect.value === 'any') { 68 | ui.showToast("Please enter a search query or select a filter.", "error"); 69 | if (ui.searchResultsContainer) ui.searchResultsContainer.innerHTML = '

Please enter a search query or select a filter.

'; 70 | if (ui.searchPaginationContainer) ui.searchPaginationContainer.innerHTML = ''; 71 | return; 72 | } 73 | ui.searchPagination.currentPage = 1; 74 | ui.handleSearchSubmit(); 75 | }); 76 | 77 | ui.settingsForm.addEventListener('submit', (event) => { 78 | event.preventDefault(); 79 | ui.handleSettingsSave(); 80 | }); 81 | 82 | // Download form inputs 83 | ui.modelUrlInput.addEventListener('input', () => ui.debounceFetchDownloadPreview()); 84 | ui.modelUrlInput.addEventListener('paste', () => ui.debounceFetchDownloadPreview(0)); 85 | ui.modelVersionIdInput.addEventListener('blur', () => ui.fetchAndDisplayDownloadPreview()); 86 | 87 | // --- DYNAMIC CONTENT LISTENERS (Event Delegation) --- 88 | 89 | // Status tab actions (Cancel/Retry/Open/Clear) and click-to-toggle blur on thumbs 90 | ui.statusContent.addEventListener('click', (event) => { 91 | const thumbContainer = event.target.closest('.civitai-thumbnail-container'); 92 | if (thumbContainer) { 93 | const nsfwLevel = Number(thumbContainer.dataset.nsfwLevel ?? thumbContainer.getAttribute('data-nsfw-level')); 94 | const threshold = Number(ui.settings?.nsfwBlurMinLevel ?? 4); 95 | const enabled = ui.settings?.hideMatureInSearch === true; 96 | if (enabled && Number.isFinite(nsfwLevel) && nsfwLevel >= threshold) { 97 | if (thumbContainer.classList.contains('blurred')) { 98 | thumbContainer.classList.remove('blurred'); 99 | const overlay = thumbContainer.querySelector('.civitai-nsfw-overlay'); 100 | if (overlay) overlay.remove(); 101 | } else { 102 | thumbContainer.classList.add('blurred'); 103 | if (!thumbContainer.querySelector('.civitai-nsfw-overlay')) { 104 | const ov = document.createElement('div'); 105 | ov.className = 'civitai-nsfw-overlay'; 106 | ov.title = 'R-rated: click to reveal'; 107 | ov.textContent = 'R'; 108 | thumbContainer.appendChild(ov); 109 | } 110 | } 111 | return; // consume 112 | } 113 | } 114 | 115 | const button = event.target.closest('button'); 116 | if (!button) return; 117 | 118 | const downloadId = button.dataset.id; 119 | if (downloadId) { 120 | if (button.classList.contains('civitai-cancel-button')) ui.handleCancelDownload(downloadId); 121 | else if (button.classList.contains('civitai-retry-button')) ui.handleRetryDownload(downloadId, button); 122 | else if (button.classList.contains('civitai-openpath-button')) ui.handleOpenPath(downloadId, button); 123 | } else if (button.id === 'civitai-clear-history-button') { 124 | ui.confirmClearModal.style.display = 'flex'; 125 | } 126 | }); 127 | 128 | // Download preview click-to-toggle blur 129 | ui.downloadPreviewArea.addEventListener('click', (event) => { 130 | const thumbContainer = event.target.closest('.civitai-thumbnail-container'); 131 | if (thumbContainer) { 132 | const nsfwLevel = Number(thumbContainer.dataset.nsfwLevel ?? thumbContainer.getAttribute('data-nsfw-level')); 133 | const threshold = Number(ui.settings?.nsfwBlurMinLevel ?? 4); 134 | const enabled = ui.settings?.hideMatureInSearch === true; 135 | if (enabled && Number.isFinite(nsfwLevel) && nsfwLevel >= threshold) { 136 | if (thumbContainer.classList.contains('blurred')) { 137 | thumbContainer.classList.remove('blurred'); 138 | const overlay = thumbContainer.querySelector('.civitai-nsfw-overlay'); 139 | if (overlay) overlay.remove(); 140 | } else { 141 | thumbContainer.classList.add('blurred'); 142 | if (!thumbContainer.querySelector('.civitai-nsfw-overlay')) { 143 | const ov = document.createElement('div'); 144 | ov.className = 'civitai-nsfw-overlay'; 145 | ov.title = 'R-rated: click to reveal'; 146 | ov.textContent = 'R'; 147 | thumbContainer.appendChild(ov); 148 | } 149 | } 150 | } 151 | } 152 | }); 153 | 154 | // Search results actions, including click-to-toggle blur 155 | ui.searchResultsContainer.addEventListener('click', (event) => { 156 | const thumbContainer = event.target.closest('.civitai-thumbnail-container'); 157 | if (thumbContainer) { 158 | const nsfwLevel = Number(thumbContainer.dataset.nsfwLevel ?? thumbContainer.getAttribute('data-nsfw-level')); 159 | const threshold = Number(ui.settings?.nsfwBlurMinLevel ?? 4); 160 | const enabled = ui.settings?.hideMatureInSearch === true; 161 | if (enabled && Number.isFinite(nsfwLevel) && nsfwLevel >= threshold) { 162 | if (thumbContainer.classList.contains('blurred')) { 163 | thumbContainer.classList.remove('blurred'); 164 | const overlay = thumbContainer.querySelector('.civitai-nsfw-overlay'); 165 | if (overlay) overlay.remove(); 166 | } else { 167 | thumbContainer.classList.add('blurred'); 168 | if (!thumbContainer.querySelector('.civitai-nsfw-overlay')) { 169 | const ov = document.createElement('div'); 170 | ov.className = 'civitai-nsfw-overlay'; 171 | ov.title = 'R-rated: click to reveal'; 172 | ov.textContent = 'R'; 173 | thumbContainer.appendChild(ov); 174 | } 175 | } 176 | return; // Don't trigger other actions on this click 177 | } 178 | } 179 | 180 | const downloadButton = event.target.closest('.civitai-search-download-button'); 181 | if (downloadButton) { 182 | event.preventDefault(); 183 | const { modelId, versionId, modelType } = downloadButton.dataset; 184 | if (!modelId || !versionId) { 185 | ui.showToast("Error: Missing data for download.", "error"); 186 | return; 187 | } 188 | const modelTypeInternalKey = Object.keys(ui.modelTypes).find(key => ui.modelTypes[key]?.toLowerCase() === modelType?.toLowerCase()) || ui.settings.defaultModelType; 189 | 190 | ui.modelUrlInput.value = modelId; 191 | ui.modelVersionIdInput.value = versionId; 192 | ui.customFilenameInput.value = ''; 193 | ui.forceRedownloadCheckbox.checked = false; 194 | ui.downloadModelTypeSelect.value = modelTypeInternalKey; 195 | 196 | ui.switchTab('download'); 197 | ui.showToast(`Filled download form for Model ID ${modelId}.`, 'info', 4000); 198 | ui.fetchAndDisplayDownloadPreview(); 199 | return; 200 | } 201 | 202 | const viewAllButton = event.target.closest('.show-all-versions-button'); 203 | if (viewAllButton) { 204 | const modelId = viewAllButton.dataset.modelId; 205 | const versionsContainer = ui.searchResultsContainer.querySelector(`#all-versions-${modelId}`); 206 | if (versionsContainer) { 207 | const currentlyVisible = versionsContainer.style.display !== 'none'; 208 | versionsContainer.style.display = currentlyVisible ? 'none' : 'flex'; 209 | viewAllButton.innerHTML = currentlyVisible 210 | ? `All versions (${viewAllButton.dataset.totalVersions}) ` 211 | : `Show less `; 212 | } 213 | } 214 | }); 215 | 216 | // Pagination 217 | ui.searchPaginationContainer.addEventListener('click', (event) => { 218 | const button = event.target.closest('.civitai-page-button'); 219 | if (button && !button.disabled) { 220 | const page = parseInt(button.dataset.page, 10); 221 | if (page && page !== ui.searchPagination.currentPage) { 222 | ui.searchPagination.currentPage = page; 223 | ui.handleSearchSubmit(); 224 | } 225 | } 226 | }); 227 | 228 | // Confirmation Modal 229 | ui.confirmClearYesButton.addEventListener('click', () => ui.handleClearHistory()); 230 | ui.confirmClearNoButton.addEventListener('click', () => { 231 | ui.confirmClearModal.style.display = 'none'; 232 | }); 233 | ui.confirmClearModal.addEventListener('click', (event) => { 234 | if (event.target === ui.confirmClearModal) { 235 | ui.confirmClearModal.style.display = 'none'; 236 | } 237 | }); 238 | } 239 | -------------------------------------------------------------------------------- /api/civitai.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: api/civitai.py 3 | # ================================================ 4 | import requests 5 | import json 6 | from typing import List, Optional, Dict, Any, Union 7 | 8 | class CivitaiAPI: 9 | """Simple wrapper for interacting with the Civitai API v1.""" 10 | BASE_URL = "https://civitai.com/api/v1" 11 | 12 | def __init__(self, api_key: Optional[str] = None): 13 | self.api_key = api_key 14 | self.base_headers = {'Content-Type': 'application/json'} 15 | if api_key: 16 | self.base_headers["Authorization"] = f"Bearer {api_key}" 17 | print("[Civitai API] Using API Key.") 18 | else: 19 | print("[Civitai API] No API Key provided.") 20 | 21 | def _get_request_headers(self, method: str, has_json_data: bool) -> Dict[str, str]: 22 | """Returns headers for a specific request.""" 23 | headers = self.base_headers.copy() 24 | # Don't send content-type for GET/HEAD if no json_data 25 | if method.upper() in ["GET", "HEAD"] and not has_json_data: 26 | headers.pop('Content-Type', None) 27 | return headers 28 | 29 | def _request(self, method: str, endpoint: str, params: Optional[Dict] = None, 30 | json_data: Optional[Dict] = None, stream: bool = False, 31 | allow_redirects: bool = True, timeout: int = 30) -> Union[Dict[str, Any], requests.Response, None]: 32 | """Makes a request to the Civitai API and handles basic errors.""" 33 | url = f"{self.BASE_URL}/{endpoint.lstrip('/')}" 34 | request_headers = self._get_request_headers(method, json_data is not None) 35 | 36 | try: 37 | response = requests.request( 38 | method, 39 | url, 40 | headers=request_headers, 41 | params=params, 42 | json=json_data, 43 | stream=stream, 44 | allow_redirects=allow_redirects, 45 | timeout=timeout 46 | ) 47 | response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) 48 | 49 | if stream: 50 | return response # Return the response object for streaming 51 | 52 | # Handle No Content response (e.g., 204) 53 | if response.status_code == 204 or not response.content: 54 | return None 55 | 56 | return response.json() 57 | 58 | except requests.exceptions.HTTPError as http_err: 59 | error_detail = None 60 | status_code = http_err.response.status_code 61 | try: 62 | error_detail = http_err.response.json() 63 | except json.JSONDecodeError: 64 | error_detail = http_err.response.text[:200] # First 200 chars 65 | print(f"Civitai API HTTP Error ({method} {url}): Status {status_code}, Response: {error_detail}") 66 | # Return a structured error dictionary 67 | return {"error": f"HTTP Error: {status_code}", "details": error_detail, "status_code": status_code} 68 | 69 | except requests.exceptions.RequestException as req_err: 70 | print(f"Civitai API Request Error ({method} {url}): {req_err}") 71 | return {"error": str(req_err), "details": None, "status_code": None} 72 | 73 | except json.JSONDecodeError as json_err: 74 | print(f"Civitai API Error: Failed to decode JSON response from {url}: {json_err}") 75 | # Include response text if possible and not streaming 76 | response_text = response.text[:200] if not stream and hasattr(response, 'text') else "N/A" 77 | return {"error": "Invalid JSON response", "details": response_text, "status_code": response.status_code if hasattr(response, 'status_code') else None} 78 | 79 | def get_model_info(self, model_id: int) -> Optional[Dict[str, Any]]: 80 | """Gets information about a model by its ID. (GET /models/{id})""" 81 | endpoint = f"/models/{model_id}" 82 | result = self._request("GET", endpoint) 83 | # Check if the result is an error dictionary 84 | if isinstance(result, dict) and "error" in result: 85 | return result # Propagate error dict 86 | return result # Return model info dict or None 87 | 88 | def get_model_version_info(self, version_id: int) -> Optional[Dict[str, Any]]: 89 | """Gets information about a specific model version by its ID. (GET /model-versions/{id})""" 90 | endpoint = f"/model-versions/{version_id}" 91 | result = self._request("GET", endpoint) 92 | if isinstance(result, dict) and "error" in result: 93 | return result 94 | return result 95 | 96 | def search_models(self, query: str, types: Optional[List[str]] = None, 97 | sort: str = 'Highest Rated', period: str = 'AllTime', 98 | limit: int = 20, page: int = 1, 99 | nsfw: Optional[bool] = None) -> Optional[Dict[str, Any]]: 100 | """Searches for models on Civitai. (GET /models)""" 101 | endpoint = "/models" 102 | params = { 103 | "limit": max(1, min(100, limit)), # Ensure limit is reasonable 104 | "page": max(1, page), 105 | "query": query, 106 | "sort": sort, 107 | "period": period 108 | } 109 | if types: 110 | # `requests` handles lists by appending multiple key=value pairs, 111 | # which matches the expectation for 'array' type in the API doc. 112 | params["types"] = types 113 | if nsfw is not None: 114 | params["nsfw"] = str(nsfw).lower() # API expects string "true" or "false" 115 | 116 | result = self._request("GET", endpoint, params=params) 117 | if isinstance(result, dict) and "error" in result: 118 | return result 119 | # Ensure structure is as expected before returning 120 | if isinstance(result, dict) and "items" in result and "metadata" in result: 121 | return result 122 | else: 123 | print(f"Warning: Unexpected search result format: {result}") 124 | # Return a consistent empty structure on unexpected format 125 | return {"items": [], "metadata": {"totalItems": 0, "currentPage": page, "pageSize": limit, "totalPages": 0}} 126 | 127 | def search_models_meili(self, query: str, types: Optional[List[str]] = None, 128 | base_models: Optional[List[str]] = None, 129 | sort: str = 'metrics.downloadCount:desc', # Default to Most Downloaded 130 | limit: int = 20, page: int = 1, 131 | nsfw: Optional[bool] = None) -> Optional[Dict[str, Any]]: 132 | """Searches models using the Civitai Meilisearch endpoint.""" 133 | meili_url = "https://search.civitai.com/multi-search" 134 | headers = {'Content-Type': 'application/json'} 135 | headers['Authorization'] = f'Bearer 8c46eb2508e21db1e9828a97968d91ab1ca1caa5f70a00e88a2ba1e286603b61' #Nothing harmful, everyone have the same meilisearch bearer token. I checked with 3 accounts 136 | 137 | offset = max(0, (page - 1) * limit) 138 | 139 | # Map simple sort terms to Meilisearch syntax 140 | sort_mapping = { 141 | "Relevancy": "id:desc", 142 | "Most Downloaded": "metrics.downloadCount:desc", 143 | "Highest Rated": "metrics.thumbsUpCount:desc", 144 | "Most Liked": "metrics.favoriteCount:desc", 145 | "Most Discussed": "metrics.commentCount:desc", 146 | "Most Collected": "metrics.collectedCount:desc", 147 | "Most Buzz": "metrics.tippedAmountCount:desc", 148 | "Newest": "createdAt:desc", 149 | } 150 | meili_sort = [sort_mapping.get(sort, "metrics.downloadCount:desc")] 151 | 152 | 153 | # --- Build Filters --- 154 | # Meilisearch uses an array of filter groups. Filters within a group are OR'd, groups are AND'd. 155 | filter_groups = [] 156 | 157 | # Type Filter Group (OR logic) 158 | if types and isinstance(types, list) and len(types) > 0: 159 | # Map internal type keys/display names to API type names if needed, 160 | # but the provided example uses direct type names like "LORA". Let's assume frontend sends correct names. 161 | # Ensure proper quoting for string values in the filter. 162 | type_filters = [f'"type"="{t}"' for t in types] 163 | filter_groups.append(type_filters) 164 | 165 | # Base Model Filter Group (OR logic) 166 | if base_models and isinstance(base_models, list) and len(base_models) > 0: 167 | base_model_filters = [f'"version.baseModel"="{bm}"' for bm in base_models] 168 | filter_groups.append(base_model_filters) 169 | 170 | # NSFW Filter (applied as AND) - Meili typically uses boolean facets or numeric levels 171 | # Let's filter by 'nsfwLevel' being acceptable (1=None, 2=Mild, 4=Mature) if NSFW is false or None. 172 | # If NSFW is true, we don't add this filter (allow all levels). 173 | # This might need adjustment based on exact Meili setup and desired behavior. 174 | # An alternative is filtering on the 'nsfw' boolean field if it exists directly. 175 | if nsfw is None or nsfw is False: 176 | # Example: Allow levels 1, 2, 4 (adjust as needed) 177 | # Meili syntax for multiple values: nsfwLevel IN [1, 2, 4] 178 | filter_groups.append("nsfwLevel IN [1, 2, 4]") # Filter applied directly as AND 179 | # Or maybe filter on the boolean `nsfw` field if it's indexed: 180 | # filter_groups.append("nsfw = false") 181 | 182 | # Availability Filter (Public) 183 | filter_groups.append("availability = Public") # Filter applied directly as AND 184 | 185 | # --- Construct Request Body --- 186 | payload = { 187 | "queries": [ 188 | { 189 | "q": query if query else "", # Send empty string "" if no query 190 | "indexUid": "models_v9", 191 | "facets": [ # Keep facets requested by frontend if needed for analytics/refinement UI 192 | "category.name", 193 | "checkpointType", 194 | "fileFormats", 195 | "lastVersionAtUnix", 196 | "tags.name", 197 | "type", 198 | "user.username", 199 | "version.baseModel", 200 | "nsfwLevel" 201 | ], 202 | "attributesToHighlight": [], # Keep empty if not using highlighting 203 | "highlightPreTag": "__ais-highlight__", 204 | "highlightPostTag": "__/ais-highlight__", 205 | "limit": max(1, min(100, limit)), # Ensure reasonable limit 206 | "offset": offset, 207 | "filter": filter_groups 208 | } 209 | ] 210 | } 211 | if(sort != "Relevancy"): 212 | payload["queries"][0]["sort"] = meili_sort 213 | 214 | 215 | try: 216 | # print(f"DEBUG: Meili Search Payload: {json.dumps(payload, indent=2)}") # Debugging payload 217 | response = requests.post(meili_url, headers=headers, json=payload, timeout=25) # Use reasonable timeout 218 | response.raise_for_status() 219 | 220 | results_data = response.json() 221 | # print(f"DEBUG: Meili Search Response: {json.dumps(results_data, indent=2)}") # Debugging response 222 | 223 | # Basic validation of response structure 224 | if not results_data or not isinstance(results_data.get('results'), list) or not results_data['results']: 225 | print(f"Warning: Meili search returned unexpected structure or empty results list: {results_data}") 226 | # Return empty structure consistent with expected format downstream 227 | return {"hits": [], "limit": limit, "offset": offset, "estimatedTotalHits": 0} 228 | 229 | # Return the content of the first result (assuming single query) 230 | first_result = results_data['results'][0] 231 | if isinstance(first_result, dict) and "hits" in first_result: 232 | # Return the relevant part of the response 233 | return first_result # Includes hits, processingTimeMs, limit, offset, estimatedTotalHits etc. 234 | else: 235 | print(f"Warning: Meili search first result structure invalid: {first_result}") 236 | return {"hits": [], "limit": limit, "offset": offset, "estimatedTotalHits": 0} 237 | 238 | except requests.exceptions.HTTPError as http_err: 239 | error_detail = None 240 | status_code = http_err.response.status_code 241 | try: 242 | error_detail = http_err.response.json() 243 | except json.JSONDecodeError: 244 | error_detail = http_err.response.text[:200] 245 | print(f"Civitai Meili Search HTTP Error ({meili_url}): Status {status_code}, Response: {error_detail}") 246 | return {"error": f"Meili HTTP Error: {status_code}", "details": error_detail, "status_code": status_code} 247 | 248 | except requests.exceptions.RequestException as req_err: 249 | print(f"Civitai Meili Search Request Error ({meili_url}): {req_err}") 250 | return {"error": str(req_err), "details": None, "status_code": None} 251 | 252 | except json.JSONDecodeError as json_err: 253 | print(f"Civitai Meili Search Error: Failed to decode JSON response from {meili_url}: {json_err}") 254 | response_text = response.text[:200] if hasattr(response, 'text') else "N/A" 255 | return {"error": "Invalid JSON response from Meili", "details": response_text, "status_code": response.status_code if hasattr(response, 'status_code') else None} -------------------------------------------------------------------------------- /utils/helpers.py: -------------------------------------------------------------------------------- 1 | # ================================================ 2 | # File: utils/helpers.py 3 | # ================================================ 4 | import os 5 | import urllib.parse 6 | import re 7 | from pathlib import Path 8 | from typing import Optional, List, Dict, Any 9 | 10 | import folder_paths 11 | 12 | # Import config values needed here 13 | from ..config import PLUGIN_ROOT, MODEL_TYPE_DIRS 14 | 15 | def get_model_dir(model_type: str) -> str: 16 | """ 17 | Resolve the absolute directory path for a model type using ComfyUI's 18 | folder_paths manager. Respects extra_model_paths.yaml and symlinks. 19 | Ensures the directory exists. 20 | """ 21 | model_type_raw = (model_type or "").strip() 22 | model_type_key = model_type_raw.lower() 23 | 24 | display_and_type = MODEL_TYPE_DIRS.get(model_type_key) 25 | folder_paths_type = None 26 | if display_and_type: 27 | _, folder_paths_type = display_and_type 28 | 29 | if display_and_type and folder_paths_type: 30 | try: 31 | # Preferred: ask ComfyUI for the configured directory for this type 32 | full_path = folder_paths.get_directory_by_type(folder_paths_type) 33 | # If API returned a falsy value, try alternative lookups 34 | if not full_path: 35 | raise RuntimeError(f"No directory registered for type '{folder_paths_type}'") 36 | except Exception: 37 | # Fallback: assume a subdirectory under models_dir 38 | print(f"Warning: Could not resolve path for type '{folder_paths_type}' via folder_paths. Falling back to models_dir.") 39 | # Try get_folder_paths first 40 | full_path = None 41 | try: 42 | get_fp = getattr(folder_paths, 'get_folder_paths', None) 43 | if callable(get_fp): 44 | paths_list = get_fp(folder_paths_type) 45 | if isinstance(paths_list, (list, tuple)) and paths_list: 46 | full_path = paths_list[0] 47 | except Exception: 48 | pass 49 | if not full_path: 50 | models_dir = getattr(folder_paths, 'models_dir', None) 51 | if not models_dir: 52 | # Last resort: try to infer a models dir near base_path 53 | base = getattr(folder_paths, 'base_path', os.getcwd()) 54 | models_dir = os.path.join(base, 'models') 55 | full_path = os.path.join(models_dir, folder_paths_type) 56 | else: 57 | # Treat model_type as a literal folder under the main models directory 58 | models_dir = getattr(folder_paths, 'models_dir', None) 59 | if not models_dir: 60 | base = getattr(folder_paths, 'base_path', os.getcwd()) 61 | models_dir = os.path.join(base, 'models') 62 | # Use the raw (case-preserving) folder name 63 | full_path = os.path.join(models_dir, model_type_raw) 64 | 65 | # Ensure the directory exists 66 | try: 67 | # Ensure full_path is a string path 68 | if not isinstance(full_path, (str, bytes, os.PathLike)): 69 | raise TypeError(f"Resolved directory for '{model_type_key}' is invalid: {full_path!r}") 70 | os.makedirs(full_path, exist_ok=True) 71 | except Exception as e: 72 | print(f"Error: Could not create directory '{full_path}': {e}") 73 | 74 | return full_path 75 | 76 | def parse_civitai_input(url_or_id: str) -> tuple[int | None, int | None]: 77 | """ 78 | Parses Civitai URL or ID string. 79 | Returns: (model_id, version_id) tuple. Both can be None. 80 | Handles URLs like /models/123 and /models/123?modelVersionId=456 81 | """ 82 | if not url_or_id: 83 | return None, None 84 | 85 | url_or_id = str(url_or_id).strip() 86 | model_id: int | None = None 87 | version_id: int | None = None 88 | query_params = {} 89 | 90 | # Check if it's just a number (could be model or version ID) 91 | # Treat digits-only input as MODEL ID primarily, as users often copy just that. 92 | # Version ID can be specified separately or via full URL query param. 93 | if url_or_id.isdigit(): 94 | try: 95 | # Assume it's a Model ID if just digits are provided. 96 | model_id = int(url_or_id) 97 | print(f"Parsed input '{url_or_id}' as Model ID.") 98 | # Don't assume it's a version ID here. Let it be specified if needed. 99 | return model_id, None 100 | except (ValueError, TypeError): 101 | print(f"Warning: Could not parse '{url_or_id}' as a numeric ID.") 102 | return None, None 103 | 104 | # If not just digits, try parsing as URL 105 | try: 106 | parsed_url = urllib.parse.urlparse(url_or_id) 107 | 108 | # Basic check for URL structure and domain 109 | if not parsed_url.scheme or not parsed_url.netloc: 110 | # Maybe it's a path like /models/123 without the domain? 111 | if url_or_id.startswith(("/models/", "/model-versions/")): 112 | # Re-parse with a dummy scheme and domain 113 | parsed_url = urllib.parse.urlparse("https://civitai.com" + url_or_id) 114 | if not parsed_url.path: # If still fails, give up 115 | print(f"Input '{url_or_id}' is not a recognizable Civitai path or URL.") 116 | return None, None 117 | else: 118 | print(f"Input '{url_or_id}' is not a valid ID or Civitai URL/path.") 119 | return None, None 120 | 121 | # Check domain if it was present 122 | if parsed_url.netloc and "civitai.com" not in parsed_url.netloc.lower(): 123 | print(f"Input URL '{url_or_id}' is not a Civitai URL.") 124 | return None, None 125 | 126 | # Extract path components and query parameters 127 | path_parts = [p for p in parsed_url.path.split('/') if p] # Remove empty parts 128 | query_params = urllib.parse.parse_qs(parsed_url.query) 129 | 130 | # --- Logic --- 131 | # 1. Check query params for modelVersionId FIRST (most explicit) 132 | if 'modelVersionId' in query_params: 133 | try: 134 | version_id = int(query_params['modelVersionId'][0]) 135 | print(f"Found Version ID {version_id} in query parameters.") 136 | except (ValueError, IndexError, TypeError): 137 | print(f"Warning: Found modelVersionId in query but couldn't parse: {query_params.get('modelVersionId')}") 138 | version_id = None # Reset if parsing failed 139 | 140 | # 2. Check path for /models/ID 141 | model_id_from_path = None 142 | if "models" in path_parts: 143 | try: 144 | models_index = path_parts.index("models") 145 | if models_index + 1 < len(path_parts): 146 | # Take the part right after /models/ and check if it's digits 147 | potential_id_str = path_parts[models_index + 1] 148 | if potential_id_str.isdigit(): 149 | model_id_from_path = int(potential_id_str) 150 | print(f"Found Model ID {model_id_from_path} in URL path.") 151 | except (ValueError, IndexError, TypeError): 152 | print(f"Warning: Found /models/ in path but couldn't parse ID from {path_parts}") 153 | 154 | # 3. Check path for /model-versions/ID (less common, usually doesn't contain model ID) 155 | version_id_from_path = None 156 | if version_id is None and "model-versions" in path_parts: # Only check if not found in query 157 | try: 158 | versions_index = path_parts.index("model-versions") 159 | if versions_index + 1 < len(path_parts): 160 | potential_id_str = path_parts[versions_index + 1] 161 | if potential_id_str.isdigit(): 162 | version_id_from_path = int(potential_id_str) 163 | # Set version_id only if not already set by query param 164 | if version_id is None: 165 | version_id = version_id_from_path 166 | print(f"Found Version ID {version_id} in URL path.") 167 | except (ValueError, IndexError, TypeError): 168 | print(f"Warning: Found /model-versions/ in path but couldn't parse ID from {path_parts}") 169 | 170 | # 4. Assign final model ID (prefer path over digits-only assumption if URL was parsed) 171 | if model_id_from_path is not None: 172 | model_id = model_id_from_path 173 | # If no model ID found yet and input looked like a URL, maybe it was ONLY a version URL? 174 | elif model_id is None and version_id is not None: 175 | print("Warning: Found Version ID but no Model ID in the URL. Model info might be incomplete.") 176 | # Keep the initially parsed model_id if input was digits-only 177 | 178 | except Exception as e: 179 | print(f"Error parsing Civitai input '{url_or_id}': {e}") 180 | return None, None 181 | 182 | print(f"Parsed Civitai input: Model ID = {model_id}, Version ID = {version_id}") 183 | # Return the determined IDs. It's the caller's responsibility to fetch model info if only version ID is present. 184 | return model_id, version_id 185 | 186 | # Updated sanitize_filename to be more restrictive 187 | def sanitize_filename(filename: str, default_filename: str = "downloaded_model") -> str: 188 | """ 189 | Stricter filename sanitization. Replaces invalid characters, trims whitespace, 190 | handles reserved names (Windows), and ensures it's not empty. 191 | Aims for better cross-OS compatibility. 192 | """ 193 | if not filename: 194 | return default_filename 195 | 196 | # Decode if bytes 197 | if isinstance(filename, bytes): 198 | try: 199 | filename = filename.decode('utf-8') 200 | except UnicodeDecodeError: 201 | # If decode fails, fall back to a safe default representation or hex 202 | # For simplicity, just use default for now if decoding problematic bytes 203 | return default_filename + "_decode_error" 204 | 205 | # Remove characters invalid for Windows/Linux/MacOS filenames 206 | # Invalid Chars: < > : " / \ | ? * and control characters (0-31) 207 | # Also replace NULL character just in case. 208 | sanitized = re.sub(r'[\x00-\x1f<>:"/\\|?*]', '_', filename) 209 | 210 | # Replace sequences of multiple underscores or spaces introduced by replacement 211 | sanitized = re.sub(r'[_ ]{2,}', '_', sanitized) 212 | 213 | # Remove leading/trailing whitespace, dots, underscores 214 | sanitized = sanitized.strip('. _') 215 | 216 | # Windows Reserved Names (case-insensitive) 217 | reserved_names = {'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'} 218 | # Check base name without extension 219 | base_name, ext = os.path.splitext(sanitized) 220 | if base_name.upper() in reserved_names: 221 | sanitized = f"_{base_name}_{ext}" # Prepend underscore 222 | 223 | # Prevent names that are just '.' or '..' (though stripping dots should handle this) 224 | if sanitized == '.' or sanitized == '..': 225 | sanitized = default_filename + "_invalid_name" 226 | 227 | # If sanitization results in an empty string (unlikely now), use default 228 | if not sanitized: 229 | sanitized = default_filename 230 | 231 | # Optional: Limit overall length (e.g., 200 chars), considering path limits 232 | # Be careful as some systems have path limits, not just filename limits 233 | max_len = 200 # A reasonable limit for the filename itself 234 | if len(sanitized) > max_len: 235 | name_part, ext_part = os.path.splitext(sanitized) 236 | # Truncate the name part, ensuring total length is within max_len 237 | allowed_name_len = max_len - len(ext_part) 238 | if allowed_name_len <= 0: # Handle case where extension itself is too long 239 | sanitized = sanitized[:max_len] # Truncate forcefully 240 | else: 241 | sanitized = name_part[:allowed_name_len] + ext_part 242 | print(f"Warning: Sanitized filename truncated to {max_len} characters.") 243 | 244 | return sanitized 245 | 246 | def select_primary_file(files: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: 247 | """ 248 | Selects the best file from a list of files based on a heuristic. 249 | Prefers primary, then safetensors, then pruned, etc. 250 | Returns the selected file dictionary or None. 251 | """ 252 | if not files or not isinstance(files, list): 253 | return None 254 | 255 | # First, try to find a file explicitly marked as "primary" with a valid download URL 256 | primary_marked_file = next((f for f in files if isinstance(f, dict) and f.get("primary") and f.get('downloadUrl')), None) 257 | if primary_marked_file: 258 | return primary_marked_file 259 | 260 | # If no primary file is marked, sort all available files using a heuristic 261 | def sort_key(file_obj): 262 | if not isinstance(file_obj, dict): return 99 263 | if not file_obj.get('downloadUrl'): return 98 # Deprioritize files without URL 264 | 265 | name_lower = file_obj.get("name", "").lower() 266 | meta = file_obj.get("metadata", {}) or {} 267 | format_type = meta.get("format", "").lower() 268 | size_type = meta.get("size", "").lower() 269 | 270 | # Fallback to file extension if format metadata missing 271 | is_safetensor = ".safetensors" in name_lower or format_type == "safetensor" 272 | is_pickle = ".ckpt" in name_lower or ".pt" in name_lower or format_type == "pickletensor" 273 | is_pruned = size_type == "pruned" 274 | 275 | if is_safetensor and is_pruned: return 0 276 | if is_safetensor: return 1 277 | if is_pickle and is_pruned: return 2 278 | if is_pickle: return 3 279 | # Prioritize model files over others like VAEs if type is available 280 | if file_obj.get("type") == "Model": return 4 281 | if file_obj.get("type") == "Pruned Model": return 5 282 | return 10 # Other types last 283 | 284 | valid_files = [f for f in files if isinstance(f, dict) and f.get("downloadUrl")] 285 | if not valid_files: 286 | return None 287 | 288 | sorted_files = sorted(valid_files, key=sort_key) 289 | return sorted_files[0] 290 | -------------------------------------------------------------------------------- /web/js/ui/UI.js: -------------------------------------------------------------------------------- 1 | import { Feedback } from "./feedback.js"; 2 | import { setupEventListeners } from "./handlers/eventListeners.js"; 3 | import { handleDownloadSubmit, fetchAndDisplayDownloadPreview, debounceFetchDownloadPreview } from "./handlers/downloadHandler.js"; 4 | import { handleSearchSubmit } from "./handlers/searchHandler.js"; 5 | import { handleSettingsSave, loadAndApplySettings, loadSettingsFromCookie, saveSettingsToCookie, applySettings, getDefaultSettings } from "./handlers/settingsHandler.js"; 6 | import { startStatusUpdates, stopStatusUpdates, updateStatus, handleCancelDownload, handleRetryDownload, handleOpenPath, handleClearHistory } from "./handlers/statusHandler.js"; 7 | import { renderSearchResults } from "./searchRenderer.js"; 8 | import { renderDownloadList } from "./statusRenderer.js"; 9 | import { renderDownloadPreview } from "./previewRenderer.js"; 10 | import { modalTemplate } from "./templates.js"; 11 | import { CivitaiDownloaderAPI } from "../api/civitai.js"; 12 | 13 | export class CivitaiDownloaderUI { 14 | constructor() { 15 | this.modal = null; 16 | this.tabs = {}; 17 | this.tabContents = {}; 18 | this.activeTab = 'download'; 19 | this.modelTypes = {}; 20 | this.statusInterval = null; 21 | this.statusData = { queue: [], active: [], history: [] }; 22 | this.baseModels = []; 23 | this.searchPagination = { currentPage: 1, totalPages: 1, limit: 20 }; 24 | this.settings = this.getDefaultSettings(); 25 | this.toastTimeout = null; 26 | this.modelPreviewDebounceTimeout = null; 27 | 28 | this.updateStatus(); 29 | this.buildModalHTML(); 30 | this.cacheDOMElements(); 31 | this.setupEventListeners(); 32 | this.feedback = new Feedback(this.modal.querySelector('#civitai-toast')); 33 | // Ensure icon stylesheet is loaded so buttons render icons immediately 34 | this.ensureFontAwesome(); 35 | } 36 | 37 | // --- Core UI Methods --- 38 | buildModalHTML() { 39 | this.modal = document.createElement('div'); 40 | this.modal.className = 'civitai-downloader-modal'; 41 | this.modal.id = 'civitai-downloader-modal'; 42 | this.modal.innerHTML = modalTemplate(this.settings); 43 | } 44 | 45 | cacheDOMElements() { 46 | this.closeButton = this.modal.querySelector('#civitai-close-modal'); 47 | this.tabContainer = this.modal.querySelector('.civitai-downloader-tabs'); 48 | 49 | // Download Tab 50 | this.downloadForm = this.modal.querySelector('#civitai-download-form'); 51 | this.downloadPreviewArea = this.modal.querySelector('#civitai-download-preview-area'); 52 | this.modelUrlInput = this.modal.querySelector('#civitai-model-url'); 53 | this.modelVersionIdInput = this.modal.querySelector('#civitai-model-version-id'); 54 | this.downloadModelTypeSelect = this.modal.querySelector('#civitai-model-type'); 55 | this.createModelTypeButton = this.modal.querySelector('#civitai-create-model-type'); 56 | this.customFilenameInput = this.modal.querySelector('#civitai-custom-filename'); 57 | this.subdirSelect = this.modal.querySelector('#civitai-subdir-select'); 58 | this.createSubdirButton = this.modal.querySelector('#civitai-create-subdir'); 59 | this.downloadConnectionsInput = this.modal.querySelector('#civitai-connections'); 60 | this.forceRedownloadCheckbox = this.modal.querySelector('#civitai-force-redownload'); 61 | this.downloadSubmitButton = this.modal.querySelector('#civitai-download-submit'); 62 | 63 | // Search Tab 64 | this.searchForm = this.modal.querySelector('#civitai-search-form'); 65 | this.searchQueryInput = this.modal.querySelector('#civitai-search-query'); 66 | this.searchTypeSelect = this.modal.querySelector('#civitai-search-type'); 67 | this.searchBaseModelSelect = this.modal.querySelector('#civitai-search-base-model'); 68 | this.searchSortSelect = this.modal.querySelector('#civitai-search-sort'); 69 | this.searchPeriodSelect = this.modal.querySelector('#civitai-search-period'); 70 | this.searchSubmitButton = this.modal.querySelector('#civitai-search-submit'); 71 | this.searchResultsContainer = this.modal.querySelector('#civitai-search-results'); 72 | this.searchPaginationContainer = this.modal.querySelector('#civitai-search-pagination'); 73 | 74 | // Status Tab 75 | this.statusContent = this.modal.querySelector('#civitai-status-content'); 76 | this.activeListContainer = this.modal.querySelector('#civitai-active-list'); 77 | this.queuedListContainer = this.modal.querySelector('#civitai-queued-list'); 78 | this.historyListContainer = this.modal.querySelector('#civitai-history-list'); 79 | this.statusIndicator = this.modal.querySelector('#civitai-status-indicator'); 80 | this.activeCountSpan = this.modal.querySelector('#civitai-active-count'); 81 | this.clearHistoryButton = this.modal.querySelector('#civitai-clear-history-button'); 82 | this.confirmClearModal = this.modal.querySelector('#civitai-confirm-clear-modal'); 83 | this.confirmClearYesButton = this.modal.querySelector('#civitai-confirm-clear-yes'); 84 | this.confirmClearNoButton = this.modal.querySelector('#civitai-confirm-clear-no'); 85 | 86 | // Settings Tab 87 | this.settingsForm = this.modal.querySelector('#civitai-settings-form'); 88 | this.settingsApiKeyInput = this.modal.querySelector('#civitai-settings-api-key'); 89 | this.settingsConnectionsInput = this.modal.querySelector('#civitai-settings-connections'); 90 | this.settingsDefaultTypeSelect = this.modal.querySelector('#civitai-settings-default-type'); 91 | this.settingsAutoOpenCheckbox = this.modal.querySelector('#civitai-settings-auto-open-status'); 92 | this.settingsHideMatureCheckbox = this.modal.querySelector('#civitai-settings-hide-mature'); 93 | this.settingsNsfwThresholdInput = this.modal.querySelector('#civitai-settings-nsfw-threshold'); 94 | this.settingsSaveButton = this.modal.querySelector('#civitai-settings-save'); 95 | 96 | // Toast Notification 97 | this.toastElement = this.modal.querySelector('#civitai-toast'); 98 | 99 | // Collect tabs and contents 100 | this.tabs = {}; 101 | this.modal.querySelectorAll('.civitai-downloader-tab').forEach(tab => { 102 | this.tabs[tab.dataset.tab] = tab; 103 | }); 104 | this.tabContents = {}; 105 | this.modal.querySelectorAll('.civitai-downloader-tab-content').forEach(content => { 106 | const tabName = content.id.replace('civitai-tab-', ''); 107 | if (tabName) this.tabContents[tabName] = content; 108 | }); 109 | } 110 | 111 | async initializeUI() { 112 | console.info("[Civicomfy] Initializing UI components..."); 113 | await this.populateModelTypes(); 114 | await this.populateBaseModels(); 115 | this.loadAndApplySettings(); 116 | } 117 | 118 | async populateModelTypes() { 119 | console.log("[Civicomfy] Populating model types..."); 120 | try { 121 | const types = await CivitaiDownloaderAPI.getModelTypes(); 122 | if (!types || typeof types !== 'object' || Object.keys(types).length === 0) { 123 | throw new Error("Received invalid model types data format."); 124 | } 125 | this.modelTypes = types; 126 | const sortedTypes = Object.entries(this.modelTypes).sort((a, b) => a[1].localeCompare(b[1])); 127 | 128 | this.downloadModelTypeSelect.innerHTML = ''; 129 | this.searchTypeSelect.innerHTML = ''; 130 | this.settingsDefaultTypeSelect.innerHTML = ''; 131 | 132 | sortedTypes.forEach(([key, displayName]) => { 133 | const option = document.createElement('option'); 134 | option.value = key; 135 | option.textContent = displayName; 136 | this.downloadModelTypeSelect.appendChild(option.cloneNode(true)); 137 | this.settingsDefaultTypeSelect.appendChild(option.cloneNode(true)); 138 | this.searchTypeSelect.appendChild(option.cloneNode(true)); 139 | }); 140 | // After types are populated, load subdirs for the current selection 141 | await this.loadAndPopulateSubdirs(this.downloadModelTypeSelect.value); 142 | } catch (error) { 143 | console.error("[Civicomfy] Failed to get or populate model types:", error); 144 | this.showToast('Failed to load model types', 'error'); 145 | this.downloadModelTypeSelect.innerHTML = ''; 146 | this.modelTypes = { "checkpoint": "Checkpoint (Default)" }; 147 | } 148 | } 149 | 150 | async loadAndPopulateSubdirs(modelType) { 151 | try { 152 | const res = await CivitaiDownloaderAPI.getModelDirs(modelType); 153 | const select = this.subdirSelect; 154 | if (!select) return; 155 | const current = select.value; 156 | select.innerHTML = ''; 157 | const optRoot = document.createElement('option'); 158 | optRoot.value = ''; 159 | optRoot.textContent = '(root)'; 160 | select.appendChild(optRoot); 161 | if (res && Array.isArray(res.subdirs)) { 162 | // res.subdirs contains '' for root; skip empty since we added (root) 163 | res.subdirs.filter(p => p && typeof p === 'string').forEach(rel => { 164 | const opt = document.createElement('option'); 165 | opt.value = rel; 166 | opt.textContent = rel; 167 | select.appendChild(opt); 168 | }); 169 | } 170 | // Restore selection if still present 171 | if (Array.from(select.options).some(o => o.value === current)) { 172 | select.value = current; 173 | } 174 | } catch (e) { 175 | console.error('[Civicomfy] Failed to load subdirectories:', e); 176 | if (this.subdirSelect) { 177 | this.subdirSelect.innerHTML = ''; 178 | } 179 | } 180 | } 181 | 182 | // (loadAndPopulateRoots removed; dynamic types already reflect models/ subfolders) 183 | 184 | async populateBaseModels() { 185 | console.log("[Civicomfy] Populating base models..."); 186 | try { 187 | const result = await CivitaiDownloaderAPI.getBaseModels(); 188 | if (!result || !Array.isArray(result.base_models)) { 189 | throw new Error("Invalid base models data format received."); 190 | } 191 | this.baseModels = result.base_models.sort(); 192 | const existingOptions = Array.from(this.searchBaseModelSelect.options); 193 | existingOptions.slice(1).forEach(opt => opt.remove()); 194 | this.baseModels.forEach(baseModelName => { 195 | const option = document.createElement('option'); 196 | option.value = baseModelName; 197 | option.textContent = baseModelName; 198 | this.searchBaseModelSelect.appendChild(option); 199 | }); 200 | } catch (error) { 201 | console.error("[Civicomfy] Failed to get or populate base models:", error); 202 | this.showToast('Failed to load base models list', 'error'); 203 | } 204 | } 205 | 206 | switchTab(tabId) { 207 | if (this.activeTab === tabId || !this.tabs[tabId] || !this.tabContents[tabId]) return; 208 | 209 | this.tabs[this.activeTab]?.classList.remove('active'); 210 | this.tabContents[this.activeTab]?.classList.remove('active'); 211 | 212 | this.tabs[tabId].classList.add('active'); 213 | this.tabContents[tabId].classList.add('active'); 214 | this.tabContents[tabId].scrollTop = 0; 215 | this.activeTab = tabId; 216 | 217 | if (tabId === 'status') this.updateStatus(); 218 | else if (tabId === 'settings') this.applySettings(); 219 | else if(tabId === 'download') { 220 | this.downloadConnectionsInput.value = this.settings.numConnections; 221 | if (Object.keys(this.modelTypes).length > 0) { 222 | this.downloadModelTypeSelect.value = this.settings.defaultModelType; 223 | } 224 | } 225 | } 226 | 227 | // --- Modal Control --- 228 | openModal() { 229 | this.modal?.classList.add('open'); 230 | document.body.style.setProperty('overflow', 'hidden', 'important'); 231 | this.startStatusUpdates(); 232 | if (this.activeTab === 'status') this.updateStatus(); 233 | if (!this.settings.apiKey) this.switchTab('settings'); 234 | } 235 | 236 | closeModal() { 237 | this.modal?.classList.remove('open'); 238 | document.body.style.removeProperty('overflow'); 239 | this.stopStatusUpdates(); 240 | } 241 | 242 | // --- Utility Methods --- 243 | formatBytes(bytes, decimals = 2) { 244 | if (bytes === null || bytes === undefined || isNaN(bytes)) return 'N/A'; 245 | if (bytes === 0) return '0 Bytes'; 246 | const k = 1024; 247 | const dm = decimals < 0 ? 0 : decimals; 248 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; 249 | const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)); 250 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 251 | } 252 | 253 | formatSpeed(bytesPerSecond) { 254 | if (!isFinite(bytesPerSecond) || bytesPerSecond <= 0) return ''; 255 | return this.formatBytes(bytesPerSecond) + '/s'; 256 | } 257 | 258 | formatDuration(isoStart, isoEnd) { 259 | try { 260 | const diffSeconds = Math.round((new Date(isoEnd) - new Date(isoStart)) / 1000); 261 | if (isNaN(diffSeconds) || diffSeconds < 0) return 'N/A'; 262 | if (diffSeconds < 60) return `${diffSeconds}s`; 263 | const diffMinutes = Math.floor(diffSeconds / 60); 264 | const remainingSeconds = diffSeconds % 60; 265 | return `${diffMinutes}m ${remainingSeconds}s`; 266 | } catch (e) { 267 | return 'N/A'; 268 | } 269 | } 270 | 271 | showToast(message, type = 'info', duration = 3000) { 272 | this.feedback?.show(message, type, duration); 273 | } 274 | 275 | ensureFontAwesome() { 276 | this.feedback?.ensureFontAwesome(); 277 | } 278 | 279 | // --- Rendering (delegated to external renderers) --- 280 | renderDownloadList = (items, container, emptyMessage) => renderDownloadList(this, items, container, emptyMessage); 281 | renderSearchResults = (items) => renderSearchResults(this, items); 282 | renderDownloadPreview = (data) => renderDownloadPreview(this, data); 283 | 284 | // --- Auto-select model type based on Civitai model type --- 285 | inferFolderFromCivitaiType(civitaiType) { 286 | if (!civitaiType || typeof civitaiType !== 'string') return null; 287 | const t = civitaiType.trim().toLowerCase(); 288 | const keys = Object.keys(this.modelTypes || {}); 289 | if (keys.length === 0) return null; 290 | 291 | const exists = (k) => keys.includes(k); 292 | const findBy = (pred) => keys.find(pred); 293 | 294 | // Direct matches first 295 | if (exists(t)) return t; 296 | if (exists(`${t}s`)) return `${t}s`; 297 | 298 | // Common mappings from Civitai types to ComfyUI folders 299 | const candidates = []; 300 | const addIfExists = (k) => { if (exists(k)) candidates.push(k); }; 301 | 302 | switch (t) { 303 | case 'checkpoint': 304 | addIfExists('checkpoints'); 305 | addIfExists('models'); 306 | break; 307 | case 'lora': case 'locon': case 'lycoris': 308 | addIfExists('loras'); 309 | break; 310 | case 'vae': 311 | addIfExists('vae'); 312 | break; 313 | case 'textualinversion': case 'embedding': case 'embeddings': 314 | addIfExists('embeddings'); 315 | break; 316 | case 'hypernetwork': 317 | addIfExists('hypernetworks'); 318 | break; 319 | case 'controlnet': 320 | addIfExists('controlnet'); 321 | break; 322 | case 'unet': case 'unet2': 323 | addIfExists('unet'); 324 | break; 325 | case 'diffusers': case 'diffusionmodels': case 'diffusion_models': case 'diffusion': 326 | addIfExists('diffusers'); 327 | addIfExists('diffusion_models'); 328 | break; 329 | case 'upscaler': case 'upscalers': 330 | addIfExists('upscale_models'); 331 | addIfExists('upscalers'); 332 | break; 333 | case 'motionmodule': 334 | addIfExists('motion_models'); 335 | break; 336 | case 'poses': 337 | addIfExists('poses'); 338 | break; 339 | case 'wildcards': 340 | addIfExists('wildcards'); 341 | break; 342 | case 'onnx': 343 | addIfExists('onnx'); 344 | break; 345 | } 346 | if (candidates.length > 0) return candidates[0]; 347 | 348 | // Relaxed match: name contains type 349 | const contains = findBy(k => k.toLowerCase().includes(t)); 350 | if (contains) return contains; 351 | 352 | return null; 353 | } 354 | 355 | async autoSelectModelTypeFromCivitai(civitaiType) { 356 | try { 357 | const folder = this.inferFolderFromCivitaiType(civitaiType); 358 | if (!folder) return; 359 | if (this.downloadModelTypeSelect && this.downloadModelTypeSelect.value !== folder) { 360 | this.downloadModelTypeSelect.value = folder; 361 | await this.loadAndPopulateSubdirs(folder); 362 | // Reset subdir to root after auto-switch 363 | if (this.subdirSelect) this.subdirSelect.value = ''; 364 | } 365 | } catch (e) { 366 | console.warn('[Civicomfy] Auto-select model type failed:', e); 367 | } 368 | } 369 | 370 | renderSearchPagination(metadata) { 371 | if (!this.searchPaginationContainer) return; 372 | if (!metadata || metadata.totalPages <= 1) { 373 | this.searchPaginationContainer.innerHTML = ''; 374 | this.searchPagination = { ...this.searchPagination, ...metadata }; 375 | return; 376 | } 377 | 378 | this.searchPagination = { ...this.searchPagination, ...metadata }; 379 | const { currentPage, totalPages, totalItems } = this.searchPagination; 380 | 381 | const createButton = (text, page, isDisabled = false, isCurrent = false) => { 382 | const button = document.createElement('button'); 383 | button.className = `civitai-button small civitai-page-button ${isCurrent ? 'primary active' : ''}`; 384 | button.dataset.page = page; 385 | button.disabled = isDisabled; 386 | button.innerHTML = text; 387 | button.type = 'button'; 388 | return button; 389 | }; 390 | 391 | const fragment = document.createDocumentFragment(); 392 | fragment.appendChild(createButton('« Prev', currentPage - 1, currentPage === 1)); 393 | 394 | let startPage = Math.max(1, currentPage - 2); 395 | let endPage = Math.min(totalPages, currentPage + 2); 396 | 397 | if (startPage > 1) fragment.appendChild(createButton('1', 1)); 398 | if (startPage > 2) fragment.appendChild(document.createElement('span')).textContent = '...'; 399 | 400 | for (let i = startPage; i <= endPage; i++) { 401 | fragment.appendChild(createButton(i, i, false, i === currentPage)); 402 | } 403 | 404 | if (endPage < totalPages - 1) fragment.appendChild(document.createElement('span')).textContent = '...'; 405 | if (endPage < totalPages) fragment.appendChild(createButton(totalPages, totalPages)); 406 | 407 | fragment.appendChild(createButton('Next »', currentPage + 1, currentPage === totalPages)); 408 | 409 | const info = document.createElement('div'); 410 | info.className = 'civitai-pagination-info'; 411 | info.textContent = `Page ${currentPage} of ${totalPages} (${totalItems.toLocaleString()} models)`; 412 | fragment.appendChild(info); 413 | 414 | this.searchPaginationContainer.innerHTML = ''; 415 | this.searchPaginationContainer.appendChild(fragment); 416 | } 417 | 418 | // --- Event Handlers and State Management (delegated to handlers) --- 419 | setupEventListeners = () => setupEventListeners(this); 420 | getDefaultSettings = () => getDefaultSettings(); 421 | loadAndApplySettings = () => loadAndApplySettings(this); 422 | loadSettingsFromCookie = () => loadSettingsFromCookie(this); 423 | saveSettingsToCookie = () => saveSettingsToCookie(this); 424 | applySettings = () => applySettings(this); 425 | handleSettingsSave = () => handleSettingsSave(this); 426 | handleDownloadSubmit = () => handleDownloadSubmit(this); 427 | handleSearchSubmit = () => handleSearchSubmit(this); 428 | fetchAndDisplayDownloadPreview = () => fetchAndDisplayDownloadPreview(this); 429 | debounceFetchDownloadPreview = (delay) => debounceFetchDownloadPreview(this, delay); 430 | startStatusUpdates = () => startStatusUpdates(this); 431 | stopStatusUpdates = () => stopStatusUpdates(this); 432 | updateStatus = () => updateStatus(this); 433 | handleCancelDownload = (downloadId) => handleCancelDownload(this, downloadId); 434 | handleRetryDownload = (downloadId, button) => handleRetryDownload(this, downloadId, button); 435 | handleOpenPath = (downloadId, button) => handleOpenPath(this, downloadId, button); 436 | handleClearHistory = () => handleClearHistory(this); 437 | } 438 | -------------------------------------------------------------------------------- /web/js/civitaiDownloader.css: -------------------------------------------------------------------------------- 1 | .civitai-downloader-modal { 2 | position: fixed; 3 | z-index: 1001; /* Above ComfyUI elements */ 4 | left: 0; 5 | top: 0; 6 | width: 100%; 7 | height: 100%; 8 | overflow: hidden; /* Prevent body scroll */ 9 | background-color: rgba(0, 0, 0, 0.6); 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | opacity: 0; 14 | visibility: hidden; 15 | transition: opacity 0.3s ease, visibility 0s linear 0.3s; 16 | } 17 | 18 | .civitai-downloader-modal.open { 19 | opacity: 1; 20 | visibility: visible; 21 | transition: opacity 0.3s ease, visibility 0s linear 0s; 22 | } 23 | 24 | .civitai-downloader-modal-content { 25 | background-color: var(--comfy-menu-bg); 26 | color: var(--comfy-text-color); 27 | margin: auto; 28 | padding: 0; /* Remove padding, handle internally */ 29 | border-radius: 8px; 30 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); 31 | width: 900px; 32 | max-width: 95%; 33 | height: 700px; /* Fixed height */ 34 | max-height: 90vh; 35 | display: flex; /* Use flexbox for layout */ 36 | flex-direction: column; /* Stack header, tabs, content */ 37 | overflow: hidden; /* Prevent content overflow */ 38 | } 39 | 40 | .civitai-downloader-header { 41 | display: flex; 42 | justify-content: space-between; 43 | align-items: center; 44 | padding: 15px 20px; 45 | border-bottom: 1px solid var(--border-color, #444); 46 | flex-shrink: 0; /* Prevent header from shrinking */ 47 | } 48 | 49 | .civitai-downloader-header h2 { 50 | margin: 0; 51 | font-size: 1.3em; 52 | } 53 | 54 | .civitai-close-button { 55 | background: none; 56 | border: none; 57 | color: var(--comfy-text-color); 58 | font-size: 28px; 59 | cursor: pointer; 60 | padding: 0 5px; 61 | line-height: 1; 62 | } 63 | .civitai-close-button:hover { 64 | color: #aaa; 65 | } 66 | 67 | .civitai-downloader-body { 68 | display: flex; 69 | flex-direction: column; 70 | flex-grow: 1; /* Allow body to take remaining space */ 71 | overflow: hidden; /* Manage internal scrolling */ 72 | } 73 | 74 | .civitai-downloader-tabs { 75 | display: flex; 76 | border-bottom: 1px solid var(--border-color, #444); 77 | padding: 0 15px; 78 | flex-shrink: 0; /* Prevent tabs from shrinking */ 79 | } 80 | 81 | .civitai-downloader-tab { 82 | padding: 10px 18px; 83 | cursor: pointer; 84 | border: none; 85 | background: none; 86 | color: var(--comfy-text-color); 87 | opacity: 0.7; 88 | position: relative; 89 | top: 1px; /* Align with bottom border */ 90 | margin-bottom: -1px; /* Overlap border */ 91 | } 92 | 93 | .civitai-downloader-tab.active { 94 | opacity: 1; 95 | border-bottom: 3px solid var(--accent-color, #5c8aff); 96 | font-weight: bold; 97 | } 98 | .civitai-downloader-tab:hover { 99 | opacity: 1; 100 | } 101 | 102 | .civitai-downloader-tab-content { 103 | display: none; 104 | padding: 20px; 105 | flex-grow: 1; /* Allow content to take space */ 106 | overflow-y: auto; /* Enable scrolling ONLY for the content area */ 107 | } 108 | 109 | .civitai-downloader-tab-content.active { 110 | display: block; /* Show active tab */ 111 | } 112 | 113 | .civitai-form-group { 114 | margin-bottom: 18px; 115 | } 116 | .civitai-form-group:last-child { 117 | margin-bottom: 0; 118 | } 119 | 120 | .civitai-form-group label { 121 | display: block; 122 | margin-bottom: 6px; 123 | font-weight: bold; 124 | font-size: 0.95em; 125 | } 126 | 127 | .civitai-input, 128 | .civitai-select { 129 | width: 100%; 130 | padding: 10px; 131 | background-color: var(--comfy-input-bg); 132 | color: var(--comfy-text-color); 133 | border: 1px solid var(--border-color, #555); 134 | border-radius: 4px; 135 | box-sizing: border-box; /* Include padding/border in width */ 136 | } 137 | .civitai-input:focus, 138 | .civitai-select:focus { 139 | outline: none; 140 | border-color: var(--accent-color, #5c8aff); 141 | box-shadow: 0 0 0 2px rgba(92, 138, 255, 0.3); 142 | } 143 | 144 | .civitai-form-row { 145 | display: flex; 146 | gap: 15px; 147 | flex-wrap: wrap; /* Allow wrapping on smaller screens */ 148 | } 149 | .civitai-form-row > .civitai-form-group { 150 | flex: 1; /* Distribute space */ 151 | min-width: 180px; /* Minimum width before wrapping */ 152 | } 153 | /* Adjust for checkbox/inline elements */ 154 | .civitai-form-group.inline { 155 | display: flex; 156 | align-items: center; 157 | gap: 8px; 158 | margin-bottom: 10px; /* Less margin for checkboxes */ 159 | } 160 | .civitai-form-group.inline label { 161 | margin-bottom: 0; /* Remove default bottom margin */ 162 | order: 1; /* Put label after checkbox */ 163 | } 164 | .civitai-checkbox { 165 | width: auto; 166 | order: 0; /* Put checkbox before label */ 167 | accent-color: var(--accent-color, #5c8aff); 168 | transform: scale(1.1); 169 | } 170 | 171 | .civitai-button { 172 | background-color: var(--comfy-input-bg); 173 | color: var(--comfy-text-color); 174 | border: 1px solid var(--border-color, #555); 175 | padding: 10px 18px; 176 | border-radius: 4px; 177 | cursor: pointer; 178 | font-size: 1em; 179 | transition: background-color 0.2s ease, border-color 0.2s ease; 180 | } 181 | .civitai-button:hover { 182 | background-color: var(--comfy-input-bg-hover); 183 | border-color: #777; 184 | } 185 | .civitai-button:disabled { 186 | opacity: 0.5; 187 | cursor: not-allowed; 188 | } 189 | 190 | .civitai-button.primary { 191 | background-color: var(--accent-color, #5c8aff); 192 | border-color: var(--accent-color, #5c8aff); 193 | color: white; 194 | } 195 | .civitai-button.primary:hover { 196 | background-color: #4a7ee0; /* Slightly darker blue */ 197 | border-color: #4a7ee0; 198 | } 199 | .civitai-button.primary:disabled { 200 | background-color: var(--accent-color, #5c8aff); /* Keep color but lower opacity */ 201 | border-color: var(--accent-color, #5c8aff); 202 | } 203 | 204 | .civitai-button.danger { 205 | background-color: #e64b4b; 206 | border-color: #e64b4b; 207 | color: white; 208 | } 209 | .civitai-button.danger:hover { 210 | background-color: #d93a3a; 211 | border-color: #d93a3a; 212 | } 213 | .civitai-button.small { 214 | padding: 5px 10px; 215 | font-size: 0.85em; 216 | } 217 | 218 | /* Status Tab Styling */ 219 | .civitai-status-section { 220 | margin-bottom: 25px; 221 | } 222 | .civitai-status-section h3 { 223 | margin-top: 0; 224 | margin-bottom: 15px; 225 | border-bottom: 1px solid var(--border-color, #444); 226 | padding-bottom: 8px; 227 | font-size: 1.1em; 228 | } 229 | 230 | .civitai-download-list { 231 | display: flex; 232 | flex-direction: column; 233 | gap: 15px; 234 | } 235 | 236 | .civitai-download-item { 237 | display: flex; 238 | gap: 15px; 239 | align-items: flex-start; /* Align items top */ 240 | padding: 15px; 241 | border-radius: 6px; 242 | background-color: var(--comfy-input-bg); /* Slightly different bg for items */ 243 | border: 1px solid var(--border-color, #555); 244 | } 245 | 246 | .civitai-download-thumbnail { 247 | width: 80px; 248 | height: 80px; 249 | object-fit: cover; 250 | border-radius: 4px; 251 | background-color: #333; /* Placeholder color */ 252 | flex-shrink: 0; /* Don't shrink thumbnail */ 253 | margin-top: 3px; /* Align better with text */ 254 | } 255 | 256 | .civitai-download-info { 257 | flex-grow: 1; /* Allow info to take available space */ 258 | display: flex; 259 | flex-direction: column; 260 | gap: 5px; 261 | overflow: hidden; /* Prevent long text overflow */ 262 | } 263 | .civitai-download-info strong { 264 | font-size: 1.05em; 265 | white-space: nowrap; 266 | overflow: hidden; 267 | text-overflow: ellipsis; 268 | } 269 | .civitai-download-info p { 270 | margin: 0; 271 | font-size: 0.9em; 272 | color: #ccc; 273 | white-space: nowrap; 274 | overflow: hidden; 275 | text-overflow: ellipsis; 276 | } 277 | .civitai-download-info .filename { 278 | font-style: italic; 279 | color: #bbb; 280 | } 281 | .civitai-download-info .error-message { 282 | color: #ff6b6b; 283 | font-size: 0.85em; 284 | white-space: normal; /* Allow error message to wrap */ 285 | word-break: break-word; 286 | } 287 | 288 | .civitai-download-actions { 289 | display: flex; 290 | align-items: center; 291 | gap: 8px; 292 | margin-left: auto; /* Push actions to the right */ 293 | flex-shrink: 0; /* Don't shrink action buttons */ 294 | } 295 | 296 | .civitai-progress-container { 297 | margin-top: 8px; 298 | width: 100%; 299 | background-color: var(--comfy-menu-bg); /* Darker background for contrast */ 300 | border-radius: 4px; 301 | overflow: hidden; 302 | height: 20px; 303 | border: 1px solid var(--border-color, #555); 304 | } 305 | 306 | .civitai-progress-bar { 307 | height: 100%; 308 | background-color: var(--accent-color, #5c8aff); 309 | text-align: center; 310 | color: white; 311 | line-height: 20px; /* Center text vertically */ 312 | font-size: 12px; 313 | font-weight: bold; 314 | transition: width 0.3s ease-out; 315 | min-width: 20px; /* Show something even for 0% */ 316 | width: 0%; /* Start at 0 */ 317 | white-space: nowrap; 318 | overflow: hidden; 319 | } 320 | .civitai-progress-bar.completed { 321 | background-color: #4caf50; /* Green for completed */ 322 | } 323 | .civitai-progress-bar.failed { 324 | background-color: #f44336; /* Red for failed */ 325 | } 326 | .civitai-progress-bar.cancelled { 327 | background-color: #9e9e9e; /* Grey for cancelled */ 328 | } 329 | 330 | .civitai-speed-indicator { 331 | font-size: 0.85em; 332 | color: #ccc; 333 | margin-top: 4px; 334 | text-align: right; 335 | } 336 | 337 | /* Search Tab Styling */ 338 | .civitai-search-controls { 339 | display: flex; 340 | gap: 15px; 341 | margin-bottom: 20px; 342 | flex-wrap: wrap; 343 | } 344 | .civitai-search-controls .civitai-input { 345 | flex-grow: 1; /* Let search input take more space */ 346 | } 347 | .civitai-search-controls .civitai-select { 348 | min-width: 150px; /* Min width for dropdowns */ 349 | } 350 | .civitai-search-controls .civitai-button { 351 | align-self: flex-end; /* Align button with bottom of inputs */ 352 | } 353 | 354 | .civitai-search-results { 355 | margin-top: 20px; 356 | } 357 | .civitai-search-item { 358 | display: flex; 359 | gap: 15px; 360 | align-items: flex-start; 361 | margin-bottom: 15px; 362 | padding: 15px; 363 | border-radius: 6px; 364 | background-color: var(--comfy-input-bg); 365 | border: 1px solid var(--border-color, #555); 366 | transition: background-color 0.2s ease; 367 | } 368 | 369 | .civitai-thumbnail-container { 370 | position: relative; /* Required for absolute positioning of the child badge */ 371 | display: block; 372 | line-height: 0; 373 | width: 120px; 374 | height: 170px; 375 | flex-shrink: 0; 376 | } 377 | 378 | .civitai-search-thumbnail { 379 | width: 120px; /* Slightly larger for search */ 380 | height: 170px; 381 | object-fit: cover; 382 | border-radius: 4px; 383 | background-color: #333; 384 | flex-shrink: 0; 385 | } 386 | 387 | .civitai-search-meta-info { 388 | font-size: 0.85em; 389 | color: #aaa; 390 | margin-bottom: 5px; 391 | display: flex; 392 | gap: 15px; 393 | flex-wrap: wrap; 394 | } 395 | 396 | .civitai-search-meta-info span { 397 | display: inline-flex; 398 | align-items: center; 399 | gap: 5px; 400 | } 401 | 402 | .civitai-search-meta-info i { 403 | color: #ccc; 404 | } 405 | .civitai-type-badge { 406 | position: absolute; 407 | bottom: 5px; /* Adjust spacing from bottom */ 408 | left: 5px; /* Adjust spacing from left */ 409 | background-color: rgb(48, 94, 70); /* Black background with transparency */ 410 | color: white; 411 | padding: 10px 8px; /* Adjust padding (top/bottom, left/right) */ 412 | border-radius: 10px; /* Adjust for desired roundness */ 413 | font-size: 0.75em; /* Adjust font size */ 414 | font-weight: bold; 415 | z-index: 2; /* Ensure it's above the image */ 416 | white-space: nowrap; /* Prevent text wrapping */ 417 | } 418 | 419 | .civitai-search-info { 420 | flex-grow: 1; 421 | display: flex; 422 | flex-direction: column; 423 | gap: 5px; 424 | overflow: hidden; 425 | } 426 | .civitai-search-info h4 { 427 | margin: 0; 428 | font-size: 1.1em; 429 | white-space: nowrap; 430 | overflow: hidden; 431 | text-overflow: ellipsis; 432 | } 433 | .civitai-search-info p { 434 | margin: 0; 435 | font-size: 0.9em; 436 | color: #ccc; 437 | } 438 | .civitai-search-tags { 439 | display: flex; 440 | flex-wrap: wrap; 441 | gap: 5px; 442 | margin-top: 8px; 443 | } 444 | .civitai-search-tag { 445 | background-color: rgba(255, 255, 255, 0.1); 446 | color: #eee; 447 | padding: 3px 8px; 448 | border-radius: 10px; 449 | font-size: 0.8em; 450 | } 451 | .civitai-search-stats span { 452 | margin-right: 15px; 453 | font-size: 0.85em; 454 | color: #bbb; 455 | } 456 | .civitai-search-stats i { /* If using icons */ 457 | margin-right: 4px; 458 | } 459 | 460 | .civitai-search-actions { 461 | display: flex; 462 | flex-direction: column; /* Stack buttons vertically */ 463 | gap: 8px; 464 | align-items: flex-end; /* Align buttons to the right */ 465 | margin-left: auto; 466 | flex-shrink: 0; 467 | } 468 | 469 | .version-buttons-container { 470 | display: flex; 471 | flex-direction: column; 472 | align-items: flex-end; 473 | gap: 5px; 474 | } 475 | 476 | .all-versions-container { 477 | display: flex; 478 | flex-direction: column; 479 | gap: 5px; 480 | padding-bottom: 5px; 481 | white-space: nowrap; /* Prevent wrapping */ 482 | max-width: 100%; /* Prevent excessive width */ 483 | align-items: flex-end; 484 | } 485 | 486 | .show-all-versions-button { 487 | align-self: flex-end; /* Align button to left */ 488 | margin-top: auto; /* Push to the bottom */ 489 | } 490 | .base-model-badge { 491 | display: inline-block; /* Allows padding and background */ 492 | background-color: #3a3a3a; /* Or a specific purple hex code like #800080 */ 493 | color: white; 494 | font-weight: bold; 495 | padding: 2px 6px; /* Adjust padding for desired size (vertical horizontal) */ 496 | border-radius: 3px; /* Adjust for desired roundness */ 497 | margin-right: 4px; /* Optional: Adds some space between badge and hyphen */ 498 | line-height: 1; /* Optional: Can help contain the background better */ 499 | vertical-align: middle; /* Optional: Helps align the badge nicely with text */ 500 | } 501 | 502 | 503 | /* Settings Tab Styling */ 504 | .civitai-settings-container { 505 | display: grid; 506 | grid-template-columns: 1fr; /* Single column */ 507 | gap: 20px; 508 | } 509 | 510 | @media (min-width: 768px) { 511 | .civitai-settings-container { 512 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); /* Responsive columns */ 513 | } 514 | } 515 | 516 | .civitai-settings-section { 517 | background-color: var(--comfy-input-bg); 518 | padding: 15px; 519 | border-radius: 6px; 520 | border: 1px solid var(--border-color, #555); 521 | } 522 | .civitai-settings-section h4 { 523 | margin-top: 0; 524 | margin-bottom: 15px; 525 | border-bottom: 1px solid var(--border-color, #444); 526 | padding-bottom: 8px; 527 | } 528 | 529 | .civitai-toast { 530 | position: fixed; 531 | bottom: 20px; 532 | left: 50%; 533 | transform: translateX(-50%); 534 | background-color: rgba(0, 0, 0, 0.8); 535 | color: white; 536 | padding: 12px 20px; 537 | border-radius: 6px; 538 | z-index: 1005; /* Above modal */ 539 | font-size: 0.95em; 540 | opacity: 0; 541 | transition: opacity 0.3s ease, bottom 0.3s ease; 542 | pointer-events: none; /* Don't block clicks */ 543 | } 544 | 545 | .civitai-toast.show { 546 | opacity: 1; 547 | bottom: 30px; 548 | } 549 | 550 | .civitai-toast.success { 551 | background-color: rgba(76, 175, 80, 0.9); /* Green */ 552 | } 553 | 554 | .civitai-toast.error { 555 | background-color: rgba(244, 67, 54, 0.9); /* Red */ 556 | } 557 | 558 | .civitai-toast.info { 559 | background-color: rgba(33, 150, 243, 0.9); /* Blue */ 560 | } 561 | 562 | /* Tooltip Styles */ 563 | [data-tooltip] { 564 | position: relative; 565 | cursor: help; 566 | } 567 | [data-tooltip]::after { 568 | content: attr(data-tooltip); 569 | position: absolute; 570 | left: 50%; 571 | transform: translateX(-50%); 572 | bottom: 100%; 573 | margin-bottom: 5px; 574 | background-color: rgba(0, 0, 0, 0.85); 575 | color: white; 576 | padding: 5px 10px; 577 | border-radius: 4px; 578 | font-size: 0.85em; 579 | white-space: nowrap; 580 | opacity: 0; 581 | visibility: hidden; 582 | transition: opacity 0.2s ease, visibility 0.2s ease; 583 | z-index: 1010; /* Ensure tooltip is on top */ 584 | } 585 | [data-tooltip]:hover::after { 586 | opacity: 1; 587 | visibility: visible; 588 | } 589 | 590 | .civitai-download-preview-area { 591 | /* Add specific margins/padding if needed */ 592 | min-height: 50px; /* Ensure it has some height for the loading spinner */ 593 | } 594 | 595 | /* Style for the description box */ 596 | .model-description-content { 597 | max-height: 200px; /* Limit height */ 598 | overflow-y: auto; /* Allow scrolling */ 599 | background-color: var(--comfy-input-bg); /* Match input background */ 600 | padding: 10px; 601 | border-radius: 4px; 602 | font-size: 0.9em; /* Slightly smaller text */ 603 | border: 1px solid var(--border-color, #555); 604 | color: var(--comfy-text-color); /* Ensure text color matches */ 605 | line-height: 1.5; /* Improve readability */ 606 | } 607 | 608 | /* Style links within the description */ 609 | .model-description-content a { 610 | color: var(--accent-color, #5c8aff); /* Make links stand out */ 611 | text-decoration: underline; 612 | } 613 | .model-description-content a:hover { 614 | color: #8bb0ff; /* Lighter color on hover */ 615 | } 616 | 617 | /* Ensure reused search item styles look okay in this context */ 618 | .civitai-download-preview-area .civitai-search-item { 619 | margin-bottom: 15px; /* Add space below the item */ 620 | background-color: var(--comfy-input-bg); /* Explicitly set background */ 621 | } 622 | 623 | /* --- Confirmation Modal Styles --- */ 624 | .civitai-confirmation-modal { 625 | display: none; /* Hidden by default */ 626 | position: fixed; /* Stay in place */ 627 | z-index: 1001; /* Ensure it's above the main modal */ 628 | left: 0; 629 | top: 0; 630 | width: 100%; 631 | height: 100%; 632 | overflow: auto; /* Enable scroll if needed */ 633 | background-color: rgba(0,0,0,0.6); /* Dim background */ 634 | /* Use flexbox for easy centering */ 635 | justify-content: center; 636 | align-items: center; 637 | } 638 | 639 | .civitai-confirmation-modal-content { 640 | background-color: var(--comfy-menu-bg, #282828); 641 | margin: auto; 642 | padding: 25px; 643 | border: 1px solid var(--border-color, #444); 644 | border-radius: 8px; 645 | width: 80%; 646 | max-width: 450px; /* Limit width */ 647 | box-shadow: 0 5px 15px rgba(0,0,0,0.3); 648 | color: var(--input-text, #ddd); 649 | } 650 | 651 | .civitai-confirmation-modal-content h4 { 652 | margin-top: 0; 653 | margin-bottom: 15px; 654 | color: var(--desc-text, #eee); 655 | border-bottom: 1px solid var(--border-color, #444); 656 | padding-bottom: 10px; 657 | } 658 | 659 | .civitai-confirmation-modal-content p { 660 | margin-bottom: 25px; 661 | line-height: 1.5; 662 | font-size: 0.95em; 663 | } 664 | 665 | .civitai-confirmation-modal-actions { 666 | display: flex; 667 | justify-content: flex-end; /* Align buttons to the right */ 668 | gap: 10px; /* Space between buttons */ 669 | } 670 | 671 | /* Style buttons within the confirmation modal */ 672 | .civitai-confirmation-modal-actions .civitai-button { 673 | padding: 8px 15px; /* Adjust padding */ 674 | min-width: 80px; /* Ensure minimum width */ 675 | } 676 | 677 | /* Ensure both img and video within the thumbnail container behave similarly */ 678 | .civitai-thumbnail-container img.civitai-search-thumbnail, 679 | .civitai-thumbnail-container video.civitai-search-thumbnail, 680 | .civitai-thumbnail-container img.civitai-download-thumbnail, 681 | .civitai-thumbnail-container video.civitai-download-thumbnail { 682 | display: block; /* Prevent extra space below */ 683 | width: 100%; /* Fill the container width */ 684 | height: 100%; /* Fill the container height */ 685 | object-fit: cover; /* Scale while maintaining aspect ratio, cropping if needed */ 686 | background-color: #333; /* Background for loading/error states */ 687 | } 688 | 689 | /* You might already have rules for the container itself, ensure they define a size */ 690 | .civitai-thumbnail-container { 691 | width: 100px; /* Example size */ 692 | height: 140px; /* Example size */ 693 | overflow: hidden; /* Hide parts of the video/image outside the cover area */ 694 | position: relative; /* For positioning badges etc. */ 695 | flex-shrink: 0; /* Prevent shrinking in flex layouts */ 696 | border-radius: 4px; /* Match styling */ 697 | } 698 | 699 | /* Blur/Hide for R-rated thumbnails */ 700 | .civitai-thumbnail-container.blurred img.civitai-search-thumbnail, 701 | .civitai-thumbnail-container.blurred video.civitai-search-thumbnail, 702 | .civitai-thumbnail-container.blurred img.civitai-download-thumbnail, 703 | .civitai-thumbnail-container.blurred video.civitai-download-thumbnail { 704 | filter: blur(14px) brightness(0.6) saturate(0.6); 705 | pointer-events: none; /* Let clicks hit the container */ 706 | } 707 | .civitai-thumbnail-container .civitai-nsfw-overlay { 708 | position: absolute; 709 | inset: 0; 710 | display: flex; 711 | align-items: center; 712 | justify-content: center; 713 | color: #fff; 714 | font-weight: 800; 715 | font-size: 20px; 716 | letter-spacing: 1px; 717 | background: rgba(0,0,0,0.35); 718 | z-index: 3; 719 | user-select: none; 720 | } 721 | /* Make it obvious it’s clickable */ 722 | .civitai-thumbnail-container.blurred { cursor: pointer; } 723 | 724 | /* Style for the type badge (likely exists) */ 725 | .civitai-type-badge { 726 | /* ... existing styles ... */ 727 | position: absolute; 728 | bottom: 5px; 729 | right: 5px; 730 | /* ... */ 731 | } 732 | --------------------------------------------------------------------------------