Add files via upload

This commit is contained in:
VodooWaWa
2026-04-03 00:33:41 +08:00
committed by GitHub
parent 2cf630327b
commit 2d979ecd28
4 changed files with 1847 additions and 2 deletions
+64 -2
View File
@@ -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)。
+402
View File
@@ -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.")
+429
View File
@@ -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 */
}
+952
View File
@@ -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 = `
<div class="vs-overlay"></div>
<div class="vs-modal vs-modal-large">
<div class="vs-header">
<h2>${t('modelDetails')}: ${name.split('/').pop()}</h2>
<div class="vs-close">&times;</div>
</div>
<div class="vs-body vs-body-no-padding">
<iframe src="${url}" class="vs-iframe" frameborder="0"></iframe>
</div>
</div>
`;
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 = `<div class="vs-empty">${t('noModels')}</div>`;
return;
}
for (const m of displayModels) {
const card = document.createElement("div");
card.className = "vs-card";
let imgHtml = `<div class="vs-no-image">${t('noImage')}</div>`;
if (m.info.has_image) {
const encodedName = encodeURIComponent(m.fullPath);
imgHtml = `<img src="/visual_select/preview?name=${encodedName}&format=image&type=${window._vs_current_model_type || ''}" alt="${m.name}" loading="lazy" />`;
}
let detailsBtn = '<div class="vs-details-placeholder" style="height: 28px; width: 100%;"></div>';
if (m.info.has_html) {
const encodedName = encodeURIComponent(m.fullPath);
// Changed from <a> tag to <button> to ensure consistent event handling
detailsBtn = `<button class="vs-details-btn" data-url="/visual_select/preview?name=${encodedName}&format=html&type=${window._vs_current_model_type || ''}">${t('details')}</button>`;
}
let displayName = m.name;
let subNameHtml = '';
if (m.info.civitai_name) {
displayName = m.info.civitai_name;
subNameHtml = `<div class="vs-card-filename" title="${m.name}">${m.name}</div>`;
}
let badgesHtml = '<div class="vs-badges">';
if (m.info.base_model) {
badgesHtml += `<span class="vs-badge vs-badge-model">${m.info.base_model}</span>`;
}
if (m.info.fp_format) {
badgesHtml += `<span class="vs-badge vs-badge-fp">${m.info.fp_format}</span>`;
}
if (m.info.is_nsfw) {
badgesHtml += `<span class="vs-badge vs-badge-nsfw">NSFW</span>`;
}
badgesHtml += '</div>';
card.innerHTML = `
<div class="vs-card-img${(blurNsfw && m.info.is_nsfw) ? ' vs-nsfw-blur' : ''}">${imgHtml}</div>
<div class="vs-card-info-wrapper">
<div class="vs-card-name" title="${displayName}">${displayName}</div>
${subNameHtml}
${badgesHtml}
</div>
<div class="vs-card-actions">
<button class="vs-select-btn" data-name="${m.fullPath}">
${t('select')}
</button>
${detailsBtn}
</div>
`;
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 = `
<div class="vs-filter-btn active" data-filter="all">${t('all')}</div>
<div class="vs-filter-btn" data-filter="with_info">${t('withInfo')}</div>
<div class="vs-filter-btn" data-filter="without_info">${t('noInfo')}</div>
`;
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 = `
<div class="vs-overlay"></div>
<div class="vs-modal">
<div class="vs-header">
<h2>🎨 ${titleName}</h2>
<div class="vs-close">&times;</div>
</div>
<div class="vs-toolbar">
<input type="text" class="vs-search-input" placeholder="${t('searchPlaceholder')}" />
<select class="vs-filter-select" id="vs-basemodel-filter">
<option value="all">${t('allBaseModels')}</option>
</select>
<select class="vs-filter-select" id="vs-fp-filter">
<option value="all">${t('allFpFormats')}</option>
</select>
<select class="vs-filter-select" id="vs-nsfw-filter">
<option value="all">${t('allContent')}</option>
<option value="sfw">${t('sfwOnly')}</option>
<option value="nsfw">${t('nsfwOnly')}</option>
</select>
<label class="vs-nsfw-blur-toggle">
<input type="checkbox" id="vs-nsfw-blur-toggle" />
<span>${t('nsfwBlur')}</span>
</label>
</div>
<div class="vs-body">
<div class="vs-sidebar">
<ul class="vs-folder-tree"></ul>
</div>
<div class="vs-content vs-main">
<div class="vs-loading">${t('loading')}</div>
<div class="vs-grid"></div>
</div>
</div>
</div>
`;
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);
}
}
}
});