Add files via upload
This commit is contained in:
@@ -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
@@ -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.")
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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">×</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">×</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user