diff --git a/README.md b/README.md index 163a416..1ccc011 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ -# Comfyui-Visual-Select -A plugin for selecting ComfyUI models in the viewport. +# ComfyUI Visual Select (视图选择器) v1.0 + +ComfyUI Visual Select 是一款旨在提升模型选择体验的 ComfyUI 前端扩展插件。它通过拦截原生的模型下拉列表,提供了一个直观、支持预览图和详细信息的模型可视化浏览器,帮助用户更高效地管理和选择模型。 + +## ✨ 主要特性 + +* **🎨 可视化模型浏览**:用直观的卡片网格代替了繁琐的文字下拉菜单,支持显示模型封面图。 +* **📂 目录树导航**:支持按照文件夹结构对模型进行分类浏览。 +* **🔍 高级筛选与搜索**: + * 支持按文件名或模型名称进行文本搜索。 + * 支持按基础模型 (Base Model) 过滤。 + * 支持按模型精度 (FP Format) 过滤。 + * 支持按内容安全级别 (SFW/NSFW) 过滤。 +* **📄 HTML 详情预览**:支持直接在弹窗中渲染模型同名配套的 HTML 详细信息页面,并完美修复了本地图片资源的加载问题。 +* **🌐 国际化支持**:内置中英双语界面,可在设置中无缝切换。 +* **⚙️ 高度可定制 (持久化配置)**: + * 支持自定义拦截关键字。 + * 支持自定义排除关键字(默认放过内置独立模型浏览器的`model_type`)。 + * 配置修改后自动保存,即使在无痕模式下也不会丢失设置。 +* **🧩 独立模型浏览器节点**:提供了一个专门的 `🎨 Visual Select Model Browser` 节点,用于独立浏览和选择任何类型的模型。 + +## 📦 安装说明 + +1. 进入你的 ComfyUI 插件目录 (`ComfyUI/custom_nodes/`)。 +2. 将此项目克隆或解压至该目录下,确保文件夹名称为 `Comfyui-Visual-Select`。 +3. 重启 ComfyUI 即可自动加载。 + +## 🛠️ 使用方法 + +### 悬浮选择按钮 +当插件启用时,点击任意一个支持的**模型选择输入框**(例如 `Load Checkpoint` 里的 `ckpt_name`),鼠标左侧会弹出一个淡绿色的 `🎨 视图选择 (Visual Select)` 悬浮按钮。点击该按钮即可打开可视化面板。 + +*注:如果不点击它,该悬浮按钮会在 5 秒后自动消失,不会干扰你的正常操作。* + +### 🎨 Visual Select Model Browser 节点 +你也可以在画布中添加专用的浏览器节点: +1. 双击画布或右键,在 `🎨 Visual Select` 分类下找到 `🎨 模型浏览器 (Visual Select Model Browser)` 节点。 +2. 首先在 `model_type` 中选择你想浏览的模型大类(如 checkpoints, loras 等)。 +3. 然后点击 `selected_model`,即可弹出对应的可视化界面进行挑选。 +4. 将该节点的输出连接到任何接受模型路径字符串的节点即可使用。 + +## ⚙️ 设置选项 + +点击 ComfyUI 界面右上角的齿轮图标进入**设置 (Settings)**,在列表中可以找到以下配置项: + +* **🎨 Visual Select - 1. 开启可视化选择器**: 插件总开关,开启后生效。 +* **🎨 Visual Select - 2. 拦截关键字配置**: 只有当节点的属性名包含这些关键字时(如 ckpt, lora 等),才会弹出悬浮按钮。使用英文逗号分隔。 +* **🎨 Visual Select - 3. 排除关键字配置**: 当节点属性名包含这些关键字时,强制**不拦截**(直接显示原生下拉框)。默认包含 `model_type`。 +* **🎨 Visual Select - 4. 语言 (Language)**: 切换可视化界面的语言(中文 / English)。 + +> **提示**:所有的设置都会自动保存在插件目录下的 `config.json` 文件中,支持持久化。 + +## 📁 支持的模型封面和详情格式 + +插件会自动扫描与模型文件**同名**的特定后缀文件来显示信息: + +* **封面图**:支持 `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`。例如模型为 `model.safetensors`,则会自动读取 `model.png`。 +* **模型信息**:支持读取 Civitai 助手生成的 `.civitai.info` 文件,用于提取模型全名、Base Model、精度和 NSFW 标签。 +* **HTML 详情**:支持 `.html` 文件,将在点击“详情”按钮时以弹窗形式完整渲染。 + +## 🤝 兼容性 + +* 完全兼容传统的 LiteGraph 渲染模式 (V1 Canvas)。 +* 兼容 ComfyUI 最新的 Vue 组件架构 (Node 2.0 Canvas)。 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ee55adf --- /dev/null +++ b/__init__.py @@ -0,0 +1,402 @@ +import server +import folder_paths +import os +import json +import urllib.parse +import re +import time +from aiohttp import web + +# Define the web directory for frontend files +WEB_DIRECTORY = "./web" + +# Create a fully functional node that acts as a Model Browser +class VisualSelectModelBrowser: + @classmethod + def INPUT_TYPES(s): + types = list(folder_paths.folder_names_and_paths.keys()) + default_type = "checkpoints" if "checkpoints" in types else types[0] + try: + models = folder_paths.get_filename_list(default_type) + except: + models = [] + if not models: + models = [""] + + return { + "required": { + "model_type": (types, {"default": default_type}), + "selected_model": (models, ), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("model_name",) + FUNCTION = "get_model" + CATEGORY = "🎨 Visual Select" + + def get_model(self, model_type, selected_model): + return (selected_model,) + +NODE_CLASS_MAPPINGS = { + "VisualSelectModelBrowser": VisualSelectModelBrowser +} +NODE_DISPLAY_NAME_MAPPINGS = { + "VisualSelectModelBrowser": "🎨 Visual Select Model Browser" +} + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] + +print("✨ [Visual Select] Model Preview Extension is loading...") + +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") +DEFAULT_CONFIG = { + "enabled": True, + "intercept_types": "ckpt,model,lora,vae,unet,controlnet,checkpoint", + "intercept_excludes": "model_type", + "language": "zh-CN", + "nsfw_blur": True +} + +try: + from comfy.model_downloader import get_full_path_or_raise as _comfy_get_full_path_or_raise + from comfy.model_downloader import get_filename_list as _comfy_get_filename_list +except Exception: + _comfy_get_full_path_or_raise = None + _comfy_get_filename_list = None + +_MODEL_TYPE_ALIASES = { + "checkpoint": "checkpoints", + "ckpt": "checkpoints", + "lora": "loras", + "control": "controlnet", +} + +_BASENAME_INDEX_CACHE = {} +_BASENAME_INDEX_CACHE_TS = {} +_BASENAME_INDEX_CACHE_TTL_SEC = 30 + +def _normalize_model_type(model_type: str) -> str: + if not model_type: + return "" + mt = str(model_type).strip() + if mt in folder_paths.folder_names_and_paths: + return mt + mt_lower = mt.lower() + if mt_lower in _MODEL_TYPE_ALIASES: + mapped = _MODEL_TYPE_ALIASES[mt_lower] + if mapped in folder_paths.folder_names_and_paths: + return mapped + for k, v in _MODEL_TYPE_ALIASES.items(): + if k in mt_lower and v in folder_paths.folder_names_and_paths: + return v + return mt + +def _get_filename_list(folder_type: str): + try: + return folder_paths.get_filename_list(folder_type) + except Exception: + if _comfy_get_filename_list: + return _comfy_get_filename_list(folder_type) + return [] + +def _get_basename_index(folder_type: str): + now = time.time() + ts = _BASENAME_INDEX_CACHE_TS.get(folder_type, 0) + if folder_type in _BASENAME_INDEX_CACHE and (now - ts) < _BASENAME_INDEX_CACHE_TTL_SEC: + return _BASENAME_INDEX_CACHE[folder_type] + + index = {} + names = _get_filename_list(folder_type) or [] + for n in names: + if not isinstance(n, str) or not n: + continue + base = os.path.basename(n).lower() + if base not in index: + index[base] = n + + _BASENAME_INDEX_CACHE[folder_type] = index + _BASENAME_INDEX_CACHE_TS[folder_type] = now + return index + +def _resolve_model_full_path(model_type: str, model_name: str): + if not model_name or not isinstance(model_name, str): + return None + if os.path.isabs(model_name): + return None + + mt = _normalize_model_type(model_type) + candidate_types = [] + if mt and mt in folder_paths.folder_names_and_paths: + candidate_types.append(mt) + + for folder_name in folder_paths.folder_names_and_paths: + if folder_name not in candidate_types: + candidate_types.append(folder_name) + + for folder_name in candidate_types: + try: + path = folder_paths.get_full_path(folder_name, model_name) + if path and os.path.exists(path): + return path + except Exception: + pass + if _comfy_get_full_path_or_raise: + try: + path = _comfy_get_full_path_or_raise(folder_name, model_name) + if path and os.path.exists(path): + return path + except Exception: + pass + + if ("/" not in model_name) and ("\\" not in model_name): + model_name_lower = model_name.lower() + for folder_name in candidate_types: + idx = _get_basename_index(folder_name) + rel = idx.get(model_name_lower) + if not rel: + continue + try: + path = folder_paths.get_full_path(folder_name, rel) + if path and os.path.exists(path): + return path + except Exception: + pass + if _comfy_get_full_path_or_raise: + try: + path = _comfy_get_full_path_or_raise(folder_name, rel) + if path and os.path.exists(path): + return path + except Exception: + pass + + return None + +def load_config(): + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + cfg = json.load(f) + if isinstance(cfg, dict): + for k, v in DEFAULT_CONFIG.items(): + if k not in cfg: + cfg[k] = v + return cfg + return DEFAULT_CONFIG + except Exception as e: + print(f"[Visual_Select] Error loading config: {e}") + return DEFAULT_CONFIG + +def save_config(config): + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4) + except Exception as e: + print(f"[Visual_Select] Error saving config: {e}") + +# Ensure __init__.py works correctly for frontend injection +if hasattr(server.PromptServer, "instance"): + @server.PromptServer.instance.routes.get("/visual_select/config") + async def get_config_route(request): + return web.json_response(load_config()) + + @server.PromptServer.instance.routes.post("/visual_select/config") + async def post_config_route(request): + try: + data = await request.json() + config = load_config() + if "enabled" in data: + config["enabled"] = data["enabled"] + if "intercept_types" in data: + config["intercept_types"] = data["intercept_types"] + if "intercept_excludes" in data: + config["intercept_excludes"] = data["intercept_excludes"] + if "language" in data: + config["language"] = data["language"] + if "nsfw_blur" in data: + config["nsfw_blur"] = bool(data["nsfw_blur"]) + save_config(config) + return web.json_response({"status": "success"}) + except Exception as e: + return web.json_response({"status": "error", "message": str(e)}, status=500) + + # Setup API routes for fetching preview images and html details + @server.PromptServer.instance.routes.get("/visual_select/preview") + async def get_model_preview(request): + model_name = request.query.get("name", "") + preview_type = request.query.get("format", "image") # 'image' or 'html' + model_type = request.query.get("type", "") + + if not model_name: + return web.Response(status=400, text="Model name required") + + full_path = _resolve_model_full_path(model_type, model_name) + + if not full_path: + return web.Response(status=404, text="Model not found") + + base_path = os.path.splitext(full_path)[0] + + if preview_type == "image": + extensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"] + for ext in extensions: + img_path = base_path + ext + if os.path.exists(img_path): + return web.FileResponse(img_path) + return web.Response(status=404, text="Preview image not found") + + elif preview_type == "html": + html_path = base_path + ".html" + if os.path.exists(html_path): + try: + with open(html_path, "r", encoding="utf-8") as f: + html_content = f.read() + + def replace_asset_path(match): + attr = match.group(1) + file_path = match.group(2) + new_url = f"/visual_select/model_asset?type={urllib.parse.quote(model_type)}&model={urllib.parse.quote(model_name)}&file={urllib.parse.quote(file_path)}" + return f'{attr}="{new_url}"' + + # Replace relative src and href attributes with our asset endpoint + html_content = re.sub( + r'(src|href)=["\'](?!http|data:|blob:|/|#)([^"\']+)["\']', + replace_asset_path, + html_content + ) + return web.Response(text=html_content, content_type="text/html") + except Exception as e: + print(f"[Visual_Select] Error processing HTML {html_path}: {e}") + # Fallback to serving the raw file if parsing fails + return web.FileResponse(html_path) + return web.Response(status=404, text="Preview HTML not found") + + return web.Response(status=400, text="Invalid format") + + @server.PromptServer.instance.routes.get("/visual_select/model_asset") + async def get_model_asset(request): + model_type = request.query.get("type", "") + model_name = request.query.get("model", "") + file_name = request.query.get("file", "") + + if not model_name or not file_name: + return web.Response(status=400, text="Missing parameters") + + full_path = _resolve_model_full_path(model_type, model_name) + + if not full_path: + return web.Response(status=404, text="Model not found") + + base_dir = os.path.dirname(full_path) + + # Decode file_name if it was URL encoded in the HTML (e.g. spaces as %20) + file_name = urllib.parse.unquote(file_name) + + # Resolve absolute path for the requested asset + asset_path = os.path.abspath(os.path.join(base_dir, file_name)) + + # Security check: ensure the requested file is actually within the model's directory + # to prevent directory traversal attacks like file=../../../etc/passwd + if not asset_path.startswith(os.path.abspath(base_dir)): + return web.Response(status=403, text="Forbidden: Invalid file path") + + if os.path.exists(asset_path): + return web.FileResponse(asset_path) + + return web.Response(status=404, text="Asset not found") + + @server.PromptServer.instance.routes.post("/visual_select/preview_info") + async def get_model_preview_info(request): + data = await request.json() + model_names = data.get("names", []) + model_type = data.get("type", "") + result = {} + + extensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"] + + for model_name in model_names: + full_path = _resolve_model_full_path(model_type, model_name) + + if full_path: + base_path = os.path.splitext(full_path)[0] + has_image = False + has_html = False + civitai_name = None + base_model = None + fp_format = None + is_nsfw = False + + for ext in extensions: + if os.path.exists(base_path + ext): + has_image = True + break + + if os.path.exists(base_path + ".html"): + has_html = True + + info_path = base_path + ".civitai.info" + if os.path.exists(info_path): + try: + with open(info_path, "r", encoding="utf-8") as f: + info_data = json.load(f) + if "name" in info_data: + civitai_name = info_data["name"] + if "baseModel" in info_data: + base_model = info_data["baseModel"] + if "nsfw" in info_data: + is_nsfw = info_data["nsfw"] + if "metadata" in info_data and isinstance(info_data["metadata"], dict) and "fp" in info_data["metadata"]: + fp_format = info_data["metadata"]["fp"] + except Exception as e: + print(f"[Visual_Select] Error reading {info_path}: {e}") + + result[model_name] = { + "has_image": has_image, + "has_html": has_html, + "civitai_name": civitai_name, + "base_model": base_model, + "fp_format": fp_format, + "is_nsfw": is_nsfw + } + + return web.json_response(result) + + @server.PromptServer.instance.routes.get("/visual_select/get_models") + async def get_models_by_type(request): + model_type = request.query.get("type", "checkpoints") + + # Robust model_type mapping + if model_type not in folder_paths.folder_names_and_paths: + # Try some common aliases + aliases = { + "checkpoint": "checkpoints", + "ckpt": "checkpoints", + "lora": "loras", + "control": "controlnet", + } + for k, v in aliases.items(): + if k in model_type.lower() and v in folder_paths.folder_names_and_paths: + model_type = v + break + + try: + # We want just the relative path string like "folder/model.safetensors" + models = folder_paths.get_filename_list(model_type) + except: + models = [] + + result = [] + if not models: + return web.json_response(result) + + for model_name in models: + # We only need the name, frontend will fetch the rest via /visual_select/preview_info + # Ensure model_name is a string (sometimes folder_paths returns weird objects) + if isinstance(model_name, str): + result.append({ + "name": model_name + }) + + return web.json_response(result) + +print("Loaded ComfyUI-Visual-Select plugin.") diff --git a/web/css/model_preview.css b/web/css/model_preview.css new file mode 100644 index 0000000..3261e3d --- /dev/null +++ b/web/css/model_preview.css @@ -0,0 +1,429 @@ +.visual-select-dialog { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 9999; + display: flex; + justify-content: center; + align-items: center; + font-family: sans-serif; + color: #eee; +} + +.vs-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(2px); +} + +.vs-modal { + position: relative; + width: 80%; + max-width: 1200px; + height: 80%; + background: #222; + border-radius: 8px; + display: flex; + flex-direction: column; + box-shadow: 0 10px 30px rgba(0,0,0,0.5); + border: 1px solid #444; + overflow: hidden; +} + +.vs-modal-large { + width: 90%; + height: 90%; +} + +.vs-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + background: #1a1a1a; + border-bottom: 1px solid #333; +} + +.vs-header h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 500; +} + +.vs-close { + font-size: 1.5rem; + cursor: pointer; + line-height: 1; + color: #aaa; + transition: color 0.2s; +} + +.vs-close:hover { + color: #fff; +} + +.vs-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.vs-body-no-padding { + padding: 0; +} + +.vs-sidebar { + width: 250px; + background: #1e1e1e; + border-right: 1px solid #333; + display: flex; + flex-direction: column; +} + +.vs-filters { + display: flex; + background: #111; + border-bottom: 1px solid #333; + padding: 5px; + gap: 5px; +} + +.vs-filter-btn { + flex: 1; + text-align: center; + padding: 6px 0; + font-size: 0.8rem; + cursor: pointer; + background: #222; + border-radius: 4px; + color: #aaa; + transition: all 0.2s; +} + +.vs-filter-btn:hover { + background: #333; + color: #fff; +} + +.vs-filter-btn.active { + background: #007bff; + color: #fff; + font-weight: bold; +} + +.vs-folder-tree { + list-style: none; + margin: 0; + padding: 10px 0; + overflow-y: auto; + flex: 1; +} + +.vs-folder-item { + padding: 10px 20px; + cursor: pointer; + font-size: 0.95rem; + color: #ccc; + transition: background 0.2s, color 0.2s; + word-break: break-all; +} + +.vs-folder-item:hover { + background: #333; + color: #fff; +} + +.vs-folder-item.active { + background: #444; + color: #fff; + font-weight: bold; + border-left: 3px solid #007bff; +} + +.vs-content { + flex: 1; + padding: 20px; + overflow-y: auto; + background: #222; + position: relative; + display: flex; + flex-direction: column; +} + +.vs-toolbar { + display: flex; + gap: 10px; + padding: 12px 20px; + border-bottom: 1px solid #333; + flex-wrap: wrap; + align-items: center; + background: #1a1a1a; +} + +.vs-nsfw-blur-toggle { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: #111; + border: 1px solid #444; + border-radius: 4px; + color: #ddd; + font-size: 0.85rem; + cursor: pointer; + user-select: none; +} + +.vs-nsfw-blur-toggle input { + margin: 0; +} + +.vs-top-bar { + display: flex; + gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.vs-search-input { + flex: 2; + min-width: 200px; + padding: 8px 12px; + background: #111; + border: 1px solid #444; + border-radius: 4px; + color: #eee; + font-size: 0.9rem; +} + +.vs-search-input:focus { + outline: none; + border-color: #007bff; +} + +.vs-filter-select { + flex: 1; + min-width: 120px; + padding: 8px; + background: #111; + border: 1px solid #444; + border-radius: 4px; + color: #eee; + font-size: 0.9rem; + cursor: pointer; +} + +.vs-filter-select:focus { + outline: none; + border-color: #007bff; +} + +.vs-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.2rem; + color: #888; +} + +.vs-empty { + width: 100%; + text-align: center; + color: #888; + margin-top: 50px; +} + +.vs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 20px; +} + +.vs-card { + background: var(--comfy-menu-bg, #222); + border: 1px solid var(--border-color, #444); + border-radius: 6px; + overflow: hidden; + display: flex; + flex-direction: column; + transition: all 0.2s ease; + cursor: pointer; + box-shadow: 0 4px 6px rgba(0,0,0,0.3); + height: 100%; +} + +.vs-card:hover { + transform: translateY(-2px); + border-color: #00ff88; + box-shadow: 0 6px 12px rgba(0, 255, 136, 0.2); +} + +.vs-card-img { + width: 100%; + height: 180px; + background: #111; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} + +.vs-card-img img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease, filter 0.2s ease; +} + +.vs-card:hover .vs-card-img img { + transform: scale(1.05); +} + +.vs-card-img.vs-nsfw-blur img { + filter: blur(14px) brightness(0.7); +} + +.vs-no-image { + color: #555; + font-size: 0.9rem; +} + +.vs-card-info-wrapper { + display: flex; + flex-direction: column; + flex: 1; + padding: 10px; + gap: 5px; +} + +.vs-card-name { + font-size: 14px; + font-weight: bold; + color: white; + word-break: break-all; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.2; +} + +.vs-card-filename { + font-size: 11px; + color: #888; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.vs-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: auto; + min-height: 20px; /* Ensure space even if empty */ +} + +.vs-badge { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 10px; + background: #333; + color: #ccc; + white-space: nowrap; +} + +.vs-badge-model { + background: #173d61; + color: #8ab4f8; +} + +.vs-badge-fp { + background: #3d2c17; + color: #f8b48a; +} + +.vs-badge-nsfw { + background: #ff4444; + color: white; + font-weight: bold; + border: 1px solid #cc0000; +} + +.vs-card-actions { + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-top: 1px solid var(--border-color, #444); + gap: 8px; + margin-top: auto; + min-height: 72px; /* Force consistent height for actions area */ +} + +.vs-select-btn { + width: 100%; + padding: 6px 12px; + background: #0066cc; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + transition: background 0.2s, box-shadow 0.2s; +} + +.vs-select-btn:hover { + background: #0088ff; +} + +.vs-details-btn { + width: 100%; + padding: 6px 12px; + background: #28a745; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + font-size: 13.3px; /* Match typical default button font size or inherit */ + transition: background 0.2s, box-shadow 0.2s; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.vs-details-btn:hover { + background: #218838; +} + +.vs-details-dialog { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 10000; + display: flex; + justify-content: center; + align-items: center; +} + +.vs-iframe { + width: 100%; + height: 100%; + border: none; + background: #fff; /* HTML details might expect a light background by default */ +} diff --git a/web/visual_select_main.js b/web/visual_select_main.js new file mode 100644 index 0000000..c6ef6a8 --- /dev/null +++ b/web/visual_select_main.js @@ -0,0 +1,952 @@ +import { app } from "../../scripts/app.js"; + +console.log("🚀 [Visual_Select] Extension script starting to load!"); + +const cssUrl = new URL("./css/model_preview.css", import.meta.url).href; +const link = document.createElement("link"); +link.rel = "stylesheet"; +link.type = "text/css"; +link.href = cssUrl; +document.head.appendChild(link); + +console.log("🚀 [Visual_Select] CSS loaded:", cssUrl); + +const MODEL_EXTENSIONS = [".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".sft"]; + +// Initialize global config +window._vs_config = { + enabled: true, + intercept_types: "ckpt,model,lora,vae,unet,controlnet,checkpoint", + intercept_excludes: "model_type", + language: "zh-CN", + nsfw_blur: true +}; + +// I18n strings +const I18N = { + "en-US": { + selectModel: "Select Model", + browser: "Browser", + searchPlaceholder: "Search by filename or model name...", + allBaseModels: "All Base Models", + allFpFormats: "All FP formats", + allContent: "All Content", + sfwOnly: "SFW Only", + nsfwOnly: "NSFW Only", + nsfwBlur: "Blur NSFW", + loading: "Loading models...", + noModels: "No models match the criteria", + noImage: "No Image", + details: "Details", + select: "Select", + all: "All", + withInfo: "With Info", + noInfo: "No Info", + modelDetails: "Model Details", + floatingBtn: "🎨 Visual Select", + errorLoading: "Error loading models." + }, + "zh-CN": { + selectModel: "选择模型", + browser: "模型浏览", + searchPlaceholder: "按文件名或模型名称搜索...", + allBaseModels: "所有基础模型", + allFpFormats: "所有精度", + allContent: "所有内容", + sfwOnly: "仅安全内容 (SFW)", + nsfwOnly: "仅成人内容 (NSFW)", + nsfwBlur: "NSFW 模糊", + loading: "加载模型中...", + noModels: "没有符合条件的模型", + noImage: "无图片", + details: "详情", + select: "选择", + all: "全部", + withInfo: "有信息", + noInfo: "无信息", + modelDetails: "模型详情", + floatingBtn: "🎨 视图选择", + errorLoading: "加载模型失败。" + } +}; + +function t(key) { + const lang = window._vs_config.language === "en-US" ? "en-US" : "zh-CN"; + return I18N[lang][key] || key; +} + +// Fetch backend config on load to support incognito mode persistence +fetch("/visual_select/config") + .then(r => r.json()) + .then(cfg => { + window._vs_config = cfg; + // Sync ComfyUI settings UI with backend config + try { + if (app.extensionManager?.settings) { + app.extensionManager.settings.set("VisualSelect.Settings.Enabled", cfg.enabled); + app.extensionManager.settings.set("VisualSelect.Settings.InterceptTypes", cfg.intercept_types); + app.extensionManager.settings.set("VisualSelect.Settings.InterceptExcludes", cfg.intercept_excludes); + app.extensionManager.settings.set("VisualSelect.Settings.Language", cfg.language); + } else if (app.ui && app.ui.settings) { + app.ui.settings.setSettingValue("VisualSelect.Settings.Enabled", cfg.enabled); + app.ui.settings.setSettingValue("VisualSelect.Settings.InterceptTypes", cfg.intercept_types); + app.ui.settings.setSettingValue("VisualSelect.Settings.InterceptExcludes", cfg.intercept_excludes); + app.ui.settings.setSettingValue("VisualSelect.Settings.Language", cfg.language); + } + } catch(e) {} + }) + .catch(e => console.error("[Visual_Select] Error loading backend config", e)); + +function isModelWidget(widget) { + if (!widget || widget.type !== "combo") return false; + + if (!window._vs_config.enabled) { + return false; + } + + // 1. Fallback check first (Widget name) - This handles cases where options are loaded asynchronously + const name = (widget.name || "").toLowerCase(); + + let excludesStr = window._vs_config.intercept_excludes || "model_type"; + let excludes = excludesStr.split(',').map(s => s.trim().toLowerCase()).filter(s => s.length > 0); + if (excludes.some(ex => name.includes(ex))) { + return false; + } + + let interceptConfigStr = window._vs_config.intercept_types || "ckpt,model,lora,vae,unet,controlnet,checkpoint"; + + let interceptTypes = interceptConfigStr.split(',').map(s => s.trim().toLowerCase()).filter(s => s.length > 0); + if (interceptTypes.length === 0) { + interceptTypes = ["ckpt", "model", "lora", "vae", "unet", "controlnet", "checkpoint"]; + } + + if (interceptTypes.some(type => name.includes(type))) { + return true; + } + + // 2. Check if it contains common model extensions (if options are already loaded) + if (widget.options && widget.options.values && widget.options.values.length > 0) { + const hasExt = widget.options.values.some(v => typeof v === 'string' && MODEL_EXTENSIONS.some(ext => v.toLowerCase().endsWith(ext))); + if (hasExt) return true; + } + + return false; +} + +let currentDialog = null; + +function closeDialog() { + if (currentDialog) { + document.body.removeChild(currentDialog); + currentDialog = null; + } +} + +function showDetailsDialog(name) { + const encodedName = encodeURIComponent(name); + const url = `/visual_select/preview?name=${encodedName}&format=html`; + + const dialog = document.createElement("div"); + dialog.className = "vs-details-dialog"; + dialog.innerHTML = ` +
+