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 = ` +
+
+
+

${t('modelDetails')}: ${name.split('/').pop()}

+
×
+
+
+ +
+
+ `; + + document.body.appendChild(dialog); + + dialog.querySelector(".vs-close").onclick = () => document.body.removeChild(dialog); + dialog.querySelector(".vs-overlay").onclick = () => document.body.removeChild(dialog); +} + +function buildUI(dialog, widget, node, models, info) { + dialog.querySelector(".vs-loading").style.display = "none"; + + // Parse folders + const folders = new Set(); + folders.add("/"); // root + + const modelData = []; + for (const model of models) { + if (typeof model !== "string") continue; + let folder = "/"; + let name = model; + const lastSlash = Math.max(model.lastIndexOf("/"), model.lastIndexOf("\\")); + if (lastSlash !== -1) { + folder = model.substring(0, lastSlash); + name = model.substring(lastSlash + 1); + folders.add(folder); + + // Add parent folders too + let parentFolder = folder; + while (true) { + const parentSlash = Math.max(parentFolder.lastIndexOf("/"), parentFolder.lastIndexOf("\\")); + if (parentSlash !== -1) { + parentFolder = parentFolder.substring(0, parentSlash); + folders.add(parentFolder); + } else { + break; + } + } + } + const modelInfo = info[model] || {has_image: false, has_html: false, civitai_name: null}; + const hasAnyInfo = modelInfo.has_image || modelInfo.has_html || modelInfo.civitai_name; + + modelData.push({ + fullPath: model, + folder: folder, + name: name, + info: modelInfo, + hasAnyInfo: hasAnyInfo + }); + } + + // Sort folders alphabetically + const sortedFolders = Array.from(folders).sort((a, b) => a.localeCompare(b)); + + const treeEl = dialog.querySelector(".vs-folder-tree"); + const gridEl = dialog.querySelector(".vs-grid"); + + // Top bar elements + const searchInput = dialog.querySelector(".vs-search-input"); + const basemodelSelect = dialog.querySelector("#vs-basemodel-filter"); + const fpSelect = dialog.querySelector("#vs-fp-filter"); + const nsfwSelect = dialog.querySelector("#vs-nsfw-filter"); + const nsfwBlurToggle = dialog.querySelector("#vs-nsfw-blur-toggle"); + + // Extract unique base models and fp formats + const baseModels = new Set(); + const fpFormats = new Set(); + modelData.forEach(m => { + if (m.info.base_model) baseModels.add(m.info.base_model); + if (m.info.fp_format) fpFormats.add(m.info.fp_format); + }); + + Array.from(baseModels).sort().forEach(bm => { + const opt = document.createElement("option"); + opt.value = bm; + opt.innerText = bm; + basemodelSelect.appendChild(opt); + }); + + Array.from(fpFormats).sort().forEach(fp => { + const opt = document.createElement("option"); + opt.value = fp; + opt.innerText = fp; + fpSelect.appendChild(opt); + }); + + let activeFolderEl = null; + let currentFilter = 'all'; // 'all', 'with_info', 'without_info' + let currentFolder = '/'; + + function renderFolder() { + gridEl.innerHTML = ""; + + const searchTerm = searchInput.value.toLowerCase(); + const selectedBaseModel = basemodelSelect.value; + const selectedFp = fpSelect.value; + const selectedNsfw = nsfwSelect.value; + const blurNsfw = window._vs_config.nsfw_blur !== false; + + let displayModels; + if (currentFolder === '/') { + // Root folder shows ALL models + displayModels = modelData.slice(); + } else { + // Other folders only show models exactly in that folder (or subfolders if desired, + // but exact match is usually better for specific folders, though we could do startsWith) + displayModels = modelData.filter(m => m.folder === currentFolder || m.folder.startsWith(currentFolder + "/")); + } + + // Apply sidebar filter + if (currentFilter === 'with_info') { + displayModels = displayModels.filter(m => m.hasAnyInfo); + } else if (currentFilter === 'without_info') { + displayModels = displayModels.filter(m => !m.hasAnyInfo); + } + + // Apply top bar filters + displayModels = displayModels.filter(m => { + // Text Search + let matchSearch = true; + if (searchTerm) { + const searchTarget = (m.name + " " + (m.info.civitai_name || "")).toLowerCase(); + matchSearch = searchTarget.includes(searchTerm); + } + if (!matchSearch) return false; + + // Base Model + if (selectedBaseModel !== "all" && m.info.base_model !== selectedBaseModel) { + return false; + } + + // FP Format + if (selectedFp !== "all" && m.info.fp_format !== selectedFp) { + return false; + } + + // NSFW + if (selectedNsfw === "sfw" && m.info.is_nsfw) { + return false; + } else if (selectedNsfw === "nsfw" && !m.info.is_nsfw) { + return false; + } + + return true; + }); + + if (displayModels.length === 0) { + gridEl.innerHTML = `
${t('noModels')}
`; + return; + } + + for (const m of displayModels) { + const card = document.createElement("div"); + card.className = "vs-card"; + + let imgHtml = `
${t('noImage')}
`; + if (m.info.has_image) { + const encodedName = encodeURIComponent(m.fullPath); + imgHtml = `${m.name}`; + } + + let detailsBtn = '
'; + if (m.info.has_html) { + const encodedName = encodeURIComponent(m.fullPath); + // Changed from tag to `; + } + + let displayName = m.name; + let subNameHtml = ''; + if (m.info.civitai_name) { + displayName = m.info.civitai_name; + subNameHtml = `
${m.name}
`; + } + + let badgesHtml = '
'; + if (m.info.base_model) { + badgesHtml += `${m.info.base_model}`; + } + if (m.info.fp_format) { + badgesHtml += `${m.info.fp_format}`; + } + if (m.info.is_nsfw) { + badgesHtml += `NSFW`; + } + badgesHtml += '
'; + + card.innerHTML = ` +
${imgHtml}
+
+
${displayName}
+ ${subNameHtml} + ${badgesHtml} +
+
+ + ${detailsBtn} +
+ `; + + gridEl.appendChild(card); + } + + // Bind events + gridEl.querySelectorAll(".vs-select-btn").forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const name = e.target.getAttribute("data-name"); + widget.value = name; + if (widget.callback) { + widget.callback(name, app.canvas, node, null, null); + } + app.graph.setDirtyCanvas(true, true); + closeDialog(); + }; + }); + + // Bind details button event + gridEl.querySelectorAll(".vs-details-btn").forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const url = e.target.getAttribute("data-url"); + if (url) { + window.open(url, "_blank"); + } + }; + }); + + // Add double click on card image/name/filename to select + gridEl.querySelectorAll(".vs-card-img, .vs-card-name, .vs-card-filename").forEach(el => { + el.ondblclick = (e) => { + const card = el.closest(".vs-card"); + const selectBtn = card.querySelector(".vs-select-btn"); + if (selectBtn) selectBtn.click(); + }; + }); + } + + // Render sidebar + const filterContainer = document.createElement("div"); + filterContainer.className = "vs-filters"; + filterContainer.innerHTML = ` +
${t('all')}
+
${t('withInfo')}
+
${t('noInfo')}
+ `; + treeEl.parentNode.insertBefore(filterContainer, treeEl); + + filterContainer.querySelectorAll('.vs-filter-btn').forEach(btn => { + btn.onclick = (e) => { + filterContainer.querySelectorAll('.vs-filter-btn').forEach(b => b.classList.remove('active')); + e.target.classList.add('active'); + currentFilter = e.target.getAttribute('data-filter'); + renderFolder(); + }; + }); + + // Bind events to top bar filters + searchInput.addEventListener("input", renderFolder); + basemodelSelect.addEventListener("change", renderFolder); + fpSelect.addEventListener("change", renderFolder); + nsfwSelect.addEventListener("change", renderFolder); + if (nsfwBlurToggle) { + nsfwBlurToggle.checked = window._vs_config.nsfw_blur !== false; + nsfwBlurToggle.addEventListener("change", () => { + window._vs_config.nsfw_blur = nsfwBlurToggle.checked; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({nsfw_blur: window._vs_config.nsfw_blur}) + }).catch(e => console.error(e)); + renderFolder(); + }); + } + + for (const folder of sortedFolders) { + const li = document.createElement("li"); + li.innerText = folder; + li.className = "vs-folder-item"; + li.onclick = () => { + if (activeFolderEl) activeFolderEl.classList.remove("active"); + li.classList.add("active"); + activeFolderEl = li; + currentFolder = folder; + renderFolder(); + }; + treeEl.appendChild(li); + + // Default select root (or first folder if root is empty, but we click root first) + if (folder === "/") { + li.click(); + } + } + + // If root was empty but clicked, and we have other folders, maybe auto-select the first non-empty + if (activeFolderEl && modelData.filter(m => m.folder === "/").length === 0 && sortedFolders.length > 1) { + treeEl.children[1].click(); + } +} + +// Global variables for tracking mouse +window._vs_mouseX = 0; +window._vs_mouseY = 0; +document.addEventListener('mousemove', e => { + window._vs_mouseX = e.clientX; + window._vs_mouseY = e.clientY; +}); + +let floatingBtn = null; +let floatingBtnTimeout = null; + +function showFloatingButton(widget, node, forceType) { + if (floatingBtn) { + floatingBtn.remove(); + floatingBtn = null; + } + if (floatingBtnTimeout) { + clearTimeout(floatingBtnTimeout); + floatingBtnTimeout = null; + } + + floatingBtn = document.createElement('div'); + floatingBtn.className = 'vs-floating-btn'; + floatingBtn.innerHTML = t('floatingBtn'); + floatingBtn.style.position = 'fixed'; + + // Position it to the left of the mouse + let leftPos = window._vs_mouseX - 140; + if (leftPos < 0) leftPos = window._vs_mouseX + 20; // fallback to right if no space + + floatingBtn.style.left = leftPos + 'px'; + floatingBtn.style.top = (window._vs_mouseY - 15) + 'px'; + floatingBtn.style.zIndex = '9999999'; + // Base style: Light green + floatingBtn.style.background = '#e6f7e6'; + floatingBtn.style.color = '#004400'; + floatingBtn.style.padding = '6px 12px'; + floatingBtn.style.border = '1px solid #90ee90'; + floatingBtn.style.borderRadius = '6px'; + floatingBtn.style.cursor = 'pointer'; + floatingBtn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.3)'; + floatingBtn.style.fontSize = '12px'; + floatingBtn.style.fontFamily = 'sans-serif'; + floatingBtn.style.transition = 'all 0.15s ease'; + + floatingBtn.onmouseenter = () => { + // Hover style: slightly darker green + floatingBtn.style.background = '#ccf0cc'; + floatingBtn.style.borderColor = '#66cc66'; + floatingBtn.style.transform = 'scale(1.02)'; + floatingBtn.style.boxShadow = '0 4px 8px rgba(0,0,0,0.4)'; + }; + floatingBtn.onmouseleave = () => { + // Revert to base style + floatingBtn.style.background = '#e6f7e6'; + floatingBtn.style.borderColor = '#90ee90'; + floatingBtn.style.transform = 'scale(1)'; + floatingBtn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.3)'; + }; + + floatingBtn.onmousedown = (e) => { + e.preventDefault(); + e.stopPropagation(); + // Active (click) style + floatingBtn.style.background = '#90ee90'; + floatingBtn.style.transform = 'scale(0.98)'; + floatingBtn.style.boxShadow = 'inset 0 2px 4px rgba(0,0,0,0.2)'; + }; + + floatingBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + floatingBtn.remove(); + floatingBtn = null; + if (floatingBtnTimeout) clearTimeout(floatingBtnTimeout); + + if (window.LiteGraph && LiteGraph.closeAllContextMenus) { + LiteGraph.closeAllContextMenus(); + } + document.body.click(); + + showAdvancedBrowser(widget, node, forceType); + }; + + document.body.appendChild(floatingBtn); + + // Auto-hide after 5 seconds + floatingBtnTimeout = setTimeout(() => { + if (floatingBtn) { + floatingBtn.style.opacity = '0'; + setTimeout(() => { + if (floatingBtn) { + floatingBtn.remove(); + floatingBtn = null; + } + }, 300); // Wait for CSS transition (if any) before removing DOM + } + }, 5000); +} + +document.addEventListener('mousedown', (e) => { + // LiteGraph canvas click handler removed, relying on 5s auto-hide instead +}, true); + +function injectWidget(w, node) { + if (isModelWidget(w) && !w._vs_injected) { + // We no longer rely on w.mouse because combo widgets don't trigger it in LiteGraph. + // Handled by LiteGraph.ContextMenu override and global mousedown listener instead. + + // HOWEVER, for PromptAssistant compatibility, some widgets might be wrapped. + // Let's also attach a custom event listener to the element if it exists + if (w.element || w.inputEl) { + const el = w.element || w.inputEl; + el.addEventListener("mousedown", (e) => { + if (e.button === 0) { + const wName = (w.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 => wName.includes(ex))) { + setTimeout(() => showFloatingButton(w, node, null), 10); + } + } + }); + } + + w._vs_injected = true; + console.log("[Visual_Select] Successfully marked widget for tracking:", w.name); + } +} + +// Global mousedown listener for Node 2.0 Vue components and fallback for LiteGraph +document.addEventListener("mousedown", (e) => { + if (!window._vs_config.enabled) return; + + // Detect if we clicked on a combo widget input area (works for both Vue and LiteGraph HTML elements) + const comboEl = e.target.closest("select, input, .p-dropdown, .p-select, .comfy-multiline-input"); + + // Fallback: Check if clicked inside a node to handle LiteGraph canvas clicks + const nodeEl = e.target.closest(".comfy-node, .comfy-vue-node, [data-node-id]"); + + if (comboEl || nodeEl) { + if (app.graph) { + let nodeId = null; + if (nodeEl) { + nodeId = nodeEl.getAttribute("data-node-id") || (nodeEl.id && nodeEl.id.replace("node-", "")); + } + + // If we can't find node id from DOM, try to get active node from graph + const node = nodeId ? app.graph.getNodeById(Number(nodeId)) : null; + + if (node) { + // Find the specific widget that was clicked + const widgetEl = e.target.closest("[data-widget-name], .p-field, .property-wrapper, .litemenu-entry"); + let targetWidget = null; + + if (widgetEl) { + const wName = widgetEl.getAttribute("data-widget-name") || (widgetEl.querySelector("label") ? widgetEl.querySelector("label").innerText.replace("*", "").trim() : ""); + if (wName) { + targetWidget = node.widgets?.find(w => w.name === wName || w.name + ":" === wName || wName.includes(w.name)); + } + } + + // If DOM search failed (e.g., pure canvas click), try to find widget by mouse position + if (!targetWidget && node.widgets) { + // This is a rough estimation for LiteGraph canvas clicks + const localY = e.offsetY || (e.clientY - node.pos[1]); + // Only check if we are in the widget area (below inputs/outputs) + if (localY > 30) { + for (const w of node.widgets) { + if (node.type === "VisualSelectModelBrowser" && w.name === "model_type") { + targetWidget = w; + break; + } else if (isModelWidget(w)) { + targetWidget = w; + break; // Just grab the first model widget we find for now + } + } + } + } + + if (targetWidget) { + // Check exclusion for fallback clicks + const wName = (targetWidget.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 => wName.includes(ex))) { + // Do not show button for excluded widgets + return; + } + + if (isModelWidget(targetWidget)) { + let forceType = null; + if (node.type === "VisualSelectModelBrowser") { + const typeWidget = node.widgets.find(w => w.name === "model_type"); + if (typeWidget) forceType = typeWidget.value; + } + setTimeout(() => showFloatingButton(targetWidget, node, forceType), 50); + } + } + } + } + } +}, true); + +async function showAdvancedBrowser(widget, node, forceType) { + if (currentDialog) { + closeDialog(); + } + + let type = forceType; + let targetWidget = widget; + let modelValues = widget.options?.values || []; + let titleName = `${t('selectModel')}: ${widget.name}`; + + if (forceType) { + titleName = `${t('browser')}: ${type}`; + try { + const res = await fetch(`/visual_select/get_models?type=${type}`); + const models = await res.json(); + modelValues = models.map(m => m.name); + if (widget.options) widget.options.values = modelValues; + } catch(e) { + console.error("Failed to load models for browser", e); + } + } else { + // Try to infer type for image fetching fallback + const wName = widget.name.toLowerCase(); + if (wName.includes("unet")) type = "unet"; + else if (wName.includes("clip")) type = "clip"; + else if (wName.includes("lora")) type = "loras"; + else if (wName.includes("vae")) type = "vae"; + else if (wName.includes("control")) type = "controlnet"; + else type = "checkpoints"; + } + + const dialog = document.createElement("div"); + dialog.className = "visual-select-dialog vs-dialog"; + + dialog.innerHTML = ` +
+
+
+

🎨 ${titleName}

+
×
+
+
+ + + + + +
+
+
+
    +
    +
    +
    ${t('loading')}
    +
    +
    +
    +
    + `; + + document.body.appendChild(dialog); + currentDialog = dialog; + + dialog.querySelector(".vs-close").onclick = () => closeDialog(); + dialog.querySelector(".vs-overlay").onclick = () => closeDialog(); + + window._vs_current_model_type = type; // Tell buildUI which type to fetch images for + + fetch("/visual_select/preview_info", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({names: modelValues, type}) + }) + .then(r => r.json()) + .then(info => { + buildUI(dialog, targetWidget, node, modelValues, info); + }) + .catch(err => { + const loadingEl = dialog.querySelector(".vs-loading"); + if(loadingEl) { + loadingEl.innerText = t('errorLoading'); + } + console.error(err); + }); +} + +app.registerExtension({ + name: "visual_select.model_preview", + async setup() { + console.log("🚀 [Visual_Select] Main Extension setup executed!"); + + if (app.ui && app.ui.settings) { + app.ui.settings.addSetting({ + id: "VisualSelect.Settings.Language", + name: "🎨 Visual Select - 4. 语言 (Language)", + type: "combo", + options: (value) => [ + { value: "zh-CN", text: "中文 (Chinese)", selected: value === "zh-CN" }, + { value: "en-US", text: "English", selected: value === "en-US" } + ], + defaultValue: "zh-CN", + onChange: (val) => { + window._vs_config.language = val; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({language: val}) + }).catch(e => console.error(e)); + } + }); + app.ui.settings.addSetting({ + id: "VisualSelect.Settings.InterceptExcludes", + name: "🎨 Visual Select - 3. 排除关键字配置 (用逗号分隔)", + type: "text", + defaultValue: "model_type", + onChange: (val) => { + window._vs_config.intercept_excludes = val; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({intercept_excludes: val}) + }).catch(e => console.error(e)); + } + }); + app.ui.settings.addSetting({ + id: "VisualSelect.Settings.InterceptTypes", + name: "🎨 Visual Select - 2. 拦截关键字配置 (用逗号分隔)", + type: "text", + defaultValue: "ckpt,model,lora,vae,unet,controlnet,checkpoint", + onChange: (val) => { + window._vs_config.intercept_types = val; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({intercept_types: val}) + }).catch(e => console.error(e)); + } + }); + app.ui.settings.addSetting({ + id: "VisualSelect.Settings.Enabled", + name: "🎨 Visual Select - 1. 开启可视化选择器", + type: "boolean", + defaultValue: true, + onChange: (val) => { + window._vs_config.enabled = val; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({enabled: val}) + }).catch(e => console.error(e)); + } + }); + } + + // Intercept LiteGraph ContextMenu for Combo Widgets (V1 Canvas) + if (window.LiteGraph && window.LiteGraph.ContextMenu) { + const origContextMenu = window.LiteGraph.ContextMenu; + window.LiteGraph.ContextMenu = function(values, options) { + // Check if plugin is enabled + if (window._vs_config.enabled && app.canvas && app.canvas.current_node) { + const node = app.canvas.current_node; + // Find which widget this menu belongs to by comparing the values array + const widget = node.widgets?.find(w => w.options && w.options.values === values); + + if (widget) { + const wName = (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 => wName.includes(ex)) && isModelWidget(widget)) { + setTimeout(() => showFloatingButton(widget, node, null), 10); + } + } + } + + return origContextMenu.apply(this, arguments); + }; + console.log("🚀 [Visual_Select] Hooked into LiteGraph.ContextMenu!"); + } + }, + async beforeRegisterNodeDef(nodeType, nodeData, app) { + // --- Special Logic for the Visual Select Model Browser Node --- + if (nodeData.name === "VisualSelectModelBrowser") { + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + if (origOnNodeCreated) { + origOnNodeCreated.apply(this, arguments); + } + + if (this.widgets) { + for (const w of this.widgets) { + if (w) injectWidget(w, this); + } + } + + const modelTypeWidget = this.widgets.find(w => w.name === "model_type"); + const selectedModelWidget = this.widgets.find(w => w.name === "selected_model"); + + if (modelTypeWidget && selectedModelWidget) { + const origCallback = modelTypeWidget.callback; + modelTypeWidget.callback = async function(val) { + if (origCallback) origCallback.apply(this, arguments); + try { + const res = await fetch(`/visual_select/get_models?type=${val}`); + const models = await res.json(); + const modelNames = models.map(m => m.name); + selectedModelWidget.options.values = modelNames; + if (modelNames.length > 0) { + selectedModelWidget.value = modelNames[0]; + } else { + selectedModelWidget.value = ""; + } + if (app.canvas) app.graph.setDirtyCanvas(true, true); + } catch (e) { + console.error("Failed to update models", e); + } + }; + } + + if (this.setSize) { + this.setSize([300, 80]); + } + }; + + return; + } + + // --- Logic for injecting into other nodes --- + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + if (origOnNodeCreated) { + origOnNodeCreated.apply(this, arguments); + } + if (this.widgets) { + for (const w of this.widgets) { + if (w) { + injectWidget(w, this); + } + } + } + + // Overwrite this instance's addWidget to catch dynamically added widgets + const origAddWidget = this.addWidget; + this.addWidget = function(...args) { + const w = origAddWidget.apply(this, args); + if (w) { + injectWidget(w, this); + } + return w; + }; + }; + + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + if (origOnConfigure) { + origOnConfigure.apply(this, arguments); + } + if (this.widgets) { + for (const w of this.widgets) { + injectWidget(w, this); + } + } + }; + }, + + loadedGraphNode(node, app) { + // Ensure widgets are injected when a graph is loaded + if (node.widgets) { + for (const w of node.widgets) { + injectWidget(w, node); + } + } + } +});